Skip to content

Commit

Permalink
[Detection Engine][Rules] - Adds custom highlighted fields option (#1…
Browse files Browse the repository at this point in the history
…63235)

## Summary

Allows a user to define which fields to highlight in areas where we
currently use "highlighted fields" feature.
  • Loading branch information
yctercero authored Aug 16, 2023
1 parent 847e0cb commit a772ab7
Show file tree
Hide file tree
Showing 78 changed files with 685 additions and 83 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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?

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 => ({
Expand All @@ -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 => ({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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({});
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ import {
RelatedIntegrationArray,
RequiredFieldArray,
RuleAuthorArray,
RuleCustomHighlightedFieldArray,
RuleDescription,
RuleFalsePositiveArray,
RuleFilterArray,
Expand Down Expand Up @@ -116,6 +117,7 @@ export const baseSchema = buildRuleSchemas({
output_index: AlertsIndex,
namespace: AlertsIndexNamespace,
meta: RuleMetadata,
investigation_fields: RuleCustomHighlightedFieldArray,
// Throttle
throttle: RuleActionThrottle,
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -284,6 +288,7 @@ const EventDetailsComponent: React.FC<Props> = ({
isReadOnly,
}}
goToTable={goToTableTab}
investigationFields={maybeRule?.investigation_fields ?? []}
/>
<EuiSpacer size="xl" />
<Insights
Expand Down Expand Up @@ -337,6 +342,7 @@ const EventDetailsComponent: React.FC<Props> = ({
userRisk,
allEnrichments,
isEnrichmentsLoading,
maybeRule,
]
);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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),
Expand Down Expand Up @@ -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;
}) => {
Expand All @@ -306,6 +320,7 @@ export const getSummaryRows = ({
eventCategories,
eventCode,
eventRuleType,
highlightedFieldsOverride: investigationFields ?? [],
});

return data != null
Expand Down
Loading

0 comments on commit a772ab7

Please sign in to comment.