[8.x] [Security Solution][Alert flyout] Edit highlighted fields in overview tab (#216740) (#218323)

# Backport

This will backport the following commits from `main` to `8.x`:
- [[Security Solution][Alert flyout] Edit highlighted fields in overview
tab (#216740)](https://github.com/elastic/kibana/pull/216740)

<!--- Backport version: 9.6.6 -->

### Questions ?
Please refer to the [Backport tool
documentation](https://github.com/sorenlouv/backport)

<!--BACKPORT
[{"author":{"name":"christineweng","email":"18648970+christineweng@users.noreply.github.com"},"sourceCommit":{"committedDate":"2025-04-15T17:18:35Z","message":"[Security
Solution][Alert flyout] Edit highlighted fields in overview tab
(#216740)\n\n## Summary\n\nThis PR allows user to edit highlighted
fields in alert flyout, under\n`Investigations`. The modal shows default
highlighted fields that are\ndefined by Elastic, and allow user to edit
custom highlighted fields.\n\nCurrently this feature is behind feature
flag\n`editHighlightedFieldsEnabled` (not enabled by
default).\n\n\n\nhttps://github.com/user-attachments/assets/35b3d09e-5e21-42ea-80e9-e8c0753985c9\n\n\n\n####
Disabled when:\n\n<details>\n<summary>User does not have security
privilege</summary>\n\n\n![image](https://github.com/user-attachments/assets/69ba7bc7-2d9b-4a2c-ae8e-e9c14f396a31)\n\n</details>\n\n<details>\n<summary>Prebuilt
rule w/o enterprise license (showing
upsell)</summary>\n\n\n![image](https://github.com/user-attachments/assets/a9c38e20-85b2-4082-af5e-a8707b2098cb)\n\n</details>\n\n####
Do not show the button when:\n<details>\n<summary>Not an alert
</summary>\n\n\n![image](https://github.com/user-attachments/assets/b5e9afde-f0d0-4a88-aaed-7481ba586850)\n\n</details>\n\n<details>\n<summary>rule
preview</summary>\n\n\n![image](https://github.com/user-attachments/assets/283d7a83-50b2-48ab-af2d-11692501c205)\n\n</details>\n\n###
Checklist\n\nCheck the PR satisfies following conditions. \n\nReviewers
should verify this PR satisfies this list as well.\n\n- [x] Any text
added follows [EUI's
writing\nguidelines](https://elastic.github.io/eui/#/guidelines/writing),
uses\nsentence case text and includes
[i18n\nsupport](https://github.com/elastic/kibana/blob/main/src/platform/packages/shared/kbn-i18n/README.md)\n-
[x] [Unit or
functional\ntests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)\nwere
updated or added to match the most common scenarios\n- [x] The PR
description includes the appropriate Release Notes section,\nand the
correct `release_note:*` label is applied per
the\n[guidelines](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)","sha":"a4a11bb46f63ad78399f152257a883d1a35f4ce9","branchLabelMapping":{"^v9.1.0$":"main","^v8.19.0$":"8.x","^v(\\d+).(\\d+).\\d+$":"$1.$2"}},"sourcePullRequest":{"labels":["release_note:feature","Team:Threat
Hunting:Investigations","backport:version","v9.1.0","v8.19.0"],"title":"[Security
Solution][Alert flyout] Edit highlighted fields in overview
tab","number":216740,"url":"https://github.com/elastic/kibana/pull/216740","mergeCommit":{"message":"[Security
Solution][Alert flyout] Edit highlighted fields in overview tab
(#216740)\n\n## Summary\n\nThis PR allows user to edit highlighted
fields in alert flyout, under\n`Investigations`. The modal shows default
highlighted fields that are\ndefined by Elastic, and allow user to edit
custom highlighted fields.\n\nCurrently this feature is behind feature
flag\n`editHighlightedFieldsEnabled` (not enabled by
default).\n\n\n\nhttps://github.com/user-attachments/assets/35b3d09e-5e21-42ea-80e9-e8c0753985c9\n\n\n\n####
Disabled when:\n\n<details>\n<summary>User does not have security
privilege</summary>\n\n\n![image](https://github.com/user-attachments/assets/69ba7bc7-2d9b-4a2c-ae8e-e9c14f396a31)\n\n</details>\n\n<details>\n<summary>Prebuilt
rule w/o enterprise license (showing
upsell)</summary>\n\n\n![image](https://github.com/user-attachments/assets/a9c38e20-85b2-4082-af5e-a8707b2098cb)\n\n</details>\n\n####
Do not show the button when:\n<details>\n<summary>Not an alert
</summary>\n\n\n![image](https://github.com/user-attachments/assets/b5e9afde-f0d0-4a88-aaed-7481ba586850)\n\n</details>\n\n<details>\n<summary>rule
preview</summary>\n\n\n![image](https://github.com/user-attachments/assets/283d7a83-50b2-48ab-af2d-11692501c205)\n\n</details>\n\n###
Checklist\n\nCheck the PR satisfies following conditions. \n\nReviewers
should verify this PR satisfies this list as well.\n\n- [x] Any text
added follows [EUI's
writing\nguidelines](https://elastic.github.io/eui/#/guidelines/writing),
uses\nsentence case text and includes
[i18n\nsupport](https://github.com/elastic/kibana/blob/main/src/platform/packages/shared/kbn-i18n/README.md)\n-
[x] [Unit or
functional\ntests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)\nwere
updated or added to match the most common scenarios\n- [x] The PR
description includes the appropriate Release Notes section,\nand the
correct `release_note:*` label is applied per
the\n[guidelines](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)","sha":"a4a11bb46f63ad78399f152257a883d1a35f4ce9"}},"sourceBranch":"main","suggestedTargetBranches":["8.x"],"targetPullRequestStates":[{"branch":"main","label":"v9.1.0","branchLabelMappingKey":"^v9.1.0$","isSourceBranch":true,"state":"MERGED","url":"https://github.com/elastic/kibana/pull/216740","number":216740,"mergeCommit":{"message":"[Security
Solution][Alert flyout] Edit highlighted fields in overview tab
(#216740)\n\n## Summary\n\nThis PR allows user to edit highlighted
fields in alert flyout, under\n`Investigations`. The modal shows default
highlighted fields that are\ndefined by Elastic, and allow user to edit
custom highlighted fields.\n\nCurrently this feature is behind feature
flag\n`editHighlightedFieldsEnabled` (not enabled by
default).\n\n\n\nhttps://github.com/user-attachments/assets/35b3d09e-5e21-42ea-80e9-e8c0753985c9\n\n\n\n####
Disabled when:\n\n<details>\n<summary>User does not have security
privilege</summary>\n\n\n![image](https://github.com/user-attachments/assets/69ba7bc7-2d9b-4a2c-ae8e-e9c14f396a31)\n\n</details>\n\n<details>\n<summary>Prebuilt
rule w/o enterprise license (showing
upsell)</summary>\n\n\n![image](https://github.com/user-attachments/assets/a9c38e20-85b2-4082-af5e-a8707b2098cb)\n\n</details>\n\n####
Do not show the button when:\n<details>\n<summary>Not an alert
</summary>\n\n\n![image](https://github.com/user-attachments/assets/b5e9afde-f0d0-4a88-aaed-7481ba586850)\n\n</details>\n\n<details>\n<summary>rule
preview</summary>\n\n\n![image](https://github.com/user-attachments/assets/283d7a83-50b2-48ab-af2d-11692501c205)\n\n</details>\n\n###
Checklist\n\nCheck the PR satisfies following conditions. \n\nReviewers
should verify this PR satisfies this list as well.\n\n- [x] Any text
added follows [EUI's
writing\nguidelines](https://elastic.github.io/eui/#/guidelines/writing),
uses\nsentence case text and includes
[i18n\nsupport](https://github.com/elastic/kibana/blob/main/src/platform/packages/shared/kbn-i18n/README.md)\n-
[x] [Unit or
functional\ntests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)\nwere
updated or added to match the most common scenarios\n- [x] The PR
description includes the appropriate Release Notes section,\nand the
correct `release_note:*` label is applied per
the\n[guidelines](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)","sha":"a4a11bb46f63ad78399f152257a883d1a35f4ce9"}},{"branch":"8.x","label":"v8.19.0","branchLabelMappingKey":"^v8.19.0$","isSourceBranch":false,"state":"NOT_CREATED"}]}]
BACKPORT-->

Co-authored-by: christineweng <18648970+christineweng@users.noreply.github.com>
This commit is contained in:
Kibana Machine 2025-04-16 00:18:03 +02:00 committed by GitHub
parent e5bb57656c
commit 3e77aaab8f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 1167 additions and 73 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -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('<HighlightedFields />', () => {
beforeEach(() => {
(useRuleWithFallback as jest.Mock).mockReturnValue({ investigation_fields: undefined });
});
it('should render the component', () => {
const contextValue = {
dataFormattedForFieldBrowser: mockDataFormattedForFieldBrowser,
scopeId: 'scopeId',
} as unknown as DocumentDetailsContext;
(useHighlightedFields as jest.Mock).mockReturnValue({
field: {
values: ['value'],
},
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,
});
});
const { getByTestId } = renderHighlightedFields(contextValue);
it('should render the component', () => {
(useHighlightedFields as jest.Mock).mockReturnValue({
field: {
values: ['value'],
},
});
expect(getByTestId(HIGHLIGHTED_FIELDS_TITLE_TEST_ID)).toBeInTheDocument();
expect(getByTestId(HIGHLIGHTED_FIELDS_DETAILS_TEST_ID)).toBeInTheDocument();
const { getByTestId, queryByTestId } = renderHighlightedFields(mockContextValue);
expect(getByTestId(HIGHLIGHTED_FIELDS_TITLE_TEST_ID)).toBeInTheDocument();
expect(getByTestId(HIGHLIGHTED_FIELDS_DETAILS_TEST_ID)).toBeInTheDocument();
expect(queryByTestId(HIGHLIGHTED_FIELDS_EDIT_BUTTON_TEST_ID)).not.toBeInTheDocument();
});
it(`should render no data message if there aren't any highlighted fields`, () => {
(useHighlightedFields as jest.Mock).mockReturnValue({});
const { getByText, queryByTestId } = renderHighlightedFields(mockContextValue);
expect(getByText(NO_DATA_MESSAGE)).toBeInTheDocument();
expect(queryByTestId(HIGHLIGHTED_FIELDS_EDIT_BUTTON_TEST_ID)).not.toBeInTheDocument();
});
});
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,
});
});
const { getByText } = renderHighlightedFields(contextValue);
expect(getByText(NO_DATA_MESSAGE)).toBeInTheDocument();
it('should render the component', () => {
(useHighlightedFields as jest.Mock).mockReturnValue({
field: {
values: ['value'],
},
});
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();
});
});
});

View file

@ -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<EuiBasicTableColumn<HighlightedFieldsTableRow>> = [
* 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 (
<EuiFlexGroup direction="column" gutterSize="s">
<EuiFlexItem data-test-subj={HIGHLIGHTED_FIELDS_TITLE_TEST_ID}>
<EuiTitle size="xxs">
<h5>
<FormattedMessage
id="xpack.securitySolution.flyout.right.investigation.highlightedFields.highlightedFieldsTitle"
defaultMessage="Highlighted fields"
/>
</h5>
</EuiTitle>
<EuiFlexGroup direction="column" gutterSize="none">
<EuiFlexItem>
<EuiFlexGroup alignItems="center" css={{ minHeight: '40px' }}>
<EuiFlexItem data-test-subj={HIGHLIGHTED_FIELDS_TITLE_TEST_ID}>
<EuiTitle size="xxs">
<h5>
<FormattedMessage
id="xpack.securitySolution.flyout.right.investigation.highlightedFields.highlightedFieldsTitle"
defaultMessage="Highlighted fields"
/>
</h5>
</EuiTitle>
</EuiFlexItem>
{editHighlightedFieldsEnabled && (
<EuiFlexItem grow={false}>
<EditHighlightedFieldsButton
customHighlightedFields={investigationFields}
dataFormattedForFieldBrowser={dataFormattedForFieldBrowser}
setIsEditLoading={setIsEditLoading}
/>
</EuiFlexItem>
)}
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem data-test-subj={HIGHLIGHTED_FIELDS_DETAILS_TEST_ID}>
<EuiPanel hasBorder hasShadow={false}>
@ -123,7 +140,7 @@ export const HighlightedFields: FC = () => {
items={items}
columns={columns}
compressed
loading={loading}
loading={isEditLoading}
message={
<FormattedMessage
id="xpack.securitySolution.flyout.right.investigation.highlightedFields.noDataDescription"

View file

@ -0,0 +1,112 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { render, fireEvent } from '@testing-library/react';
import { DocumentDetailsContext } from '../../shared/context';
import { TestProviders } from '../../../../common/mock';
import { EditHighlightedFieldsButton } from './highlighted_fields_button';
import { mockDataFormattedForFieldBrowser } from '../../shared/mocks/mock_data_formatted_for_field_browser';
import {
HIGHLIGHTED_FIELDS_EDIT_BUTTON_TEST_ID,
HIGHLIGHTED_FIELDS_MODAL_TEST_ID,
HIGHLIGHTED_FIELDS_EDIT_BUTTON_LOADING_TEST_ID,
} from './test_ids';
import { mockContextValue } from '../../shared/mocks/mock_context';
import type { RuleResponse } from '../../../../../common/api/detection_engine';
import { useRuleIndexPattern } from '../../../../detection_engine/rule_creation_ui/pages/form';
import { useHighlightedFieldsPrivilege } from '../../shared/hooks/use_highlighted_fields_privilege';
import { useRuleDetails } from '../../../rule_details/hooks/use_rule_details';
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('../../shared/hooks/use_highlighted_fields_privilege');
jest.mock('../../../rule_details/hooks/use_rule_details');
const mockSetIsEditLoading = jest.fn();
const mockCustomHighlightedFields = ['field1', 'field2'];
const defaultProps = {
rule: { id: '123', index: ['index1', 'index2'] } as RuleResponse,
customHighlightedFields: mockCustomHighlightedFields,
dataFormattedForFieldBrowser: mockDataFormattedForFieldBrowser,
setIsEditLoading: mockSetIsEditLoading,
isExistingRule: true,
};
const renderEditHighlighedFieldsButton = (props = defaultProps) =>
render(
<TestProviders>
<DocumentDetailsContext.Provider value={mockContextValue}>
<EditHighlightedFieldsButton {...props} />
</DocumentDetailsContext.Provider>
</TestProviders>
);
describe('<EditHighlighedFieldsButton />', () => {
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();
});
});

View file

@ -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<EditHighlightedFieldsButtonProps> = ({
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 (
<EuiLoadingSpinner size="m" data-test-subj={HIGHLIGHTED_FIELDS_EDIT_BUTTON_LOADING_TEST_ID} />
);
}
if (!rule) {
return null;
}
return (
<>
<EuiToolTip
content={tooltipContent}
data-test-subj={HIGHLIGHTED_FIELDS_EDIT_BUTTON_TOOLTIP_TEST_ID}
>
<EuiButtonEmpty
iconType={'gear'}
onClick={onClick}
disabled={isDisabled}
data-test-subj={HIGHLIGHTED_FIELDS_EDIT_BUTTON_TEST_ID}
>
<FormattedMessage
id="xpack.securitySolution.flyout.right.investigation.highlightedFields.editHighlightedFieldsButton"
defaultMessage="Edit"
/>
</EuiButtonEmpty>
</EuiToolTip>
{isModalVisible && (
<HighlightedFieldsModal
customHighlightedFields={customHighlightedFields}
dataFormattedForFieldBrowser={dataFormattedForFieldBrowser}
rule={rule}
setIsEditLoading={setIsEditLoading}
setIsModalVisible={setIsModalVisible}
/>
)}
</>
);
};

View file

@ -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(
<TestProviders>
<DocumentDetailsContext.Provider value={mockContextValue}>
<HighlightedFieldsModal {...props} />
</DocumentDetailsContext.Provider>
</TestProviders>
);
describe('<HighlighedFieldsModal />', () => {
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);
});
});

View file

@ -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<InvestigationFieldsFormData> = {
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<HighlightedFieldsModalProps> = ({
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 (
<EuiModal
css={{ width: '600px' }}
data-test-subj={HIGHLIGHTED_FIELDS_MODAL_TEST_ID}
id={modalTitleId}
onClose={onCancel}
>
<EuiModalHeader data-test-subj={HIGHLIGHTED_FIELDS_MODAL_TITLE_TEST_ID}>
<EuiModalHeaderTitle id={modalTitleId} size="s">
<FormattedMessage
id="xpack.securitySolution.flyout.right.investigation.highlightedFields.modalTitle"
defaultMessage="Edit highlighted fields"
/>
</EuiModalHeaderTitle>
</EuiModalHeader>
<EuiModalBody>
<EuiText size="s" data-test-subj={HIGHLIGHTED_FIELDS_MODAL_DESCRIPTION_TEST_ID}>
<FormattedMessage
id="xpack.securitySolution.flyout.right.investigation.highlightedFields.modalDescription"
defaultMessage="Changes made here will be applied to the {ruleName} rule. Any custom fields you add will be displayed in all alerts generated by this rule."
values={{ ruleName: <b>{rule.name}</b> }}
/>
</EuiText>
<EuiSpacer size="m" />
<EuiText
size="xs"
css={css`
font-weight: ${euiTheme.font.weight.semiBold};
`}
>
<FormattedMessage
id="xpack.securitySolution.flyout.right.investigation.highlightedFields.modalDefaultFieldsTitle"
defaultMessage="Default"
/>
</EuiText>
<EuiSpacer size="s" />
<EuiFlexGroup
data-test-subj={HIGHLIGHTED_FIELDS_MODAL_DEFAULT_FIELDS_TEST_ID}
gutterSize="xs"
justifyContent="flexStart"
wrap
>
{defaultFieldsArray.map((field: string) => (
<EuiFlexItem key={field} grow={false}>
<EuiBadge color="hollow">{field}</EuiBadge>
</EuiFlexItem>
))}
</EuiFlexGroup>
<EuiSpacer size="l" />
<Form form={form}>
<CommonUseField
path="customHighlightedFields"
config={formConfig}
componentProps={componentProps}
/>
</Form>
</EuiModalBody>
<EuiModalFooter>
<EuiFlexGroup justifyContent="flexEnd">
<EuiFlexItem grow={false}>
<EuiButtonEmpty
data-test-subj={HIGHLIGHTED_FIELDS_MODAL_CANCEL_BUTTON_TEST_ID}
flush="left"
onClick={onCancel}
>
<FormattedMessage
id="xpack.securitySolution.flyout.right.investigation.highlightedFields.modalCancelButton"
defaultMessage="Cancel"
/>
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
data-test-subj={HIGHLIGHTED_FIELDS_MODAL_SAVE_BUTTON_TEST_ID}
fill
onClick={onSubmit}
>
<FormattedMessage
id="xpack.securitySolution.flyout.right.investigation.highlightedFields.modalSaveButton"
defaultMessage="Save"
/>
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiModalFooter>
</EuiModal>
);
};

View file

@ -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('<InvestigationSection />', () => {
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', () => {

View file

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

View file

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

View file

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

View file

@ -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<UseHighlightedFieldsResult>((acc, field) => {

View file

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

View file

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

View file

@ -41,7 +41,7 @@ export interface UsePrevalenceParams {
/**
* User defined fields to highlight (defined on the rule)
*/
investigationFields?: string[];
investigationFields: string[];
}
export interface UsePrevalenceResult {