[8.8] [Security solution] Grouping bug fixes from BC1 (#156619) (#156717)

# Backport

This will backport the following commits from `main` to `8.8`:
- [[Security solution] Grouping bug fixes from BC1
(#156619)](https://github.com/elastic/kibana/pull/156619)

<!--- Backport version: 8.9.7 -->

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

<!--BACKPORT [{"author":{"name":"Steph
Milovic","email":"stephanie.milovic@elastic.co"},"sourceCommit":{"committedDate":"2023-05-04T17:01:51Z","message":"[Security
solution] Grouping bug fixes from BC1
(#156619)","sha":"0f2ee85d8b492c654f7e613e8b40a8f2a91ae634","branchLabelMapping":{"^v8.9.0$":"main","^v(\\d+).(\\d+).\\d+$":"$1.$2"}},"sourcePullRequest":{"labels":["release_note:skip","Team:Threat
Hunting","Team: SecuritySolution","Team:Threat
Hunting:Explore","v8.8.0","v8.9.0"],"number":156619,"url":"https://github.com/elastic/kibana/pull/156619","mergeCommit":{"message":"[Security
solution] Grouping bug fixes from BC1
(#156619)","sha":"0f2ee85d8b492c654f7e613e8b40a8f2a91ae634"}},"sourceBranch":"main","suggestedTargetBranches":["8.8"],"targetPullRequestStates":[{"branch":"8.8","label":"v8.8.0","labelRegex":"^v(\\d+).(\\d+).\\d+$","isSourceBranch":false,"state":"NOT_CREATED"},{"branch":"main","label":"v8.9.0","labelRegex":"^v8.9.0$","isSourceBranch":true,"state":"MERGED","url":"https://github.com/elastic/kibana/pull/156619","number":156619,"mergeCommit":{"message":"[Security
solution] Grouping bug fixes from BC1
(#156619)","sha":"0f2ee85d8b492c654f7e613e8b40a8f2a91ae634"}}]}]
BACKPORT-->

Co-authored-by: Steph Milovic <stephanie.milovic@elastic.co>
This commit is contained in:
Kibana Machine 2023-05-04 14:44:48 -04:00 committed by GitHub
parent b8904f0332
commit 15863ca642
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 58 additions and 33 deletions

View file

@ -6,7 +6,7 @@
* Side Public License, v 1.
*/
export const createGroupFilter = (selectedGroup: string, query?: string) =>
export const createGroupFilter = (selectedGroup: string, query?: string | null) =>
query && selectedGroup
? [
{

View file

@ -8,19 +8,21 @@
import { fireEvent, render } from '@testing-library/react';
import { GroupPanel } from '.';
import { createGroupFilter } from './helpers';
import { createGroupFilter, getNullGroupFilter } from './helpers';
import React from 'react';
const onToggleGroup = jest.fn();
const renderChildComponent = jest.fn();
const ruleName = 'Rule name';
const ruleDesc = 'Rule description';
const selectedGroup = 'kibana.alert.rule.name';
const testProps = {
isLoading: false,
isNullGroup: false,
groupBucket: {
key: [ruleName, ruleDesc],
key_as_string: `${ruleName}|${ruleDesc}`,
selectedGroup,
key: [ruleName, ruleName],
key_as_string: `${ruleName}|${ruleName}`,
doc_count: 98,
hostsCountAggregation: {
value: 5,
@ -54,7 +56,7 @@ const testProps = {
},
},
renderChildComponent,
selectedGroup: 'kibana.alert.rule.name',
selectedGroup,
onGroupClose: () => {},
};
@ -69,7 +71,12 @@ describe('grouping accordion panel', () => {
createGroupFilter(testProps.selectedGroup, ruleName)
);
});
it('does not create query without a valid groupFieldValue', () => {
it('creates the query for the selectedGroup attribute when the group is null', () => {
const { getByTestId } = render(<GroupPanel {...testProps} isNullGroup />);
expect(getByTestId('grouping-accordion')).toBeInTheDocument();
expect(renderChildComponent).toHaveBeenCalledWith(getNullGroupFilter(testProps.selectedGroup));
});
it('does not render accordion or create query without a valid groupFieldValue', () => {
const { queryByTestId } = render(
<GroupPanel
{...testProps}
@ -83,6 +90,11 @@ describe('grouping accordion panel', () => {
expect(queryByTestId('grouping-accordion')).not.toBeInTheDocument();
expect(renderChildComponent).not.toHaveBeenCalled();
});
it('Does not render accordion or create query when groupBucket.selectedGroup !== selectedGroup', () => {
const { queryByTestId } = render(<GroupPanel {...testProps} selectedGroup="source.ip" />);
expect(queryByTestId('grouping-accordion')).not.toBeInTheDocument();
expect(testProps.renderChildComponent).not.toHaveBeenCalled();
});
it('When onToggleGroup not defined, does nothing on toggle', () => {
const { container } = render(<GroupPanel {...testProps} />);
fireEvent.click(container.querySelector('[data-test-subj="grouping-accordion"] button')!);

View file

@ -10,7 +10,7 @@ import { EuiAccordion, EuiFlexGroup, EuiFlexItem, EuiTitle, EuiIconTip } from '@
import type { Filter } from '@kbn/es-query';
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
import { firstNonNullValue } from '../../helpers';
import type { RawBucket } from '../types';
import type { GroupingBucket } from '../types';
import { createGroupFilter, getNullGroupFilter } from './helpers';
interface GroupPanelProps<T> {
@ -18,14 +18,14 @@ interface GroupPanelProps<T> {
customAccordionClassName?: string;
extraAction?: React.ReactNode;
forceState?: 'open' | 'closed';
groupBucket: RawBucket<T>;
groupBucket: GroupingBucket<T>;
groupPanelRenderer?: JSX.Element;
groupingLevel?: number;
isLoading: boolean;
isNullGroup?: boolean;
nullGroupMessage?: string;
onGroupClose: () => void;
onToggleGroup?: (isOpen: boolean, groupBucket: RawBucket<T>) => void;
onToggleGroup?: (isOpen: boolean, groupBucket: GroupingBucket<T>) => void;
renderChildComponent: (groupFilter: Filter[]) => React.ReactElement;
selectedGroup: string;
}
@ -81,8 +81,10 @@ const GroupPanelComponent = <T,>({
lastForceState.current = 'open';
}
}, [onGroupClose, forceState, selectedGroup]);
const groupFieldValue = useMemo(() => firstNonNullValue(groupBucket.key), [groupBucket.key]);
const groupFieldValue = useMemo(
() => (groupBucket.selectedGroup === selectedGroup ? firstNonNullValue(groupBucket.key) : null),
[groupBucket.key, groupBucket.selectedGroup, selectedGroup]
);
const groupFilters = useMemo(
() =>

View file

@ -24,6 +24,7 @@ export const mockGroupingProps = {
{
key: [host1Name],
key_as_string: `${host1Name}`,
selectedGroup: 'host.name',
doc_count: 1,
hostsCountAggregation: {
value: 1,
@ -56,6 +57,7 @@ export const mockGroupingProps = {
{
key: [host2Name],
key_as_string: `${host2Name}`,
selectedGroup: 'host.name',
doc_count: 1,
hostsCountAggregation: {
value: 1,
@ -88,6 +90,7 @@ export const mockGroupingProps = {
{
key: ['-'],
key_as_string: `-`,
selectedGroup: 'host.name',
isNullGroup: true,
doc_count: 11,
hostsCountAggregation: {

View file

@ -16,15 +16,16 @@ export const NONE_GROUP_KEY = 'none';
export type RawBucket<T> = GenericBuckets & T;
export interface GroupingBucket {
export type GroupingBucket<T> = RawBucket<T> & {
selectedGroup: string;
isNullGroup?: boolean;
}
};
/** Defines the shape of the aggregation returned by Elasticsearch */
// TODO: write developer docs for these fields
export interface RootAggregation<T> {
groupByFields?: {
buckets?: Array<RawBucket<T> & GroupingBucket>;
buckets?: Array<GroupingBucket<T>>;
};
groupsCount?: {
value?: number | null;

View file

@ -161,16 +161,19 @@ describe('group selector', () => {
{
key: ['20.80.64.28', '20.80.64.28'],
key_as_string: '20.80.64.28|20.80.64.28',
selectedGroup: 'source.ip',
doc_count: 75,
},
{
key: ['0.0.0.0', '0.0.0.0'],
key_as_string: '0.0.0.0|0.0.0.0',
selectedGroup: 'source.ip',
doc_count: 75,
},
{
key: ['0.0.0.0', '::'],
key_as_string: '0.0.0.0|::',
selectedGroup: 'source.ip',
doc_count: 75,
},
],
@ -186,23 +189,26 @@ describe('group selector', () => {
},
};
it('parseGroupingQuery finds and flags the null group', () => {
const result = parseGroupingQuery(groupingAggs);
const result = parseGroupingQuery('source.ip', groupingAggs);
expect(result).toEqual({
groupByFields: {
buckets: [
{
key: ['20.80.64.28'],
key_as_string: '20.80.64.28',
selectedGroup: 'source.ip',
doc_count: 75,
},
{
key: ['0.0.0.0'],
key_as_string: '0.0.0.0',
selectedGroup: 'source.ip',
doc_count: 75,
},
{
key: [getEmptyValue()],
key_as_string: getEmptyValue(),
selectedGroup: 'source.ip',
isNullGroup: true,
doc_count: 75,
},
@ -220,7 +226,7 @@ describe('group selector', () => {
});
});
it('parseGroupingQuery adjust group count when null field group is present', () => {
const result: GroupingAggregation<{}> = parseGroupingQuery({
const result: GroupingAggregation<{}> = parseGroupingQuery('source.ip', {
...groupingAggs,
unitsCountWithoutNull: { value: 99 },
});

View file

@ -119,9 +119,11 @@ export const getGroupingQuery = ({
/**
* Parses the grouping query response to add the isNullGroup
* flag to the buckets and to format the bucket keys
* @param buckets buckets returned from the grouping query
* @param selectedGroup from the grouping query
* @param aggs aggs returned from the grouping query
*/
export const parseGroupingQuery = <T>(
selectedGroup: string,
aggs?: GroupingAggregation<T>
): GroupingAggregation<T> | {} => {
if (!aggs) {
@ -138,11 +140,13 @@ export const parseGroupingQuery = <T>(
? {
...group,
key: [group.key[0]],
selectedGroup,
key_as_string: group.key[0],
}
: {
...group,
key: [emptyValue],
selectedGroup,
key_as_string: emptyValue,
isNullGroup: true,
};

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import React, { useCallback, useEffect, useMemo } from 'react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { v4 as uuidv4 } from 'uuid';
import type { Filter, Query } from '@kbn/es-query';
import { buildEsQuery } from '@kbn/es-query';
@ -193,13 +193,17 @@ export const GroupedSubLevelComponent: React.FC<AlertsTableComponentProps> = ({
skip: isNoneGroup([selectedGroup]),
});
const [queriedGroup, setQueriedGroup] = useState('');
const aggs = useMemo(
() => parseGroupingQuery(alertsGroupsData?.aggregations),
[alertsGroupsData]
// queriedGroup because `selectedGroup` updates before the query response
() => parseGroupingQuery(queriedGroup, alertsGroupsData?.aggregations),
[alertsGroupsData?.aggregations, queriedGroup]
);
useEffect(() => {
if (!isNoneGroup([selectedGroup])) {
setQueriedGroup(queryGroups?.aggs?.groupsCount?.cardinality?.field ?? '');
setAlertsQuery(queryGroups);
}
}, [queryGroups, selectedGroup, setAlertsQuery]);

View file

@ -51,7 +51,7 @@ describe('getStats', () => {
});
expect(
badgesUserName.find(
(badge) => badge.badge != null && badge.title === `IP's:` && badge.badge.value === 1
(badge) => badge.badge != null && badge.title === `Hosts:` && badge.badge.value === 1
)
).toBeTruthy();
});

View file

@ -136,7 +136,7 @@ export const getStats = (
return [
...severityStat,
{
title: i18n.STATS_GROUP_IPS,
title: i18n.STATS_GROUP_HOSTS,
badge: {
value: bucket.hostsCountAggregation?.value ?? 0,
},
@ -153,7 +153,7 @@ export const getStats = (
return [
...severityStat,
{
title: i18n.STATS_GROUP_IPS,
title: i18n.STATS_GROUP_HOSTS,
badge: {
value: bucket.hostsCountAggregation?.value ?? 0,
},

View file

@ -167,19 +167,12 @@ const getAggregationsByGroupField = (field: string): NamedAggregation[] => {
},
},
{
usersCountAggregation: {
hostsCountAggregation: {
cardinality: {
field: 'host.name',
},
},
},
{
usersCountAggregation: {
cardinality: {
field: 'user.name',
},
},
},
]
);
break;
@ -208,7 +201,7 @@ const getAggregationsByGroupField = (field: string): NamedAggregation[] => {
},
},
{
usersCountAggregation: {
hostsCountAggregation: {
cardinality: {
field: 'host.name',
},