[8.8] [Security solution] Fix grouping query, be ready for arrays! (#157330) (#157754)

# Backport

This will backport the following commits from `main` to `8.8`:
- [[Security solution] Fix grouping query, be ready for arrays!
(#157330)](https://github.com/elastic/kibana/pull/157330)

<!--- 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-11T23:03:16Z","message":"[Security
solution] Fix grouping query, be ready for arrays!
(#157330)","sha":"4371c157b0bd7a084836d5c9928bf1752f2c9d52","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","Feature:Alerts
Grouping","v8.9.0"],"number":157330,"url":"https://github.com/elastic/kibana/pull/157330","mergeCommit":{"message":"[Security
solution] Fix grouping query, be ready for arrays!
(#157330)","sha":"4371c157b0bd7a084836d5c9928bf1752f2c9d52"}},"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/157330","number":157330,"mergeCommit":{"message":"[Security
solution] Fix grouping query, be ready for arrays!
(#157330)","sha":"4371c157b0bd7a084836d5c9928bf1752f2c9d52"}}]}]
BACKPORT-->

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Steph Milovic 2023-05-15 15:20:05 -06:00 committed by GitHub
parent c1a961d80a
commit 263945cdc0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 560 additions and 693 deletions

View file

@ -1,51 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
export const createGroupFilter = (selectedGroup: string, query?: string | null) =>
query && selectedGroup
? [
{
meta: {
alias: null,
disabled: false,
key: selectedGroup,
negate: false,
params: {
query,
},
type: 'phrase',
},
query: {
match_phrase: {
[selectedGroup]: {
query,
},
},
},
},
]
: [];
export const getNullGroupFilter = (selectedGroup: string) => [
{
meta: {
disabled: false,
negate: true,
alias: null,
key: selectedGroup,
field: selectedGroup,
value: 'exists',
type: 'exists',
},
query: {
exists: {
field: selectedGroup,
},
},
},
];

View file

@ -8,8 +8,9 @@
import { fireEvent, render } from '@testing-library/react';
import { GroupPanel } from '.';
import { createGroupFilter, getNullGroupFilter } from './helpers';
import { createGroupFilter, getNullGroupFilter } from '../../containers/query/helpers';
import React from 'react';
import { groupingBucket } from '../../mocks';
const onToggleGroup = jest.fn();
const renderChildComponent = jest.fn();
@ -20,40 +21,10 @@ const testProps = {
isLoading: false,
isNullGroup: false,
groupBucket: {
...groupingBucket,
selectedGroup,
key: [ruleName, ruleName],
key_as_string: `${ruleName}|${ruleName}`,
doc_count: 98,
hostsCountAggregation: {
value: 5,
},
ruleTags: {
doc_count_error_upper_bound: 0,
sum_other_doc_count: 0,
buckets: [],
},
alertsCount: {
value: 98,
},
rulesCountAggregation: {
value: 1,
},
severitiesSubAggregation: {
doc_count_error_upper_bound: 0,
sum_other_doc_count: 0,
buckets: [
{
key: 'low',
doc_count: 98,
},
],
},
countSeveritySubAggregation: {
value: 1,
},
usersCountAggregation: {
value: 98,
},
key: [ruleName],
key_as_string: `${ruleName}`,
},
renderChildComponent,
selectedGroup,
@ -68,7 +39,7 @@ describe('grouping accordion panel', () => {
const { getByTestId } = render(<GroupPanel {...testProps} />);
expect(getByTestId('grouping-accordion')).toBeInTheDocument();
expect(renderChildComponent).toHaveBeenCalledWith(
createGroupFilter(testProps.selectedGroup, ruleName)
createGroupFilter(testProps.selectedGroup, [ruleName])
);
});
it('creates the query for the selectedGroup attribute when the group is null', () => {
@ -82,8 +53,7 @@ describe('grouping accordion panel', () => {
{...testProps}
groupBucket={{
...testProps.groupBucket,
// @ts-expect-error
key: null,
selectedGroup: 'wrong-group',
}}
/>
);

View file

@ -9,9 +9,8 @@
import { EuiAccordion, EuiFlexGroup, EuiFlexItem, EuiTitle, EuiIconTip } from '@elastic/eui';
import type { Filter } from '@kbn/es-query';
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
import { firstNonNullValue } from '../../helpers';
import type { GroupingBucket } from '../types';
import { createGroupFilter, getNullGroupFilter } from './helpers';
import { createGroupFilter, getNullGroupFilter } from '../../containers/query/helpers';
interface GroupPanelProps<T> {
customAccordionButtonClassName?: string;
@ -41,9 +40,11 @@ const DefaultGroupPanelRenderer = ({
}) => (
<div>
<EuiFlexGroup gutterSize="s" alignItems="center" responsive={false}>
<EuiFlexItem grow={false}>
<EuiFlexItem grow={false} className="eui-textTruncate">
<EuiTitle size="xs" className="euiAccordionForm__title">
<h4 className="eui-textTruncate">{title}</h4>
<h4 className="eui-textTruncate" title={title}>
{title}
</h4>
</EuiTitle>
</EuiFlexItem>
{isNullGroup && nullGroupMessage && (
@ -81,17 +82,23 @@ const GroupPanelComponent = <T,>({
lastForceState.current = 'open';
}
}, [onGroupClose, forceState, selectedGroup]);
const groupFieldValue = useMemo(
() => (groupBucket.selectedGroup === selectedGroup ? firstNonNullValue(groupBucket.key) : null),
[groupBucket.key, groupBucket.selectedGroup, selectedGroup]
const groupFieldValue = useMemo<{ asString: string | null; asArray: string[] | null }>(
() =>
groupBucket.selectedGroup === selectedGroup
? {
asString: groupBucket.key_as_string,
asArray: groupBucket.key,
}
: { asString: null, asArray: null },
[groupBucket.key, groupBucket.key_as_string, groupBucket.selectedGroup, selectedGroup]
);
const groupFilters = useMemo(
() =>
isNullGroup
? getNullGroupFilter(selectedGroup)
: createGroupFilter(selectedGroup, groupFieldValue),
[groupFieldValue, isNullGroup, selectedGroup]
: createGroupFilter(selectedGroup, groupFieldValue.asArray),
[groupFieldValue.asArray, isNullGroup, selectedGroup]
);
const onToggle = useCallback(
@ -103,14 +110,14 @@ const GroupPanelComponent = <T,>({
[groupBucket, onToggleGroup]
);
return !groupFieldValue ? null : (
return !groupFieldValue.asString ? null : (
<EuiAccordion
buttonClassName={customAccordionButtonClassName}
buttonContent={
<div data-test-subj="group-panel-toggle" className="groupingPanelRenderer">
{groupPanelRenderer ?? (
<DefaultGroupPanelRenderer
title={groupFieldValue}
title={groupFieldValue.asString}
isNullGroup={isNullGroup}
nullGroupMessage={nullGroupMessage}
/>
@ -123,7 +130,7 @@ const GroupPanelComponent = <T,>({
extraAction={extraAction}
forceState={forceState}
isLoading={isLoading}
id={`group${groupingLevel}-${groupFieldValue}`}
id={`group${groupingLevel}-${groupFieldValue.asString}`}
onToggle={onToggle}
paddingSize="m"
>

View file

@ -10,7 +10,7 @@ import { fireEvent, render, within } from '@testing-library/react';
import React from 'react';
import { I18nProvider } from '@kbn/i18n-react';
import { Grouping } from './grouping';
import { createGroupFilter, getNullGroupFilter } from './accordion_panel/helpers';
import { createGroupFilter, getNullGroupFilter } from '../containers/query/helpers';
import { METRIC_TYPE } from '@kbn/analytics';
import { getTelemetryEvent } from '../telemetry/const';
@ -79,12 +79,12 @@ describe('grouping container', () => {
fireEvent.click(group1);
expect(renderChildComponent).toHaveBeenNthCalledWith(
1,
createGroupFilter(testProps.selectedGroup, host1Name)
createGroupFilter(testProps.selectedGroup, [host1Name])
);
fireEvent.click(group2);
expect(renderChildComponent).toHaveBeenNthCalledWith(
2,
createGroupFilter(testProps.selectedGroup, host2Name)
createGroupFilter(testProps.selectedGroup, [host2Name])
);
});

View file

@ -17,19 +17,19 @@ import type { Filter } from '@kbn/es-query';
import React, { useMemo, useState } from 'react';
import { METRIC_TYPE, UiCounterMetricType } from '@kbn/analytics';
import { defaultUnit, firstNonNullValue } from '../helpers';
import { createGroupFilter, getNullGroupFilter } from './accordion_panel/helpers';
import { createGroupFilter, getNullGroupFilter } from '../containers/query/helpers';
import { GroupPanel } from './accordion_panel';
import { GroupStats } from './accordion_panel/group_stats';
import { EmptyGroupingComponent } from './empty_results_panel';
import { countCss, groupingContainerCss, groupingContainerCssLevel } from './styles';
import { GROUPS_UNIT, NULL_GROUP } from './translations';
import type { GroupingAggregation, GroupPanelRenderer } from './types';
import { GroupStatsRenderer, OnGroupToggle } from './types';
import type { ParsedGroupingAggregation, GroupPanelRenderer } from './types';
import { GroupingBucket, GroupStatsRenderer, OnGroupToggle } from './types';
import { getTelemetryEvent } from '../telemetry/const';
export interface GroupingProps<T> {
activePage: number;
data?: GroupingAggregation<T>;
data?: ParsedGroupingAggregation<T>;
groupPanelRenderer?: GroupPanelRenderer<T>;
groupSelector?: JSX.Element;
// list of custom UI components which correspond to your custom rendered metrics aggregations
@ -92,7 +92,7 @@ const GroupingComponent = <T,>({
const groupPanels = useMemo(
() =>
data?.groupByFields?.buckets?.map((groupBucket, groupNumber) => {
data?.groupByFields?.buckets?.map((groupBucket: GroupingBucket<T>, groupNumber) => {
const group = firstNonNullValue(groupBucket.key);
const groupKey = `group-${groupNumber}-${group}`;
const isNullGroup = groupBucket.isNullGroup ?? false;
@ -112,7 +112,10 @@ const GroupingComponent = <T,>({
groupFilter={
isNullGroup
? getNullGroupFilter(selectedGroup)
: createGroupFilter(selectedGroup, group)
: createGroupFilter(
selectedGroup,
Array.isArray(groupBucket.key) ? groupBucket.key : [groupBucket.key]
)
}
groupNumber={groupNumber}
statRenderers={

View file

@ -6,7 +6,6 @@
* Side Public License, v 1.
*/
// copied from common/search_strategy/common
export interface GenericBuckets {
key: string | string[];
key_as_string?: string; // contains, for example, formatted dates
@ -17,15 +16,16 @@ export const NONE_GROUP_KEY = 'none';
export type RawBucket<T> = GenericBuckets & T;
export type GroupingBucket<T> = RawBucket<T> & {
key: string[];
key_as_string: string;
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<GroupingBucket<T>>;
buckets?: Array<RawBucket<T>>;
};
groupsCount?: {
value?: number | null;
@ -38,6 +38,12 @@ export interface RootAggregation<T> {
};
}
export type ParsedRootAggregation<T> = RootAggregation<T> & {
groupByFields?: {
buckets?: Array<GroupingBucket<T>>;
};
};
export type GroupingFieldTotalAggregation<T> = Record<
string,
{
@ -47,6 +53,8 @@ export type GroupingFieldTotalAggregation<T> = Record<
>;
export type GroupingAggregation<T> = RootAggregation<T> & GroupingFieldTotalAggregation<T>;
export type ParsedGroupingAggregation<T> = ParsedRootAggregation<T> &
GroupingFieldTotalAggregation<T>;
export interface BadgeMetric {
value: number;

View file

@ -0,0 +1,35 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { createGroupFilter } from './helpers';
const selectedGroup = 'host.name';
describe('createGroupFilter', () => {
it('returns an array of Filter objects with correct meta and query properties when values and selectedGroup are truthy', () => {
const values = ['host1', 'host2'];
const result = createGroupFilter(selectedGroup, values);
expect(result).toHaveLength(3);
expect(result[0].meta.key).toBe(selectedGroup);
expect(result[0].query.script.script.params.field).toBe(selectedGroup);
expect(result[0].query.script.script.params.size).toBe(values.length);
expect(result[1].meta.key).toBe(selectedGroup);
expect(result[1].query.match_phrase[selectedGroup].query).toBe(values[0]);
expect(result[2].meta.key).toBe(selectedGroup);
expect(result[2].query.match_phrase[selectedGroup].query).toBe(values[1]);
});
it('returns an empty array when values is an empty array and selectedGroup is truthy', () => {
const result = createGroupFilter(selectedGroup, []);
expect(result).toHaveLength(0);
});
it('returns an empty array when values is null and selectedGroup is truthy', () => {
const result = createGroupFilter(selectedGroup, null);
expect(result).toHaveLength(0);
});
});

View file

@ -6,32 +6,82 @@
* Side Public License, v 1.
*/
import { ES_FIELD_TYPES } from '@kbn/field-types';
/**
* Returns a tuple of values according to the `esType` param, these values are meant to be applied in the _missing_
* property of the query aggregation of the grouping, to look up for missing values in the response buckets.
* These values do not need to be anything in particular, the only requirement is they have to be 2 different values that validate against the field type.
*/
export function getFieldTypeMissingValues(esType: string[]): [number, number] | [string, string] {
const knownType: ES_FIELD_TYPES = esType[0] as ES_FIELD_TYPES;
switch (knownType) {
case ES_FIELD_TYPES.BYTE:
case ES_FIELD_TYPES.DOUBLE:
case ES_FIELD_TYPES.INTEGER:
case ES_FIELD_TYPES.LONG:
case ES_FIELD_TYPES.FLOAT:
case ES_FIELD_TYPES.HALF_FLOAT:
case ES_FIELD_TYPES.SCALED_FLOAT:
case ES_FIELD_TYPES.SHORT:
case ES_FIELD_TYPES.UNSIGNED_LONG:
case ES_FIELD_TYPES.DATE:
case ES_FIELD_TYPES.DATE_NANOS:
return [0, 1];
case ES_FIELD_TYPES.IP:
return ['0.0.0.0', '::'];
default:
return ['-', '--'];
}
}
import { Filter, FILTERS } from '@kbn/es-query';
export const getEmptyValue = () => '—';
type StrictFilter = Filter & {
query: Record<string, any>;
};
export const createGroupFilter = (
selectedGroup: string,
values?: string[] | null
): StrictFilter[] =>
values != null && values.length > 0
? values.reduce(
(acc: StrictFilter[], query) => [
...acc,
{
meta: {
alias: null,
disabled: false,
key: selectedGroup,
negate: false,
params: {
query,
},
type: 'phrase',
},
query: {
match_phrase: {
[selectedGroup]: {
query,
},
},
},
},
],
[
{
meta: {
alias: null,
disabled: false,
type: FILTERS.CUSTOM,
negate: false,
key: selectedGroup,
},
query: {
script: {
script: {
// this will give us an exact match for events with multiple values on the group field
// for example, when values === ['a'], we match events with ['a'], but not ['a', 'b', 'c']
source: "doc[params['field']].size()==params['size']",
params: {
field: selectedGroup,
size: values.length,
},
},
},
},
},
]
)
: [];
export const getNullGroupFilter = (selectedGroup: string): StrictFilter[] => [
{
meta: {
disabled: false,
negate: true,
alias: null,
key: selectedGroup,
value: 'exists',
type: 'exists',
},
query: {
exists: {
field: selectedGroup,
},
},
},
];

View file

@ -10,6 +10,7 @@ import type { GroupingQueryArgs } from './types';
import { getGroupingQuery, parseGroupingQuery } from '.';
import { getEmptyValue } from './helpers';
import { GroupingAggregation } from '../../..';
import { groupingBucket } from '../../mocks';
const testProps: GroupingQueryArgs = {
additionalFilters: [],
@ -55,7 +56,7 @@ const testProps: GroupingQueryArgs = {
pageNumber: 0,
rootAggregations: [],
runtimeMappings: {},
selectedGroupEsTypes: ['keyword'],
uniqueValue: 'whatAGreatAndUniqueValue',
size: 25,
to: '2023-02-23T06:59:59.999Z',
};
@ -63,14 +64,11 @@ describe('group selector', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('Sets multi terms query with missing argument for 2 default values', () => {
it('Sets runtime field and terms query', () => {
const result = getGroupingQuery(testProps);
result.aggs.groupByFields?.multi_terms?.terms.forEach((term, i) => {
expect(term).toEqual({
field: 'host.name',
missing: i === 0 ? '-' : '--',
});
});
expect(result.runtime_mappings.groupByField.script.params.selectedGroup).toEqual('host.name');
expect(result.aggs.groupByFields.aggs).toEqual({
bucket_truncate: { bucket_sort: { from: 0, size: 25 } },
alertsCount: { cardinality: { field: 'kibana.alert.uuid' } },
@ -137,100 +135,120 @@ describe('group selector', () => {
});
expect(result.query.bool.filter.length).toEqual(2);
});
it('Uses 0/1 for number fields', () => {
const result = getGroupingQuery({ ...testProps, selectedGroupEsTypes: ['long'] });
result.aggs.groupByFields?.multi_terms?.terms.forEach((term, i) => {
expect(term).toEqual({
field: 'host.name',
missing: i === 0 ? 0 : 1,
});
});
});
it('Uses 0.0.0.0/:: for ip fields', () => {
const result = getGroupingQuery({ ...testProps, selectedGroupEsTypes: ['ip'] });
result.aggs.groupByFields?.multi_terms?.terms.forEach((term, i) => {
expect(term).toEqual({
field: 'host.name',
missing: i === 0 ? '0.0.0.0' : '::',
});
});
});
const groupingAggs = {
groupByFields: {
buckets: [
{
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,
...groupingBucket,
key: '20.80.64.28',
},
{
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,
...groupingBucket,
key: '0.0.0.0',
},
{
key: ['0.0.0.0', '::'],
key_as_string: '0.0.0.0|::',
selectedGroup: 'source.ip',
doc_count: 75,
...groupingBucket,
key: testProps.uniqueValue,
},
],
},
unitsCount: {
value: 100,
},
unitsCountWithoutNull: {
value: 100,
},
groupsCount: {
value: 20,
},
};
it('parseGroupingQuery finds and flags the null group', () => {
const result = parseGroupingQuery('source.ip', groupingAggs);
const result = parseGroupingQuery('source.ip', testProps.uniqueValue, groupingAggs);
expect(result).toEqual({
groupByFields: {
buckets: [
{
...groupingBucket,
key: ['20.80.64.28'],
key_as_string: '20.80.64.28',
selectedGroup: 'source.ip',
doc_count: 75,
},
{
...groupingBucket,
key: ['0.0.0.0'],
key_as_string: '0.0.0.0',
selectedGroup: 'source.ip',
doc_count: 75,
},
{
...groupingBucket,
key: [getEmptyValue()],
key_as_string: getEmptyValue(),
selectedGroup: 'source.ip',
isNullGroup: true,
doc_count: 75,
},
],
},
unitsCount: {
value: 100,
},
unitsCountWithoutNull: {
value: 100,
},
groupsCount: {
value: 20,
},
});
});
it('parseGroupingQuery adjust group count when null field group is present', () => {
const result: GroupingAggregation<{}> = parseGroupingQuery('source.ip', {
it('parseGroupingQuery parses and formats fields witih multiple values', () => {
const multiValuedAggs = {
...groupingAggs,
unitsCountWithoutNull: { value: 99 },
});
groupByFields: {
buckets: [
{
...groupingBucket,
key: `20.80.64.28${testProps.uniqueValue}0.0.0.0${testProps.uniqueValue}1.1.1.1`,
},
{
...groupingBucket,
key: `0.0.0.0`,
},
{
...groupingBucket,
key: `ip.with,comma${testProps.uniqueValue}ip.without.comma`,
},
],
},
};
const result: GroupingAggregation<{}> = parseGroupingQuery(
'source.ip',
testProps.uniqueValue,
multiValuedAggs
);
expect(result.groupsCount?.value).toEqual(21);
expect(result).toEqual({
groupByFields: {
buckets: [
{
...groupingBucket,
key: ['20.80.64.28', '0.0.0.0', '1.1.1.1'],
key_as_string: '20.80.64.28, 0.0.0.0, 1.1.1.1',
selectedGroup: 'source.ip',
},
{
...groupingBucket,
key: ['0.0.0.0'],
key_as_string: '0.0.0.0',
selectedGroup: 'source.ip',
},
{
...groupingBucket,
key: ['ip.with,comma', 'ip.without.comma'],
key_as_string: 'ip.with,comma, ip.without.comma',
selectedGroup: 'source.ip',
},
],
},
unitsCount: {
value: 100,
},
groupsCount: {
value: 20,
},
});
});
});

View file

@ -6,8 +6,8 @@
* Side Public License, v 1.
*/
import { getEmptyValue, getFieldTypeMissingValues } from './helpers';
import { GroupingAggregation } from '../..';
import { getEmptyValue } from './helpers';
import { GroupingAggregation, ParsedGroupingAggregation } from '../..';
import type { GroupingQueryArgs, GroupingQuery } from './types';
/** The maximum number of groups to render */
export const DEFAULT_GROUP_BY_FIELD_SIZE = 10;
@ -26,11 +26,11 @@ export const MAX_QUERY_SIZE = 10000;
* @param rootAggregations Top level aggregations to get the groups number or overall groups metrics.
* Array of {@link NamedAggregation}
* @param runtimeMappings mappings of runtime fields [see runtimeMappings]{@link GroupingQueryArgs.runtimeMappings}
* @param selectedGroupEsTypes array of selected group types
* @param size number of grouping results per page
* @param sort add one or more sorts on specific fields
* @param statsAggregations group level aggregations which correspond to {@link GroupStatsRenderer} configuration
* @param to ending timestamp
* @param uniqueValue unique value to use for crazy query magic
*
* @returns query dsl {@link GroupingQuery}
*/
@ -42,32 +42,40 @@ export const getGroupingQuery = ({
pageNumber,
rootAggregations,
runtimeMappings,
selectedGroupEsTypes,
size = DEFAULT_GROUP_BY_FIELD_SIZE,
sort,
statsAggregations,
to,
uniqueValue,
}: GroupingQueryArgs): GroupingQuery => ({
size: 0,
runtime_mappings: {
...runtimeMappings,
groupByField: {
type: 'keyword',
script: {
source:
// when size()==0, emits a uniqueValue as the value to represent this group else join by uniqueValue.
"if (doc[params['selectedGroup']].size()==0) { emit(params['uniqueValue']) }" +
// Else, join the values with uniqueValue. We cannot simply emit the value like doc[params['selectedGroup']].value,
// the runtime field will only return the first value in an array.
// The docs advise that if the field has multiple values, "Scripts can call the emit method multiple times to emit multiple values."
// However, this gives us a group for each value instead of combining the values like we're aiming for.
// Instead of .value, we can retrieve all values with .join().
// Instead of joining with a "," we should join with a unique value to avoid splitting a value that happens to contain a ",".
// We will format into a proper array in parseGroupingQuery .
" else { emit(doc[params['selectedGroup']].join(params['uniqueValue']))}",
params: {
selectedGroup: groupByField,
uniqueValue,
},
},
},
},
aggs: {
groupByFields: {
multi_terms: {
terms: [
// by looking up multiple missing values, we can ensure we're not overwriting an existing group with the default value
{
field: groupByField,
// the AggregationsMultiTermLookup type is wrong in the elasticsearch node package
// when this issues is resolved, we can remove these ts expect errors
// https://github.com/elastic/elasticsearch/issues/95628
// @ts-expect-error
missing: getFieldTypeMissingValues(selectedGroupEsTypes)[0],
},
{
field: groupByField,
// @ts-expect-error
missing: getFieldTypeMissingValues(selectedGroupEsTypes)[1],
},
],
terms: {
field: 'groupByField',
size: MAX_QUERY_SIZE,
},
aggs: {
@ -84,14 +92,8 @@ export const getGroupingQuery = ({
},
},
unitsCountWithoutNull: { value_count: { field: groupByField } },
unitsCount: {
value_count: {
field: groupByField,
missing: getFieldTypeMissingValues(selectedGroupEsTypes)[0],
},
},
groupsCount: { cardinality: { field: groupByField } },
unitsCount: { value_count: { field: 'groupByField' } },
groupsCount: { cardinality: { field: 'groupByField' } },
...(rootAggregations
? rootAggregations.reduce((aggObj, subAgg) => Object.assign(aggObj, subAgg), {})
@ -112,7 +114,6 @@ export const getGroupingQuery = ({
],
},
},
runtime_mappings: runtimeMappings,
_source: false,
});
@ -120,46 +121,45 @@ export const getGroupingQuery = ({
* Parses the grouping query response to add the isNullGroup
* flag to the buckets and to format the bucket keys
* @param selectedGroup from the grouping query
* @param aggs aggs returned from the grouping query
* @param uniqueValue from the grouping query
* @param aggs aggregation response from the grouping query
*/
export const parseGroupingQuery = <T>(
selectedGroup: string,
uniqueValue: string,
aggs?: GroupingAggregation<T>
): GroupingAggregation<T> | {} => {
): ParsedGroupingAggregation<T> | {} => {
if (!aggs) {
return {};
}
const groupByFields = aggs?.groupByFields?.buckets?.map((group) => {
if (!Array.isArray(group.key)) {
return group;
}
const emptyValue = getEmptyValue();
// If the keys are different means that the `missing` values of the multi_terms aggregation have been applied, we use the default empty string.
// If the keys are equal means the `missing` values have not been applied, they are stored values.
return group.key[0] === group.key[1]
? {
...group,
key: [group.key[0]],
selectedGroup,
key_as_string: group.key[0],
}
: {
...group,
key: [emptyValue],
selectedGroup,
key_as_string: emptyValue,
isNullGroup: true,
};
if (group.key === uniqueValue) {
return {
...group,
key: [emptyValue],
selectedGroup,
key_as_string: emptyValue,
isNullGroup: true,
};
}
// doing isArray check for TS
// the key won't be an array, runtime fields cannot be multivalued
const groupKey = Array.isArray(group.key) ? group.key[0] : group.key;
const valueAsArray = groupKey.split(uniqueValue);
return {
...group,
key: valueAsArray,
selectedGroup,
key_as_string: valueAsArray.join(', '),
};
});
return {
...aggs,
groupByFields: { buckets: groupByFields },
groupsCount: {
value:
(aggs.unitsCount?.value !== aggs.unitsCountWithoutNull?.value
? (aggs.groupsCount?.value ?? 0) + 1
: aggs.groupsCount?.value) ?? 0,
value: aggs.groupsCount?.value ?? 0,
},
};
};

View file

@ -6,7 +6,11 @@
* Side Public License, v 1.
*/
import type { MappingRuntimeFields } from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import type {
InlineScript,
MappingRuntimeField,
MappingRuntimeFields,
} from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import type { BoolQuery } from '@kbn/es-query';
import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
@ -28,7 +32,7 @@ export interface GroupingQueryArgs {
runtimeMappings?: MappingRuntimeFields;
additionalAggregationsRoot?: NamedAggregation[];
pageNumber?: number;
selectedGroupEsTypes: string[];
uniqueValue: string;
size?: number;
sort?: Array<{ [category: string]: { order: 'asc' | 'desc' } }>;
statsAggregations?: NamedAggregation[];
@ -38,10 +42,18 @@ export interface GroupingQueryArgs {
export interface MainAggregation extends NamedAggregation {
groupByFields: {
aggs: NamedAggregation;
multi_terms: estypes.AggregationsAggregationContainer['multi_terms'];
terms: estypes.AggregationsAggregationContainer['terms'];
};
}
export interface GroupingRuntimeField extends MappingRuntimeField {
script: InlineScript & {
params: Record<string, any>;
};
}
type GroupingMappingRuntimeFields = Record<'groupByField', GroupingRuntimeField>;
export interface GroupingQuery extends estypes.QueryDslQueryContainer {
aggs: MainAggregation;
query: {
@ -49,7 +61,7 @@ export interface GroupingQuery extends estypes.QueryDslQueryContainer {
filter: Array<BoolAgg | RangeAgg>;
};
};
runtime_mappings: MappingRuntimeFields | undefined;
runtime_mappings: MappingRuntimeFields & GroupingMappingRuntimeFields;
size: number;
_source: boolean;
}

View file

@ -0,0 +1,26 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
export const groupingBucket = {
key: '192.168.0.4',
doc_count: 75,
hostsCountAggregation: { value: 1 },
rulesCountAggregation: { value: 32 },
unitsCount: { value: 1920 },
severitiesSubAggregation: {
doc_count_error_upper_bound: 0,
sum_other_doc_count: 0,
buckets: [
{ key: 'critical', doc_count: 480 },
{ key: 'high', doc_count: 480 },
{ key: 'low', doc_count: 480 },
{ key: 'medium', doc_count: 480 },
],
},
countSeveritySubAggregation: { value: 4 },
};

View file

@ -26,6 +26,5 @@
"@kbn/shared-svg",
"@kbn/ui-theme",
"@kbn/analytics",
"@kbn/field-types"
]
}

View file

@ -27,20 +27,30 @@ import { createStore } from '../../../common/store';
import { useKibana as mockUseKibana } from '../../../common/lib/kibana/__mocks__';
import { createTelemetryServiceMock } from '../../../common/lib/telemetry/telemetry_service.mock';
import { useQueryAlerts } from '../../containers/detection_engine/alerts/use_query';
import { groupingSearchResponse } from './grouping_settings/mock';
import { getQuery, groupingSearchResponse } from './grouping_settings/mock';
jest.mock('../../containers/detection_engine/alerts/use_query');
jest.mock('../../../common/containers/sourcerer');
jest.mock('../../../common/utils/normalize_time_range');
jest.mock('../../../common/containers/use_global_time', () => ({
useGlobalTime: jest.fn().mockReturnValue({
from: '2020-07-07T08:20:18.966Z',
isInitializing: false,
to: '2020-07-08T08:20:18.966Z',
setQuery: jest.fn(),
}),
jest.mock('uuid', () => ({
v4: jest.fn().mockReturnValue('test-uuid'),
}));
const mockDate = {
from: '2020-07-07T08:20:18.966Z',
to: '2020-07-08T08:20:18.966Z',
};
const mockUseGlobalTime = jest
.fn()
.mockReturnValue({ ...mockDate, setQuery: jest.fn(), deleteQuery: jest.fn() });
jest.mock('../../../common/containers/use_global_time', () => {
return {
useGlobalTime: (...props: unknown[]) => mockUseGlobalTime(...props),
};
});
const mockOptions = [
{ label: 'ruleName', key: 'kibana.alert.rule.name' },
{ label: 'userName', key: 'user.name' },
@ -105,8 +115,8 @@ const sourcererDataView = {
const renderChildComponent = (groupingFilters: Filter[]) => <p data-test-subj="alerts-table" />;
const testProps: AlertsTableComponentProps = {
...mockDate,
defaultFilters: [],
from: '2020-07-07T08:20:18.966Z',
globalFilters: [],
globalQuery: {
query: 'query',
@ -119,7 +129,6 @@ const testProps: AlertsTableComponentProps = {
runtimeMappings: {},
signalIndexName: 'test',
tableId: TableId.test,
to: '2020-07-08T08:20:18.966Z',
};
const mockUseQueryAlerts = useQueryAlerts as jest.Mock;
@ -220,61 +229,7 @@ describe('GroupedAlertsTable', () => {
);
expect(mockUseQueryAlerts).toHaveBeenLastCalledWith({
indexName: 'test',
query: {
_source: false,
aggs: {
groupByFields: {
aggs: {
bucket_truncate: {
bucket_sort: { from: 0, size: 25, sort: [{ unitsCount: { order: 'desc' } }] },
},
countSeveritySubAggregation: { cardinality: { field: 'kibana.alert.severity' } },
hostsCountAggregation: { cardinality: { field: 'host.name' } },
description: { terms: { field: 'kibana.alert.rule.description', size: 1 } },
ruleTags: { terms: { field: 'kibana.alert.rule.tags' } },
severitiesSubAggregation: { terms: { field: 'kibana.alert.severity' } },
unitsCount: { cardinality: { field: 'kibana.alert.uuid' } },
usersCountAggregation: { cardinality: { field: 'user.name' } },
},
multi_terms: {
size: 10000,
terms: [
{ field: 'kibana.alert.rule.name', missing: '-' },
{ field: 'kibana.alert.rule.name', missing: '--' },
],
},
},
groupsCount: { cardinality: { field: 'kibana.alert.rule.name' } },
unitsCount: {
value_count: {
field: 'kibana.alert.rule.name',
missing: '-',
},
},
unitsCountWithoutNull: {
value_count: {
field: 'kibana.alert.rule.name',
},
},
},
query: {
bool: {
filter: [
{ bool: { filter: [], must: [], must_not: [], should: [] } },
{
range: {
'@timestamp': {
gte: '2020-07-07T08:20:18.966Z',
lte: '2020-07-08T08:20:18.966Z',
},
},
},
],
},
},
runtime_mappings: {},
size: 0,
},
query: getQuery('kibana.alert.rule.name', 'SuperUniqueValue-test-uuid', mockDate),
queryName: 'securitySolutionUI fetchAlerts grouping',
skip: false,
});

View file

@ -16,7 +16,7 @@ import type { DynamicGroupingProps } from '@kbn/securitysolution-grouping/src';
import type { MappingRuntimeFields } from '@elastic/elasticsearch/lib/api/types';
import type { TableIdLiteral } from '@kbn/securitysolution-data-table';
import { parseGroupingQuery } from '@kbn/securitysolution-grouping/src';
import { combineQueries, getFieldEsTypes } from '../../../common/lib/kuery';
import { combineQueries } from '../../../common/lib/kuery';
import { SourcererScopeName } from '../../../common/store/sourcerer/model';
import type { AlertsGroupingAggregation } from './grouping_settings/types';
import type { Status } from '../../../../common/detection_engine/schemas/common';
@ -141,16 +141,14 @@ export const GroupedSubLevelComponent: React.FC<AlertsTableComponentProps> = ({
}
}, [defaultFilters, globalFilters, globalQuery, parentGroupingFilter]);
const selectedGroupEsTypes = useMemo(
() => getFieldEsTypes(selectedGroup, browserFields),
[selectedGroup, browserFields]
);
// create a unique, but stable (across re-renders) value
const uniqueValue = useMemo(() => `SuperUniqueValue-${uuidv4()}`, []);
const queryGroups = useMemo(() => {
return getAlertsGroupingQuery({
additionalFilters,
selectedGroup,
selectedGroupEsTypes,
uniqueValue,
from,
runtimeMappings,
to,
@ -164,8 +162,8 @@ export const GroupedSubLevelComponent: React.FC<AlertsTableComponentProps> = ({
pageSize,
runtimeMappings,
selectedGroup,
selectedGroupEsTypes,
to,
uniqueValue,
]);
const emptyGlobalQuery = useMemo(() => getGlobalQuery([]), [getGlobalQuery]);
@ -201,14 +199,16 @@ export const GroupedSubLevelComponent: React.FC<AlertsTableComponentProps> = ({
parseGroupingQuery(
// fallback to selectedGroup if queriedGroup.current is null, this happens in tests
queriedGroup.current === null ? selectedGroup : queriedGroup.current,
uniqueValue,
alertsGroupsData?.aggregations
),
[alertsGroupsData?.aggregations, selectedGroup]
[alertsGroupsData?.aggregations, selectedGroup, uniqueValue]
);
useEffect(() => {
if (!isNoneGroup([selectedGroup])) {
queriedGroup.current = queryGroups?.aggs?.groupsCount?.cardinality?.field ?? '';
queriedGroup.current =
queryGroups?.runtime_mappings?.groupByField?.script?.params?.selectedGroup ?? '';
setAlertsQuery(queryGroups);
}
}, [queryGroups, selectedGroup, setAlertsQuery]);
@ -255,37 +255,33 @@ export const GroupedSubLevelComponent: React.FC<AlertsTableComponentProps> = ({
() =>
getGrouping({
activePage: pageIndex,
data: {
...alertsGroupsData?.aggregations,
...aggs,
},
data: aggs,
groupingLevel,
inspectButton: inspect,
isLoading: loading || isLoadingGroups,
itemsPerPage: pageSize,
onChangeGroupsItemsPerPage: (size: number) => setPageSize(size),
onChangeGroupsPage: (index) => setPageIndex(index),
renderChildComponent,
onGroupClose,
renderChildComponent,
selectedGroup,
takeActionItems: getTakeActionItems,
}),
[
getGrouping,
pageIndex,
alertsGroupsData,
aggs,
getGrouping,
getTakeActionItems,
groupingLevel,
inspect,
loading,
isLoadingGroups,
loading,
onGroupClose,
pageIndex,
pageSize,
renderChildComponent,
onGroupClose,
selectedGroup,
getTakeActionItems,
setPageSize,
setPageIndex,
setPageSize,
]
);
};

View file

@ -7,6 +7,112 @@
import { mockAlertSearchResponse } from '../../../../common/components/alerts_treemap/lib/mocks/mock_alert_search_response';
export const getQuery = (
selectedGroup: string,
uniqueValue: string,
timeRange: { from: string; to: string }
) => ({
_source: false,
aggs: {
unitsCount: {
value_count: {
field: 'groupByField',
},
},
groupsCount: {
cardinality: {
field: 'groupByField',
},
},
groupByFields: {
aggs: {
unitsCount: {
cardinality: {
field: 'kibana.alert.uuid',
},
},
description: {
terms: {
field: 'kibana.alert.rule.description',
size: 1,
},
},
bucket_truncate: {
bucket_sort: {
from: 0,
size: 25,
sort: [
{
unitsCount: {
order: 'desc',
},
},
],
},
},
countSeveritySubAggregation: {
cardinality: {
field: 'kibana.alert.severity',
},
},
hostsCountAggregation: {
cardinality: {
field: 'host.name',
},
},
ruleTags: {
terms: {
field: 'kibana.alert.rule.tags',
},
},
severitiesSubAggregation: {
terms: {
field: 'kibana.alert.severity',
},
},
usersCountAggregation: {
cardinality: {
field: 'user.name',
},
},
},
terms: {
field: 'groupByField',
size: 10000,
},
},
},
query: {
bool: {
filter: [
{ bool: { filter: [], must: [], must_not: [], should: [] } },
{
range: {
'@timestamp': {
gte: timeRange.from,
lte: timeRange.to,
},
},
},
],
},
},
runtime_mappings: {
groupByField: {
type: 'keyword',
script: {
source:
"if (doc[params['selectedGroup']].size()==0) { emit(params['uniqueValue']) } else { emit(doc[params['selectedGroup']].join(params['uniqueValue']))}",
params: {
selectedGroup,
uniqueValue,
},
},
},
},
size: 0,
});
export const groupingSearchResponse = {
...mockAlertSearchResponse,
hits: {
@ -26,8 +132,8 @@ export const groupingSearchResponse = {
sum_other_doc_count: 0,
buckets: [
{
key: ['critical hosts [Duplicate]', 'critical hosts [Duplicate]'],
key_as_string: 'critical hosts [Duplicate]|critical hosts [Duplicate]',
key: ['critical hosts [Duplicate]'],
key_as_string: 'critical hosts [Duplicate]',
doc_count: 300,
hostsCountAggregation: {
value: 30,
@ -77,9 +183,8 @@ export const groupingSearchResponse = {
},
},
{
key: ['critical hosts [Duplicate] [Duplicate]', 'critical hosts [Duplicate] [Duplicate]'],
key_as_string:
'critical hosts [Duplicate] [Duplicate]|critical hosts [Duplicate] [Duplicate]',
key: ['critical hosts [Duplicate] [Duplicate]'],
key_as_string: 'critical hosts [Duplicate] [Duplicate]',
doc_count: 300,
hostsCountAggregation: {
value: 30,
@ -129,8 +234,8 @@ export const groupingSearchResponse = {
},
},
{
key: ['high hosts [Duplicate]', 'high hosts [Duplicate]'],
key_as_string: 'high hosts [Duplicate]|high hosts [Duplicate]',
key: ['high hosts [Duplicate]'],
key_as_string: 'high hosts [Duplicate]',
doc_count: 300,
hostsCountAggregation: {
value: 30,
@ -180,8 +285,8 @@ export const groupingSearchResponse = {
},
},
{
key: ['high hosts [Duplicate] [Duplicate]', 'high hosts [Duplicate] [Duplicate]'],
key_as_string: 'high hosts [Duplicate] [Duplicate]|high hosts [Duplicate] [Duplicate]',
key: ['high hosts [Duplicate] [Duplicate]'],
key_as_string: 'high hosts [Duplicate] [Duplicate]',
doc_count: 300,
hostsCountAggregation: {
value: 30,
@ -231,8 +336,8 @@ export const groupingSearchResponse = {
},
},
{
key: ['low hosts [Duplicate]', 'low hosts [Duplicate]'],
key_as_string: 'low hosts [Duplicate]|low hosts [Duplicate]',
key: ['low hosts [Duplicate]'],
key_as_string: 'low hosts [Duplicate]',
doc_count: 300,
hostsCountAggregation: {
value: 30,
@ -282,8 +387,8 @@ export const groupingSearchResponse = {
},
},
{
key: ['low hosts [Duplicate] [Duplicate]', 'low hosts [Duplicate] [Duplicate]'],
key_as_string: 'low hosts [Duplicate] [Duplicate]|low hosts [Duplicate] [Duplicate]',
key: ['low hosts [Duplicate] [Duplicate]'],
key_as_string: 'low hosts [Duplicate] [Duplicate]',
doc_count: 300,
hostsCountAggregation: {
value: 30,
@ -333,8 +438,8 @@ export const groupingSearchResponse = {
},
},
{
key: ['medium hosts [Duplicate]', 'medium hosts [Duplicate]'],
key_as_string: 'medium hosts [Duplicate]|medium hosts [Duplicate]',
key: ['medium hosts [Duplicate]'],
key_as_string: 'medium hosts [Duplicate]',
doc_count: 300,
hostsCountAggregation: {
value: 30,
@ -384,9 +489,8 @@ export const groupingSearchResponse = {
},
},
{
key: ['medium hosts [Duplicate] [Duplicate]', 'medium hosts [Duplicate] [Duplicate]'],
key_as_string:
'medium hosts [Duplicate] [Duplicate]|medium hosts [Duplicate] [Duplicate]',
key: ['medium hosts [Duplicate] [Duplicate]'],
key_as_string: 'medium hosts [Duplicate] [Duplicate]',
doc_count: 300,
hostsCountAggregation: {
value: 30,
@ -436,8 +540,8 @@ export const groupingSearchResponse = {
},
},
{
key: ['critical users [Duplicate]', 'critical users [Duplicate]'],
key_as_string: 'critical users [Duplicate]|critical users [Duplicate]',
key: ['critical users [Duplicate]'],
key_as_string: 'critical users [Duplicate]',
doc_count: 273,
hostsCountAggregation: {
value: 10,
@ -487,12 +591,8 @@ export const groupingSearchResponse = {
},
},
{
key: [
'critical users [Duplicate] [Duplicate]',
'critical users [Duplicate] [Duplicate]',
],
key_as_string:
'critical users [Duplicate] [Duplicate]|critical users [Duplicate] [Duplicate]',
key: ['critical users [Duplicate] [Duplicate]'],
key_as_string: 'critical users [Duplicate] [Duplicate]',
doc_count: 273,
hostsCountAggregation: {
value: 10,
@ -542,8 +642,8 @@ export const groupingSearchResponse = {
},
},
{
key: ['high users [Duplicate]', 'high users [Duplicate]'],
key_as_string: 'high users [Duplicate]|high users [Duplicate]',
key: ['high users [Duplicate]'],
key_as_string: 'high users [Duplicate]',
doc_count: 273,
hostsCountAggregation: {
value: 10,
@ -593,8 +693,8 @@ export const groupingSearchResponse = {
},
},
{
key: ['high users [Duplicate] [Duplicate]', 'high users [Duplicate] [Duplicate]'],
key_as_string: 'high users [Duplicate] [Duplicate]|high users [Duplicate] [Duplicate]',
key: ['high users [Duplicate] [Duplicate]'],
key_as_string: 'high users [Duplicate] [Duplicate]',
doc_count: 273,
hostsCountAggregation: {
value: 10,
@ -644,8 +744,8 @@ export const groupingSearchResponse = {
},
},
{
key: ['low users [Duplicate]', 'low users [Duplicate]'],
key_as_string: 'low users [Duplicate]|low users [Duplicate]',
key: ['low users [Duplicate]'],
key_as_string: 'low users [Duplicate]',
doc_count: 273,
hostsCountAggregation: {
value: 10,
@ -695,8 +795,8 @@ export const groupingSearchResponse = {
},
},
{
key: ['low users [Duplicate] [Duplicate]', 'low users [Duplicate] [Duplicate]'],
key_as_string: 'low users [Duplicate] [Duplicate]|low users [Duplicate] [Duplicate]',
key: ['low users [Duplicate] [Duplicate]'],
key_as_string: 'low users [Duplicate] [Duplicate]',
doc_count: 273,
hostsCountAggregation: {
value: 10,
@ -746,8 +846,8 @@ export const groupingSearchResponse = {
},
},
{
key: ['medium users [Duplicate]', 'medium users [Duplicate]'],
key_as_string: 'medium users [Duplicate]|medium users [Duplicate]',
key: ['medium users [Duplicate]'],
key_as_string: 'medium users [Duplicate]',
doc_count: 273,
hostsCountAggregation: {
value: 10,
@ -797,9 +897,8 @@ export const groupingSearchResponse = {
},
},
{
key: ['medium users [Duplicate] [Duplicate]', 'medium users [Duplicate] [Duplicate]'],
key_as_string:
'medium users [Duplicate] [Duplicate]|medium users [Duplicate] [Duplicate]',
key: ['medium users [Duplicate] [Duplicate]'],
key_as_string: 'medium users [Duplicate] [Duplicate]',
doc_count: 273,
hostsCountAggregation: {
value: 10,
@ -849,8 +948,8 @@ export const groupingSearchResponse = {
},
},
{
key: ['critical hosts', 'critical hosts'],
key_as_string: 'critical hosts|critical hosts',
key: ['critical hosts'],
key_as_string: 'critical hosts',
doc_count: 100,
hostsCountAggregation: {
value: 30,
@ -900,12 +999,8 @@ export const groupingSearchResponse = {
},
},
{
key: [
'critical hosts [Duplicate] [Duplicate] [Duplicate]',
'critical hosts [Duplicate] [Duplicate] [Duplicate]',
],
key_as_string:
'critical hosts [Duplicate] [Duplicate] [Duplicate]|critical hosts [Duplicate] [Duplicate] [Duplicate]',
key: ['critical hosts [Duplicate] [Duplicate] [Duplicate]'],
key_as_string: 'critical hosts [Duplicate] [Duplicate] [Duplicate]',
doc_count: 100,
hostsCountAggregation: {
value: 30,
@ -955,8 +1050,8 @@ export const groupingSearchResponse = {
},
},
{
key: ['high hosts', 'high hosts'],
key_as_string: 'high hosts|high hosts',
key: ['high hosts'],
key_as_string: 'high hosts',
doc_count: 100,
hostsCountAggregation: {
value: 30,
@ -1006,12 +1101,8 @@ export const groupingSearchResponse = {
},
},
{
key: [
'high hosts [Duplicate] [Duplicate] [Duplicate]',
'high hosts [Duplicate] [Duplicate] [Duplicate]',
],
key_as_string:
'high hosts [Duplicate] [Duplicate] [Duplicate]|high hosts [Duplicate] [Duplicate] [Duplicate]',
key: ['high hosts [Duplicate] [Duplicate] [Duplicate]'],
key_as_string: 'high hosts [Duplicate] [Duplicate] [Duplicate]',
doc_count: 100,
hostsCountAggregation: {
value: 30,
@ -1061,8 +1152,8 @@ export const groupingSearchResponse = {
},
},
{
key: ['low hosts ', 'low hosts '],
key_as_string: 'low hosts |low hosts ',
key: ['low hosts '],
key_as_string: 'low hosts ',
doc_count: 100,
hostsCountAggregation: {
value: 30,
@ -1112,12 +1203,8 @@ export const groupingSearchResponse = {
},
},
{
key: [
'low hosts [Duplicate] [Duplicate] [Duplicate]',
'low hosts [Duplicate] [Duplicate] [Duplicate]',
],
key_as_string:
'low hosts [Duplicate] [Duplicate] [Duplicate]|low hosts [Duplicate] [Duplicate] [Duplicate]',
key: ['low hosts [Duplicate] [Duplicate] [Duplicate]'],
key_as_string: 'low hosts [Duplicate] [Duplicate] [Duplicate]',
doc_count: 100,
hostsCountAggregation: {
value: 30,
@ -1167,8 +1254,8 @@ export const groupingSearchResponse = {
},
},
{
key: ['medium hosts', 'medium hosts'],
key_as_string: 'medium hosts|medium hosts',
key: ['medium hosts'],
key_as_string: 'medium hosts',
doc_count: 100,
hostsCountAggregation: {
value: 30,
@ -1218,12 +1305,8 @@ export const groupingSearchResponse = {
},
},
{
key: [
'medium hosts [Duplicate] [Duplicate] [Duplicate]',
'medium hosts [Duplicate] [Duplicate] [Duplicate]',
],
key_as_string:
'medium hosts [Duplicate] [Duplicate] [Duplicate]|medium hosts [Duplicate] [Duplicate] [Duplicate]',
key: ['medium hosts [Duplicate] [Duplicate] [Duplicate]'],
key_as_string: 'medium hosts [Duplicate] [Duplicate] [Duplicate]',
doc_count: 100,
hostsCountAggregation: {
value: 30,
@ -1273,12 +1356,8 @@ export const groupingSearchResponse = {
},
},
{
key: [
'critical users [Duplicate] [Duplicate] [Duplicate]',
'critical users [Duplicate] [Duplicate] [Duplicate]',
],
key_as_string:
'critical users [Duplicate] [Duplicate] [Duplicate]|critical users [Duplicate] [Duplicate] [Duplicate]',
key: ['critical users [Duplicate] [Duplicate] [Duplicate]'],
key_as_string: 'critical users [Duplicate] [Duplicate] [Duplicate]',
doc_count: 91,
hostsCountAggregation: {
value: 10,

View file

@ -6,295 +6,55 @@
*/
import { getAlertsGroupingQuery } from '.';
import { getQuery } from './mock';
let sampleData = {
from: '2022-12-29T22:57:34.029Z',
to: '2023-01-28T22:57:29.029Z',
pageIndex: 0,
pageSize: 25,
runtimeMappings: {},
uniqueValue: 'aSuperUniqueValue',
selectedGroup: 'kibana.alert.rule.name',
additionalFilters: [{ bool: { filter: [], must: [], must_not: [], should: [] } }],
};
describe('getAlertsGroupingQuery', () => {
it('returns query with aggregations for kibana.alert.rule.name', () => {
const groupingQuery = getAlertsGroupingQuery({
from: '2022-12-29T22:57:34.029Z',
to: '2023-01-28T22:57:29.029Z',
pageIndex: 0,
pageSize: 25,
runtimeMappings: {},
selectedGroupEsTypes: ['keyword'],
selectedGroup: 'kibana.alert.rule.name',
additionalFilters: [
{
bool: {
must: [],
filter: [
{
match_phrase: {
'kibana.alert.workflow_status': 'acknowledged',
},
},
],
should: [],
must_not: [
{
exists: {
field: 'kibana.alert.building_block_type',
},
},
],
},
},
],
});
expect(groupingQuery).toStrictEqual({
_source: false,
aggs: {
unitsCount: {
value_count: {
field: 'kibana.alert.rule.name',
missing: '-',
},
},
unitsCountWithoutNull: {
value_count: {
field: 'kibana.alert.rule.name',
},
},
groupsCount: {
cardinality: {
field: 'kibana.alert.rule.name',
},
},
groupByFields: {
aggs: {
unitsCount: {
cardinality: {
field: 'kibana.alert.uuid',
},
},
description: {
terms: {
field: 'kibana.alert.rule.description',
size: 1,
},
},
bucket_truncate: {
bucket_sort: {
from: 0,
size: 25,
sort: [
{
unitsCount: {
order: 'desc',
},
},
],
},
},
countSeveritySubAggregation: {
cardinality: {
field: 'kibana.alert.severity',
},
},
hostsCountAggregation: {
cardinality: {
field: 'host.name',
},
},
ruleTags: {
terms: {
field: 'kibana.alert.rule.tags',
},
},
severitiesSubAggregation: {
terms: {
field: 'kibana.alert.severity',
},
},
usersCountAggregation: {
cardinality: {
field: 'user.name',
},
},
},
multi_terms: {
size: 10000,
terms: [
{
field: 'kibana.alert.rule.name',
missing: '-',
},
{
field: 'kibana.alert.rule.name',
missing: '--',
},
],
},
},
},
query: {
bool: {
filter: [
{
bool: {
filter: [
{
match_phrase: {
'kibana.alert.workflow_status': 'acknowledged',
},
},
],
must: [],
must_not: [
{
exists: {
field: 'kibana.alert.building_block_type',
},
},
],
should: [],
},
},
{
range: {
'@timestamp': {
gte: '2022-12-29T22:57:34.029Z',
lte: '2023-01-28T22:57:29.029Z',
},
},
},
],
},
},
runtime_mappings: {},
size: 0,
});
const groupingQuery = getAlertsGroupingQuery(sampleData);
expect(groupingQuery).toStrictEqual(
getQuery(sampleData.selectedGroup, sampleData.uniqueValue, {
from: sampleData.from,
to: sampleData.to,
})
);
});
it('returns default query with aggregations if the field specific metrics was not defined', () => {
const groupingQuery = getAlertsGroupingQuery({
from: '2022-12-29T22:57:34.029Z',
to: '2023-01-28T22:57:29.029Z',
pageIndex: 0,
pageSize: 25,
runtimeMappings: {},
selectedGroupEsTypes: ['keyword'],
sampleData = {
...sampleData,
selectedGroup: 'process.name',
additionalFilters: [
{
bool: {
must: [],
filter: [
{
match_phrase: {
'kibana.alert.workflow_status': 'acknowledged',
},
},
],
should: [],
must_not: [
{
exists: {
field: 'kibana.alert.building_block_type',
},
},
],
},
},
],
};
const groupingQuery = getAlertsGroupingQuery(sampleData);
const expectedResult = getQuery(sampleData.selectedGroup, sampleData.uniqueValue, {
from: sampleData.from,
to: sampleData.to,
});
const { unitsCount, bucket_truncate: bucketTruncate } = expectedResult.aggs.groupByFields.aggs;
expect(groupingQuery).toStrictEqual({
_source: false,
...expectedResult,
aggs: {
unitsCount: {
value_count: {
field: 'process.name',
missing: '-',
},
},
unitsCountWithoutNull: {
value_count: {
field: 'process.name',
},
},
groupsCount: {
cardinality: {
field: 'process.name',
},
},
...expectedResult.aggs,
groupByFields: {
...expectedResult.aggs.groupByFields,
aggs: {
unitsCount: {
cardinality: {
field: 'kibana.alert.uuid',
},
},
bucket_truncate: {
bucket_sort: {
from: 0,
size: 25,
sort: [
{
unitsCount: {
order: 'desc',
},
},
],
},
},
rulesCountAggregation: {
cardinality: {
field: 'kibana.alert.rule.rule_id',
},
},
},
multi_terms: {
size: 10000,
terms: [
{
field: 'process.name',
missing: '-',
},
{
field: 'process.name',
missing: '--',
},
],
bucket_truncate: bucketTruncate,
unitsCount,
rulesCountAggregation: { cardinality: { field: 'kibana.alert.rule.rule_id' } },
},
},
},
query: {
bool: {
filter: [
{
bool: {
filter: [
{
match_phrase: {
'kibana.alert.workflow_status': 'acknowledged',
},
},
],
must: [],
must_not: [
{
exists: {
field: 'kibana.alert.building_block_type',
},
},
],
should: [],
},
},
{
range: {
'@timestamp': {
gte: '2022-12-29T22:57:34.029Z',
lte: '2023-01-28T22:57:29.029Z',
},
},
},
],
},
},
runtime_mappings: {},
size: 0,
});
});
});

View file

@ -19,7 +19,7 @@ interface AlertsGroupingQueryParams {
pageSize: number;
runtimeMappings: MappingRuntimeFields;
selectedGroup: string;
selectedGroupEsTypes: string[];
uniqueValue: string;
to: string;
}
@ -30,7 +30,7 @@ export const getAlertsGroupingQuery = ({
pageSize,
runtimeMappings,
selectedGroup,
selectedGroupEsTypes,
uniqueValue,
to,
}: AlertsGroupingQueryParams) =>
getGroupingQuery({
@ -42,7 +42,7 @@ export const getAlertsGroupingQuery = ({
: [],
pageNumber: pageIndex * pageSize,
runtimeMappings,
selectedGroupEsTypes,
uniqueValue,
size: pageSize,
sort: [{ unitsCount: { order: 'desc' } }],
to,