mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 09:48:58 -04:00
[AI4DSOC] Alert Flyout (#218018)
## Summary Alert flyout for AI for the SOC. <img width="600" alt="Screenshot 2025-04-11 at 12 15 22 PM" src="https://github.com/user-attachments/assets/fea2f7fb-7424-46b5-b9c2-5cafa336b0a9" /> ### The flyout sections include: - New header highlighting the integration source <img width="596" alt="Screenshot 2025-04-11 at 12 16 00 PM" src="https://github.com/user-attachments/assets/13033225-9e41-431f-8061-5df96a981665" /> - AI generated alert summary generated by button (Generate or Regenerate). Stored in a new data stream (`.kibana-elastic-ai-assistant-alert-summary-*`) <img width="595" alt="Screenshot 2025-04-11 at 12 15 55 PM" src="https://github.com/user-attachments/assets/ac835db2-2cbb-4a59-9e71-f1a9616a777f" /> - Anonymization toggle for the alert summary is located in the flyout gear settings menu <img width="270" alt="Screenshot 2025-04-11 at 12 32 45 PM" src="https://github.com/user-attachments/assets/952936b9-571b-48e5-bd57-ecfd33855df3" /> - Highlighted fields <img width="600" alt="Screenshot 2025-04-11 at 12 15 52 PM" src="https://github.com/user-attachments/assets/3fccfab2-3e8b-4edc-adaf-3f320d9a5d20" /> - Attack discovery `MiniAttackChain` (currently hardcoded to a preconfigured connector, waiting for further work from @andrew-goldstein to hook up to actual alert related AD) <img width="597" alt="Screenshot 2025-04-11 at 12 15 36 PM" src="https://github.com/user-attachments/assets/d181f68d-5b77-4df4-a316-54e84d655a4c" /> - Conversations dropdown that show any conversations this alert is referenced <img width="601" alt="Screenshot 2025-04-11 at 12 18 03 PM" src="https://github.com/user-attachments/assets/71d533d3-99b4-49c4-b336-05152fd64ed4" /> - Suggested prompts that create a new conversation with the alert as context (_copy pending_) <img width="594" alt="Screenshot 2025-04-11 at 12 18 09 PM" src="https://github.com/user-attachments/assets/bca58f5a-f05c-4cdf-a466-0926c99e0ad6" /> - The connector used in the alert summary generation is selected in Stack Management > Advanced Settings > Security Solution > Default AI Connector (_copy pending_) <img width="1163" alt="Screenshot 2025-04-11 at 12 34 15 PM" src="https://github.com/user-attachments/assets/d2128497-22e4-4c14-b08c-991dc8287391" /> ### New prompts This PR adds 2 new prompts under a new `promptGroupId.aiForSoc`: - `promptDictionary.alertSummarySystemPrompt` - `promptDictionary.alertSummary` In order to access these prompts in the proper spots, the new find alert summary route returns the "user" prompt (`promptDictionary.alertSummary`). In order to get the system prompt in place, we pass a `promptIds` object to the `POST_ACTIONS_CONNECTOR_EXECUTE` which is appended to the main system prompt ## Testing This needs to be ran in Serverless: - `yarn es serverless --projectType security` - `yarn serverless-security --no-base-path` You also need to enable the AI for SOC tier, by adding the following to your `serverless.security.dev.yml` file: ``` xpack.securitySolutionServerless.productTypes: [ { product_line: 'ai_soc', product_tier: 'search_ai_lake' }, ] ``` Use one of these Serverless users: - `platform_engineer` - `endpoint_operations_analyst` - `endpoint_policy_manager` - `admin` - `system_indices_superuser` Then: - generate data: `yarn test:generate:serverless-dev` - create 4 catch all rules, each with a name of a AI for SOC integration (`google_secops`, `microsoft_sentinel`,, `sentinel_one` and `crowdstrike`) => to do that you'll need to temporary comment the `serverless.security.dev.yaml` config changes as the rules page is not accessible in AI for SOC. - change [this line](https://github.com/elastic/kibana/blob/main/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/alert_summary/use_fetch_integrations.ts#L73) to `installedPackages: availablePackages` to force having some packages installed - change [this line](https://github.com/elastic/kibana/blob/main/x-pack/solutions/security/plugins/security_solution/public/detections/hooks/alert_summary/use_integrations.ts#L63) to `r.name === p.name` to make sure there will be matches between integrations and rules With this alerts data, you should be able to test each section of the flyout _except_ the attack discovery widget, instructions for that are below. #### Attack discovery widget As I am waiting for updates from Andrew, currently the attack discovery widget looks up attack discoveries from a particular preconfigured connector. In order to test: 1. Add preconfigured connector to your `kibana.dev.yml`: https://p.elstc.co/paste/J2qmGMeQ#GKSPhlggX4F93aUSKJsKpsqtCcyTepCkfJOEVxlZyfB 2. Generate attack discovery with this connector 3. Open the new flyout, you will see the attack discovery widget ## Outstanding TODOs These are all noted in the code 1. Attack discovery widget is hardcoded to the preconfigured connector id. The widget should instead look up discoveries by alert ID, pending work from @andrew-goldstein 2. Update copy for suggested prompts 3. Update copy for ai connector UI setting 4. Update AI connector UI setting to default to Elastic Managed LLM once it is fully available in serverless --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com> Co-authored-by: PhilippeOberti <philippe.oberti@elastic.co> Co-authored-by: Angela Chuang <yi-chun.chuang@elastic.co>
This commit is contained in:
parent
add6e303d2
commit
ba0894daa6
101 changed files with 6315 additions and 378 deletions
|
@ -99,4 +99,20 @@ describe('SelectInput', () => {
|
|||
|
||||
expect(container).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders with an error message when isInvalid is true', () => {
|
||||
const props = {
|
||||
...defaultProps,
|
||||
optionValues: ['option1', 'option2', 'option3'],
|
||||
} as SelectInputProps;
|
||||
|
||||
const { getByTestId, getByText } = render(
|
||||
wrap(<SelectInput {...props} field={{ ...props.field, defaultValue: 'invalidOption' }} />)
|
||||
);
|
||||
|
||||
const input = getByTestId(`${TEST_SUBJ_PREFIX_FIELD}-${id}`) as HTMLSelectElement;
|
||||
expect(input).toBeInvalid();
|
||||
expect(input.options[0].value).toBe('');
|
||||
expect(getByText('The saved option is unavailable. Select a new option')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -8,10 +8,11 @@
|
|||
*/
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import { EuiSelect, EuiSelectProps } from '@elastic/eui';
|
||||
import { EuiFormRow, EuiSelect, EuiSelectProps } from '@elastic/eui';
|
||||
|
||||
import { getFieldInputValue, useUpdate } from '@kbn/management-settings-utilities';
|
||||
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { InputProps } from '../types';
|
||||
import { TEST_SUBJ_PREFIX_FIELD } from '.';
|
||||
|
||||
|
@ -25,6 +26,10 @@ export interface SelectInputProps extends InputProps<'select'> {
|
|||
optionValues: Array<string | number>;
|
||||
}
|
||||
|
||||
const invalidMessage = i18n.translate('management.settings.selectInput.invalidMessage', {
|
||||
defaultMessage: 'The saved option is unavailable. Select a new option',
|
||||
});
|
||||
|
||||
/**
|
||||
* Component for manipulating a `select` field.
|
||||
*/
|
||||
|
@ -59,15 +64,22 @@ export const SelectInput = ({
|
|||
const { id, ariaAttributes } = field;
|
||||
const { ariaLabel, ariaDescribedBy } = ariaAttributes;
|
||||
const [value] = getFieldInputValue(field, unsavedChange);
|
||||
const isInvalid = useMemo(() => !optionsProp.includes(value), [optionsProp, value]);
|
||||
|
||||
return (
|
||||
<EuiSelect
|
||||
aria-describedby={ariaDescribedBy}
|
||||
aria-label={ariaLabel}
|
||||
data-test-subj={`${TEST_SUBJ_PREFIX_FIELD}-${id}`}
|
||||
disabled={!isSavingEnabled}
|
||||
fullWidth
|
||||
{...{ onChange, options, value }}
|
||||
/>
|
||||
<>
|
||||
<EuiFormRow isInvalid={isInvalid} error={isInvalid ? [invalidMessage] : undefined}>
|
||||
<EuiSelect
|
||||
aria-describedby={ariaDescribedBy}
|
||||
aria-label={ariaLabel}
|
||||
data-test-subj={`${TEST_SUBJ_PREFIX_FIELD}-${id}`}
|
||||
disabled={!isSavingEnabled}
|
||||
isInvalid={isInvalid}
|
||||
hasNoInitialSelection={isInvalid}
|
||||
fullWidth
|
||||
{...{ onChange, options, ...(isInvalid ? {} : { value }) }}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -59,6 +59,14 @@ export const ELASTIC_AI_ASSISTANT_KNOWLEDGE_BASE_INDICES_URL =
|
|||
export const ELASTIC_AI_ASSISTANT_EVALUATE_URL =
|
||||
`${ELASTIC_AI_ASSISTANT_INTERNAL_URL}/evaluate` as const;
|
||||
|
||||
// Alert summary
|
||||
export const ELASTIC_AI_ASSISTANT_ALERT_SUMMARY_URL =
|
||||
`${ELASTIC_AI_ASSISTANT_INTERNAL_URL}/alert_summary` as const;
|
||||
export const ELASTIC_AI_ASSISTANT_ALERT_SUMMARY_URL_BULK_ACTION =
|
||||
`${ELASTIC_AI_ASSISTANT_ALERT_SUMMARY_URL}/_bulk_action` as const;
|
||||
export const ELASTIC_AI_ASSISTANT_ALERT_SUMMARY_URL_FIND =
|
||||
`${ELASTIC_AI_ASSISTANT_ALERT_SUMMARY_URL}/_find` as const;
|
||||
|
||||
// Defend insights
|
||||
export const DEFEND_INSIGHTS_ID = 'defend-insights';
|
||||
export const DEFEND_INSIGHTS = `${ELASTIC_AI_ASSISTANT_INTERNAL_URL}/defend_insights`;
|
||||
|
@ -92,4 +100,4 @@ export const INVOKE_LLM_SERVER_TIMEOUT = 4 * 60 * 1000; // 4 minutes
|
|||
* the `core-http-browser` from retrying the request.
|
||||
*
|
||||
*/
|
||||
export const INVOKE_LL_CLIENT_TIMEOUT = INVOKE_LLM_SERVER_TIMEOUT - 3000; // 4 minutes - 3 second
|
||||
export const INVOKE_LLM_CLIENT_TIMEOUT = INVOKE_LLM_SERVER_TIMEOUT - 3000; // 4 minutes - 3 second
|
||||
|
|
|
@ -17,7 +17,7 @@
|
|||
import { z } from '@kbn/zod';
|
||||
import { BooleanFromString } from '@kbn/zod-helpers';
|
||||
|
||||
import { NonEmptyString, ScreenContext } from '../common_attributes.gen';
|
||||
import { NonEmptyString, ScreenContext, PromptIds } from '../common_attributes.gen';
|
||||
import { Replacements } from '../conversations/common_attributes.gen';
|
||||
|
||||
export type ExecuteConnectorRequestQuery = z.infer<typeof ExecuteConnectorRequestQuery>;
|
||||
|
@ -53,6 +53,10 @@ export const ExecuteConnectorRequestBody = z.object({
|
|||
langSmithProject: z.string().optional(),
|
||||
langSmithApiKey: z.string().optional(),
|
||||
screenContext: ScreenContext.optional(),
|
||||
/**
|
||||
* optional system prompt, will be appended to default system prompt. Different from conversation system prompt, which is retrieved on the server
|
||||
*/
|
||||
promptIds: PromptIds.optional(),
|
||||
});
|
||||
export type ExecuteConnectorRequestBodyInput = z.input<typeof ExecuteConnectorRequestBody>;
|
||||
|
||||
|
|
|
@ -71,6 +71,9 @@ paths:
|
|||
type: string
|
||||
screenContext:
|
||||
$ref: '../common_attributes.schema.yaml#/components/schemas/ScreenContext'
|
||||
promptIds:
|
||||
$ref: '../common_attributes.schema.yaml#/components/schemas/PromptIds'
|
||||
description: optional system prompt, will be appended to default system prompt. Different from conversation system prompt, which is retrieved on the server
|
||||
responses:
|
||||
'200':
|
||||
description: Successful static response
|
||||
|
|
|
@ -0,0 +1,137 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
/*
|
||||
* NOTICE: Do not edit this file manually.
|
||||
* This file is automatically generated by the OpenAPI Generator, @kbn/openapi-generator.
|
||||
*
|
||||
* info:
|
||||
* title: Bulk AlertSummary Actions API endpoint
|
||||
* version: 1
|
||||
*/
|
||||
|
||||
import { z } from '@kbn/zod';
|
||||
|
||||
import { NonEmptyString, User } from '../common_attributes.gen';
|
||||
import { Replacements } from '../conversations/common_attributes.gen';
|
||||
|
||||
export type AlertSummaryBulkActionSkipReason = z.infer<typeof AlertSummaryBulkActionSkipReason>;
|
||||
export const AlertSummaryBulkActionSkipReason = z.literal('ALERT_SUMMARY_NOT_MODIFIED');
|
||||
|
||||
export type AlertSummaryBulkActionSkipResult = z.infer<typeof AlertSummaryBulkActionSkipResult>;
|
||||
export const AlertSummaryBulkActionSkipResult = z.object({
|
||||
id: z.string(),
|
||||
alertId: z.string().optional(),
|
||||
skip_reason: AlertSummaryBulkActionSkipReason,
|
||||
});
|
||||
|
||||
export type AlertSummaryDetailsInError = z.infer<typeof AlertSummaryDetailsInError>;
|
||||
export const AlertSummaryDetailsInError = z.object({
|
||||
alertId: z.string().optional(),
|
||||
id: z.string(),
|
||||
});
|
||||
|
||||
export type NormalizedAlertSummaryError = z.infer<typeof NormalizedAlertSummaryError>;
|
||||
export const NormalizedAlertSummaryError = z.object({
|
||||
message: z.string(),
|
||||
status_code: z.number().int(),
|
||||
err_code: z.string().optional(),
|
||||
alert_summaries: z.array(AlertSummaryDetailsInError),
|
||||
});
|
||||
|
||||
export type AlertSummaryResponse = z.infer<typeof AlertSummaryResponse>;
|
||||
export const AlertSummaryResponse = z.object({
|
||||
id: NonEmptyString,
|
||||
alertId: NonEmptyString,
|
||||
timestamp: NonEmptyString.optional(),
|
||||
summary: z.string(),
|
||||
recommendedActions: z.string().optional(),
|
||||
replacements: Replacements,
|
||||
updatedAt: z.string().optional(),
|
||||
updatedBy: z.string().optional(),
|
||||
createdAt: z.string().optional(),
|
||||
createdBy: z.string().optional(),
|
||||
users: z.array(User).optional(),
|
||||
/**
|
||||
* Kibana space
|
||||
*/
|
||||
namespace: z.string().optional(),
|
||||
});
|
||||
|
||||
export type AlertSummaryBulkCrudActionResults = z.infer<typeof AlertSummaryBulkCrudActionResults>;
|
||||
export const AlertSummaryBulkCrudActionResults = z.object({
|
||||
updated: z.array(AlertSummaryResponse),
|
||||
created: z.array(AlertSummaryResponse),
|
||||
deleted: z.array(z.string()),
|
||||
skipped: z.array(AlertSummaryBulkActionSkipResult),
|
||||
});
|
||||
|
||||
export type BulkCrudActionSummary = z.infer<typeof BulkCrudActionSummary>;
|
||||
export const BulkCrudActionSummary = z.object({
|
||||
failed: z.number().int(),
|
||||
skipped: z.number().int(),
|
||||
succeeded: z.number().int(),
|
||||
total: z.number().int(),
|
||||
});
|
||||
|
||||
export type AlertSummaryBulkCrudActionResponse = z.infer<typeof AlertSummaryBulkCrudActionResponse>;
|
||||
export const AlertSummaryBulkCrudActionResponse = z.object({
|
||||
success: z.boolean().optional(),
|
||||
status_code: z.number().int().optional(),
|
||||
message: z.string().optional(),
|
||||
alert_summaries_count: z.number().int().optional(),
|
||||
attributes: z.object({
|
||||
results: AlertSummaryBulkCrudActionResults,
|
||||
summary: BulkCrudActionSummary,
|
||||
errors: z.array(NormalizedAlertSummaryError).optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
export type BulkActionBase = z.infer<typeof BulkActionBase>;
|
||||
export const BulkActionBase = z.object({
|
||||
/**
|
||||
* Query to filter alert summaries
|
||||
*/
|
||||
query: z.string().optional(),
|
||||
/**
|
||||
* Array of alert summary IDs
|
||||
*/
|
||||
ids: z.array(z.string()).min(1).optional(),
|
||||
});
|
||||
|
||||
export type AlertSummaryCreateProps = z.infer<typeof AlertSummaryCreateProps>;
|
||||
export const AlertSummaryCreateProps = z.object({
|
||||
alertId: z.string(),
|
||||
summary: z.string(),
|
||||
recommendedActions: z.string().optional(),
|
||||
replacements: Replacements,
|
||||
});
|
||||
|
||||
export type AlertSummaryUpdateProps = z.infer<typeof AlertSummaryUpdateProps>;
|
||||
export const AlertSummaryUpdateProps = z.object({
|
||||
id: z.string(),
|
||||
summary: z.string().optional(),
|
||||
recommendedActions: z.string().optional(),
|
||||
replacements: Replacements.optional(),
|
||||
});
|
||||
|
||||
export type PerformAlertSummaryBulkActionRequestBody = z.infer<
|
||||
typeof PerformAlertSummaryBulkActionRequestBody
|
||||
>;
|
||||
export const PerformAlertSummaryBulkActionRequestBody = z.object({
|
||||
delete: BulkActionBase.optional(),
|
||||
create: z.array(AlertSummaryCreateProps).optional(),
|
||||
update: z.array(AlertSummaryUpdateProps).optional(),
|
||||
});
|
||||
export type PerformAlertSummaryBulkActionRequestBodyInput = z.input<
|
||||
typeof PerformAlertSummaryBulkActionRequestBody
|
||||
>;
|
||||
|
||||
export type PerformAlertSummaryBulkActionResponse = z.infer<
|
||||
typeof PerformAlertSummaryBulkActionResponse
|
||||
>;
|
||||
export const PerformAlertSummaryBulkActionResponse = AlertSummaryBulkCrudActionResponse;
|
|
@ -0,0 +1,249 @@
|
|||
openapi: 3.0.0
|
||||
info:
|
||||
title: Bulk AlertSummary Actions API endpoint
|
||||
version: '1'
|
||||
paths:
|
||||
/internal/elastic_assistant/alert_summary/_bulk_action:
|
||||
post:
|
||||
x-codegen-enabled: true
|
||||
x-labels: [ess, serverless]
|
||||
operationId: PerformAlertSummaryBulkAction
|
||||
summary: Apply a bulk action to alert summaries
|
||||
description: Apply a bulk action to multiple alert summaries. The bulk action is applied to all alert summaries that match the filter or to the list of alert summaries by their IDs.
|
||||
tags:
|
||||
- Bulk API
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
delete:
|
||||
$ref: '#/components/schemas/BulkActionBase'
|
||||
create:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/AlertSummaryCreateProps'
|
||||
update:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/AlertSummaryUpdateProps'
|
||||
responses:
|
||||
200:
|
||||
description: Indicates a successful call.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/AlertSummaryBulkCrudActionResponse'
|
||||
400:
|
||||
description: Generic Error
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
statusCode:
|
||||
type: number
|
||||
error:
|
||||
type: string
|
||||
message:
|
||||
type: string
|
||||
|
||||
components:
|
||||
schemas:
|
||||
AlertSummaryBulkActionSkipReason:
|
||||
type: string
|
||||
enum:
|
||||
- ALERT_SUMMARY_NOT_MODIFIED
|
||||
|
||||
AlertSummaryBulkActionSkipResult:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
alertId:
|
||||
type: string
|
||||
skip_reason:
|
||||
$ref: '#/components/schemas/AlertSummaryBulkActionSkipReason'
|
||||
required:
|
||||
- id
|
||||
- skip_reason
|
||||
|
||||
AlertSummaryDetailsInError:
|
||||
type: object
|
||||
properties:
|
||||
alertId:
|
||||
type: string
|
||||
id:
|
||||
type: string
|
||||
required:
|
||||
- id
|
||||
|
||||
NormalizedAlertSummaryError:
|
||||
type: object
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
status_code:
|
||||
type: integer
|
||||
err_code:
|
||||
type: string
|
||||
alert_summaries:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/AlertSummaryDetailsInError'
|
||||
required:
|
||||
- message
|
||||
- status_code
|
||||
- alert_summaries
|
||||
|
||||
AlertSummaryResponse:
|
||||
type: object
|
||||
required:
|
||||
- id
|
||||
- alertId
|
||||
- summary
|
||||
- replacements
|
||||
properties:
|
||||
id:
|
||||
$ref: '../common_attributes.schema.yaml#/components/schemas/NonEmptyString'
|
||||
alertId:
|
||||
$ref: '../common_attributes.schema.yaml#/components/schemas/NonEmptyString'
|
||||
'timestamp':
|
||||
$ref: '../common_attributes.schema.yaml#/components/schemas/NonEmptyString'
|
||||
summary:
|
||||
type: string
|
||||
recommendedActions:
|
||||
type: string
|
||||
replacements:
|
||||
$ref: '../conversations/common_attributes.schema.yaml#/components/schemas/Replacements'
|
||||
updatedAt:
|
||||
type: string
|
||||
updatedBy:
|
||||
type: string
|
||||
createdAt:
|
||||
type: string
|
||||
createdBy:
|
||||
type: string
|
||||
users:
|
||||
type: array
|
||||
items:
|
||||
$ref: '../common_attributes.schema.yaml#/components/schemas/User'
|
||||
namespace:
|
||||
type: string
|
||||
description: Kibana space
|
||||
|
||||
AlertSummaryBulkCrudActionResults:
|
||||
type: object
|
||||
properties:
|
||||
updated:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/AlertSummaryResponse'
|
||||
created:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/AlertSummaryResponse'
|
||||
deleted:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
skipped:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/AlertSummaryBulkActionSkipResult'
|
||||
required:
|
||||
- updated
|
||||
- created
|
||||
- deleted
|
||||
- skipped
|
||||
|
||||
|
||||
BulkCrudActionSummary:
|
||||
type: object
|
||||
properties:
|
||||
failed:
|
||||
type: integer
|
||||
skipped:
|
||||
type: integer
|
||||
succeeded:
|
||||
type: integer
|
||||
total:
|
||||
type: integer
|
||||
required:
|
||||
- failed
|
||||
- skipped
|
||||
- succeeded
|
||||
- total
|
||||
|
||||
AlertSummaryBulkCrudActionResponse:
|
||||
type: object
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
status_code:
|
||||
type: integer
|
||||
message:
|
||||
type: string
|
||||
alert_summaries_count:
|
||||
type: integer
|
||||
attributes:
|
||||
type: object
|
||||
properties:
|
||||
results:
|
||||
$ref: '#/components/schemas/AlertSummaryBulkCrudActionResults'
|
||||
summary:
|
||||
$ref: '#/components/schemas/BulkCrudActionSummary'
|
||||
errors:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/NormalizedAlertSummaryError'
|
||||
required:
|
||||
- results
|
||||
- summary
|
||||
required:
|
||||
- attributes
|
||||
|
||||
BulkActionBase:
|
||||
x-inline: true
|
||||
type: object
|
||||
properties:
|
||||
query:
|
||||
type: string
|
||||
description: Query to filter alert summaries
|
||||
ids:
|
||||
type: array
|
||||
description: Array of alert summary IDs
|
||||
minItems: 1
|
||||
items:
|
||||
type: string
|
||||
|
||||
AlertSummaryCreateProps:
|
||||
type: object
|
||||
required:
|
||||
- alertId
|
||||
- summary
|
||||
- replacements
|
||||
properties:
|
||||
alertId:
|
||||
type: string
|
||||
summary:
|
||||
type: string
|
||||
recommendedActions:
|
||||
type: string
|
||||
replacements:
|
||||
$ref: '../conversations/common_attributes.schema.yaml#/components/schemas/Replacements'
|
||||
|
||||
AlertSummaryUpdateProps:
|
||||
type: object
|
||||
required:
|
||||
- id
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
summary:
|
||||
type: string
|
||||
recommendedActions:
|
||||
type: string
|
||||
replacements:
|
||||
$ref: '../conversations/common_attributes.schema.yaml#/components/schemas/Replacements'
|
|
@ -0,0 +1,68 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
/*
|
||||
* NOTICE: Do not edit this file manually.
|
||||
* This file is automatically generated by the OpenAPI Generator, @kbn/openapi-generator.
|
||||
*
|
||||
* info:
|
||||
* title: Find AlertSummary API endpoint
|
||||
* version: 1
|
||||
*/
|
||||
|
||||
import { z } from '@kbn/zod';
|
||||
import { ArrayFromString } from '@kbn/zod-helpers';
|
||||
|
||||
import { SortOrder } from '../common_attributes.gen';
|
||||
import { AlertSummaryResponse } from './bulk_crud_alert_summary_route.gen';
|
||||
|
||||
export type FindAlertSummarySortField = z.infer<typeof FindAlertSummarySortField>;
|
||||
export const FindAlertSummarySortField = z.enum(['created_at', 'updated_at']);
|
||||
export type FindAlertSummarySortFieldEnum = typeof FindAlertSummarySortField.enum;
|
||||
export const FindAlertSummarySortFieldEnum = FindAlertSummarySortField.enum;
|
||||
|
||||
export type FindAlertSummaryRequestQuery = z.infer<typeof FindAlertSummaryRequestQuery>;
|
||||
export const FindAlertSummaryRequestQuery = z.object({
|
||||
fields: ArrayFromString(z.string()).optional(),
|
||||
/**
|
||||
* Search query
|
||||
*/
|
||||
filter: z.string().optional(),
|
||||
/**
|
||||
* Connector id used for prompt lookup
|
||||
*/
|
||||
connector_id: z.string(),
|
||||
/**
|
||||
* Field to sort by
|
||||
*/
|
||||
sort_field: FindAlertSummarySortField.optional(),
|
||||
/**
|
||||
* Sort order
|
||||
*/
|
||||
sort_order: SortOrder.optional(),
|
||||
/**
|
||||
* Page number
|
||||
*/
|
||||
page: z.coerce.number().int().min(1).optional().default(1),
|
||||
/**
|
||||
* Alert Summary per page
|
||||
*/
|
||||
per_page: z.coerce.number().int().min(0).optional().default(20),
|
||||
});
|
||||
export type FindAlertSummaryRequestQueryInput = z.input<typeof FindAlertSummaryRequestQuery>;
|
||||
|
||||
export type FindAlertSummaryResponse = z.infer<typeof FindAlertSummaryResponse>;
|
||||
export const FindAlertSummaryResponse = z.object({
|
||||
/**
|
||||
* Prompt to use to generate new alert summary
|
||||
*/
|
||||
prompt: z.string(),
|
||||
page: z.number().int(),
|
||||
perPage: z.number().int(),
|
||||
total: z.number().int(),
|
||||
data: z.array(AlertSummaryResponse),
|
||||
});
|
|
@ -0,0 +1,111 @@
|
|||
openapi: 3.0.0
|
||||
info:
|
||||
title: Find AlertSummary API endpoint
|
||||
version: '1'
|
||||
paths:
|
||||
/internal/elastic_assistant/alert_summary/_find:
|
||||
get:
|
||||
x-codegen-enabled: true
|
||||
x-labels: [ess, serverless]
|
||||
operationId: FindAlertSummary
|
||||
description: Get a list of all alert summaries.
|
||||
summary: Get alert summary
|
||||
tags:
|
||||
- Alert Summary API
|
||||
parameters:
|
||||
- name: 'fields'
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
- name: 'filter'
|
||||
in: query
|
||||
description: Search query
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
- name: 'connector_id'
|
||||
in: query
|
||||
description: Connector id used for prompt lookup
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
- name: 'sort_field'
|
||||
in: query
|
||||
description: Field to sort by
|
||||
required: false
|
||||
schema:
|
||||
$ref: '#/components/schemas/FindAlertSummarySortField'
|
||||
- name: 'sort_order'
|
||||
in: query
|
||||
description: Sort order
|
||||
required: false
|
||||
schema:
|
||||
$ref: '../common_attributes.schema.yaml#/components/schemas/SortOrder'
|
||||
- name: 'page'
|
||||
in: query
|
||||
description: Page number
|
||||
required: false
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 1
|
||||
default: 1
|
||||
- name: 'per_page'
|
||||
in: query
|
||||
description: Alert Summary per page
|
||||
required: false
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 0
|
||||
default: 20
|
||||
|
||||
responses:
|
||||
200:
|
||||
description: Successful response
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
prompt:
|
||||
type: string
|
||||
description: Prompt to use to generate new alert summary
|
||||
page:
|
||||
type: integer
|
||||
perPage:
|
||||
type: integer
|
||||
total:
|
||||
type: integer
|
||||
data:
|
||||
type: array
|
||||
items:
|
||||
$ref: './bulk_crud_alert_summary_route.schema.yaml#/components/schemas/AlertSummaryResponse'
|
||||
required:
|
||||
- page
|
||||
- perPage
|
||||
- total
|
||||
- data
|
||||
- prompt
|
||||
400:
|
||||
description: Generic Error
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
statusCode:
|
||||
type: number
|
||||
error:
|
||||
type: string
|
||||
message:
|
||||
type: string
|
||||
|
||||
components:
|
||||
schemas:
|
||||
FindAlertSummarySortField:
|
||||
type: string
|
||||
enum:
|
||||
- 'created_at'
|
||||
- 'updated_at'
|
|
@ -59,3 +59,12 @@ export const ScreenContext = z.object({
|
|||
*/
|
||||
timeZone: z.string().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* User screen context
|
||||
*/
|
||||
export type PromptIds = z.infer<typeof PromptIds>;
|
||||
export const PromptIds = z.object({
|
||||
promptId: z.string(),
|
||||
promptGroupId: z.string(),
|
||||
});
|
||||
|
|
|
@ -41,3 +41,15 @@ components:
|
|||
timeZone:
|
||||
description: The local timezone of the user
|
||||
type: string
|
||||
|
||||
PromptIds:
|
||||
description: User screen context
|
||||
type: object
|
||||
required:
|
||||
- promptId
|
||||
- promptGroupId
|
||||
properties:
|
||||
promptId:
|
||||
type: string
|
||||
promptGroupId:
|
||||
type: string
|
||||
|
|
|
@ -18,9 +18,38 @@ import {
|
|||
PRIVILEGE_ESCALATION,
|
||||
RECONNAISSANCE,
|
||||
replaceNewlineLiterals,
|
||||
} from './helpers';
|
||||
import { mockAttackDiscovery } from './pages/mock/mock_attack_discovery';
|
||||
} from './attack_discovery_helpers';
|
||||
import * as i18n from './translations';
|
||||
import type { AttackDiscovery } from '../..';
|
||||
|
||||
const mockAttackDiscovery: AttackDiscovery = {
|
||||
alertIds: [
|
||||
'639801cdb10a93610be4a91fe0eac94cd3d4d292cf0c2a6d7b3674d7f7390357',
|
||||
'bdcf649846dc3ed0ca66537e1c1dc62035a35a208ba4d9853a93e9be4b0dbea3',
|
||||
'cdbd13134bbd371cd045e5f89970b21ab866a9c3817b2aaba8d8e247ca88b823',
|
||||
'58571e1653b4201c4f35d49b6eb4023fc0219d5885ff7c385a9253a692a77104',
|
||||
'06fcb3563de7dad14137c0bb4e5bae17948c808b8a3b8c60d9ec209a865b20ed',
|
||||
'8bd3fcaeca5698ee26df402c8bc40df0404d34a278bc0bd9355910c8c92a4aee',
|
||||
'59ff4efa1a03b0d1cb5c0640f5702555faf5c88d273616c1b6e22dcfc47ac46c',
|
||||
'f352f8ca14a12062cde77ff2b099202bf74f4a7d757c2ac75ac63690b2f2f91a',
|
||||
],
|
||||
detailsMarkdown:
|
||||
'The following attack progression appears to have occurred on the host {{ host.name 5e454c38-439c-4096-8478-0a55511c76e3 }} involving the user {{ user.name 3bdc7952-a334-4d95-8092-cd176546e18a }}:\n\n- A suspicious application named "My Go Application.app" was launched, likely through a malicious download or installation.\n- This application spawned child processes to copy a malicious file named "unix1" to the user\'s home directory and make it executable.\n- The malicious "unix1" file was then executed, attempting to access the user\'s login keychain and potentially exfiltrate credentials.\n- The suspicious application also launched the "osascript" utility to display a fake system dialog prompting the user for their password, a technique known as credentials phishing.\n\nThis appears to be a multi-stage attack involving malware delivery, privilege escalation, credential access, and potentially data exfiltration. The attacker may have used social engineering techniques like phishing to initially compromise the system. The suspicious "My Go Application.app" exhibits behavior characteristic of malware families that attempt to steal user credentials and maintain persistence. Mitigations should focus on removing the malicious files, resetting credentials, and enhancing security controls around application whitelisting, user training, and data protection.',
|
||||
entitySummaryMarkdown:
|
||||
'Suspicious activity involving the host {{ host.name 5e454c38-439c-4096-8478-0a55511c76e3 }} and user {{ user.name 3bdc7952-a334-4d95-8092-cd176546e18a }}.',
|
||||
id: 'e6d1f8ef-7c1d-42d6-ba6a-11610bab72b1',
|
||||
mitreAttackTactics: [
|
||||
'Initial Access',
|
||||
'Execution',
|
||||
'Persistence',
|
||||
'Privilege Escalation',
|
||||
'Credential Access',
|
||||
],
|
||||
summaryMarkdown:
|
||||
'A multi-stage malware attack was detected on {{ host.name 5e454c38-439c-4096-8478-0a55511c76e3 }} involving {{ user.name 3bdc7952-a334-4d95-8092-cd176546e18a }}. A suspicious application delivered malware, attempted credential theft, and established persistence.',
|
||||
timestamp: '2024-06-25T21:14:40.098Z',
|
||||
title: 'Malware Attack With Credential Theft Attempt',
|
||||
};
|
||||
|
||||
const expectedTactics = {
|
||||
[RECONNAISSANCE]: i18n.RECONNAISSANCE,
|
|
@ -0,0 +1,99 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license 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 { AttackDiscovery } from '../..';
|
||||
import * as i18n from './translations';
|
||||
|
||||
export const RECONNAISSANCE = 'Reconnaissance';
|
||||
export const RESOURCE_DEVELOPMENT = 'Resource Development';
|
||||
export const INITIAL_ACCESS = 'Initial Access';
|
||||
export const EXECUTION = 'Execution';
|
||||
export const PERSISTENCE = 'Persistence';
|
||||
export const PRIVILEGE_ESCALATION = 'Privilege Escalation';
|
||||
export const DEFENSE_EVASION = 'Defense Evasion';
|
||||
export const CREDENTIAL_ACCESS = 'Credential Access';
|
||||
export const DISCOVERY = 'Discovery';
|
||||
export const LATERAL_MOVEMENT = 'Lateral Movement';
|
||||
export const COLLECTION = 'Collection';
|
||||
export const COMMAND_AND_CONTROL = 'Command and Control';
|
||||
export const EXFILTRATION = 'Exfiltration';
|
||||
export const IMPACT = 'Impact';
|
||||
|
||||
/** A subset of the Mitre Attack Tactics */
|
||||
export const MITRE_ATTACK_TACTICS_SUBSET = [
|
||||
RECONNAISSANCE,
|
||||
RESOURCE_DEVELOPMENT,
|
||||
INITIAL_ACCESS,
|
||||
EXECUTION,
|
||||
PERSISTENCE,
|
||||
PRIVILEGE_ESCALATION,
|
||||
DEFENSE_EVASION,
|
||||
CREDENTIAL_ACCESS,
|
||||
DISCOVERY,
|
||||
LATERAL_MOVEMENT,
|
||||
COLLECTION,
|
||||
COMMAND_AND_CONTROL,
|
||||
EXFILTRATION,
|
||||
IMPACT,
|
||||
] as const;
|
||||
|
||||
export const getTacticLabel = (tactic: string): string => {
|
||||
switch (tactic) {
|
||||
case RECONNAISSANCE:
|
||||
return i18n.RECONNAISSANCE;
|
||||
case RESOURCE_DEVELOPMENT:
|
||||
return i18n.RESOURCE_DEVELOPMENT;
|
||||
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 DEFENSE_EVASION:
|
||||
return i18n.DEFENSE_EVASION;
|
||||
case CREDENTIAL_ACCESS:
|
||||
return i18n.CREDENTIAL_ACCESS;
|
||||
case DISCOVERY:
|
||||
return i18n.DISCOVERY;
|
||||
case LATERAL_MOVEMENT:
|
||||
return i18n.LATERAL_MOVEMENT;
|
||||
case COLLECTION:
|
||||
return i18n.COLLECTION;
|
||||
case COMMAND_AND_CONTROL:
|
||||
return i18n.COMMAND_AND_CONTROL;
|
||||
case EXFILTRATION:
|
||||
return i18n.EXFILTRATION;
|
||||
case IMPACT:
|
||||
return i18n.IMPACT;
|
||||
default:
|
||||
return tactic;
|
||||
}
|
||||
};
|
||||
|
||||
export interface TacticMetadata {
|
||||
detected: boolean;
|
||||
index: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export const getTacticMetadata = (attackDiscovery: AttackDiscovery): TacticMetadata[] =>
|
||||
MITRE_ATTACK_TACTICS_SUBSET.map((tactic, i) => ({
|
||||
detected:
|
||||
attackDiscovery.mitreAttackTactics === undefined
|
||||
? false
|
||||
: attackDiscovery.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');
|
|
@ -0,0 +1,105 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { i18n } from '@kbn/i18n';
|
||||
|
||||
export const COLLECTION = i18n.translate(
|
||||
'xpack.elasticAssistantCommon.attackDiscovery.mitre.attack.tactics.collectionLabel',
|
||||
{
|
||||
defaultMessage: 'Collection',
|
||||
}
|
||||
);
|
||||
|
||||
export const COMMAND_AND_CONTROL = i18n.translate(
|
||||
'xpack.elasticAssistantCommon.attackDiscovery.mitre.attack.tactics.commandAndControlLabel',
|
||||
{
|
||||
defaultMessage: 'Command & Control',
|
||||
}
|
||||
);
|
||||
|
||||
export const CREDENTIAL_ACCESS = i18n.translate(
|
||||
'xpack.elasticAssistantCommon.attackDiscovery.mitre.attack.tactics.credentialAccessLabel',
|
||||
{
|
||||
defaultMessage: 'Credential Access',
|
||||
}
|
||||
);
|
||||
|
||||
export const DEFENSE_EVASION = i18n.translate(
|
||||
'xpack.elasticAssistantCommon.attackDiscovery.mitre.attack.tactics.defenseEvasionLabel',
|
||||
{
|
||||
defaultMessage: 'Defense Evasion',
|
||||
}
|
||||
);
|
||||
|
||||
export const DISCOVERY = i18n.translate(
|
||||
'xpack.elasticAssistantCommon.attackDiscovery.mitre.attack.tactics.discoveryLabel',
|
||||
{
|
||||
defaultMessage: 'Discovery',
|
||||
}
|
||||
);
|
||||
|
||||
export const EXECUTION = i18n.translate(
|
||||
'xpack.elasticAssistantCommon.attackDiscovery.mitre.attack.tactics.executionLabel',
|
||||
{
|
||||
defaultMessage: 'Execution',
|
||||
}
|
||||
);
|
||||
|
||||
export const EXFILTRATION = i18n.translate(
|
||||
'xpack.elasticAssistantCommon.attackDiscovery.mitre.attack.tactics.exfiltrationLabel',
|
||||
{
|
||||
defaultMessage: 'Exfiltration',
|
||||
}
|
||||
);
|
||||
|
||||
export const LATERAL_MOVEMENT = i18n.translate(
|
||||
'xpack.elasticAssistantCommon.attackDiscovery.mitre.attack.tactics.lateralMovementLabel',
|
||||
{
|
||||
defaultMessage: 'Lateral Movement',
|
||||
}
|
||||
);
|
||||
|
||||
export const IMPACT = i18n.translate(
|
||||
'xpack.elasticAssistantCommon.attackDiscovery.mitre.attack.tactics.impactLabel',
|
||||
{
|
||||
defaultMessage: 'Impact',
|
||||
}
|
||||
);
|
||||
export const INITIAL_ACCESS = i18n.translate(
|
||||
'xpack.elasticAssistantCommon.attackDiscovery.mitre.attack.tactics.initialAccessLabel',
|
||||
{
|
||||
defaultMessage: 'Initial Access',
|
||||
}
|
||||
);
|
||||
|
||||
export const PERSISTENCE = i18n.translate(
|
||||
'xpack.elasticAssistantCommon.attackDiscovery.mitre.attack.tactics.persistenceLabel',
|
||||
{
|
||||
defaultMessage: 'Persistence',
|
||||
}
|
||||
);
|
||||
|
||||
export const PRIVILEGE_ESCALATION = i18n.translate(
|
||||
'xpack.elasticAssistantCommon.attackDiscovery.mitre.attack.tactics.privilegeEscalationLabel',
|
||||
{
|
||||
defaultMessage: 'Privilege Escalation',
|
||||
}
|
||||
);
|
||||
|
||||
export const RECONNAISSANCE = i18n.translate(
|
||||
'xpack.elasticAssistantCommon.attackDiscovery.mitre.attack.tactics.reconnaissanceLabel',
|
||||
{
|
||||
defaultMessage: 'Reconnaissance',
|
||||
}
|
||||
);
|
||||
|
||||
export const RESOURCE_DEVELOPMENT = i18n.translate(
|
||||
'xpack.elasticAssistantCommon.attackDiscovery.mitre.attack.tactics.resourceDevelopmentLabel',
|
||||
{
|
||||
defaultMessage: 'Resource Development',
|
||||
}
|
||||
);
|
|
@ -66,3 +66,9 @@ export {
|
|||
/** The default (relative) start of the date range (i.e. `now-24h`) */
|
||||
DEFAULT_START,
|
||||
} from './impl/alerts/get_open_and_acknowledged_alerts_query';
|
||||
|
||||
export {
|
||||
getTacticLabel,
|
||||
getTacticMetadata,
|
||||
replaceNewlineLiterals,
|
||||
} from './impl/utils/attack_discovery_helpers';
|
||||
|
|
|
@ -21,5 +21,6 @@
|
|||
"@kbn/core",
|
||||
"@kbn/logging",
|
||||
"@kbn/zod",
|
||||
"@kbn/i18n",
|
||||
]
|
||||
}
|
||||
|
|
|
@ -0,0 +1,42 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import React, { memo, useCallback } from 'react';
|
||||
import { EuiCallOut, EuiLink } from '@elastic/eui';
|
||||
import { useAssistantContext } from '../../..';
|
||||
import * as i18n from '../translations';
|
||||
|
||||
interface Props {
|
||||
canSeeAdvancedSettings: boolean;
|
||||
}
|
||||
|
||||
export const ConnectorMissingCallout = memo(({ canSeeAdvancedSettings }: Props) => {
|
||||
const { navigateToApp } = useAssistantContext();
|
||||
const goToKibanaSettings = useCallback(
|
||||
() => navigateToApp('management', { path: '/kibana/settings?query=defaultAIConnector' }),
|
||||
[navigateToApp]
|
||||
);
|
||||
|
||||
return (
|
||||
<EuiCallOut title={i18n.MISSING_CONNECTOR} color="danger" iconType="error">
|
||||
<p>
|
||||
{canSeeAdvancedSettings
|
||||
? i18n.CONNECTOR_MISSING_MESSAGE_ADMIN
|
||||
: i18n.CONNECTOR_MISSING_MESSAGE}
|
||||
{canSeeAdvancedSettings && (
|
||||
<>
|
||||
{' '}
|
||||
<EuiLink onClick={goToKibanaSettings}>{i18n.ADVANCED_SETTINGS_LINK_TITLE}</EuiLink>
|
||||
{'.'}
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</EuiCallOut>
|
||||
);
|
||||
});
|
||||
|
||||
ConnectorMissingCallout.displayName = 'ConnectorMissingCallout';
|
|
@ -0,0 +1,120 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { AlertSummary } from '.';
|
||||
import type { PromptContext } from '../../..';
|
||||
import { useAlertSummary } from './use_alert_summary';
|
||||
|
||||
jest.mock('./use_alert_summary');
|
||||
const promptContext: PromptContext = {
|
||||
category: 'alert',
|
||||
description: 'Alert summary',
|
||||
getPromptContext: jest
|
||||
.fn()
|
||||
.mockResolvedValue('{ host.name: "test-host", more.data: 123, "user.name": "test-user"}'),
|
||||
id: '_promptContextId',
|
||||
suggestedUserPrompt: '_suggestedUserPrompt',
|
||||
tooltip: '_tooltip',
|
||||
replacements: { 'host.name': '12345' },
|
||||
};
|
||||
const defaultProps = {
|
||||
alertId: 'test-alert-id',
|
||||
canSeeAdvancedSettings: true,
|
||||
defaultConnectorId: 'test-connector-id',
|
||||
isContextReady: true,
|
||||
promptContext,
|
||||
setHasAlertSummary: jest.fn(),
|
||||
showAnonymizedValues: false,
|
||||
};
|
||||
|
||||
describe('AlertSummary', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
(useAlertSummary as jest.Mock).mockReturnValue({
|
||||
alertSummary: '',
|
||||
recommendedActions: '',
|
||||
hasAlertSummary: false,
|
||||
fetchAISummary: jest.fn(),
|
||||
isLoading: false,
|
||||
messageAndReplacements: {
|
||||
message: '',
|
||||
replacements: {},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('renders the loading state when `isLoading` is true', () => {
|
||||
(useAlertSummary as jest.Mock).mockReturnValue({
|
||||
alertSummary: '',
|
||||
recommendedActions: '',
|
||||
hasAlertSummary: true,
|
||||
fetchAISummary: jest.fn(),
|
||||
isLoading: true,
|
||||
messageAndReplacements: {
|
||||
message: '',
|
||||
replacements: {},
|
||||
},
|
||||
});
|
||||
render(<AlertSummary {...defaultProps} />);
|
||||
expect(screen.getByTestId('generating-summary')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the alert summary when `hasAlertSummary` is true and `isLoading` is false', () => {
|
||||
(useAlertSummary as jest.Mock).mockReturnValue({
|
||||
alertSummary: 'Test alert summary',
|
||||
recommendedActions: 'Test recommended actions',
|
||||
hasAlertSummary: true,
|
||||
fetchAISummary: jest.fn(),
|
||||
isLoading: false,
|
||||
messageAndReplacements: {
|
||||
message: '',
|
||||
replacements: {},
|
||||
},
|
||||
});
|
||||
render(<AlertSummary {...defaultProps} />);
|
||||
expect(screen.getAllByTestId('messageText')[0]).toHaveTextContent('Test alert summary');
|
||||
expect(screen.getAllByTestId('messageText')[1]).toHaveTextContent('Test recommended actions');
|
||||
});
|
||||
|
||||
it('renders the generate button when `hasAlertSummary` is false', () => {
|
||||
const fetchAISummary = jest.fn();
|
||||
(useAlertSummary as jest.Mock).mockReturnValue({
|
||||
alertSummary: '',
|
||||
recommendedActions: '',
|
||||
hasAlertSummary: false,
|
||||
fetchAISummary,
|
||||
isLoading: false,
|
||||
messageAndReplacements: {
|
||||
message: '',
|
||||
replacements: {},
|
||||
},
|
||||
});
|
||||
render(<AlertSummary {...defaultProps} />);
|
||||
fireEvent.click(screen.getByTestId('generateInsights'));
|
||||
expect(fetchAISummary).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('renders the regenerate button when `hasAlertSummary` is true', () => {
|
||||
const fetchAISummary = jest.fn();
|
||||
(useAlertSummary as jest.Mock).mockReturnValue({
|
||||
alertSummary: 'Test alert summary',
|
||||
recommendedActions: 'Test recommended actions',
|
||||
hasAlertSummary: true,
|
||||
fetchAISummary,
|
||||
isLoading: false,
|
||||
messageAndReplacements: {
|
||||
message: '',
|
||||
replacements: {},
|
||||
},
|
||||
});
|
||||
render(<AlertSummary {...defaultProps} />);
|
||||
fireEvent.click(screen.getByTestId('regenerateInsights'));
|
||||
expect(fetchAISummary).toHaveBeenCalled();
|
||||
});
|
||||
});
|
|
@ -0,0 +1,154 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import React, { memo, useEffect } from 'react';
|
||||
import {
|
||||
EuiButton,
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiPanel,
|
||||
EuiSkeletonText,
|
||||
EuiSpacer,
|
||||
EuiText,
|
||||
} from '@elastic/eui';
|
||||
import { css } from '@emotion/react';
|
||||
import { AssistantIcon } from '@kbn/ai-assistant-icon';
|
||||
import { ConnectorMissingCallout } from './connector_missing_callout';
|
||||
import { useAlertSummary } from './use_alert_summary';
|
||||
import type { PromptContext } from '../../..';
|
||||
import { MessageText } from '../message_text';
|
||||
import * as i18n from '../translations';
|
||||
|
||||
interface Props {
|
||||
alertId: string;
|
||||
canSeeAdvancedSettings: boolean;
|
||||
defaultConnectorId: string;
|
||||
isContextReady: boolean;
|
||||
promptContext: PromptContext;
|
||||
setHasAlertSummary: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
showAnonymizedValues?: boolean;
|
||||
}
|
||||
|
||||
export const AlertSummary = memo(
|
||||
({
|
||||
alertId,
|
||||
canSeeAdvancedSettings,
|
||||
defaultConnectorId,
|
||||
isContextReady,
|
||||
promptContext,
|
||||
setHasAlertSummary,
|
||||
showAnonymizedValues,
|
||||
}: Props) => {
|
||||
const {
|
||||
alertSummary,
|
||||
recommendedActions,
|
||||
hasAlertSummary,
|
||||
fetchAISummary,
|
||||
isConnectorMissing,
|
||||
isLoading,
|
||||
messageAndReplacements,
|
||||
} = useAlertSummary({
|
||||
alertId,
|
||||
defaultConnectorId,
|
||||
isContextReady,
|
||||
promptContext,
|
||||
showAnonymizedValues,
|
||||
});
|
||||
useEffect(() => {
|
||||
setHasAlertSummary(hasAlertSummary);
|
||||
}, [hasAlertSummary, setHasAlertSummary]);
|
||||
return (
|
||||
<>
|
||||
{hasAlertSummary ? (
|
||||
isLoading ? (
|
||||
<>
|
||||
<EuiText
|
||||
color="subdued"
|
||||
css={css`
|
||||
font-style: italic;
|
||||
`}
|
||||
size="s"
|
||||
data-test-subj="generating-summary"
|
||||
>
|
||||
{i18n.GENERATING}
|
||||
</EuiText>
|
||||
<EuiSkeletonText lines={3} size="s" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{isConnectorMissing && (
|
||||
<>
|
||||
<ConnectorMissingCallout canSeeAdvancedSettings={canSeeAdvancedSettings} />
|
||||
<EuiSpacer size="m" />
|
||||
</>
|
||||
)}
|
||||
<MessageText content={alertSummary} />
|
||||
|
||||
<EuiSpacer size="m" />
|
||||
{recommendedActions && (
|
||||
<>
|
||||
<EuiPanel hasShadow={false} hasBorder paddingSize="s">
|
||||
<EuiText size="xs" color="subdued">
|
||||
{i18n.RECOMMENDED_ACTIONS}
|
||||
</EuiText>
|
||||
<EuiSpacer size="s" />
|
||||
<MessageText content={recommendedActions} />
|
||||
</EuiPanel>
|
||||
<EuiSpacer size="m" />
|
||||
</>
|
||||
)}
|
||||
<EuiFlexGroup gutterSize="s">
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButton
|
||||
onClick={fetchAISummary}
|
||||
color="primary"
|
||||
size="m"
|
||||
data-test-subj="regenerateInsights"
|
||||
isLoading={messageAndReplacements == null}
|
||||
>
|
||||
<EuiFlexGroup
|
||||
gutterSize="s"
|
||||
alignItems="center"
|
||||
responsive={false}
|
||||
wrap={false}
|
||||
>
|
||||
<EuiFlexItem grow={false}>
|
||||
<AssistantIcon size="m" />
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>{i18n.REGENERATE}</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiButton>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</>
|
||||
)
|
||||
) : (
|
||||
<EuiFlexGroup gutterSize="s">
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButton
|
||||
onClick={fetchAISummary}
|
||||
color="primary"
|
||||
size="m"
|
||||
data-test-subj="generateInsights"
|
||||
isLoading={messageAndReplacements == null}
|
||||
>
|
||||
<EuiFlexGroup gutterSize="s" alignItems="center">
|
||||
<EuiFlexItem grow={false}>
|
||||
<AssistantIcon size="m" />
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>{i18n.GENERATE}</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiButton>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
AlertSummary.displayName = 'AlertSummary';
|
|
@ -0,0 +1,176 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { renderHook, act, waitFor } from '@testing-library/react';
|
||||
import { useAlertSummary } from './use_alert_summary';
|
||||
import { useChatComplete } from '../../assistant/api/chat_complete/use_chat_complete';
|
||||
import { useFetchAnonymizationFields } from '../../assistant/api/anonymization_fields/use_fetch_anonymization_fields';
|
||||
import { useFetchAlertSummary } from './use_fetch_alert_summary';
|
||||
import { useBulkUpdateAlertSummary } from './use_bulk_update_alert_summary';
|
||||
import type { PromptContext } from '../../..';
|
||||
|
||||
jest.mock('../../assistant/api/chat_complete/use_chat_complete');
|
||||
jest.mock('../../assistant/api/anonymization_fields/use_fetch_anonymization_fields');
|
||||
jest.mock('./use_fetch_alert_summary');
|
||||
jest.mock('./use_bulk_update_alert_summary');
|
||||
const promptContext: PromptContext = {
|
||||
category: 'alert',
|
||||
description: 'Alert summary',
|
||||
getPromptContext: jest
|
||||
.fn()
|
||||
.mockResolvedValue('{ host.name: "test-host", more.data: 123, "user.name": "test-user"}'),
|
||||
id: '_promptContextId',
|
||||
suggestedUserPrompt: '_suggestedUserPrompt',
|
||||
tooltip: '_tooltip',
|
||||
replacements: { 'host.name': '12345' },
|
||||
};
|
||||
describe('useAlertSummary', () => {
|
||||
const mockSendMessage = jest.fn();
|
||||
const mockAbortStream = jest.fn();
|
||||
const mockRefetchAlertSummary = jest.fn();
|
||||
const mockBulkUpdate = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
(useChatComplete as jest.Mock).mockReturnValue({
|
||||
sendMessage: mockSendMessage,
|
||||
abortStream: mockAbortStream,
|
||||
});
|
||||
|
||||
(useFetchAnonymizationFields as jest.Mock).mockReturnValue({
|
||||
data: [],
|
||||
isFetched: true,
|
||||
});
|
||||
|
||||
(useFetchAlertSummary as jest.Mock).mockReturnValue({
|
||||
data: { data: [] },
|
||||
refetch: mockRefetchAlertSummary,
|
||||
isFetched: true,
|
||||
});
|
||||
|
||||
(useBulkUpdateAlertSummary as jest.Mock).mockReturnValue({
|
||||
bulkUpdate: mockBulkUpdate,
|
||||
});
|
||||
});
|
||||
|
||||
it('should initialize with default values', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useAlertSummary({
|
||||
alertId: 'test-alert-id',
|
||||
defaultConnectorId: 'test-connector-id',
|
||||
isContextReady: false,
|
||||
promptContext,
|
||||
showAnonymizedValues: false,
|
||||
})
|
||||
);
|
||||
|
||||
expect(result.current.alertSummary).toBe('No summary available');
|
||||
expect(result.current.hasAlertSummary).toBe(false);
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
expect(result.current.messageAndReplacements).toBeNull();
|
||||
expect(result.current.recommendedActions).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should fetch AI summary when fetchAISummary is called', async () => {
|
||||
(useFetchAlertSummary as jest.Mock)
|
||||
.mockReturnValueOnce({
|
||||
data: {
|
||||
data: [{ id: 'summary-id', summary: '', replacements: {} }],
|
||||
prompt: 'Generate an alert summary!',
|
||||
},
|
||||
refetch: mockRefetchAlertSummary,
|
||||
isFetched: true,
|
||||
})
|
||||
.mockReturnValue({
|
||||
data: {
|
||||
data: [
|
||||
{
|
||||
id: 'summary-id',
|
||||
summary: 'Generated summary',
|
||||
recommendedActions: 'Generated actions',
|
||||
replacements: {},
|
||||
},
|
||||
],
|
||||
prompt: 'Generate an alert summary!',
|
||||
},
|
||||
refetch: mockRefetchAlertSummary,
|
||||
isFetched: true,
|
||||
});
|
||||
|
||||
const mockResponse = {
|
||||
response: JSON.stringify({
|
||||
summary: 'Generated summary',
|
||||
recommendedActions: 'Generated actions',
|
||||
}),
|
||||
isError: false,
|
||||
};
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useAlertSummary({
|
||||
alertId: 'test-alert-id',
|
||||
defaultConnectorId: 'test-connector-id',
|
||||
isContextReady: true,
|
||||
promptContext,
|
||||
showAnonymizedValues: false,
|
||||
})
|
||||
);
|
||||
const expectedMessageAndReplacements = {
|
||||
message:
|
||||
'CONTEXT:\n"""\n{ host.name: "test-host", more.data: 123, "user.name": "test-user"}\n"""\n\nGenerate an alert summary!',
|
||||
replacements: { 'host.name': '12345' },
|
||||
};
|
||||
await waitFor(() => {
|
||||
expect(result.current.messageAndReplacements).toEqual(expectedMessageAndReplacements);
|
||||
});
|
||||
|
||||
mockSendMessage.mockResolvedValue(mockResponse);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.fetchAISummary();
|
||||
});
|
||||
|
||||
expect(mockSendMessage).toHaveBeenCalledWith({
|
||||
...expectedMessageAndReplacements,
|
||||
promptIds: { promptGroupId: 'aiForSoc', promptId: 'alertSummarySystemPrompt' },
|
||||
query: { content_references_disabled: true },
|
||||
});
|
||||
|
||||
expect(mockBulkUpdate).toHaveBeenCalledWith({
|
||||
alertSummary: {
|
||||
update: [
|
||||
{
|
||||
id: 'summary-id',
|
||||
summary: 'Generated summary',
|
||||
recommendedActions: 'Generated actions',
|
||||
replacements: { 'host.name': '12345' },
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(mockRefetchAlertSummary).toHaveBeenCalled();
|
||||
expect(result.current.alertSummary).toBe('Generated summary');
|
||||
expect(result.current.recommendedActions).toBe('Generated actions');
|
||||
});
|
||||
|
||||
it('should abort stream on unmount', () => {
|
||||
const { unmount } = renderHook(() =>
|
||||
useAlertSummary({
|
||||
alertId: 'test-alert-id',
|
||||
defaultConnectorId: 'test-connector-id',
|
||||
isContextReady: false,
|
||||
promptContext,
|
||||
showAnonymizedValues: false,
|
||||
})
|
||||
);
|
||||
|
||||
unmount();
|
||||
|
||||
expect(mockAbortStream).toHaveBeenCalled();
|
||||
});
|
||||
});
|
|
@ -0,0 +1,231 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
replaceAnonymizedValuesWithOriginalValues,
|
||||
Replacements,
|
||||
} from '@kbn/elastic-assistant-common';
|
||||
import { useChatComplete } from '../../assistant/api/chat_complete/use_chat_complete';
|
||||
import { useFetchAnonymizationFields } from '../../assistant/api/anonymization_fields/use_fetch_anonymization_fields';
|
||||
import { useFetchAlertSummary } from './use_fetch_alert_summary';
|
||||
import { useBulkUpdateAlertSummary } from './use_bulk_update_alert_summary';
|
||||
import { getNewSelectedPromptContext } from '../../data_anonymization/get_new_selected_prompt_context';
|
||||
import { getCombinedMessage } from '../../assistant/prompt/helpers';
|
||||
import type { PromptContext } from '../../..';
|
||||
import * as i18n from '../translations';
|
||||
|
||||
interface Props {
|
||||
alertId: string;
|
||||
defaultConnectorId: string;
|
||||
isContextReady: boolean;
|
||||
promptContext: PromptContext;
|
||||
showAnonymizedValues?: boolean;
|
||||
}
|
||||
interface UseAlertSummary {
|
||||
alertSummary: string;
|
||||
hasAlertSummary: boolean;
|
||||
fetchAISummary: () => void;
|
||||
isConnectorMissing: boolean;
|
||||
isLoading: boolean;
|
||||
messageAndReplacements: { message: string; replacements: Replacements } | null;
|
||||
recommendedActions: string | undefined;
|
||||
}
|
||||
|
||||
export const useAlertSummary = ({
|
||||
alertId,
|
||||
defaultConnectorId,
|
||||
isContextReady,
|
||||
promptContext,
|
||||
showAnonymizedValues = false,
|
||||
}: Props): UseAlertSummary => {
|
||||
const { abortStream, sendMessage } = useChatComplete({
|
||||
connectorId: defaultConnectorId,
|
||||
});
|
||||
const { data: anonymizationFields, isFetched: isFetchedAnonymizationFields } =
|
||||
useFetchAnonymizationFields();
|
||||
const [isConnectorMissing, setIsConnectorMissing] = useState<boolean>(false);
|
||||
const [alertSummary, setAlertSummary] = useState<string>(i18n.NO_SUMMARY_AVAILABLE);
|
||||
const [recommendedActions, setRecommendedActions] = useState<string | undefined>();
|
||||
const [messageAndReplacements, setMessageAndReplacements] = useState<{
|
||||
message: string;
|
||||
replacements: Replacements;
|
||||
} | null>(null);
|
||||
// indicates that an alert summary exists or is being created/fetched
|
||||
const [hasAlertSummary, setHasAlertSummary] = useState<boolean>(false);
|
||||
const {
|
||||
data: fetchedAlertSummary,
|
||||
refetch: refetchAlertSummary,
|
||||
isFetched: isAlertSummaryFetched,
|
||||
} = useFetchAlertSummary({
|
||||
alertId,
|
||||
connectorId: defaultConnectorId,
|
||||
});
|
||||
const { bulkUpdate } = useBulkUpdateAlertSummary();
|
||||
|
||||
useEffect(() => {
|
||||
if (fetchedAlertSummary.data.length > 0) {
|
||||
setHasAlertSummary(true);
|
||||
setAlertSummary(
|
||||
showAnonymizedValues
|
||||
? fetchedAlertSummary.data[0].summary
|
||||
: replaceAnonymizedValuesWithOriginalValues({
|
||||
messageContent: fetchedAlertSummary.data[0].summary,
|
||||
replacements: fetchedAlertSummary.data[0].replacements,
|
||||
})
|
||||
);
|
||||
if (fetchedAlertSummary.data[0].recommendedActions) {
|
||||
setRecommendedActions(
|
||||
showAnonymizedValues
|
||||
? fetchedAlertSummary.data[0].recommendedActions
|
||||
: replaceAnonymizedValuesWithOriginalValues({
|
||||
messageContent: fetchedAlertSummary.data[0].recommendedActions,
|
||||
replacements: fetchedAlertSummary.data[0].replacements,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
}, [fetchedAlertSummary, showAnonymizedValues]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchContext = async () => {
|
||||
const newSelectedPromptContext = await getNewSelectedPromptContext({
|
||||
anonymizationFields,
|
||||
promptContext,
|
||||
});
|
||||
const selectedPromptContexts = {
|
||||
[promptContext.id]: newSelectedPromptContext,
|
||||
};
|
||||
|
||||
const userMessage = getCombinedMessage({
|
||||
currentReplacements: {},
|
||||
promptText: fetchedAlertSummary.prompt,
|
||||
selectedPromptContexts,
|
||||
});
|
||||
const baseReplacements: Replacements = userMessage.replacements ?? {};
|
||||
|
||||
const selectedPromptContextsReplacements = Object.values(
|
||||
selectedPromptContexts
|
||||
).reduce<Replacements>((acc, context) => ({ ...acc, ...context.replacements }), {});
|
||||
|
||||
const replacements: Replacements = {
|
||||
...baseReplacements,
|
||||
...selectedPromptContextsReplacements,
|
||||
};
|
||||
setMessageAndReplacements({ message: userMessage.content ?? '', replacements });
|
||||
};
|
||||
|
||||
if (isFetchedAnonymizationFields && isContextReady && isAlertSummaryFetched) fetchContext();
|
||||
}, [
|
||||
anonymizationFields,
|
||||
isFetchedAnonymizationFields,
|
||||
isContextReady,
|
||||
isAlertSummaryFetched,
|
||||
fetchedAlertSummary.prompt,
|
||||
promptContext,
|
||||
]);
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
|
||||
const fetchAISummary = useCallback(() => {
|
||||
const fetchSummary = async (content: { message: string; replacements: Replacements }) => {
|
||||
setIsConnectorMissing(false);
|
||||
setIsGenerating(true);
|
||||
setHasAlertSummary(true);
|
||||
|
||||
const rawResponse = await sendMessage({
|
||||
...content,
|
||||
promptIds: { promptGroupId: 'aiForSoc', promptId: 'alertSummarySystemPrompt' },
|
||||
query: {
|
||||
content_references_disabled: true,
|
||||
},
|
||||
});
|
||||
let responseSummary;
|
||||
let responseRecommendedActions;
|
||||
try {
|
||||
const parsedResponse = JSON.parse(rawResponse.response);
|
||||
responseSummary = parsedResponse.summary;
|
||||
responseRecommendedActions = parsedResponse.recommendedActions;
|
||||
} catch (e) {
|
||||
// AI did not return the expected JSON
|
||||
responseSummary = rawResponse.response;
|
||||
}
|
||||
|
||||
if (!rawResponse.isError) {
|
||||
if (fetchedAlertSummary.data.length > 0) {
|
||||
await bulkUpdate({
|
||||
alertSummary: {
|
||||
update: [
|
||||
{
|
||||
id: fetchedAlertSummary.data[0].id,
|
||||
summary: responseSummary,
|
||||
...(responseRecommendedActions
|
||||
? { recommendedActions: responseRecommendedActions }
|
||||
: {}),
|
||||
replacements: content.replacements,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await bulkUpdate({
|
||||
alertSummary: {
|
||||
create: [
|
||||
{
|
||||
alertId,
|
||||
summary: responseSummary,
|
||||
...(responseRecommendedActions
|
||||
? { recommendedActions: responseRecommendedActions }
|
||||
: {}),
|
||||
replacements: content.replacements,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
await refetchAlertSummary();
|
||||
} else {
|
||||
if (responseSummary.includes('Failed to load action')) {
|
||||
setIsConnectorMissing(true);
|
||||
}
|
||||
setAlertSummary(
|
||||
showAnonymizedValues
|
||||
? responseSummary
|
||||
: replaceAnonymizedValuesWithOriginalValues({
|
||||
messageContent: responseSummary,
|
||||
replacements: content.replacements,
|
||||
})
|
||||
);
|
||||
}
|
||||
setIsGenerating(false);
|
||||
};
|
||||
|
||||
if (messageAndReplacements !== null) fetchSummary(messageAndReplacements);
|
||||
}, [
|
||||
alertId,
|
||||
bulkUpdate,
|
||||
fetchedAlertSummary.data,
|
||||
messageAndReplacements,
|
||||
refetchAlertSummary,
|
||||
sendMessage,
|
||||
showAnonymizedValues,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
abortStream();
|
||||
};
|
||||
}, [abortStream]);
|
||||
return {
|
||||
alertSummary,
|
||||
hasAlertSummary,
|
||||
fetchAISummary,
|
||||
isConnectorMissing,
|
||||
isLoading: isGenerating,
|
||||
messageAndReplacements,
|
||||
recommendedActions,
|
||||
};
|
||||
};
|
|
@ -0,0 +1,76 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { useBulkUpdateAlertSummary } from './use_bulk_update_alert_summary';
|
||||
import { useAssistantContext } from '../../..';
|
||||
|
||||
jest.mock('../../..', () => ({
|
||||
useAssistantContext: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('useBulkUpdateAlertSummary', () => {
|
||||
const mockHttp = {
|
||||
fetch: jest.fn(),
|
||||
};
|
||||
const mockToasts = {
|
||||
addDanger: jest.fn(),
|
||||
addError: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
(useAssistantContext as jest.Mock).mockReturnValue({
|
||||
http: mockHttp,
|
||||
toasts: mockToasts,
|
||||
});
|
||||
});
|
||||
|
||||
it('should call the API with the correct parameters', async () => {
|
||||
const { result } = renderHook(() => useBulkUpdateAlertSummary());
|
||||
|
||||
const alertSummary = { update: [], create: [] };
|
||||
mockHttp.fetch.mockResolvedValue({ success: true });
|
||||
|
||||
await act(async () => {
|
||||
await result.current.bulkUpdate({ alertSummary });
|
||||
});
|
||||
|
||||
expect(mockHttp.fetch).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify(alertSummary),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle API errors and show a toast message', async () => {
|
||||
const { result } = renderHook(() => useBulkUpdateAlertSummary());
|
||||
|
||||
const alertSummary = { update: [], create: [] };
|
||||
const errorMessage = 'API error';
|
||||
mockHttp.fetch.mockRejectedValue(new Error(errorMessage));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.bulkUpdate({ alertSummary });
|
||||
});
|
||||
|
||||
expect(mockToasts.addDanger).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Failed to update alert summaries')
|
||||
);
|
||||
});
|
||||
|
||||
it('should abort the request when abortStream is called', () => {
|
||||
const { result } = renderHook(() => useBulkUpdateAlertSummary());
|
||||
|
||||
act(() => {
|
||||
result.current.abortStream();
|
||||
});
|
||||
|
||||
expect(mockHttp.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
|
@ -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.
|
||||
*/
|
||||
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import { HttpSetup, IToasts } from '@kbn/core/public';
|
||||
import {
|
||||
API_VERSIONS,
|
||||
ELASTIC_AI_ASSISTANT_ALERT_SUMMARY_URL_BULK_ACTION,
|
||||
} from '@kbn/elastic-assistant-common';
|
||||
import {
|
||||
PerformAlertSummaryBulkActionRequestBody,
|
||||
PerformAlertSummaryBulkActionResponse,
|
||||
} from '@kbn/elastic-assistant-common/impl/schemas/alert_summary/bulk_crud_alert_summary_route.gen';
|
||||
import { useAssistantContext } from '../../..';
|
||||
|
||||
interface BulkUpdateAlertSummaryProps {
|
||||
alertSummary: PerformAlertSummaryBulkActionRequestBody;
|
||||
}
|
||||
|
||||
interface UseBulkUpdateAlertSummary {
|
||||
abortStream: () => void;
|
||||
isLoading: boolean;
|
||||
bulkUpdate: (
|
||||
props: BulkUpdateAlertSummaryProps
|
||||
) => Promise<PerformAlertSummaryBulkActionResponse | void>;
|
||||
}
|
||||
|
||||
export const useBulkUpdateAlertSummary = (): UseBulkUpdateAlertSummary => {
|
||||
const { http, toasts } = useAssistantContext();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const abortController = useRef(new AbortController());
|
||||
|
||||
const bulkUpdate = useCallback(
|
||||
async ({ alertSummary }: BulkUpdateAlertSummaryProps) => {
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
return await bulkUpdateAlertSummary({
|
||||
http,
|
||||
alertSummary,
|
||||
toasts,
|
||||
signal: abortController.current.signal,
|
||||
});
|
||||
} catch (error) {
|
||||
toasts?.addDanger(
|
||||
i18n.translate('xpack.elasticAssistant.alertSummary.bulkActionsError', {
|
||||
defaultMessage: 'Failed to update alert summaries: {error}',
|
||||
values: { error: error instanceof Error ? error.message : String(error) },
|
||||
})
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
},
|
||||
[http, toasts]
|
||||
);
|
||||
|
||||
const cancelRequest = useCallback(() => {
|
||||
abortController.current.abort();
|
||||
abortController.current = new AbortController();
|
||||
}, []);
|
||||
|
||||
return { isLoading, bulkUpdate, abortStream: cancelRequest };
|
||||
};
|
||||
|
||||
const bulkUpdateAlertSummary = async ({
|
||||
alertSummary,
|
||||
http,
|
||||
signal,
|
||||
}: {
|
||||
alertSummary: PerformAlertSummaryBulkActionRequestBody;
|
||||
http: HttpSetup;
|
||||
signal?: AbortSignal;
|
||||
toasts?: IToasts;
|
||||
}): Promise<PerformAlertSummaryBulkActionResponse | void> => {
|
||||
const result = await http.fetch<PerformAlertSummaryBulkActionResponse>(
|
||||
ELASTIC_AI_ASSISTANT_ALERT_SUMMARY_URL_BULK_ACTION,
|
||||
{
|
||||
method: 'POST',
|
||||
version: API_VERSIONS.internal.v1,
|
||||
body: JSON.stringify(alertSummary),
|
||||
signal,
|
||||
}
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
const serverError = result.attributes.errors
|
||||
?.map(
|
||||
(e) =>
|
||||
`${e.status_code ? `Error code: ${e.status_code}. ` : ''}Error message: ${
|
||||
e.message
|
||||
} for alert summaries ${e.alert_summaries.map((c) => c.id).join(', ')}`
|
||||
)
|
||||
.join(',\n');
|
||||
throw new Error(serverError);
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
|
@ -0,0 +1,68 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
import { useFetchAlertSummary } from './use_fetch_alert_summary';
|
||||
import { useAssistantContext } from '../../..';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import React, { ReactNode } from 'react';
|
||||
|
||||
jest.mock('../../..', () => ({
|
||||
useAssistantContext: jest.fn(),
|
||||
}));
|
||||
const args = {
|
||||
alertId: '12345',
|
||||
connectorId: '67890',
|
||||
};
|
||||
const createWrapper = () => {
|
||||
const queryClient = new QueryClient();
|
||||
// eslint-disable-next-line react/display-name
|
||||
return ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
const mockAlertSummary = {
|
||||
summary: "CPU utilization for host 'prod-web-01' exceeded 90% threshold, reaching 95%.",
|
||||
};
|
||||
describe('useFetchAlertSummary', () => {
|
||||
const mockHttp = {
|
||||
fetch: jest.fn(),
|
||||
};
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
(useAssistantContext as jest.Mock).mockReturnValue({
|
||||
http: mockHttp,
|
||||
assistantAvailability: {
|
||||
isAssistantEnabled: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should fetch alert summary successfully', async () => {
|
||||
mockHttp.fetch.mockResolvedValue({
|
||||
data: [mockAlertSummary],
|
||||
page: 1,
|
||||
perPage: 1,
|
||||
total: 1,
|
||||
prompt: 'Generate an alert summary!',
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useFetchAlertSummary(args), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
expect(mockHttp.fetch).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
method: 'GET',
|
||||
})
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(result.current.data.data).toHaveLength(1);
|
||||
expect(result.current.data.data[0].summary).toEqual(mockAlertSummary.summary);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,86 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { FindAlertSummaryResponse } from '@kbn/elastic-assistant-common/impl/schemas/alert_summary/find_alert_summary_route.gen';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
API_VERSIONS,
|
||||
ELASTIC_AI_ASSISTANT_ALERT_SUMMARY_URL_FIND,
|
||||
} from '@kbn/elastic-assistant-common';
|
||||
import { useAssistantContext } from '../../..';
|
||||
|
||||
export interface UseFetchAlertSummaryParams {
|
||||
signal?: AbortSignal | undefined;
|
||||
alertId: string;
|
||||
connectorId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* API call for fetching alert_summary for current spaceId
|
||||
*
|
||||
* @param {Object} options - The options object.
|
||||
* @param {string} options.alertId - alert id
|
||||
* @param {AbortSignal} [options.signal] - AbortSignal
|
||||
*
|
||||
* @returns {useQuery} hook for getting the status of the alert_summary
|
||||
*/
|
||||
|
||||
export const useFetchAlertSummary = ({
|
||||
alertId,
|
||||
connectorId,
|
||||
signal,
|
||||
}: UseFetchAlertSummaryParams) => {
|
||||
const {
|
||||
assistantAvailability: { isAssistantEnabled },
|
||||
http,
|
||||
} = useAssistantContext();
|
||||
|
||||
const QUERY = {
|
||||
page: 1,
|
||||
per_page: 1, // only fetching one alert summary
|
||||
filter: `alert_id:${alertId}`,
|
||||
connector_id: connectorId,
|
||||
};
|
||||
|
||||
const CACHING_KEYS = [
|
||||
ELASTIC_AI_ASSISTANT_ALERT_SUMMARY_URL_FIND,
|
||||
QUERY.page,
|
||||
QUERY.per_page,
|
||||
QUERY.filter,
|
||||
QUERY.connector_id,
|
||||
API_VERSIONS.internal.v1,
|
||||
];
|
||||
|
||||
return useQuery<FindAlertSummaryResponse, unknown, FindAlertSummaryResponse>(
|
||||
CACHING_KEYS,
|
||||
async () =>
|
||||
http.fetch(ELASTIC_AI_ASSISTANT_ALERT_SUMMARY_URL_FIND, {
|
||||
method: 'GET',
|
||||
version: API_VERSIONS.internal.v1,
|
||||
query: QUERY,
|
||||
signal,
|
||||
}),
|
||||
{
|
||||
initialData: {
|
||||
data: [],
|
||||
page: 1,
|
||||
perPage: 1,
|
||||
total: 0,
|
||||
prompt: '',
|
||||
},
|
||||
placeholderData: {
|
||||
data: [],
|
||||
page: 1,
|
||||
perPage: 1,
|
||||
total: 0,
|
||||
prompt: '',
|
||||
},
|
||||
keepPreviousData: true,
|
||||
enabled: isAssistantEnabled,
|
||||
}
|
||||
);
|
||||
};
|
|
@ -0,0 +1,75 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { EuiBadge, EuiFlexGroup, EuiFlexItem, EuiText, useEuiTheme } from '@elastic/eui';
|
||||
import { css } from '@emotion/react';
|
||||
import type { AttackDiscovery } from '@kbn/elastic-assistant-common';
|
||||
import React, { memo } from 'react';
|
||||
import { MiniAttackChain } from './mini_attack_chain';
|
||||
import * as i18n from './translations';
|
||||
|
||||
interface Props {
|
||||
attackDiscovery: AttackDiscovery;
|
||||
}
|
||||
|
||||
export const AttackDiscoveryDetails = memo(({ attackDiscovery }: Props) => {
|
||||
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="alertsLabel"
|
||||
size="xs"
|
||||
>
|
||||
{i18n.ALERTS}
|
||||
</EuiText>
|
||||
</EuiFlexItem>
|
||||
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiBadge color="danger" data-test-subj="alertsBadge">
|
||||
{attackDiscovery.alertIds.length}
|
||||
</EuiBadge>
|
||||
</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="attackChainLabel"
|
||||
size="xs"
|
||||
>
|
||||
{i18n.ATTACK_CHAIN}
|
||||
</EuiText>
|
||||
</EuiFlexItem>
|
||||
|
||||
<EuiFlexItem grow={false}>
|
||||
<MiniAttackChain attackDiscovery={attackDiscovery} />
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
);
|
||||
});
|
||||
|
||||
AttackDiscoveryDetails.displayName = 'AttackDiscoveryDetails';
|
|
@ -0,0 +1,97 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { AttackDiscoveryWidget } from '.';
|
||||
import { useAssistantContext } from '../../assistant_context';
|
||||
import { useFetchAttackDiscovery } from './use_fetch_attack_discovery';
|
||||
import * as i18n from './translations';
|
||||
|
||||
// Mock the custom hooks
|
||||
jest.mock('../../assistant_context', () => ({
|
||||
useAssistantContext: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('./use_fetch_attack_discovery', () => ({
|
||||
useFetchAttackDiscovery: jest.fn(),
|
||||
}));
|
||||
const mockData = {
|
||||
alertIds: ['alert-id-xyz789'],
|
||||
detailsMarkdown: `
|
||||
* Suspicious process \`process.name\`:\`rundll32.exe\` launched by \`process.parent.name\`:\`winword.exe\` on \`host.name\`:\`finance-ws-03\`.
|
||||
* Network connection initiated by \`process.name\`:\`rundll32.exe\` to \`destination.ip\`:\`203.0.113.25\` on \`destination.port\`:\`443\`.
|
||||
`,
|
||||
mitreAttackTactics: ['TA0002', 'TA0011'],
|
||||
summaryMarkdown:
|
||||
'Possible command and control activity initiated by `process.name`:`rundll32.exe` originating from `process.parent.name`:`winword.exe` on host `host.name`:`finance-ws-03`.',
|
||||
title: 'Suspicious Rundll32 Network Activity',
|
||||
};
|
||||
describe('AttackDiscoveryWidget', () => {
|
||||
const mockNavigateToApp = jest.fn();
|
||||
const mockHttp = {};
|
||||
const mockToasts = {};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
(useAssistantContext as jest.Mock).mockReturnValue({
|
||||
http: mockHttp,
|
||||
toasts: mockToasts,
|
||||
navigateToApp: mockNavigateToApp,
|
||||
});
|
||||
});
|
||||
|
||||
it('renders loading spinner when data is being fetched', () => {
|
||||
(useFetchAttackDiscovery as jest.Mock).mockReturnValue({
|
||||
isFetching: true,
|
||||
data: null,
|
||||
});
|
||||
|
||||
render(<AttackDiscoveryWidget />);
|
||||
|
||||
expect(screen.getByRole('progressbar')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders no results message when no data is available', () => {
|
||||
(useFetchAttackDiscovery as jest.Mock).mockReturnValue({
|
||||
isFetching: false,
|
||||
data: null,
|
||||
});
|
||||
|
||||
render(<AttackDiscoveryWidget />);
|
||||
|
||||
expect(screen.getByText(i18n.NO_RESULTS)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders attack discovery details when data is available', () => {
|
||||
(useFetchAttackDiscovery as jest.Mock).mockReturnValue({
|
||||
isFetching: false,
|
||||
data: mockData,
|
||||
});
|
||||
|
||||
render(<AttackDiscoveryWidget />);
|
||||
|
||||
expect(screen.getByText(mockData.title)).toBeInTheDocument();
|
||||
expect(screen.getByTestId('alertsBadge')).toHaveTextContent('1');
|
||||
});
|
||||
|
||||
it('navigates to attack discovery page when "View Details" button is clicked', () => {
|
||||
(useFetchAttackDiscovery as jest.Mock).mockReturnValue({
|
||||
isFetching: false,
|
||||
data: mockData,
|
||||
});
|
||||
|
||||
render(<AttackDiscoveryWidget />);
|
||||
|
||||
fireEvent.click(screen.getByTestId('attackDiscoveryViewDetails'));
|
||||
|
||||
expect(mockNavigateToApp).toHaveBeenCalledWith('security', {
|
||||
path: 'attack_discovery',
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,101 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import React, { memo, useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
EuiButtonEmpty,
|
||||
EuiLoadingSpinner,
|
||||
EuiPanel,
|
||||
EuiSpacer,
|
||||
EuiText,
|
||||
EuiTitle,
|
||||
useEuiTheme,
|
||||
} from '@elastic/eui';
|
||||
import { AttackDiscovery } from '@kbn/elastic-assistant-common';
|
||||
import { css } from '@emotion/react';
|
||||
import { useAssistantContext } from '../../assistant_context';
|
||||
import { AttackDiscoveryDetails } from './attack_discovery_details';
|
||||
import { useFetchAttackDiscovery } from './use_fetch_attack_discovery';
|
||||
import * as i18n from './translations';
|
||||
|
||||
interface Props {
|
||||
// TODO use alert id for attack discovery
|
||||
id?: string;
|
||||
}
|
||||
|
||||
export const AttackDiscoveryWidget = memo(({ id }: Props) => {
|
||||
const { http, toasts, navigateToApp } = useAssistantContext();
|
||||
const { euiTheme } = useEuiTheme();
|
||||
// TODO fetch by alert id, not connector id. Waiting for Andrew's API updates
|
||||
const connectorId = 'my-gpt4o-ai';
|
||||
const { isFetching, data } = useFetchAttackDiscovery({ connectorId, http, toasts });
|
||||
const [attackDiscovery, setAttackDiscovery] = useState<AttackDiscovery | null>(null);
|
||||
const handleNavigateToAttackDiscovery = useCallback(
|
||||
() =>
|
||||
navigateToApp('security', {
|
||||
path: 'attack_discovery',
|
||||
}),
|
||||
[navigateToApp]
|
||||
);
|
||||
useEffect(() => {
|
||||
if (data != null) {
|
||||
setAttackDiscovery(data);
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{isFetching ? (
|
||||
<EuiLoadingSpinner />
|
||||
) : attackDiscovery ? (
|
||||
<EuiPanel
|
||||
css={css`
|
||||
margin: ${euiTheme.size.s} 0;
|
||||
`}
|
||||
paddingSize="m"
|
||||
hasBorder
|
||||
>
|
||||
<EuiText color="subdued" size="s">
|
||||
<p>{i18n.ALERT_PART}</p>
|
||||
</EuiText>
|
||||
<EuiTitle size="xs">
|
||||
<h3>{attackDiscovery.title}</h3>
|
||||
</EuiTitle>
|
||||
<EuiSpacer size="xs" />
|
||||
<AttackDiscoveryDetails attackDiscovery={attackDiscovery} />
|
||||
<EuiButtonEmpty
|
||||
iconSide="right"
|
||||
iconType="popout"
|
||||
data-test-subj="attackDiscoveryViewDetails"
|
||||
onClick={handleNavigateToAttackDiscovery}
|
||||
css={css`
|
||||
padding: 0;
|
||||
`}
|
||||
>
|
||||
{i18n.VIEW_DETAILS}
|
||||
</EuiButtonEmpty>
|
||||
</EuiPanel>
|
||||
) : (
|
||||
<EuiPanel
|
||||
css={css`
|
||||
margin: ${euiTheme.size.s} 0;
|
||||
`}
|
||||
paddingSize="m"
|
||||
hasBorder
|
||||
>
|
||||
<EuiPanel color="subdued" hasBorder={true}>
|
||||
<EuiText size="s">
|
||||
<p>{i18n.NO_RESULTS}</p>
|
||||
</EuiText>
|
||||
</EuiPanel>
|
||||
</EuiPanel>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
AttackDiscoveryWidget.displayName = 'AttackDiscoveryWidget';
|
|
@ -0,0 +1,35 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { type AttackDiscovery, getTacticMetadata } from '@kbn/elastic-assistant-common';
|
||||
export const mockAttackDiscovery = {
|
||||
mitreAttackTactics: [
|
||||
'Initial Access',
|
||||
'Execution',
|
||||
'Persistence',
|
||||
'Privilege Escalation',
|
||||
'Credential Access',
|
||||
],
|
||||
} as unknown as AttackDiscovery;
|
||||
|
||||
import { MiniAttackChain } from './mini_attack_chain';
|
||||
|
||||
describe('MiniAttackChain', () => {
|
||||
it('displays the expected number of circles', () => {
|
||||
// get detected tactics from the attack discovery:
|
||||
const tacticMetadata = getTacticMetadata(mockAttackDiscovery);
|
||||
expect(tacticMetadata.length).toBeGreaterThan(0); // test pre-condition
|
||||
|
||||
render(<MiniAttackChain attackDiscovery={mockAttackDiscovery} />);
|
||||
|
||||
const circles = screen.getAllByTestId('circle');
|
||||
|
||||
expect(circles.length).toEqual(tacticMetadata.length);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,68 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { css } from '@emotion/react';
|
||||
import { EuiFlexGroup, EuiFlexItem, EuiText, EuiToolTip, useEuiTheme } from '@elastic/eui';
|
||||
import React, { memo, useMemo } from 'react';
|
||||
import type { AttackDiscovery } from '@kbn/elastic-assistant-common';
|
||||
import { getTacticMetadata } from '@kbn/elastic-assistant-common';
|
||||
import { ATTACK_CHAIN_TOOLTIP } from './translations';
|
||||
|
||||
interface Props {
|
||||
attackDiscovery: AttackDiscovery;
|
||||
}
|
||||
|
||||
export const MiniAttackChain = memo(({ attackDiscovery }: Props) => {
|
||||
const { euiTheme } = useEuiTheme();
|
||||
const tactics = useMemo(() => getTacticMetadata(attackDiscovery), [attackDiscovery]);
|
||||
const detectedTactics = useMemo(() => tactics.filter((tactic) => tactic.detected), [tactics]);
|
||||
|
||||
const detectedTacticsList = useMemo(
|
||||
() =>
|
||||
detectedTactics.map(({ name }) => (
|
||||
<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>
|
||||
);
|
||||
});
|
||||
|
||||
MiniAttackChain.displayName = 'MiniAttackChain';
|
|
@ -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 ALERT_PART = i18n.translate(
|
||||
'xpack.elasticAssistant.alertSummary.attackDiscovery.alertPart',
|
||||
{
|
||||
defaultMessage: 'This alert is part of a',
|
||||
}
|
||||
);
|
||||
|
||||
export const VIEW_DETAILS = i18n.translate(
|
||||
'xpack.elasticAssistant.alertSummary.attackDiscovery.viewDetails',
|
||||
{
|
||||
defaultMessage: 'View details in Attack Discovery',
|
||||
}
|
||||
);
|
||||
|
||||
export const ALERTS = i18n.translate('xpack.elasticAssistant.alertSummary.attackDiscovery.alerts', {
|
||||
defaultMessage: 'Alerts:',
|
||||
});
|
||||
|
||||
export const ATTACK_CHAIN = i18n.translate(
|
||||
'xpack.elasticAssistant.alertSummary.attackDiscovery.attackChainLabel',
|
||||
{
|
||||
defaultMessage: 'Attack chain:',
|
||||
}
|
||||
);
|
||||
export const ATTACK_CHAIN_TOOLTIP = (tacticsCount: number) =>
|
||||
i18n.translate(
|
||||
'xpack.elasticAssistant.alertSummary.attackDiscovery.miniAttackChain.attackChainTooltip',
|
||||
{
|
||||
defaultMessage:
|
||||
'{tacticsCount} {tacticsCount, plural, one {tactic was} other {tactics were}} identified in the attack:',
|
||||
values: { tacticsCount },
|
||||
}
|
||||
);
|
||||
|
||||
export const NO_RESULTS = i18n.translate(
|
||||
'xpack.elasticAssistant.alertSummary.attackDiscovery.noResults',
|
||||
{
|
||||
defaultMessage: 'Not part of any attack discoveries',
|
||||
}
|
||||
);
|
|
@ -0,0 +1,85 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import {
|
||||
API_VERSIONS,
|
||||
AttackDiscovery,
|
||||
AttackDiscoveryGetResponse,
|
||||
replaceNewlineLiterals,
|
||||
} from '@kbn/elastic-assistant-common';
|
||||
import type { UseQueryResult } from '@tanstack/react-query';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import type { HttpSetup, IHttpFetchError, ResponseErrorBody } from '@kbn/core-http-browser';
|
||||
import type { IToasts } from '@kbn/core-notifications-browser';
|
||||
import * as uuid from 'uuid';
|
||||
|
||||
const CAPABILITIES_QUERY_KEY = ['elastic-assistant', 'attack-discoveries'];
|
||||
|
||||
export interface UseAttackDiscoveryParams {
|
||||
connectorId: string;
|
||||
http: HttpSetup;
|
||||
toasts?: IToasts;
|
||||
}
|
||||
/**
|
||||
* Hook for getting an attack discovery for a given alert
|
||||
*
|
||||
* @param {Object} options - The options object.
|
||||
* @param {HttpSetup} options.http - HttpSetup
|
||||
* @param {IToasts} options.toasts - IToasts
|
||||
*
|
||||
* @returns {useQuery} hook for getting the status of the Knowledge Base
|
||||
*/
|
||||
export const useFetchAttackDiscovery = ({
|
||||
// TODO use alertId instead of connectorId
|
||||
connectorId,
|
||||
http,
|
||||
toasts,
|
||||
}: UseAttackDiscoveryParams): UseQueryResult<AttackDiscovery | null, IHttpFetchError> => {
|
||||
return useQuery({
|
||||
queryKey: CAPABILITIES_QUERY_KEY,
|
||||
queryFn: async ({ signal }) => {
|
||||
return http.fetch(`/internal/elastic_assistant/attack_discovery/${connectorId}`, {
|
||||
method: 'GET',
|
||||
version: API_VERSIONS.internal.v1,
|
||||
signal,
|
||||
});
|
||||
},
|
||||
retry: false,
|
||||
refetchOnWindowFocus: false,
|
||||
keepPreviousData: true,
|
||||
// Deprecated, hoist to `queryCache` w/in `QueryClient. See: https://stackoverflow.com/a/76961109
|
||||
onError: (error: IHttpFetchError<ResponseErrorBody>) => {
|
||||
if (error.name !== 'AbortError') {
|
||||
toasts?.addError(error.body && error.body.message ? new Error(error.body.message) : error, {
|
||||
title: i18n.translate('xpack.elasticAssistant.attackDiscovery.statusError', {
|
||||
defaultMessage: 'Error fetching attack discoveries',
|
||||
}),
|
||||
});
|
||||
}
|
||||
},
|
||||
select: (rawResponse: AttackDiscoveryGetResponse) => {
|
||||
const parsedResponse = AttackDiscoveryGetResponse.safeParse(rawResponse);
|
||||
if (!parsedResponse.success) {
|
||||
throw new Error('Failed to parse the attack discovery GET response');
|
||||
}
|
||||
const attackDiscovery = parsedResponse.data.data?.attackDiscoveries[0] ?? null;
|
||||
|
||||
return attackDiscovery != null
|
||||
? {
|
||||
...attackDiscovery,
|
||||
id: attackDiscovery.id ?? uuid.v4(),
|
||||
detailsMarkdown: replaceNewlineLiterals(attackDiscovery.detailsMarkdown),
|
||||
entitySummaryMarkdown: replaceNewlineLiterals(
|
||||
attackDiscovery.entitySummaryMarkdown ?? ''
|
||||
),
|
||||
summaryMarkdown: replaceNewlineLiterals(attackDiscovery.summaryMarkdown),
|
||||
}
|
||||
: null;
|
||||
},
|
||||
});
|
||||
};
|
|
@ -0,0 +1,98 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { Conversations } from './conversations';
|
||||
import { useFetchCurrentUserConversations } from '../assistant/api';
|
||||
import { useAssistantContext } from '../assistant_context';
|
||||
|
||||
// Mock the custom hooks
|
||||
jest.mock('../assistant/api', () => ({
|
||||
useFetchCurrentUserConversations: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../assistant_context', () => ({
|
||||
useAssistantContext: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('Conversations', () => {
|
||||
const mockShowAssistantOverlay = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
(useAssistantContext as jest.Mock).mockReturnValue({
|
||||
euiTheme: { colors: { textPrimary: '#000' } },
|
||||
http: {},
|
||||
assistantAvailability: { isAssistantEnabled: true },
|
||||
showAssistantOverlay: mockShowAssistantOverlay,
|
||||
});
|
||||
});
|
||||
|
||||
it('renders loading state when conversations are not loaded', () => {
|
||||
(useFetchCurrentUserConversations as jest.Mock).mockReturnValue({
|
||||
data: {},
|
||||
isFetched: false,
|
||||
});
|
||||
|
||||
render(<Conversations id="test-id" />);
|
||||
|
||||
expect(screen.getByTestId('loading-skeleton')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders conversations when loaded', () => {
|
||||
(useFetchCurrentUserConversations as jest.Mock).mockReturnValue({
|
||||
data: {
|
||||
conversation1: { id: 'conversation1', title: 'Conversation 1' },
|
||||
conversation2: { id: 'conversation2', title: 'Conversation 2' },
|
||||
},
|
||||
isFetched: true,
|
||||
});
|
||||
|
||||
render(<Conversations id="test-id" />);
|
||||
|
||||
expect(screen.getByTestId('conversation-count')).toHaveTextContent('2');
|
||||
});
|
||||
|
||||
it('opens and closes the popover when the view button is clicked', () => {
|
||||
(useFetchCurrentUserConversations as jest.Mock).mockReturnValue({
|
||||
data: {
|
||||
conversation1: { id: 'conversation1', title: 'Conversation 1' },
|
||||
},
|
||||
isFetched: true,
|
||||
});
|
||||
|
||||
render(<Conversations id="test-id" />);
|
||||
|
||||
fireEvent.click(screen.getByTestId('view-conversations'));
|
||||
|
||||
expect(screen.getByText('Conversation 1')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByTestId('view-conversations'));
|
||||
expect(screen.queryByText('Conversation 1')).not.toBeVisible();
|
||||
});
|
||||
|
||||
it('calls showAssistantOverlay when a conversation is selected', () => {
|
||||
(useFetchCurrentUserConversations as jest.Mock).mockReturnValue({
|
||||
data: {
|
||||
conversation1: { id: 'conversation1', title: 'Conversation 1' },
|
||||
},
|
||||
isFetched: true,
|
||||
});
|
||||
|
||||
render(<Conversations id="test-id" />);
|
||||
|
||||
fireEvent.click(screen.getByTestId('view-conversations'));
|
||||
const conversationItem = screen.getByText('Conversation 1');
|
||||
fireEvent.click(conversationItem);
|
||||
|
||||
expect(mockShowAssistantOverlay).toHaveBeenCalledWith({
|
||||
showOverlay: true,
|
||||
selectedConversation: { id: 'conversation1' },
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,121 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import React, { memo, useCallback, useMemo, useState } from 'react';
|
||||
import {
|
||||
EuiBadge,
|
||||
EuiButtonEmpty,
|
||||
EuiContextMenuItem,
|
||||
EuiContextMenuPanel,
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiPanel,
|
||||
EuiPopover,
|
||||
EuiSkeletonText,
|
||||
EuiText,
|
||||
useEuiTheme,
|
||||
} from '@elastic/eui';
|
||||
import { css } from '@emotion/react';
|
||||
import { useFetchCurrentUserConversations } from '../assistant/api';
|
||||
import { useAssistantContext } from '../assistant_context';
|
||||
import * as i18n from './translations';
|
||||
|
||||
interface Props {
|
||||
id?: string;
|
||||
}
|
||||
|
||||
export const Conversations = memo(({ id }: Props) => {
|
||||
const { euiTheme } = useEuiTheme();
|
||||
const {
|
||||
http,
|
||||
assistantAvailability: { isAssistantEnabled },
|
||||
showAssistantOverlay,
|
||||
} = useAssistantContext();
|
||||
const { data: conversations, isFetched: conversationsLoaded } = useFetchCurrentUserConversations({
|
||||
http,
|
||||
isAssistantEnabled,
|
||||
filter: `messages:{ content : "${id}" }`,
|
||||
});
|
||||
const conversationCount = useMemo(() => Object.keys(conversations).length, [conversations]);
|
||||
|
||||
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
|
||||
const togglePopover = useCallback(() => setIsPopoverOpen(!isPopoverOpen), [isPopoverOpen]);
|
||||
const closePopover = useCallback(() => setIsPopoverOpen(false), []);
|
||||
const onSelectConversation = useCallback(
|
||||
(conversationId: string) => {
|
||||
closePopover();
|
||||
showAssistantOverlay({ showOverlay: true, selectedConversation: { id: conversationId } });
|
||||
},
|
||||
[closePopover, showAssistantOverlay]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<EuiPanel paddingSize="s" color="subdued" hasBorder={true}>
|
||||
{conversationsLoaded ? (
|
||||
<EuiFlexGroup alignItems="center" justifyContent="spaceBetween">
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiFlexGroup alignItems="center" gutterSize="s">
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiText size="s">
|
||||
<p>{i18n.YOUR_CONVERSATIONS}</p>
|
||||
</EuiText>
|
||||
</EuiFlexItem>
|
||||
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiBadge
|
||||
color="hollow"
|
||||
css={css`
|
||||
color: ${euiTheme.colors.textPrimary};
|
||||
`}
|
||||
data-test-subj="conversation-count"
|
||||
>
|
||||
{conversationCount}
|
||||
</EuiBadge>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiFlexItem>
|
||||
{conversationCount > 0 && (
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiPopover
|
||||
button={
|
||||
<EuiButtonEmpty
|
||||
data-test-subj="view-conversations"
|
||||
iconSide="right"
|
||||
iconType="arrowDown"
|
||||
onClick={togglePopover}
|
||||
>
|
||||
{i18n.VIEW}
|
||||
</EuiButtonEmpty>
|
||||
}
|
||||
isOpen={isPopoverOpen}
|
||||
closePopover={closePopover}
|
||||
anchorPosition="downRight"
|
||||
>
|
||||
<EuiContextMenuPanel>
|
||||
{Object.values(conversations).map((conversation) => (
|
||||
<EuiContextMenuItem
|
||||
key={conversation.id}
|
||||
onClick={() => onSelectConversation(conversation.id)}
|
||||
>
|
||||
{conversation.title}
|
||||
</EuiContextMenuItem>
|
||||
))}
|
||||
</EuiContextMenuPanel>
|
||||
</EuiPopover>
|
||||
</EuiFlexItem>
|
||||
)}
|
||||
</EuiFlexGroup>
|
||||
) : (
|
||||
<EuiSkeletonText data-test-subj="loading-skeleton" lines={1} size="xs" />
|
||||
)}
|
||||
</EuiPanel>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
Conversations.displayName = 'Conversations';
|
|
@ -0,0 +1,11 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
export { AlertSummary } from './alert_summary';
|
||||
export { SuggestedPrompts } from './suggested_prompts';
|
||||
export { AttackDiscoveryWidget } from './attack_discovery';
|
||||
export { Conversations } from './conversations';
|
|
@ -0,0 +1,45 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
import { EuiCodeBlock, EuiFlexGroup, EuiFlexItem, EuiPanel, useEuiTheme } from '@elastic/eui';
|
||||
import { css } from '@emotion/css';
|
||||
import React, { memo } from 'react';
|
||||
|
||||
export const CustomCodeBlock = memo(({ value, lang }: { value: string; lang: string }) => {
|
||||
const theme = useEuiTheme();
|
||||
|
||||
return (
|
||||
<EuiPanel
|
||||
hasShadow={false}
|
||||
hasBorder={false}
|
||||
paddingSize="s"
|
||||
className={css`
|
||||
background-color: ${theme.euiTheme.colors.lightestShade};
|
||||
|
||||
.euiCodeBlock__pre {
|
||||
margin-bottom: 0;
|
||||
padding: ${theme.euiTheme.size.m};
|
||||
min-block-size: 48px;
|
||||
}
|
||||
|
||||
.euiCodeBlock__controls {
|
||||
inset-block-start: ${theme.euiTheme.size.m};
|
||||
inset-inline-end: ${theme.euiTheme.size.m};
|
||||
}
|
||||
`}
|
||||
>
|
||||
<EuiFlexGroup direction="column" gutterSize="xs">
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiCodeBlock isCopyable fontSize="m" language={lang}>
|
||||
{value}
|
||||
</EuiCodeBlock>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiPanel>
|
||||
);
|
||||
});
|
||||
|
||||
CustomCodeBlock.displayName = 'CustomCodeBlock';
|
|
@ -0,0 +1,35 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license 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 { Node } from 'unist';
|
||||
import type { Parent } from 'mdast';
|
||||
|
||||
export const customCodeBlockLanguagePlugin = () => {
|
||||
const visitor = (node: Node) => {
|
||||
if ('children' in node) {
|
||||
const nodeAsParent = node as Parent;
|
||||
nodeAsParent.children.forEach((child) => {
|
||||
visitor(child);
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
node.type === 'code' &&
|
||||
(node.lang === 'eql' ||
|
||||
node.lang === 'esql' ||
|
||||
node.lang === 'kql' ||
|
||||
node.lang === 'dsl' ||
|
||||
node.lang === 'json')
|
||||
) {
|
||||
node.type = 'customCodeBlock';
|
||||
}
|
||||
};
|
||||
|
||||
return (tree: Node) => {
|
||||
visitor(tree);
|
||||
};
|
||||
};
|
|
@ -0,0 +1,96 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
import {
|
||||
EuiTable,
|
||||
EuiTableRow,
|
||||
EuiTableRowCell,
|
||||
EuiTableHeaderCell,
|
||||
EuiMarkdownFormat,
|
||||
EuiSpacer,
|
||||
EuiText,
|
||||
getDefaultEuiMarkdownParsingPlugins,
|
||||
getDefaultEuiMarkdownProcessingPlugins,
|
||||
} from '@elastic/eui';
|
||||
import { css } from '@emotion/react';
|
||||
import React, { useMemo } from 'react';
|
||||
import { customCodeBlockLanguagePlugin } from './custom_codeblock_markdown_plugin';
|
||||
import { CustomCodeBlock } from './custom_code_block';
|
||||
|
||||
interface Props {
|
||||
content: string;
|
||||
['data-test-subj']?: string;
|
||||
}
|
||||
|
||||
const getPluginDependencies = () => {
|
||||
const parsingPlugins = getDefaultEuiMarkdownParsingPlugins();
|
||||
|
||||
const processingPlugins = getDefaultEuiMarkdownProcessingPlugins();
|
||||
|
||||
const { components } = processingPlugins[1][1];
|
||||
|
||||
processingPlugins[1][1].components = {
|
||||
...components,
|
||||
contentReference: () => {
|
||||
return null;
|
||||
},
|
||||
customCodeBlock: (props) => {
|
||||
return (
|
||||
<>
|
||||
<CustomCodeBlock value={props.value} lang={props.lang} />
|
||||
<EuiSpacer size="m" />
|
||||
</>
|
||||
);
|
||||
},
|
||||
table: (props) => (
|
||||
<>
|
||||
<EuiTable {...props} />
|
||||
<EuiSpacer size="m" />
|
||||
</>
|
||||
),
|
||||
th: (props) => {
|
||||
const { children, ...rest } = props;
|
||||
return <EuiTableHeaderCell {...rest}>{children}</EuiTableHeaderCell>;
|
||||
},
|
||||
tr: (props) => <EuiTableRow {...props} />,
|
||||
td: (props) => {
|
||||
const { children, ...rest } = props;
|
||||
return (
|
||||
<EuiTableRowCell truncateText={true} {...rest}>
|
||||
{children}
|
||||
</EuiTableRowCell>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
parsingPluginList: [customCodeBlockLanguagePlugin, ...parsingPlugins],
|
||||
processingPluginList: processingPlugins,
|
||||
};
|
||||
};
|
||||
|
||||
// to be used for alert summary. For AI Assistant, use `x-pack/solutions/security/plugins/security_solution/public/assistant/get_comments/stream/message_text.tsx`
|
||||
// This component does not handle rendering of any content references, does not supply a loading cursor, and does not augmentMessageCodeBlocks
|
||||
export function MessageText({ content, 'data-test-subj': dataTestSubj }: Props) {
|
||||
const containerCss = css`
|
||||
overflow-wrap: anywhere;
|
||||
`;
|
||||
|
||||
const { parsingPluginList, processingPluginList } = useMemo(() => getPluginDependencies(), []);
|
||||
|
||||
return (
|
||||
<EuiText css={containerCss} data-test-subj={dataTestSubj}>
|
||||
<EuiMarkdownFormat
|
||||
data-test-subj={'messageText'}
|
||||
parsingPluginList={parsingPluginList}
|
||||
processingPluginList={processingPluginList}
|
||||
textSize="s"
|
||||
>
|
||||
{content}
|
||||
</EuiMarkdownFormat>
|
||||
</EuiText>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,78 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { SuggestedPrompts } from './suggested_prompts';
|
||||
import { useAssistantContext } from '../assistant_context';
|
||||
import { useAssistantOverlay } from '../assistant/use_assistant_overlay';
|
||||
|
||||
// Mock the custom hooks
|
||||
jest.mock('../assistant_context', () => ({
|
||||
useAssistantContext: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../assistant/use_assistant_overlay', () => ({
|
||||
useAssistantOverlay: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('SuggestedPrompts', () => {
|
||||
const mockShowAssistantOverlay = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
(useAssistantContext as jest.Mock).mockReturnValue({
|
||||
assistantAvailability: { isAssistantEnabled: true },
|
||||
});
|
||||
(useAssistantOverlay as jest.Mock).mockReturnValue({
|
||||
showAssistantOverlay: mockShowAssistantOverlay,
|
||||
});
|
||||
});
|
||||
|
||||
it('renders the suggested prompts', () => {
|
||||
render(
|
||||
<SuggestedPrompts
|
||||
getPromptContext={jest.fn()}
|
||||
ruleName="Test Rule"
|
||||
timestamp="2023-01-01T00:00:00Z"
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getAllByRole('button')).toHaveLength(3); // Assuming there are 3 prompts
|
||||
});
|
||||
|
||||
it('calls showAssistantOverlay when a prompt is clicked', () => {
|
||||
render(
|
||||
<SuggestedPrompts
|
||||
getPromptContext={jest.fn()}
|
||||
ruleName="Test Rule"
|
||||
timestamp="2023-01-01T00:00:00Z"
|
||||
/>
|
||||
);
|
||||
|
||||
const firstPromptButton = screen.getAllByRole('button')[0];
|
||||
fireEvent.click(firstPromptButton);
|
||||
|
||||
expect(mockShowAssistantOverlay).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('displays the correct title and description in the overlay', () => {
|
||||
render(
|
||||
<SuggestedPrompts
|
||||
getPromptContext={jest.fn()}
|
||||
ruleName="Test Rule"
|
||||
timestamp="2023-01-01T00:00:00Z"
|
||||
/>
|
||||
);
|
||||
|
||||
const firstPromptButton = screen.getAllByRole('button')[0];
|
||||
fireEvent.click(firstPromptButton);
|
||||
|
||||
expect(mockShowAssistantOverlay).toHaveBeenCalledWith(true);
|
||||
expect(mockShowAssistantOverlay).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,119 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import React, { memo, useCallback, useEffect, useState } from 'react';
|
||||
import { EuiButtonEmpty, EuiPanel, useEuiTheme } from '@elastic/eui';
|
||||
import { css } from '@emotion/react';
|
||||
import { useAssistantContext } from '../assistant_context';
|
||||
import { useAssistantOverlay } from '../assistant/use_assistant_overlay';
|
||||
import type { PromptContext } from '../assistant/prompt_context/types';
|
||||
import * as i18n from './translations';
|
||||
|
||||
interface Props {
|
||||
getPromptContext: PromptContext['getPromptContext'];
|
||||
ruleName: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
interface Prompt {
|
||||
icon: string;
|
||||
prompt: string;
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
// TODO update this copy, waiting on James Spiteri's input
|
||||
const prompts: Prompt[] = [
|
||||
{
|
||||
icon: 'bullseye',
|
||||
prompt: i18n.PROMPT_1_PROMPT,
|
||||
title: i18n.PROMPT_1_TITLE,
|
||||
description: i18n.PROMPT_1_DESCRIPTION,
|
||||
},
|
||||
{
|
||||
icon: 'cloudStormy',
|
||||
prompt: i18n.PROMPT_2_PROMPT,
|
||||
title: i18n.PROMPT_2_TITLE,
|
||||
description: i18n.PROMPT_2_DESCRIPTION,
|
||||
},
|
||||
{
|
||||
icon: 'database',
|
||||
prompt: i18n.PROMPT_3_PROMPT,
|
||||
title: i18n.PROMPT_3_TITLE,
|
||||
description: i18n.PROMPT_3_DESCRIPTION,
|
||||
},
|
||||
];
|
||||
export const SuggestedPrompts = memo(({ getPromptContext, ruleName, timestamp }: Props) => {
|
||||
const {
|
||||
assistantAvailability: { isAssistantEnabled },
|
||||
} = useAssistantContext();
|
||||
const { euiTheme } = useEuiTheme();
|
||||
const [promptOverlay, setPromptOverlay] = useState<Omit<Prompt, 'icon'> | null>(null);
|
||||
|
||||
const onClick = useCallback(
|
||||
(prompt: Prompt) => {
|
||||
setPromptOverlay({
|
||||
title: `${prompt.title}: ${ruleName} - ${timestamp}`,
|
||||
description: i18n.ALERT_FROM_FLYOUT,
|
||||
prompt: prompt.prompt,
|
||||
});
|
||||
},
|
||||
[ruleName, timestamp]
|
||||
);
|
||||
|
||||
const { showAssistantOverlay } = useAssistantOverlay(
|
||||
'alert',
|
||||
promptOverlay?.title ?? '',
|
||||
promptOverlay?.description ?? '',
|
||||
getPromptContext,
|
||||
null,
|
||||
promptOverlay?.prompt ?? '',
|
||||
i18n.SUGGESTED_PROMPTS_CONTEXT_TOOLTIP,
|
||||
isAssistantEnabled
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (promptOverlay !== null) {
|
||||
showAssistantOverlay(true);
|
||||
}
|
||||
}, [promptOverlay, showAssistantOverlay]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{prompts.map((prompt, index) => (
|
||||
<EuiPanel
|
||||
css={css`
|
||||
margin: ${euiTheme.size.xs} 0;
|
||||
`}
|
||||
key={index}
|
||||
paddingSize="m"
|
||||
hasBorder
|
||||
>
|
||||
<EuiButtonEmpty
|
||||
onClick={() => onClick(prompt)}
|
||||
flush="both"
|
||||
color="text"
|
||||
iconType={prompt.icon}
|
||||
css={css`
|
||||
svg {
|
||||
inline-size: 40px;
|
||||
block-size: 40px;
|
||||
padding-inline: 10px;
|
||||
background: ${euiTheme.colors.backgroundBaseDisabled};
|
||||
border-radius: 5px;
|
||||
}
|
||||
`}
|
||||
>
|
||||
{prompt.description}
|
||||
</EuiButtonEmpty>
|
||||
</EuiPanel>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
SuggestedPrompts.displayName = 'SuggestedPrompt';
|
|
@ -0,0 +1,148 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license 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';
|
||||
|
||||
// START SUMMARY
|
||||
|
||||
export const NO_SUMMARY_AVAILABLE = i18n.translate(
|
||||
'xpack.elasticAssistant.alertSummary.noSummaryAvailable',
|
||||
{
|
||||
defaultMessage: 'No summary available',
|
||||
}
|
||||
);
|
||||
|
||||
export const RECOMMENDED_ACTIONS = i18n.translate(
|
||||
'xpack.elasticAssistant.alertSummary.recommendedActions',
|
||||
{
|
||||
defaultMessage: 'Recommended actions',
|
||||
}
|
||||
);
|
||||
|
||||
export const GENERATING = i18n.translate('xpack.elasticAssistant.alertSummary.generating', {
|
||||
defaultMessage: 'Generating AI description and recommended actions.',
|
||||
});
|
||||
|
||||
export const GENERATE = i18n.translate('xpack.elasticAssistant.alertSummary.generate', {
|
||||
defaultMessage: 'Generate insights',
|
||||
});
|
||||
|
||||
export const REGENERATE = i18n.translate('xpack.elasticAssistant.alertSummary.regenerate', {
|
||||
defaultMessage: 'Regenerate insights',
|
||||
});
|
||||
|
||||
export const MISSING_CONNECTOR = i18n.translate(
|
||||
'xpack.elasticAssistant.alertSummary.missingConnector',
|
||||
{
|
||||
defaultMessage: 'Missing connector',
|
||||
}
|
||||
);
|
||||
|
||||
export const CONNECTOR_MISSING_MESSAGE = i18n.translate(
|
||||
'xpack.elasticAssistant.alertSummary.noConnectorMessage',
|
||||
{
|
||||
defaultMessage: 'Your default AI connector is invalid and may have been deleted.',
|
||||
}
|
||||
);
|
||||
|
||||
export const CONNECTOR_MISSING_MESSAGE_ADMIN = i18n.translate(
|
||||
'xpack.elasticAssistant.alertSummary.noConnectorMessageForAdmin',
|
||||
{
|
||||
defaultMessage:
|
||||
'Your default AI connector is invalid and may have been deleted. You may update the default AI connector via',
|
||||
}
|
||||
);
|
||||
|
||||
export const ADVANCED_SETTINGS_LINK_TITLE = i18n.translate(
|
||||
'xpack.elasticAssistant.alertSummary.advancedSettingsLinkTitle',
|
||||
{
|
||||
defaultMessage: 'Security Solution advanced settings',
|
||||
}
|
||||
);
|
||||
|
||||
// END SUMMARY
|
||||
|
||||
// START SUGGESTED PROMPTS
|
||||
|
||||
export const ALERT_FROM_FLYOUT = i18n.translate(
|
||||
'xpack.elasticAssistant.alertSummary.alertFromFlyout',
|
||||
{
|
||||
defaultMessage: 'Alert (from flyout)',
|
||||
}
|
||||
);
|
||||
|
||||
export const PROMPT_1_TITLE = i18n.translate('xpack.elasticAssistant.alertSummary.prompt1Title', {
|
||||
defaultMessage: 'Detailed Alert Analysis',
|
||||
});
|
||||
|
||||
export const PROMPT_1_DESCRIPTION = i18n.translate(
|
||||
'xpack.elasticAssistant.alertSummary.prompt1Description',
|
||||
{
|
||||
defaultMessage: 'Dive deeper into what happened with this alert.',
|
||||
}
|
||||
);
|
||||
|
||||
export const PROMPT_1_PROMPT = i18n.translate('xpack.elasticAssistant.alertSummary.prompt1Prompt', {
|
||||
defaultMessage:
|
||||
"Provide a thorough breakdown of this alert, including the attack technique, potential impact, and risk assessment. Explain the technical details in a way that's immediately actionable",
|
||||
});
|
||||
|
||||
export const PROMPT_2_TITLE = i18n.translate('xpack.elasticAssistant.alertSummary.prompt2Title', {
|
||||
defaultMessage: 'Best practices for noisy alerts',
|
||||
});
|
||||
|
||||
export const PROMPT_2_DESCRIPTION = i18n.translate(
|
||||
'xpack.elasticAssistant.alertSummary.prompt2Description',
|
||||
{
|
||||
defaultMessage: 'Find Related Threat Intelligence Articles from Elastic Security Labs.',
|
||||
}
|
||||
);
|
||||
|
||||
export const PROMPT_2_PROMPT = i18n.translate('xpack.elasticAssistant.alertSummary.prompt2Prompt', {
|
||||
defaultMessage:
|
||||
'Can you provide relevant Elastic Security Labs intelligence about the threat indicators or techniques in this alert? Include any known threat actors, campaigns, or similar attack patterns documented in ESL research.',
|
||||
});
|
||||
|
||||
export const PROMPT_3_TITLE = i18n.translate('xpack.elasticAssistant.alertSummary.prompt3Title', {
|
||||
defaultMessage: 'Alert Remediation Strategy',
|
||||
});
|
||||
|
||||
export const PROMPT_3_DESCRIPTION = i18n.translate(
|
||||
'xpack.elasticAssistant.alertSummary.prompt3Description',
|
||||
{
|
||||
defaultMessage: 'Generate Step-by-Step Remediation Plan.',
|
||||
}
|
||||
);
|
||||
|
||||
export const PROMPT_3_PROMPT = i18n.translate('xpack.elasticAssistant.alertSummary.prompt3Prompt', {
|
||||
defaultMessage:
|
||||
'Based on this alert, please outline a comprehensive remediation plan including immediate containment steps, investigation actions, and long-term mitigation strategies to prevent similar incidents.',
|
||||
});
|
||||
|
||||
export const SUGGESTED_PROMPTS_CONTEXT_TOOLTIP = i18n.translate(
|
||||
'xpack.elasticAssistant.alertSummary.suggestedPromptsContextTooltip',
|
||||
{
|
||||
defaultMessage: 'Add this alert as context.',
|
||||
}
|
||||
);
|
||||
|
||||
// END SUGGESTED PROMPTS
|
||||
|
||||
// START AI ASSISTANT
|
||||
|
||||
export const YOUR_CONVERSATIONS = i18n.translate(
|
||||
'xpack.elasticAssistant.aiAssistant.yourConversations',
|
||||
{
|
||||
defaultMessage: 'Your conversations',
|
||||
}
|
||||
);
|
||||
|
||||
export const VIEW = i18n.translate('xpack.elasticAssistant.aiAssistant.view', {
|
||||
defaultMessage: 'View',
|
||||
});
|
||||
|
||||
// END AI ASSISTANT
|
|
@ -0,0 +1,168 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
import { postChatComplete, PostChatCompleteParams } from './post_chat_complete';
|
||||
import { HttpSetup } from '@kbn/core-http-browser';
|
||||
import { API_VERSIONS } from '@kbn/elastic-assistant-common';
|
||||
|
||||
const mockHttpFetch = jest.fn();
|
||||
|
||||
const mockHttp: HttpSetup = {
|
||||
fetch: mockHttpFetch,
|
||||
} as unknown as HttpSetup;
|
||||
|
||||
describe('postChatComplete', () => {
|
||||
const defaultParams: PostChatCompleteParams = {
|
||||
actionTypeId: '.gen-ai',
|
||||
connectorId: 'mock-connector-id',
|
||||
http: mockHttp,
|
||||
message: 'test message',
|
||||
replacements: {},
|
||||
signal: undefined,
|
||||
query: undefined,
|
||||
traceOptions: undefined,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should return a successful response when the API call succeeds', async () => {
|
||||
const mockResponse = {
|
||||
status: 'ok',
|
||||
data: 'mock-response',
|
||||
trace_data: {
|
||||
transaction_id: 'mock-transaction-id',
|
||||
trace_id: 'mock-trace-id',
|
||||
},
|
||||
};
|
||||
|
||||
mockHttpFetch.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await postChatComplete(defaultParams);
|
||||
|
||||
expect(mockHttpFetch).toHaveBeenCalledWith(
|
||||
'/internal/elastic_assistant/actions/connector/mock-connector-id/_execute',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
actionTypeId: '.gen-ai',
|
||||
alertsIndexPattern: undefined,
|
||||
langSmithProject: undefined,
|
||||
langSmithApiKey: undefined,
|
||||
message: 'test message',
|
||||
promptIds: undefined,
|
||||
replacements: {},
|
||||
subAction: 'invokeAI',
|
||||
}),
|
||||
signal: undefined,
|
||||
query: undefined,
|
||||
version: API_VERSIONS.internal.v1,
|
||||
})
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
response: 'mock-response',
|
||||
isError: false,
|
||||
isStream: false,
|
||||
traceData: {
|
||||
transactionId: 'mock-transaction-id',
|
||||
traceId: 'mock-trace-id',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should return an error response when the API call fails', async () => {
|
||||
const mockError = new Error('API error');
|
||||
mockHttpFetch.mockRejectedValue(mockError);
|
||||
|
||||
const result = await postChatComplete(defaultParams);
|
||||
|
||||
expect(result).toEqual({
|
||||
response: 'An error occurred sending your message.\n\nAPI error',
|
||||
isError: true,
|
||||
isStream: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return an error response when the API response status is not "ok"', async () => {
|
||||
const mockResponse = {
|
||||
status: 'error',
|
||||
service_message: 'Service error message',
|
||||
};
|
||||
|
||||
mockHttpFetch.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await postChatComplete(defaultParams);
|
||||
|
||||
expect(result).toEqual({
|
||||
response: 'An error occurred sending your message.\n\nService error message',
|
||||
isError: true,
|
||||
isStream: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle missing trace data gracefully', async () => {
|
||||
const mockResponse = {
|
||||
status: 'ok',
|
||||
data: 'mock-response',
|
||||
};
|
||||
|
||||
mockHttpFetch.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await postChatComplete(defaultParams);
|
||||
|
||||
expect(result).toEqual({
|
||||
response: 'mock-response',
|
||||
isError: false,
|
||||
isStream: false,
|
||||
traceData: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('should include trace options in the request body if provided', async () => {
|
||||
const paramsWithTraceOptions: PostChatCompleteParams = {
|
||||
...defaultParams,
|
||||
traceOptions: {
|
||||
langSmithProject: 'mock-project',
|
||||
langSmithApiKey: 'mock-api-key',
|
||||
apmUrl: 'mock-apm-url',
|
||||
},
|
||||
};
|
||||
|
||||
const mockResponse = {
|
||||
status: 'ok',
|
||||
data: 'mock-response',
|
||||
};
|
||||
|
||||
mockHttpFetch.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await postChatComplete(paramsWithTraceOptions);
|
||||
|
||||
expect(mockHttpFetch).toHaveBeenCalledWith(
|
||||
'/internal/elastic_assistant/actions/connector/mock-connector-id/_execute',
|
||||
expect.objectContaining({
|
||||
body: JSON.stringify({
|
||||
actionTypeId: '.gen-ai',
|
||||
alertsIndexPattern: undefined,
|
||||
langSmithProject: 'mock-project',
|
||||
langSmithApiKey: 'mock-api-key',
|
||||
message: 'test message',
|
||||
promptIds: undefined,
|
||||
replacements: {},
|
||||
subAction: 'invokeAI',
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
response: 'mock-response',
|
||||
isError: false,
|
||||
isStream: false,
|
||||
traceData: undefined,
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,122 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { HttpFetchQuery, HttpSetup } from '@kbn/core-http-browser';
|
||||
import {
|
||||
API_VERSIONS,
|
||||
MessageMetadata,
|
||||
PromptIds,
|
||||
Replacements,
|
||||
} from '@kbn/elastic-assistant-common';
|
||||
import { TraceOptions } from '../../types';
|
||||
import { API_ERROR } from '../../translations';
|
||||
|
||||
export interface PostChatCompleteParams {
|
||||
actionTypeId: string;
|
||||
alertsIndexPattern?: string;
|
||||
connectorId: string;
|
||||
http: HttpSetup;
|
||||
message: string;
|
||||
promptIds?: PromptIds;
|
||||
replacements: Replacements;
|
||||
query?: HttpFetchQuery;
|
||||
signal?: AbortSignal | undefined;
|
||||
traceOptions?: TraceOptions;
|
||||
}
|
||||
export interface ChatCompleteResponse {
|
||||
response: string;
|
||||
isError: boolean;
|
||||
isStream: boolean;
|
||||
traceData?: {
|
||||
transactionId: string;
|
||||
traceId: string;
|
||||
};
|
||||
metadata?: MessageMetadata;
|
||||
}
|
||||
export const postChatComplete = async ({
|
||||
actionTypeId,
|
||||
alertsIndexPattern,
|
||||
connectorId,
|
||||
http,
|
||||
message,
|
||||
promptIds,
|
||||
replacements,
|
||||
query,
|
||||
signal,
|
||||
traceOptions,
|
||||
}: PostChatCompleteParams): Promise<ChatCompleteResponse> => {
|
||||
try {
|
||||
const path = `/internal/elastic_assistant/actions/connector/${connectorId}/_execute`;
|
||||
const requestBody = {
|
||||
actionTypeId,
|
||||
alertsIndexPattern,
|
||||
langSmithProject:
|
||||
traceOptions?.langSmithProject === '' ? undefined : traceOptions?.langSmithProject,
|
||||
langSmithApiKey:
|
||||
traceOptions?.langSmithApiKey === '' ? undefined : traceOptions?.langSmithApiKey,
|
||||
message,
|
||||
promptIds,
|
||||
replacements,
|
||||
subAction: 'invokeAI',
|
||||
};
|
||||
const response = await http.fetch<{
|
||||
connector_id: string;
|
||||
data: string;
|
||||
metadata?: MessageMetadata;
|
||||
replacements?: Replacements;
|
||||
service_message?: string;
|
||||
status: string;
|
||||
trace_data?: {
|
||||
transaction_id: string;
|
||||
trace_id: string;
|
||||
};
|
||||
}>(path, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(requestBody),
|
||||
signal,
|
||||
query,
|
||||
version: API_VERSIONS.internal.v1,
|
||||
});
|
||||
if (response.status !== 'ok' || !response.data) {
|
||||
if (response.service_message) {
|
||||
return {
|
||||
response: `${API_ERROR}\n\n${response.service_message}`,
|
||||
isError: true,
|
||||
isStream: false,
|
||||
};
|
||||
}
|
||||
return {
|
||||
response: API_ERROR,
|
||||
isError: true,
|
||||
isStream: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Only add traceData if it exists in the response
|
||||
const traceData =
|
||||
response.trace_data?.trace_id != null && response.trace_data?.transaction_id != null
|
||||
? {
|
||||
traceId: response.trace_data?.trace_id,
|
||||
transactionId: response.trace_data?.transaction_id,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
response: response.data,
|
||||
metadata: response.metadata,
|
||||
isError: false,
|
||||
isStream: false,
|
||||
traceData,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
response: `${API_ERROR}\n\n${error?.body?.message ?? error?.message}`,
|
||||
isError: true,
|
||||
isStream: false,
|
||||
};
|
||||
}
|
||||
};
|
|
@ -0,0 +1,113 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { renderHook, act, waitFor } from '@testing-library/react';
|
||||
import { useChatComplete } from './use_chat_complete';
|
||||
import { useAssistantContext, useLoadConnectors } from '../../../..';
|
||||
import { postChatComplete, ChatCompleteResponse } from './post_chat_complete';
|
||||
|
||||
jest.mock('../../../..', () => ({
|
||||
useAssistantContext: jest.fn(),
|
||||
useLoadConnectors: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('./post_chat_complete', () => ({
|
||||
postChatComplete: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('useChatComplete', () => {
|
||||
const mockAbortController = {
|
||||
abort: jest.fn(),
|
||||
signal: {},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
global.AbortController = jest.fn(
|
||||
() => mockAbortController
|
||||
) as unknown as typeof AbortController;
|
||||
|
||||
(useAssistantContext as jest.Mock).mockReturnValue({
|
||||
alertsIndexPattern: 'mock-alerts-index-pattern',
|
||||
http: {},
|
||||
traceOptions: {},
|
||||
});
|
||||
|
||||
(useLoadConnectors as jest.Mock).mockReturnValue({
|
||||
data: [{ id: 'mock-connector-id', actionTypeId: '.gen-ai' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('should initialize with default values', () => {
|
||||
const { result } = renderHook(() => useChatComplete({ connectorId: 'mock-connector-id' }));
|
||||
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
expect(typeof result.current.sendMessage).toBe('function');
|
||||
expect(typeof result.current.abortStream).toBe('function');
|
||||
});
|
||||
|
||||
it('should call postChatComplete when sendMessage is invoked', async () => {
|
||||
const mockResponse = { data: 'mock-response' };
|
||||
(postChatComplete as jest.Mock).mockResolvedValue(mockResponse);
|
||||
|
||||
const { result } = renderHook(() => useChatComplete({ connectorId: 'mock-connector-id' }));
|
||||
|
||||
await act(async () => {
|
||||
const response = await result.current.sendMessage({
|
||||
message: 'test message',
|
||||
replacements: {},
|
||||
});
|
||||
|
||||
expect(postChatComplete).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
actionTypeId: '.gen-ai',
|
||||
connectorId: 'mock-connector-id',
|
||||
message: 'test message',
|
||||
})
|
||||
);
|
||||
expect(response).toEqual(mockResponse);
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle abortStream correctly', () => {
|
||||
const { result } = renderHook(() => useChatComplete({ connectorId: 'mock-connector-id' }));
|
||||
|
||||
act(() => {
|
||||
result.current.abortStream();
|
||||
});
|
||||
|
||||
expect(mockAbortController.abort).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should set isLoading to true while sending a message and false after completion', async () => {
|
||||
(postChatComplete as jest.Mock).mockResolvedValue({});
|
||||
|
||||
const { result } = renderHook(() => useChatComplete({ connectorId: 'mock-connector-id' }));
|
||||
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
|
||||
let sendMessagePromise: Promise<ChatCompleteResponse>;
|
||||
|
||||
await act(async () => {
|
||||
sendMessagePromise = result.current.sendMessage({
|
||||
message: 'test message',
|
||||
replacements: {},
|
||||
});
|
||||
|
||||
// Wait until isLoading becomes true
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await sendMessagePromise;
|
||||
});
|
||||
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
});
|
|
@ -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 { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { INVOKE_LLM_CLIENT_TIMEOUT, PromptIds, Replacements } from '@kbn/elastic-assistant-common';
|
||||
import { HttpFetchQuery } from '@kbn/core-http-browser';
|
||||
import { ChatCompleteResponse, postChatComplete } from './post_chat_complete';
|
||||
import { useAssistantContext, useLoadConnectors } from '../../../..';
|
||||
import { FETCH_MESSAGE_TIMEOUT_ERROR } from '../../use_send_message/translations';
|
||||
|
||||
interface SendMessageProps {
|
||||
message: string;
|
||||
promptIds?: PromptIds;
|
||||
replacements: Replacements;
|
||||
query?: HttpFetchQuery;
|
||||
}
|
||||
interface UseChatComplete {
|
||||
abortStream: () => void;
|
||||
isLoading: boolean;
|
||||
sendMessage: (props: SendMessageProps) => Promise<ChatCompleteResponse>;
|
||||
}
|
||||
|
||||
// useChatComplete uses the same api as useSendMessage (post_actions_connector_execute) but without requiring conversationId/apiConfig
|
||||
// it is meant to be used for one-off messages that don't require a conversation
|
||||
export const useChatComplete = ({ connectorId }: { connectorId: string }): UseChatComplete => {
|
||||
const { alertsIndexPattern, http, traceOptions } = useAssistantContext();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const abortController = useRef(new AbortController());
|
||||
const { data: connectors } = useLoadConnectors({ http, inferenceEnabled: true });
|
||||
const actionTypeId = useMemo(
|
||||
() => connectors?.find(({ id }) => id === connectorId)?.actionTypeId ?? '.gen-ai',
|
||||
[connectors, connectorId]
|
||||
);
|
||||
const sendMessage = useCallback(
|
||||
async ({ message, promptIds, replacements, query }: SendMessageProps) => {
|
||||
setIsLoading(true);
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
abortController.current.abort(FETCH_MESSAGE_TIMEOUT_ERROR);
|
||||
abortController.current = new AbortController();
|
||||
}, INVOKE_LLM_CLIENT_TIMEOUT);
|
||||
|
||||
try {
|
||||
return await postChatComplete({
|
||||
actionTypeId,
|
||||
alertsIndexPattern,
|
||||
connectorId,
|
||||
http,
|
||||
message,
|
||||
promptIds,
|
||||
replacements,
|
||||
query,
|
||||
signal: abortController.current.signal,
|
||||
traceOptions,
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
setIsLoading(false);
|
||||
}
|
||||
},
|
||||
[actionTypeId, alertsIndexPattern, connectorId, http, traceOptions]
|
||||
);
|
||||
|
||||
const cancelRequest = useCallback(() => {
|
||||
abortController.current.abort();
|
||||
abortController.current = new AbortController();
|
||||
}, []);
|
||||
|
||||
return { isLoading, sendMessage, abortStream: cancelRequest };
|
||||
};
|
|
@ -7,7 +7,7 @@
|
|||
|
||||
import { HttpSetup } from '@kbn/core-http-browser';
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import { ApiConfig, INVOKE_LL_CLIENT_TIMEOUT, Replacements } from '@kbn/elastic-assistant-common';
|
||||
import { ApiConfig, INVOKE_LLM_CLIENT_TIMEOUT, Replacements } from '@kbn/elastic-assistant-common';
|
||||
import moment from 'moment';
|
||||
import { useAssistantContext } from '../../assistant_context';
|
||||
import { fetchConnectorExecuteAction, FetchConnectorExecuteResponse } from '../api';
|
||||
|
@ -43,7 +43,7 @@ export const useSendMessage = (): UseSendMessage => {
|
|||
const timeoutId = setTimeout(() => {
|
||||
abortController.current.abort(i18n.FETCH_MESSAGE_TIMEOUT_ERROR);
|
||||
abortController.current = new AbortController();
|
||||
}, INVOKE_LL_CLIENT_TIMEOUT);
|
||||
}, INVOKE_LLM_CLIENT_TIMEOUT);
|
||||
|
||||
try {
|
||||
return await fetchConnectorExecuteAction({
|
||||
|
|
|
@ -18,7 +18,6 @@ export async function getNewSelectedPromptContext({
|
|||
promptContext: PromptContext;
|
||||
}): Promise<SelectedPromptContext> {
|
||||
const rawData = await promptContext.getPromptContext();
|
||||
|
||||
if (typeof rawData === 'string') {
|
||||
return {
|
||||
contextAnonymizationFields: undefined,
|
||||
|
|
|
@ -169,3 +169,10 @@ export {
|
|||
/** Your anonymization settings will apply to these alerts (label) */
|
||||
YOUR_ANONYMIZATION_SETTINGS,
|
||||
} from './impl/knowledge_base/translations';
|
||||
|
||||
export {
|
||||
AlertSummary,
|
||||
Conversations,
|
||||
SuggestedPrompts,
|
||||
AttackDiscoveryWidget,
|
||||
} from './impl/alerts';
|
||||
|
|
|
@ -43,6 +43,6 @@
|
|||
"@kbn/shared-ux-router",
|
||||
"@kbn/inference-endpoint-ui-common",
|
||||
"@kbn/datemath",
|
||||
"@kbn/alerts-ui-shared",
|
||||
"@kbn/alerts-ui-shared"
|
||||
]
|
||||
}
|
||||
|
|
|
@ -0,0 +1,118 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import type { estypes } from '@elastic/elasticsearch';
|
||||
import { EsAlertSummarySchema } from '../ai_assistant_data_clients/alert_summary/types';
|
||||
import {
|
||||
PerformAlertSummaryBulkActionRequestBody,
|
||||
AlertSummaryCreateProps,
|
||||
AlertSummaryResponse,
|
||||
AlertSummaryUpdateProps,
|
||||
} from '@kbn/elastic-assistant-common/impl/schemas/alert_summary/bulk_crud_alert_summary_route.gen';
|
||||
export const mockEsAlertSummarySchema = {
|
||||
'@timestamp': '2019-12-13T16:40:33.400Z',
|
||||
created_at: '2019-12-13T16:40:33.400Z',
|
||||
updated_at: '2019-12-13T16:40:33.400Z',
|
||||
namespace: 'default',
|
||||
id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd',
|
||||
summary: 'test content',
|
||||
recommended_actions: 'do something',
|
||||
alert_id: '1234',
|
||||
replacements: [],
|
||||
created_by: 'elastic',
|
||||
updated_by: 'elastic',
|
||||
users: [
|
||||
{
|
||||
id: 'user-id-1',
|
||||
name: 'elastic',
|
||||
},
|
||||
],
|
||||
};
|
||||
export const getAlertSummarySearchEsMock = () => {
|
||||
const searchResponse: estypes.SearchResponse<EsAlertSummarySchema> = {
|
||||
took: 3,
|
||||
timed_out: false,
|
||||
_shards: {
|
||||
total: 2,
|
||||
successful: 2,
|
||||
skipped: 0,
|
||||
failed: 0,
|
||||
},
|
||||
hits: {
|
||||
total: {
|
||||
value: 1,
|
||||
relation: 'eq',
|
||||
},
|
||||
max_score: 0,
|
||||
hits: [
|
||||
{
|
||||
_index: 'foo',
|
||||
_id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd',
|
||||
_source: mockEsAlertSummarySchema,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
return searchResponse;
|
||||
};
|
||||
|
||||
export const getCreateAlertSummarySchemaMock = (): AlertSummaryCreateProps => ({
|
||||
alertId: '1234',
|
||||
summary: 'test content',
|
||||
replacements: {},
|
||||
});
|
||||
|
||||
export const getUpdateAlertSummarySchemaMock = (
|
||||
alertSummaryId = 'summary-1'
|
||||
): AlertSummaryUpdateProps => ({
|
||||
summary: 'test content',
|
||||
id: alertSummaryId,
|
||||
});
|
||||
|
||||
export const getAlertSummaryMock = (
|
||||
params?: AlertSummaryCreateProps | AlertSummaryUpdateProps
|
||||
): AlertSummaryResponse => ({
|
||||
id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd',
|
||||
summary: 'test content',
|
||||
alertId: '1234',
|
||||
replacements: {},
|
||||
...(params ?? {}),
|
||||
createdAt: '2019-12-13T16:40:33.400Z',
|
||||
updatedAt: '2019-12-13T16:40:33.400Z',
|
||||
namespace: 'default',
|
||||
users: [
|
||||
{
|
||||
id: 'user-id-1',
|
||||
name: 'elastic',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
export const getQueryAlertSummaryParams = (
|
||||
isUpdate?: boolean
|
||||
): AlertSummaryCreateProps | AlertSummaryUpdateProps => {
|
||||
return isUpdate
|
||||
? {
|
||||
summary: 'test 2',
|
||||
alertId: '123',
|
||||
replacements: { 'host.name': '5678' },
|
||||
id: '1',
|
||||
}
|
||||
: {
|
||||
summary: 'test 2',
|
||||
alertId: '123',
|
||||
replacements: { 'host.name': '5678' },
|
||||
};
|
||||
};
|
||||
|
||||
export const getPerformBulkActionSchemaMock = (): PerformAlertSummaryBulkActionRequestBody => ({
|
||||
create: [getQueryAlertSummaryParams(false) as AlertSummaryCreateProps],
|
||||
delete: {
|
||||
ids: ['99403909-ca9b-49ba-9d7a-7e5320e68d05'],
|
||||
},
|
||||
update: [getQueryAlertSummaryParams(true) as AlertSummaryUpdateProps],
|
||||
});
|
|
@ -29,6 +29,8 @@ import {
|
|||
ConversationUpdateProps,
|
||||
DEFEND_INSIGHTS,
|
||||
DEFEND_INSIGHTS_BY_ID,
|
||||
ELASTIC_AI_ASSISTANT_ALERT_SUMMARY_URL_BULK_ACTION,
|
||||
ELASTIC_AI_ASSISTANT_ALERT_SUMMARY_URL_FIND,
|
||||
ELASTIC_AI_ASSISTANT_ANONYMIZATION_FIELDS_URL_BULK_ACTION,
|
||||
ELASTIC_AI_ASSISTANT_ANONYMIZATION_FIELDS_URL_FIND,
|
||||
ELASTIC_AI_ASSISTANT_CONVERSATIONS_URL,
|
||||
|
@ -62,6 +64,10 @@ import {
|
|||
AnonymizationFieldCreateProps,
|
||||
AnonymizationFieldUpdateProps,
|
||||
} from '@kbn/elastic-assistant-common/impl/schemas/anonymization_fields/bulk_crud_anonymization_fields_route.gen';
|
||||
import {
|
||||
AlertSummaryCreateProps,
|
||||
AlertSummaryUpdateProps,
|
||||
} from '@kbn/elastic-assistant-common/impl/schemas/alert_summary/bulk_crud_alert_summary_route.gen';
|
||||
|
||||
export const requestMock = {
|
||||
create: httpServerMock.createKibanaRequest,
|
||||
|
@ -164,6 +170,13 @@ export const getCurrentUserPromptsRequest = () =>
|
|||
path: ELASTIC_AI_ASSISTANT_PROMPTS_URL_FIND,
|
||||
});
|
||||
|
||||
export const getCurrentUserAlertSummaryRequest = () =>
|
||||
requestMock.create({
|
||||
method: 'get',
|
||||
path: ELASTIC_AI_ASSISTANT_ALERT_SUMMARY_URL_FIND,
|
||||
query: { connector_id: '123' },
|
||||
});
|
||||
|
||||
export const getCurrentUserAnonymizationFieldsRequest = () =>
|
||||
requestMock.create({
|
||||
method: 'get',
|
||||
|
@ -260,6 +273,23 @@ export const getAnonymizationFieldsBulkActionRequest = (
|
|||
},
|
||||
});
|
||||
|
||||
export const getAlertSummaryBulkActionRequest = (
|
||||
create: AlertSummaryCreateProps[] = [],
|
||||
update: AlertSummaryUpdateProps[] = [],
|
||||
deleteIds: string[] = []
|
||||
) =>
|
||||
requestMock.create({
|
||||
method: 'patch',
|
||||
path: ELASTIC_AI_ASSISTANT_ALERT_SUMMARY_URL_BULK_ACTION,
|
||||
body: {
|
||||
create,
|
||||
update,
|
||||
delete: {
|
||||
ids: deleteIds,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const getCancelAttackDiscoveryRequest = (connectorId: string) =>
|
||||
requestMock.create({
|
||||
method: 'put',
|
||||
|
|
|
@ -54,6 +54,7 @@ export const createMockClients = () => {
|
|||
getAttackDiscoverySchedulingDataClient: attackDiscoveryScheduleDataClientMock.create(),
|
||||
getDefendInsightsDataClient: dataClientMock.create(),
|
||||
getAIAssistantAnonymizationFieldsDataClient: dataClientMock.create(),
|
||||
getAlertSummaryDataClient: dataClientMock.create(),
|
||||
getSpaceId: jest.fn(),
|
||||
getCurrentUser: jest.fn(),
|
||||
inference: jest.fn(),
|
||||
|
@ -128,6 +129,10 @@ const createElasticAssistantRequestContextMock = (
|
|||
() => clients.elasticAssistant.getAIAssistantPromptsDataClient
|
||||
) as unknown as jest.MockInstance<Promise<AIAssistantDataClient | null>, [], unknown> &
|
||||
(() => Promise<AIAssistantDataClient | null>),
|
||||
getAlertSummaryDataClient: jest.fn(
|
||||
() => clients.elasticAssistant.getAlertSummaryDataClient
|
||||
) as unknown as jest.MockInstance<Promise<AIAssistantDataClient | null>, [], unknown> &
|
||||
(() => Promise<AIAssistantDataClient | null>),
|
||||
getAttackDiscoveryDataClient: jest.fn(
|
||||
() => clients.elasticAssistant.getAttackDiscoveryDataClient
|
||||
) as unknown as jest.MockInstance<Promise<AttackDiscoveryDataClient | null>, [], unknown> &
|
||||
|
|
|
@ -17,6 +17,8 @@ import { EsAnonymizationFieldsSchema } from '../ai_assistant_data_clients/anonym
|
|||
import { getAnonymizationFieldsSearchEsMock } from './anonymization_fields_schema.mock';
|
||||
import { getKnowledgeBaseEntrySearchEsMock } from './knowledge_base_entry_schema.mock';
|
||||
import { EsKnowledgeBaseEntrySchema } from '../ai_assistant_data_clients/knowledge_base/types';
|
||||
import { EsAlertSummarySchema } from '../ai_assistant_data_clients/alert_summary/types';
|
||||
import { getAlertSummarySearchEsMock } from './alert_summary.mock';
|
||||
|
||||
export const responseMock = {
|
||||
create: httpServerMock.createResponseFactory,
|
||||
|
@ -51,6 +53,13 @@ export const getFindPromptsResultWithSingleHit = (): FindResponse<EsPromptsSchem
|
|||
data: getPromptsSearchEsMock(),
|
||||
});
|
||||
|
||||
export const getFindAlertSummaryResultWithSingleHit = (): FindResponse<EsAlertSummarySchema> => ({
|
||||
page: 1,
|
||||
perPage: 1,
|
||||
total: 1,
|
||||
data: getAlertSummarySearchEsMock(),
|
||||
});
|
||||
|
||||
export const getFindAnonymizationFieldsResultWithSingleHit =
|
||||
(): FindResponse<EsAnonymizationFieldsSchema> => ({
|
||||
page: 1,
|
||||
|
|
|
@ -0,0 +1,46 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { FieldMap } from '@kbn/data-stream-adapter';
|
||||
|
||||
export const alertSummaryFieldsFieldMap: FieldMap = {
|
||||
'@timestamp': {
|
||||
type: 'date',
|
||||
array: false,
|
||||
required: false,
|
||||
},
|
||||
alert_id: {
|
||||
type: 'keyword',
|
||||
array: false,
|
||||
required: true,
|
||||
},
|
||||
summary: {
|
||||
type: 'text',
|
||||
array: false,
|
||||
required: false,
|
||||
},
|
||||
updated_at: {
|
||||
type: 'date',
|
||||
array: false,
|
||||
required: false,
|
||||
},
|
||||
updated_by: {
|
||||
type: 'keyword',
|
||||
array: false,
|
||||
required: false,
|
||||
},
|
||||
created_at: {
|
||||
type: 'date',
|
||||
array: false,
|
||||
required: false,
|
||||
},
|
||||
created_by: {
|
||||
type: 'keyword',
|
||||
array: false,
|
||||
required: false,
|
||||
},
|
||||
};
|
|
@ -0,0 +1,145 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import {
|
||||
transformESToAlertSummary,
|
||||
transformESSearchToAlertSummary,
|
||||
transformToUpdateScheme,
|
||||
transformToCreateScheme,
|
||||
getUpdateScript,
|
||||
} from './helpers';
|
||||
import {
|
||||
mockEsAlertSummarySchema,
|
||||
getUpdateAlertSummarySchemaMock,
|
||||
getAlertSummarySearchEsMock,
|
||||
getCreateAlertSummarySchemaMock,
|
||||
} from '../../__mocks__/alert_summary.mock';
|
||||
import { AuthenticatedUser } from '@kbn/core-security-common';
|
||||
import { mockAuthenticatedUser } from '@kbn/core-security-common/src/authentication/authenticated_user.mock';
|
||||
|
||||
const mockEsSearchResponse = getAlertSummarySearchEsMock();
|
||||
const mockAlertSummaryUpdateProps = getUpdateAlertSummarySchemaMock();
|
||||
const mockUser = mockAuthenticatedUser({
|
||||
authentication_provider: { type: 'basic', name: 'basic1' },
|
||||
});
|
||||
describe('helpers', () => {
|
||||
describe('transformToUpdateScheme', () => {
|
||||
it('should transform update props to the correct update schema', () => {
|
||||
const user = mockUser;
|
||||
const updatedAt = '2023-01-01T00:00:00.000Z';
|
||||
const result = transformToUpdateScheme(user, updatedAt, mockAlertSummaryUpdateProps);
|
||||
|
||||
expect(result).toEqual({
|
||||
id: mockAlertSummaryUpdateProps.id,
|
||||
updated_at: updatedAt,
|
||||
updated_by: user.username,
|
||||
summary: mockAlertSummaryUpdateProps.summary,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('transformToCreateScheme', () => {
|
||||
it('should transform create props to the correct create schema', () => {
|
||||
const user = mockUser;
|
||||
const updatedAt = '2023-01-01T00:00:00.000Z';
|
||||
const result = transformToCreateScheme(user, updatedAt, getCreateAlertSummarySchemaMock());
|
||||
|
||||
expect(result).toEqual({
|
||||
'@timestamp': updatedAt,
|
||||
updated_at: updatedAt,
|
||||
updated_by: user.username,
|
||||
created_at: updatedAt,
|
||||
created_by: user.username,
|
||||
summary: 'test content',
|
||||
alert_id: '1234',
|
||||
users: [
|
||||
{
|
||||
id: user.profile_uid,
|
||||
name: user.username,
|
||||
},
|
||||
],
|
||||
replacements: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUpdateScript', () => {
|
||||
it('should generate the correct update script', () => {
|
||||
const alertSummary = transformToUpdateScheme(
|
||||
{ username: 'test_user', profile_uid: '123' } as AuthenticatedUser,
|
||||
'2023-01-01T00:00:00.000Z',
|
||||
mockAlertSummaryUpdateProps
|
||||
);
|
||||
const result = getUpdateScript({ alertSummary, isPatch: true });
|
||||
|
||||
expect(result).toEqual({
|
||||
script: {
|
||||
source: `
|
||||
if (params.assignEmpty == true || params.containsKey('summary')) {
|
||||
ctx._source.summary = params.summary;
|
||||
}
|
||||
if (params.assignEmpty == true || params.containsKey('recommended_actions')) {
|
||||
ctx._source.recommended_actions = params.recommended_actions;
|
||||
}
|
||||
ctx._source.updated_at = params.updated_at;
|
||||
`,
|
||||
lang: 'painless',
|
||||
params: {
|
||||
...alertSummary,
|
||||
assignEmpty: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('transformESToAlertSummary', () => {
|
||||
it('should transform Elasticsearch hit to alert summary', () => {
|
||||
const result = transformESToAlertSummary([mockEsAlertSummarySchema]);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
id: mockEsAlertSummarySchema.id,
|
||||
timestamp: mockEsAlertSummarySchema['@timestamp'],
|
||||
createdAt: mockEsAlertSummarySchema.created_at,
|
||||
updatedAt: mockEsAlertSummarySchema.updated_at,
|
||||
namespace: mockEsAlertSummarySchema.namespace,
|
||||
summary: mockEsAlertSummarySchema.summary,
|
||||
recommendedActions: mockEsAlertSummarySchema.recommended_actions,
|
||||
alertId: mockEsAlertSummarySchema.alert_id,
|
||||
createdBy: mockEsAlertSummarySchema.created_by,
|
||||
updatedBy: mockEsAlertSummarySchema.updated_by,
|
||||
users: mockEsAlertSummarySchema.users,
|
||||
replacements: {},
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('transformESSearchToAlertSummary', () => {
|
||||
it('should transform Elasticsearch search response to alert summaries', () => {
|
||||
const result = transformESSearchToAlertSummary(mockEsSearchResponse);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
id: mockEsAlertSummarySchema.id,
|
||||
timestamp: mockEsAlertSummarySchema['@timestamp'],
|
||||
createdAt: mockEsAlertSummarySchema.created_at,
|
||||
updatedAt: mockEsAlertSummarySchema.updated_at,
|
||||
namespace: mockEsAlertSummarySchema.namespace,
|
||||
summary: mockEsAlertSummarySchema.summary,
|
||||
recommendedActions: mockEsAlertSummarySchema.recommended_actions,
|
||||
alertId: mockEsAlertSummarySchema.alert_id,
|
||||
createdBy: mockEsAlertSummarySchema.created_by,
|
||||
updatedBy: mockEsAlertSummarySchema.updated_by,
|
||||
users: mockEsAlertSummarySchema.users,
|
||||
replacements: {},
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,157 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import type { estypes } from '@elastic/elasticsearch';
|
||||
import {
|
||||
AlertSummaryCreateProps,
|
||||
AlertSummaryResponse,
|
||||
AlertSummaryUpdateProps,
|
||||
} from '@kbn/elastic-assistant-common/impl/schemas/alert_summary/bulk_crud_alert_summary_route.gen';
|
||||
import { AuthenticatedUser } from '@kbn/core-security-common';
|
||||
import { CreateAlertSummarySchema, EsAlertSummarySchema, UpdateAlertSummarySchema } from './types';
|
||||
|
||||
export const transformESToAlertSummary = (
|
||||
response: EsAlertSummarySchema[]
|
||||
): AlertSummaryResponse[] => {
|
||||
return response.map((alertSummarySchema) => {
|
||||
const alertSummary: AlertSummaryResponse = {
|
||||
timestamp: alertSummarySchema['@timestamp'],
|
||||
createdAt: alertSummarySchema.created_at,
|
||||
users:
|
||||
alertSummarySchema.users?.map((user) => ({
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
})) ?? [],
|
||||
summary: alertSummarySchema.summary,
|
||||
alertId: alertSummarySchema.alert_id,
|
||||
updatedAt: alertSummarySchema.updated_at,
|
||||
recommendedActions: alertSummarySchema.recommended_actions,
|
||||
namespace: alertSummarySchema.namespace,
|
||||
id: alertSummarySchema.id,
|
||||
createdBy: alertSummarySchema.created_by,
|
||||
updatedBy: alertSummarySchema.updated_by,
|
||||
replacements: alertSummarySchema.replacements.reduce((acc: Record<string, string>, r) => {
|
||||
acc[r.uuid] = r.value;
|
||||
return acc;
|
||||
}, {}),
|
||||
};
|
||||
|
||||
return alertSummary;
|
||||
});
|
||||
};
|
||||
|
||||
export const transformESSearchToAlertSummary = (
|
||||
response: estypes.SearchResponse<EsAlertSummarySchema>
|
||||
): AlertSummaryResponse[] => {
|
||||
return response.hits.hits
|
||||
.filter((hit) => hit._source !== undefined)
|
||||
.map((hit) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const alertSummarySchema = hit._source!;
|
||||
const alertSummary: AlertSummaryResponse = {
|
||||
timestamp: alertSummarySchema['@timestamp'],
|
||||
createdAt: alertSummarySchema.created_at,
|
||||
users:
|
||||
alertSummarySchema.users?.map((user) => ({
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
})) ?? [],
|
||||
summary: alertSummarySchema.summary,
|
||||
recommendedActions: alertSummarySchema.recommended_actions,
|
||||
updatedAt: alertSummarySchema.updated_at,
|
||||
namespace: alertSummarySchema.namespace,
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
id: hit._id!,
|
||||
alertId: alertSummarySchema.alert_id,
|
||||
createdBy: alertSummarySchema.created_by,
|
||||
updatedBy: alertSummarySchema.updated_by,
|
||||
replacements: alertSummarySchema.replacements.reduce((acc: Record<string, string>, r) => {
|
||||
acc[r.uuid] = r.value;
|
||||
return acc;
|
||||
}, {}),
|
||||
};
|
||||
|
||||
return alertSummary;
|
||||
});
|
||||
};
|
||||
|
||||
export const transformToUpdateScheme = (
|
||||
user: AuthenticatedUser,
|
||||
updatedAt: string,
|
||||
{ summary, recommendedActions, replacements, id }: AlertSummaryUpdateProps
|
||||
): UpdateAlertSummarySchema => {
|
||||
return {
|
||||
id,
|
||||
updated_at: updatedAt,
|
||||
updated_by: user.username,
|
||||
...(summary ? { summary } : {}),
|
||||
...(recommendedActions ? { recommended_actions: recommendedActions } : {}),
|
||||
...(replacements
|
||||
? {
|
||||
replacements: Object.keys(replacements).map((key) => ({
|
||||
uuid: key,
|
||||
value: replacements[key],
|
||||
})),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
};
|
||||
|
||||
export const transformToCreateScheme = (
|
||||
user: AuthenticatedUser,
|
||||
updatedAt: string,
|
||||
{ summary, alertId, recommendedActions, replacements }: AlertSummaryCreateProps
|
||||
): CreateAlertSummarySchema => {
|
||||
return {
|
||||
'@timestamp': updatedAt,
|
||||
updated_at: updatedAt,
|
||||
updated_by: user.username,
|
||||
created_at: updatedAt,
|
||||
created_by: user.username,
|
||||
summary: summary ?? '',
|
||||
...(recommendedActions ? { recommended_actions: recommendedActions } : {}),
|
||||
alert_id: alertId,
|
||||
users: [
|
||||
{
|
||||
id: user.profile_uid,
|
||||
name: user.username,
|
||||
},
|
||||
],
|
||||
replacements: Object.keys(replacements).map((key) => ({
|
||||
uuid: key,
|
||||
value: replacements[key],
|
||||
})),
|
||||
};
|
||||
};
|
||||
|
||||
export const getUpdateScript = ({
|
||||
alertSummary,
|
||||
isPatch,
|
||||
}: {
|
||||
alertSummary: UpdateAlertSummarySchema;
|
||||
isPatch?: boolean;
|
||||
}) => {
|
||||
return {
|
||||
script: {
|
||||
source: `
|
||||
if (params.assignEmpty == true || params.containsKey('summary')) {
|
||||
ctx._source.summary = params.summary;
|
||||
}
|
||||
if (params.assignEmpty == true || params.containsKey('recommended_actions')) {
|
||||
ctx._source.recommended_actions = params.recommended_actions;
|
||||
}
|
||||
ctx._source.updated_at = params.updated_at;
|
||||
`,
|
||||
lang: 'painless',
|
||||
params: {
|
||||
...alertSummary, // when assigning undefined in painless, it will remove property and wil set it to null
|
||||
// for patch we don't want to remove unspecified value in payload
|
||||
assignEmpty: !(isPatch ?? true),
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
|
@ -0,0 +1,56 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { EsReplacementSchema } from '../conversations/types';
|
||||
|
||||
export interface EsAlertSummarySchema {
|
||||
id: string;
|
||||
alert_id: string;
|
||||
'@timestamp': string;
|
||||
created_at: string;
|
||||
created_by: string;
|
||||
summary: string;
|
||||
recommended_actions?: string;
|
||||
replacements: EsReplacementSchema[];
|
||||
users?: Array<{
|
||||
id?: string;
|
||||
name?: string;
|
||||
}>;
|
||||
updated_at?: string;
|
||||
updated_by?: string;
|
||||
namespace: string;
|
||||
}
|
||||
|
||||
export interface UpdateAlertSummarySchema {
|
||||
id: string;
|
||||
'@timestamp'?: string;
|
||||
summary?: string;
|
||||
recommended_actions?: string;
|
||||
replacements?: EsReplacementSchema[];
|
||||
updated_at?: string;
|
||||
updated_by?: string;
|
||||
users?: Array<{
|
||||
id?: string;
|
||||
name?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface CreateAlertSummarySchema {
|
||||
'@timestamp'?: string;
|
||||
alert_id: string;
|
||||
summary: string;
|
||||
replacements: EsReplacementSchema[];
|
||||
recommended_actions?: string;
|
||||
updated_at?: string;
|
||||
updated_by?: string;
|
||||
created_at?: string;
|
||||
created_by?: string;
|
||||
users?: Array<{
|
||||
id?: string;
|
||||
name?: string;
|
||||
}>;
|
||||
}
|
|
@ -149,7 +149,7 @@ describe('AI Assistant Service', () => {
|
|||
|
||||
expect(assistantService.isInitialized()).toEqual(true);
|
||||
|
||||
expect(clusterClient.cluster.putComponentTemplate).toHaveBeenCalledTimes(6);
|
||||
expect(clusterClient.cluster.putComponentTemplate).toHaveBeenCalledTimes(7);
|
||||
|
||||
const expectedTemplates = [
|
||||
'.kibana-elastic-ai-assistant-component-template-conversations',
|
||||
|
@ -158,6 +158,7 @@ describe('AI Assistant Service', () => {
|
|||
'.kibana-elastic-ai-assistant-component-template-anonymization-fields',
|
||||
'.kibana-elastic-ai-assistant-component-template-attack-discovery',
|
||||
'.kibana-elastic-ai-assistant-component-template-defend-insights',
|
||||
'.kibana-elastic-ai-assistant-component-template-alert-summary',
|
||||
];
|
||||
expectedTemplates.forEach((t, i) => {
|
||||
expect(clusterClient.cluster.putComponentTemplate.mock.calls[i][0].name).toEqual(t);
|
||||
|
@ -659,7 +660,7 @@ describe('AI Assistant Service', () => {
|
|||
'AI Assistant service initialized',
|
||||
async () => assistantService.isInitialized() === true
|
||||
);
|
||||
expect(clusterClient.cluster.putComponentTemplate).toHaveBeenCalledTimes(8);
|
||||
expect(clusterClient.cluster.putComponentTemplate).toHaveBeenCalledTimes(9);
|
||||
|
||||
const expectedTemplates = [
|
||||
'.kibana-elastic-ai-assistant-component-template-conversations',
|
||||
|
@ -670,6 +671,7 @@ describe('AI Assistant Service', () => {
|
|||
'.kibana-elastic-ai-assistant-component-template-anonymization-fields',
|
||||
'.kibana-elastic-ai-assistant-component-template-attack-discovery',
|
||||
'.kibana-elastic-ai-assistant-component-template-defend-insights',
|
||||
'.kibana-elastic-ai-assistant-component-template-alert-summary',
|
||||
];
|
||||
expectedTemplates.forEach((t, i) => {
|
||||
expect(clusterClient.cluster.putComponentTemplate.mock.calls[i][0].name).toEqual(t);
|
||||
|
@ -694,7 +696,7 @@ describe('AI Assistant Service', () => {
|
|||
async () => (await getSpaceResourcesInitialized(assistantService)) === true
|
||||
);
|
||||
|
||||
expect(clusterClient.indices.putIndexTemplate).toHaveBeenCalledTimes(8);
|
||||
expect(clusterClient.indices.putIndexTemplate).toHaveBeenCalledTimes(9);
|
||||
const expectedTemplates = [
|
||||
'.kibana-elastic-ai-assistant-index-template-conversations',
|
||||
'.kibana-elastic-ai-assistant-index-template-conversations',
|
||||
|
@ -704,6 +706,7 @@ describe('AI Assistant Service', () => {
|
|||
'.kibana-elastic-ai-assistant-index-template-anonymization-fields',
|
||||
'.kibana-elastic-ai-assistant-index-template-attack-discovery',
|
||||
'.kibana-elastic-ai-assistant-index-template-defend-insights',
|
||||
'.kibana-elastic-ai-assistant-index-template-alert-summary',
|
||||
];
|
||||
expectedTemplates.forEach((t, i) => {
|
||||
expect(clusterClient.indices.putIndexTemplate.mock.calls[i][0].name).toEqual(t);
|
||||
|
@ -727,7 +730,7 @@ describe('AI Assistant Service', () => {
|
|||
async () => (await getSpaceResourcesInitialized(assistantService)) === true
|
||||
);
|
||||
|
||||
expect(clusterClient.indices.putSettings).toHaveBeenCalledTimes(8);
|
||||
expect(clusterClient.indices.putSettings).toHaveBeenCalledTimes(9);
|
||||
});
|
||||
|
||||
test('should retry updating index mappings for existing indices for transient ES errors', async () => {
|
||||
|
@ -747,7 +750,7 @@ describe('AI Assistant Service', () => {
|
|||
async () => (await getSpaceResourcesInitialized(assistantService)) === true
|
||||
);
|
||||
|
||||
expect(clusterClient.indices.putMapping).toHaveBeenCalledTimes(8);
|
||||
expect(clusterClient.indices.putMapping).toHaveBeenCalledTimes(9);
|
||||
});
|
||||
|
||||
test('should retry creating concrete index for transient ES errors', async () => {
|
||||
|
@ -781,7 +784,7 @@ describe('AI Assistant Service', () => {
|
|||
async () => (await getSpaceResourcesInitialized(assistantService)) === true
|
||||
);
|
||||
|
||||
expect(clusterClient.indices.createDataStream).toHaveBeenCalledTimes(6);
|
||||
expect(clusterClient.indices.createDataStream).toHaveBeenCalledTimes(7);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -20,6 +20,7 @@ import {
|
|||
import { omit, some } from 'lodash';
|
||||
import { InstallationStatus } from '@kbn/product-doc-base-plugin/common/install_status';
|
||||
import { TrainedModelsProvider } from '@kbn/ml-plugin/server/shared_services/providers';
|
||||
import { alertSummaryFieldsFieldMap } from '../ai_assistant_data_clients/alert_summary/field_maps_configuration';
|
||||
import { attackDiscoveryFieldMap } from '../lib/attack_discovery/persistence/field_maps_configuration/field_maps_configuration';
|
||||
import { defendInsightsFieldMap } from '../lib/defend_insights/persistence/field_maps_configuration';
|
||||
import { getDefaultAnonymizationFields } from '../../common/anonymization';
|
||||
|
@ -93,7 +94,8 @@ export type CreateDataStream = (params: {
|
|||
| 'knowledgeBase'
|
||||
| 'prompts'
|
||||
| 'attackDiscovery'
|
||||
| 'defendInsights';
|
||||
| 'defendInsights'
|
||||
| 'alertSummary';
|
||||
fieldMap: FieldMap;
|
||||
kibanaVersion: string;
|
||||
spaceId?: string;
|
||||
|
@ -109,6 +111,7 @@ export class AIAssistantService {
|
|||
private conversationsDataStream: DataStreamSpacesAdapter;
|
||||
private knowledgeBaseDataStream: DataStreamSpacesAdapter;
|
||||
private promptsDataStream: DataStreamSpacesAdapter;
|
||||
private alertSummaryDataStream: DataStreamSpacesAdapter;
|
||||
private anonymizationFieldsDataStream: DataStreamSpacesAdapter;
|
||||
private attackDiscoveryDataStream: DataStreamSpacesAdapter;
|
||||
private defendInsightsDataStream: DataStreamSpacesAdapter;
|
||||
|
@ -152,6 +155,11 @@ export class AIAssistantService {
|
|||
kibanaVersion: options.kibanaVersion,
|
||||
fieldMap: defendInsightsFieldMap,
|
||||
});
|
||||
this.alertSummaryDataStream = this.createDataStream({
|
||||
resource: 'alertSummary',
|
||||
kibanaVersion: options.kibanaVersion,
|
||||
fieldMap: alertSummaryFieldsFieldMap,
|
||||
});
|
||||
|
||||
this.initPromise = this.initializeResources();
|
||||
|
||||
|
@ -428,6 +436,12 @@ export class AIAssistantService {
|
|||
logger: this.options.logger,
|
||||
pluginStop$: this.options.pluginStop$,
|
||||
});
|
||||
|
||||
await this.alertSummaryDataStream.install({
|
||||
esClient,
|
||||
logger: this.options.logger,
|
||||
pluginStop$: this.options.pluginStop$,
|
||||
});
|
||||
} catch (error) {
|
||||
this.options.logger.warn(`Error initializing AI assistant resources: ${error.message}`);
|
||||
this.initialized = false;
|
||||
|
@ -441,6 +455,7 @@ export class AIAssistantService {
|
|||
|
||||
private readonly resourceNames: AssistantResourceNames = {
|
||||
componentTemplate: {
|
||||
alertSummary: getResourceName('component-template-alert-summary'),
|
||||
conversations: getResourceName('component-template-conversations'),
|
||||
knowledgeBase: getResourceName('component-template-knowledge-base'),
|
||||
prompts: getResourceName('component-template-prompts'),
|
||||
|
@ -449,6 +464,7 @@ export class AIAssistantService {
|
|||
defendInsights: getResourceName('component-template-defend-insights'),
|
||||
},
|
||||
aliases: {
|
||||
alertSummary: getResourceName('alert-summary'),
|
||||
conversations: getResourceName('conversations'),
|
||||
knowledgeBase: getResourceName('knowledge-base'),
|
||||
prompts: getResourceName('prompts'),
|
||||
|
@ -457,6 +473,7 @@ export class AIAssistantService {
|
|||
defendInsights: getResourceName('defend-insights'),
|
||||
},
|
||||
indexPatterns: {
|
||||
alertSummary: getResourceName('alert-summary*'),
|
||||
conversations: getResourceName('conversations*'),
|
||||
knowledgeBase: getResourceName('knowledge-base*'),
|
||||
prompts: getResourceName('prompts*'),
|
||||
|
@ -465,6 +482,7 @@ export class AIAssistantService {
|
|||
defendInsights: getResourceName('defend-insights*'),
|
||||
},
|
||||
indexTemplate: {
|
||||
alertSummary: getResourceName('index-template-alert-summary'),
|
||||
conversations: getResourceName('index-template-conversations'),
|
||||
knowledgeBase: getResourceName('index-template-knowledge-base'),
|
||||
prompts: getResourceName('index-template-prompts'),
|
||||
|
@ -666,6 +684,25 @@ export class AIAssistantService {
|
|||
});
|
||||
}
|
||||
|
||||
public async createAlertSummaryDataClient(
|
||||
opts: CreateAIAssistantClientParams
|
||||
): Promise<AIAssistantDataClient | null> {
|
||||
const res = await this.checkResourcesInstallation(opts);
|
||||
|
||||
if (res === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new AIAssistantDataClient({
|
||||
logger: this.options.logger,
|
||||
elasticsearchClientPromise: this.options.elasticsearchClientPromise,
|
||||
spaceId: opts.spaceId,
|
||||
kibanaVersion: this.options.kibanaVersion,
|
||||
indexPatternsResourceName: this.resourceNames.aliases.alertSummary,
|
||||
currentUser: opts.currentUser,
|
||||
});
|
||||
}
|
||||
|
||||
public async createAIAssistantAnonymizationFieldsDataClient(
|
||||
opts: CreateAIAssistantClientParams
|
||||
): Promise<AIAssistantDataClient | null> {
|
||||
|
@ -733,6 +770,12 @@ export class AIAssistantService {
|
|||
await this.anonymizationFieldsDataStream.installSpace(spaceId);
|
||||
await this.createDefaultAnonymizationFields(spaceId);
|
||||
}
|
||||
const alertSummaryIndexName = await this.alertSummaryDataStream.getInstalledSpaceName(
|
||||
spaceId
|
||||
);
|
||||
if (!alertSummaryIndexName) {
|
||||
await this.alertSummaryDataStream.installSpace(spaceId);
|
||||
}
|
||||
} catch (error) {
|
||||
this.options.logger.warn(
|
||||
`Error initializing AI assistant namespace level resources: ${error.message}`
|
||||
|
|
|
@ -25,6 +25,8 @@ import {
|
|||
GEMINI_CHAT_TITLE,
|
||||
DEFAULT_CHAT_TITLE,
|
||||
DEFEND_INSIGHTS,
|
||||
ALERT_SUMMARY_500,
|
||||
ALERT_SUMMARY_SYSTEM_PROMPT,
|
||||
} from './prompts';
|
||||
|
||||
export const promptGroupId = {
|
||||
|
@ -33,9 +35,12 @@ export const promptGroupId = {
|
|||
defendInsights: {
|
||||
incompatibleAntivirus: 'defendInsights-incompatibleAntivirus',
|
||||
},
|
||||
aiForSoc: 'aiForSoc',
|
||||
};
|
||||
|
||||
export const promptDictionary = {
|
||||
alertSummary: `alertSummary`,
|
||||
alertSummarySystemPrompt: `alertSummarySystemPrompt`,
|
||||
systemPrompt: `systemPrompt`,
|
||||
userPrompt: `userPrompt`,
|
||||
chatTitle: `chatTitle`,
|
||||
|
@ -250,4 +255,18 @@ export const localPrompts: Prompt[] = [
|
|||
default: DEFEND_INSIGHTS.INCOMPATIBLE_ANTIVIRUS.EVENTS_VALUE,
|
||||
},
|
||||
},
|
||||
{
|
||||
promptId: promptDictionary.alertSummary,
|
||||
promptGroupId: promptGroupId.aiForSoc,
|
||||
prompt: {
|
||||
default: ALERT_SUMMARY_500,
|
||||
},
|
||||
},
|
||||
{
|
||||
promptId: promptDictionary.alertSummarySystemPrompt,
|
||||
promptGroupId: promptGroupId.aiForSoc,
|
||||
prompt: {
|
||||
default: ALERT_SUMMARY_SYSTEM_PROMPT,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
|
|
@ -227,3 +227,32 @@ Review the insights below and remove any that are not from an antivirus program
|
|||
EVENTS_VALUE: 'The process.executable value of the event',
|
||||
},
|
||||
};
|
||||
|
||||
export const ALERT_SUMMARY_500 = `Evaluate the cyber security alert from the context above. Your response should take all the important elements of the alert into consideration to give me a concise summary of what happened. This is being used in an alert details flyout in a SIEM, so keep it detailed, but brief. Limit your response to 500 characters. Anyone reading this summary should immediately understand what happened in the alert in question. Only reply with the summary, and nothing else.
|
||||
|
||||
Using another 200 characters, add a second paragraph with a bulleted list of recommended actions a cyber security analyst should take here. Don't invent random, potentially harmful recommended actions.`;
|
||||
|
||||
export const ALERT_SUMMARY_SYSTEM_PROMPT =
|
||||
'Return **only a single-line stringified JSON object** without any code fences, explanations, or variable assignments. Do **not** wrap the output in triple backticks or any Markdown code block. \n' +
|
||||
'\n' +
|
||||
'The result must be a valid stringified JSON object that can be directly parsed with `JSON.parse()` in JavaScript.\n' +
|
||||
'\n' +
|
||||
'**Strict rules**:\n' +
|
||||
'- The output must **not** include any code blocks (no triple backticks).\n' +
|
||||
'- The output must be **a string**, ready to be passed directly into `JSON.parse()`.\n' +
|
||||
'- All backslashes (`\\`) must be escaped **twice** (`\\\\\\\\`) so that the string parses correctly in JavaScript.\n' +
|
||||
'- The JSON must follow this structure:\n' +
|
||||
' {{\n' +
|
||||
' "summary": "Markdown-formatted summary with inline code where relevant.",\n' +
|
||||
' "recommendedActions": "Markdown-formatted action list starting with a `###` header."\n' +
|
||||
' }}\n' +
|
||||
'- The summary text should just be text. It does not need any titles or leading items in bold.\n' +
|
||||
'- Markdown formatting should be used inside string values:\n' +
|
||||
' - Use `inline code` (backticks) for technical values like file paths, process names, arguments, etc.\n' +
|
||||
' - Use `**bold**` for emphasis.\n' +
|
||||
' - Use `-` for bullet points.\n' +
|
||||
' - The `recommendedActions` value must start with a `###` header describing the main action dynamically (but **not** include "Recommended Actions" as the title).\n' +
|
||||
'- **Do not** include any extra explanation or text. Only return the stringified JSON object.\n' +
|
||||
'\n' +
|
||||
'The response should look like this:\n' +
|
||||
'{{"summary":"Markdown-formatted summary text.","recommendedActions":"Markdown-formatted action list starting with a ### header."}}';
|
||||
|
|
|
@ -0,0 +1,229 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { loggingSystemMock } from '@kbn/core/server/mocks';
|
||||
import { serverMock } from '../../__mocks__/server';
|
||||
import { requestContextMock } from '../../__mocks__/request_context';
|
||||
import { getAlertSummaryBulkActionRequest, requestMock } from '../../__mocks__/request';
|
||||
import { authenticatedUser } from '../../__mocks__/user';
|
||||
import { ELASTIC_AI_ASSISTANT_PROMPTS_URL_BULK_ACTION } from '@kbn/elastic-assistant-common';
|
||||
import {
|
||||
getEmptyFindResult,
|
||||
getFindAlertSummaryResultWithSingleHit,
|
||||
} from '../../__mocks__/response';
|
||||
import { bulkAlertSummaryRoute } from './bulk_actions_route';
|
||||
import {
|
||||
getPerformBulkActionSchemaMock,
|
||||
getCreateAlertSummarySchemaMock,
|
||||
getUpdateAlertSummarySchemaMock,
|
||||
getAlertSummaryMock,
|
||||
} from '../../__mocks__/alert_summary.mock';
|
||||
import { transformToUpdateScheme } from '../../ai_assistant_data_clients/alert_summary/helpers';
|
||||
|
||||
describe('Perform bulk action route', () => {
|
||||
let server: ReturnType<typeof serverMock.create>;
|
||||
let { clients, context } = requestContextMock.createTools();
|
||||
let logger: ReturnType<typeof loggingSystemMock.createLogger>;
|
||||
const mockAlertSummary = transformToUpdateScheme(
|
||||
authenticatedUser,
|
||||
'2019-12-13T16:40:33.400Z',
|
||||
getAlertSummaryMock(getUpdateAlertSummarySchemaMock())
|
||||
);
|
||||
const mockUser1 = authenticatedUser;
|
||||
|
||||
beforeEach(async () => {
|
||||
server = serverMock.create();
|
||||
logger = loggingSystemMock.createLogger();
|
||||
({ clients, context } = requestContextMock.createTools());
|
||||
|
||||
clients.elasticAssistant.getAlertSummaryDataClient.findDocuments.mockResolvedValue(
|
||||
Promise.resolve(getFindAlertSummaryResultWithSingleHit())
|
||||
);
|
||||
(
|
||||
(await clients.elasticAssistant.getAlertSummaryDataClient.getWriter()).bulk as jest.Mock
|
||||
).mockResolvedValue({
|
||||
docs_created: [mockAlertSummary, mockAlertSummary],
|
||||
docs_updated: [mockAlertSummary, mockAlertSummary],
|
||||
docs_deleted: [],
|
||||
errors: [],
|
||||
});
|
||||
context.elasticAssistant.getCurrentUser.mockResolvedValue(mockUser1);
|
||||
bulkAlertSummaryRoute(server.router, logger);
|
||||
});
|
||||
|
||||
describe('status codes', () => {
|
||||
it('returns 200 when performing bulk action with all dependencies present', async () => {
|
||||
clients.elasticAssistant.getAlertSummaryDataClient.findDocuments.mockResolvedValueOnce(
|
||||
Promise.resolve(getEmptyFindResult())
|
||||
);
|
||||
const response = await server.inject(
|
||||
getAlertSummaryBulkActionRequest(
|
||||
[getCreateAlertSummarySchemaMock()],
|
||||
[getUpdateAlertSummarySchemaMock('49403909-ca9b-49ba-9d7a-7e5320e68d04')],
|
||||
['99403909-ca9b-49ba-9d7a-7e5320e68d05']
|
||||
),
|
||||
requestContextMock.convertContext(context)
|
||||
);
|
||||
expect(response.status).toEqual(200);
|
||||
expect(response.body).toEqual({
|
||||
success: true,
|
||||
alert_summaries_count: 3,
|
||||
attributes: {
|
||||
results: someBulkActionResults(),
|
||||
summary: {
|
||||
failed: 0,
|
||||
skipped: 0,
|
||||
succeeded: 3,
|
||||
total: 3,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('alert_summaries bulk actions failures', () => {
|
||||
it('returns partial failure error if update of few alert summaries fail', async () => {
|
||||
(
|
||||
(await clients.elasticAssistant.getAlertSummaryDataClient.getWriter()).bulk as jest.Mock
|
||||
).mockResolvedValue({
|
||||
docs_created: [mockAlertSummary],
|
||||
docs_updated: [],
|
||||
docs_deleted: [],
|
||||
errors: [
|
||||
{
|
||||
message: 'mocked validation message',
|
||||
document: { id: 'failed-alert-summary-id-1', name: 'Detect Root/Admin Users' },
|
||||
},
|
||||
{
|
||||
message: 'mocked validation message',
|
||||
document: { id: 'failed-alert-summary-id-2', name: 'Detect Root/Admin Users' },
|
||||
},
|
||||
{
|
||||
message: 'test failure',
|
||||
document: { id: 'failed-alert-summary-id-3', name: 'Detect Root/Admin Users' },
|
||||
},
|
||||
],
|
||||
total: 4,
|
||||
});
|
||||
clients.elasticAssistant.getAlertSummaryDataClient.findDocuments.mockResolvedValueOnce(
|
||||
Promise.resolve(getEmptyFindResult())
|
||||
);
|
||||
const response = await server.inject(
|
||||
getAlertSummaryBulkActionRequest(
|
||||
[getCreateAlertSummarySchemaMock()],
|
||||
[getUpdateAlertSummarySchemaMock('49403909-ca9b-49ba-9d7a-7e5320e68d04')],
|
||||
['99403909-ca9b-49ba-9d7a-7e5320e68d05']
|
||||
),
|
||||
requestContextMock.convertContext(context)
|
||||
);
|
||||
|
||||
expect(response.status).toEqual(500);
|
||||
expect(response.body).toEqual({
|
||||
attributes: {
|
||||
summary: {
|
||||
failed: 3,
|
||||
succeeded: 1,
|
||||
skipped: 0,
|
||||
total: 4,
|
||||
},
|
||||
errors: [
|
||||
{
|
||||
message: 'mocked validation message',
|
||||
alert_summaries: [
|
||||
{
|
||||
id: 'failed-alert-summary-id-1',
|
||||
},
|
||||
],
|
||||
status_code: 500,
|
||||
},
|
||||
{
|
||||
message: 'mocked validation message',
|
||||
alert_summaries: [
|
||||
{
|
||||
id: 'failed-alert-summary-id-2',
|
||||
},
|
||||
],
|
||||
status_code: 500,
|
||||
},
|
||||
{
|
||||
message: 'test failure',
|
||||
alert_summaries: [
|
||||
{
|
||||
id: 'failed-alert-summary-id-3',
|
||||
},
|
||||
],
|
||||
status_code: 500,
|
||||
},
|
||||
],
|
||||
results: someBulkActionResults(),
|
||||
},
|
||||
message: 'Bulk edit partially failed',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('request validation', () => {
|
||||
it('rejects payloads with no ids in delete operation', async () => {
|
||||
const request = requestMock.create({
|
||||
method: 'post',
|
||||
path: ELASTIC_AI_ASSISTANT_PROMPTS_URL_BULK_ACTION,
|
||||
body: { ...getPerformBulkActionSchemaMock(), delete: { ids: [] } },
|
||||
});
|
||||
const result = server.validate(request);
|
||||
expect(result.badRequest).toHaveBeenCalledWith(
|
||||
'delete.ids: Array must contain at least 1 element(s)'
|
||||
);
|
||||
});
|
||||
|
||||
it('accepts payloads with only delete action', async () => {
|
||||
const request = requestMock.create({
|
||||
method: 'post',
|
||||
path: ELASTIC_AI_ASSISTANT_PROMPTS_URL_BULK_ACTION,
|
||||
body: getPerformBulkActionSchemaMock(),
|
||||
});
|
||||
const result = server.validate(request);
|
||||
|
||||
expect(result.ok).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('accepts payloads with all operations', async () => {
|
||||
const request = requestMock.create({
|
||||
method: 'post',
|
||||
path: ELASTIC_AI_ASSISTANT_PROMPTS_URL_BULK_ACTION,
|
||||
body: getPerformBulkActionSchemaMock(),
|
||||
});
|
||||
const result = server.validate(request);
|
||||
|
||||
expect(result.ok).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects payload if there is more than 100 deletes in payload', async () => {
|
||||
const request = requestMock.create({
|
||||
method: 'post',
|
||||
path: ELASTIC_AI_ASSISTANT_PROMPTS_URL_BULK_ACTION,
|
||||
body: {
|
||||
...getPerformBulkActionSchemaMock(),
|
||||
delete: { ids: Array.from({ length: 101 }).map(() => 'fake-id') },
|
||||
},
|
||||
});
|
||||
|
||||
const response = await server.inject(request, requestContextMock.convertContext(context));
|
||||
|
||||
expect(response.status).toEqual(400);
|
||||
expect(response.body.message).toEqual('More than 100 ids sent for bulk edit action.');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function someBulkActionResults() {
|
||||
return {
|
||||
created: expect.any(Array),
|
||||
deleted: expect.any(Array),
|
||||
updated: expect.any(Array),
|
||||
skipped: expect.any(Array),
|
||||
};
|
||||
}
|
|
@ -0,0 +1,241 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import moment from 'moment';
|
||||
import type { IKibanaResponse, KibanaResponseFactory, Logger } from '@kbn/core/server';
|
||||
|
||||
import { transformError } from '@kbn/securitysolution-es-utils';
|
||||
import {
|
||||
API_VERSIONS,
|
||||
ELASTIC_AI_ASSISTANT_ALERT_SUMMARY_URL_BULK_ACTION,
|
||||
} from '@kbn/elastic-assistant-common';
|
||||
|
||||
import {
|
||||
AlertSummaryResponse,
|
||||
AlertSummaryBulkActionSkipResult,
|
||||
AlertSummaryBulkCrudActionResponse,
|
||||
AlertSummaryBulkCrudActionResults,
|
||||
BulkCrudActionSummary,
|
||||
PerformAlertSummaryBulkActionRequestBody,
|
||||
PerformAlertSummaryBulkActionResponse,
|
||||
} from '@kbn/elastic-assistant-common/impl/schemas/alert_summary/bulk_crud_alert_summary_route.gen';
|
||||
import { buildRouteValidationWithZod } from '@kbn/elastic-assistant-common/impl/schemas/common';
|
||||
import { PROMPTS_TABLE_MAX_PAGE_SIZE } from '../../../common/constants';
|
||||
import { ElasticAssistantPluginRouter } from '../../types';
|
||||
import { buildResponse } from '../utils';
|
||||
import {
|
||||
getUpdateScript,
|
||||
transformToCreateScheme,
|
||||
transformToUpdateScheme,
|
||||
transformESToAlertSummary,
|
||||
transformESSearchToAlertSummary,
|
||||
} from '../../ai_assistant_data_clients/alert_summary/helpers';
|
||||
import {
|
||||
EsAlertSummarySchema,
|
||||
UpdateAlertSummarySchema,
|
||||
} from '../../ai_assistant_data_clients/alert_summary/types';
|
||||
import { performChecks } from '../helpers';
|
||||
|
||||
export interface BulkOperationError {
|
||||
message: string;
|
||||
status?: number;
|
||||
document: {
|
||||
id: string;
|
||||
};
|
||||
}
|
||||
|
||||
const buildBulkResponse = (
|
||||
response: KibanaResponseFactory,
|
||||
{
|
||||
errors = [],
|
||||
updated = [],
|
||||
created = [],
|
||||
deleted = [],
|
||||
skipped = [],
|
||||
}: {
|
||||
errors?: BulkOperationError[];
|
||||
updated?: AlertSummaryResponse[];
|
||||
created?: AlertSummaryResponse[];
|
||||
deleted?: string[];
|
||||
skipped?: AlertSummaryBulkActionSkipResult[];
|
||||
}
|
||||
): IKibanaResponse<AlertSummaryBulkCrudActionResponse> => {
|
||||
const numSucceeded = updated.length + created.length + deleted.length;
|
||||
const numSkipped = skipped.length;
|
||||
const numFailed = errors.length;
|
||||
|
||||
const summary: BulkCrudActionSummary = {
|
||||
failed: numFailed,
|
||||
succeeded: numSucceeded,
|
||||
skipped: numSkipped,
|
||||
total: numSucceeded + numFailed + numSkipped,
|
||||
};
|
||||
|
||||
const results: AlertSummaryBulkCrudActionResults = {
|
||||
updated,
|
||||
created,
|
||||
deleted,
|
||||
skipped,
|
||||
};
|
||||
|
||||
if (numFailed > 0) {
|
||||
return response.custom<AlertSummaryBulkCrudActionResponse>({
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: {
|
||||
message: summary.succeeded > 0 ? 'Bulk edit partially failed' : 'Bulk edit failed',
|
||||
attributes: {
|
||||
errors: errors.map((e: BulkOperationError) => ({
|
||||
status_code: e.status ?? 500,
|
||||
alert_summaries: [{ id: e.document.id }],
|
||||
message: e.message,
|
||||
})),
|
||||
results,
|
||||
summary,
|
||||
},
|
||||
},
|
||||
statusCode: 500,
|
||||
});
|
||||
}
|
||||
|
||||
const responseBody: AlertSummaryBulkCrudActionResponse = {
|
||||
success: true,
|
||||
alert_summaries_count: summary.total,
|
||||
attributes: { results, summary },
|
||||
};
|
||||
|
||||
return response.ok({ body: responseBody });
|
||||
};
|
||||
|
||||
export const bulkAlertSummaryRoute = (router: ElasticAssistantPluginRouter, logger: Logger) => {
|
||||
router.versioned
|
||||
.post({
|
||||
access: 'internal',
|
||||
path: ELASTIC_AI_ASSISTANT_ALERT_SUMMARY_URL_BULK_ACTION,
|
||||
security: {
|
||||
authz: {
|
||||
requiredPrivileges: ['elasticAssistant'],
|
||||
},
|
||||
},
|
||||
options: {
|
||||
timeout: {
|
||||
idleSocket: moment.duration(15, 'minutes').asMilliseconds(),
|
||||
},
|
||||
},
|
||||
})
|
||||
.addVersion(
|
||||
{
|
||||
version: API_VERSIONS.internal.v1,
|
||||
validate: {
|
||||
request: {
|
||||
body: buildRouteValidationWithZod(PerformAlertSummaryBulkActionRequestBody),
|
||||
},
|
||||
},
|
||||
},
|
||||
async (
|
||||
context,
|
||||
request,
|
||||
response
|
||||
): Promise<IKibanaResponse<PerformAlertSummaryBulkActionResponse>> => {
|
||||
const { body } = request;
|
||||
const assistantResponse = buildResponse(response);
|
||||
|
||||
const operationsCount =
|
||||
(body?.update ? body.update?.length : 0) +
|
||||
(body?.create ? body.create?.length : 0) +
|
||||
(body?.delete ? body.delete?.ids?.length ?? 0 : 0);
|
||||
if (operationsCount > PROMPTS_TABLE_MAX_PAGE_SIZE) {
|
||||
return assistantResponse.error({
|
||||
body: `More than ${PROMPTS_TABLE_MAX_PAGE_SIZE} ids sent for bulk edit action.`,
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const abortController = new AbortController();
|
||||
|
||||
// subscribing to completed$, because it handles both cases when request was completed and aborted.
|
||||
// when route is finished by timeout, aborted$ is not getting fired
|
||||
request.events.completed$.subscribe(() => abortController.abort());
|
||||
try {
|
||||
const ctx = await context.resolve(['core', 'elasticAssistant', 'licensing']);
|
||||
// Perform license and authenticated user checks
|
||||
const checkResponse = await performChecks({
|
||||
context: ctx,
|
||||
request,
|
||||
response,
|
||||
});
|
||||
if (!checkResponse.isSuccess) {
|
||||
return checkResponse.response;
|
||||
}
|
||||
const authenticatedUser = checkResponse.currentUser;
|
||||
|
||||
const dataClient = await ctx.elasticAssistant.getAlertSummaryDataClient();
|
||||
|
||||
if (body.create && body.create.length > 0) {
|
||||
const result = await dataClient?.findDocuments<EsAlertSummarySchema>({
|
||||
perPage: 100,
|
||||
page: 1,
|
||||
filter: `(${body.create.map((c) => `alertId:${c.alertId}`).join(' OR ')})`,
|
||||
fields: ['name'],
|
||||
});
|
||||
if (result?.data != null && result.total > 0) {
|
||||
return assistantResponse.error({
|
||||
statusCode: 409,
|
||||
body: `Alert summary for: "${result.data.hits.hits
|
||||
.map((c) => c._source?.alert_id ?? c._id)
|
||||
.join(',')}" already exists`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const writer = await dataClient?.getWriter();
|
||||
const changedAt = new Date().toISOString();
|
||||
const {
|
||||
errors,
|
||||
docs_created: docsCreated,
|
||||
docs_updated: docsUpdated,
|
||||
docs_deleted: docsDeleted,
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
} = await writer!.bulk({
|
||||
documentsToCreate: body.create?.map((f) =>
|
||||
transformToCreateScheme(authenticatedUser, changedAt, f)
|
||||
),
|
||||
documentsToDelete: body.delete?.ids,
|
||||
documentsToUpdate: body.update?.map((f) =>
|
||||
transformToUpdateScheme(authenticatedUser, changedAt, f)
|
||||
),
|
||||
getUpdateScript: (document: UpdateAlertSummarySchema) =>
|
||||
getUpdateScript({ alertSummary: document, isPatch: true }),
|
||||
// any user can update any alert summary
|
||||
authenticatedUser: undefined,
|
||||
});
|
||||
const created =
|
||||
docsCreated.length > 0
|
||||
? await dataClient?.findDocuments<EsAlertSummarySchema>({
|
||||
page: 1,
|
||||
perPage: 100,
|
||||
filter: docsCreated.map((c) => `_id:${c}`).join(' OR '),
|
||||
})
|
||||
: undefined;
|
||||
|
||||
return buildBulkResponse(response, {
|
||||
updated: docsUpdated
|
||||
? transformESToAlertSummary(docsUpdated as EsAlertSummarySchema[])
|
||||
: [],
|
||||
created: created ? transformESSearchToAlertSummary(created.data) : [],
|
||||
deleted: docsDeleted ?? [],
|
||||
errors,
|
||||
});
|
||||
} catch (err) {
|
||||
const error = transformError(err);
|
||||
return assistantResponse.error({
|
||||
body: error.message,
|
||||
statusCode: error.statusCode,
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
};
|
|
@ -0,0 +1,141 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { getCurrentUserAlertSummaryRequest, requestMock } from '../../__mocks__/request';
|
||||
import { ELASTIC_AI_ASSISTANT_PROMPTS_URL_FIND } from '@kbn/elastic-assistant-common';
|
||||
import { serverMock } from '../../__mocks__/server';
|
||||
import { requestContextMock } from '../../__mocks__/request_context';
|
||||
import { getFindAlertSummaryResultWithSingleHit } from '../../__mocks__/response';
|
||||
import { findAlertSummaryRoute } from './find_route';
|
||||
import { loggingSystemMock } from '@kbn/core-logging-server-mocks';
|
||||
import type { AuthenticatedUser } from '@kbn/core-security-common';
|
||||
import { actionsClientMock } from '@kbn/actions-plugin/server/actions_client/actions_client.mock';
|
||||
import { getAlertSummaryMock } from '../../__mocks__/alert_summary.mock';
|
||||
|
||||
jest.mock('../../lib/prompt', () => ({
|
||||
...jest.requireActual('../../lib/prompt'),
|
||||
getPrompt: jest.fn().mockResolvedValue('hello world'),
|
||||
}));
|
||||
describe('Find user prompts route', () => {
|
||||
let server: ReturnType<typeof serverMock.create>;
|
||||
let { clients, context } = requestContextMock.createTools();
|
||||
let logger: ReturnType<typeof loggingSystemMock.createLogger>;
|
||||
|
||||
beforeEach(async () => {
|
||||
server = serverMock.create();
|
||||
({ clients, context } = requestContextMock.createTools());
|
||||
const mockUser1 = {
|
||||
username: 'my_username',
|
||||
authentication_realm: {
|
||||
type: 'my_realm_type',
|
||||
name: 'my_realm_name',
|
||||
},
|
||||
} as AuthenticatedUser;
|
||||
|
||||
clients.elasticAssistant.getAlertSummaryDataClient.findDocuments.mockResolvedValue(
|
||||
Promise.resolve(getFindAlertSummaryResultWithSingleHit())
|
||||
);
|
||||
logger = loggingSystemMock.createLogger();
|
||||
context.elasticAssistant.getCurrentUser.mockResolvedValue(mockUser1);
|
||||
(context.elasticAssistant.actions.getActionsClientWithRequest as jest.Mock) = jest
|
||||
.fn()
|
||||
.mockReturnValueOnce(actionsClientMock.create());
|
||||
findAlertSummaryRoute(server.router, logger);
|
||||
});
|
||||
|
||||
describe('status codes', () => {
|
||||
test('returns 200', async () => {
|
||||
const response = await server.inject(
|
||||
getCurrentUserAlertSummaryRequest(),
|
||||
requestContextMock.convertContext(context)
|
||||
);
|
||||
expect(response.status).toEqual(200);
|
||||
expect(response.body).toEqual({
|
||||
perPage: 1,
|
||||
page: 1,
|
||||
total: 1,
|
||||
data: [
|
||||
{
|
||||
...getAlertSummaryMock(),
|
||||
createdBy: `elastic`,
|
||||
recommendedActions: 'do something',
|
||||
timestamp: '2019-12-13T16:40:33.400Z',
|
||||
updatedBy: `elastic`,
|
||||
},
|
||||
],
|
||||
prompt: 'hello world',
|
||||
});
|
||||
});
|
||||
|
||||
test('catches error if search throws error', async () => {
|
||||
clients.elasticAssistant.getAlertSummaryDataClient.findDocuments.mockRejectedValueOnce(
|
||||
new Error('Test error')
|
||||
);
|
||||
const response = await server.inject(
|
||||
getCurrentUserAlertSummaryRequest(),
|
||||
requestContextMock.convertContext(context)
|
||||
);
|
||||
expect(response.status).toEqual(500);
|
||||
expect(response.body).toEqual({
|
||||
message: 'Test error',
|
||||
status_code: 500,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('request validation', () => {
|
||||
test('allows optional query params', async () => {
|
||||
const request = requestMock.create({
|
||||
method: 'get',
|
||||
path: ELASTIC_AI_ASSISTANT_PROMPTS_URL_FIND,
|
||||
query: {
|
||||
connector_id: '123',
|
||||
page: 2,
|
||||
per_page: 20,
|
||||
sort_field: 'created_at',
|
||||
fields: ['field1', 'field2'],
|
||||
},
|
||||
});
|
||||
const result = server.validate(request);
|
||||
|
||||
expect(result.ok).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('disallows invalid sort fields', async () => {
|
||||
const request = requestMock.create({
|
||||
method: 'get',
|
||||
path: ELASTIC_AI_ASSISTANT_PROMPTS_URL_FIND,
|
||||
query: {
|
||||
connector_id: '123',
|
||||
page: 2,
|
||||
per_page: 20,
|
||||
sort_field: 'name1',
|
||||
fields: ['field1', 'field2'],
|
||||
},
|
||||
});
|
||||
const result = server.validate(request);
|
||||
|
||||
expect(result.badRequest).toHaveBeenCalledWith(
|
||||
`sort_field: Invalid enum value. Expected 'created_at' | 'updated_at', received 'name1'`
|
||||
);
|
||||
});
|
||||
|
||||
test('ignores unknown query params', async () => {
|
||||
const request = requestMock.create({
|
||||
method: 'get',
|
||||
path: ELASTIC_AI_ASSISTANT_PROMPTS_URL_FIND,
|
||||
query: {
|
||||
connector_id: '123',
|
||||
invalid_value: 'test 1',
|
||||
},
|
||||
});
|
||||
const result = server.validate(request);
|
||||
|
||||
expect(result.ok).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,107 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import type { IKibanaResponse, Logger } from '@kbn/core/server';
|
||||
import { transformError } from '@kbn/securitysolution-es-utils';
|
||||
|
||||
import {
|
||||
API_VERSIONS,
|
||||
ELASTIC_AI_ASSISTANT_ALERT_SUMMARY_URL_FIND,
|
||||
} from '@kbn/elastic-assistant-common';
|
||||
import {
|
||||
FindAlertSummaryRequestQuery,
|
||||
FindAlertSummaryResponse,
|
||||
} from '@kbn/elastic-assistant-common/impl/schemas/alert_summary/find_alert_summary_route.gen';
|
||||
import { buildRouteValidationWithZod } from '@kbn/elastic-assistant-common/impl/schemas/common';
|
||||
import _ from 'lodash';
|
||||
import { getPrompt, promptDictionary } from '../../lib/prompt';
|
||||
import { ElasticAssistantPluginRouter } from '../../types';
|
||||
import { buildResponse } from '../utils';
|
||||
import { EsAlertSummarySchema } from '../../ai_assistant_data_clients/alert_summary/types';
|
||||
import { transformESSearchToAlertSummary } from '../../ai_assistant_data_clients/alert_summary/helpers';
|
||||
import { performChecks } from '../helpers';
|
||||
import { promptGroupId } from '../../lib/prompt/local_prompt_object';
|
||||
|
||||
export const findAlertSummaryRoute = (router: ElasticAssistantPluginRouter, logger: Logger) => {
|
||||
router.versioned
|
||||
.get({
|
||||
access: 'internal',
|
||||
path: ELASTIC_AI_ASSISTANT_ALERT_SUMMARY_URL_FIND,
|
||||
security: {
|
||||
authz: {
|
||||
requiredPrivileges: ['elasticAssistant'],
|
||||
},
|
||||
},
|
||||
})
|
||||
.addVersion(
|
||||
{
|
||||
version: API_VERSIONS.internal.v1,
|
||||
validate: {
|
||||
request: {
|
||||
query: buildRouteValidationWithZod(FindAlertSummaryRequestQuery),
|
||||
},
|
||||
},
|
||||
},
|
||||
async (context, request, response): Promise<IKibanaResponse<FindAlertSummaryResponse>> => {
|
||||
const assistantResponse = buildResponse(response);
|
||||
|
||||
try {
|
||||
const { query } = request;
|
||||
const ctx = await context.resolve(['core', 'elasticAssistant', 'licensing']);
|
||||
// Perform license and authenticated user checks
|
||||
const checkResponse = await performChecks({
|
||||
context: ctx,
|
||||
request,
|
||||
response,
|
||||
});
|
||||
if (!checkResponse.isSuccess) {
|
||||
return checkResponse.response;
|
||||
}
|
||||
const dataClient = await ctx.elasticAssistant.getAlertSummaryDataClient();
|
||||
const actions = ctx.elasticAssistant.actions;
|
||||
const actionsClient = await actions.getActionsClientWithRequest(request);
|
||||
const savedObjectsClient = ctx.elasticAssistant.savedObjectsClient;
|
||||
const result = await dataClient?.findDocuments<EsAlertSummarySchema>({
|
||||
perPage: query.per_page,
|
||||
page: query.page,
|
||||
sortField: query.sort_field,
|
||||
sortOrder: query.sort_order,
|
||||
...(query.filter ? { filter: decodeURIComponent(query.filter) } : {}),
|
||||
fields: query.fields?.map((f) => _.snakeCase(f)),
|
||||
});
|
||||
const prompt = await getPrompt({
|
||||
actionsClient,
|
||||
connectorId: query.connector_id,
|
||||
promptId: promptDictionary.alertSummary,
|
||||
promptGroupId: promptGroupId.aiForSoc,
|
||||
savedObjectsClient,
|
||||
});
|
||||
|
||||
if (result) {
|
||||
return response.ok({
|
||||
body: {
|
||||
perPage: result.perPage,
|
||||
page: result.page,
|
||||
total: result.total,
|
||||
data: transformESSearchToAlertSummary(result.data),
|
||||
prompt,
|
||||
},
|
||||
});
|
||||
}
|
||||
return response.ok({
|
||||
body: { perPage: query.per_page, page: query.page, data: [], total: 0, prompt },
|
||||
});
|
||||
} catch (err) {
|
||||
const error = transformError(err);
|
||||
return assistantResponse.error({
|
||||
body: error.message,
|
||||
statusCode: error.statusCode,
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
};
|
|
@ -24,12 +24,15 @@ import {
|
|||
} from '@kbn/elastic-assistant-common';
|
||||
import { licensingMock } from '@kbn/licensing-plugin/server/mocks';
|
||||
import { appendAssistantMessageToConversation, langChainExecute } from './helpers';
|
||||
import { getPrompt } from '../lib/prompt';
|
||||
|
||||
const license = licensingMock.createLicenseMock();
|
||||
const actionsClient = actionsClientMock.create();
|
||||
jest.mock('../lib/build_response', () => ({
|
||||
buildResponse: jest.fn().mockImplementation((x) => x),
|
||||
}));
|
||||
jest.mock('../lib/prompt');
|
||||
const mockGetPrompt = getPrompt as jest.Mock;
|
||||
|
||||
const mockStream = jest.fn().mockImplementation(() => new PassThrough());
|
||||
const mockLangChainExecute = langChainExecute as jest.Mock;
|
||||
|
@ -466,4 +469,39 @@ describe('postActionsConnectorExecuteRoute', () => {
|
|||
mockGetElser
|
||||
);
|
||||
});
|
||||
it('calls getPrompt with promptIds when passed in request.body', async () => {
|
||||
const mockRouter = {
|
||||
versioned: {
|
||||
post: jest.fn().mockImplementation(() => {
|
||||
return {
|
||||
addVersion: jest.fn().mockImplementation(async (_, handler) => {
|
||||
await handler(
|
||||
mockContext,
|
||||
{
|
||||
...mockRequest,
|
||||
body: {
|
||||
...mockRequest.body,
|
||||
promptIds: { promptId: 'test-prompt-id', promptGroupId: 'test-group-id' },
|
||||
},
|
||||
},
|
||||
mockResponse
|
||||
);
|
||||
|
||||
expect(mockGetPrompt).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
promptId: 'test-prompt-id',
|
||||
promptGroupId: 'test-group-id',
|
||||
})
|
||||
);
|
||||
}),
|
||||
};
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
await postActionsConnectorExecuteRoute(
|
||||
mockRouter as unknown as IRouter<ElasticAssistantRequestHandlerContext>,
|
||||
mockGetElser
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -22,6 +22,7 @@ import {
|
|||
POST_ACTIONS_CONNECTOR_EXECUTE,
|
||||
} from '@kbn/elastic-assistant-common';
|
||||
import { buildRouteValidationWithZod } from '@kbn/elastic-assistant-common/impl/schemas/common';
|
||||
import { getPrompt } from '../lib/prompt';
|
||||
import { INVOKE_ASSISTANT_ERROR_EVENT } from '../lib/telemetry/event_based_telemetry';
|
||||
import { buildResponse } from '../lib/build_response';
|
||||
import { ElasticAssistantRequestHandlerContext, GetElser } from '../types';
|
||||
|
@ -121,7 +122,6 @@ export const postActionsConnectorExecuteRoute = (
|
|||
const conversationsDataClient =
|
||||
await assistantContext.getAIAssistantConversationsDataClient();
|
||||
const promptsDataClient = await assistantContext.getAIAssistantPromptsDataClient();
|
||||
|
||||
const contentReferencesStore = newContentReferencesStore({
|
||||
disabled: request.query.content_references_disabled,
|
||||
});
|
||||
|
@ -148,6 +148,7 @@ export const postActionsConnectorExecuteRoute = (
|
|||
});
|
||||
}
|
||||
};
|
||||
const promptIds = request.body.promptIds;
|
||||
let systemPrompt;
|
||||
if (conversationsDataClient && promptsDataClient && conversationId) {
|
||||
systemPrompt = await getSystemPromptFromUserConversation({
|
||||
|
@ -156,6 +157,20 @@ export const postActionsConnectorExecuteRoute = (
|
|||
promptsDataClient,
|
||||
});
|
||||
}
|
||||
if (promptIds) {
|
||||
const additionalSystemPrompt = await getPrompt({
|
||||
actionsClient,
|
||||
connectorId,
|
||||
// promptIds is promptId and promptGroupId
|
||||
...promptIds,
|
||||
savedObjectsClient,
|
||||
});
|
||||
|
||||
systemPrompt =
|
||||
systemPrompt && systemPrompt.length
|
||||
? `${systemPrompt}\n\n${additionalSystemPrompt}`
|
||||
: additionalSystemPrompt;
|
||||
}
|
||||
return await langChainExecute({
|
||||
abortSignal,
|
||||
isStream: request.body.subAction !== 'invokeAI',
|
||||
|
|
|
@ -7,6 +7,7 @@
|
|||
|
||||
import type { Logger } from '@kbn/core/server';
|
||||
|
||||
import { findAlertSummaryRoute } from './alert_summary/find_route';
|
||||
import { cancelAttackDiscoveryRoute } from './attack_discovery/post/cancel/cancel_attack_discovery';
|
||||
import { getAttackDiscoveryRoute } from './attack_discovery/get/get_attack_discovery';
|
||||
import { postAttackDiscoveryRoute } from './attack_discovery/post/post_attack_discovery';
|
||||
|
@ -41,13 +42,7 @@ import {
|
|||
import { deleteKnowledgeBaseEntryRoute } from './knowledge_base/entries/delete_route';
|
||||
import { updateKnowledgeBaseEntryRoute } from './knowledge_base/entries/update_route';
|
||||
import { getKnowledgeBaseEntryRoute } from './knowledge_base/entries/get_route';
|
||||
import { createAttackDiscoverySchedulesRoute } from './attack_discovery/schedules/create';
|
||||
import { getAttackDiscoverySchedulesRoute } from './attack_discovery/schedules/get';
|
||||
import { updateAttackDiscoverySchedulesRoute } from './attack_discovery/schedules/update';
|
||||
import { deleteAttackDiscoverySchedulesRoute } from './attack_discovery/schedules/delete';
|
||||
import { findAttackDiscoverySchedulesRoute } from './attack_discovery/schedules/find';
|
||||
import { disableAttackDiscoverySchedulesRoute } from './attack_discovery/schedules/disable';
|
||||
import { enableAttackDiscoverySchedulesRoute } from './attack_discovery/schedules/enable';
|
||||
import { bulkAlertSummaryRoute } from './alert_summary/bulk_actions_route';
|
||||
|
||||
export const registerRoutes = (
|
||||
router: ElasticAssistantPluginRouter,
|
||||
|
@ -108,14 +103,9 @@ export const registerRoutes = (
|
|||
postAttackDiscoveryRoute(router);
|
||||
cancelAttackDiscoveryRoute(router);
|
||||
|
||||
// Attack Discovery Schedules
|
||||
createAttackDiscoverySchedulesRoute(router);
|
||||
getAttackDiscoverySchedulesRoute(router);
|
||||
findAttackDiscoverySchedulesRoute(router);
|
||||
updateAttackDiscoverySchedulesRoute(router);
|
||||
deleteAttackDiscoverySchedulesRoute(router);
|
||||
disableAttackDiscoverySchedulesRoute(router);
|
||||
enableAttackDiscoverySchedulesRoute(router);
|
||||
// Alert Summary
|
||||
bulkAlertSummaryRoute(router, logger);
|
||||
findAlertSummaryRoute(router, logger);
|
||||
|
||||
// Defend insights
|
||||
getDefendInsightRoute(router);
|
||||
|
|
|
@ -169,6 +169,16 @@ export class RequestContextFactory implements IRequestContextFactory {
|
|||
});
|
||||
}),
|
||||
|
||||
getAlertSummaryDataClient: memoize(async () => {
|
||||
const currentUser = await getCurrentUser();
|
||||
return this.assistantService.createAlertSummaryDataClient({
|
||||
spaceId: getSpaceId(),
|
||||
licensing: context.licensing,
|
||||
logger: this.logger,
|
||||
currentUser,
|
||||
});
|
||||
}),
|
||||
|
||||
getAIAssistantAnonymizationFieldsDataClient: memoize(async () => {
|
||||
const currentUser = await getCurrentUser();
|
||||
return this.assistantService.createAIAssistantAnonymizationFieldsDataClient({
|
||||
|
|
|
@ -157,6 +157,7 @@ export interface ElasticAssistantApiRequestHandlerContext {
|
|||
getAttackDiscoverySchedulingDataClient: () => Promise<AttackDiscoveryScheduleDataClient | null>;
|
||||
getDefendInsightsDataClient: () => Promise<DefendInsightsDataClient | null>;
|
||||
getAIAssistantPromptsDataClient: () => Promise<AIAssistantDataClient | null>;
|
||||
getAlertSummaryDataClient: () => Promise<AIAssistantDataClient | null>;
|
||||
getAIAssistantAnonymizationFieldsDataClient: () => Promise<AIAssistantDataClient | null>;
|
||||
llmTasks: LlmTasksPluginStart;
|
||||
inference: InferenceServerStart;
|
||||
|
@ -182,6 +183,7 @@ export type GetElser = () => Promise<string> | never;
|
|||
|
||||
export interface AssistantResourceNames {
|
||||
componentTemplate: {
|
||||
alertSummary: string;
|
||||
conversations: string;
|
||||
knowledgeBase: string;
|
||||
prompts: string;
|
||||
|
@ -190,6 +192,7 @@ export interface AssistantResourceNames {
|
|||
defendInsights: string;
|
||||
};
|
||||
indexTemplate: {
|
||||
alertSummary: string;
|
||||
conversations: string;
|
||||
knowledgeBase: string;
|
||||
prompts: string;
|
||||
|
@ -198,6 +201,7 @@ export interface AssistantResourceNames {
|
|||
defendInsights: string;
|
||||
};
|
||||
aliases: {
|
||||
alertSummary: string;
|
||||
conversations: string;
|
||||
knowledgeBase: string;
|
||||
prompts: string;
|
||||
|
@ -206,6 +210,7 @@ export interface AssistantResourceNames {
|
|||
defendInsights: string;
|
||||
};
|
||||
indexPatterns: {
|
||||
alertSummary: string;
|
||||
conversations: string;
|
||||
knowledgeBase: string;
|
||||
prompts: string;
|
||||
|
|
|
@ -170,6 +170,9 @@ export const DEFAULT_INDEX_PATTERN = [...INCLUDE_INDEX_PATTERN, ...EXCLUDE_ELAST
|
|||
/** This Kibana Advanced Setting enables the `Security news` feed widget */
|
||||
export const ENABLE_NEWS_FEED_SETTING = 'securitySolution:enableNewsFeed' as const;
|
||||
|
||||
/** This Kibana Advanced Setting sets a default AI connector for serverless AI features (AI for SOC) */
|
||||
export const DEFAULT_AI_CONNECTOR = 'securitySolution:defaultAIConnector' as const;
|
||||
|
||||
/** This Kibana Advanced Setting allows users to enable/disable querying cold and frozen data tiers in analyzer */
|
||||
export const EXCLUDE_COLD_AND_FROZEN_TIERS_IN_ANALYZER =
|
||||
'securitySolution:excludeColdAndFrozenTiersInAnalyzer' as const;
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
|
||||
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
|
||||
import type { MessageRole } from '@kbn/elastic-assistant-common/impl/schemas';
|
||||
import type { MessageRole } from '@kbn/elastic-assistant-common';
|
||||
import type { ContentMessage } from '..';
|
||||
import { useStream } from './use_stream';
|
||||
import { StopGeneratingButton } from './buttons/stop_generating_button';
|
||||
|
|
|
@ -5,95 +5,8 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import type { AttackDiscovery } from '@kbn/elastic-assistant-common';
|
||||
import * as i18n from './translations';
|
||||
|
||||
export const RECONNAISSANCE = 'Reconnaissance';
|
||||
export const RESOURCE_DEVELOPMENT = 'Resource Development';
|
||||
export const INITIAL_ACCESS = 'Initial Access';
|
||||
export const EXECUTION = 'Execution';
|
||||
export const PERSISTENCE = 'Persistence';
|
||||
export const PRIVILEGE_ESCALATION = 'Privilege Escalation';
|
||||
export const DEFENSE_EVASION = 'Defense Evasion';
|
||||
export const CREDENTIAL_ACCESS = 'Credential Access';
|
||||
export const DISCOVERY = 'Discovery';
|
||||
export const LATERAL_MOVEMENT = 'Lateral Movement';
|
||||
export const COLLECTION = 'Collection';
|
||||
export const COMMAND_AND_CONTROL = 'Command and Control';
|
||||
export const EXFILTRATION = 'Exfiltration';
|
||||
export const IMPACT = 'Impact';
|
||||
|
||||
/** A subset of the Mitre Attack Tactics */
|
||||
export const MITRE_ATTACK_TACTICS_SUBSET = [
|
||||
RECONNAISSANCE,
|
||||
RESOURCE_DEVELOPMENT,
|
||||
INITIAL_ACCESS,
|
||||
EXECUTION,
|
||||
PERSISTENCE,
|
||||
PRIVILEGE_ESCALATION,
|
||||
DEFENSE_EVASION,
|
||||
CREDENTIAL_ACCESS,
|
||||
DISCOVERY,
|
||||
LATERAL_MOVEMENT,
|
||||
COLLECTION,
|
||||
COMMAND_AND_CONTROL,
|
||||
EXFILTRATION,
|
||||
IMPACT,
|
||||
] as const;
|
||||
|
||||
export const getTacticLabel = (tactic: string): string => {
|
||||
switch (tactic) {
|
||||
case RECONNAISSANCE:
|
||||
return i18n.RECONNAISSANCE;
|
||||
case RESOURCE_DEVELOPMENT:
|
||||
return i18n.RESOURCE_DEVELOPMENT;
|
||||
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 DEFENSE_EVASION:
|
||||
return i18n.DEFENSE_EVASION;
|
||||
case CREDENTIAL_ACCESS:
|
||||
return i18n.CREDENTIAL_ACCESS;
|
||||
case DISCOVERY:
|
||||
return i18n.DISCOVERY;
|
||||
case LATERAL_MOVEMENT:
|
||||
return i18n.LATERAL_MOVEMENT;
|
||||
case COLLECTION:
|
||||
return i18n.COLLECTION;
|
||||
case COMMAND_AND_CONTROL:
|
||||
return i18n.COMMAND_AND_CONTROL;
|
||||
case EXFILTRATION:
|
||||
return i18n.EXFILTRATION;
|
||||
case IMPACT:
|
||||
return i18n.IMPACT;
|
||||
default:
|
||||
return tactic;
|
||||
}
|
||||
};
|
||||
|
||||
export interface TacticMetadata {
|
||||
detected: boolean;
|
||||
index: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export const getTacticMetadata = (attackDiscovery: AttackDiscovery): TacticMetadata[] =>
|
||||
MITRE_ATTACK_TACTICS_SUBSET.map((tactic, i) => ({
|
||||
detected:
|
||||
attackDiscovery.mitreAttackTactics === undefined
|
||||
? false
|
||||
: attackDiscovery.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');
|
||||
export {
|
||||
getTacticLabel,
|
||||
getTacticMetadata,
|
||||
replaceNewlineLiterals,
|
||||
} from '@kbn/elastic-assistant-common';
|
||||
|
|
|
@ -8,7 +8,6 @@
|
|||
import { render, screen } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
|
||||
import type { TacticMetadata } from '../../../../../../../helpers';
|
||||
import { getTacticMetadata } from '../../../../../../../helpers';
|
||||
import { mockAttackDiscovery } from '../../../../../../mock/mock_attack_discovery';
|
||||
import { MiniAttackChain } from '.';
|
||||
|
@ -16,7 +15,7 @@ import { MiniAttackChain } from '.';
|
|||
describe('MiniAttackChain', () => {
|
||||
it('displays the expected number of circles', () => {
|
||||
// get detected tactics from the attack discovery:
|
||||
const tacticMetadata: TacticMetadata[] = getTacticMetadata(mockAttackDiscovery);
|
||||
const tacticMetadata = getTacticMetadata(mockAttackDiscovery);
|
||||
expect(tacticMetadata.length).toBeGreaterThan(0); // test pre-condition
|
||||
|
||||
render(<MiniAttackChain attackDiscovery={mockAttackDiscovery} />);
|
||||
|
|
|
@ -0,0 +1,38 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { render } from '@testing-library/react';
|
||||
import {
|
||||
AI_ASSISTANT_SECTION_TEST_ID,
|
||||
AIAssistantSection,
|
||||
SUGGESTED_PROMPTS_SECTION_TEST_ID,
|
||||
} from './ai_assistant_section';
|
||||
import { useAIForSOCDetailsContext } from '../context';
|
||||
import { TestProviders } from '../../../common/mock';
|
||||
|
||||
jest.mock('../context');
|
||||
|
||||
describe('AIAssistantSection', () => {
|
||||
it('should render the switch in the unchecked state by default', () => {
|
||||
(useAIForSOCDetailsContext as jest.Mock).mockReturnValue({
|
||||
eventId: 'eventId',
|
||||
getFieldsData: jest.fn(),
|
||||
});
|
||||
|
||||
const getPromptContext = jest.fn();
|
||||
|
||||
const { getByTestId } = render(
|
||||
<TestProviders>
|
||||
<AIAssistantSection getPromptContext={getPromptContext} />
|
||||
</TestProviders>
|
||||
);
|
||||
|
||||
expect(getByTestId(AI_ASSISTANT_SECTION_TEST_ID)).toHaveTextContent('AI Assistant');
|
||||
expect(getByTestId(SUGGESTED_PROMPTS_SECTION_TEST_ID)).toHaveTextContent('Suggested prompts');
|
||||
});
|
||||
});
|
|
@ -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 React, { memo, useMemo } from 'react';
|
||||
import { EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiTitle } from '@elastic/eui';
|
||||
import { Conversations, SuggestedPrompts } from '@kbn/elastic-assistant';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { ALERT_RULE_NAME, TIMESTAMP } from '@kbn/rule-data-utils';
|
||||
import { getField } from '../../document_details/shared/utils';
|
||||
import { useAIForSOCDetailsContext } from '../context';
|
||||
|
||||
export const AI_ASSISTANT_SECTION_TEST_ID = 'ai-for-soc-alert-flyout-ai-assistant-section';
|
||||
export const SUGGESTED_PROMPTS_SECTION_TEST_ID =
|
||||
'ai-for-soc-alert-flyout-suggested-prompts-section';
|
||||
|
||||
const AI_ASSISTANT = i18n.translate('xpack.securitySolution.aiAssistantSection.title', {
|
||||
defaultMessage: 'AI Assistant',
|
||||
});
|
||||
const SUGGESTED_PROMPTS = i18n.translate(
|
||||
'xpack.securitySolution.alertSummary.suggestedPromptsSection.title',
|
||||
{
|
||||
defaultMessage: 'Suggested prompts',
|
||||
}
|
||||
);
|
||||
|
||||
export interface AIAssistantSectionProps {
|
||||
/**
|
||||
* The Elastic AI Assistant will invoke this function to retrieve the context data,
|
||||
* which will be included in a prompt (e.g. the contents of an alert or an event)
|
||||
*/
|
||||
getPromptContext: () => Promise<string> | Promise<Record<string, string[]>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Panel to be displayed in AI for SOC alert summary flyout
|
||||
*/
|
||||
export const AIAssistantSection = memo(({ getPromptContext }: AIAssistantSectionProps) => {
|
||||
const { eventId, getFieldsData } = useAIForSOCDetailsContext();
|
||||
|
||||
const ruleName = useMemo(() => getField(getFieldsData(ALERT_RULE_NAME)) || '', [getFieldsData]);
|
||||
const timestamp = useMemo(() => getField(getFieldsData(TIMESTAMP)) || '', [getFieldsData]);
|
||||
|
||||
return (
|
||||
<EuiFlexGroup direction="column">
|
||||
<EuiFlexItem data-test-subj={AI_ASSISTANT_SECTION_TEST_ID}>
|
||||
<EuiTitle size={'s'}>
|
||||
<h2>{AI_ASSISTANT}</h2>
|
||||
</EuiTitle>
|
||||
<EuiSpacer size="s" />
|
||||
<Conversations id={eventId} />
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem data-test-subj={SUGGESTED_PROMPTS_SECTION_TEST_ID}>
|
||||
<EuiTitle size="xxs">
|
||||
<h4>{SUGGESTED_PROMPTS}</h4>
|
||||
</EuiTitle>
|
||||
<EuiSpacer size="xs" />
|
||||
<SuggestedPrompts
|
||||
getPromptContext={getPromptContext}
|
||||
ruleName={ruleName}
|
||||
timestamp={timestamp}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
);
|
||||
});
|
||||
|
||||
AIAssistantSection.displayName = 'AIAssistantSection';
|
|
@ -0,0 +1,62 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { render } from '@testing-library/react';
|
||||
import { useAIForSOCDetailsContext } from '../context';
|
||||
import { TestProviders } from '../../../common/mock';
|
||||
import { AISummarySection, ALERT_SUMMARY_SECTION_TEST_ID } from './ai_summary_section';
|
||||
import { ALERT_SUMMARY_OPTIONS_MENU_BUTTON_TEST_ID } from './settings_menu';
|
||||
import { useKibana as mockUseKibana } from '../../../common/lib/kibana/__mocks__';
|
||||
|
||||
jest.mock('../context');
|
||||
|
||||
const mockedUseKibana = {
|
||||
...mockUseKibana(),
|
||||
services: {
|
||||
...mockUseKibana().services,
|
||||
application: {
|
||||
...mockUseKibana().services.application,
|
||||
capabilities: {
|
||||
management: {
|
||||
kibana: {
|
||||
settings: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
uiSettings: {
|
||||
get: jest.fn().mockReturnValue('default-connector-id'),
|
||||
},
|
||||
},
|
||||
};
|
||||
jest.mock('../../../common/lib/kibana', () => {
|
||||
return {
|
||||
...jest.requireActual('../../../common/lib/kibana'),
|
||||
useKibana: () => mockedUseKibana,
|
||||
};
|
||||
});
|
||||
|
||||
describe('AISummarySection', () => {
|
||||
it('should render the switch in the unchecked state by default', () => {
|
||||
(useAIForSOCDetailsContext as jest.Mock).mockReturnValue({
|
||||
eventId: 'eventId',
|
||||
dataFormattedForFieldBrowser: [],
|
||||
showAnonymizedValues: jest.fn(),
|
||||
});
|
||||
const getPromptContext = jest.fn();
|
||||
|
||||
const { getByTestId } = render(
|
||||
<TestProviders>
|
||||
<AISummarySection getPromptContext={getPromptContext} />
|
||||
</TestProviders>
|
||||
);
|
||||
|
||||
expect(getByTestId(ALERT_SUMMARY_SECTION_TEST_ID)).toHaveTextContent('AI summary');
|
||||
expect(getByTestId(ALERT_SUMMARY_OPTIONS_MENU_BUTTON_TEST_ID)).toBeInTheDocument();
|
||||
});
|
||||
});
|
|
@ -0,0 +1,86 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import React, { memo, useMemo, useState } from 'react';
|
||||
import { EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiTitle } from '@elastic/eui';
|
||||
import type { PromptContext } from '@kbn/elastic-assistant';
|
||||
import { AlertSummary } from '@kbn/elastic-assistant';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { AlertSummaryOptionsMenu } from './settings_menu';
|
||||
import { useKibana } from '../../../common/lib/kibana';
|
||||
import { useAIForSOCDetailsContext } from '../context';
|
||||
import { DEFAULT_AI_CONNECTOR } from '../../../../common/constants';
|
||||
|
||||
export const ALERT_SUMMARY_SECTION_TEST_ID = 'ai-for-soc-alert-flyout-alert-summary-section';
|
||||
|
||||
const AI_SUMMARY = i18n.translate('xpack.securitySolution.alertSummary.aiSummarySection.title', {
|
||||
defaultMessage: 'AI summary',
|
||||
});
|
||||
|
||||
export interface AISummarySectionProps {
|
||||
/**
|
||||
* The Elastic AI Assistant will invoke this function to retrieve the context data,
|
||||
* which will be included in a prompt (e.g. the contents of an alert or an event)
|
||||
*/
|
||||
getPromptContext: () => Promise<string> | Promise<Record<string, string[]>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* AI summary section of the AI for SOC alert summary alert flyout.
|
||||
*/
|
||||
export const AISummarySection = memo(({ getPromptContext }: AISummarySectionProps) => {
|
||||
const [hasAlertSummary, setHasAlertSummary] = useState(false);
|
||||
|
||||
const {
|
||||
application: { capabilities },
|
||||
uiSettings,
|
||||
} = useKibana().services;
|
||||
|
||||
const { eventId, dataFormattedForFieldBrowser, showAnonymizedValues } =
|
||||
useAIForSOCDetailsContext();
|
||||
|
||||
const canSeeAdvancedSettings = capabilities.management.kibana.settings ?? false;
|
||||
const defaultConnectorId = uiSettings.get<string>(DEFAULT_AI_CONNECTOR);
|
||||
|
||||
const promptContext: PromptContext = useMemo(
|
||||
() => ({
|
||||
category: 'alert',
|
||||
description: 'Alert summary',
|
||||
getPromptContext,
|
||||
id: `contextId-${eventId}`,
|
||||
tooltip: '', // empty as tooltip is only used within Assistant, but in the flyout
|
||||
}),
|
||||
[eventId, getPromptContext]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<EuiFlexGroup data-test-subj={ALERT_SUMMARY_SECTION_TEST_ID} justifyContent={'spaceBetween'}>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiTitle size={'s'}>
|
||||
<h2>{AI_SUMMARY}</h2>
|
||||
</EuiTitle>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<AlertSummaryOptionsMenu hasAlertSummary={hasAlertSummary} />
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
<EuiSpacer size="s" />
|
||||
<AlertSummary
|
||||
alertId={eventId}
|
||||
canSeeAdvancedSettings={canSeeAdvancedSettings}
|
||||
defaultConnectorId={defaultConnectorId}
|
||||
isContextReady={(dataFormattedForFieldBrowser ?? []).length > 0}
|
||||
promptContext={promptContext}
|
||||
setHasAlertSummary={setHasAlertSummary}
|
||||
showAnonymizedValues={showAnonymizedValues}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
AISummarySection.displayName = 'AISummarySection';
|
|
@ -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 React from 'react';
|
||||
import { fireEvent, render } from '@testing-library/react';
|
||||
import {
|
||||
ALERT_SUMMARY_ANONYMIZE_TOGGLE_TEST_ID,
|
||||
AnonymizationSwitch,
|
||||
} from './anonymization_switch';
|
||||
import { AIForSOCDetailsContext } from '../context';
|
||||
|
||||
const mockSetShowAnonymizedValues = jest.fn();
|
||||
const mockContextValue = {
|
||||
showAnonymizedValues: false,
|
||||
setShowAnonymizedValues: mockSetShowAnonymizedValues,
|
||||
} as unknown as AIForSOCDetailsContext;
|
||||
|
||||
const renderAnonymizedSwitch = (contextValue: AIForSOCDetailsContext, hasAlertSummary: boolean) =>
|
||||
render(
|
||||
<AIForSOCDetailsContext.Provider value={contextValue}>
|
||||
<AnonymizationSwitch hasAlertSummary={hasAlertSummary} />
|
||||
</AIForSOCDetailsContext.Provider>
|
||||
);
|
||||
|
||||
describe('AnonymizationSwitch', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should render the switch in the unchecked state by default', () => {
|
||||
const { getByTestId } = renderAnonymizedSwitch(mockContextValue, true);
|
||||
|
||||
expect(getByTestId(ALERT_SUMMARY_ANONYMIZE_TOGGLE_TEST_ID)).not.toBeChecked();
|
||||
});
|
||||
|
||||
it('should call setShowAnonymizedValues with true when the switch is toggled on', () => {
|
||||
const { getByTestId } = renderAnonymizedSwitch(mockContextValue, true);
|
||||
|
||||
fireEvent.click(getByTestId(ALERT_SUMMARY_ANONYMIZE_TOGGLE_TEST_ID));
|
||||
|
||||
expect(mockSetShowAnonymizedValues).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('should call setShowAnonymizedValues with false when the switch is toggled off', () => {
|
||||
const contextValue = {
|
||||
...mockContextValue,
|
||||
showAnonymizedValues: true,
|
||||
};
|
||||
|
||||
const { getByTestId } = renderAnonymizedSwitch(contextValue, true);
|
||||
|
||||
fireEvent.click(getByTestId(ALERT_SUMMARY_ANONYMIZE_TOGGLE_TEST_ID));
|
||||
|
||||
expect(mockSetShowAnonymizedValues).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it('should not render the switch if showAnonymizedValues is undefined', () => {
|
||||
const contextValue = {
|
||||
...mockContextValue,
|
||||
showAnonymizedValues: undefined,
|
||||
};
|
||||
|
||||
const { queryByTestId } = renderAnonymizedSwitch(contextValue, true);
|
||||
|
||||
expect(queryByTestId(ALERT_SUMMARY_ANONYMIZE_TOGGLE_TEST_ID)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should enable the switch when hasAlertSummary is true', () => {
|
||||
const { getByTestId } = renderAnonymizedSwitch(mockContextValue, true);
|
||||
|
||||
expect(getByTestId(ALERT_SUMMARY_ANONYMIZE_TOGGLE_TEST_ID)).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it('should disable the switch when hasAlertSummary is false', () => {
|
||||
const { getByTestId } = renderAnonymizedSwitch(mockContextValue, false);
|
||||
|
||||
expect(getByTestId(ALERT_SUMMARY_ANONYMIZE_TOGGLE_TEST_ID)).toBeDisabled();
|
||||
});
|
||||
});
|
|
@ -0,0 +1,106 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import React, { memo, useCallback } from 'react';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import type { EuiSwitchEvent } from '@elastic/eui';
|
||||
import { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiSwitch, EuiToolTip } from '@elastic/eui';
|
||||
import { FormattedMessage } from '@kbn/i18n-react';
|
||||
import { useAIForSOCDetailsContext } from '../context';
|
||||
|
||||
export const ALERT_SUMMARY_ANONYMIZE_TOGGLE_TEST_ID =
|
||||
'ai-for-soc-alert-flyout-alert-summary-anonymize-toggle';
|
||||
|
||||
/**
|
||||
* Conditionally wrap a component
|
||||
*/
|
||||
const ConditionalWrap = ({
|
||||
condition,
|
||||
wrap,
|
||||
children,
|
||||
}: {
|
||||
condition: boolean;
|
||||
wrap: (children: React.ReactElement) => React.ReactElement;
|
||||
children: React.ReactElement;
|
||||
}) => (condition ? wrap(children) : children);
|
||||
|
||||
export interface AnonymizationSwitchProps {
|
||||
/**
|
||||
* If true, the component will wrap the toggle with a tooltip
|
||||
*/
|
||||
hasAlertSummary: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a toggle switch used in the AI for SOC alert summary flyout in the AI summary section.
|
||||
* This enables/disables anonymized values.
|
||||
*/
|
||||
export const AnonymizationSwitch = memo(({ hasAlertSummary }: AnonymizationSwitchProps) => {
|
||||
const { showAnonymizedValues, setShowAnonymizedValues } = useAIForSOCDetailsContext();
|
||||
|
||||
const onChangeShowAnonymizedValues = useCallback(
|
||||
(e: EuiSwitchEvent) => {
|
||||
setShowAnonymizedValues(e.target.checked);
|
||||
},
|
||||
[setShowAnonymizedValues]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{showAnonymizedValues !== undefined && (
|
||||
<EuiFlexGroup direction="row" gutterSize="s" alignItems="center">
|
||||
<EuiFlexItem grow={false}>
|
||||
<ConditionalWrap
|
||||
condition={!hasAlertSummary}
|
||||
wrap={(children) => (
|
||||
<EuiToolTip
|
||||
position="top"
|
||||
key={'disabled-anonymize-values-tooltip'}
|
||||
content={
|
||||
<FormattedMessage
|
||||
id="xpack.securitySolution.flyout.settings.anonymizeValues.disabled.tooltip"
|
||||
defaultMessage="The alert summary has not generated, and does not contain anonymized fields."
|
||||
/>
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</EuiToolTip>
|
||||
)}
|
||||
>
|
||||
<EuiSwitch
|
||||
data-test-subj={ALERT_SUMMARY_ANONYMIZE_TOGGLE_TEST_ID}
|
||||
checked={showAnonymizedValues ?? false}
|
||||
compressed
|
||||
disabled={!hasAlertSummary}
|
||||
label={i18n.translate('xpack.securitySolution.flyout.settings.anonymizeValues', {
|
||||
defaultMessage: 'Show anonymized values',
|
||||
})}
|
||||
onChange={onChangeShowAnonymizedValues}
|
||||
/>
|
||||
</ConditionalWrap>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiToolTip
|
||||
position="top"
|
||||
key={'anonymize-values-tooltip'}
|
||||
content={
|
||||
<FormattedMessage
|
||||
id="xpack.securitySolution.flyout.settings.anonymizeValues.tooltip"
|
||||
defaultMessage="Toggle to reveal or obfuscate field values in your alert summary. The data sent to the LLM is still anonymized based on settings in Configurations > AI Settings > Anonymization."
|
||||
/>
|
||||
}
|
||||
>
|
||||
<EuiIcon tabIndex={0} type="iInCircle" />
|
||||
</EuiToolTip>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
AnonymizationSwitch.displayName = 'AnonymizationSwitch';
|
|
@ -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 React from 'react';
|
||||
import { render } from '@testing-library/react';
|
||||
import { useAIForSOCDetailsContext } from '../context';
|
||||
import { TestProviders } from '../../../common/mock';
|
||||
import { AttackDiscoverySection } from './attack_discovery_section';
|
||||
import { ATTACK_DISCOVERY_SECTION_TEST_ID } from '..';
|
||||
|
||||
jest.mock('../context');
|
||||
|
||||
describe('AttackDiscoverySection', () => {
|
||||
it('should render the switch in the unchecked state by default', () => {
|
||||
(useAIForSOCDetailsContext as jest.Mock).mockReturnValue({
|
||||
eventId: 'eventId',
|
||||
});
|
||||
|
||||
const { getByTestId } = render(
|
||||
<TestProviders>
|
||||
<AttackDiscoverySection />
|
||||
</TestProviders>
|
||||
);
|
||||
|
||||
expect(getByTestId(ATTACK_DISCOVERY_SECTION_TEST_ID)).toHaveTextContent('Attack Discovery');
|
||||
});
|
||||
});
|
|
@ -0,0 +1,39 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import React, { memo } from 'react';
|
||||
import { EuiSpacer, EuiTitle } from '@elastic/eui';
|
||||
import { AttackDiscoveryWidget } from '@kbn/elastic-assistant';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { ATTACK_DISCOVERY_SECTION_TEST_ID } from '..';
|
||||
import { useAIForSOCDetailsContext } from '../context';
|
||||
|
||||
const ATTACK_DISCOVERY = i18n.translate(
|
||||
'xpack.securitySolution.alertSummary.attackDiscoverySection.title',
|
||||
{
|
||||
defaultMessage: 'Attack Discovery',
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Panel to be displayed in AI for SOC alert summary flyout
|
||||
*/
|
||||
export const AttackDiscoverySection = memo(() => {
|
||||
const { eventId } = useAIForSOCDetailsContext();
|
||||
|
||||
return (
|
||||
<div data-test-subj={ATTACK_DISCOVERY_SECTION_TEST_ID}>
|
||||
<EuiTitle size={'s'}>
|
||||
<h2>{ATTACK_DISCOVERY}</h2>
|
||||
</EuiTitle>
|
||||
<EuiSpacer size="s" />
|
||||
<AttackDiscoveryWidget id={eventId} />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
AttackDiscoverySection.displayName = 'AttackDiscoverySection';
|
|
@ -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 React from 'react';
|
||||
import { render } from '@testing-library/react';
|
||||
import {
|
||||
ALERT_SUMMARY_OPTIONS_MENU_BUTTON_TEST_ID,
|
||||
ALERT_SUMMARY_OPTIONS_MENU_PANELS_TEST_ID,
|
||||
AlertSummaryOptionsMenu,
|
||||
} from './settings_menu';
|
||||
import { ALERT_SUMMARY_ANONYMIZE_TOGGLE_TEST_ID } from './anonymization_switch';
|
||||
import { AIForSOCDetailsContext } from '../context';
|
||||
|
||||
const mockContextValue = {
|
||||
showAnonymizedValues: false,
|
||||
setShowAnonymizedValues: jest.fn(),
|
||||
} as unknown as AIForSOCDetailsContext;
|
||||
|
||||
describe('AlertSummaryOptionsMenu', () => {
|
||||
it('renders button with the anonymize option', () => {
|
||||
const { getByTestId } = render(
|
||||
<AIForSOCDetailsContext.Provider value={mockContextValue}>
|
||||
<AlertSummaryOptionsMenu hasAlertSummary={true} />
|
||||
</AIForSOCDetailsContext.Provider>
|
||||
);
|
||||
|
||||
const button = getByTestId(ALERT_SUMMARY_OPTIONS_MENU_BUTTON_TEST_ID);
|
||||
|
||||
expect(button).toBeInTheDocument();
|
||||
|
||||
button.click();
|
||||
|
||||
expect(getByTestId(ALERT_SUMMARY_ANONYMIZE_TOGGLE_TEST_ID)).toBeInTheDocument();
|
||||
expect(getByTestId(ALERT_SUMMARY_OPTIONS_MENU_PANELS_TEST_ID)).toHaveTextContent('Options');
|
||||
expect(getByTestId(ALERT_SUMMARY_OPTIONS_MENU_PANELS_TEST_ID)).toHaveTextContent(
|
||||
'Show anonymized values'
|
||||
);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,80 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import React, { memo, useCallback, useMemo, useState } from 'react';
|
||||
import { EuiButtonIcon, EuiContextMenu, EuiPanel, EuiPopover } from '@elastic/eui';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { AnonymizationSwitch } from './anonymization_switch';
|
||||
|
||||
export const ALERT_SUMMARY_OPTIONS_MENU_BUTTON_TEST_ID =
|
||||
'ai-for-soc-alert-flyout-alert-summary-options-menu-button';
|
||||
export const ALERT_SUMMARY_OPTIONS_MENU_PANELS_TEST_ID =
|
||||
'ai-for-soc-alert-flyout-alert-summary-options-menu-panels';
|
||||
|
||||
const OPTIONS_MENU = i18n.translate('xpack.securitySolution.flyout.alertSummary.optionsMenuTitle', {
|
||||
defaultMessage: 'Options',
|
||||
});
|
||||
|
||||
export interface AlertSummaryOptionsMenu {
|
||||
/**
|
||||
* To pass down to the anonymization component rendered in the popover
|
||||
*/
|
||||
hasAlertSummary: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options menu displayed to the right of the AI summary section in the alert summary flyout.
|
||||
* It currently contains a single option to allows anonymizing values.
|
||||
*/
|
||||
export const AlertSummaryOptionsMenu = memo(({ hasAlertSummary }: AlertSummaryOptionsMenu) => {
|
||||
const [isPopoverOpen, setPopover] = useState(false);
|
||||
const togglePopover = useCallback(() => setPopover(!isPopoverOpen), [isPopoverOpen]);
|
||||
|
||||
const button = useMemo(
|
||||
() => (
|
||||
<EuiButtonIcon
|
||||
aria-label={OPTIONS_MENU}
|
||||
data-test-subj={ALERT_SUMMARY_OPTIONS_MENU_BUTTON_TEST_ID}
|
||||
color="text"
|
||||
iconType="boxesVertical"
|
||||
onClick={togglePopover}
|
||||
/>
|
||||
),
|
||||
[togglePopover]
|
||||
);
|
||||
const panels = useMemo(
|
||||
() => [
|
||||
{
|
||||
id: 0,
|
||||
title: OPTIONS_MENU,
|
||||
content: (
|
||||
<EuiPanel paddingSize="s">
|
||||
<AnonymizationSwitch hasAlertSummary={hasAlertSummary} />
|
||||
</EuiPanel>
|
||||
),
|
||||
},
|
||||
],
|
||||
[hasAlertSummary]
|
||||
);
|
||||
return (
|
||||
<EuiPopover
|
||||
button={button}
|
||||
isOpen={isPopoverOpen}
|
||||
closePopover={togglePopover}
|
||||
panelPaddingSize="none"
|
||||
anchorPosition="downLeft"
|
||||
>
|
||||
<EuiContextMenu
|
||||
data-test-subj={ALERT_SUMMARY_OPTIONS_MENU_PANELS_TEST_ID}
|
||||
initialPanelId={0}
|
||||
panels={panels}
|
||||
/>
|
||||
</EuiPopover>
|
||||
);
|
||||
});
|
||||
|
||||
AlertSummaryOptionsMenu.displayName = 'AlertSummaryOptionsMenu';
|
|
@ -8,12 +8,16 @@
|
|||
import React, { createContext, memo, useContext, useMemo } from 'react';
|
||||
import type { BrowserFields, TimelineEventsDetailsItem } from '@kbn/timelines-plugin/common';
|
||||
import type { EcsSecurityExtension as Ecs } from '@kbn/securitysolution-ecs';
|
||||
import useLocalStorage from 'react-use/lib/useLocalStorage';
|
||||
import { useRuleWithFallback } from '../../detection_engine/rule_management/logic/use_rule_with_fallback';
|
||||
import { useSpaceId } from '../../common/hooks/use_space_id';
|
||||
import type { SearchHit } from '../../../common/search_strategy';
|
||||
import type { GetFieldsData } from '../document_details/shared/hooks/use_get_fields_data';
|
||||
import { FlyoutLoading } from '../shared/components/flyout_loading';
|
||||
import { useEventDetails } from '../document_details/shared/hooks/use_event_details';
|
||||
import type { AIForSOCDetailsProps } from './types';
|
||||
import { FlyoutError } from '../shared/components/flyout_error';
|
||||
import { useBasicDataFromDetailsData } from '../document_details/shared/hooks/use_basic_data_from_details_data';
|
||||
|
||||
export interface AIForSOCDetailsContext {
|
||||
/**
|
||||
|
@ -44,6 +48,16 @@ export interface AIForSOCDetailsContext {
|
|||
* The actual raw document object
|
||||
*/
|
||||
searchHit: SearchHit;
|
||||
/**
|
||||
* User defined fields to highlight (defined on the rule)
|
||||
*/
|
||||
investigationFields: string[];
|
||||
/**
|
||||
* Anonymization switch state in local storage
|
||||
* If undefined, the spaceId is not retrievable and the switch is not shown
|
||||
*/
|
||||
showAnonymizedValues?: boolean;
|
||||
setShowAnonymizedValues: React.Dispatch<React.SetStateAction<boolean | undefined>>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -71,6 +85,16 @@ export const AIForSOCDetailsProvider = memo(
|
|||
eventId: id,
|
||||
indexName,
|
||||
});
|
||||
|
||||
const { ruleId } = useBasicDataFromDetailsData(dataFormattedForFieldBrowser);
|
||||
const { rule: maybeRule } = useRuleWithFallback(ruleId);
|
||||
|
||||
const spaceId = useSpaceId();
|
||||
const [showAnonymizedValues = spaceId ? false : undefined, setShowAnonymizedValues] =
|
||||
useLocalStorage<boolean | undefined>(
|
||||
`securitySolution.aiAlertFlyout.showAnonymization.${spaceId}`
|
||||
);
|
||||
|
||||
const contextValue = useMemo(
|
||||
() =>
|
||||
dataFormattedForFieldBrowser && dataAsNestedObject && id && indexName && searchHit
|
||||
|
@ -82,6 +106,9 @@ export const AIForSOCDetailsProvider = memo(
|
|||
getFieldsData,
|
||||
indexName,
|
||||
searchHit,
|
||||
investigationFields: maybeRule?.investigation_fields?.field_names ?? [],
|
||||
setShowAnonymizedValues,
|
||||
showAnonymizedValues,
|
||||
}
|
||||
: undefined,
|
||||
[
|
||||
|
@ -91,7 +118,10 @@ export const AIForSOCDetailsProvider = memo(
|
|||
getFieldsData,
|
||||
id,
|
||||
indexName,
|
||||
maybeRule,
|
||||
searchHit,
|
||||
setShowAnonymizedValues,
|
||||
showAnonymizedValues,
|
||||
]
|
||||
);
|
||||
|
||||
|
|
|
@ -0,0 +1,94 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { render } from '@testing-library/react';
|
||||
import { AIForSOCPanel, FLYOUT_BODY_TEST_ID } from '.';
|
||||
import { useKibana as mockUseKibana } from '../../common/lib/kibana/__mocks__';
|
||||
import { TestProviders } from '../../common/mock';
|
||||
import { mockDataFormattedForFieldBrowser } from '../document_details/shared/mocks/mock_data_formatted_for_field_browser';
|
||||
import { type FlyoutPanelHistory, useExpandableFlyoutHistory } from '@kbn/expandable-flyout';
|
||||
import {
|
||||
COLLAPSE_DETAILS_BUTTON_TEST_ID,
|
||||
EXPAND_DETAILS_BUTTON_TEST_ID,
|
||||
FLYOUT_HISTORY_BUTTON_TEST_ID,
|
||||
} from '../shared/components/test_ids';
|
||||
import { useAIForSOCDetailsContext } from './context';
|
||||
import { TAKE_ACTION_BUTTON_TEST_ID } from './components/take_action_button';
|
||||
import { mockDataAsNestedObject } from '../document_details/shared/mocks/mock_data_as_nested_object';
|
||||
|
||||
jest.mock('@kbn/expandable-flyout', () => ({
|
||||
useExpandableFlyoutApi: jest.fn().mockReturnValue({ closeLeftPanel: jest.fn() }),
|
||||
useExpandableFlyoutHistory: jest.fn(),
|
||||
useExpandableFlyoutState: jest.fn().mockReturnValue({ left: {} }),
|
||||
}));
|
||||
|
||||
jest.mock('./context');
|
||||
|
||||
const mockedUseKibana = {
|
||||
...mockUseKibana(),
|
||||
services: {
|
||||
...mockUseKibana().services,
|
||||
application: {
|
||||
...mockUseKibana().services.application,
|
||||
capabilities: {
|
||||
management: {
|
||||
kibana: {
|
||||
settings: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
uiSettings: {
|
||||
get: jest.fn().mockReturnValue('default-connector-id'),
|
||||
},
|
||||
},
|
||||
};
|
||||
jest.mock('../../common/lib/kibana', () => {
|
||||
return {
|
||||
...jest.requireActual('../../common/lib/kibana'),
|
||||
useKibana: () => mockedUseKibana,
|
||||
};
|
||||
});
|
||||
|
||||
describe('AIForSOCPanel', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
const flyoutHistory: FlyoutPanelHistory[] = [
|
||||
{ lastOpen: Date.now(), panel: { id: 'id1', params: {} } },
|
||||
];
|
||||
(useExpandableFlyoutHistory as jest.Mock).mockReturnValue(flyoutHistory);
|
||||
});
|
||||
|
||||
it('renders the AIForSOCPanel component', () => {
|
||||
(useAIForSOCDetailsContext as jest.Mock).mockReturnValue({
|
||||
dataAsNestedObject: mockDataAsNestedObject,
|
||||
dataFormattedForFieldBrowser: mockDataFormattedForFieldBrowser,
|
||||
getFieldsData: jest.fn(),
|
||||
investigationFields: [],
|
||||
});
|
||||
|
||||
const { getByTestId, queryByTestId } = render(
|
||||
<TestProviders>
|
||||
<AIForSOCPanel />
|
||||
</TestProviders>
|
||||
);
|
||||
|
||||
expect(queryByTestId(EXPAND_DETAILS_BUTTON_TEST_ID)).not.toBeInTheDocument();
|
||||
expect(queryByTestId(COLLAPSE_DETAILS_BUTTON_TEST_ID)).not.toBeInTheDocument();
|
||||
|
||||
expect(getByTestId(FLYOUT_HISTORY_BUTTON_TEST_ID)).toBeInTheDocument();
|
||||
|
||||
expect(getByTestId(FLYOUT_BODY_TEST_ID)).toHaveTextContent('AI summary');
|
||||
expect(getByTestId(FLYOUT_BODY_TEST_ID)).toHaveTextContent('Attack Discovery');
|
||||
expect(getByTestId(FLYOUT_BODY_TEST_ID)).toHaveTextContent('AI Assistant');
|
||||
expect(getByTestId(FLYOUT_BODY_TEST_ID)).toHaveTextContent('Suggested prompts');
|
||||
|
||||
expect(getByTestId(TAKE_ACTION_BUTTON_TEST_ID)).toBeInTheDocument();
|
||||
});
|
||||
});
|
|
@ -5,7 +5,13 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import React, { memo } from 'react';
|
||||
import React, { memo, useCallback } from 'react';
|
||||
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
|
||||
import { getRawData } from '../../assistant/helpers';
|
||||
import { AIAssistantSection } from './components/ai_assistant_section';
|
||||
import { AttackDiscoverySection } from './components/attack_discovery_section';
|
||||
import { AISummarySection } from './components/ai_summary_section';
|
||||
import { HighlightedFields } from '../document_details/right/components/highlighted_fields';
|
||||
import { useAIForSOCDetailsContext } from './context';
|
||||
import { FlyoutBody } from '../shared/components/flyout_body';
|
||||
import { FlyoutNavigation } from '../shared/components/flyout_navigation';
|
||||
|
@ -15,12 +21,18 @@ import { FlyoutHeader } from '../shared/components/flyout_header';
|
|||
import { HeaderTitle } from './components/header_title';
|
||||
|
||||
export const FLYOUT_BODY_TEST_ID = 'ai-for-soc-alert-flyout-body';
|
||||
export const ATTACK_DISCOVERY_SECTION_TEST_ID = 'ai-for-soc-alert-flyout-attack-discovery-section';
|
||||
|
||||
/**
|
||||
* Panel to be displayed in AI for SOC alert summary flyout
|
||||
*/
|
||||
export const AIForSOCPanel: React.FC<Partial<AIForSOCDetailsProps>> = memo(() => {
|
||||
const { eventId } = useAIForSOCDetailsContext();
|
||||
const { dataFormattedForFieldBrowser, investigationFields } = useAIForSOCDetailsContext();
|
||||
|
||||
const getPromptContext = useCallback(
|
||||
async () => getRawData(dataFormattedForFieldBrowser),
|
||||
[dataFormattedForFieldBrowser]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
@ -29,10 +41,28 @@ export const AIForSOCPanel: React.FC<Partial<AIForSOCDetailsProps>> = memo(() =>
|
|||
<HeaderTitle />
|
||||
</FlyoutHeader>
|
||||
<FlyoutBody data-test-subj={FLYOUT_BODY_TEST_ID}>
|
||||
<>{eventId}</>
|
||||
<EuiFlexGroup direction="column">
|
||||
<EuiFlexItem>
|
||||
<AISummarySection getPromptContext={getPromptContext} />
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem>
|
||||
<HighlightedFields
|
||||
dataFormattedForFieldBrowser={dataFormattedForFieldBrowser}
|
||||
investigationFields={investigationFields}
|
||||
isPreview={false}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem>
|
||||
<AttackDiscoverySection />
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem>
|
||||
<AIAssistantSection getPromptContext={getPromptContext} />
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</FlyoutBody>
|
||||
<PanelFooter />
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
AIForSOCPanel.displayName = 'AIForSOCPanel';
|
||||
|
|
|
@ -7,7 +7,6 @@
|
|||
|
||||
import React from 'react';
|
||||
import { render } from '@testing-library/react';
|
||||
import { DocumentDetailsContext } from '../../shared/context';
|
||||
import {
|
||||
HIGHLIGHTED_FIELDS_DETAILS_TEST_ID,
|
||||
HIGHLIGHTED_FIELDS_EDIT_BUTTON_TEST_ID,
|
||||
|
@ -18,7 +17,6 @@ import { useHighlightedFields } from '../../shared/hooks/use_highlighted_fields'
|
|||
import { TestProviders } from '../../../../common/mock';
|
||||
import { useRuleIndexPattern } from '../../../../detection_engine/rule_creation_ui/pages/form';
|
||||
import { mockContextValue } from '../../shared/mocks/mock_context';
|
||||
import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features';
|
||||
import { useHighlightedFieldsPrivilege } from '../../shared/hooks/use_highlighted_fields_privilege';
|
||||
import { useRuleDetails } from '../../../rule_details/hooks/use_rule_details';
|
||||
import type { RuleResponse } from '../../../../../common/api/detection_engine';
|
||||
|
@ -26,7 +24,6 @@ import type { RuleResponse } from '../../../../../common/api/detection_engine';
|
|||
jest.mock('../../shared/hooks/use_highlighted_fields');
|
||||
jest.mock('../../../../detection_engine/rule_management/logic/use_rule_with_fallback');
|
||||
jest.mock('../../../../detection_engine/rule_creation_ui/pages/form');
|
||||
jest.mock('../../../../common/hooks/use_experimental_features');
|
||||
jest.mock('../../shared/hooks/use_highlighted_fields_privilege');
|
||||
jest.mock('../../../rule_details/hooks/use_rule_details');
|
||||
const mockAddSuccess = jest.fn();
|
||||
|
@ -36,109 +33,80 @@ jest.mock('../../../../common/hooks/use_app_toasts', () => ({
|
|||
}),
|
||||
}));
|
||||
|
||||
const renderHighlightedFields = (contextValue: DocumentDetailsContext) =>
|
||||
const renderHighlightedFields = (showEditButton = false) =>
|
||||
render(
|
||||
<TestProviders>
|
||||
<DocumentDetailsContext.Provider value={contextValue}>
|
||||
<HighlightedFields />
|
||||
</DocumentDetailsContext.Provider>
|
||||
<HighlightedFields
|
||||
dataFormattedForFieldBrowser={mockContextValue.dataFormattedForFieldBrowser}
|
||||
investigationFields={mockContextValue.investigationFields}
|
||||
isPreview={mockContextValue.isPreview}
|
||||
scopeId={mockContextValue.scopeId}
|
||||
showEditButton={showEditButton}
|
||||
/>
|
||||
</TestProviders>
|
||||
);
|
||||
|
||||
const NO_DATA_MESSAGE = "There's no highlighted fields for this alert.";
|
||||
|
||||
describe('<HighlightedFields />', () => {
|
||||
describe('when editHighlightedFieldsEnabled is false', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
(useIsExperimentalFeatureEnabled as jest.Mock).mockReturnValue(false);
|
||||
(useHighlightedFieldsPrivilege as jest.Mock).mockReturnValue({
|
||||
isEditHighlightedFieldsDisabled: false,
|
||||
tooltipContent: 'tooltip content',
|
||||
});
|
||||
(useRuleIndexPattern as jest.Mock).mockReturnValue({
|
||||
indexPattern: { fields: ['field'] },
|
||||
isIndexPatternLoading: false,
|
||||
});
|
||||
(useRuleDetails as jest.Mock).mockReturnValue({
|
||||
rule: null,
|
||||
isExistingRule: true,
|
||||
loading: false,
|
||||
});
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
(useHighlightedFieldsPrivilege as jest.Mock).mockReturnValue({
|
||||
isEditHighlightedFieldsDisabled: false,
|
||||
tooltipContent: 'tooltip content',
|
||||
});
|
||||
|
||||
it('should render the component', () => {
|
||||
(useHighlightedFields as jest.Mock).mockReturnValue({
|
||||
field: {
|
||||
values: ['value'],
|
||||
},
|
||||
});
|
||||
|
||||
const { getByTestId, queryByTestId } = renderHighlightedFields(mockContextValue);
|
||||
|
||||
expect(getByTestId(HIGHLIGHTED_FIELDS_TITLE_TEST_ID)).toBeInTheDocument();
|
||||
expect(getByTestId(HIGHLIGHTED_FIELDS_DETAILS_TEST_ID)).toBeInTheDocument();
|
||||
expect(queryByTestId(HIGHLIGHTED_FIELDS_EDIT_BUTTON_TEST_ID)).not.toBeInTheDocument();
|
||||
(useRuleIndexPattern as jest.Mock).mockReturnValue({
|
||||
indexPattern: { fields: ['field'] },
|
||||
isIndexPatternLoading: false,
|
||||
});
|
||||
|
||||
it(`should render no data message if there aren't any highlighted fields`, () => {
|
||||
(useHighlightedFields as jest.Mock).mockReturnValue({});
|
||||
|
||||
const { getByText, queryByTestId } = renderHighlightedFields(mockContextValue);
|
||||
expect(getByText(NO_DATA_MESSAGE)).toBeInTheDocument();
|
||||
expect(queryByTestId(HIGHLIGHTED_FIELDS_EDIT_BUTTON_TEST_ID)).not.toBeInTheDocument();
|
||||
(useRuleDetails as jest.Mock).mockReturnValue({
|
||||
rule: { id: '123' } as RuleResponse,
|
||||
isExistingRule: true,
|
||||
loading: false,
|
||||
});
|
||||
});
|
||||
|
||||
describe('when editHighlightedFieldsEnabled is true', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
(useIsExperimentalFeatureEnabled as jest.Mock).mockReturnValue(true);
|
||||
(useHighlightedFieldsPrivilege as jest.Mock).mockReturnValue({
|
||||
isEditHighlightedFieldsDisabled: false,
|
||||
tooltipContent: 'tooltip content',
|
||||
});
|
||||
(useRuleIndexPattern as jest.Mock).mockReturnValue({
|
||||
indexPattern: { fields: ['field'] },
|
||||
isIndexPatternLoading: false,
|
||||
});
|
||||
(useRuleDetails as jest.Mock).mockReturnValue({
|
||||
rule: { id: '123' } as RuleResponse,
|
||||
isExistingRule: true,
|
||||
loading: false,
|
||||
});
|
||||
it('should render the component', () => {
|
||||
(useHighlightedFields as jest.Mock).mockReturnValue({
|
||||
field: {
|
||||
values: ['value'],
|
||||
},
|
||||
});
|
||||
|
||||
it('should render the component', () => {
|
||||
(useHighlightedFields as jest.Mock).mockReturnValue({
|
||||
field: {
|
||||
values: ['value'],
|
||||
},
|
||||
});
|
||||
const { getByTestId, queryByTestId } = renderHighlightedFields();
|
||||
|
||||
const { getByTestId } = renderHighlightedFields(mockContextValue);
|
||||
expect(getByTestId(HIGHLIGHTED_FIELDS_TITLE_TEST_ID)).toBeInTheDocument();
|
||||
expect(getByTestId(HIGHLIGHTED_FIELDS_DETAILS_TEST_ID)).toBeInTheDocument();
|
||||
expect(queryByTestId(HIGHLIGHTED_FIELDS_EDIT_BUTTON_TEST_ID)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(getByTestId(HIGHLIGHTED_FIELDS_TITLE_TEST_ID)).toBeInTheDocument();
|
||||
expect(getByTestId(HIGHLIGHTED_FIELDS_DETAILS_TEST_ID)).toBeInTheDocument();
|
||||
expect(getByTestId(HIGHLIGHTED_FIELDS_EDIT_BUTTON_TEST_ID)).toBeInTheDocument();
|
||||
it(`should render no data message if there aren't any highlighted fields`, () => {
|
||||
(useHighlightedFields as jest.Mock).mockReturnValue({});
|
||||
|
||||
const { getByText, queryByTestId } = renderHighlightedFields();
|
||||
expect(getByText(NO_DATA_MESSAGE)).toBeInTheDocument();
|
||||
expect(queryByTestId(HIGHLIGHTED_FIELDS_EDIT_BUTTON_TEST_ID)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render the component with edit button', () => {
|
||||
(useHighlightedFields as jest.Mock).mockReturnValue({
|
||||
field: {
|
||||
values: ['value'],
|
||||
},
|
||||
});
|
||||
|
||||
it(`should render no data message if there aren't any highlighted fields`, () => {
|
||||
(useHighlightedFields as jest.Mock).mockReturnValue({});
|
||||
const { getByTestId } = renderHighlightedFields(true);
|
||||
|
||||
const { getByText, getByTestId } = renderHighlightedFields(mockContextValue);
|
||||
expect(getByText(NO_DATA_MESSAGE)).toBeInTheDocument();
|
||||
expect(getByTestId(HIGHLIGHTED_FIELDS_EDIT_BUTTON_TEST_ID)).toBeInTheDocument();
|
||||
});
|
||||
expect(getByTestId(HIGHLIGHTED_FIELDS_EDIT_BUTTON_TEST_ID)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not render edit button if rule is null', () => {
|
||||
(useRuleDetails as jest.Mock).mockReturnValue({
|
||||
rule: null,
|
||||
isExistingRule: true,
|
||||
loading: false,
|
||||
});
|
||||
const { queryByTestId } = renderHighlightedFields(mockContextValue);
|
||||
expect(queryByTestId(HIGHLIGHTED_FIELDS_EDIT_BUTTON_TEST_ID)).not.toBeInTheDocument();
|
||||
it('should not render edit button if rule is null', () => {
|
||||
(useRuleDetails as jest.Mock).mockReturnValue({
|
||||
rule: null,
|
||||
isExistingRule: true,
|
||||
loading: false,
|
||||
});
|
||||
const { queryByTestId } = renderHighlightedFields(true);
|
||||
expect(queryByTestId(HIGHLIGHTED_FIELDS_EDIT_BUTTON_TEST_ID)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -5,19 +5,17 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import type { FC } from 'react';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import React, { memo, useMemo, useState } from 'react';
|
||||
import type { EuiBasicTableColumn } from '@elastic/eui';
|
||||
import { EuiFlexGroup, EuiFlexItem, EuiInMemoryTable, EuiPanel, EuiTitle } from '@elastic/eui';
|
||||
import { FormattedMessage } from '@kbn/i18n-react';
|
||||
import type { TimelineEventsDetailsItem } from '@kbn/timelines-plugin/common';
|
||||
import { convertHighlightedFieldsToTableRow } from '../../shared/utils/highlighted_fields_helpers';
|
||||
import { HighlightedFieldsCell } from './highlighted_fields_cell';
|
||||
import { CellActions } from '../../shared/components/cell_actions';
|
||||
import { HIGHLIGHTED_FIELDS_DETAILS_TEST_ID, HIGHLIGHTED_FIELDS_TITLE_TEST_ID } from './test_ids';
|
||||
import { useDocumentDetailsContext } from '../../shared/context';
|
||||
import { useHighlightedFields } from '../../shared/hooks/use_highlighted_fields';
|
||||
import { EditHighlightedFieldsButton } from './highlighted_fields_button';
|
||||
import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features';
|
||||
|
||||
export interface HighlightedFieldsTableRow {
|
||||
/**
|
||||
|
@ -39,12 +37,18 @@ export interface HighlightedFieldsTableRow {
|
|||
values: string[] | null | undefined;
|
||||
/**
|
||||
* Maintain backwards compatibility // TODO remove when possible
|
||||
* Only needed if alerts page flyout (which uses CellActions), NOT in the AI for SOC alert summary flyout.
|
||||
*/
|
||||
scopeId: string;
|
||||
/**
|
||||
* Boolean to indicate this field is shown in a preview
|
||||
* Only needed if alerts page flyout (which uses CellActions), NOT in the AI for SOC alert summary flyout.
|
||||
*/
|
||||
isPreview: boolean;
|
||||
/**
|
||||
* If true, cell actions will be shown on hover
|
||||
*/
|
||||
showCellActions: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -76,80 +80,130 @@ const columns: Array<EuiBasicTableColumn<HighlightedFieldsTableRow>> = [
|
|||
values: string[] | null | undefined;
|
||||
scopeId: string;
|
||||
isPreview: boolean;
|
||||
showCellActions: boolean;
|
||||
}) => (
|
||||
<CellActions field={description.field} value={description.values}>
|
||||
<HighlightedFieldsCell
|
||||
values={description.values}
|
||||
field={description.field}
|
||||
originalField={description.originalField}
|
||||
/>
|
||||
</CellActions>
|
||||
<>
|
||||
{description.showCellActions ? (
|
||||
<CellActions field={description.field} value={description.values}>
|
||||
<HighlightedFieldsCell
|
||||
values={description.values}
|
||||
field={description.field}
|
||||
originalField={description.originalField}
|
||||
scopeId={description.scopeId}
|
||||
showPreview={true}
|
||||
/>
|
||||
</CellActions>
|
||||
) : (
|
||||
<HighlightedFieldsCell
|
||||
values={description.values}
|
||||
field={description.field}
|
||||
originalField={description.originalField}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
export interface HighlightedFieldsProps {
|
||||
/**
|
||||
* An array of field objects with category and value
|
||||
*/
|
||||
dataFormattedForFieldBrowser: TimelineEventsDetailsItem[];
|
||||
/**
|
||||
* User defined fields to highlight (defined on the rule)
|
||||
*/
|
||||
investigationFields: string[];
|
||||
/**
|
||||
* Boolean to indicate whether flyout is opened in rule preview
|
||||
*/
|
||||
isPreview: boolean;
|
||||
/**
|
||||
* Maintain backwards compatibility // TODO remove when possible
|
||||
* Only needed if alerts page flyout (which uses CellActions), NOT in the AI for SOC alert summary flyout.
|
||||
*/
|
||||
scopeId?: string;
|
||||
/**
|
||||
* If true, cell actions will be shown on hover.
|
||||
* This is false by default (for the AI for SOC alert summary page) and will be true for the alerts page.
|
||||
*/
|
||||
showCellActions?: boolean;
|
||||
/**
|
||||
* If true, the edit button will be shown on hover (granted that the editHighlightedFieldsEnabled is also turned on).
|
||||
* This is false by default (for the AI for SOC alert summary page) and will be true for the alerts page.
|
||||
*/
|
||||
showEditButton?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Component that displays the highlighted fields in the right panel under the Investigation section.
|
||||
* It is used in both in the alerts page and the AI for SOC alert summary page. The latter has no CellActions enabled.
|
||||
*/
|
||||
export const HighlightedFields: FC = () => {
|
||||
const { dataFormattedForFieldBrowser, scopeId, isPreview, investigationFields } =
|
||||
useDocumentDetailsContext();
|
||||
|
||||
const [isEditLoading, setIsEditLoading] = useState(false);
|
||||
const editHighlightedFieldsEnabled = useIsExperimentalFeatureEnabled(
|
||||
'editHighlightedFieldsEnabled'
|
||||
);
|
||||
|
||||
const highlightedFields = useHighlightedFields({
|
||||
export const HighlightedFields = memo(
|
||||
({
|
||||
dataFormattedForFieldBrowser,
|
||||
investigationFields,
|
||||
});
|
||||
const items = useMemo(
|
||||
() => convertHighlightedFieldsToTableRow(highlightedFields, scopeId, isPreview),
|
||||
[highlightedFields, scopeId, isPreview]
|
||||
);
|
||||
isPreview,
|
||||
scopeId = '',
|
||||
showCellActions = false,
|
||||
showEditButton = false,
|
||||
}: HighlightedFieldsProps) => {
|
||||
const [isEditLoading, setIsEditLoading] = useState(false);
|
||||
|
||||
return (
|
||||
<EuiFlexGroup direction="column" gutterSize="none">
|
||||
<EuiFlexItem>
|
||||
<EuiFlexGroup alignItems="center" css={{ minHeight: '40px' }}>
|
||||
<EuiFlexItem data-test-subj={HIGHLIGHTED_FIELDS_TITLE_TEST_ID}>
|
||||
<EuiTitle size="xxs">
|
||||
<h5>
|
||||
<FormattedMessage
|
||||
id="xpack.securitySolution.flyout.right.investigation.highlightedFields.highlightedFieldsTitle"
|
||||
defaultMessage="Highlighted fields"
|
||||
/>
|
||||
</h5>
|
||||
</EuiTitle>
|
||||
</EuiFlexItem>
|
||||
{editHighlightedFieldsEnabled && (
|
||||
<EuiFlexItem grow={false}>
|
||||
<EditHighlightedFieldsButton
|
||||
customHighlightedFields={investigationFields}
|
||||
dataFormattedForFieldBrowser={dataFormattedForFieldBrowser}
|
||||
setIsEditLoading={setIsEditLoading}
|
||||
/>
|
||||
const highlightedFields = useHighlightedFields({
|
||||
dataFormattedForFieldBrowser,
|
||||
investigationFields,
|
||||
});
|
||||
const items = useMemo(
|
||||
() =>
|
||||
convertHighlightedFieldsToTableRow(highlightedFields, scopeId, isPreview, showCellActions),
|
||||
[highlightedFields, scopeId, isPreview, showCellActions]
|
||||
);
|
||||
|
||||
return (
|
||||
<EuiFlexGroup direction="column" gutterSize="none">
|
||||
<EuiFlexItem>
|
||||
<EuiFlexGroup alignItems="center" css={{ minHeight: '40px' }}>
|
||||
<EuiFlexItem data-test-subj={HIGHLIGHTED_FIELDS_TITLE_TEST_ID}>
|
||||
<EuiTitle size="xxs">
|
||||
<h5>
|
||||
<FormattedMessage
|
||||
id="xpack.securitySolution.flyout.right.investigation.highlightedFields.highlightedFieldsTitle"
|
||||
defaultMessage="Highlighted fields"
|
||||
/>
|
||||
</h5>
|
||||
</EuiTitle>
|
||||
</EuiFlexItem>
|
||||
)}
|
||||
</EuiFlexGroup>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem data-test-subj={HIGHLIGHTED_FIELDS_DETAILS_TEST_ID}>
|
||||
<EuiPanel hasBorder hasShadow={false}>
|
||||
<EuiInMemoryTable
|
||||
items={items}
|
||||
columns={columns}
|
||||
compressed
|
||||
loading={isEditLoading}
|
||||
message={
|
||||
<FormattedMessage
|
||||
id="xpack.securitySolution.flyout.right.investigation.highlightedFields.noDataDescription"
|
||||
defaultMessage="There's no highlighted fields for this alert."
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</EuiPanel>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
);
|
||||
};
|
||||
{showEditButton && (
|
||||
<EuiFlexItem grow={false}>
|
||||
<EditHighlightedFieldsButton
|
||||
customHighlightedFields={investigationFields}
|
||||
dataFormattedForFieldBrowser={dataFormattedForFieldBrowser}
|
||||
setIsEditLoading={setIsEditLoading}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
)}
|
||||
</EuiFlexGroup>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem data-test-subj={HIGHLIGHTED_FIELDS_DETAILS_TEST_ID}>
|
||||
<EuiPanel hasBorder hasShadow={false}>
|
||||
<EuiInMemoryTable
|
||||
items={items}
|
||||
columns={columns}
|
||||
compressed
|
||||
loading={isEditLoading}
|
||||
message={
|
||||
<FormattedMessage
|
||||
id="xpack.securitySolution.flyout.right.investigation.highlightedFields.noDataDescription"
|
||||
defaultMessage="There's no highlighted fields for this alert."
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</EuiPanel>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
HighlightedFields.displayName = 'HighlightedFields';
|
||||
|
|
|
@ -4,9 +4,9 @@
|
|||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import type { FC } from 'react';
|
||||
import { EuiButtonEmpty, EuiToolTip, EuiLoadingSpinner } from '@elastic/eui';
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { EuiButtonEmpty, EuiLoadingSpinner, EuiToolTip } from '@elastic/eui';
|
||||
import type { TimelineEventsDetailsItem } from '@kbn/timelines-plugin/common';
|
||||
import { FormattedMessage } from '@kbn/i18n-react';
|
||||
import { useHighlightedFieldsPrivilege } from '../../shared/hooks/use_highlighted_fields_privilege';
|
||||
|
@ -14,9 +14,9 @@ import { useBasicDataFromDetailsData } from '../../shared/hooks/use_basic_data_f
|
|||
import { useRuleDetails } from '../../../rule_details/hooks/use_rule_details';
|
||||
import { HighlightedFieldsModal } from './highlighted_fields_modal';
|
||||
import {
|
||||
HIGHLIGHTED_FIELDS_EDIT_BUTTON_LOADING_TEST_ID,
|
||||
HIGHLIGHTED_FIELDS_EDIT_BUTTON_TEST_ID,
|
||||
HIGHLIGHTED_FIELDS_EDIT_BUTTON_TOOLTIP_TEST_ID,
|
||||
HIGHLIGHTED_FIELDS_EDIT_BUTTON_LOADING_TEST_ID,
|
||||
} from './test_ids';
|
||||
|
||||
interface EditHighlightedFieldsButtonProps {
|
||||
|
|
|
@ -13,7 +13,6 @@ import {
|
|||
HIGHLIGHTED_FIELDS_LINKED_CELL_TEST_ID,
|
||||
} from './test_ids';
|
||||
import { HighlightedFieldsCell } from './highlighted_fields_cell';
|
||||
import { DocumentDetailsContext } from '../../shared/context';
|
||||
import { TestProviders } from '../../../../common/mock';
|
||||
import { useGetAgentStatus } from '../../../../management/hooks/agents/use_get_agent_status';
|
||||
import { mockFlyoutApi } from '../../shared/mocks/mock_flyout_context';
|
||||
|
@ -22,7 +21,7 @@ import { HostPreviewPanelKey } from '../../../entity_details/host_right';
|
|||
import { HOST_PREVIEW_BANNER } from './host_entity_overview';
|
||||
import { UserPreviewPanelKey } from '../../../entity_details/user_right';
|
||||
import { USER_PREVIEW_BANNER } from './user_entity_overview';
|
||||
import { NetworkPreviewPanelKey, NETWORK_PREVIEW_BANNER } from '../../../network_details';
|
||||
import { NETWORK_PREVIEW_BANNER, NetworkPreviewPanelKey } from '../../../network_details';
|
||||
import { createTelemetryServiceMock } from '../../../../common/lib/telemetry/telemetry_service.mock';
|
||||
|
||||
jest.mock('../../../../management/hooks');
|
||||
|
@ -43,18 +42,17 @@ jest.mock('../../../../common/lib/kibana', () => {
|
|||
|
||||
const useGetAgentStatusMock = useGetAgentStatus as jest.Mock;
|
||||
|
||||
const panelContextValue = {
|
||||
eventId: 'event id',
|
||||
indexName: 'indexName',
|
||||
scopeId: 'scopeId',
|
||||
} as unknown as DocumentDetailsContext;
|
||||
const SCOPE_ID = 'scopeId';
|
||||
|
||||
const renderHighlightedFieldsCell = (values: string[], field: string) =>
|
||||
const renderHighlightedFieldsCell = (values: string[], field: string, showPreview = false) =>
|
||||
render(
|
||||
<TestProviders>
|
||||
<DocumentDetailsContext.Provider value={panelContextValue}>
|
||||
<HighlightedFieldsCell values={values} field={field} />
|
||||
</DocumentDetailsContext.Provider>
|
||||
<HighlightedFieldsCell
|
||||
values={values}
|
||||
field={field}
|
||||
scopeId={SCOPE_ID}
|
||||
showPreview={showPreview}
|
||||
/>
|
||||
</TestProviders>
|
||||
);
|
||||
|
||||
|
@ -64,19 +62,13 @@ describe('<HighlightedFieldsCell />', () => {
|
|||
});
|
||||
|
||||
it('should render a basic cell', () => {
|
||||
const { getByTestId } = render(
|
||||
<TestProviders>
|
||||
<DocumentDetailsContext.Provider value={panelContextValue}>
|
||||
<HighlightedFieldsCell values={['value']} field={'field'} />
|
||||
</DocumentDetailsContext.Provider>
|
||||
</TestProviders>
|
||||
);
|
||||
const { getByTestId } = renderHighlightedFieldsCell(['value'], 'field', true);
|
||||
|
||||
expect(getByTestId(HIGHLIGHTED_FIELDS_BASIC_CELL_TEST_ID)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should open host preview when click on host', () => {
|
||||
const { getByTestId } = renderHighlightedFieldsCell(['test host'], 'host.name');
|
||||
const { getByTestId } = renderHighlightedFieldsCell(['test host'], 'host.name', true);
|
||||
expect(getByTestId(HIGHLIGHTED_FIELDS_LINKED_CELL_TEST_ID)).toBeInTheDocument();
|
||||
|
||||
getByTestId(HIGHLIGHTED_FIELDS_LINKED_CELL_TEST_ID).click();
|
||||
|
@ -84,14 +76,14 @@ describe('<HighlightedFieldsCell />', () => {
|
|||
id: HostPreviewPanelKey,
|
||||
params: {
|
||||
hostName: 'test host',
|
||||
scopeId: panelContextValue.scopeId,
|
||||
scopeId: SCOPE_ID,
|
||||
banner: HOST_PREVIEW_BANNER,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should open user preview when click on user', () => {
|
||||
const { getByTestId } = renderHighlightedFieldsCell(['test user'], 'user.name');
|
||||
const { getByTestId } = renderHighlightedFieldsCell(['test user'], 'user.name', true);
|
||||
expect(getByTestId(HIGHLIGHTED_FIELDS_LINKED_CELL_TEST_ID)).toBeInTheDocument();
|
||||
|
||||
getByTestId(HIGHLIGHTED_FIELDS_LINKED_CELL_TEST_ID).click();
|
||||
|
@ -99,14 +91,14 @@ describe('<HighlightedFieldsCell />', () => {
|
|||
id: UserPreviewPanelKey,
|
||||
params: {
|
||||
userName: 'test user',
|
||||
scopeId: panelContextValue.scopeId,
|
||||
scopeId: SCOPE_ID,
|
||||
banner: USER_PREVIEW_BANNER,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should open ip preview when click on ip', () => {
|
||||
const { getByTestId } = renderHighlightedFieldsCell(['100:XXX:XXX'], 'source.ip');
|
||||
const { getByTestId } = renderHighlightedFieldsCell(['100:XXX:XXX'], 'source.ip', true);
|
||||
expect(getByTestId(HIGHLIGHTED_FIELDS_LINKED_CELL_TEST_ID)).toBeInTheDocument();
|
||||
|
||||
getByTestId(HIGHLIGHTED_FIELDS_LINKED_CELL_TEST_ID).click();
|
||||
|
@ -115,7 +107,7 @@ describe('<HighlightedFieldsCell />', () => {
|
|||
params: {
|
||||
ip: '100:XXX:XXX',
|
||||
flowTarget: 'source',
|
||||
scopeId: panelContextValue.scopeId,
|
||||
scopeId: SCOPE_ID,
|
||||
banner: NETWORK_PREVIEW_BANNER,
|
||||
},
|
||||
});
|
||||
|
@ -128,9 +120,7 @@ describe('<HighlightedFieldsCell />', () => {
|
|||
});
|
||||
const { getByTestId } = render(
|
||||
<TestProviders>
|
||||
<DocumentDetailsContext.Provider value={panelContextValue}>
|
||||
<HighlightedFieldsCell values={['value']} field={'agent.status'} />
|
||||
</DocumentDetailsContext.Provider>
|
||||
<HighlightedFieldsCell values={['value']} field={'agent.status'} scopeId={SCOPE_ID} />
|
||||
</TestProviders>
|
||||
);
|
||||
|
||||
|
@ -145,13 +135,12 @@ describe('<HighlightedFieldsCell />', () => {
|
|||
|
||||
const { getByTestId } = render(
|
||||
<TestProviders>
|
||||
<DocumentDetailsContext.Provider value={panelContextValue}>
|
||||
<HighlightedFieldsCell
|
||||
values={['value']}
|
||||
field={'agent.status'}
|
||||
originalField="observer.serial_number"
|
||||
/>
|
||||
</DocumentDetailsContext.Provider>
|
||||
<HighlightedFieldsCell
|
||||
values={['value']}
|
||||
field={'agent.status'}
|
||||
originalField="observer.serial_number"
|
||||
scopeId={SCOPE_ID}
|
||||
/>
|
||||
</TestProviders>
|
||||
);
|
||||
|
||||
|
@ -166,13 +155,12 @@ describe('<HighlightedFieldsCell />', () => {
|
|||
|
||||
const { getByTestId } = render(
|
||||
<TestProviders>
|
||||
<DocumentDetailsContext.Provider value={panelContextValue}>
|
||||
<HighlightedFieldsCell
|
||||
values={['value']}
|
||||
field={'agent.status'}
|
||||
originalField="crowdstrike.event.DeviceId"
|
||||
/>
|
||||
</DocumentDetailsContext.Provider>
|
||||
<HighlightedFieldsCell
|
||||
values={['value']}
|
||||
field={'agent.status'}
|
||||
originalField="crowdstrike.event.DeviceId"
|
||||
scopeId={SCOPE_ID}
|
||||
/>
|
||||
</TestProviders>
|
||||
);
|
||||
|
||||
|
@ -182,9 +170,7 @@ describe('<HighlightedFieldsCell />', () => {
|
|||
it('should not render if values is null', () => {
|
||||
const { container } = render(
|
||||
<TestProviders>
|
||||
<DocumentDetailsContext.Provider value={panelContextValue}>
|
||||
<HighlightedFieldsCell values={null} field={'field'} />
|
||||
</DocumentDetailsContext.Provider>
|
||||
<HighlightedFieldsCell values={null} field={'field'} scopeId={SCOPE_ID} />
|
||||
</TestProviders>
|
||||
);
|
||||
|
||||
|
|
|
@ -11,7 +11,6 @@ import { EuiFlexItem } from '@elastic/eui';
|
|||
import { getAgentTypeForAgentIdField } from '../../../../common/lib/endpoint/utils/get_agent_type_for_agent_id_field';
|
||||
import type { ResponseActionAgentType } from '../../../../../common/endpoint/service/response_actions/constants';
|
||||
import { AgentStatus } from '../../../../common/components/endpoint/agents/agent_status';
|
||||
import { useDocumentDetailsContext } from '../../shared/context';
|
||||
import { AGENT_STATUS_FIELD_NAME } from '../../../../timelines/components/timeline/body/renderers/constants';
|
||||
import {
|
||||
HIGHLIGHTED_FIELDS_AGENT_STATUS_CELL_TEST_ID,
|
||||
|
@ -34,6 +33,16 @@ export interface HighlightedFieldsCellProps {
|
|||
* Highlighted field's value to display
|
||||
*/
|
||||
values: string[] | null | undefined;
|
||||
/**
|
||||
* Maintain backwards compatibility // TODO remove when possible
|
||||
* Only needed if alerts page flyout (which has PreviewLink), NOT in the AI for SOC alert summary flyout.
|
||||
*/
|
||||
scopeId?: string;
|
||||
/**
|
||||
* If true, we show a PreviewLink for some specific fields.
|
||||
* This is false by default (for the AI for SOC alert summary page) and will be true for the alerts page.
|
||||
*/
|
||||
showPreview?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -43,9 +52,9 @@ export const HighlightedFieldsCell: FC<HighlightedFieldsCellProps> = ({
|
|||
values,
|
||||
field,
|
||||
originalField = '',
|
||||
scopeId = '',
|
||||
showPreview = false,
|
||||
}) => {
|
||||
const { scopeId } = useDocumentDetailsContext();
|
||||
|
||||
const agentType: ResponseActionAgentType = useMemo(() => {
|
||||
return getAgentTypeForAgentIdField(originalField);
|
||||
}, [originalField]);
|
||||
|
@ -60,7 +69,7 @@ export const HighlightedFieldsCell: FC<HighlightedFieldsCellProps> = ({
|
|||
key={`${i}-${value}`}
|
||||
data-test-subj={`${value}-${HIGHLIGHTED_FIELDS_CELL_TEST_ID}`}
|
||||
>
|
||||
{hasPreview(field) ? (
|
||||
{showPreview && hasPreview(field) ? (
|
||||
<PreviewLink
|
||||
field={field}
|
||||
value={value}
|
||||
|
|
|
@ -6,13 +6,13 @@
|
|||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { __IntlProvider as IntlProvider } from '@kbn/i18n-react';
|
||||
import { render } from '@testing-library/react';
|
||||
import {
|
||||
HIGHLIGHTED_FIELDS_EDIT_BUTTON_TEST_ID,
|
||||
HIGHLIGHTED_FIELDS_TITLE_TEST_ID,
|
||||
INVESTIGATION_GUIDE_TEST_ID,
|
||||
INVESTIGATION_SECTION_CONTENT_TEST_ID,
|
||||
INVESTIGATION_SECTION_HEADER_TEST_ID,
|
||||
INVESTIGATION_GUIDE_TEST_ID,
|
||||
HIGHLIGHTED_FIELDS_TITLE_TEST_ID,
|
||||
} from './test_ids';
|
||||
import { DocumentDetailsContext } from '../../shared/context';
|
||||
import { InvestigationSection } from './investigation_section';
|
||||
|
@ -24,12 +24,17 @@ import { useExpandSection } from '../hooks/use_expand_section';
|
|||
import { useHighlightedFields } from '../../shared/hooks/use_highlighted_fields';
|
||||
import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features';
|
||||
import { useFetchIndex } from '../../../../common/containers/source';
|
||||
import { useRuleDetails } from '../../../rule_details/hooks/use_rule_details';
|
||||
import type { RuleResponse } from '../../../../../common/api/detection_engine';
|
||||
import { useHighlightedFieldsPrivilege } from '../../shared/hooks/use_highlighted_fields_privilege';
|
||||
|
||||
jest.mock('../../../../detection_engine/rule_management/logic/use_rule_with_fallback');
|
||||
jest.mock('../hooks/use_expand_section');
|
||||
jest.mock('../../shared/hooks/use_highlighted_fields');
|
||||
jest.mock('../../../../common/hooks/use_experimental_features');
|
||||
jest.mock('../../../../common/containers/source');
|
||||
jest.mock('../../../rule_details/hooks/use_rule_details');
|
||||
jest.mock('../../shared/hooks/use_highlighted_fields_privilege');
|
||||
|
||||
const mockAddSuccess = jest.fn();
|
||||
jest.mock('../../../../common/hooks/use_app_toasts', () => ({
|
||||
|
@ -47,24 +52,24 @@ const panelContextValue = {
|
|||
|
||||
const renderInvestigationSection = (contextValue = panelContextValue) =>
|
||||
render(
|
||||
<IntlProvider locale="en">
|
||||
<TestProvider>
|
||||
<DocumentDetailsContext.Provider value={contextValue}>
|
||||
<InvestigationSection />
|
||||
</DocumentDetailsContext.Provider>
|
||||
</TestProvider>
|
||||
</IntlProvider>
|
||||
<TestProvider>
|
||||
<DocumentDetailsContext.Provider value={contextValue}>
|
||||
<InvestigationSection />
|
||||
</DocumentDetailsContext.Provider>
|
||||
</TestProvider>
|
||||
);
|
||||
|
||||
describe('<InvestigationSection />', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
(useExpandSection as jest.Mock).mockReturnValue(true);
|
||||
(useHighlightedFields as jest.Mock).mockReturnValue([]);
|
||||
(useRuleWithFallback as jest.Mock).mockReturnValue({ rule: { note: 'test note' } });
|
||||
(useIsExperimentalFeatureEnabled as jest.Mock).mockReturnValue(false);
|
||||
(useFetchIndex as jest.Mock).mockReturnValue([false, { indexPatterns: { fields: ['field'] } }]);
|
||||
});
|
||||
|
||||
it('should render investigation component', () => {
|
||||
it('should render investigation component top level items', () => {
|
||||
const { getByTestId } = renderInvestigationSection();
|
||||
expect(getByTestId(INVESTIGATION_SECTION_HEADER_TEST_ID)).toBeInTheDocument();
|
||||
expect(getByTestId(INVESTIGATION_SECTION_HEADER_TEST_ID)).toHaveTextContent('Investigation');
|
||||
|
@ -79,37 +84,49 @@ describe('<InvestigationSection />', () => {
|
|||
});
|
||||
|
||||
it('should render the component expanded if value is true in local storage', () => {
|
||||
(useExpandSection as jest.Mock).mockReturnValue(true);
|
||||
(useHighlightedFields as jest.Mock).mockReturnValue([]);
|
||||
|
||||
const { getByTestId } = renderInvestigationSection();
|
||||
expect(getByTestId(INVESTIGATION_SECTION_CONTENT_TEST_ID)).toBeVisible();
|
||||
});
|
||||
|
||||
it('should render investigation guide and highlighted fields when document is signal', () => {
|
||||
(useExpandSection as jest.Mock).mockReturnValue(true);
|
||||
(useHighlightedFields as jest.Mock).mockReturnValue([]);
|
||||
it('should render highlighted fields section without edit button when feature flag is disabled', () => {
|
||||
const { getByTestId, queryByTestId } = renderInvestigationSection();
|
||||
expect(getByTestId(HIGHLIGHTED_FIELDS_TITLE_TEST_ID)).toBeInTheDocument();
|
||||
expect(queryByTestId(HIGHLIGHTED_FIELDS_EDIT_BUTTON_TEST_ID)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render highlighted fields section with edit button when feature flag is enabled', () => {
|
||||
(useIsExperimentalFeatureEnabled as jest.Mock).mockReturnValue(true);
|
||||
(useRuleDetails as jest.Mock).mockReturnValue({
|
||||
rule: { id: '123' } as RuleResponse,
|
||||
isExistingRule: true,
|
||||
loading: false,
|
||||
});
|
||||
(useHighlightedFieldsPrivilege as jest.Mock).mockReturnValue({
|
||||
isEditHighlightedFieldsDisabled: false,
|
||||
tooltipContent: 'tooltip content',
|
||||
});
|
||||
|
||||
const { getByTestId } = renderInvestigationSection();
|
||||
expect(getByTestId(INVESTIGATION_GUIDE_TEST_ID)).toBeInTheDocument();
|
||||
expect(getByTestId(HIGHLIGHTED_FIELDS_TITLE_TEST_ID)).toBeInTheDocument();
|
||||
expect(getByTestId(HIGHLIGHTED_FIELDS_EDIT_BUTTON_TEST_ID)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render investigation guide and highlighted fields when document is signal', () => {
|
||||
const { getByTestId } = renderInvestigationSection();
|
||||
expect(getByTestId(INVESTIGATION_GUIDE_TEST_ID)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not render investigation guide when document is not signal', () => {
|
||||
(useExpandSection as jest.Mock).mockReturnValue(true);
|
||||
(useHighlightedFields as jest.Mock).mockReturnValue([]);
|
||||
|
||||
const mockGetFieldsData = (field: string) => {
|
||||
switch (field) {
|
||||
case 'event.kind':
|
||||
return 'alert';
|
||||
}
|
||||
};
|
||||
const { getByTestId, queryByTestId } = renderInvestigationSection({
|
||||
const { queryByTestId } = renderInvestigationSection({
|
||||
...panelContextValue,
|
||||
getFieldsData: mockGetFieldsData,
|
||||
});
|
||||
expect(queryByTestId(INVESTIGATION_GUIDE_TEST_ID)).not.toBeInTheDocument();
|
||||
expect(getByTestId(HIGHLIGHTED_FIELDS_TITLE_TEST_ID)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -8,6 +8,7 @@
|
|||
import React, { memo } from 'react';
|
||||
import { EuiSpacer } from '@elastic/eui';
|
||||
import { FormattedMessage } from '@kbn/i18n-react';
|
||||
import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features';
|
||||
import { useExpandSection } from '../hooks/use_expand_section';
|
||||
import { ExpandableSection } from './expandable_section';
|
||||
import { HighlightedFields } from './highlighted_fields';
|
||||
|
@ -21,14 +22,19 @@ const KEY = 'investigation';
|
|||
|
||||
/**
|
||||
* Second section of the overview tab in details flyout.
|
||||
* It contains investigation guide (alerts only) and highlighted fields
|
||||
* It contains investigation guide (alerts only) and highlighted fields.
|
||||
*/
|
||||
export const InvestigationSection = memo(() => {
|
||||
const { getFieldsData } = useDocumentDetailsContext();
|
||||
const { dataFormattedForFieldBrowser, getFieldsData, investigationFields, isPreview, scopeId } =
|
||||
useDocumentDetailsContext();
|
||||
const eventKind = getField(getFieldsData('event.kind'));
|
||||
|
||||
const expanded = useExpandSection({ title: KEY, defaultValue: true });
|
||||
|
||||
const editHighlightedFieldsEnabled = useIsExperimentalFeatureEnabled(
|
||||
'editHighlightedFieldsEnabled'
|
||||
);
|
||||
|
||||
return (
|
||||
<ExpandableSection
|
||||
expanded={expanded}
|
||||
|
@ -48,7 +54,14 @@ export const InvestigationSection = memo(() => {
|
|||
<EuiSpacer size="m" />
|
||||
</>
|
||||
)}
|
||||
<HighlightedFields />
|
||||
<HighlightedFields
|
||||
dataFormattedForFieldBrowser={dataFormattedForFieldBrowser}
|
||||
investigationFields={investigationFields}
|
||||
isPreview={isPreview}
|
||||
scopeId={scopeId}
|
||||
showCellActions={true}
|
||||
showEditButton={editHighlightedFieldsEnabled}
|
||||
/>
|
||||
</ExpandableSection>
|
||||
);
|
||||
});
|
||||
|
|
|
@ -12,6 +12,7 @@ import {
|
|||
|
||||
const scopeId = 'scopeId';
|
||||
const isPreview = false;
|
||||
const showCellActions = false;
|
||||
|
||||
describe('convertHighlightedFieldsToTableRow', () => {
|
||||
it('should convert highlighted fields to a table row', () => {
|
||||
|
@ -20,7 +21,9 @@ describe('convertHighlightedFieldsToTableRow', () => {
|
|||
values: ['host-1'],
|
||||
},
|
||||
};
|
||||
expect(convertHighlightedFieldsToTableRow(highlightedFields, scopeId, isPreview)).toEqual([
|
||||
expect(
|
||||
convertHighlightedFieldsToTableRow(highlightedFields, scopeId, isPreview, showCellActions)
|
||||
).toEqual([
|
||||
{
|
||||
field: 'host.name',
|
||||
description: {
|
||||
|
@ -28,6 +31,7 @@ describe('convertHighlightedFieldsToTableRow', () => {
|
|||
values: ['host-1'],
|
||||
scopeId: 'scopeId',
|
||||
isPreview,
|
||||
showCellActions,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
@ -40,7 +44,9 @@ describe('convertHighlightedFieldsToTableRow', () => {
|
|||
values: ['host-1'],
|
||||
},
|
||||
};
|
||||
expect(convertHighlightedFieldsToTableRow(highlightedFields, scopeId, isPreview)).toEqual([
|
||||
expect(
|
||||
convertHighlightedFieldsToTableRow(highlightedFields, scopeId, isPreview, showCellActions)
|
||||
).toEqual([
|
||||
{
|
||||
field: 'host.name-override',
|
||||
description: {
|
||||
|
@ -49,6 +55,7 @@ describe('convertHighlightedFieldsToTableRow', () => {
|
|||
values: ['host-1'],
|
||||
scopeId: 'scopeId',
|
||||
isPreview,
|
||||
showCellActions,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
@ -61,7 +68,9 @@ describe('convertHighlightedFieldsToTableRow', () => {
|
|||
values: ['host-1'],
|
||||
},
|
||||
};
|
||||
expect(convertHighlightedFieldsToTableRow(highlightedFields, scopeId, isPreview)).toEqual([
|
||||
expect(
|
||||
convertHighlightedFieldsToTableRow(highlightedFields, scopeId, isPreview, showCellActions)
|
||||
).toEqual([
|
||||
{
|
||||
field: 'host.name-override',
|
||||
description: {
|
||||
|
@ -70,6 +79,7 @@ describe('convertHighlightedFieldsToTableRow', () => {
|
|||
values: ['value override!'],
|
||||
scopeId: 'scopeId',
|
||||
isPreview,
|
||||
showCellActions,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
|
|
@ -11,13 +11,16 @@ import type { HighlightedFieldsTableRow } from '../../right/components/highlight
|
|||
|
||||
/**
|
||||
* Converts the highlighted fields to a format that can be consumed by the HighlightedFields component
|
||||
* @param highlightedFields
|
||||
* @param scopeId
|
||||
* @param highlightedFields field/value pairs
|
||||
* @param scopeId used in the alerts page for CellActions
|
||||
* @param isPreview used in the alerts page for CellActions and also to hide PreviewLinks
|
||||
* @param showCellActions used in alert summary page to hide CellActions entirely
|
||||
*/
|
||||
export const convertHighlightedFieldsToTableRow = (
|
||||
highlightedFields: UseHighlightedFieldsResult,
|
||||
scopeId: string,
|
||||
isPreview: boolean
|
||||
isPreview: boolean,
|
||||
showCellActions: boolean
|
||||
): HighlightedFieldsTableRow[] => {
|
||||
const fieldNames = Object.keys(highlightedFields);
|
||||
return fieldNames.map((fieldName) => {
|
||||
|
@ -36,6 +39,7 @@ export const convertHighlightedFieldsToTableRow = (
|
|||
values,
|
||||
scopeId,
|
||||
isPreview,
|
||||
showCellActions,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
|
|
@ -212,6 +212,7 @@ export class Plugin implements ISecuritySolutionPlugin {
|
|||
const experimentalFeatures = config.experimentalFeatures;
|
||||
|
||||
initSavedObjects(core.savedObjects);
|
||||
|
||||
initUiSettings(core.uiSettings, experimentalFeatures, config.enableUiSettingsValidations);
|
||||
productFeaturesService.init(plugins.features);
|
||||
|
||||
|
|
|
@ -9,6 +9,7 @@ import { i18n } from '@kbn/i18n';
|
|||
import { schema } from '@kbn/config-schema';
|
||||
|
||||
import type { CoreSetup, UiSettingsParams } from '@kbn/core/server';
|
||||
import type { Connector } from '@kbn/actions-plugin/server/application/connector/types';
|
||||
import {
|
||||
APP_ID,
|
||||
DEFAULT_ANOMALY_SCORE,
|
||||
|
@ -41,6 +42,7 @@ import {
|
|||
ENABLE_VISUALIZATIONS_IN_FLYOUT_SETTING,
|
||||
ENABLE_GRAPH_VISUALIZATION_SETTING,
|
||||
ENABLE_ASSET_INVENTORY_SETTING,
|
||||
DEFAULT_AI_CONNECTOR,
|
||||
} from '../common/constants';
|
||||
import type { ExperimentalFeatures } from '../common/experimental_features';
|
||||
import { LogLevelSetting } from '../common/api/detection_engine/rule_monitoring';
|
||||
|
@ -512,3 +514,29 @@ export const initUiSettings = (
|
|||
|
||||
uiSettings.register(orderSettings(securityUiSettings));
|
||||
};
|
||||
export const getDefaultAIConnectorSetting = (connectors: Connector[]): SettingsConfig | null =>
|
||||
connectors.length > 0
|
||||
? {
|
||||
[DEFAULT_AI_CONNECTOR]: {
|
||||
name: i18n.translate('xpack.securitySolution.uiSettings.defaultAIConnectorLabel', {
|
||||
defaultMessage: 'Default AI Connector',
|
||||
}),
|
||||
// TODO, make Elastic LLM the default value once fully available in serverless
|
||||
value: connectors[0].id,
|
||||
description: i18n.translate(
|
||||
'xpack.securitySolution.uiSettings.defaultAIConnectorDescription',
|
||||
{
|
||||
// TODO update this copy, waiting on James Spiteri's input
|
||||
defaultMessage: 'Default AI connector for serverless AI features (AI for SOC)',
|
||||
}
|
||||
),
|
||||
type: 'select',
|
||||
options: connectors.map(({ id }) => id),
|
||||
optionLabels: Object.fromEntries(connectors.map(({ id, name }) => [id, name])),
|
||||
category: [APP_ID],
|
||||
requiresPageReload: true,
|
||||
schema: schema.string(),
|
||||
solution: 'security',
|
||||
},
|
||||
}
|
||||
: null;
|
||||
|
|
|
@ -14,6 +14,9 @@ import type {
|
|||
} from '@kbn/core/server';
|
||||
|
||||
import { SECURITY_PROJECT_SETTINGS } from '@kbn/serverless-security-settings';
|
||||
import { isSupportedConnector } from '@kbn/inference-common';
|
||||
import { getDefaultAIConnectorSetting } from '@kbn/security-solution-plugin/server/ui_settings';
|
||||
import type { Connector } from '@kbn/actions-plugin/server/application/connector/types';
|
||||
import { getEnabledProductFeatures } from '../common/pli/pli_features';
|
||||
|
||||
import type { ServerlessSecurityConfig } from './config';
|
||||
|
@ -70,7 +73,10 @@ export class SecuritySolutionServerlessPlugin
|
|||
this.logger.info(`Security Solution running with product types:\n${productTypesStr}`);
|
||||
}
|
||||
|
||||
public setup(coreSetup: CoreSetup, pluginsSetup: SecuritySolutionServerlessPluginSetupDeps) {
|
||||
public setup(
|
||||
coreSetup: CoreSetup<SecuritySolutionServerlessPluginStartDeps>,
|
||||
pluginsSetup: SecuritySolutionServerlessPluginSetupDeps
|
||||
) {
|
||||
this.config = createConfig(this.initializerContext, pluginsSetup.securitySolution);
|
||||
|
||||
// Register product features
|
||||
|
@ -84,6 +90,29 @@ export class SecuritySolutionServerlessPlugin
|
|||
// Setup project uiSettings whitelisting
|
||||
pluginsSetup.serverless.setupProjectSettings(SECURITY_PROJECT_SETTINGS);
|
||||
|
||||
// use metering check which verifies AI4SOC is enabled
|
||||
if (ai4SocMeteringService.shouldMeter(this.config)) {
|
||||
// Serverless Advanced Settings setup
|
||||
coreSetup
|
||||
.getStartServices()
|
||||
.then(async ([_, depsStart]) => {
|
||||
try {
|
||||
const unsecuredActionsClient = depsStart.actions.getUnsecuredActionsClient();
|
||||
// using "default" space actually forces the api to use undefined space (see getAllUnsecured)
|
||||
const aiConnectors = (await unsecuredActionsClient.getAll('default')).filter(
|
||||
(connector: Connector) => isSupportedConnector(connector)
|
||||
);
|
||||
const defaultAIConnectorSetting = getDefaultAIConnectorSetting(aiConnectors);
|
||||
if (defaultAIConnectorSetting !== null) {
|
||||
coreSetup.uiSettings.register(defaultAIConnectorSetting);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(`Error registering default AI connector: ${error}`);
|
||||
}
|
||||
})
|
||||
.catch(() => {}); // it shouldn't reject, but just in case
|
||||
}
|
||||
|
||||
// Tasks
|
||||
this.cloudSecurityUsageReportingTask = new SecurityUsageReportingTask({
|
||||
core: coreSetup,
|
||||
|
|
|
@ -18,8 +18,10 @@ import type {
|
|||
import type { CloudSetup } from '@kbn/cloud-plugin/server';
|
||||
import type { SecuritySolutionEssPluginSetup } from '@kbn/security-solution-ess/server';
|
||||
import type { FleetStartContract } from '@kbn/fleet-plugin/server';
|
||||
import type { PluginSetupContract as ActionsPluginSetupContract } from '@kbn/actions-plugin/server';
|
||||
|
||||
import type {
|
||||
PluginSetupContract as ActionsPluginSetupContract,
|
||||
PluginStartContract as ActionsPluginStartContract,
|
||||
} from '@kbn/actions-plugin/server';
|
||||
import type { ServerlessPluginSetup } from '@kbn/serverless/server';
|
||||
import type { AutomaticImportPluginSetup } from '@kbn/automatic-import-plugin/server';
|
||||
import type { ProductTier } from '../common/product';
|
||||
|
@ -45,6 +47,7 @@ export interface SecuritySolutionServerlessPluginSetupDeps {
|
|||
}
|
||||
|
||||
export interface SecuritySolutionServerlessPluginStartDeps {
|
||||
actions: ActionsPluginStartContract;
|
||||
security: SecurityPluginStart;
|
||||
securitySolution: SecuritySolutionPluginStart;
|
||||
features: FeaturesPluginStart;
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue