[Detection Engine][Rules] - Adds custom highlighted fields option (#163235)

## Summary

Allows a user to define which fields to highlight in areas where we
currently use "highlighted fields" feature.
This commit is contained in:
Yara Tercero 2023-08-16 03:14:50 -07:00 committed by GitHub
parent 847e0cbe72
commit a772ab7fa8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
78 changed files with 685 additions and 83 deletions

View file

@ -20,6 +20,7 @@ const getRulesSchemaMock = (anchorDate: string = ANCHOR_DATE) => ({
enabled: true,
false_positives: ['false positive 1', 'false positive 2'],
from: 'now-6m',
investigation_fields: ['custom.field1', 'custom.field2'],
immutable: false,
name: 'Query with a rule id',
query: 'user.name: root or user.name: admin',

View file

@ -55,6 +55,14 @@ export const RuleAuthorArray = t.array(t.string); // should be non-empty strings
export type RuleFalsePositiveArray = t.TypeOf<typeof RuleFalsePositiveArray>;
export const RuleFalsePositiveArray = t.array(t.string); // should be non-empty strings?
/**
* User defined fields to display in areas such as alert details and exceptions auto-populate
* Field added in PR - https://github.com/elastic/kibana/pull/163235
* @example const investigationFields: RuleCustomHighlightedFieldArray = ['host.os.name']
*/
export type RuleCustomHighlightedFieldArray = t.TypeOf<typeof RuleCustomHighlightedFieldArray>;
export const RuleCustomHighlightedFieldArray = t.array(NonEmptyString);
export type RuleReferenceArray = t.TypeOf<typeof RuleReferenceArray>;
export const RuleReferenceArray = t.array(t.string); // should be non-empty strings?

View file

@ -1289,6 +1289,36 @@ describe('rules schema', () => {
expect(message.schema).toEqual({});
expect(getPaths(left(message.errors))).toEqual(['invalid keys "data_view_id"']);
});
test('You can optionally send in an array of investigation_fields', () => {
const payload: RuleCreateProps = {
...getCreateRulesSchemaMock(),
investigation_fields: ['field1', 'field2'],
};
const decoded = RuleCreateProps.decode(payload);
const checked = exactCheck(payload, decoded);
const message = pipe(checked, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([]);
expect(message.schema).toEqual(payload);
});
test('You cannot send in an array of investigation_fields that are numbers', () => {
const payload = {
...getCreateRulesSchemaMock(),
investigation_fields: [0, 1, 2],
};
const decoded = RuleCreateProps.decode(payload);
const checked = exactCheck(payload, decoded);
const message = pipe(checked, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([
'Invalid value "0" supplied to "investigation_fields"',
'Invalid value "1" supplied to "investigation_fields"',
'Invalid value "2" supplied to "investigation_fields"',
]);
expect(message.schema).toEqual({});
});
});
describe('response', () => {

View file

@ -64,6 +64,7 @@ const getResponseBaseParams = (anchorDate: string = ANCHOR_DATE): SharedResponse
timestamp_override: undefined,
timestamp_override_fallback_disabled: undefined,
namespace: undefined,
investigation_fields: undefined,
});
export const getRulesSchemaMock = (anchorDate: string = ANCHOR_DATE): QueryRule => ({
@ -77,6 +78,7 @@ export const getRulesSchemaMock = (anchorDate: string = ANCHOR_DATE): QueryRule
saved_id: undefined,
response_actions: undefined,
alert_suppression: undefined,
investigation_fields: undefined,
});
export const getSavedQuerySchemaMock = (anchorDate: string = ANCHOR_DATE): SavedQueryRule => ({

View file

@ -232,4 +232,65 @@ describe('Rule response schema', () => {
expect(message.schema).toEqual({});
});
});
describe('investigation_fields', () => {
test('it should validate rule with empty array for "investigation_fields"', () => {
const payload = getRulesSchemaMock();
payload.investigation_fields = [];
const decoded = RuleResponse.decode(payload);
const checked = exactCheck(payload, decoded);
const message = pipe(checked, foldLeftRight);
const expected = { ...getRulesSchemaMock(), investigation_fields: [] };
expect(getPaths(left(message.errors))).toEqual([]);
expect(message.schema).toEqual(expected);
});
test('it should validate rule with "investigation_fields"', () => {
const payload = getRulesSchemaMock();
payload.investigation_fields = ['foo', 'bar'];
const decoded = RuleResponse.decode(payload);
const checked = exactCheck(payload, decoded);
const message = pipe(checked, foldLeftRight);
const expected = { ...getRulesSchemaMock(), investigation_fields: ['foo', 'bar'] };
expect(getPaths(left(message.errors))).toEqual([]);
expect(message.schema).toEqual(expected);
});
test('it should validate undefined for "investigation_fields"', () => {
const payload: RuleResponse = {
...getRulesSchemaMock(),
investigation_fields: undefined,
};
const decoded = RuleResponse.decode(payload);
const checked = exactCheck(payload, decoded);
const message = pipe(checked, foldLeftRight);
const expected = { ...getRulesSchemaMock(), investigation_fields: undefined };
expect(getPaths(left(message.errors))).toEqual([]);
expect(message.schema).toEqual(expected);
});
test('it should NOT validate a string for "investigation_fields"', () => {
const payload: Omit<RuleResponse, 'investigation_fields'> & {
investigation_fields: string;
} = {
...getRulesSchemaMock(),
investigation_fields: 'foo',
};
const decoded = RuleResponse.decode(payload);
const checked = exactCheck(payload, decoded);
const message = pipe(checked, foldLeftRight);
expect(getPaths(left(message.errors))).toEqual([
'Invalid value "foo" supplied to "investigation_fields"',
]);
expect(message.schema).toEqual({});
});
});
});

View file

@ -53,6 +53,7 @@ import {
RelatedIntegrationArray,
RequiredFieldArray,
RuleAuthorArray,
RuleCustomHighlightedFieldArray,
RuleDescription,
RuleFalsePositiveArray,
RuleFilterArray,
@ -116,6 +117,7 @@ export const baseSchema = buildRuleSchemas({
output_index: AlertsIndex,
namespace: AlertsIndexNamespace,
meta: RuleMetadata,
investigation_fields: RuleCustomHighlightedFieldArray,
// Throttle
throttle: RuleActionThrottle,
},

View file

@ -141,6 +141,45 @@ describe('AlertSummaryView', () => {
});
});
});
test('User specified investigation fields appear in summary rows', async () => {
const mockData = mockAlertDetailsData.map((item) => {
if (item.category === 'event' && item.field === 'event.category') {
return {
...item,
values: ['network'],
originalValue: ['network'],
};
}
return item;
});
const renderProps = {
...props,
investigationFields: ['custom.field'],
data: [
...mockData,
{ category: 'custom', field: 'custom.field', values: ['blob'], originalValue: 'blob' },
] as TimelineEventsDetailsItem[],
};
await act(async () => {
const { getByText } = render(
<TestProvidersComponent>
<AlertSummaryView {...renderProps} />
</TestProvidersComponent>
);
[
'custom.field',
'host.name',
'user.name',
'destination.address',
'source.address',
'source.port',
'process.name',
].forEach((fieldId) => {
expect(getByText(fieldId));
});
});
});
test('Network event renders the correct summary rows', async () => {
const renderProps = {
...props,

View file

@ -21,10 +21,30 @@ const AlertSummaryViewComponent: React.FC<{
title: string;
goToTable: () => void;
isReadOnly?: boolean;
}> = ({ browserFields, data, eventId, isDraggable, scopeId, title, goToTable, isReadOnly }) => {
investigationFields?: string[];
}> = ({
browserFields,
data,
eventId,
isDraggable,
scopeId,
title,
goToTable,
isReadOnly,
investigationFields,
}) => {
const summaryRows = useMemo(
() => getSummaryRows({ browserFields, data, eventId, isDraggable, scopeId, isReadOnly }),
[browserFields, data, eventId, isDraggable, scopeId, isReadOnly]
() =>
getSummaryRows({
browserFields,
data,
eventId,
isDraggable,
scopeId,
isReadOnly,
investigationFields,
}),
[browserFields, data, eventId, isDraggable, scopeId, isReadOnly, investigationFields]
);
return (

View file

@ -21,6 +21,8 @@ import styled from 'styled-components';
import { isEmpty } from 'lodash';
import type { EcsSecurityExtension as Ecs } from '@kbn/securitysolution-ecs';
import { useBasicDataFromDetailsData } from '../../../timelines/components/side_panel/event_details/helpers';
import { useRuleWithFallback } from '../../../detection_engine/rule_management/logic/use_rule_with_fallback';
import type { RawEventData } from '../../../../common/types/response_actions';
import { useResponseActionsView } from './response_actions_view';
import { useIsExperimentalFeatureEnabled } from '../../hooks/use_experimental_features';
@ -169,6 +171,8 @@ const EventDetailsComponent: React.FC<Props> = ({
const goToTableTab = useCallback(() => setSelectedTabId(EventsViewType.tableView), []);
const eventFields = useMemo(() => getEnrichmentFields(data), [data]);
const { ruleId } = useBasicDataFromDetailsData(data);
const { rule: maybeRule } = useRuleWithFallback(ruleId);
const existingEnrichments = useMemo(
() =>
isAlert
@ -284,6 +288,7 @@ const EventDetailsComponent: React.FC<Props> = ({
isReadOnly,
}}
goToTable={goToTableTab}
investigationFields={maybeRule?.investigation_fields ?? []}
/>
<EuiSpacer size="xl" />
<Insights
@ -337,6 +342,7 @@ const EventDetailsComponent: React.FC<Props> = ({
userRisk,
allEnrichments,
isEnrichmentsLoading,
maybeRule,
]
);

View file

@ -215,6 +215,15 @@ function getFieldsByRuleType(ruleType?: string): EventSummaryField[] {
}
}
/**
* Gets the fields to display based on custom rules and configuration
* @param customs The list of custom-defined fields to display
* @returns The list of custom-defined fields to display
*/
function getHighlightedFieldsOverride(customs: string[]): EventSummaryField[] {
return customs.map((field) => ({ id: field }));
}
/**
This function is exported because it is used in the Exception Component to
populate the conditions with the Highlighted Fields. Additionally, the new
@ -229,12 +238,15 @@ export function getEventFieldsToDisplay({
eventCategories,
eventCode,
eventRuleType,
highlightedFieldsOverride,
}: {
eventCategories: EventCategories;
eventCode?: string;
eventRuleType?: string;
highlightedFieldsOverride: string[];
}): EventSummaryField[] {
const fields = [
...getHighlightedFieldsOverride(highlightedFieldsOverride),
...alwaysDisplayedFields,
...getFieldsByCategory(eventCategories),
...getFieldsByEventCode(eventCode, eventCategories),
@ -281,11 +293,13 @@ export const getSummaryRows = ({
eventId,
isDraggable = false,
isReadOnly = false,
investigationFields,
}: {
data: TimelineEventsDetailsItem[];
browserFields: BrowserFields;
scopeId: string;
eventId: string;
investigationFields?: string[];
isDraggable?: boolean;
isReadOnly?: boolean;
}) => {
@ -306,6 +320,7 @@ export const getSummaryRows = ({
eventCategories,
eventCode,
eventRuleType,
highlightedFieldsOverride: investigationFields ?? [],
});
return data != null

View file

@ -555,6 +555,7 @@ describe('helpers', () => {
severity_mapping: [],
tags: ['tag1', 'tag2'],
threat: getThreatMock(),
investigation_fields: ['foo', 'bar'],
};
expect(result).toEqual(expected);
@ -635,6 +636,7 @@ describe('helpers', () => {
severity_mapping: [],
tags: ['tag1', 'tag2'],
threat: getThreatMock(),
investigation_fields: ['foo', 'bar'],
};
expect(result).toEqual(expected);
@ -659,6 +661,7 @@ describe('helpers', () => {
severity_mapping: [],
tags: ['tag1', 'tag2'],
threat: getThreatMock(),
investigation_fields: ['foo', 'bar'],
};
expect(result).toEqual(expected);
@ -702,6 +705,7 @@ describe('helpers', () => {
severity_mapping: [],
tags: ['tag1', 'tag2'],
threat: getThreatMock(),
investigation_fields: ['foo', 'bar'],
};
expect(result).toEqual(expected);
@ -754,6 +758,7 @@ describe('helpers', () => {
],
},
],
investigation_fields: ['foo', 'bar'],
};
expect(result).toEqual(expected);
@ -782,6 +787,95 @@ describe('helpers', () => {
threat: getThreatMock(),
timestamp_override: 'event.ingest',
timestamp_override_fallback_disabled: true,
investigation_fields: ['foo', 'bar'],
};
expect(result).toEqual(expected);
});
test('returns formatted object if investigation_fields is empty array', () => {
const mockStepData: AboutStepRule = {
...mockData,
investigationFields: [],
};
const result = formatAboutStepData(mockStepData);
const expected: AboutStepRuleJson = {
author: ['Elastic'],
description: '24/7',
false_positives: ['test'],
license: 'Elastic License',
name: 'Query with rule-id',
note: '# this is some markdown documentation',
references: ['www.test.co'],
risk_score: 21,
risk_score_mapping: [],
severity: 'low',
severity_mapping: [],
tags: ['tag1', 'tag2'],
rule_name_override: undefined,
threat_indicator_path: undefined,
timestamp_override: undefined,
timestamp_override_fallback_disabled: undefined,
threat: getThreatMock(),
investigation_fields: [],
};
expect(result).toEqual(expected);
});
test('returns formatted object with investigation_fields', () => {
const mockStepData: AboutStepRule = {
...mockData,
investigationFields: ['foo', 'bar'],
};
const result = formatAboutStepData(mockStepData);
const expected: AboutStepRuleJson = {
author: ['Elastic'],
description: '24/7',
false_positives: ['test'],
license: 'Elastic License',
name: 'Query with rule-id',
note: '# this is some markdown documentation',
references: ['www.test.co'],
risk_score: 21,
risk_score_mapping: [],
severity: 'low',
severity_mapping: [],
tags: ['tag1', 'tag2'],
threat: getThreatMock(),
investigation_fields: ['foo', 'bar'],
threat_indicator_path: undefined,
timestamp_override: undefined,
timestamp_override_fallback_disabled: undefined,
};
expect(result).toEqual(expected);
});
test('returns formatted object if investigation_fields includes empty string', () => {
const mockStepData: AboutStepRule = {
...mockData,
investigationFields: [' '],
};
const result = formatAboutStepData(mockStepData);
const expected: AboutStepRuleJson = {
author: ['Elastic'],
description: '24/7',
false_positives: ['test'],
license: 'Elastic License',
name: 'Query with rule-id',
note: '# this is some markdown documentation',
references: ['www.test.co'],
risk_score: 21,
risk_score_mapping: [],
severity: 'low',
severity_mapping: [],
tags: ['tag1', 'tag2'],
threat: getThreatMock(),
investigation_fields: [],
threat_indicator_path: undefined,
timestamp_override: undefined,
timestamp_override_fallback_disabled: undefined,
};
expect(result).toEqual(expected);

View file

@ -485,6 +485,7 @@ export const formatAboutStepData = (
const {
author,
falsePositives,
investigationFields,
references,
riskScore,
severity,
@ -524,6 +525,7 @@ export const formatAboutStepData = (
: {}),
false_positives: falsePositives.filter((item) => !isEmpty(item)),
references: references.filter((item) => !isEmpty(item)),
investigation_fields: investigationFields.filter((item) => !isEmpty(item.trim())),
risk_score: riskScore.value,
risk_score_mapping: riskScore.isMappingChecked
? riskScore.mapping.filter((m) => m.field != null && m.field !== '')

View file

@ -662,6 +662,42 @@ describe('When the add exception modal is opened', () => {
expect(getByTestId('entryType')).toHaveTextContent('match');
expect(getByTestId('entryValue')).toHaveTextContent('test/path');
});
it('should include rule defined custom highlighted fields', () => {
const wrapper = render(
(() => (
<TestProviders>
<AddExceptionFlyout
rules={[
{
...getRulesSchemaMock(),
investigation_fields: ['foo.bar'],
exceptions_list: [],
} as Rule,
]}
isBulkAction={false}
alertData={{ ...alertDataMock, foo: { bar: 'blob' } } as AlertData}
isAlertDataLoading={false}
alertStatus="open"
isEndpointItem={false}
showAlertCloseOptions
onCancel={jest.fn()}
onConfirm={jest.fn()}
/>
</TestProviders>
))()
);
const { getByTestId, getAllByTestId } = wrapper;
expect(getByTestId('alertExceptionBuilder')).toBeInTheDocument();
expect(getAllByTestId('entryField')[0]).toHaveTextContent('foo.bar');
expect(getAllByTestId('entryOperator')[0]).toHaveTextContent('included');
expect(getAllByTestId('entryType')[0]).toHaveTextContent('match');
expect(getAllByTestId('entryValue')[0]).toHaveTextContent('blob');
expect(getAllByTestId('entryField')[1]).toHaveTextContent('file.path');
expect(getAllByTestId('entryOperator')[1]).toHaveTextContent('included');
expect(getAllByTestId('entryType')[1]).toHaveTextContent('match');
expect(getAllByTestId('entryValue')[1]).toHaveTextContent('test/path');
});
});
describe('bulk closeable alert data is passed in', () => {
let wrapper: ReactWrapper;

View file

@ -346,6 +346,9 @@ export const AddExceptionFlyout = memo(function AddExceptionFlyout({
const populatedException = getPrepopulatedRuleExceptionWithHighlightFields({
alertData,
exceptionItemName,
// With "rule_default" type, there is only ever one rule associated.
// That is why it's ok to pull just the first item from rules array here.
ruleCustomHighlightedFields: rules?.[0]?.investigation_fields ?? [],
});
if (populatedException) {
setComment(i18n.ADD_RULE_EXCEPTION_FROM_ALERT_COMMENT(alertData._id));
@ -354,7 +357,7 @@ export const AddExceptionFlyout = memo(function AddExceptionFlyout({
}
}
}
}, [listType, exceptionItemName, alertData, setInitialExceptionItems, setComment]);
}, [listType, exceptionItemName, alertData, rules, setInitialExceptionItems, setComment]);
const osTypesSelection = useMemo((): OsTypeArray => {
return hasAlertData ? retrieveAlertOsTypes(alertData) : selectedOs ? [...selectedOs] : [];

View file

@ -1560,6 +1560,27 @@ describe('Exception helpers', () => {
},
{ field: 'process.name', operator: 'included', type: 'match', value: 'malware writer' },
];
const expectedExceptionEntriesWithCustomHighlightedFields = [
{
field: 'event.type',
operator: 'included',
type: 'match',
value: 'creation',
},
{
field: 'agent.id',
operator: 'included',
type: 'match',
value: 'f4f86e7c-29bd-4655-b7d0-a3d08ad0c322',
},
{
field: 'process.executable',
operator: 'included',
type: 'match',
value: 'C:/malware.exe',
},
{ field: 'process.name', operator: 'included', type: 'match', value: 'malware writer' },
];
const entriesWithMatchAny = {
field: 'Endpoint.capabilities',
operator,
@ -1739,12 +1760,12 @@ describe('Exception helpers', () => {
},
];
it('should return the highlighted fields correctly when eventCode, eventCategory and RuleType are in the alertData', () => {
const res = getAlertHighlightedFields(alertData);
const res = getAlertHighlightedFields(alertData, []);
expect(res).toEqual(allHighlightFields);
});
it('should return highlighted fields without the file.Ext.quarantine_path when "event.code" is not in the alertData', () => {
const alertDataWithoutEventCode = { ...alertData, 'event.code': null };
const res = getAlertHighlightedFields(alertDataWithoutEventCode);
const res = getAlertHighlightedFields(alertDataWithoutEventCode, []);
expect(res).toEqual([
...baseGeneratedAlertHighlightedFields,
{
@ -1763,7 +1784,7 @@ describe('Exception helpers', () => {
});
it('should return highlighted fields without the file and process props when "event.category" is not in the alertData', () => {
const alertDataWithoutEventCategory = { ...alertData, 'event.category': null };
const res = getAlertHighlightedFields(alertDataWithoutEventCategory);
const res = getAlertHighlightedFields(alertDataWithoutEventCategory, []);
expect(res).toEqual([
...baseGeneratedAlertHighlightedFields,
{
@ -1775,7 +1796,7 @@ describe('Exception helpers', () => {
});
it('should return the process highlighted fields correctly when eventCategory is an array', () => {
const alertDataEventCategoryProcessArray = { ...alertData, 'event.category': ['process'] };
const res = getAlertHighlightedFields(alertDataEventCategoryProcessArray);
const res = getAlertHighlightedFields(alertDataEventCategoryProcessArray, []);
expect(res).not.toEqual(
expect.arrayContaining([
{ id: 'file.name' },
@ -1793,20 +1814,20 @@ describe('Exception helpers', () => {
});
it('should return all highlighted fields even when the "kibana.alert.rule.type" is not in the alertData', () => {
const alertDataWithoutEventCategory = { ...alertData, 'kibana.alert.rule.type': null };
const res = getAlertHighlightedFields(alertDataWithoutEventCategory);
const res = getAlertHighlightedFields(alertDataWithoutEventCategory, []);
expect(res).toEqual(allHighlightFields);
});
it('should return all highlighted fields when there are no fields to be filtered out', () => {
jest.mock('./highlighted_fields_config', () => ({ highlightedFieldsPrefixToExclude: [] }));
const res = getAlertHighlightedFields(alertData);
const res = getAlertHighlightedFields(alertData, []);
expect(res).toEqual(allHighlightFields);
});
it('should exclude the "agent.id" from highlighted fields when agent.type is not "endpoint"', () => {
jest.mock('./highlighted_fields_config', () => ({ highlightedFieldsPrefixToExclude: [] }));
const alertDataWithoutAgentType = { ...alertData, agent: { ...alertData.agent, type: '' } };
const res = getAlertHighlightedFields(alertDataWithoutAgentType);
const res = getAlertHighlightedFields(alertDataWithoutAgentType, []);
expect(res).toEqual(allHighlightFields.filter((field) => field.id !== AGENT_ID));
});
@ -1814,10 +1835,14 @@ describe('Exception helpers', () => {
jest.mock('./highlighted_fields_config', () => ({ highlightedFieldsPrefixToExclude: [] }));
const alertDataWithoutRuleUUID = { ...alertData, 'kibana.alert.rule.uuid': '' };
const res = getAlertHighlightedFields(alertDataWithoutRuleUUID);
const res = getAlertHighlightedFields(alertDataWithoutRuleUUID, []);
expect(res).toEqual(allHighlightFields.filter((field) => field.id !== AGENT_ID));
});
it('should include custom highlighted fields', () => {
const res = getAlertHighlightedFields(alertData, ['event.type']);
expect(res).toEqual([{ id: 'event.type' }, ...allHighlightFields]);
});
});
describe('getPrepopulatedRuleExceptionWithHighlightFields', () => {
it('should not create any exception and return null if there are no highlighted fields', () => {
@ -1826,6 +1851,7 @@ describe('Exception helpers', () => {
const res = getPrepopulatedRuleExceptionWithHighlightFields({
alertData: defaultAlertData,
exceptionItemName: '',
ruleCustomHighlightedFields: [],
});
expect(res).toBe(null);
});
@ -1835,6 +1861,7 @@ describe('Exception helpers', () => {
const res = getPrepopulatedRuleExceptionWithHighlightFields({
alertData: defaultAlertData,
exceptionItemName: '',
ruleCustomHighlightedFields: [],
});
expect(res).toBe(null);
});
@ -1842,6 +1869,7 @@ describe('Exception helpers', () => {
const exception = getPrepopulatedRuleExceptionWithHighlightFields({
alertData,
exceptionItemName: name,
ruleCustomHighlightedFields: [],
});
expect(exception?.entries).toEqual(
@ -1849,6 +1877,21 @@ describe('Exception helpers', () => {
);
expect(exception?.name).toEqual(name);
});
it('should create a new exception and populate its entries with the custom highlighted fields', () => {
const exception = getPrepopulatedRuleExceptionWithHighlightFields({
alertData,
exceptionItemName: name,
ruleCustomHighlightedFields: ['event.type'],
});
expect(exception?.entries).toEqual(
expectedExceptionEntriesWithCustomHighlightedFields.map((entry) => ({
...entry,
id: '123',
}))
);
expect(exception?.name).toEqual(name);
});
});
});
});

View file

@ -908,11 +908,13 @@ export const buildExceptionEntriesFromAlertFields = ({
export const getPrepopulatedRuleExceptionWithHighlightFields = ({
alertData,
exceptionItemName,
ruleCustomHighlightedFields,
}: {
alertData: AlertData;
exceptionItemName: string;
ruleCustomHighlightedFields: string[];
}): ExceptionsBuilderExceptionItem | null => {
const highlightedFields = getAlertHighlightedFields(alertData);
const highlightedFields = getAlertHighlightedFields(alertData, ruleCustomHighlightedFields);
if (!highlightedFields.length) return null;
const exceptionEntries = buildExceptionEntriesFromAlertFields({ highlightedFields, alertData });
@ -951,11 +953,13 @@ export const filterHighlightedFields = (
* * Alert field ids filters
* @param alertData The Alert data object
*/
export const getAlertHighlightedFields = (alertData: AlertData): EventSummaryField[] => {
export const getAlertHighlightedFields = (
alertData: AlertData,
ruleCustomHighlightedFields: string[]
): EventSummaryField[] => {
const eventCategory = get(alertData, EVENT_CATEGORY);
const eventCode = get(alertData, EVENT_CODE);
const eventRuleType = get(alertData, KIBANA_ALERT_RULE_TYPE);
const eventCategories = {
primaryEventCategory: Array.isArray(eventCategory) ? eventCategory[0] : eventCategory,
allEventCategories: [eventCategory],
@ -965,6 +969,7 @@ export const getAlertHighlightedFields = (alertData: AlertData): EventSummaryFie
eventCategories,
eventCode,
eventRuleType,
highlightedFieldsOverride: ruleCustomHighlightedFields,
});
return filterHighlightedFields(fieldsToDisplay, highlightedFieldsPrefixToExclude, alertData);
};

View file

@ -73,6 +73,7 @@ import {
TimestampField,
TimestampOverride,
TimestampOverrideFallbackDisabled,
RuleCustomHighlightedFieldArray,
} from '../../../../common/api/detection_engine/model/rule_schema';
import type {
@ -201,6 +202,7 @@ export const RuleSchema = t.intersection([
version: RuleVersion,
execution_summary: RuleExecutionSummary,
alert_suppression: AlertSuppression,
investigation_fields: RuleCustomHighlightedFieldArray,
}),
]);

View file

@ -194,6 +194,7 @@ export const mockAboutStepRule = (): AboutStepRule => ({
tags: ['tag1', 'tag2'],
threat: getThreatMock(),
note: '# this is some markdown documentation',
investigationFields: ['foo', 'bar'],
});
export const mockActionsStepRule = (enabled = false): ActionsStepRule => ({

View file

@ -15,6 +15,7 @@ import { ExceptionListTypeEnum } from '@kbn/securitysolution-io-ts-list-types';
import { get } from 'lodash/fp';
import type { EcsSecurityExtension as Ecs } from '@kbn/securitysolution-ecs';
import { TableId } from '@kbn/securitysolution-data-table';
import { useRuleWithFallback } from '../../../../detection_engine/rule_management/logic/use_rule_with_fallback';
import { DEFAULT_ACTION_BUTTON_WIDTH } from '../../../../common/components/header_actions';
import { isActiveTimeline } from '../../../../helpers';
import { useOsqueryContextActionItem } from '../../osquery/use_osquery_context_action_item';
@ -384,6 +385,7 @@ export const AddExceptionFlyoutWrapper: React.FC<AddExceptionFlyoutWrapperProps>
alertStatus,
}) => {
const { loading: isSignalIndexLoading, signalIndexName } = useSignalIndex();
const { rule: maybeRule, loading: isRuleLoading } = useRuleWithFallback(ruleId);
const { loading: isLoadingAlertData, data } = useQueryAlerts<EcsHit, {}>({
query: buildGetAlertByIdQuery(eventId),
@ -429,32 +431,13 @@ export const AddExceptionFlyoutWrapper: React.FC<AddExceptionFlyoutWrapperProps>
return ruleDataViewId;
}, [enrichedAlert, ruleDataViewId]);
// TODO: Do we want to notify user when they are working off of an older version of a rule
// if they select to add an exception from an alert referencing an older rule version?
const memoRule = useMemo(() => {
if (enrichedAlert != null && enrichedAlert['kibana.alert.rule.parameters'] != null) {
return [
{
...enrichedAlert['kibana.alert.rule.parameters'],
id: ruleId,
rule_id: ruleRuleId,
name: ruleName,
index: memoRuleIndices,
data_view_id: memoDataViewId,
},
] as Rule[];
if (maybeRule) {
return [maybeRule];
}
return [
{
id: ruleId,
rule_id: ruleRuleId,
name: ruleName,
index: memoRuleIndices,
data_view_id: memoDataViewId,
},
] as Rule[];
}, [enrichedAlert, memoDataViewId, memoRuleIndices, ruleId, ruleName, ruleRuleId]);
return null;
}, [maybeRule]);
const isLoading =
(isLoadingAlertData && isSignalIndexLoading) ||
@ -466,7 +449,7 @@ export const AddExceptionFlyoutWrapper: React.FC<AddExceptionFlyoutWrapperProps>
rules={memoRule}
isEndpointItem={exceptionListType === ExceptionListTypeEnum.ENDPOINT}
alertData={enrichedAlert}
isAlertDataLoading={isLoading}
isAlertDataLoading={isLoading || isRuleLoading}
alertStatus={alertStatus}
isBulkAction={false}
showAlertCloseOptions

View file

@ -27,6 +27,7 @@ import {
buildUrlsDescription,
buildNoteDescription,
buildRuleTypeDescription,
buildHighlightedFieldsOverrideDescription,
} from './helpers';
import type { ListItems } from './types';
@ -508,4 +509,28 @@ describe('helpers', () => {
expect(result.description).toEqual('Indicator Match');
});
});
describe('buildHighlightedFieldsOverrideDescription', () => {
test('returns ListItem with passed in label and custom highlighted fields', () => {
const result: ListItems[] = buildHighlightedFieldsOverrideDescription('Test label', [
'foo',
'bar',
]);
const wrapper = shallow<React.ReactElement>(result[0].description as React.ReactElement);
const element = wrapper.find(
'[data-test-subj="customHighlightedFieldsStringArrayDescriptionBadgeItem"]'
);
expect(result[0].title).toEqual('Test label');
expect(element.exists()).toBeTruthy();
expect(element.at(0).text()).toEqual('foo');
expect(element.at(1).text()).toEqual('bar');
});
test('returns empty array if passed in note is empty string', () => {
const result: ListItems[] = buildHighlightedFieldsOverrideDescription('Test label', []);
expect(result).toHaveLength(0);
});
});
});

View file

@ -27,15 +27,13 @@ import { FieldIcon } from '@kbn/react-field';
import type { ThreatMapping, Type } from '@kbn/securitysolution-io-ts-alerting-types';
import { FilterBadgeGroup } from '@kbn/unified-search-plugin/public';
import type { RequiredFieldArray } from '../../../../../common/api/detection_engine/model/rule_schema/common_attributes';
import { MATCHES, AND, OR } from '../../../../common/components/threat_match/translations';
import type { EqlOptionsSelected } from '../../../../../common/search_strategy';
import { assertUnreachable } from '../../../../../common/utility_types';
import * as i18nSeverity from '../severity_mapping/translations';
import * as i18nRiskScore from '../risk_score_mapping/translations';
import type {
RequiredFieldArray,
Threshold,
} from '../../../../../common/api/detection_engine/model/rule_schema';
import type { Threshold } from '../../../../../common/api/detection_engine/model/rule_schema';
import * as i18n from './translations';
import type { BuildQueryBarDescription, BuildThreatDescription, ListItems } from './types';
@ -201,6 +199,38 @@ export const buildUnorderedListArrayDescription = (
return [];
};
export const buildHighlightedFieldsOverrideDescription = (
label: string,
values: string[]
): ListItems[] => {
if (isEmpty(values)) {
return [];
}
const description = (
<EuiFlexGroup responsive={false} gutterSize="xs" wrap>
{values.map((val: string) =>
isEmpty(val) ? null : (
<EuiFlexItem grow={false} key={`${label}-${val}`}>
<EuiBadgeWrap
data-test-subj="customHighlightedFieldsStringArrayDescriptionBadgeItem"
color="hollow"
>
{val}
</EuiBadgeWrap>
</EuiFlexItem>
)
)}
</EuiFlexGroup>
);
return [
{
title: label,
description,
},
];
};
export const buildStringArrayDescription = (
label: string,
field: string,

View file

@ -262,7 +262,7 @@ describe('description_step', () => {
mockLicenseService
);
expect(result.length).toEqual(11);
expect(result.length).toEqual(12);
});
});

View file

@ -14,11 +14,11 @@ import type { ThreatMapping, Threats, Type } from '@kbn/securitysolution-io-ts-a
import type { DataViewBase, Filter } from '@kbn/es-query';
import { FilterStateStore } from '@kbn/es-query';
import { FilterManager } from '@kbn/data-plugin/public';
import { buildRelatedIntegrationsDescription } from '../related_integrations/integrations_description';
import type {
RelatedIntegrationArray,
RequiredFieldArray,
} from '../../../../../common/api/detection_engine/model/rule_schema';
} from '../../../../../common/api/detection_engine/model/rule_schema/common_attributes';
import { buildRelatedIntegrationsDescription } from '../related_integrations/integrations_description';
import { DEFAULT_TIMELINE_TITLE } from '../../../../timelines/components/timeline/translations';
import type { EqlOptionsSelected } from '../../../../../common/search_strategy';
import { useKibana } from '../../../../common/lib/kibana';
@ -47,6 +47,7 @@ import {
buildAlertSuppressionDescription,
buildAlertSuppressionWindowDescription,
buildAlertSuppressionMissingFieldsDescription,
buildHighlightedFieldsOverrideDescription,
} from './helpers';
import * as i18n from './translations';
import { buildMlJobsDescription } from './build_ml_jobs_description';
@ -261,6 +262,9 @@ export const getDescriptionItem = (
} else if (field === 'falsePositives') {
const values: string[] = get(field, data);
return buildUnorderedListArrayDescription(label, field, values);
} else if (field === 'investigationFields') {
const values: string[] = get(field, data);
return buildHighlightedFieldsOverrideDescription(label, values);
} else if (field === 'riskScore') {
const values: AboutStepRiskScore = get(field, data);
return buildRiskScoreDescription(values);

View file

@ -11,40 +11,44 @@ import { EuiToolTip } from '@elastic/eui';
import type { DataViewFieldBase } from '@kbn/es-query';
import type { FieldHook } from '../../../../shared_imports';
import { Field } from '../../../../shared_imports';
import { GROUP_BY_FIELD_PLACEHOLDER, GROUP_BY_FIELD_LICENSE_WARNING } from './translations';
import { FIELD_PLACEHOLDER } from './translations';
interface GroupByFieldsProps {
interface MultiSelectAutocompleteProps {
browserFields: DataViewFieldBase[];
isDisabled: boolean;
field: FieldHook;
fullWidth?: boolean;
disabledText?: string;
}
const FIELD_COMBO_BOX_WIDTH = 410;
const fieldDescribedByIds = 'detectionEngineStepDefineRuleGroupByField';
const fieldDescribedByIds = 'detectionEngineMultiSelectAutocompleteField';
export const GroupByComponent: React.FC<GroupByFieldsProps> = ({
export const MultiSelectAutocompleteComponent: React.FC<MultiSelectAutocompleteProps> = ({
browserFields,
disabledText,
isDisabled,
field,
}: GroupByFieldsProps) => {
fullWidth = false,
}: MultiSelectAutocompleteProps) => {
const fieldEuiFieldProps = useMemo(
() => ({
fullWidth: true,
noSuggestions: false,
options: browserFields.map((browserField) => ({ label: browserField.name })),
placeholder: GROUP_BY_FIELD_PLACEHOLDER,
placeholder: FIELD_PLACEHOLDER,
onCreateOption: undefined,
style: { width: `${FIELD_COMBO_BOX_WIDTH}px` },
...(fullWidth ? {} : { style: { width: `${FIELD_COMBO_BOX_WIDTH}px` } }),
isDisabled,
}),
[browserFields, isDisabled]
[browserFields, isDisabled, fullWidth]
);
const fieldComponent = (
<Field field={field} idAria={fieldDescribedByIds} euiFieldProps={fieldEuiFieldProps} />
);
return isDisabled ? (
<EuiToolTip position="right" content={GROUP_BY_FIELD_LICENSE_WARNING}>
<EuiToolTip position="right" content={disabledText}>
{fieldComponent}
</EuiToolTip>
) : (
@ -52,4 +56,4 @@ export const GroupByComponent: React.FC<GroupByFieldsProps> = ({
);
};
export const GroupByFields = React.memo(GroupByComponent);
export const MultiSelectFieldsAutocomplete = React.memo(MultiSelectAutocompleteComponent);

View file

@ -7,16 +7,9 @@
import { i18n } from '@kbn/i18n';
export const GROUP_BY_FIELD_PLACEHOLDER = i18n.translate(
'xpack.securitySolution.detectionEngine.createRule.stepDefineRule.groupBy.placeholderText',
export const FIELD_PLACEHOLDER = i18n.translate(
'xpack.securitySolution.detectionEngine.createRule.stepDefineRule.multiSelectFields.placeholderText',
{
defaultMessage: 'Select a field',
}
);
export const GROUP_BY_FIELD_LICENSE_WARNING = i18n.translate(
'xpack.securitySolution.detectionEngine.createRule.stepDefineRule.groupBy.licenseWarning',
{
defaultMessage: 'Alert suppression is enabled with Platinum license or above',
}
);

View file

@ -26,6 +26,7 @@ export const stepAboutDefaultValue: AboutStepRule = {
riskScore: { value: 21, mapping: [], isMappingChecked: false },
references: [''],
falsePositives: [''],
investigationFields: [],
license: '',
ruleNameOverride: '',
tags: [],

View file

@ -274,6 +274,7 @@ describe('StepAboutRuleComponent', () => {
technique: [],
},
],
investigationFields: [],
};
await act(async () => {
@ -333,6 +334,7 @@ describe('StepAboutRuleComponent', () => {
technique: [],
},
],
investigationFields: [],
};
await act(async () => {

View file

@ -33,6 +33,7 @@ import { useFetchIndex } from '../../../../common/containers/source';
import { DEFAULT_INDICATOR_SOURCE_PATH } from '../../../../../common/constants';
import { useKibana } from '../../../../common/lib/kibana';
import { useRuleIndices } from '../../../../detection_engine/rule_management/logic/use_rule_indices';
import { MultiSelectFieldsAutocomplete } from '../multi_select_fields';
const CommonUseField = getUseField({ component: Field });
@ -237,6 +238,16 @@ const StepAboutRuleComponent: FC<StepAboutRuleProps> = ({
}}
/>
<EuiSpacer size="l" />
<UseField
path="investigationFields"
component={MultiSelectFieldsAutocomplete}
componentProps={{
browserFields: indexPattern.fields,
isDisabled: isLoading || indexPatternLoading,
fullWidth: true,
}}
/>
<EuiSpacer size="l" />
<UseField
path="note"
component={MarkdownEditorForm}

View file

@ -160,6 +160,16 @@ export const schema: FormSchema<AboutStepRule> = {
),
labelAppend: OptionalFieldLabel,
},
investigationFields: {
type: FIELD_TYPES.COMBO_BOX,
label: i18n.translate(
'xpack.securitySolution.detectionEngine.createRule.stepAboutRule.fieldCustomHighlightedFieldsLabel',
{
defaultMessage: 'Custom highlighted fields',
}
),
labelAppend: OptionalFieldLabel,
},
license: {
type: FIELD_TYPES.TEXT,
label: i18n.translate(

View file

@ -28,6 +28,13 @@ export const ADD_FALSE_POSITIVE = i18n.translate(
}
);
export const ADD_CUSTOM_HIGHLIGHTED_FIELD = i18n.translate(
'xpack.securitySolution.detectionEngine.createRule.stepAboutRuleForm.addCustomHighlightedFieldDescription',
{
defaultMessage: 'Add a custom highlighted field',
}
);
export const GLOBAL_ENDPOINT_EXCEPTION_LIST = i18n.translate(
'xpack.securitySolution.detectionEngine.createRule.stepAboutRuleForm.endpointExceptionListLabel',
{

View file

@ -75,7 +75,7 @@ import { NewTermsFields } from '../new_terms_fields';
import { ScheduleItem } from '../schedule_item_form';
import { DocLink } from '../../../../common/components/links_to_docs/doc_link';
import { defaultCustomQuery } from '../../../pages/detection_engine/rules/utils';
import { GroupByFields } from '../group_by_fields';
import { MultiSelectFieldsAutocomplete } from '../multi_select_fields';
import { useLicense } from '../../../../common/hooks/use_license';
import {
minimumLicenseForSuppression,
@ -752,9 +752,10 @@ const StepDefineRuleComponent: FC<StepDefineRuleProps> = ({
>
<UseField
path="groupByFields"
component={GroupByFields}
component={MultiSelectFieldsAutocomplete}
componentProps={{
browserFields: termsAggregationFields,
disabledText: i18n.GROUP_BY_FIELD_LICENSE_WARNING,
isDisabled:
!license.isAtLeast(minimumLicenseForSuppression) && groupByFields?.length === 0,
}}

View file

@ -171,3 +171,10 @@ export const ALERT_SUPPRESSION_MISSING_FIELDS_DO_NOT_SUPPRESS_OPTION = i18n.tran
defaultMessage: 'Do not suppress alerts for events with missing fields',
}
);
export const GROUP_BY_FIELD_LICENSE_WARNING = i18n.translate(
'xpack.securitySolution.detectionEngine.createRule.stepDefineRule.groupBy.licenseWarning',
{
defaultMessage: 'Alert suppression is enabled with Platinum license or above',
}
);

View file

@ -144,6 +144,7 @@ describe('rule helpers', () => {
threat: getThreatMock(),
timestampOverride: 'event.ingested',
timestampOverrideFallbackDisabled: false,
investigationFields: [],
};
const scheduleRuleStepData = { from: '0s', interval: '5m' };
const ruleActionsStepData = {
@ -181,6 +182,14 @@ describe('rule helpers', () => {
expect(result.note).toEqual('');
});
test('returns customHighlightedField as empty array if property does not exist on rule', () => {
const mockedRule = mockRuleWithEverything('test-id');
delete mockedRule.investigation_fields;
const result: AboutStepRule = getAboutStepsData(mockedRule, false);
expect(result.investigationFields).toEqual([]);
});
});
describe('determineDetailsValue', () => {

View file

@ -200,6 +200,7 @@ export const getAboutStepsData = (rule: Rule, detailsView: boolean): AboutStepRu
severity,
false_positives: falsePositives,
risk_score: riskScore,
investigation_fields: investigationFields,
tags,
threat,
threat_indicator_path: threatIndicatorPath,
@ -230,6 +231,7 @@ export const getAboutStepsData = (rule: Rule, detailsView: boolean): AboutStepRu
isMappingChecked: riskScoreMapping.length > 0,
},
falsePositives,
investigationFields: investigationFields ?? [],
threat: threat as Threats,
threatIndicatorPath,
};
@ -343,6 +345,7 @@ const commonRuleParamsKeys = [
'name',
'description',
'false_positives',
'investigation_fields',
'rule_id',
'max_signals',
'risk_score',

View file

@ -89,6 +89,7 @@ export interface AboutStepRule {
riskScore: AboutStepRiskScore;
references: string[];
falsePositives: string[];
investigationFields: string[];
license: string;
ruleNameOverride: string;
tags: string[];
@ -238,6 +239,7 @@ export interface AboutStepRuleJson {
timestamp_override?: TimestampOverride;
timestamp_override_fallback_disabled?: boolean;
note?: string;
investigation_fields?: string[];
}
export interface ScheduleStepRuleJson {

View file

@ -83,6 +83,7 @@ export const stepAboutDefaultValue: AboutStepRule = {
isBuildingBlock: false,
severity: { value: 'low', mapping: fillEmptySeverityMappings([]), isMappingChecked: false },
riskScore: { value: 21, mapping: [], isMappingChecked: false },
investigationFields: [],
references: [''],
falsePositives: [''],
license: '',

View file

@ -55,6 +55,7 @@ const contextValue: LeftPanelContext = {
scopeId: '',
browserFields: null,
searchHit: undefined,
investigationFields: [],
};
const renderCorrelationDetails = () => {

View file

@ -108,7 +108,8 @@ const columns: Array<EuiBasicTableColumn<unknown>> = [
* Prevalence table displayed in the document details expandable flyout left section under the Insights tab
*/
export const PrevalenceDetails: React.FC = () => {
const { browserFields, dataFormattedForFieldBrowser, eventId, scopeId } = useLeftPanelContext();
const { browserFields, dataFormattedForFieldBrowser, eventId, scopeId, investigationFields } =
useLeftPanelContext();
const data = useMemo(() => {
const summaryRows = getSummaryRows({
@ -116,6 +117,7 @@ export const PrevalenceDetails: React.FC = () => {
data: dataFormattedForFieldBrowser || [],
eventId,
scopeId,
investigationFields,
isReadOnly: false,
});
@ -137,7 +139,7 @@ export const PrevalenceDetails: React.FC = () => {
userPrevalence: fields,
};
});
}, [browserFields, dataFormattedForFieldBrowser, eventId, scopeId]);
}, [browserFields, investigationFields, dataFormattedForFieldBrowser, eventId, scopeId]);
if (!eventId || !dataFormattedForFieldBrowser || !browserFields || !data || data.length === 0) {
return (

View file

@ -15,12 +15,16 @@ import type { LeftPanelProps } from '.';
import type { GetFieldsData } from '../../common/hooks/use_get_fields_data';
import { useGetFieldsData } from '../../common/hooks/use_get_fields_data';
import { useTimelineEventsDetails } from '../../timelines/containers/details';
import { getAlertIndexAlias } from '../../timelines/components/side_panel/event_details/helpers';
import {
getAlertIndexAlias,
useBasicDataFromDetailsData,
} from '../../timelines/components/side_panel/event_details/helpers';
import { useSpaceId } from '../../common/hooks/use_space_id';
import { useRouteSpy } from '../../common/utils/route/use_route_spy';
import { SecurityPageName } from '../../../common/constants';
import { SourcererScopeName } from '../../common/store/sourcerer/model';
import { useSourcererDataView } from '../../common/containers/sourcerer';
import { useRuleWithFallback } from '../../detection_engine/rule_management/logic/use_rule_with_fallback';
export interface LeftPanelContext {
/**
@ -51,6 +55,10 @@ export interface LeftPanelContext {
* The actual raw document object
*/
searchHit: SearchHit | undefined;
/**
* User defined fields to highlight (defined on the rule)
*/
investigationFields: string[];
/**
* Retrieves searchHit values for the provided field
*/
@ -83,6 +91,8 @@ export const LeftPanelProvider = ({ id, indexName, scopeId, children }: LeftPane
skip: !id,
});
const getFieldsData = useGetFieldsData(searchHit?.fields);
const { ruleId } = useBasicDataFromDetailsData(dataFormattedForFieldBrowser);
const { rule: maybeRule } = useRuleWithFallback(ruleId);
const contextValue = useMemo(
() =>
@ -95,6 +105,7 @@ export const LeftPanelProvider = ({ id, indexName, scopeId, children }: LeftPane
dataAsNestedObject,
dataFormattedForFieldBrowser,
searchHit,
investigationFields: maybeRule?.investigation_fields ?? [],
getFieldsData,
}
: undefined,
@ -103,10 +114,11 @@ export const LeftPanelProvider = ({ id, indexName, scopeId, children }: LeftPane
indexName,
scopeId,
sourcererDataView.browserFields,
dataFormattedForFieldBrowser,
getFieldsData,
dataAsNestedObject,
dataFormattedForFieldBrowser,
searchHit,
maybeRule?.investigation_fields,
getFieldsData,
]
);

View file

@ -75,6 +75,7 @@ describe('useThreatIntelligenceDetails', () => {
_index: 'testIndex',
},
dataAsNestedObject: null,
investigationFields: [],
});
});

View file

@ -47,4 +47,5 @@ export const mockContextValue: LeftPanelContext = {
dataAsNestedObject: {
_id: 'testId',
},
investigationFields: [],
};

View file

@ -13,10 +13,16 @@ import { HighlightedFields } from './highlighted_fields';
import { mockDataFormattedForFieldBrowser } from '../mocks/mock_context';
import { useHighlightedFields } from '../hooks/use_highlighted_fields';
import { TestProviders } from '../../../common/mock';
import { useRuleWithFallback } from '../../../detection_engine/rule_management/logic/use_rule_with_fallback';
jest.mock('../hooks/use_highlighted_fields');
jest.mock('../../../detection_engine/rule_management/logic/use_rule_with_fallback');
describe('<HighlightedFields />', () => {
beforeEach(() => {
(useRuleWithFallback as jest.Mock).mockReturnValue({ investigation_fields: [] });
});
it('should render the component', () => {
const panelContextValue = {
dataFormattedForFieldBrowser: mockDataFormattedForFieldBrowser,

View file

@ -9,6 +9,8 @@ import type { FC } from 'react';
import React from 'react';
import type { EuiBasicTableColumn } from '@elastic/eui';
import { EuiFlexGroup, EuiFlexItem, EuiInMemoryTable, EuiPanel, EuiTitle } from '@elastic/eui';
import { useRuleWithFallback } from '../../../detection_engine/rule_management/logic/use_rule_with_fallback';
import { useBasicDataFromDetailsData } from '../../../timelines/components/side_panel/event_details/helpers';
import { HighlightedFieldsCell } from './highlighted_fields_cell';
import {
CellActionsMode,
@ -57,8 +59,13 @@ const columns: Array<EuiBasicTableColumn<UseHighlightedFieldsResult>> = [
*/
export const HighlightedFields: FC = () => {
const { dataFormattedForFieldBrowser } = useRightPanelContext();
const { ruleId } = useBasicDataFromDetailsData(dataFormattedForFieldBrowser);
const { rule: maybeRule } = useRuleWithFallback(ruleId);
const highlightedFields = useHighlightedFields({ dataFormattedForFieldBrowser });
const highlightedFields = useHighlightedFields({
dataFormattedForFieldBrowser,
investigationFields: maybeRule?.investigation_fields ?? [],
});
if (!dataFormattedForFieldBrowser || highlightedFields.length === 0) {
return null;

View file

@ -23,8 +23,14 @@ import { PREVALENCE_TAB_ID } from '../../left/components/prevalence_details';
* and the SummaryPanel component for data rendering.
*/
export const PrevalenceOverview: FC = () => {
const { eventId, indexName, browserFields, dataFormattedForFieldBrowser, scopeId } =
useRightPanelContext();
const {
eventId,
indexName,
browserFields,
dataFormattedForFieldBrowser,
scopeId,
investigationFields,
} = useRightPanelContext();
const { openLeftPanel } = useExpandableFlyoutContext();
const goToCorrelationsTab = useCallback(() => {
@ -46,6 +52,7 @@ export const PrevalenceOverview: FC = () => {
eventId,
browserFields,
dataFormattedForFieldBrowser,
investigationFields,
scopeId,
});

View file

@ -10,9 +10,13 @@ import { css } from '@emotion/react';
import React, { createContext, useContext, useMemo } from 'react';
import { EuiFlexItem, EuiLoadingSpinner } from '@elastic/eui';
import type { EcsSecurityExtension as Ecs } from '@kbn/securitysolution-ecs';
import type { SearchHit } from '../../../common/search_strategy';
import { useTimelineEventsDetails } from '../../timelines/containers/details';
import { getAlertIndexAlias } from '../../timelines/components/side_panel/event_details/helpers';
import {
getAlertIndexAlias,
useBasicDataFromDetailsData,
} from '../../timelines/components/side_panel/event_details/helpers';
import { useSpaceId } from '../../common/hooks/use_space_id';
import { useRouteSpy } from '../../common/utils/route/use_route_spy';
import { SecurityPageName } from '../../../common/constants';
@ -21,6 +25,7 @@ import { useSourcererDataView } from '../../common/containers/sourcerer';
import type { RightPanelProps } from '.';
import type { GetFieldsData } from '../../common/hooks/use_get_fields_data';
import { useGetFieldsData } from '../../common/hooks/use_get_fields_data';
import { useRuleWithFallback } from '../../detection_engine/rule_management/logic/use_rule_with_fallback';
export interface RightPanelContext {
/**
@ -51,6 +56,10 @@ export interface RightPanelContext {
* The actual raw document object
*/
searchHit: SearchHit | undefined;
/**
* User defined fields to highlight (defined on the rule)
*/
investigationFields: string[];
/**
* Promise to trigger a data refresh
*/
@ -94,6 +103,8 @@ export const RightPanelProvider = ({
skip: !id,
});
const getFieldsData = useGetFieldsData(searchHit?.fields);
const { ruleId } = useBasicDataFromDetailsData(dataFormattedForFieldBrowser);
const { rule: maybeRule } = useRuleWithFallback(ruleId);
const contextValue = useMemo(
() =>
@ -106,12 +117,14 @@ export const RightPanelProvider = ({
dataAsNestedObject,
dataFormattedForFieldBrowser,
searchHit,
investigationFields: maybeRule?.investigation_fields ?? [],
refetchFlyoutData,
getFieldsData,
}
: undefined,
[
id,
maybeRule,
indexName,
scopeId,
sourcererDataView.browserFields,

View file

@ -19,6 +19,10 @@ export interface UseHighlightedFieldsParams {
* An array of field objects with category and value
*/
dataFormattedForFieldBrowser: TimelineEventsDetailsItem[] | null;
/**
* An array of fields user has selected to highlight, defined on rule
*/
investigationFields?: string[];
}
export interface UseHighlightedFieldsResult {
@ -43,6 +47,7 @@ export interface UseHighlightedFieldsResult {
*/
export const useHighlightedFields = ({
dataFormattedForFieldBrowser,
investigationFields,
}: UseHighlightedFieldsParams): UseHighlightedFieldsResult[] => {
if (!dataFormattedForFieldBrowser) return [];
@ -61,6 +66,7 @@ export const useHighlightedFields = ({
{ category: 'kibana', field: ALERT_RULE_TYPE },
dataFormattedForFieldBrowser
);
const eventRuleType = Array.isArray(eventRuleTypeField?.originalValue)
? eventRuleTypeField?.originalValue?.[0]
: eventRuleTypeField?.originalValue;
@ -69,6 +75,7 @@ export const useHighlightedFields = ({
eventCategories,
eventCode,
eventRuleType,
highlightedFieldsOverride: investigationFields ?? [],
});
return tableFields.reduce<UseHighlightedFieldsResult[]>((acc, field) => {

View file

@ -29,6 +29,10 @@ export interface UsePrevalenceParams {
* Maintain backwards compatibility // TODO remove when possible
*/
scopeId: string;
/**
* User defined fields to highlight (defined on rule)
*/
investigationFields?: string[];
}
/**
@ -41,6 +45,7 @@ export const usePrevalence = ({
eventId,
browserFields,
dataFormattedForFieldBrowser,
investigationFields,
scopeId,
}: UsePrevalenceParams): ReactElement[] => {
// retrieves the highlighted fields
@ -49,11 +54,12 @@ export const usePrevalence = ({
getSummaryRows({
browserFields: browserFields || {},
data: dataFormattedForFieldBrowser || [],
investigationFields: investigationFields || [],
eventId,
scopeId,
isReadOnly: false,
}),
[browserFields, dataFormattedForFieldBrowser, eventId, scopeId]
[browserFields, investigationFields, dataFormattedForFieldBrowser, eventId, scopeId]
);
return useMemo(

View file

@ -20,5 +20,6 @@ export const mockContextValue: RightPanelContext = {
browserFields: null,
dataAsNestedObject: null,
searchHit: undefined,
investigationFields: [],
refetchFlyoutData: jest.fn(),
};

View file

@ -100,4 +100,5 @@ export const getOutputRuleAlertForRest = (): RuleResponse => ({
namespace: undefined,
data_view_id: undefined,
alert_suppression: undefined,
investigation_fields: [],
});

View file

@ -44,6 +44,7 @@ describe('schedule_notification_actions', () => {
responseActions: [],
riskScore: 80,
riskScoreMapping: [],
investigationFields: [],
ruleNameOverride: undefined,
dataViewId: undefined,
outputIndex: 'output-1',

View file

@ -64,6 +64,7 @@ describe('schedule_throttle_notification_actions', () => {
requiredFields: [],
setup: '',
alertSuppression: undefined,
investigationFields: undefined,
};
});

View file

@ -62,6 +62,7 @@ describe('duplicateRule', () => {
timestampOverrideFallbackDisabled: undefined,
dataViewId: undefined,
alertSuppression: undefined,
investigationFields: undefined,
},
schedule: {
interval: '5m',

View file

@ -45,6 +45,7 @@ export const updateRules = async ({
ruleId: existingRule.params.ruleId,
falsePositives: ruleUpdate.false_positives ?? [],
from: ruleUpdate.from ?? 'now-6m',
investigationFields: ruleUpdate.investigation_fields ?? [],
// Unlike the create route, immutable comes from the existing rule here
immutable: existingRule.params.immutable,
license: ruleUpdate.license,

View file

@ -136,6 +136,7 @@ describe('getExportAll', () => {
note: '# Investigative notes',
version: 1,
exceptions_list: getListArrayMock(),
investigation_fields: [],
});
expect(detailsJson).toEqual({
exported_exception_list_count: 1,
@ -319,6 +320,7 @@ describe('getExportAll', () => {
version: 1,
revision: 0,
exceptions_list: getListArrayMock(),
investigation_fields: [],
});
expect(detailsJson).toEqual({
exported_exception_list_count: 1,

View file

@ -132,6 +132,7 @@ describe('get_export_by_object_ids', () => {
note: '# Investigative notes',
version: 1,
exceptions_list: getListArrayMock(),
investigation_fields: [],
},
exportDetails: {
exported_exception_list_count: 0,
@ -327,6 +328,7 @@ describe('get_export_by_object_ids', () => {
version: 1,
revision: 0,
exceptions_list: getListArrayMock(),
investigation_fields: [],
});
expect(detailsJson).toEqual({
exported_exception_list_count: 0,
@ -523,6 +525,7 @@ describe('get_export_by_object_ids', () => {
namespace: undefined,
data_view_id: undefined,
alert_suppression: undefined,
investigation_fields: [],
},
],
};

View file

@ -409,6 +409,7 @@ export const convertPatchAPIToInternalSchema = (
description: nextParams.description ?? existingParams.description,
ruleId: existingParams.ruleId,
falsePositives: nextParams.false_positives ?? existingParams.falsePositives,
investigationFields: nextParams.investigation_fields ?? existingParams.investigationFields,
from: nextParams.from ?? existingParams.from,
immutable: existingParams.immutable,
license: nextParams.license ?? existingParams.license,
@ -470,6 +471,7 @@ export const convertCreateAPIToInternalSchema = (
description: input.description,
ruleId: newRuleId,
falsePositives: input.false_positives ?? [],
investigationFields: input.investigation_fields ?? [],
from: input.from ?? 'now-6m',
immutable,
license: input.license,
@ -619,6 +621,7 @@ export const commonParamsCamelToSnake = (params: BaseRuleParams) => {
rule_name_override: params.ruleNameOverride,
timestamp_override: params.timestampOverride,
timestamp_override_fallback_disabled: params.timestampOverrideFallbackDisabled,
investigation_fields: params.investigationFields,
author: params.author,
false_positives: params.falsePositives,
from: params.from,

View file

@ -78,6 +78,7 @@ export const ruleOutput = (): RuleResponse => ({
data_view_id: undefined,
saved_id: undefined,
alert_suppression: undefined,
investigation_fields: [],
});
describe('validate', () => {

View file

@ -48,6 +48,7 @@ const getBaseRuleParams = (): BaseRuleParams => {
timelineTitle: 'some-timeline-title',
timestampOverride: undefined,
timestampOverrideFallbackDisabled: undefined,
investigationFields: [],
meta: {
someMeta: 'someField',
},

View file

@ -57,6 +57,7 @@ import {
RuleAuthorArray,
RuleDescription,
RuleFalsePositiveArray,
RuleCustomHighlightedFieldArray,
RuleFilterArray,
RuleLicense,
RuleMetadata,
@ -97,6 +98,7 @@ export const baseRuleParams = t.exact(
falsePositives: RuleFalsePositiveArray,
from: RuleIntervalFrom,
ruleId: RuleSignatureId,
investigationFields: t.union([RuleCustomHighlightedFieldArray, t.undefined]),
immutable: IsRuleImmutable,
license: t.union([RuleLicense, t.undefined]),
outputIndex: AlertsIndex,

View file

@ -523,6 +523,7 @@ export const sampleSignalHit = (): SignalHit => ({
filters: undefined,
saved_id: undefined,
alert_suppression: undefined,
investigation_fields: undefined,
},
depth: 1,
},

View file

@ -165,6 +165,7 @@ describe('buildAlert', () => {
index: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'],
query: 'user.name: root or user.name: admin',
filters: [{ query: { match_phrase: { 'host.name': 'some-host' } } }],
investigation_fields: [],
},
[ALERT_RULE_INDICES]: completeRule.ruleParams.index,
...flattenWithPrefix(ALERT_RULE_NAMESPACE, {
@ -358,6 +359,7 @@ describe('buildAlert', () => {
index: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'],
query: 'user.name: root or user.name: admin',
filters: [{ query: { match_phrase: { 'host.name': 'some-host' } } }],
investigation_fields: [],
},
...flattenWithPrefix(ALERT_RULE_NAMESPACE, {
actions: [],

View file

@ -31106,7 +31106,6 @@
"xpack.securitySolution.detectionEngine.createRule.stepDefineRule.fieldThresholdFieldCardinalityFieldHelpText": "Sélectionner un champ pour vérifier la cardinalité",
"xpack.securitySolution.detectionEngine.createRule.stepDefineRule.fieldThresholdLabel": "Seuil",
"xpack.securitySolution.detectionEngine.createRule.stepDefineRule.groupBy.licenseWarning": "La suppression d'alertes est activée avec la licence Platinum ou supérieure",
"xpack.securitySolution.detectionEngine.createRule.stepDefineRule.groupBy.placeholderText": "Sélectionner un champ",
"xpack.securitySolution.detectionEngine.createRule.stepDefineRule.groupByDurationValueLabel": "Supprimer les alertes pour",
"xpack.securitySolution.detectionEngine.createRule.stepDefineRule.groupByFieldsLabel": "Supprimer les alertes par",
"xpack.securitySolution.detectionEngine.createRule.stepDefineRule.groupByFieldsLabelAppend": "Facultatif (version d'évaluation technique)",

View file

@ -31105,7 +31105,6 @@
"xpack.securitySolution.detectionEngine.createRule.stepDefineRule.fieldThresholdFieldCardinalityFieldHelpText": "カーディナリティを確認するフィールドを選択します",
"xpack.securitySolution.detectionEngine.createRule.stepDefineRule.fieldThresholdLabel": "しきい値",
"xpack.securitySolution.detectionEngine.createRule.stepDefineRule.groupBy.licenseWarning": "アラートの非表示は、プラチナライセンス以上で有効です",
"xpack.securitySolution.detectionEngine.createRule.stepDefineRule.groupBy.placeholderText": "フィールドを選択",
"xpack.securitySolution.detectionEngine.createRule.stepDefineRule.groupByDurationValueLabel": "アラートを非表示",
"xpack.securitySolution.detectionEngine.createRule.stepDefineRule.groupByFieldsLabel": "アラートを非表示",
"xpack.securitySolution.detectionEngine.createRule.stepDefineRule.groupByFieldsLabelAppend": "任意(テクニカルプレビュー)",

View file

@ -31101,7 +31101,6 @@
"xpack.securitySolution.detectionEngine.createRule.stepDefineRule.fieldThresholdFieldCardinalityFieldHelpText": "选择字段以检查基数",
"xpack.securitySolution.detectionEngine.createRule.stepDefineRule.fieldThresholdLabel": "阈值",
"xpack.securitySolution.detectionEngine.createRule.stepDefineRule.groupBy.licenseWarning": "告警阻止通过白金级或更高级许可证启用",
"xpack.securitySolution.detectionEngine.createRule.stepDefineRule.groupBy.placeholderText": "选择字段",
"xpack.securitySolution.detectionEngine.createRule.stepDefineRule.groupByDurationValueLabel": "阻止以下项的告警",
"xpack.securitySolution.detectionEngine.createRule.stepDefineRule.groupByFieldsLabel": "阻止告警的依据",
"xpack.securitySolution.detectionEngine.createRule.stepDefineRule.groupByFieldsLabelAppend": "可选(技术预览)",

View file

@ -76,6 +76,7 @@ export default ({ getService }: FtrProviderContext) => {
author: [],
created_by: 'elastic',
description: 'Simple Rule Query',
investigation_fields: [],
enabled: true,
false_positives: [],
from: 'now-6m',

View file

@ -207,6 +207,7 @@ export default ({ getService }: FtrProviderContext) => {
risk_score_mapping: [],
name: 'Simple Rule Query',
query: 'user.name: root or user.name: admin',
investigation_fields: [],
references: [],
related_integrations: [],
required_fields: [],

View file

@ -742,5 +742,6 @@ function expectToMatchRuleSchema(obj: RuleResponse): void {
index: expect.arrayContaining([]),
query: expect.any(String),
actions: expect.arrayContaining([]),
investigation_fields: expect.arrayContaining([]),
});
}

View file

@ -426,6 +426,28 @@ export default ({ getService }: FtrProviderContext) => {
message: 'rule_id: "fake_id" not found',
});
});
describe('investigation_fields', () => {
it('should overwrite investigation_fields value on update - non additive', async () => {
await createRule(supertest, log, {
...getSimpleRule('rule-1'),
investigation_fields: ['blob', 'boop'],
});
const rulePatch = {
rule_id: 'rule-1',
investigation_fields: ['foo', 'bar'],
};
const { body } = await supertest
.patch(DETECTION_ENGINE_RULES_URL)
.set('kbn-xsrf', 'true')
.send(rulePatch)
.expect(200);
expect(body.investigation_fields).to.eql(['foo', 'bar']);
});
});
});
describe('patch per-action frequencies', () => {

View file

@ -841,6 +841,28 @@ export default ({ getService }: FtrProviderContext) => {
});
});
});
describe('investigation_fields', () => {
it('should overwrite investigation_fields value on update - non additive', async () => {
await createRule(supertest, log, {
...getSimpleRule('rule-1'),
investigation_fields: ['blob', 'boop'],
});
const ruleUpdate = {
...getSimpleRuleUpdate('rule-1'),
investigation_fields: ['foo', 'bar'],
};
const { body } = await supertest
.put(DETECTION_ENGINE_RULES_URL)
.set('kbn-xsrf', 'true')
.send(ruleUpdate)
.expect(200);
expect(body.investigation_fields).to.eql(['foo', 'bar']);
});
});
});
});
};

View file

@ -337,6 +337,7 @@ export default ({ getService }: FtrProviderContext) => {
max_signals: 100,
risk_score_mapping: [],
severity_mapping: [],
investigation_fields: [],
threat: [],
to: 'now',
references: [],
@ -512,6 +513,7 @@ export default ({ getService }: FtrProviderContext) => {
related_integrations: [],
required_fields: [],
setup: '',
investigation_fields: [],
},
'kibana.alert.rule.actions': [],
'kibana.alert.rule.created_by': 'elastic',

View file

@ -146,6 +146,7 @@ export default ({ getService }: FtrProviderContext) => {
to: 'now',
type: 'machine_learning',
version: 1,
investigation_fields: [],
},
[ALERT_DEPTH]: 1,
[ALERT_REASON]: `event with process store, by root on mothra created critical alert Test ML rule.`,

View file

@ -198,6 +198,7 @@ export default ({ getService }: FtrProviderContext) => {
history_window_start: '2019-01-19T20:42:00.000Z',
index: ['auditbeat-*'],
language: 'kuery',
investigation_fields: [],
},
'kibana.alert.rule.actions': [],
'kibana.alert.rule.author': [],

View file

@ -102,4 +102,5 @@ export const getComplexRuleOutput = (ruleId = 'rule-1'): Partial<RuleResponse> =
related_integrations: [],
required_fields: [],
setup: '',
investigation_fields: [],
});

View file

@ -60,6 +60,7 @@ export const getMockSharedResponseSchema = (
timestamp_override: undefined,
timestamp_override_fallback_disabled: undefined,
namespace: undefined,
investigation_fields: [],
});
const getQueryRuleOutput = (ruleId = 'rule-1', enabled = false): RuleResponse => ({

View file

@ -42,7 +42,8 @@ import {
} from '../../../screens/exceptions';
import { goToEndpointExceptionsTab } from '../../../tasks/rule_details';
describe(
// See https://github.com/elastic/kibana/issues/163967
describe.skip(
'Endpoint Exceptions workflows from Alert',
{ tags: [tag.ESS, tag.BROKEN_IN_SERVERLESS] },
() => {

View file

@ -39,7 +39,8 @@ import {
} from '../../../../screens/exceptions';
import { waitForAlertsToPopulate } from '../../../../tasks/create_new_rule';
describe(
// See https://github.com/elastic/kibana/issues/163967
describe.skip(
'Auto populate exception with Alert data',
{ tags: [tag.ESS, tag.BROKEN_IN_SERVERLESS] },
() => {

View file

@ -4,13 +4,13 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { waitForAlertsToPopulate } from '../../../../tasks/create_new_rule';
import {
addExceptionFromFirstAlert,
goToClosedAlertsOnRuleDetailsPage,
waitForAlerts,
} from '../../../../tasks/alerts';
import { deleteAlertsAndRules, postDataView } from '../../../../tasks/common';
import { waitForAlertsToPopulate } from '../../../../tasks/create_new_rule';
import { login, visitWithoutDateRange } from '../../../../tasks/login';
import { DETECTIONS_RULE_MANAGEMENT_URL } from '../../../../urls/navigation';
import { goToRuleDetails } from '../../../../tasks/alerts_detection_rules';
@ -27,6 +27,7 @@ import {
submitNewExceptionItem,
} from '../../../../tasks/exceptions';
// See https://github.com/elastic/kibana/issues/163967
describe('Close matching Alerts ', () => {
const newRule = getNewRule();
const ITEM_NAME = 'Sample Exception Item';

View file

@ -532,6 +532,7 @@ export const expectedExportedRule = (ruleResponse: Cypress.Response<RuleResponse
immutable,
related_integrations: relatedIntegrations,
setup,
investigation_fields: investigationFields,
} = ruleResponse.body;
let query: string | undefined;
@ -558,6 +559,7 @@ export const expectedExportedRule = (ruleResponse: Cypress.Response<RuleResponse
severity,
note,
output_index: '',
investigation_fields: investigationFields,
author,
false_positives: falsePositives,
from,