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

Prebuilt rule w/o enterprise license (showing upsell)

#### Do not show the button when:
Not an alert

rule preview

### 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 {