[SECURITY SOLUTIONS] Bug top-n alerts (#94920)

* Associate timeline filter/query/dataprovider to top-n for alerts events

* fix pinned view when opening details panel

* fix top-n to bring the right raw/all indices

* review + do not add filter/query/dataprovider on Correlation/Pinned tab for topN

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Xavier Mouligneau 2021-03-23 13:36:46 -04:00 committed by GitHub
parent edb9453a83
commit 8a42049acb
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 225 additions and 27 deletions

View file

@ -134,7 +134,7 @@ const DraggableWrapperHoverContentComponent: React.FC<Props> = ({
)
? SourcererScopeName.detections
: SourcererScopeName.default;
const { browserFields, indexPattern, selectedPatterns } = useSourcererScope(activeScope);
const { browserFields, indexPattern } = useSourcererScope(activeScope);
const handleStartDragToTimeline = useCallback(() => {
startDragToTimeline();
if (closePopOver != null) {
@ -365,7 +365,6 @@ const DraggableWrapperHoverContentComponent: React.FC<Props> = ({
browserFields={browserFields}
field={field}
indexPattern={indexPattern}
indexNames={selectedPatterns}
onFilterAdded={onFilterAdded}
timelineId={timelineId ?? undefined}
toggleTopN={toggleTopN}

View file

@ -168,7 +168,6 @@ const store = createStore(
let testProps = {
browserFields: mockBrowserFields,
field,
indexNames: [],
indexPattern: mockIndexPattern,
timelineId: TimelineId.hostsPageExternalAlerts,
toggleTopN: jest.fn(),

View file

@ -25,7 +25,7 @@ import { combineQueries } from '../../../timelines/components/timeline/helpers';
import { getOptions } from './helpers';
import { TopN } from './top_n';
import { TimelineId } from '../../../../common/types/timeline';
import { TimelineId, TimelineTabs } from '../../../../common/types/timeline';
const EMPTY_FILTERS: Filter[] = [];
const EMPTY_QUERY: Query = { query: '', language: 'kuery' };
@ -47,11 +47,16 @@ const makeMapStateToProps = () => {
return {
activeTimelineEventType: activeTimeline.eventType,
activeTimelineFilters,
activeTimelineFilters:
activeTimeline.activeTab === TimelineTabs.query ? activeTimelineFilters : EMPTY_FILTERS,
activeTimelineFrom: activeTimelineInput.timerange.from,
activeTimelineKqlQueryExpression: getKqlQueryTimeline(state, TimelineId.active),
activeTimelineKqlQueryExpression:
activeTimeline.activeTab === TimelineTabs.query
? getKqlQueryTimeline(state, TimelineId.active)
: null,
activeTimelineTo: activeTimelineInput.timerange.to,
dataProviders: activeTimeline.dataProviders,
dataProviders:
activeTimeline.activeTab === TimelineTabs.query ? activeTimeline.dataProviders : [],
globalQuery: getGlobalQuerySelector(state),
globalFilters: getGlobalFiltersQuerySelector(state),
kqlMode: activeTimeline.kqlMode,
@ -72,7 +77,6 @@ interface OwnProps {
browserFields: BrowserFields;
field: string;
indexPattern: IIndexPattern;
indexNames: string[];
timelineId?: string;
toggleTopN: () => void;
onFilterAdded?: () => void;
@ -91,7 +95,6 @@ const StatefulTopNComponent: React.FC<Props> = ({
dataProviders,
field,
indexPattern,
indexNames,
globalFilters = EMPTY_FILTERS,
globalQuery = EMPTY_QUERY,
kqlMode,
@ -154,7 +157,6 @@ const StatefulTopNComponent: React.FC<Props> = ({
filters={timelineId === TimelineId.active ? EMPTY_FILTERS : globalFilters}
from={timelineId === TimelineId.active ? activeTimelineFrom : from}
indexPattern={indexPattern}
indexNames={indexNames}
options={options}
query={timelineId === TimelineId.active ? EMPTY_QUERY : globalQuery}
setAbsoluteRangeDatePickerTarget={timelineId === TimelineId.active ? 'timeline' : 'global'}

View file

@ -0,0 +1,38 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { State } from '../../store';
import { sourcererSelectors } from '../../store/selectors';
export interface IndicesSelector {
all: string[];
raw: string[];
}
export const getIndicesSelector = () => {
const getkibanaIndexPatternsSelector = sourcererSelectors.kibanaIndexPatternsSelector();
const getConfigIndexPatternsSelector = sourcererSelectors.configIndexPatternsSelector();
const getSignalIndexNameSelector = sourcererSelectors.signalIndexNameSelector();
const mapStateToProps = (state: State): IndicesSelector => {
const rawIndices = new Set(getConfigIndexPatternsSelector(state));
const kibanaIndexPatterns = getkibanaIndexPatternsSelector(state);
const alertIndexName = getSignalIndexNameSelector(state);
kibanaIndexPatterns.forEach(({ title }) => {
if (title !== alertIndexName) {
rawIndices.add(title);
}
});
return {
all: alertIndexName != null ? [...rawIndices, alertIndexName] : [...rawIndices],
raw: [...rawIndices],
};
};
return mapStateToProps;
};

View file

@ -101,7 +101,6 @@ describe('TopN', () => {
field,
filters: [],
from: '2020-04-14T00:31:47.695Z',
indexNames: [],
indexPattern: mockIndexPattern,
options: defaultOptions,
query,

View file

@ -6,7 +6,9 @@
*/
import { EuiButtonIcon, EuiSuperSelect } from '@elastic/eui';
import deepEqual from 'fast-deep-equal';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useSelector } from 'react-redux';
import styled from 'styled-components';
import { GlobalTimeArgs } from '../../containers/use_global_time';
@ -18,6 +20,8 @@ import { TimelineEventsType } from '../../../../common/types/timeline';
import { TopNOption } from './helpers';
import * as i18n from './translations';
import { getIndicesSelector, IndicesSelector } from './selectors';
import { State } from '../../store';
const TopNContainer = styled.div`
width: 600px;
@ -49,7 +53,6 @@ export interface Props extends Pick<GlobalTimeArgs, 'from' | 'to' | 'deleteQuery
field: string;
filters: Filter[];
indexPattern: IIndexPattern;
indexNames: string[];
options: TopNOption[];
query: Query;
setAbsoluteRangeDatePickerTarget: InputsModelId;
@ -67,7 +70,6 @@ const TopNComponent: React.FC<Props> = ({
field,
from,
indexPattern,
indexNames,
options,
query,
setAbsoluteRangeDatePickerTarget,
@ -80,6 +82,11 @@ const TopNComponent: React.FC<Props> = ({
const onViewSelected = useCallback((value: string) => setView(value as TimelineEventsType), [
setView,
]);
const indicesSelector = useMemo(getIndicesSelector, []);
const { all: allIndices, raw: rawIndices } = useSelector<State, IndicesSelector>(
(state) => indicesSelector(state),
deepEqual
);
useEffect(() => {
setView(defaultView);
@ -116,7 +123,7 @@ const TopNComponent: React.FC<Props> = ({
from={from}
headerChildren={headerChildren}
indexPattern={indexPattern}
indexNames={indexNames}
indexNames={view === 'raw' ? rawIndices : allIndices}
onlyField={field}
query={query}
setAbsoluteRangeDatePickerTarget={setAbsoluteRangeDatePickerTarget}
@ -127,6 +134,7 @@ const TopNComponent: React.FC<Props> = ({
/>
) : (
<SignalsByCategory
combinedQueries={combinedQueries}
filters={filters}
from={from}
headerChildren={headerChildren}

View file

@ -12,7 +12,8 @@ import { shallow, mount } from 'enzyme';
import '../../../common/mock/match_media';
import { esQuery } from '../../../../../../../src/plugins/data/public';
import { TestProviders } from '../../../common/mock';
import { AlertsHistogramPanel } from './index';
import { AlertsHistogramPanel, buildCombinedQueries, parseCombinedQueries } from './index';
import * as helpers from './helpers';
jest.mock('react-router-dom', () => {
const originalModule = jest.requireActual('react-router-dom');
@ -104,4 +105,125 @@ describe('AlertsHistogramPanel', () => {
});
});
});
describe('CombinedQueries', () => {
jest.mock('./helpers');
const mockGetAlertsHistogramQuery = jest.spyOn(helpers, 'getAlertsHistogramQuery');
beforeEach(() => {
mockGetAlertsHistogramQuery.mockReset();
});
it('combinedQueries props is valid, alerts query include combinedQueries', async () => {
const props = {
...defaultProps,
query: { query: 'host.name: "', language: 'kql' },
combinedQueries:
'{"bool":{"must":[],"filter":[{"match_all":{}},{"exists":{"field":"process.name"}}],"should":[],"must_not":[]}}',
};
mount(
<TestProviders>
<AlertsHistogramPanel {...props} />
</TestProviders>
);
await waitFor(() => {
expect(mockGetAlertsHistogramQuery.mock.calls[0]).toEqual([
'signal.rule.name',
'2020-07-07T08:20:18.966Z',
'2020-07-08T08:20:18.966Z',
[
{
bool: {
filter: [{ match_all: {} }, { exists: { field: 'process.name' } }],
must: [],
must_not: [],
should: [],
},
},
],
]);
});
});
});
describe('parseCombinedQueries', () => {
it('return empty object when variables is undefined', async () => {
expect(parseCombinedQueries(undefined)).toEqual({});
});
it('return empty object when variables is empty string', async () => {
expect(parseCombinedQueries('')).toEqual({});
});
it('return empty object when variables is NOT a valid stringify json object', async () => {
expect(parseCombinedQueries('hello world')).toEqual({});
});
it('return a valid json object when variables is a valid json stringify', async () => {
expect(
parseCombinedQueries(
'{"bool":{"must":[],"filter":[{"match_all":{}},{"exists":{"field":"process.name"}}],"should":[],"must_not":[]}}'
)
).toMatchInlineSnapshot(`
Object {
"bool": Object {
"filter": Array [
Object {
"match_all": Object {},
},
Object {
"exists": Object {
"field": "process.name",
},
},
],
"must": Array [],
"must_not": Array [],
"should": Array [],
},
}
`);
});
});
describe('buildCombinedQueries', () => {
it('return empty array when variables is undefined', async () => {
expect(buildCombinedQueries(undefined)).toEqual([]);
});
it('return empty array when variables is empty string', async () => {
expect(buildCombinedQueries('')).toEqual([]);
});
it('return array with empty object when variables is NOT a valid stringify json object', async () => {
expect(buildCombinedQueries('hello world')).toEqual([{}]);
});
it('return a valid json object when variables is a valid json stringify', async () => {
expect(
buildCombinedQueries(
'{"bool":{"must":[],"filter":[{"match_all":{}},{"exists":{"field":"process.name"}}],"should":[],"must_not":[]}}'
)
).toMatchInlineSnapshot(`
Array [
Object {
"bool": Object {
"filter": Array [
Object {
"match_all": Object {},
},
Object {
"exists": Object {
"field": "process.name",
},
},
],
"must": Array [],
"must_not": Array [],
"should": Array [],
},
},
]
`);
});
});
});

View file

@ -58,6 +58,7 @@ const ViewAlertsFlexItem = styled(EuiFlexItem)`
interface AlertsHistogramPanelProps
extends Pick<GlobalTimeArgs, 'from' | 'to' | 'setQuery' | 'deleteQuery'> {
chartHeight?: number;
combinedQueries?: string;
defaultStackByOption?: AlertsHistogramOption;
filters?: Filter[];
headerChildren?: React.ReactNode;
@ -86,9 +87,26 @@ const DEFAULT_STACK_BY = 'signal.rule.name';
const getDefaultStackByOption = (): AlertsHistogramOption =>
alertsHistogramOptions.find(({ text }) => text === DEFAULT_STACK_BY) ?? alertsHistogramOptions[0];
export const parseCombinedQueries = (query?: string) => {
try {
return query != null && !isEmpty(query) ? JSON.parse(query) : {};
} catch {
return {};
}
};
export const buildCombinedQueries = (query?: string) => {
try {
return isEmpty(query) ? [] : [parseCombinedQueries(query)];
} catch {
return [];
}
};
export const AlertsHistogramPanel = memo<AlertsHistogramPanelProps>(
({
chartHeight,
combinedQueries,
defaultStackByOption = getDefaultStackByOption(),
deleteQuery,
filters,
@ -124,7 +142,12 @@ export const AlertsHistogramPanel = memo<AlertsHistogramPanelProps>(
request,
refetch,
} = useQueryAlerts<{}, AlertsAggregation>(
getAlertsHistogramQuery(selectedStackByOption.value, from, to, []),
getAlertsHistogramQuery(
selectedStackByOption.value,
from,
to,
buildCombinedQueries(combinedQueries)
),
signalIndexName
);
const kibana = useKibana();
@ -223,15 +246,20 @@ export const AlertsHistogramPanel = memo<AlertsHistogramPanelProps>(
useEffect(() => {
try {
const converted = esQuery.buildEsQuery(
undefined,
query != null ? [query] : [],
filters?.filter((f) => f.meta.disabled === false) ?? [],
{
...esQuery.getEsQueryConfig(kibana.services.uiSettings),
dateFormatTZ: undefined,
}
);
let converted = null;
if (combinedQueries != null) {
converted = parseCombinedQueries(combinedQueries);
} else {
converted = esQuery.buildEsQuery(
undefined,
query != null ? [query] : [],
filters?.filter((f) => f.meta.disabled === false) ?? [],
{
...esQuery.getEsQueryConfig(kibana.services.uiSettings),
dateFormatTZ: undefined,
}
);
}
setAlertsQuery(
getAlertsHistogramQuery(
@ -245,7 +273,7 @@ export const AlertsHistogramPanel = memo<AlertsHistogramPanelProps>(
setAlertsQuery(getAlertsHistogramQuery(selectedStackByOption.value, from, to, []));
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedStackByOption.value, from, to, query, filters]);
}, [selectedStackByOption.value, from, to, query, filters, combinedQueries]);
const linkButton = useMemo(() => {
if (showLinkToAlerts) {

View file

@ -19,6 +19,7 @@ import { UpdateDateRange } from '../../../common/components/charts/common';
import { GlobalTimeArgs } from '../../../common/containers/use_global_time';
interface Props extends Pick<GlobalTimeArgs, 'from' | 'to' | 'deleteQuery' | 'setQuery'> {
combinedQueries?: string;
filters?: Filter[];
headerChildren?: React.ReactNode;
/** Override all defaults, and only display this field */
@ -29,6 +30,7 @@ interface Props extends Pick<GlobalTimeArgs, 'from' | 'to' | 'deleteQuery' | 'se
}
const SignalsByCategoryComponent: React.FC<Props> = ({
combinedQueries,
deleteQuery,
filters,
from,
@ -61,6 +63,7 @@ const SignalsByCategoryComponent: React.FC<Props> = ({
return (
<AlertsHistogramPanel
combinedQueries={combinedQueries}
deleteQuery={deleteQuery}
filters={filters}
from={from}

View file

@ -198,7 +198,7 @@ export const PinnedTabContentComponent: React.FC<Props> = ({
return (
<>
<FullWidthFlexGroup data-test-subj={`${TimelineTabs.pinned}-tab`} direction="column">
<FullWidthFlexGroup data-test-subj={`${TimelineTabs.pinned}-tab`}>
{timelineFullScreen && setTimelineFullScreen != null && (
<ExitFullScreenFlexItem grow={false}>
<ExitFullScreen fullScreen={timelineFullScreen} setFullScreen={setTimelineFullScreen} />