[Security Solution] clean unused types, export components and add many unit tests (#217100)

## Summary

This code change was originally part of [a bigger
PR](https://github.com/elastic/kibana/pull/216744) related to the AI for
SOC effort. I decided to split the work for 2 reasons:
- less files to review, less teams impacted
- this current PR will easily be backported to `8.x` while the AI for
SOC is only targeting `9.1`

This PR makes only a few small changes:
- remove unused types
- export a few components/functions outside of the `alerts_table` folder
to make them reusable within the new AI for SOC alert summary page (see
PR linked above)
- add a lot of unit tests to everything, especially the now exported
components/functions

#### UI remains unchanged:

![Screenshot 2025-04-03 at 6 09
57 PM](https://github.com/user-attachments/assets/3e4135e7-6e2f-4b4f-94e5-0dd72f1710bb)
![Screenshot 2025-04-03 at 6 10
06 PM](https://github.com/user-attachments/assets/382391d6-7ae1-4da4-a76f-495b6db69db3)
![Screenshot 2025-04-03 at 6 10
13 PM](https://github.com/user-attachments/assets/28c5947b-2168-4080-b298-5fea1f3f97c7)
![Screenshot 2025-04-03 at 6 10
21 PM](https://github.com/user-attachments/assets/2dc75fcb-9929-4821-830d-b84fceaf232d)

### Checklist

- [x] Any text added follows [EUI's writing
guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses
sentence case text and includes [i18n
support](https://github.com/elastic/kibana/blob/main/src/platform/packages/shared/kbn-i18n/README.md)
- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios

Will unblock https://github.com/elastic/security-team/issues/11973
This commit is contained in:
Philippe Oberti 2025-04-04 21:11:23 +02:00 committed by GitHub
parent 2ed4266ea5
commit 837059bcfa
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 344 additions and 509 deletions

View file

@ -9,6 +9,7 @@
import React from 'react';
import { EuiContextMenuItem } from '@elastic/eui';
export const host1Name = 'nice-host';
export const host2Name = 'cool-host';
@ -48,9 +49,6 @@ export const mockGroupingProps = {
},
],
},
countSeveritySubAggregation: {
value: 1,
},
usersCountAggregation: {
value: 1,
},
@ -81,9 +79,6 @@ export const mockGroupingProps = {
},
],
},
countSeveritySubAggregation: {
value: 1,
},
usersCountAggregation: {
value: 1,
},
@ -115,9 +110,6 @@ export const mockGroupingProps = {
},
],
},
countSeveritySubAggregation: {
value: 11,
},
usersCountAggregation: {
value: 11,
},

View file

@ -27,7 +27,6 @@ describe('group selector', () => {
bucket_truncate: { bucket_sort: { from: 0, size: 25 } },
alertsCount: { cardinality: { field: 'kibana.alert.uuid' } },
rulesCountAggregation: { cardinality: { field: 'kibana.alert.rule.rule_id' } },
countSeveritySubAggregation: { cardinality: { field: 'kibana.alert.severity' } },
severitiesSubAggregation: { terms: { field: 'kibana.alert.severity' } },
usersCountAggregation: { cardinality: { field: 'user.name' } },
});

View file

@ -25,7 +25,6 @@ export const groupingBucket = {
{ key: 'medium', doc_count: 480 },
],
},
countSeveritySubAggregation: { value: 4 },
};
export const mocktestProps1: GroupingQueryArgs = {
@ -50,13 +49,6 @@ export const mocktestProps1: GroupingQueryArgs = {
},
},
},
{
countSeveritySubAggregation: {
cardinality: {
field: 'kibana.alert.severity',
},
},
},
{
severitiesSubAggregation: {
terms: {

View file

@ -131,9 +131,6 @@ export const groupingSearchResponse = {
},
],
},
countSeveritySubAggregation: {
value: 1,
},
usersCountAggregation: {
value: 0,
},
@ -182,9 +179,6 @@ export const groupingSearchResponse = {
},
],
},
countSeveritySubAggregation: {
value: 1,
},
usersCountAggregation: {
value: 0,
},
@ -233,9 +227,6 @@ export const groupingSearchResponse = {
},
],
},
countSeveritySubAggregation: {
value: 1,
},
usersCountAggregation: {
value: 0,
},
@ -284,9 +275,6 @@ export const groupingSearchResponse = {
},
],
},
countSeveritySubAggregation: {
value: 1,
},
usersCountAggregation: {
value: 0,
},
@ -335,9 +323,6 @@ export const groupingSearchResponse = {
},
],
},
countSeveritySubAggregation: {
value: 1,
},
usersCountAggregation: {
value: 0,
},
@ -386,9 +371,6 @@ export const groupingSearchResponse = {
},
],
},
countSeveritySubAggregation: {
value: 1,
},
usersCountAggregation: {
value: 0,
},
@ -437,9 +419,6 @@ export const groupingSearchResponse = {
},
],
},
countSeveritySubAggregation: {
value: 1,
},
usersCountAggregation: {
value: 0,
},
@ -488,9 +467,6 @@ export const groupingSearchResponse = {
},
],
},
countSeveritySubAggregation: {
value: 1,
},
usersCountAggregation: {
value: 0,
},
@ -539,9 +515,6 @@ export const groupingSearchResponse = {
},
],
},
countSeveritySubAggregation: {
value: 1,
},
usersCountAggregation: {
value: 91,
},
@ -590,9 +563,6 @@ export const groupingSearchResponse = {
},
],
},
countSeveritySubAggregation: {
value: 1,
},
usersCountAggregation: {
value: 91,
},
@ -641,9 +611,6 @@ export const groupingSearchResponse = {
},
],
},
countSeveritySubAggregation: {
value: 1,
},
usersCountAggregation: {
value: 91,
},
@ -692,9 +659,6 @@ export const groupingSearchResponse = {
},
],
},
countSeveritySubAggregation: {
value: 1,
},
usersCountAggregation: {
value: 91,
},
@ -743,9 +707,6 @@ export const groupingSearchResponse = {
},
],
},
countSeveritySubAggregation: {
value: 1,
},
usersCountAggregation: {
value: 91,
},
@ -794,9 +755,6 @@ export const groupingSearchResponse = {
},
],
},
countSeveritySubAggregation: {
value: 1,
},
usersCountAggregation: {
value: 91,
},
@ -845,9 +803,6 @@ export const groupingSearchResponse = {
},
],
},
countSeveritySubAggregation: {
value: 1,
},
usersCountAggregation: {
value: 91,
},
@ -896,9 +851,6 @@ export const groupingSearchResponse = {
},
],
},
countSeveritySubAggregation: {
value: 1,
},
usersCountAggregation: {
value: 91,
},
@ -947,9 +899,6 @@ export const groupingSearchResponse = {
},
],
},
countSeveritySubAggregation: {
value: 1,
},
usersCountAggregation: {
value: 0,
},
@ -998,9 +947,6 @@ export const groupingSearchResponse = {
},
],
},
countSeveritySubAggregation: {
value: 1,
},
usersCountAggregation: {
value: 0,
},
@ -1049,9 +995,6 @@ export const groupingSearchResponse = {
},
],
},
countSeveritySubAggregation: {
value: 1,
},
usersCountAggregation: {
value: 0,
},
@ -1100,9 +1043,6 @@ export const groupingSearchResponse = {
},
],
},
countSeveritySubAggregation: {
value: 1,
},
usersCountAggregation: {
value: 0,
},
@ -1151,9 +1091,6 @@ export const groupingSearchResponse = {
},
],
},
countSeveritySubAggregation: {
value: 1,
},
usersCountAggregation: {
value: 0,
},
@ -1202,9 +1139,6 @@ export const groupingSearchResponse = {
},
],
},
countSeveritySubAggregation: {
value: 1,
},
usersCountAggregation: {
value: 0,
},
@ -1253,9 +1187,6 @@ export const groupingSearchResponse = {
},
],
},
countSeveritySubAggregation: {
value: 1,
},
usersCountAggregation: {
value: 0,
},
@ -1304,9 +1235,6 @@ export const groupingSearchResponse = {
},
],
},
countSeveritySubAggregation: {
value: 1,
},
usersCountAggregation: {
value: 0,
},
@ -1355,9 +1283,6 @@ export const groupingSearchResponse = {
},
],
},
countSeveritySubAggregation: {
value: 1,
},
usersCountAggregation: {
value: 91,
},

View file

@ -29,9 +29,9 @@ describe('defaultGroupStatsAggregations', () => {
]);
});
it('should return values depending on the input field', () => {
const ruleAggregations = defaultGroupStatsAggregations('kibana.alert.rule.name');
expect(ruleAggregations).toEqual([
it('should return values depending for kibana.alert.rule.name input field', () => {
const aggregations = defaultGroupStatsAggregations('kibana.alert.rule.name');
expect(aggregations).toEqual([
{
unitsCount: {
cardinality: {
@ -47,13 +47,6 @@ describe('defaultGroupStatsAggregations', () => {
},
},
},
{
countSeveritySubAggregation: {
cardinality: {
field: 'kibana.alert.severity',
},
},
},
{
severitiesSubAggregation: {
terms: {
@ -83,9 +76,11 @@ describe('defaultGroupStatsAggregations', () => {
},
},
]);
});
const hostAggregations = defaultGroupStatsAggregations('host.name');
expect(hostAggregations).toEqual([
it('should return values depending for host.name input field', () => {
const aggregations = defaultGroupStatsAggregations('host.name');
expect(aggregations).toEqual([
{
unitsCount: {
cardinality: {
@ -100,13 +95,6 @@ describe('defaultGroupStatsAggregations', () => {
},
},
},
{
countSeveritySubAggregation: {
cardinality: {
field: 'kibana.alert.severity',
},
},
},
{
severitiesSubAggregation: {
terms: {
@ -122,9 +110,11 @@ describe('defaultGroupStatsAggregations', () => {
},
},
]);
});
const userAggregations = defaultGroupStatsAggregations('user.name');
expect(userAggregations).toEqual([
it('should return values depending for user.name input field', () => {
const aggregations = defaultGroupStatsAggregations('user.name');
expect(aggregations).toEqual([
{
unitsCount: {
cardinality: {
@ -139,13 +129,6 @@ describe('defaultGroupStatsAggregations', () => {
},
},
},
{
countSeveritySubAggregation: {
cardinality: {
field: 'kibana.alert.severity',
},
},
},
{
severitiesSubAggregation: {
terms: {
@ -161,9 +144,11 @@ describe('defaultGroupStatsAggregations', () => {
},
},
]);
});
const sourceAggregations = defaultGroupStatsAggregations('source.ip');
expect(sourceAggregations).toEqual([
it('should return values depending for source.ip input field', () => {
const aggregations = defaultGroupStatsAggregations('source.ip');
expect(aggregations).toEqual([
{
unitsCount: {
cardinality: {
@ -178,13 +163,6 @@ describe('defaultGroupStatsAggregations', () => {
},
},
},
{
countSeveritySubAggregation: {
cardinality: {
field: 'kibana.alert.severity',
},
},
},
{
severitiesSubAggregation: {
terms: {

View file

@ -8,6 +8,35 @@
import type { NamedAggregation } from '@kbn/grouping';
import { DEFAULT_GROUP_STATS_AGGREGATION } from '../alerts_grouping';
export const SEVERITY_SUB_AGGREGATION = {
severitiesSubAggregation: {
terms: {
field: 'kibana.alert.severity',
},
},
};
export const USER_COUNT_AGGREGATION = {
usersCountAggregation: {
cardinality: {
field: 'user.name',
},
},
};
export const HOST_COUNT_AGGREGATION = {
hostsCountAggregation: {
cardinality: {
field: 'host.name',
},
},
};
export const RULE_COUNT_AGGREGATION = {
rulesCountAggregation: {
cardinality: {
field: 'kibana.alert.rule.rule_id',
},
},
};
/**
* Returns aggregations to be used to calculate the statistics to be used in the `extraAction` property of the EUiAccordion component.
* It handles custom renders for the following fields:
@ -34,34 +63,9 @@ export const defaultGroupStatsAggregations = (field: string): NamedAggregation[]
},
},
},
{
countSeveritySubAggregation: {
cardinality: {
field: 'kibana.alert.severity',
},
},
},
{
severitiesSubAggregation: {
terms: {
field: 'kibana.alert.severity',
},
},
},
{
usersCountAggregation: {
cardinality: {
field: 'user.name',
},
},
},
{
hostsCountAggregation: {
cardinality: {
field: 'host.name',
},
},
},
SEVERITY_SUB_AGGREGATION,
USER_COUNT_AGGREGATION,
HOST_COUNT_AGGREGATION,
{
ruleTags: {
terms: {
@ -74,114 +78,21 @@ export const defaultGroupStatsAggregations = (field: string): NamedAggregation[]
break;
case 'host.name':
aggMetrics.push(
...[
{
rulesCountAggregation: {
cardinality: {
field: 'kibana.alert.rule.rule_id',
},
},
},
{
countSeveritySubAggregation: {
cardinality: {
field: 'kibana.alert.severity',
},
},
},
{
severitiesSubAggregation: {
terms: {
field: 'kibana.alert.severity',
},
},
},
{
usersCountAggregation: {
cardinality: {
field: 'user.name',
},
},
},
]
...[RULE_COUNT_AGGREGATION, SEVERITY_SUB_AGGREGATION, USER_COUNT_AGGREGATION]
);
break;
case 'user.name':
aggMetrics.push(
...[
{
rulesCountAggregation: {
cardinality: {
field: 'kibana.alert.rule.rule_id',
},
},
},
{
countSeveritySubAggregation: {
cardinality: {
field: 'kibana.alert.severity',
},
},
},
{
severitiesSubAggregation: {
terms: {
field: 'kibana.alert.severity',
},
},
},
{
hostsCountAggregation: {
cardinality: {
field: 'host.name',
},
},
},
]
...[RULE_COUNT_AGGREGATION, SEVERITY_SUB_AGGREGATION, HOST_COUNT_AGGREGATION]
);
break;
case 'source.ip':
aggMetrics.push(
...[
{
rulesCountAggregation: {
cardinality: {
field: 'kibana.alert.rule.rule_id',
},
},
},
{
countSeveritySubAggregation: {
cardinality: {
field: 'kibana.alert.severity',
},
},
},
{
severitiesSubAggregation: {
terms: {
field: 'kibana.alert.severity',
},
},
},
{
hostsCountAggregation: {
cardinality: {
field: 'host.name',
},
},
},
]
...[RULE_COUNT_AGGREGATION, SEVERITY_SUB_AGGREGATION, HOST_COUNT_AGGREGATION]
);
break;
default:
aggMetrics.push({
rulesCountAggregation: {
cardinality: {
field: 'kibana.alert.rule.rule_id',
},
},
});
aggMetrics.push(RULE_COUNT_AGGREGATION);
}
return aggMetrics;
};

View file

@ -5,27 +5,82 @@
* 2.0.
*/
import { defaultGroupStatsRenderer } from '.';
import { render } from '@testing-library/react';
import { defaultGroupStatsRenderer, Severity } from '.';
import React from 'react';
import type { GenericBuckets } from '@kbn/grouping/src';
describe('getStats', () => {
it('returns array of badges which corresponds to the field name', () => {
const badgesRuleName = defaultGroupStatsRenderer('kibana.alert.rule.name', {
key: [],
describe('Severity', () => {
it('should return a single low severity UI', () => {
const buckets: GenericBuckets[] = [{ key: 'low', doc_count: 10 }];
const { getByText } = render(<Severity severities={buckets} />);
expect(getByText('Low')).toBeInTheDocument();
});
it('should return a single medium severity UI', () => {
const buckets: GenericBuckets[] = [{ key: 'medium', doc_count: 10 }];
const { getByText } = render(<Severity severities={buckets} />);
expect(getByText('Medium')).toBeInTheDocument();
});
it('should return a single high severity UI', () => {
const buckets: GenericBuckets[] = [{ key: 'high', doc_count: 10 }];
const { getByText } = render(<Severity severities={buckets} />);
expect(getByText('High')).toBeInTheDocument();
});
it('should return a single critical severity UI', () => {
const buckets: GenericBuckets[] = [{ key: 'critical', doc_count: 10 }];
const { getByText } = render(<Severity severities={buckets} />);
expect(getByText('Critical')).toBeInTheDocument();
});
it('should return a single unknown severity UI', () => {
const buckets: GenericBuckets[] = [{ key: '', doc_count: 10 }];
const { getByText } = render(<Severity severities={buckets} />);
expect(getByText('Unknown')).toBeInTheDocument();
});
it('should return a multi severity UI', () => {
const buckets: GenericBuckets[] = [
{ key: 'low', doc_count: 10 },
{ key: 'medium', doc_count: 10 },
];
const { getByText } = render(<Severity severities={buckets} />);
expect(getByText('Multi')).toBeInTheDocument();
});
});
describe('defaultGroupStatsRenderer', () => {
it('should return array of badges for kibana.alert.rule.name field', () => {
const badges = defaultGroupStatsRenderer('kibana.alert.rule.name', {
key: '',
severitiesSubAggregation: { buckets: [{ key: 'medium', doc_count: 10 }] },
countSeveritySubAggregation: { value: 1 },
usersCountAggregation: { value: 3 },
hostsCountAggregation: { value: 5 },
doc_count: 10,
});
expect(badgesRuleName.length).toBe(4);
expect(badges.length).toBe(4);
expect(
badgesRuleName.find(
badges.find(
(badge) => badge.title === 'Severity:' && badge.component != null && badge.badge == null
)
).toBeTruthy();
expect(
badgesRuleName.find(
badges.find(
(badge) =>
badge.title === 'Users:' &&
badge.component == null &&
@ -34,7 +89,7 @@ describe('getStats', () => {
)
).toBeTruthy();
expect(
badgesRuleName.find(
badges.find(
(badge) =>
badge.title === 'Hosts:' &&
badge.component == null &&
@ -43,7 +98,7 @@ describe('getStats', () => {
)
).toBeTruthy();
expect(
badgesRuleName.find(
badges.find(
(badge) =>
badge.title === 'Alerts:' &&
badge.component == null &&
@ -51,24 +106,25 @@ describe('getStats', () => {
badge.badge.value === 10
)
).toBeTruthy();
});
const badgesHostName = defaultGroupStatsRenderer('host.name', {
key: 'Host',
it('should return array of badges for host.name field', () => {
const badges = defaultGroupStatsRenderer('host.name', {
key: '',
severitiesSubAggregation: { buckets: [{ key: 'medium', doc_count: 10 }] },
countSeveritySubAggregation: { value: 1 },
usersCountAggregation: { value: 5 },
rulesCountAggregation: { value: 3 },
doc_count: 2,
});
expect(badgesHostName.length).toBe(4);
expect(badges.length).toBe(4);
expect(
badgesHostName.find(
badges.find(
(badge) => badge.title === 'Severity:' && badge.component != null && badge.badge == null
)
).toBeTruthy();
expect(
badgesHostName.find(
badges.find(
(badge) =>
badge.title === 'Users:' &&
badge.component == null &&
@ -77,7 +133,7 @@ describe('getStats', () => {
)
).toBeTruthy();
expect(
badgesHostName.find(
badges.find(
(badge) =>
badge.title === 'Rules:' &&
badge.component == null &&
@ -86,7 +142,7 @@ describe('getStats', () => {
)
).toBeTruthy();
expect(
badgesHostName.find(
badges.find(
(badge) =>
badge.title === 'Alerts:' &&
badge.component == null &&
@ -94,24 +150,25 @@ describe('getStats', () => {
badge.badge.value === 2
)
).toBeTruthy();
});
const badgesUserName = defaultGroupStatsRenderer('user.name', {
key: 'User test',
it('should return array of badges for user.name field', () => {
const badges = defaultGroupStatsRenderer('user.name', {
key: '',
severitiesSubAggregation: { buckets: [{ key: 'medium', doc_count: 10 }] },
countSeveritySubAggregation: { value: 1 },
rulesCountAggregation: { value: 2 },
hostsCountAggregation: { value: 1 },
doc_count: 1,
});
expect(badgesUserName.length).toBe(4);
expect(badges.length).toBe(4);
expect(
badgesUserName.find(
badges.find(
(badge) => badge.title === 'Severity:' && badge.component != null && badge.badge == null
)
).toBeTruthy();
expect(
badgesUserName.find(
badges.find(
(badge) =>
badge.title === 'Hosts:' &&
badge.component == null &&
@ -120,7 +177,7 @@ describe('getStats', () => {
)
).toBeTruthy();
expect(
badgesUserName.find(
badges.find(
(badge) =>
badge.title === 'Rules:' &&
badge.component == null &&
@ -129,7 +186,7 @@ describe('getStats', () => {
)
).toBeTruthy();
expect(
badgesUserName.find(
badges.find(
(badge) =>
badge.title === 'Alerts:' &&
badge.component == null &&
@ -137,24 +194,25 @@ describe('getStats', () => {
badge.badge.value === 1
)
).toBeTruthy();
});
const badgesSourceIp = defaultGroupStatsRenderer('source.ip', {
key: 'User test',
it('should return array of badges for source.ip field', () => {
const badges = defaultGroupStatsRenderer('source.ip', {
key: '',
severitiesSubAggregation: { buckets: [{ key: 'medium', doc_count: 10 }] },
countSeveritySubAggregation: { value: 1 },
rulesCountAggregation: { value: 16 },
hostsCountAggregation: { value: 17 },
doc_count: 18,
});
expect(badgesSourceIp.length).toBe(4);
expect(badges.length).toBe(4);
expect(
badgesSourceIp.find(
badges.find(
(badge) => badge.title === 'Severity:' && badge.component != null && badge.badge == null
)
).toBeTruthy();
expect(
badgesSourceIp.find(
badges.find(
(badge) =>
badge.title === 'Hosts:' &&
badge.component == null &&
@ -163,7 +221,7 @@ describe('getStats', () => {
)
).toBeTruthy();
expect(
badgesSourceIp.find(
badges.find(
(badge) =>
badge.title === 'Rules:' &&
badge.component == null &&
@ -172,7 +230,7 @@ describe('getStats', () => {
)
).toBeTruthy();
expect(
badgesSourceIp.find(
badges.find(
(badge) =>
badge.title === 'Alerts:' &&
badge.component == null &&
@ -184,9 +242,8 @@ describe('getStats', () => {
it('should return default badges if the field specific does not exist', () => {
const badges = defaultGroupStatsRenderer('process.name', {
key: 'process',
key: '',
severitiesSubAggregation: { buckets: [{ key: 'medium', doc_count: 10 }] },
countSeveritySubAggregation: { value: 1 },
rulesCountAggregation: { value: 3 },
doc_count: 10,
});

View file

@ -6,13 +6,67 @@
*/
import { EuiIcon } from '@elastic/eui';
import React from 'react';
import React, { memo } from 'react';
import type { GroupStatsItem, RawBucket } from '@kbn/grouping';
import type { GenericBuckets } from '@kbn/grouping/src';
import { DEFAULT_GROUP_STATS_RENDERER } from '../alerts_grouping';
import type { AlertsGroupingAggregation } from './types';
import * as i18n from '../translations';
const getSeverity = (severity?: string) => {
export const getUsersBadge = (bucket: RawBucket<AlertsGroupingAggregation>) => ({
title: i18n.STATS_GROUP_USERS,
badge: {
value: bucket.usersCountAggregation?.value ?? 0,
},
});
export const getHostsBadge = (bucket: RawBucket<AlertsGroupingAggregation>) => ({
title: i18n.STATS_GROUP_HOSTS,
badge: {
value: bucket.hostsCountAggregation?.value ?? 0,
},
});
export const getRulesBadge = (bucket: RawBucket<AlertsGroupingAggregation>) => ({
title: i18n.STATS_GROUP_RULES,
badge: {
value: bucket.rulesCountAggregation?.value ?? 0,
},
});
interface SingleSeverityProps {
/**
* Aggregation buckets for severities
*/
severities: GenericBuckets[];
}
/**
* Returns a colored icon and severity value (low, medium, high or critical) if only a single bucket is passed in.
* If the value of the severity is null or incorrect, we return Unknown.
* If there are multiple buckets, we return multiple icons.
*/
export const Severity = memo(({ severities }: SingleSeverityProps) => {
if (severities.length > 1) {
return (
<>
<span className="smallDot">
<EuiIcon type="dot" color="#54b399" />
</span>
<span className="smallDot">
<EuiIcon type="dot" color="#d6bf57" />
</span>
<span className="smallDot">
<EuiIcon type="dot" color="#da8b45" />
</span>
<span>
<EuiIcon type="dot" color="#e7664c" />
</span>
{i18n.STATS_GROUP_SEVERITY_MULTI}
</>
);
}
const severity = severities[0].key;
switch (severity) {
case 'low':
return (
@ -42,29 +96,32 @@ const getSeverity = (severity?: string) => {
{i18n.STATS_GROUP_SEVERITY_CRITICAL}
</>
);
default:
return <>{i18n.STATS_GROUP_SEVERITY_UNKNOWN}</>;
}
return null;
});
Severity.displayName = 'Severity';
/**
* Return a renderer for the severities aggregation.
*/
export const getSeverityComponent = (
bucket: RawBucket<AlertsGroupingAggregation>
): GroupStatsItem[] => {
const severities = bucket.severitiesSubAggregation?.buckets;
if (!severities || severities.length === 0) {
return [];
}
return [
{
title: i18n.STATS_GROUP_SEVERITY,
component: <Severity severities={severities} />,
},
];
};
const multiSeverity = (
<>
<span className="smallDot">
<EuiIcon type="dot" color="#54b399" />
</span>
<span className="smallDot">
<EuiIcon type="dot" color="#d6bf57" />
</span>
<span className="smallDot">
<EuiIcon type="dot" color="#da8b45" />
</span>
<span>
<EuiIcon type="dot" color="#e7664c" />
</span>
{i18n.STATS_GROUP_SEVERITY_MULTI}
</>
);
/**
* Returns statistics to be used in the`extraAction` property of the EuiAccordion component used within the kbn-grouping package.
* It handles custom renders for the following fields:
@ -80,88 +137,17 @@ export const defaultGroupStatsRenderer = (
selectedGroup: string,
bucket: RawBucket<AlertsGroupingAggregation>
): GroupStatsItem[] => {
const singleSeverityComponent =
bucket.severitiesSubAggregation?.buckets && bucket.severitiesSubAggregation?.buckets?.length
? getSeverity(bucket.severitiesSubAggregation?.buckets[0].key.toString())
: null;
const severityComponent =
bucket.countSeveritySubAggregation?.value && bucket.countSeveritySubAggregation?.value > 1
? multiSeverity
: singleSeverityComponent;
const severityStat: GroupStatsItem[] = !severityComponent
? []
: [
{
title: i18n.STATS_GROUP_SEVERITY,
component: severityComponent,
},
];
const severityStat: GroupStatsItem[] = getSeverityComponent(bucket);
const defaultBadges: GroupStatsItem[] = DEFAULT_GROUP_STATS_RENDERER(selectedGroup, bucket);
switch (selectedGroup) {
case 'kibana.alert.rule.name':
return [
...severityStat,
{
title: i18n.STATS_GROUP_USERS,
badge: {
value: bucket.usersCountAggregation?.value ?? 0,
},
},
{
title: i18n.STATS_GROUP_HOSTS,
badge: {
value: bucket.hostsCountAggregation?.value ?? 0,
},
},
...defaultBadges,
];
return [...severityStat, getUsersBadge(bucket), getHostsBadge(bucket), ...defaultBadges];
case 'host.name':
return [
...severityStat,
{
title: i18n.STATS_GROUP_USERS,
badge: {
value: bucket.usersCountAggregation?.value ?? 0,
},
},
{
title: i18n.STATS_GROUP_RULES,
badge: {
value: bucket.rulesCountAggregation?.value ?? 0,
},
},
...defaultBadges,
];
return [...severityStat, getUsersBadge(bucket), getRulesBadge(bucket), ...defaultBadges];
case 'user.name':
case 'source.ip':
return [
...severityStat,
{
title: i18n.STATS_GROUP_HOSTS,
badge: {
value: bucket.hostsCountAggregation?.value ?? 0,
},
},
{
title: i18n.STATS_GROUP_RULES,
badge: {
value: bucket.rulesCountAggregation?.value ?? 0,
},
},
...defaultBadges,
];
return [...severityStat, getHostsBadge(bucket), getRulesBadge(bucket), ...defaultBadges];
}
return [
...severityStat,
{
title: i18n.STATS_GROUP_RULES,
badge: {
value: bucket.rulesCountAggregation?.value ?? 0,
},
},
...defaultBadges,
];
return [...severityStat, getRulesBadge(bucket), ...defaultBadges];
};

View file

@ -5,12 +5,23 @@
* 2.0.
*/
import { defaultGroupTitleRenderers } from '.';
import React from 'react';
import {
defaultGroupTitleRenderers,
GroupWithIconContent,
RULE_NAME_GROUP_DESCRIPTION_TEST_ID,
RULE_NAME_GROUP_TAG_TEST_ID,
RULE_NAME_GROUP_TAGS_TEST_ID,
RULE_NAME_GROUP_TEST_ID,
RULE_NAME_GROUP_TITLE_TEST_ID,
RuleNameGroupContent,
} from '.';
import { render } from '@testing-library/react';
describe('defaultGroupTitleRenderers', () => {
it('renders correctly when the field renderer exists', () => {
let { getByTestId } = render(
it('should render correctly for kibana.alert.rule.name field', () => {
const { getByTestId } = render(
defaultGroupTitleRenderers(
'kibana.alert.rule.name',
{
@ -21,8 +32,11 @@ describe('defaultGroupTitleRenderers', () => {
)!
);
expect(getByTestId('rule-name-group-renderer')).toBeInTheDocument();
const result1 = render(
expect(getByTestId(RULE_NAME_GROUP_TEST_ID)).toBeInTheDocument();
});
it('should render correctly for host.name field', () => {
const { getByTestId } = render(
defaultGroupTitleRenderers(
'host.name',
{
@ -32,11 +46,12 @@ describe('defaultGroupTitleRenderers', () => {
'This is a null group!'
)!
);
getByTestId = result1.getByTestId;
expect(getByTestId('host-name-group-renderer')).toBeInTheDocument();
});
const result2 = render(
it('should render correctly for user.name field', () => {
const { getByTestId } = render(
defaultGroupTitleRenderers(
'user.name',
{
@ -46,10 +61,12 @@ describe('defaultGroupTitleRenderers', () => {
'This is a null group!'
)!
);
getByTestId = result2.getByTestId;
expect(getByTestId('host-name-group-renderer')).toBeInTheDocument();
const result3 = render(
expect(getByTestId('user-name-group-renderer')).toBeInTheDocument();
});
it('should render correctly for source.ip field', () => {
const { getByTestId } = render(
defaultGroupTitleRenderers(
'source.ip',
{
@ -59,12 +76,11 @@ describe('defaultGroupTitleRenderers', () => {
'This is a null group!'
)!
);
getByTestId = result3.getByTestId;
expect(getByTestId('source-ip-group-renderer')).toBeInTheDocument();
});
it('returns undefined when the renderer does not exist', () => {
it('should return undefined when the renderer does not exist', () => {
const wrapper = defaultGroupTitleRenderers(
'process.name',
{
@ -77,3 +93,60 @@ describe('defaultGroupTitleRenderers', () => {
expect(wrapper).toBeUndefined();
});
});
describe('RuleNameGroupContent', () => {
it('should render component', () => {
const { getByTestId, queryByTestId } = render(
<RuleNameGroupContent ruleName="rule_name" ruleDescription="rule_description" />
);
expect(getByTestId(RULE_NAME_GROUP_TEST_ID)).toBeInTheDocument();
expect(getByTestId(RULE_NAME_GROUP_TITLE_TEST_ID)).toHaveTextContent('rule_name');
expect(getByTestId(RULE_NAME_GROUP_DESCRIPTION_TEST_ID)).toHaveTextContent('rule_description');
expect(queryByTestId(RULE_NAME_GROUP_TAGS_TEST_ID)).not.toBeInTheDocument();
expect(queryByTestId(RULE_NAME_GROUP_TAG_TEST_ID)).not.toBeInTheDocument();
});
it('should render component with tags', () => {
const { getByTestId } = render(
<RuleNameGroupContent
ruleName="rule_name"
ruleDescription="rule_description"
tags={[
{
key: 'key',
doc_count: 2,
},
]}
/>
);
expect(getByTestId(RULE_NAME_GROUP_TAGS_TEST_ID)).toBeInTheDocument();
});
});
describe('GroupWithIconContent', () => {
it('should render component with icon', () => {
const { getByTestId, queryByTestId } = render(
<GroupWithIconContent title="title" icon="icon" dataTestSubj="test_id" />
);
expect(getByTestId('test_id-group-renderer')).toBeInTheDocument();
expect(getByTestId('test_id-group-renderer-icon')).toBeInTheDocument();
expect(getByTestId('test_id-group-renderer-title')).toHaveTextContent('title');
expect(queryByTestId('test_id-group-renderer-null-message')).not.toBeInTheDocument();
});
it('should render null message information icon', () => {
const { getByTestId } = render(
<GroupWithIconContent
title="title"
icon="icon"
nullGroupMessage="null_message"
dataTestSubj="test_id"
/>
);
expect(getByTestId('test_id-group-renderer-null-message')).toBeInTheDocument();
});
});

View file

@ -53,7 +53,7 @@ export const defaultGroupTitleRenderers: GroupPanelRenderer<AlertsGroupingAggreg
) : undefined;
case 'host.name':
return (
<GroupContent
<GroupWithIconContent
title={bucket.key}
icon="storage"
nullGroupMessage={nullGroupMessage}
@ -62,7 +62,7 @@ export const defaultGroupTitleRenderers: GroupPanelRenderer<AlertsGroupingAggreg
);
case 'user.name':
return (
<GroupContent
<GroupWithIconContent
title={bucket.key}
icon="user"
nullGroupMessage={nullGroupMessage}
@ -71,7 +71,7 @@ export const defaultGroupTitleRenderers: GroupPanelRenderer<AlertsGroupingAggreg
);
case 'source.ip':
return (
<GroupContent
<GroupWithIconContent
title={bucket.key}
icon="globe"
nullGroupMessage={nullGroupMessage}
@ -81,21 +81,27 @@ export const defaultGroupTitleRenderers: GroupPanelRenderer<AlertsGroupingAggreg
}
};
const RuleNameGroupContent = React.memo<{
export const RULE_NAME_GROUP_TEST_ID = 'rule-name-group-renderer';
export const RULE_NAME_GROUP_TITLE_TEST_ID = 'rule-name-group-renderer-title';
export const RULE_NAME_GROUP_DESCRIPTION_TEST_ID = 'rule-name-group-renderer-description';
export const RULE_NAME_GROUP_TAG_TEST_ID = 'rule-name-group-renderer-tag';
export const RULE_NAME_GROUP_TAGS_TEST_ID = 'rule-name-group-renderer-tags';
export const RuleNameGroupContent = React.memo<{
ruleName: string;
ruleDescription: string;
tags?: GenericBuckets[] | undefined;
}>(({ ruleName, ruleDescription, tags }) => {
const renderItem = (tag: string, i: number) => (
<EuiBadge color="hollow" key={`${tag}-${i}`} data-test-subj="tag">
<EuiBadge color="hollow" key={`${tag}-${i}`} data-test-subj={RULE_NAME_GROUP_TAG_TEST_ID}>
{tag}
</EuiBadge>
);
return (
<div style={{ display: 'table', tableLayout: 'fixed', width: '100%' }}>
<EuiFlexGroup data-test-subj="rule-name-group-renderer" gutterSize="s" alignItems="center">
<EuiFlexItem grow={false} style={{ display: 'contents' }}>
<EuiTitle size="xs">
<EuiFlexGroup data-test-subj={RULE_NAME_GROUP_TEST_ID} gutterSize="s" alignItems="center">
<EuiFlexItem grow={false} css={{ display: 'contents' }}>
<EuiTitle data-test-subj={RULE_NAME_GROUP_TITLE_TEST_ID} size="xs">
<h5 className="eui-textTruncate">{ruleName.trim()}</h5>
</EuiTitle>
</EuiFlexItem>
@ -106,14 +112,13 @@ const RuleNameGroupContent = React.memo<{
popoverTitle={COLUMN_TAGS}
popoverButtonTitle={tags.length.toString()}
popoverButtonIcon="tag"
dataTestPrefix="tags"
dataTestPrefix={RULE_NAME_GROUP_TAGS_TEST_ID}
renderItem={renderItem}
/>
</EuiFlexItem>
) : null}
</EuiFlexGroup>
<EuiText size="s">
<EuiText data-test-subj={RULE_NAME_GROUP_DESCRIPTION_TEST_ID} size="s">
<p className="eui-textTruncate">
<EuiTextColor color="subdued">{ruleDescription}</EuiTextColor>
</p>
@ -123,7 +128,7 @@ const RuleNameGroupContent = React.memo<{
});
RuleNameGroupContent.displayName = 'RuleNameGroup';
const GroupContent = React.memo<{
export const GroupWithIconContent = React.memo<{
title: string | string[];
icon: string;
nullGroupMessage?: string;
@ -135,18 +140,18 @@ const GroupContent = React.memo<{
alignItems="center"
>
<EuiFlexItem grow={false}>
<EuiIcon type={icon} size="m" />
<EuiIcon data-test-subj={`${dataTestSubj}-group-renderer-icon`} size="m" type={icon} />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiTitle size="xs">
<EuiTitle data-test-subj={`${dataTestSubj}-group-renderer-title`} size="xs">
<h5>{title}</h5>
</EuiTitle>
</EuiFlexItem>
{nullGroupMessage && (
<EuiFlexItem grow={false}>
<EuiFlexItem data-test-subj={`${dataTestSubj}-group-renderer-null-message`} grow={false}>
<EuiIconTip content={nullGroupMessage} position="right" />
</EuiFlexItem>
)}
</EuiFlexGroup>
));
GroupContent.displayName = 'GroupContent';
GroupWithIconContent.displayName = 'GroupWithIconContent';

View file

@ -50,11 +50,6 @@ export const getQuery = (
],
},
},
countSeveritySubAggregation: {
cardinality: {
field: 'kibana.alert.severity',
},
},
hostsCountAggregation: {
cardinality: {
field: 'host.name',
@ -175,9 +170,6 @@ export const groupingSearchResponse = {
},
],
},
countSeveritySubAggregation: {
value: 1,
},
usersCountAggregation: {
value: 0,
},
@ -226,9 +218,6 @@ export const groupingSearchResponse = {
},
],
},
countSeveritySubAggregation: {
value: 1,
},
usersCountAggregation: {
value: 0,
},
@ -277,9 +266,6 @@ export const groupingSearchResponse = {
},
],
},
countSeveritySubAggregation: {
value: 1,
},
usersCountAggregation: {
value: 0,
},
@ -328,9 +314,6 @@ export const groupingSearchResponse = {
},
],
},
countSeveritySubAggregation: {
value: 1,
},
usersCountAggregation: {
value: 0,
},
@ -379,9 +362,6 @@ export const groupingSearchResponse = {
},
],
},
countSeveritySubAggregation: {
value: 1,
},
usersCountAggregation: {
value: 0,
},
@ -430,9 +410,6 @@ export const groupingSearchResponse = {
},
],
},
countSeveritySubAggregation: {
value: 1,
},
usersCountAggregation: {
value: 0,
},
@ -481,9 +458,6 @@ export const groupingSearchResponse = {
},
],
},
countSeveritySubAggregation: {
value: 1,
},
usersCountAggregation: {
value: 0,
},
@ -532,9 +506,6 @@ export const groupingSearchResponse = {
},
],
},
countSeveritySubAggregation: {
value: 1,
},
usersCountAggregation: {
value: 0,
},
@ -583,9 +554,6 @@ export const groupingSearchResponse = {
},
],
},
countSeveritySubAggregation: {
value: 1,
},
usersCountAggregation: {
value: 91,
},
@ -634,9 +602,6 @@ export const groupingSearchResponse = {
},
],
},
countSeveritySubAggregation: {
value: 1,
},
usersCountAggregation: {
value: 91,
},
@ -685,9 +650,6 @@ export const groupingSearchResponse = {
},
],
},
countSeveritySubAggregation: {
value: 1,
},
usersCountAggregation: {
value: 91,
},
@ -736,9 +698,6 @@ export const groupingSearchResponse = {
},
],
},
countSeveritySubAggregation: {
value: 1,
},
usersCountAggregation: {
value: 91,
},
@ -787,9 +746,6 @@ export const groupingSearchResponse = {
},
],
},
countSeveritySubAggregation: {
value: 1,
},
usersCountAggregation: {
value: 91,
},
@ -838,9 +794,6 @@ export const groupingSearchResponse = {
},
],
},
countSeveritySubAggregation: {
value: 1,
},
usersCountAggregation: {
value: 91,
},
@ -889,9 +842,6 @@ export const groupingSearchResponse = {
},
],
},
countSeveritySubAggregation: {
value: 1,
},
usersCountAggregation: {
value: 91,
},
@ -940,9 +890,6 @@ export const groupingSearchResponse = {
},
],
},
countSeveritySubAggregation: {
value: 1,
},
usersCountAggregation: {
value: 91,
},
@ -991,9 +938,6 @@ export const groupingSearchResponse = {
},
],
},
countSeveritySubAggregation: {
value: 1,
},
usersCountAggregation: {
value: 0,
},
@ -1042,9 +986,6 @@ export const groupingSearchResponse = {
},
],
},
countSeveritySubAggregation: {
value: 1,
},
usersCountAggregation: {
value: 0,
},
@ -1093,9 +1034,6 @@ export const groupingSearchResponse = {
},
],
},
countSeveritySubAggregation: {
value: 1,
},
usersCountAggregation: {
value: 0,
},
@ -1144,9 +1082,6 @@ export const groupingSearchResponse = {
},
],
},
countSeveritySubAggregation: {
value: 1,
},
usersCountAggregation: {
value: 0,
},
@ -1195,9 +1130,6 @@ export const groupingSearchResponse = {
},
],
},
countSeveritySubAggregation: {
value: 1,
},
usersCountAggregation: {
value: 0,
},
@ -1246,9 +1178,6 @@ export const groupingSearchResponse = {
},
],
},
countSeveritySubAggregation: {
value: 1,
},
usersCountAggregation: {
value: 0,
},
@ -1297,9 +1226,6 @@ export const groupingSearchResponse = {
},
],
},
countSeveritySubAggregation: {
value: 1,
},
usersCountAggregation: {
value: 0,
},
@ -1348,9 +1274,6 @@ export const groupingSearchResponse = {
},
],
},
countSeveritySubAggregation: {
value: 1,
},
usersCountAggregation: {
value: 0,
},
@ -1399,9 +1322,6 @@ export const groupingSearchResponse = {
},
],
},
countSeveritySubAggregation: {
value: 1,
},
usersCountAggregation: {
value: 91,
},

View file

@ -8,6 +8,7 @@
import type { GenericBuckets } from '@kbn/grouping/src';
// Elasticsearch returns `null` when a sub-aggregation cannot be computed
type NumberOrNull = number | null;
export interface AlertsGroupingAggregation {
unitsCount?: {
value?: NumberOrNull;
@ -18,18 +19,12 @@ export interface AlertsGroupingAggregation {
severitiesSubAggregation?: {
buckets?: GenericBuckets[];
};
countSeveritySubAggregation?: {
value?: NumberOrNull;
};
usersCountAggregation?: {
value?: NumberOrNull;
};
hostsCountAggregation?: {
value?: NumberOrNull;
};
ipsCountAggregation?: {
value?: NumberOrNull;
};
rulesCountAggregation?: {
value?: NumberOrNull;
};
@ -38,9 +33,4 @@ export interface AlertsGroupingAggregation {
sum_other_doc_count?: number;
buckets?: GenericBuckets[];
};
stackByMultipleFields1?: {
buckets?: GenericBuckets[];
doc_count_error_upper_bound?: number;
sum_other_doc_count?: number;
};
}

View file

@ -393,6 +393,13 @@ export const STATS_GROUP_SEVERITY_MEDIUM = i18n.translate(
}
);
export const STATS_GROUP_SEVERITY_UNKNOWN = i18n.translate(
'xpack.securitySolution.detectionEngine.groups.stats.severity.unknown',
{
defaultMessage: 'Unknown',
}
);
export const INSPECT_GROUPING_TITLE = i18n.translate(
'xpack.securitySolution.detectionsEngine.grouping.inspectTitle',
{