From 79c192a4b088869002697f1b4ba4ceb7930c9cdb Mon Sep 17 00:00:00 2001 From: christineweng <18648970+christineweng@users.noreply.github.com> Date: Tue, 15 Apr 2025 12:18:35 -0500 Subject: [PATCH] [Security Solution][Alert flyout] Edit highlighted fields in overview tab (#216740) ## Summary This PR allows user to edit highlighted fields in alert flyout, under `Investigations`. The modal shows default highlighted fields that are defined by Elastic, and allow user to edit custom highlighted fields. Currently this feature is behind feature flag `editHighlightedFieldsEnabled` (not enabled by default). https://github.com/user-attachments/assets/35b3d09e-5e21-42ea-80e9-e8c0753985c9 #### Disabled when:
User does not have security privilege ![image](https://github.com/user-attachments/assets/69ba7bc7-2d9b-4a2c-ae8e-e9c14f396a31)
Prebuilt rule w/o enterprise license (showing upsell) ![image](https://github.com/user-attachments/assets/a9c38e20-85b2-4082-af5e-a8707b2098cb)
#### Do not show the button when:
Not an alert ![image](https://github.com/user-attachments/assets/b5e9afde-f0d0-4a88-aaed-7481ba586850)
rule preview ![image](https://github.com/user-attachments/assets/283d7a83-50b2-48ab-af2d-11692501c205)
### Checklist Check the PR satisfies following conditions. Reviewers should verify this PR satisfies this list as well. - [x] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/src/platform/packages/shared/kbn-i18n/README.md) - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios - [x] The PR description includes the appropriate Release Notes section, and the correct `release_note:*` label is applied per the [guidelines](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process) (cherry picked from commit a4a11bb46f63ad78399f152257a883d1a35f4ce9) --- .../common/experimental_features.ts | 5 + .../get_alert_summary_rows.test.tsx | 52 ++++ .../event_details/get_alert_summary_rows.tsx | 29 +- .../rule_exceptions/utils/helpers.tsx | 6 +- .../components/highlighted_fields.test.tsx | 132 ++++++-- .../right/components/highlighted_fields.tsx | 53 ++-- .../highlighted_fields_button.test.tsx | 112 +++++++ .../components/highlighted_fields_button.tsx | 95 ++++++ .../highlighted_fields_modal.test.tsx | 150 +++++++++ .../components/highlighted_fields_modal.tsx | 287 ++++++++++++++++++ .../components/investigation_section.test.tsx | 10 + .../right/components/test_ids.ts | 20 ++ .../document_details/shared/context.tsx | 16 +- .../hooks/use_highlighted_fields.test.tsx | 8 +- .../shared/hooks/use_highlighted_fields.ts | 17 +- .../use_highlighted_fields_privilege.test.tsx | 138 +++++++++ .../use_highlighted_fields_privilege.tsx | 106 +++++++ .../shared/hooks/use_prevalence.ts | 2 +- 18 files changed, 1166 insertions(+), 72 deletions(-) create mode 100644 x-pack/solutions/security/plugins/security_solution/public/common/components/event_details/get_alert_summary_rows.test.tsx create mode 100644 x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/right/components/highlighted_fields_button.test.tsx create mode 100644 x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/right/components/highlighted_fields_button.tsx create mode 100644 x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/right/components/highlighted_fields_modal.test.tsx create mode 100644 x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/right/components/highlighted_fields_modal.tsx create mode 100644 x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/shared/hooks/use_highlighted_fields_privilege.test.tsx create mode 100644 x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/shared/hooks/use_highlighted_fields_privilege.tsx diff --git a/x-pack/solutions/security/plugins/security_solution/common/experimental_features.ts b/x-pack/solutions/security/plugins/security_solution/common/experimental_features.ts index 876716a8254d1..3d155f4bd269a 100644 --- a/x-pack/solutions/security/plugins/security_solution/common/experimental_features.ts +++ b/x-pack/solutions/security/plugins/security_solution/common/experimental_features.ts @@ -235,6 +235,11 @@ export const allowedExperimentalValues = Object.freeze({ */ newExpandableFlyoutNavigationDisabled: false, + /** + * Enables the ability to edit highlighted fields in the alertflyout + */ + editHighlightedFieldsEnabled: false, + /** * Enables CrowdStrike's RunScript RTR command * Release: 8.18/9.0 diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/components/event_details/get_alert_summary_rows.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/common/components/event_details/get_alert_summary_rows.test.tsx new file mode 100644 index 0000000000000..d6102a4c23a75 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/common/components/event_details/get_alert_summary_rows.test.tsx @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { alwaysDisplayedFields, getHighlightedFieldsToDisplay } from './get_alert_summary_rows'; + +describe('getHighlightedFieldsToDisplay', () => { + it('should return custom highlighted fields correctly', () => { + const result = getHighlightedFieldsToDisplay({ + eventCategories: {}, + ruleCustomHighlightedFields: ['customField1', 'customField2'], + type: 'custom', + }); + expect(result).toEqual([{ id: 'customField1' }, { id: 'customField2' }]); + }); + + it('should return the default highlighted fields correctly', () => { + const result = getHighlightedFieldsToDisplay({ + eventCategories: {}, + ruleCustomHighlightedFields: ['customField1', 'customField2'], + type: 'default', + }); + expect(result).toEqual(alwaysDisplayedFields); + }); + + it('should return both custom and default highlighted fields correctly', () => { + const result = getHighlightedFieldsToDisplay({ + eventCategories: {}, + ruleCustomHighlightedFields: ['customField1', 'customField2'], + }); + expect(result).toEqual([ + { id: 'customField1' }, + { id: 'customField2' }, + ...alwaysDisplayedFields, + ]); + }); + + it('should return a list of unique fields', () => { + const result = getHighlightedFieldsToDisplay({ + eventCategories: {}, + ruleCustomHighlightedFields: ['customField1', 'customField2', 'host.name'], + }); + expect(result).toEqual([ + { id: 'customField1' }, + { id: 'customField2' }, + ...alwaysDisplayedFields, + ]); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/components/event_details/get_alert_summary_rows.tsx b/x-pack/solutions/security/plugins/security_solution/public/common/components/event_details/get_alert_summary_rows.tsx index 02c598ce833a6..4494a5290799b 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/common/components/event_details/get_alert_summary_rows.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/common/components/event_details/get_alert_summary_rows.tsx @@ -49,7 +49,7 @@ const RULE_TYPE = i18n.translate('xpack.securitySolution.detections.alerts.ruleT }); /** Always show these fields */ -const alwaysDisplayedFields: EventSummaryField[] = [ +export const alwaysDisplayedFields: EventSummaryField[] = [ { id: 'host.name' }, // Add all fields used to identify the agent ID in alert events and override them to @@ -68,8 +68,6 @@ const alwaysDisplayedFields: EventSummaryField[] = [ { id: 'rule.name' }, { id: 'cloud.provider' }, { id: 'cloud.region' }, - { id: 'cloud.provider' }, - { id: 'cloud.region' }, { id: 'orchestrator.cluster.id' }, { id: 'orchestrator.cluster.name' }, { id: 'container.image.name' }, @@ -239,7 +237,7 @@ function getFieldsByRuleType(ruleType?: string): EventSummaryField[] { * @param customs The list of custom-defined fields to display * @returns The list of custom-defined fields to display */ -function getHighlightedFieldsOverride(customs: string[]): EventSummaryField[] { +function getCustomHighlightedFields(customs: string[]): EventSummaryField[] { return customs.map((field) => ({ id: field })); } @@ -253,27 +251,36 @@ function getHighlightedFieldsOverride(customs: string[]): EventSummaryField[] { /** * Assembles a list of fields to display based on the event */ -export function getEventFieldsToDisplay({ +export function getHighlightedFieldsToDisplay({ eventCategories, eventCode, eventRuleType, - highlightedFieldsOverride, + ruleCustomHighlightedFields, + type = 'all', }: { eventCategories: EventCategories; eventCode?: string; eventRuleType?: string; - highlightedFieldsOverride: string[]; + ruleCustomHighlightedFields: string[]; + type?: 'default' | 'custom' | 'all'; }): EventSummaryField[] { - const fields = [ - ...getHighlightedFieldsOverride(highlightedFieldsOverride), + const customHighlightedFields = getCustomHighlightedFields(ruleCustomHighlightedFields); + const defaultHighlightedFields = [ ...alwaysDisplayedFields, ...getFieldsByCategory(eventCategories), ...getFieldsByEventCode(eventCode, eventCategories), ...getFieldsByRuleType(eventRuleType), ]; - // Filter all fields by their id to make sure there are no duplicates - return uniqBy('id', fields); + if (type === 'default') { + return uniqBy('id', defaultHighlightedFields); + } + + if (type === 'custom') { + return customHighlightedFields; + } + + return uniqBy('id', [...customHighlightedFields, ...defaultHighlightedFields]); } interface EventCategories { diff --git a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_exceptions/utils/helpers.tsx b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_exceptions/utils/helpers.tsx index 08e4dc4289bbc..d879b79a042f7 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_exceptions/utils/helpers.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_exceptions/utils/helpers.tsx @@ -43,7 +43,7 @@ import { removeIdFromExceptionItemsEntries } from '@kbn/securitysolution-list-ho import type { EcsSecurityExtension as Ecs, CodeSignature } from '@kbn/securitysolution-ecs'; import type { EventSummaryField } from '../../../common/components/event_details/types'; -import { getEventFieldsToDisplay } from '../../../common/components/event_details/get_alert_summary_rows'; +import { getHighlightedFieldsToDisplay } from '../../../common/components/event_details/get_alert_summary_rows'; import * as i18n from './translations'; import type { AlertData, Flattened } from './types'; @@ -987,11 +987,11 @@ export const getAlertHighlightedFields = ( allEventCategories: Array.isArray(eventCategory) ? eventCategory : [eventCategory], }; - const fieldsToDisplay = getEventFieldsToDisplay({ + const fieldsToDisplay = getHighlightedFieldsToDisplay({ eventCategories, eventCode, eventRuleType, - highlightedFieldsOverride: ruleCustomHighlightedFields, + ruleCustomHighlightedFields, }); return filterHighlightedFields(fieldsToDisplay, highlightedFieldsPrefixToExclude, alertData); }; diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/right/components/highlighted_fields.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/right/components/highlighted_fields.test.tsx index f4033bba7bce9..4850463ae6c33 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/right/components/highlighted_fields.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/right/components/highlighted_fields.test.tsx @@ -8,15 +8,33 @@ import React from 'react'; import { render } from '@testing-library/react'; import { DocumentDetailsContext } from '../../shared/context'; -import { HIGHLIGHTED_FIELDS_DETAILS_TEST_ID, HIGHLIGHTED_FIELDS_TITLE_TEST_ID } from './test_ids'; +import { + HIGHLIGHTED_FIELDS_DETAILS_TEST_ID, + HIGHLIGHTED_FIELDS_EDIT_BUTTON_TEST_ID, + HIGHLIGHTED_FIELDS_TITLE_TEST_ID, +} from './test_ids'; import { HighlightedFields } from './highlighted_fields'; -import { mockDataFormattedForFieldBrowser } from '../../shared/mocks/mock_data_formatted_for_field_browser'; import { useHighlightedFields } from '../../shared/hooks/use_highlighted_fields'; import { TestProviders } from '../../../../common/mock'; -import { useRuleWithFallback } from '../../../../detection_engine/rule_management/logic/use_rule_with_fallback'; +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'; 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(); +jest.mock('../../../../common/hooks/use_app_toasts', () => ({ + useAppToasts: () => ({ + addSuccess: mockAddSuccess, + }), +})); const renderHighlightedFields = (contextValue: DocumentDetailsContext) => render( @@ -30,35 +48,97 @@ const renderHighlightedFields = (contextValue: DocumentDetailsContext) => const NO_DATA_MESSAGE = "There's no highlighted fields for this alert."; describe('', () => { - beforeEach(() => { - (useRuleWithFallback as jest.Mock).mockReturnValue({ investigation_fields: undefined }); - }); + 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, + }); + }); + + it('should render the component', () => { + (useHighlightedFields as jest.Mock).mockReturnValue({ + field: { + values: ['value'], + }, + }); + + const { getByTestId, queryByTestId } = renderHighlightedFields(mockContextValue); - it('should render the component', () => { - const contextValue = { - dataFormattedForFieldBrowser: mockDataFormattedForFieldBrowser, - scopeId: 'scopeId', - } as unknown as DocumentDetailsContext; - (useHighlightedFields as jest.Mock).mockReturnValue({ - field: { - values: ['value'], - }, + 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(); }); - const { getByTestId } = renderHighlightedFields(contextValue); + it(`should render no data message if there aren't any highlighted fields`, () => { + (useHighlightedFields as jest.Mock).mockReturnValue({}); - expect(getByTestId(HIGHLIGHTED_FIELDS_TITLE_TEST_ID)).toBeInTheDocument(); - expect(getByTestId(HIGHLIGHTED_FIELDS_DETAILS_TEST_ID)).toBeInTheDocument(); + const { getByText, queryByTestId } = renderHighlightedFields(mockContextValue); + expect(getByText(NO_DATA_MESSAGE)).toBeInTheDocument(); + expect(queryByTestId(HIGHLIGHTED_FIELDS_EDIT_BUTTON_TEST_ID)).not.toBeInTheDocument(); + }); }); - it(`should render no data message if there aren't any highlighted fields`, () => { - const contextValue = { - dataFormattedForFieldBrowser: mockDataFormattedForFieldBrowser, - scopeId: 'scopeId', - } as unknown as DocumentDetailsContext; - (useHighlightedFields as jest.Mock).mockReturnValue({}); + 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'], + }, + }); - const { getByText } = renderHighlightedFields(contextValue); - expect(getByText(NO_DATA_MESSAGE)).toBeInTheDocument(); + const { getByTestId } = renderHighlightedFields(mockContextValue); + + 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, getByTestId } = renderHighlightedFields(mockContextValue); + expect(getByText(NO_DATA_MESSAGE)).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(); + }); }); }); diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/right/components/highlighted_fields.tsx b/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/right/components/highlighted_fields.tsx index 3824860bf5677..ac7b3b3a647e4 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/right/components/highlighted_fields.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/right/components/highlighted_fields.tsx @@ -6,18 +6,18 @@ */ import type { FC } from 'react'; -import React, { useMemo } from 'react'; +import React, { 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 { convertHighlightedFieldsToTableRow } from '../../shared/utils/highlighted_fields_helpers'; -import { useRuleWithFallback } from '../../../../detection_engine/rule_management/logic/use_rule_with_fallback'; -import { useBasicDataFromDetailsData } from '../../shared/hooks/use_basic_data_from_details_data'; 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 { /** @@ -92,13 +92,17 @@ const columns: Array> = [ * Component that displays the highlighted fields in the right panel under the Investigation section. */ export const HighlightedFields: FC = () => { - const { dataFormattedForFieldBrowser, scopeId, isPreview } = useDocumentDetailsContext(); - const { ruleId } = useBasicDataFromDetailsData(dataFormattedForFieldBrowser); - const { loading, rule: maybeRule } = useRuleWithFallback(ruleId); + const { dataFormattedForFieldBrowser, scopeId, isPreview, investigationFields } = + useDocumentDetailsContext(); + + const [isEditLoading, setIsEditLoading] = useState(false); + const editHighlightedFieldsEnabled = useIsExperimentalFeatureEnabled( + 'editHighlightedFieldsEnabled' + ); const highlightedFields = useHighlightedFields({ dataFormattedForFieldBrowser, - investigationFields: maybeRule?.investigation_fields?.field_names ?? [], + investigationFields, }); const items = useMemo( () => convertHighlightedFieldsToTableRow(highlightedFields, scopeId, isPreview), @@ -106,16 +110,29 @@ export const HighlightedFields: FC = () => { ); return ( - - - -
- -
-
+ + + + + +
+ +
+
+
+ {editHighlightedFieldsEnabled && ( + + + + )} +
@@ -123,7 +140,7 @@ export const HighlightedFields: FC = () => { items={items} columns={columns} compressed - loading={loading} + loading={isEditLoading} message={ + render( + + + + + + ); + +describe('', () => { + beforeEach(() => { + jest.clearAllMocks(); + (useHighlightedFieldsPrivilege as jest.Mock).mockReturnValue({ + isDisabled: false, + tooltipContent: 'tooltip content', + }); + (useRuleIndexPattern as jest.Mock).mockReturnValue({ + indexPattern: { fields: [{ name: 'field1' }, { name: 'field2' }] }, + isIndexPatternLoading: false, + }); + (useRuleDetails as jest.Mock).mockReturnValue({ + rule: { id: '123' } as RuleResponse, + isExistingRule: true, + loading: false, + }); + }); + + it('should render button when user has privilege to edit rule', () => { + const { getByTestId } = renderEditHighlighedFieldsButton(); + expect(getByTestId(HIGHLIGHTED_FIELDS_EDIT_BUTTON_TEST_ID)).toBeInTheDocument(); + expect(getByTestId(HIGHLIGHTED_FIELDS_EDIT_BUTTON_TEST_ID)).not.toBeDisabled(); + }); + + it('should render disabled button when user does not have privilege to edit a prebuilt rule', () => { + (useHighlightedFieldsPrivilege as jest.Mock).mockReturnValue({ + isDisabled: true, + tooltipContent: 'tooltip content', + }); + const { getByTestId } = renderEditHighlighedFieldsButton(); + expect(getByTestId(HIGHLIGHTED_FIELDS_EDIT_BUTTON_TEST_ID)).toBeInTheDocument(); + expect(getByTestId(HIGHLIGHTED_FIELDS_EDIT_BUTTON_TEST_ID)).toBeDisabled(); + }); + + it('should render modal when button is clicked', () => { + const { getByTestId } = renderEditHighlighedFieldsButton(); + + const button = getByTestId(HIGHLIGHTED_FIELDS_EDIT_BUTTON_TEST_ID); + fireEvent.click(button); + expect(getByTestId(HIGHLIGHTED_FIELDS_MODAL_TEST_ID)).toBeInTheDocument(); + }); + + it('should render loading spinner when rule is loading', () => { + (useRuleDetails as jest.Mock).mockReturnValue({ + rule: null, + isExistingRule: true, + loading: true, + }); + const { getByTestId } = renderEditHighlighedFieldsButton(); + expect(getByTestId(HIGHLIGHTED_FIELDS_EDIT_BUTTON_LOADING_TEST_ID)).toBeInTheDocument(); + }); + + it('should not render button when rule is not found', () => { + (useRuleDetails as jest.Mock).mockReturnValue({ + rule: null, + isExistingRule: false, + loading: false, + }); + const { container } = renderEditHighlighedFieldsButton(); + expect(container).toBeEmptyDOMElement(); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/right/components/highlighted_fields_button.tsx b/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/right/components/highlighted_fields_button.tsx new file mode 100644 index 0000000000000..c54f672e72e99 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/right/components/highlighted_fields_button.tsx @@ -0,0 +1,95 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license 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, { useState, useCallback } from 'react'; +import type { FC } from 'react'; +import { EuiButtonEmpty, EuiToolTip, EuiLoadingSpinner } 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'; +import { useBasicDataFromDetailsData } from '../../shared/hooks/use_basic_data_from_details_data'; +import { useRuleDetails } from '../../../rule_details/hooks/use_rule_details'; +import { HighlightedFieldsModal } from './highlighted_fields_modal'; +import { + 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 { + /** + * Preselected custom highlighted fields + */ + customHighlightedFields: string[]; + /** + * The data formatted for field browser + */ + dataFormattedForFieldBrowser: TimelineEventsDetailsItem[]; + /** + * The function to set the edit loading state + */ + setIsEditLoading: (isEditLoading: boolean) => void; +} + +/** + * Component that displays the highlighted fields in the right panel under the Investigation section. + */ +export const EditHighlightedFieldsButton: FC = ({ + customHighlightedFields, + dataFormattedForFieldBrowser, + setIsEditLoading, +}) => { + const { ruleId } = useBasicDataFromDetailsData(dataFormattedForFieldBrowser); + const { rule, isExistingRule, loading: isRuleLoading } = useRuleDetails({ ruleId }); + + const [isModalVisible, setIsModalVisible] = useState(false); + const onClick = useCallback(() => setIsModalVisible(true), []); + + const { isDisabled, tooltipContent } = useHighlightedFieldsPrivilege({ + rule, + isExistingRule, + }); + + if (isRuleLoading) { + return ( + + ); + } + + if (!rule) { + return null; + } + + return ( + <> + + + + + + {isModalVisible && ( + + )} + + ); +}; diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/right/components/highlighted_fields_modal.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/right/components/highlighted_fields_modal.test.tsx new file mode 100644 index 0000000000000..37705ca5bf196 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/right/components/highlighted_fields_modal.test.tsx @@ -0,0 +1,150 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license 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, act } from '@testing-library/react'; +import type { DataViewFieldBase } from '@kbn/es-query'; +import { TestProviders } from '../../../../common/mock'; +import { DocumentDetailsContext } from '../../shared/context'; +import { mockContextValue } from '../../shared/mocks/mock_context'; +import { usePrebuiltRuleCustomizationUpsellingMessage } from '../../../../detection_engine/rule_management/logic/prebuilt_rules/use_prebuilt_rule_customization_upselling_message'; +import { useRuleIndexPattern } from '../../../../detection_engine/rule_creation_ui/pages/form'; +import { HighlightedFieldsModal } from './highlighted_fields_modal'; +import type { RuleResponse, RuleUpdateProps } from '../../../../../common/api/detection_engine'; +import { mockDataFormattedForFieldBrowser } from '../../shared/mocks/mock_data_formatted_for_field_browser'; +import { + HIGHLIGHTED_FIELDS_MODAL_CANCEL_BUTTON_TEST_ID, + HIGHLIGHTED_FIELDS_MODAL_CUSTOM_FIELDS_TEST_ID, + HIGHLIGHTED_FIELDS_MODAL_SAVE_BUTTON_TEST_ID, + HIGHLIGHTED_FIELDS_MODAL_TEST_ID, + HIGHLIGHTED_FIELDS_MODAL_DEFAULT_FIELDS_TEST_ID, +} from './test_ids'; +import { useUpdateRule } from '../../../../detection_engine/rule_management/logic/use_update_rule'; +import { useHighlightedFields } from '../../shared/hooks/use_highlighted_fields'; + +jest.mock( + '../../../../detection_engine/rule_management/logic/prebuilt_rules/use_prebuilt_rule_customization_upselling_message' +); +jest.mock('../../../../detection_engine/rule_creation_ui/pages/form'); +jest.mock('../../../../detection_engine/rule_management/logic/use_update_rule'); +jest.mock('../../shared/hooks/use_highlighted_fields'); +jest.mock('../../../rule_details/hooks/use_rule_details'); + +const mockAddSuccess = jest.fn(); +jest.mock('../../../../common/hooks/use_app_toasts', () => ({ + useAppToasts: () => ({ + addSuccess: mockAddSuccess, + }), +})); + +const mockSetIsEditLoading = jest.fn(); +const mockSetIsModalVisible = jest.fn(); +const mockFieldOptions = [{ name: 'field1' }, { name: 'field2' }] as DataViewFieldBase[]; +const mockUpdateRule = jest.fn(); +const mockRule = { id: '123', name: 'test rule' } as RuleResponse; + +const defaultProps = { + rule: mockRule, + customHighlightedFields: [] as string[], + dataFormattedForFieldBrowser: mockDataFormattedForFieldBrowser, + setIsEditLoading: mockSetIsEditLoading, + setIsModalVisible: mockSetIsModalVisible, +}; + +const renderHighlighedFieldsModal = (props = defaultProps) => + render( + + + + + + ); + +describe('', () => { + beforeEach(() => { + jest.clearAllMocks(); + (usePrebuiltRuleCustomizationUpsellingMessage as jest.Mock).mockReturnValue('upsell message'); + (useRuleIndexPattern as jest.Mock).mockReturnValue({ + indexPattern: { fields: [{ name: 'option1' }, { name: 'option2' }] }, + isIndexPatternLoading: false, + }); + (useUpdateRule as jest.Mock).mockReturnValue({ + mutateAsync: jest.fn(), + }); + (useHighlightedFields as jest.Mock).mockReturnValue({ + default1: { values: ['test'] }, + default2: { values: ['test2'] }, + }); + (useRuleIndexPattern as jest.Mock).mockReturnValue({ + indexPattern: { fields: mockFieldOptions }, + isIndexPatternLoading: false, + }); + }); + + it('should render modal without preselected custom fields', async () => { + const { getByTestId, queryByTestId } = renderHighlighedFieldsModal(); + + expect(getByTestId(HIGHLIGHTED_FIELDS_MODAL_TEST_ID)).toBeInTheDocument(); + + const fields = getByTestId(HIGHLIGHTED_FIELDS_MODAL_DEFAULT_FIELDS_TEST_ID); + for (const f of ['default1', 'default2']) { + expect(fields).toHaveTextContent(f); + } + + expect(getByTestId(HIGHLIGHTED_FIELDS_MODAL_CUSTOM_FIELDS_TEST_ID)).toBeInTheDocument(); + expect(queryByTestId('euiComboBoxPill')).not.toBeInTheDocument(); // no preselected custom fields + }); + + it('should render modal with preselectedcustom fields', () => { + const { getByTestId, getAllByTestId } = renderHighlighedFieldsModal({ + ...defaultProps, + customHighlightedFields: ['custom1', 'custom2'], + }); + + expect(getByTestId(HIGHLIGHTED_FIELDS_MODAL_TEST_ID)).toBeInTheDocument(); + + expect(getByTestId(HIGHLIGHTED_FIELDS_MODAL_CUSTOM_FIELDS_TEST_ID)).toBeInTheDocument(); + expect(getAllByTestId('euiComboBoxPill')).toHaveLength(2); + expect(getAllByTestId('euiComboBoxPill')[0]).toHaveTextContent('custom1'); + expect(getAllByTestId('euiComboBoxPill')[1]).toHaveTextContent('custom2'); + }); + + it('should close modal when cancel button is clicked', async () => { + const { getByTestId } = renderHighlighedFieldsModal(); + const cancelButton = getByTestId(HIGHLIGHTED_FIELDS_MODAL_CANCEL_BUTTON_TEST_ID); + await act(async () => { + fireEvent.click(cancelButton); + }); + expect(mockSetIsModalVisible).toHaveBeenCalledWith(false); + }); + + it('should update rule when save button is clicked', async () => { + (useUpdateRule as jest.Mock).mockReturnValue({ mutateAsync: mockUpdateRule }); + mockUpdateRule.mockResolvedValue({ + name: 'updated rule', + } as RuleResponse); + + const { getByTestId } = renderHighlighedFieldsModal({ + ...defaultProps, + customHighlightedFields: ['custom1', 'custom2'], + }); + + await act(async () => { + getByTestId(HIGHLIGHTED_FIELDS_MODAL_SAVE_BUTTON_TEST_ID).click(); + }); + + expect(mockUpdateRule).toHaveBeenCalledWith({ + name: mockRule.name, + investigation_fields: { field_names: ['custom1', 'custom2'] }, + } as RuleUpdateProps); + + expect(mockAddSuccess).toHaveBeenCalledWith('updated rule was saved'); + expect(mockUpdateRule).toHaveBeenCalled(); + expect(mockSetIsEditLoading).toHaveBeenCalledTimes(2); + expect(mockSetIsModalVisible).toHaveBeenCalledWith(false); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/right/components/highlighted_fields_modal.tsx b/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/right/components/highlighted_fields_modal.tsx new file mode 100644 index 0000000000000..b6036a5c44e35 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/right/components/highlighted_fields_modal.tsx @@ -0,0 +1,287 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license 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, { useMemo, useState, useCallback } from 'react'; +import type { FC } from 'react'; +import { + EuiButton, + EuiButtonEmpty, + EuiFlexGroup, + EuiFlexItem, + EuiText, + EuiSpacer, + EuiModal, + EuiModalBody, + EuiModalFooter, + useGeneratedHtmlId, + EuiBadge, + EuiModalHeader, + EuiModalHeaderTitle, + useEuiTheme, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { css } from '@emotion/react'; +import { FormattedMessage } from '@kbn/i18n-react'; +import type { TimelineEventsDetailsItem } from '@kbn/timelines-plugin/common'; +import type { RuleResponse, RuleUpdateProps } from '../../../../../common/api/detection_engine'; +import { getDefineStepsData } from '../../../../detection_engine/common/helpers'; +import { useRuleIndexPattern } from '../../../../detection_engine/rule_creation_ui/pages/form'; +import { useDefaultIndexPattern } from '../../../../detection_engine/rule_management/hooks/use_default_index_pattern'; +import { useAppToasts } from '../../../../common/hooks/use_app_toasts'; +import { useUpdateRule } from '../../../../detection_engine/rule_management/logic/use_update_rule'; +import { + Form, + Field, + getUseField, + useForm, + FIELD_TYPES, + fieldValidators, +} from '../../../../shared_imports'; +import type { FormSchema } from '../../../../shared_imports'; +import { useHighlightedFields } from '../../shared/hooks/use_highlighted_fields'; +import { + HIGHLIGHTED_FIELDS_MODAL_CANCEL_BUTTON_TEST_ID, + HIGHLIGHTED_FIELDS_MODAL_DESCRIPTION_TEST_ID, + HIGHLIGHTED_FIELDS_MODAL_SAVE_BUTTON_TEST_ID, + HIGHLIGHTED_FIELDS_MODAL_DEFAULT_FIELDS_TEST_ID, + HIGHLIGHTED_FIELDS_MODAL_CUSTOM_FIELDS_TEST_ID, + HIGHLIGHTED_FIELDS_MODAL_TITLE_TEST_ID, + HIGHLIGHTED_FIELDS_MODAL_TEST_ID, +} from './test_ids'; + +const SUCCESSFULLY_SAVED_RULE = (ruleName: string) => + i18n.translate('xpack.securitySolution.detectionEngine.rules.update.successfullySavedRuleTitle', { + values: { ruleName }, + defaultMessage: '{ruleName} was saved', + }); + +const ADD_CUSTOM_FIELD_LABEL = i18n.translate( + 'xpack.securitySolution.flyout.right.investigation.highlightedFields.modalAddCustomFieldLabel', + { defaultMessage: 'Add custom' } +); + +const SELECT_PLACEHOLDER = i18n.translate( + 'xpack.securitySolution.flyout.right.investigation.highlightedFields.modalSelectPlaceholder', + { defaultMessage: 'Select or search for options' } +); + +const CommonUseField = getUseField({ component: Field }); + +interface InvestigationFieldsFormData { + customHighlightedFields: string[]; +} + +const schema: FormSchema = { + customHighlightedFields: { + fieldsToValidateOnChange: ['customHighlightedFields'], + type: FIELD_TYPES.COMBO_BOX, + validations: [{ validator: fieldValidators.emptyField('error') }], + }, +}; + +const formConfig = { + ...schema.customHighlightedFields, + label: ADD_CUSTOM_FIELD_LABEL, +}; + +interface HighlightedFieldsModalProps { + /** + * The data formatted for field browser + */ + dataFormattedForFieldBrowser: TimelineEventsDetailsItem[]; + /** + * The rule + */ + rule: RuleResponse; + /** + * The custom highlighted fields + */ + customHighlightedFields: string[]; + /** + * The function to set the edit loading state + */ + setIsEditLoading: (isEditLoading: boolean) => void; + /** + * The function to set the modal visible state + */ + setIsModalVisible: (isModalVisible: boolean) => void; +} + +/** + * Modal for editing the highlighted fields of a rule. + */ +export const HighlightedFieldsModal: FC = ({ + rule, + customHighlightedFields, + dataFormattedForFieldBrowser, + setIsEditLoading, + setIsModalVisible, +}) => { + const defaultIndexPattern = useDefaultIndexPattern(); + const { dataSourceType, index, dataViewId } = useMemo(() => getDefineStepsData(rule), [rule]); + const { indexPattern: dataView } = useRuleIndexPattern({ + dataSourceType, + index: index.length > 0 ? index : defaultIndexPattern, // fallback to default index pattern if rule has no index patterns + dataViewId, + }); + + const { addSuccess } = useAppToasts(); + const { euiTheme } = useEuiTheme(); + const { mutateAsync: updateRule } = useUpdateRule(); + const modalTitleId = useGeneratedHtmlId(); + + const defaultFields = useHighlightedFields({ + dataFormattedForFieldBrowser, + investigationFields: customHighlightedFields, + type: 'default', + }); + const defaultFieldsArray = useMemo(() => Object.keys(defaultFields), [defaultFields]); + + const options = useMemo(() => { + const allFields = dataView.fields; + return allFields + .filter((field) => !defaultFieldsArray.includes(field.name)) + .map((field) => ({ label: field.name })); + }, [dataView, defaultFieldsArray]); + + const customFields = useMemo( + () => customHighlightedFields.map((field: string) => ({ label: field })), + [customHighlightedFields] + ); + + const [selectedOptions, setSelectedOptions] = useState(customFields); + + const { form } = useForm({ + defaultValue: { customHighlightedFields: [] }, + schema, + }); + + const onCancel = useCallback(() => { + setIsModalVisible(false); + }, [setIsModalVisible]); + + const onSubmit = useCallback(async () => { + setIsEditLoading(true); + + const updatedRule = await updateRule({ + ...rule, + id: undefined, + investigation_fields: + selectedOptions.length > 0 + ? { field_names: selectedOptions.map((option) => option.label) } + : undefined, + } as RuleUpdateProps); + + addSuccess(SUCCESSFULLY_SAVED_RULE(updatedRule?.name ?? 'rule')); + setIsEditLoading(false); + setIsModalVisible(false); + }, [updateRule, addSuccess, rule, setIsModalVisible, setIsEditLoading, selectedOptions]); + + const componentProps = useMemo( + () => ({ + idAria: 'customizeHighlightedFields', + 'data-test-subj': HIGHLIGHTED_FIELDS_MODAL_CUSTOM_FIELDS_TEST_ID, + euiFieldProps: { + fullWidth: true, + noSuggestions: false, + onChange: (fields: Array<{ label: string }>) => setSelectedOptions(fields), + options, + placeholder: SELECT_PLACEHOLDER, + selectedOptions, + }, + }), + [options, selectedOptions] + ); + + return ( + + + + + + + + + {rule.name} }} + /> + + + + + + + + {defaultFieldsArray.map((field: string) => ( + + {field} + + ))} + + +
+ + +
+ + + + + + + + + + + + + + +
+ ); +}; diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/right/components/investigation_section.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/right/components/investigation_section.test.tsx index 9a25043abbdf4..60f0ae5efcfd0 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/right/components/investigation_section.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/right/components/investigation_section.test.tsx @@ -23,11 +23,20 @@ import { mockContextValue } from '../../shared/mocks/mock_context'; 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'; 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'); + +const mockAddSuccess = jest.fn(); +jest.mock('../../../../common/hooks/use_app_toasts', () => ({ + useAppToasts: () => ({ + addSuccess: mockAddSuccess, + }), +})); const panelContextValue = { ...mockContextValue, @@ -52,6 +61,7 @@ describe('', () => { jest.clearAllMocks(); (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', () => { diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/right/components/test_ids.ts b/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/right/components/test_ids.ts index c73c41062f4f8..779f11326a2e4 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/right/components/test_ids.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/right/components/test_ids.ts @@ -101,6 +101,26 @@ export const HIGHLIGHTED_FIELDS_LINKED_CELL_TEST_ID = export const HIGHLIGHTED_FIELDS_AGENT_STATUS_CELL_TEST_ID = `${HIGHLIGHTED_FIELDS_TEST_ID}AgentStatusCell` as const; +export const HIGHLIGHTED_FIELDS_EDIT_BUTTON_TEST_ID = + `${HIGHLIGHTED_FIELDS_TEST_ID}EditButton` as const; +export const HIGHLIGHTED_FIELDS_EDIT_BUTTON_LOADING_TEST_ID = + `${HIGHLIGHTED_FIELDS_EDIT_BUTTON_TEST_ID}Loading` as const; +export const HIGHLIGHTED_FIELDS_EDIT_BUTTON_TOOLTIP_TEST_ID = + `${HIGHLIGHTED_FIELDS_EDIT_BUTTON_TEST_ID}Tooltip` as const; +export const HIGHLIGHTED_FIELDS_MODAL_TEST_ID = `${HIGHLIGHTED_FIELDS_TEST_ID}Modal` as const; +export const HIGHLIGHTED_FIELDS_MODAL_TITLE_TEST_ID = + `${HIGHLIGHTED_FIELDS_MODAL_TEST_ID}Title` as const; +export const HIGHLIGHTED_FIELDS_MODAL_DESCRIPTION_TEST_ID = + `${HIGHLIGHTED_FIELDS_MODAL_TEST_ID}Description` as const; +export const HIGHLIGHTED_FIELDS_MODAL_DEFAULT_FIELDS_TEST_ID = + `${HIGHLIGHTED_FIELDS_MODAL_TEST_ID}DefaultFields` as const; +export const HIGHLIGHTED_FIELDS_MODAL_CUSTOM_FIELDS_TEST_ID = + `${HIGHLIGHTED_FIELDS_MODAL_TEST_ID}CustomFields` as const; +export const HIGHLIGHTED_FIELDS_MODAL_SAVE_BUTTON_TEST_ID = + `${HIGHLIGHTED_FIELDS_MODAL_TEST_ID}SaveButton` as const; +export const HIGHLIGHTED_FIELDS_MODAL_CANCEL_BUTTON_TEST_ID = + `${HIGHLIGHTED_FIELDS_MODAL_TEST_ID}CancelButton` as const; + /* Insights section */ export const INSIGHTS_TEST_ID = `${PREFIX}Insights` as const; diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/shared/context.tsx b/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/shared/context.tsx index 72da35f9286b2..842df3497fecb 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/shared/context.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/shared/context.tsx @@ -138,19 +138,19 @@ export const DocumentDetailsProvider = memo( } : undefined, [ - id, - indexName, - scopeId, + browserFields, dataAsNestedObject, dataFormattedForFieldBrowser, - searchHit, - browserFields, - maybeRule?.investigation_fields?.field_names, - refetchFlyoutData, getFieldsData, + id, + indexName, isPreviewMode, - jumpToEntityId, jumpToCursor, + jumpToEntityId, + refetchFlyoutData, + scopeId, + searchHit, + maybeRule, ] ); diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/shared/hooks/use_highlighted_fields.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/shared/hooks/use_highlighted_fields.test.tsx index 6bffb7c58ae3f..337fc6038e5d1 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/shared/hooks/use_highlighted_fields.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/shared/hooks/use_highlighted_fields.test.tsx @@ -21,7 +21,12 @@ const dataFormattedForFieldBrowser = mockDataFormattedForFieldBrowser; describe('useHighlightedFields', () => { it('should return data', () => { - const hookResult = renderHook(() => useHighlightedFields({ dataFormattedForFieldBrowser })); + const hookResult = renderHook(() => + useHighlightedFields({ + dataFormattedForFieldBrowser, + investigationFields: [], + }) + ); expect(hookResult.result.current).toEqual({ 'host.name': { values: ['host-name'], @@ -39,6 +44,7 @@ describe('useHighlightedFields', () => { const hookResult = renderHook(() => useHighlightedFields({ dataFormattedForFieldBrowser: mockDataFormattedForFieldBrowserWithOverridenField, + investigationFields: [], }) ); diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/shared/hooks/use_highlighted_fields.ts b/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/shared/hooks/use_highlighted_fields.ts index 571e01b9a2e22..7ded63adec2c3 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/shared/hooks/use_highlighted_fields.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/shared/hooks/use_highlighted_fields.ts @@ -12,7 +12,7 @@ import { useAlertResponseActionsSupport } from '../../../../common/hooks/endpoin import { isResponseActionsAlertAgentIdField } from '../../../../common/lib/endpoint'; import { getEventCategoriesFromData, - getEventFieldsToDisplay, + getHighlightedFieldsToDisplay, } from '../../../../common/components/event_details/get_alert_summary_rows'; export interface UseHighlightedFieldsParams { @@ -23,7 +23,14 @@ export interface UseHighlightedFieldsParams { /** * An array of fields user has selected to highlight, defined on rule */ - investigationFields?: string[]; + investigationFields: string[]; + /** + * Optional prop to specify the type of highlighted fields to display + * Custom: fields defined on the rule + * Default: fields defined by elastic + * All: both custom and default fields + */ + type?: 'default' | 'custom' | 'all'; } export interface UseHighlightedFieldsResult { @@ -45,6 +52,7 @@ export interface UseHighlightedFieldsResult { export const useHighlightedFields = ({ dataFormattedForFieldBrowser, investigationFields, + type, }: UseHighlightedFieldsParams): UseHighlightedFieldsResult => { const responseActionsSupport = useAlertResponseActionsSupport(dataFormattedForFieldBrowser); const eventCategories = getEventCategoriesFromData(dataFormattedForFieldBrowser); @@ -67,11 +75,12 @@ export const useHighlightedFields = ({ ? eventRuleTypeField?.originalValue?.[0] : eventRuleTypeField?.originalValue; - const tableFields = getEventFieldsToDisplay({ + const tableFields = getHighlightedFieldsToDisplay({ eventCategories, eventCode, eventRuleType, - highlightedFieldsOverride: investigationFields ?? [], + ruleCustomHighlightedFields: investigationFields, + type, }); return tableFields.reduce((acc, field) => { diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/shared/hooks/use_highlighted_fields_privilege.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/shared/hooks/use_highlighted_fields_privilege.test.tsx new file mode 100644 index 0000000000000..bfa299a6e19ab --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/shared/hooks/use_highlighted_fields_privilege.test.tsx @@ -0,0 +1,138 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { renderHook } from '@testing-library/react'; +import { useHighlightedFieldsPrivilege } from './use_highlighted_fields_privilege'; +import type { UseHighlightedFieldsPrivilegeParams } from './use_highlighted_fields_privilege'; +import { usePrebuiltRuleCustomizationUpsellingMessage } from '../../../../detection_engine/rule_management/logic/prebuilt_rules/use_prebuilt_rule_customization_upselling_message'; +import { hasMlLicense } from '../../../../../common/machine_learning/has_ml_license'; +import { hasMlAdminPermissions } from '../../../../../common/machine_learning/has_ml_admin_permissions'; +import type { RuleResponse } from '../../../../../common/api/detection_engine'; +import { + LACK_OF_KIBANA_SECURITY_PRIVILEGES, + ML_RULES_DISABLED_MESSAGE, +} from '../../../../detection_engine/common/translations'; +import { useUserData } from '../../../../detections/components/user_info'; + +jest.mock('../../../../common/components/ml/hooks/use_ml_capabilities'); +jest.mock( + '../../../../detection_engine/rule_management/logic/prebuilt_rules/use_prebuilt_rule_customization_upselling_message' +); +jest.mock('../../../../../common/machine_learning/has_ml_license'); +jest.mock('../../../../../common/machine_learning/has_ml_admin_permissions'); +jest.mock('../../../../detections/components/user_info'); + +const defaultProps = { + rule: {} as RuleResponse, + isExistingRule: true, +}; + +const renderUseHighlightedFieldsPrivilege = (props: UseHighlightedFieldsPrivilegeParams) => + renderHook(() => useHighlightedFieldsPrivilege(props)); + +describe('useHighlightedFieldsPrivilege', () => { + beforeEach(() => { + jest.clearAllMocks(); + (useUserData as jest.Mock).mockReturnValue([{ canUserCRUD: true }]); + (hasMlAdminPermissions as jest.Mock).mockReturnValue(false); + (hasMlLicense as jest.Mock).mockReturnValue(false); + (usePrebuiltRuleCustomizationUpsellingMessage as jest.Mock).mockReturnValue(undefined); + }); + + it('should return isDisabled as true when rule is null', () => { + const { result } = renderUseHighlightedFieldsPrivilege({ + ...defaultProps, + rule: null, + }); + + expect(result.current.isDisabled).toBe(true); + expect(result.current.tooltipContent).toBe('Deleted rule cannot be edited.'); + }); + + it('should return isDisabled as true when rule does not exist', () => { + const { result } = renderUseHighlightedFieldsPrivilege({ + ...defaultProps, + isExistingRule: false, + }); + + expect(result.current.isDisabled).toBe(true); + expect(result.current.tooltipContent).toBe('Deleted rule cannot be edited.'); + }); + + it('should return isDisabled as true when user does not have CRUD privileges', () => { + (useUserData as jest.Mock).mockReturnValue([{ canUserCRUD: false }]); + const { result } = renderUseHighlightedFieldsPrivilege(defaultProps); + expect(result.current.isDisabled).toBe(true); + expect(result.current.tooltipContent).toContain(LACK_OF_KIBANA_SECURITY_PRIVILEGES); + }); + + describe('when rule is machine learning rule', () => { + it('should return isDisabled as true when user does not have ml permissions', () => { + (hasMlAdminPermissions as jest.Mock).mockReturnValue(false); + const { result } = renderUseHighlightedFieldsPrivilege({ + ...defaultProps, + rule: { type: 'machine_learning' } as RuleResponse, + }); + expect(result.current.isDisabled).toBe(true); + expect(result.current.tooltipContent).toContain(ML_RULES_DISABLED_MESSAGE); + }); + + it('should return isDisabled as true when user does not have ml license', () => { + (hasMlLicense as jest.Mock).mockReturnValue(false); + const { result } = renderUseHighlightedFieldsPrivilege({ + ...defaultProps, + rule: { type: 'machine_learning' } as RuleResponse, + }); + expect(result.current.isDisabled).toBe(true); + expect(result.current.tooltipContent).toContain(ML_RULES_DISABLED_MESSAGE); + }); + + it('should return isDisabled as false when user has ml permissions and proper license', () => { + (hasMlAdminPermissions as jest.Mock).mockReturnValue(true); + (hasMlLicense as jest.Mock).mockReturnValue(true); + const { result } = renderUseHighlightedFieldsPrivilege({ + ...defaultProps, + rule: { type: 'machine_learning' } as RuleResponse, + }); + expect(result.current.isDisabled).toBe(false); + expect(result.current.tooltipContent).toBe('Edit highlighted fields'); + }); + }); + + describe('when rule is not machine learning rule', () => { + it('should return isDisabled as false when rule is not immutable (custom rule)', () => { + const { result } = renderUseHighlightedFieldsPrivilege({ + ...defaultProps, + rule: { type: 'query', immutable: false } as RuleResponse, + }); + expect(result.current.isDisabled).toBe(false); + expect(result.current.tooltipContent).toBe('Edit highlighted fields'); + }); + + it('should return isDisabled as false when rule is immutable (prebuilt rule) and upselling message is undefined', () => { + (usePrebuiltRuleCustomizationUpsellingMessage as jest.Mock).mockReturnValue(undefined); + const { result } = renderUseHighlightedFieldsPrivilege({ + ...defaultProps, + rule: { type: 'query', immutable: true } as RuleResponse, + }); + expect(result.current.isDisabled).toBe(false); + expect(result.current.tooltipContent).toContain('Edit highlighted fields'); + }); + + it('should return isDisabled as true when rule is immutable (prebuilt rule) and upselling message is available', () => { + (usePrebuiltRuleCustomizationUpsellingMessage as jest.Mock).mockReturnValue( + 'upselling message' + ); + const { result } = renderUseHighlightedFieldsPrivilege({ + ...defaultProps, + rule: { type: 'query', immutable: true } as RuleResponse, + }); + expect(result.current.isDisabled).toBe(true); + expect(result.current.tooltipContent).toContain('upselling message'); + }); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/shared/hooks/use_highlighted_fields_privilege.tsx b/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/shared/hooks/use_highlighted_fields_privilege.tsx new file mode 100644 index 0000000000000..1f9422c8da7ec --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/shared/hooks/use_highlighted_fields_privilege.tsx @@ -0,0 +1,106 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useMemo } from 'react'; +import { i18n } from '@kbn/i18n'; +import { useMlCapabilities } from '../../../../common/components/ml/hooks/use_ml_capabilities'; +import { usePrebuiltRuleCustomizationUpsellingMessage } from '../../../../detection_engine/rule_management/logic/prebuilt_rules/use_prebuilt_rule_customization_upselling_message'; +import { + explainLackOfPermission, + hasUserCRUDPermission, +} from '../../../../common/utils/privileges'; +import { hasMlLicense } from '../../../../../common/machine_learning/has_ml_license'; +import { hasMlAdminPermissions } from '../../../../../common/machine_learning/has_ml_admin_permissions'; +import { useUserData } from '../../../../detections/components/user_info'; +import { isMlRule } from '../../../../../common/machine_learning/helpers'; +import type { RuleResponse } from '../../../../../common/api/detection_engine'; + +export interface UseHighlightedFieldsPrivilegeParams { + /** + * The rule to be edited + */ + rule: RuleResponse | null; + /** + * Whether the rule exists + */ + isExistingRule: boolean; +} + +interface UseHighlightedFieldsPrivilegeResult { + /** + * Whether edit highlighted fields button is disabled + */ + isDisabled: boolean; + /** + * The tooltip content + */ + tooltipContent: string; +} + +/** + * Returns whether the edit highlighted fields button is disabled and the tooltip content + */ +export const useHighlightedFieldsPrivilege = ({ + rule, + isExistingRule, +}: UseHighlightedFieldsPrivilegeParams): UseHighlightedFieldsPrivilegeResult => { + const [{ canUserCRUD }] = useUserData(); + const mlCapabilities = useMlCapabilities(); + const hasMlPermissions = hasMlLicense(mlCapabilities) && hasMlAdminPermissions(mlCapabilities); + + const isEditRuleDisabled = + !rule || + !isExistingRule || + !hasUserCRUDPermission(canUserCRUD) || + (isMlRule(rule?.type) && !hasMlPermissions); + + const upsellingMessage = usePrebuiltRuleCustomizationUpsellingMessage( + 'prebuilt_rule_customization' + ); + + const isDisabled = isEditRuleDisabled || (Boolean(upsellingMessage) && rule?.immutable); + + const tooltipContent = useMemo(() => { + const explanation = explainLackOfPermission( + rule, + hasMlPermissions, + true, // default true because we don't need the message for lack of action privileges + canUserCRUD + ); + + if (isEditRuleDisabled && explanation) { + return explanation; + } + if (isEditRuleDisabled && (!isExistingRule || !rule)) { + return i18n.translate( + 'xpack.securitySolution.flyout.right.investigation.highlightedFields.editHighlightedFieldsDeletedRuleTooltip', + { defaultMessage: 'Deleted rule cannot be edited.' } + ); + } + if (upsellingMessage && rule?.immutable) { + return i18n.translate( + 'xpack.securitySolution.flyout.right.investigation.highlightedFields.editHighlightedFieldsButtonUpsellingTooltip', + { + defaultMessage: '{upsellingMessage}', + values: { upsellingMessage }, + } + ); + } + return i18n.translate( + 'xpack.securitySolution.flyout.right.investigation.highlightedFields.editHighlightedFieldsButtonTooltip', + { defaultMessage: 'Edit highlighted fields' } + ); + }, [canUserCRUD, hasMlPermissions, isEditRuleDisabled, isExistingRule, rule, upsellingMessage]); + + return useMemo( + () => ({ + isDisabled, + tooltipContent, + }), + [isDisabled, tooltipContent] + ); +}; diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/shared/hooks/use_prevalence.ts b/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/shared/hooks/use_prevalence.ts index 8811c91a0b34d..cf4e6416235ad 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/shared/hooks/use_prevalence.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/shared/hooks/use_prevalence.ts @@ -41,7 +41,7 @@ export interface UsePrevalenceParams { /** * User defined fields to highlight (defined on the rule) */ - investigationFields?: string[]; + investigationFields: string[]; } export interface UsePrevalenceResult {