[Security Solution][Detections] - rule query preview bug fix (#80750) (#81196)

### Summary 

This PR addresses the remaining query preview bugs. 

- it adds index, and request information to eql inspect - it seems that for some reason the eql search strategy response returns `null` for the `params.body` in complete responses, but not in partial responses and does not include index info. As a workaround, I set the inspect info on partial responses and manually add index info
  - added to-dos pointing this out in the code
- updated eql sequence queries preview to use the last event timestamp of a sequence to display the hits within a histogram
- it checks buckets length to determine noise warning for threshold rules, as opposed to total hit count
- remove unused i18n text
- fixes bug where threshold is being passed in for all rule types as it's always defined in the creation step, added a check to only pass through to `useMatrixHistogram` hook when rule type is threshold
This commit is contained in:
Yara Tercero 2020-10-20 13:56:07 -04:00 committed by GitHub
parent b034db9ed7
commit a92a3d1af7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 1186 additions and 590 deletions

View file

@ -0,0 +1,178 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { EqlSearchStrategyResponse } from '../../../../../data_enhanced/common';
import { Source } from './types';
import { EqlSearchResponse } from '../../../../common/detection_engine/types';
import { Connection } from '@elastic/elasticsearch';
export const getMockEqlResponse = (): EqlSearchStrategyResponse<EqlSearchResponse<Source>> => ({
id: 'some-id',
rawResponse: {
body: {
hits: {
events: [
{
_index: 'index',
_id: '1',
_source: {
'@timestamp': '2020-10-04T15:16:54.368707900Z',
},
},
{
_index: 'index',
_id: '2',
_source: {
'@timestamp': '2020-10-04T15:50:54.368707900Z',
},
},
{
_index: 'index',
_id: '3',
_source: {
'@timestamp': '2020-10-04T15:06:54.368707900Z',
},
},
{
_index: 'index',
_id: '4',
_source: {
'@timestamp': '2020-10-04T15:15:54.368707900Z',
},
},
],
total: {
value: 4,
relation: '',
},
},
is_partial: false,
is_running: false,
took: 300,
timed_out: false,
},
headers: {},
warnings: [],
meta: {
aborted: false,
attempts: 0,
context: null,
name: 'elasticsearch-js',
connection: {} as Connection,
request: {
params: {
body: JSON.stringify({
filter: {
range: {
'@timestamp': {
gte: '2020-10-07T00:46:12.414Z',
lte: '2020-10-07T01:46:12.414Z',
format: 'strict_date_optional_time',
},
},
},
}),
method: 'GET',
path: '/_eql/search/',
querystring: 'some query string',
},
options: {},
id: '',
},
},
statusCode: 200,
},
});
export const getMockEqlSequenceResponse = (): EqlSearchStrategyResponse<
EqlSearchResponse<Source>
> => ({
id: 'some-id',
rawResponse: {
body: {
hits: {
sequences: [
{
join_keys: [],
events: [
{
_index: 'index',
_id: '1',
_source: {
'@timestamp': '2020-10-04T15:16:54.368707900Z',
},
},
{
_index: 'index',
_id: '2',
_source: {
'@timestamp': '2020-10-04T15:50:54.368707900Z',
},
},
],
},
{
join_keys: [],
events: [
{
_index: 'index',
_id: '3',
_source: {
'@timestamp': '2020-10-04T15:06:54.368707900Z',
},
},
{
_index: 'index',
_id: '4',
_source: {
'@timestamp': '2020-10-04T15:15:54.368707900Z',
},
},
],
},
],
total: {
value: 4,
relation: '',
},
},
is_partial: false,
is_running: false,
took: 300,
timed_out: false,
},
headers: {},
warnings: [],
meta: {
aborted: false,
attempts: 0,
context: null,
name: 'elasticsearch-js',
connection: {} as Connection,
request: {
params: {
body: JSON.stringify({
filter: {
range: {
'@timestamp': {
gte: '2020-10-07T00:46:12.414Z',
lte: '2020-10-07T01:46:12.414Z',
format: 'strict_date_optional_time',
},
},
},
}),
method: 'GET',
path: '/_eql/search/',
querystring: 'some query string',
},
options: {},
id: '',
},
},
statusCode: 200,
},
});

View file

@ -10,55 +10,105 @@ import { inputsModel } from '../../../common/store';
import { EqlSearchStrategyResponse } from '../../../../../data_enhanced/common';
import { InspectResponse } from '../../../types';
import { EqlPreviewResponse, Source } from './types';
import { EqlSearchResponse } from '../../../../common/detection_engine/types';
import { BaseHit, EqlSearchResponse } from '../../../../common/detection_engine/types';
type EqlAggBuckets = Record<string, { timestamp: string; total: number }>;
export const EQL_QUERY_EVENT_SIZE = 100;
// Calculates which 2 min bucket segment, event should be
// sorted into
/**
* Calculates which 2 min bucket segment, event should be sorted into
* @param eventTimestamp The event to be bucketed timestamp
* @param relativeNow The timestamp we are using to calculate how far from 'now' event occurred
*/
export const calculateBucketForHour = (eventTimestamp: number, relativeNow: number): number => {
const diff: number = relativeNow - eventTimestamp;
const minutes: number = Math.floor(diff / 60000);
const diff = Math.abs(relativeNow - eventTimestamp);
const minutes = Math.floor(diff / 60000);
return Math.ceil(minutes / 2) * 2;
};
// Calculates which 1 hour bucket segment, event should be
// sorted into
/**
* Calculates which 1 hour bucket segment, event should be sorted into
* @param eventTimestamp The event to be bucketed timestamp
* @param relativeNow The timestamp we are using to calculate how far from 'now' event occurred
*/
export const calculateBucketForDay = (eventTimestamp: number, relativeNow: number): number => {
const diff: number = relativeNow - eventTimestamp;
const minutes: number = Math.floor(diff / 60000);
const diff = Math.abs(relativeNow - eventTimestamp);
const minutes = Math.floor(diff / 60000);
return Math.ceil(minutes / 60);
};
/**
* Formats the response for the UI inspect modal
* @param response The query search response
* @param indices The indices the query searched
* TODO: Update eql search strategy to return index in it's meta
* params info, currently not being returned, but expected for
* inspect modal display
*/
export const formatInspect = (
response: EqlSearchStrategyResponse<EqlSearchResponse<Source>>
response: EqlSearchStrategyResponse<EqlSearchResponse<Source>>,
indices: string[]
): InspectResponse => {
const body = response.rawResponse.meta.request.params.body;
const bodyParse = typeof body === 'string' ? JSON.parse(body) : body;
const bodyParse: Record<string, unknown> | undefined =
typeof body === 'string' ? JSON.parse(body) : body;
return {
dsl: [
JSON.stringify({ ...response.rawResponse.meta.request.params, body: bodyParse }, null, 2),
JSON.stringify(
{ ...response.rawResponse.meta.request.params, index: indices, body: bodyParse },
null,
2
),
],
response: [JSON.stringify(response.rawResponse.body, null, 2)],
};
};
// NOTE: Eql does not support aggregations, this is an in-memory
// hand-spun aggregation for the events to give the user a visual
// representation of their query results
/**
* Gets the events out of the response based on type of query
* @param isSequence Is the eql query a sequence query
* @param response The query search response
*/
export const getEventsToBucket = (
isSequence: boolean,
response: EqlSearchStrategyResponse<EqlSearchResponse<Source>>
): Array<BaseHit<Source>> => {
const hits = response.rawResponse.body.hits ?? [];
if (isSequence) {
return (
hits.sequences?.map((seq) => {
return seq.events[seq.events.length - 1];
}) ?? []
);
} else {
return hits.events ?? [];
}
};
/**
* Eql does not support aggregations, this is an in-memory
* hand-spun aggregation for the events to give the user a visual
* representation of their query results
* @param response The query search response
* @param range User chosen timeframe (last hour, day)
* @param to Based on range chosen
* @param refetch Callback used in inspect button, ref just passed through
* @param indices Indices searched by query
* @param isSequence Is the eql query a sequence query
*/
export const getEqlAggsData = (
response: EqlSearchStrategyResponse<EqlSearchResponse<Source>>,
range: Unit,
to: string,
refetch: inputsModel.Refetch
refetch: inputsModel.Refetch,
indices: string[],
isSequence: boolean
): EqlPreviewResponse => {
const { dsl, response: inspectResponse } = formatInspect(response);
// The upper bound of the timestamps
const { dsl, response: inspectResponse } = formatInspect(response, indices);
const relativeNow = Date.parse(to);
const accumulator = getInterval(range, relativeNow);
const events = response.rawResponse.body.hits.events ?? [];
const events = getEventsToBucket(isSequence, response);
const totalCount = response.rawResponse.body.hits.total.value;
const buckets = events.reduce<EqlAggBuckets>((acc, hit) => {
@ -94,12 +144,23 @@ export const getEqlAggsData = (
};
};
export const createIntervalArray = (start: number, end: number, multiplier: number) => {
/**
* Helper method to create an array to be used for calculating bucket intervals
* @param start
* @param end
* @param multiplier
*/
export const createIntervalArray = (start: number, end: number, multiplier: number): number[] => {
return Array(end - start + 1)
.fill(0)
.map((_, idx) => start + idx * multiplier);
};
/**
* Helper method to create an array to be used for calculating bucket intervals
* @param range User chosen timeframe (last hour, day)
* @param relativeNow Based on range chosen
*/
export const getInterval = (range: Unit, relativeNow: number): EqlAggBuckets => {
switch (range) {
case 'h':
@ -117,38 +178,6 @@ export const getInterval = (range: Unit, relativeNow: number): EqlAggBuckets =>
};
}, {});
default:
throw new Error('Invalid time range selected');
throw new RangeError('Invalid time range selected. Must be "Last hour" or "Last day".');
}
};
export const getSequenceAggs = (
response: EqlSearchStrategyResponse<EqlSearchResponse<Source>>,
refetch: inputsModel.Refetch
): EqlPreviewResponse => {
const { dsl, response: inspectResponse } = formatInspect(response);
const sequences = response.rawResponse.body.hits.sequences ?? [];
const totalCount = response.rawResponse.body.hits.total.value;
const data = sequences.map((sequence, i) => {
return sequence.events.map((seqEvent) => {
if (seqEvent._source['@timestamp'] == null) {
return {};
}
return {
x: seqEvent._source['@timestamp'],
y: 1,
g: `Seq. ${i + 1}`,
};
});
});
return {
data: data.flat(),
totalCount,
inspect: {
dsl,
response: inspectResponse,
},
refetch,
};
};

View file

@ -14,7 +14,7 @@ import { Source } from './types';
import { EqlSearchResponse } from '../../../../common/detection_engine/types';
import { useKibana } from '../../../common/lib/kibana';
import { useEqlPreview } from '.';
import { getMockResponse } from './helpers.test';
import { getMockEqlResponse } from './eql_search_response.mock';
jest.mock('../../../common/lib/kibana');
@ -32,7 +32,9 @@ describe('useEqlPreview', () => {
useKibana().services.notifications.toasts.addWarning = jest.fn();
(useKibana().services.data.search.search as jest.Mock).mockReturnValue(of(getMockResponse()));
(useKibana().services.data.search.search as jest.Mock).mockReturnValue(
of(getMockEqlResponse())
);
});
it('should initiate hook', async () => {
@ -96,7 +98,7 @@ describe('useEqlPreview', () => {
it('should not resolve values after search is invoked if component unmounted', async () => {
await act(async () => {
(useKibana().services.data.search.search as jest.Mock).mockReturnValue(
of(getMockResponse()).pipe(delay(5000))
of(getMockEqlResponse()).pipe(delay(5000))
);
const { result, waitForNextUpdate, unmount } = renderHook(() => useEqlPreview());
@ -117,9 +119,11 @@ describe('useEqlPreview', () => {
it('should not resolve new values on search if response is error response', async () => {
await act(async () => {
(useKibana().services.data.search.search as jest.Mock).mockReturnValue(
of({ isRunning: false, isPartial: true } as EqlSearchStrategyResponse<
EqlSearchResponse<Source>
>)
of<EqlSearchStrategyResponse<EqlSearchResponse<Source>>>({
...getMockEqlResponse(),
isRunning: false,
isPartial: true,
})
);
const { result, waitForNextUpdate } = renderHook(() => useEqlPreview());
@ -136,6 +140,30 @@ describe('useEqlPreview', () => {
});
});
// TODO: Determine why eql search strategy returns null for meta.params.body
// in complete responses, but not in partial responses
it('should update inspect information on partial response', async () => {
const mockResponse = getMockEqlResponse();
await act(async () => {
(useKibana().services.data.search.search as jest.Mock).mockReturnValue(
of<EqlSearchStrategyResponse<EqlSearchResponse<Source>>>({
isRunning: true,
isPartial: true,
rawResponse: mockResponse.rawResponse,
})
);
const { result, waitForNextUpdate } = renderHook(() => useEqlPreview());
await waitForNextUpdate();
result.current[1](params);
expect(result.current[2].inspect.dsl.length).toEqual(1);
expect(result.current[2].inspect.response.length).toEqual(1);
});
});
it('should add danger toast if search throws', async () => {
await act(async () => {
(useKibana().services.data.search.search as jest.Mock).mockReturnValue(

View file

@ -14,12 +14,13 @@ import {
AbortError,
isCompleteResponse,
isErrorResponse,
isPartialResponse,
} from '../../../../../../../src/plugins/data/common';
import {
EqlSearchStrategyRequest,
EqlSearchStrategyResponse,
} from '../../../../../data_enhanced/common';
import { getEqlAggsData, getSequenceAggs } from './helpers';
import { formatInspect, getEqlAggsData } from './helpers';
import { EqlPreviewResponse, EqlPreviewRequest, Source } from './types';
import { hasEqlSequenceQuery } from '../../../../common/detection_engine/utils';
import { EqlSearchResponse } from '../../../../common/detection_engine/types';
@ -106,13 +107,37 @@ export const useEqlPreview = (): [
if (isCompleteResponse(res)) {
if (!didCancel.current) {
setLoading(false);
if (hasEqlSequenceQuery(query)) {
setResponse(getSequenceAggs(res, refetch.current));
} else {
setResponse(getEqlAggsData(res, interval, to, refetch.current));
}
setResponse((prev) => {
const { inspect, ...rest } = getEqlAggsData(
res,
interval,
to,
refetch.current,
index,
hasEqlSequenceQuery(query)
);
const inspectDsl = prev.inspect.dsl[0] ? prev.inspect.dsl : inspect.dsl;
const inspectResp = prev.inspect.response[0]
? prev.inspect.response
: inspect.response;
return {
...prev,
...rest,
inspect: {
dsl: inspectDsl,
response: inspectResp,
},
};
});
}
unsubscribeStream.current.next();
} else if (isPartialResponse(res)) {
// TODO: Eql search strategy partial responses return a value under meta.params.body
// but the final/complete response does not, that's why the inspect values are set here
setResponse((prev) => ({ ...prev, inspect: formatInspect(res, index) }));
} else if (isErrorResponse(res)) {
setLoading(false);
notifications.toasts.addWarning(i18n.EQL_PREVIEW_FETCH_FAILURE);

View file

@ -52,7 +52,7 @@ describe('PreviewCustomQueryHistogram', () => {
expect(wrapper.find('[data-test-subj="queryPreviewLoading"]').exists()).toBeTruthy();
expect(
wrapper.find('[data-test-subj="queryPreviewCustomHistogram"]').at(0).prop('subtitle')
).toEqual(i18n.PREVIEW_SUBTITLE_LOADING);
).toEqual(i18n.QUERY_PREVIEW_SUBTITLE_LOADING);
});
test('it configures data and subtitle', () => {

View file

@ -54,7 +54,7 @@ export const PreviewCustomQueryHistogram = ({
const subtitle = useMemo(
(): string =>
isLoading ? i18n.PREVIEW_SUBTITLE_LOADING : i18n.QUERY_PREVIEW_TITLE(totalCount),
isLoading ? i18n.QUERY_PREVIEW_SUBTITLE_LOADING : i18n.QUERY_PREVIEW_TITLE(totalCount),
[isLoading, totalCount]
);
@ -67,7 +67,7 @@ export const PreviewCustomQueryHistogram = ({
barConfig={barConfig}
title={i18n.QUERY_GRAPH_HITS_TITLE}
subtitle={subtitle}
disclaimer={i18n.PREVIEW_QUERY_DISCLAIMER}
disclaimer={i18n.QUERY_PREVIEW_DISCLAIMER}
isLoading={isLoading}
data-test-subj="queryPreviewCustomHistogram"
/>

View file

@ -39,7 +39,6 @@ describe('PreviewEqlQueryHistogram', () => {
<PreviewEqlQueryHistogram
to="2020-07-08T08:20:18.966Z"
from="2020-07-07T08:20:18.966Z"
query="file where true"
data={[]}
totalCount={0}
inspect={{ dsl: [], response: [] }}
@ -53,7 +52,7 @@ describe('PreviewEqlQueryHistogram', () => {
expect(wrapper.find('[data-test-subj="queryPreviewLoading"]').exists()).toBeTruthy();
expect(
wrapper.find('[data-test-subj="queryPreviewEqlHistogram"]').at(0).prop('subtitle')
).toEqual(i18n.PREVIEW_SUBTITLE_LOADING);
).toEqual(i18n.QUERY_PREVIEW_SUBTITLE_LOADING);
});
test('it configures data and subtitle', () => {
@ -63,7 +62,6 @@ describe('PreviewEqlQueryHistogram', () => {
<PreviewEqlQueryHistogram
to="2020-07-08T08:20:18.966Z"
from="2020-07-07T08:20:18.966Z"
query="file where true"
data={[
{ x: 1602247050000, y: 2314, g: 'All others' },
{ x: 1602247162500, y: 3471, g: 'All others' },
@ -115,7 +113,6 @@ describe('PreviewEqlQueryHistogram', () => {
<PreviewEqlQueryHistogram
to="2020-07-08T08:20:18.966Z"
from="2020-07-07T08:20:18.966Z"
query="file where true"
data={[]}
totalCount={0}
inspect={{ dsl: ['some dsl'], response: ['query response'] }}
@ -133,4 +130,32 @@ describe('PreviewEqlQueryHistogram', () => {
refetch: mockRefetch,
});
});
test('it displays histogram', () => {
const wrapper = mount(
<ThemeProvider theme={() => ({ eui: euiLightVars, darkMode: false })}>
<TestProviders>
<PreviewEqlQueryHistogram
to="2020-07-08T08:20:18.966Z"
from="2020-07-07T08:20:18.966Z"
data={[
{ x: 1602247050000, y: 2314, g: 'All others' },
{ x: 1602247162500, y: 3471, g: 'All others' },
{ x: 1602247275000, y: 3369, g: 'All others' },
]}
totalCount={9154}
inspect={{ dsl: [], response: [] }}
refetch={jest.fn()}
isLoading={false}
/>
</TestProviders>
</ThemeProvider>
);
expect(wrapper.find('[data-test-subj="queryPreviewLoading"]').exists()).toBeFalsy();
expect(
wrapper.find('[data-test-subj="sharedPreviewQueryNoHistogramAvailable"]').exists()
).toBeFalsy();
expect(wrapper.find('[data-test-subj="sharedPreviewQueryHistogram"]').exists()).toBeTruthy();
});
});

View file

@ -15,7 +15,6 @@ import {
} from '../../../../common/components/charts/common';
import { InspectQuery } from '../../../../common/store/inputs/model';
import { useGlobalTime } from '../../../../common/containers/use_global_time';
import { hasEqlSequenceQuery } from '../../../../../common/detection_engine/utils';
import { inputsModel } from '../../../../common/store';
import { PreviewHistogram } from './histogram';
@ -26,7 +25,6 @@ interface PreviewEqlQueryHistogramProps {
from: string;
totalCount: number;
isLoading: boolean;
query: string;
data: ChartData[];
inspect: InspectQuery;
refetch: inputsModel.Refetch;
@ -36,7 +34,6 @@ export const PreviewEqlQueryHistogram = ({
from,
to,
totalCount,
query,
data,
inspect,
refetch,
@ -50,14 +47,11 @@ export const PreviewEqlQueryHistogram = ({
}
}, [setQuery, inspect, isInitializing, refetch]);
const barConfig = useMemo(
(): ChartSeriesConfigs => getHistogramConfig(to, from, hasEqlSequenceQuery(query)),
[from, to, query]
);
const barConfig = useMemo((): ChartSeriesConfigs => getHistogramConfig(to, from), [from, to]);
const subtitle = useMemo(
(): string =>
isLoading ? i18n.PREVIEW_SUBTITLE_LOADING : i18n.QUERY_PREVIEW_TITLE(totalCount),
isLoading ? i18n.QUERY_PREVIEW_SUBTITLE_LOADING : i18n.QUERY_PREVIEW_TITLE(totalCount),
[isLoading, totalCount]
);
@ -70,7 +64,7 @@ export const PreviewEqlQueryHistogram = ({
barConfig={barConfig}
title={i18n.QUERY_GRAPH_HITS_TITLE}
subtitle={subtitle}
disclaimer={i18n.PREVIEW_QUERY_DISCLAIMER_EQL}
disclaimer={i18n.QUERY_PREVIEW_DISCLAIMER_EQL}
isLoading={isLoading}
data-test-subj="queryPreviewEqlHistogram"
/>

View file

@ -16,13 +16,13 @@ import { FieldValueQueryBar } from '../query_bar';
import { ESQuery } from '../../../../../common/typed_json';
import { Filter } from '../../../../../../../../src/plugins/data/common/es_query';
export const HITS_THRESHOLD: Record<string, number> = {
h: 1,
d: 24,
M: 730,
};
export const isNoisy = (hits: number, timeframe: Unit) => {
/**
* Determines whether or not to display noise warning.
* Is considered noisy if alerts/hour rate > 1
* @param hits Total query search hits
* @param timeframe Range selected by user (last hour, day...)
*/
export const isNoisy = (hits: number, timeframe: Unit): boolean => {
if (timeframe === 'h') {
return hits > 1;
} else if (timeframe === 'd') {
@ -34,6 +34,12 @@ export const isNoisy = (hits: number, timeframe: Unit) => {
return false;
};
/**
* Determines what timerange options to show.
* Eql sequence queries tend to be slower, so decided
* not to include the last month option.
* @param ruleType
*/
export const getTimeframeOptions = (ruleType: Type): EuiSelectOption[] => {
if (ruleType === 'eql') {
return [
@ -49,6 +55,13 @@ export const getTimeframeOptions = (ruleType: Type): EuiSelectOption[] => {
}
};
/**
* Quick little helper to extract the query info from the
* queryBar object.
* @param queryBar Object containing all query info
* @param index Indices searched
* @param ruleType
*/
export const getInfoFromQueryBar = (
queryBar: FieldValueQueryBar,
index: string[],
@ -88,10 +101,15 @@ export const getInfoFromQueryBar = (
}
};
/**
* Config passed into elastic-charts settings.
* @param to
* @param from
*/
export const getHistogramConfig = (
to: string,
from: string,
showLegend: boolean = false
showLegend = false
): ChartSeriesConfigs => {
return {
series: {
@ -131,7 +149,11 @@ export const getHistogramConfig = (
};
};
export const getThresholdHistogramConfig = (height: number | undefined): ChartSeriesConfigs => {
/**
* Threshold histogram is displayed a bit differently,
* x-axis is not time based, but ordinal.
*/
export const getThresholdHistogramConfig = (): ChartSeriesConfigs => {
return {
series: {
xScaleType: ScaleType.Ordinal,
@ -165,6 +187,6 @@ export const getThresholdHistogramConfig = (height: number | undefined): ChartSe
},
},
},
customHeight: height ?? 200,
customHeight: 200,
};
};

View file

@ -13,9 +13,13 @@ import euiLightVars from '@elastic/eui/dist/eui_theme_light.json';
import { TestProviders } from '../../../../common/mock';
import { useKibana } from '../../../../common/lib/kibana';
import { PreviewQuery } from './';
import { getMockResponse } from '../../../../common/hooks/eql/helpers.test';
import { getMockEqlResponse } from '../../../../common/hooks/eql/eql_search_response.mock';
import { useMatrixHistogram } from '../../../../common/containers/matrix_histogram';
import { useEqlPreview } from '../../../../common/hooks/eql/';
jest.mock('../../../../common/lib/kibana');
jest.mock('../../../../common/containers/matrix_histogram');
jest.mock('../../../../common/hooks/eql/');
describe('PreviewQuery', () => {
beforeEach(() => {
@ -23,7 +27,33 @@ describe('PreviewQuery', () => {
useKibana().services.notifications.toasts.addWarning = jest.fn();
(useKibana().services.data.search.search as jest.Mock).mockReturnValue(of(getMockResponse()));
(useMatrixHistogram as jest.Mock).mockReturnValue([
false,
{
inspect: { dsl: [], response: [] },
totalCount: 1,
refetch: jest.fn(),
data: [],
buckets: [],
},
(useKibana().services.data.search.search as jest.Mock).mockReturnValue(
of(getMockEqlResponse())
),
]);
(useEqlPreview as jest.Mock).mockReturnValue([
false,
(useKibana().services.data.search.search as jest.Mock).mockReturnValue(
of(getMockEqlResponse())
),
{
inspect: { dsl: [], response: [] },
totalCount: 1,
refetch: jest.fn(),
data: [],
buckets: [],
},
]);
});
afterEach(() => {
@ -121,6 +151,42 @@ describe('PreviewQuery', () => {
expect(wrapper.find('[data-test-subj="previewEqlQueryHistogram"]').exists()).toBeFalsy();
});
test('it renders noise warning when rule type is query, timeframe is last hour and hit average is greater than 1/hour', async () => {
const wrapper = mount(
<ThemeProvider theme={() => ({ eui: euiLightVars, darkMode: false })}>
<TestProviders>
<PreviewQuery
ruleType="query"
dataTestSubj="queryPreviewSelect"
idAria="queryPreview"
query={{ query: { query: 'host.name:*', language: 'kuery' }, filters: [] }}
index={['foo-*']}
threshold={undefined}
isDisabled={false}
/>
</TestProviders>
</ThemeProvider>
);
(useMatrixHistogram as jest.Mock).mockReturnValue([
false,
{
inspect: { dsl: [], response: [] },
totalCount: 2,
refetch: jest.fn(),
data: [],
buckets: [],
},
(useKibana().services.data.search.search as jest.Mock).mockReturnValue(
of(getMockEqlResponse())
),
]);
wrapper.find('[data-test-subj="queryPreviewButton"] button').at(0).simulate('click');
expect(wrapper.find('[data-test-subj="previewQueryWarning"]').exists()).toBeTruthy();
});
test('it renders query histogram when rule type is saved_query and preview button clicked', () => {
const wrapper = mount(
<ThemeProvider theme={() => ({ eui: euiLightVars, darkMode: false })}>
@ -175,6 +241,42 @@ describe('PreviewQuery', () => {
expect(wrapper.find('[data-test-subj="previewEqlQueryHistogram"]').exists()).toBeTruthy();
});
test('it renders noise warning when rule type is eql, timeframe is last hour and hit average is greater than 1/hour', async () => {
const wrapper = mount(
<ThemeProvider theme={() => ({ eui: euiLightVars, darkMode: false })}>
<TestProviders>
<PreviewQuery
ruleType="eql"
dataTestSubj="queryPreviewSelect"
idAria="queryPreview"
query={{ query: { query: 'file where true', language: 'kuery' }, filters: [] }}
index={['foo-*']}
threshold={undefined}
isDisabled={false}
/>
</TestProviders>
</ThemeProvider>
);
(useEqlPreview as jest.Mock).mockReturnValue([
false,
(useKibana().services.data.search.search as jest.Mock).mockReturnValue(
of(getMockEqlResponse())
),
{
inspect: { dsl: [], response: [] },
totalCount: 2,
refetch: jest.fn(),
data: [],
buckets: [],
},
]);
wrapper.find('[data-test-subj="queryPreviewButton"] button').at(0).simulate('click');
expect(wrapper.find('[data-test-subj="previewQueryWarning"]').exists()).toBeTruthy();
});
test('it renders threshold histogram when preview button clicked, rule type is threshold, and threshold field is defined', () => {
const wrapper = mount(
<ThemeProvider theme={() => ({ eui: euiLightVars, darkMode: false })}>
@ -192,16 +294,70 @@ describe('PreviewQuery', () => {
</ThemeProvider>
);
(useMatrixHistogram as jest.Mock).mockReturnValue([
false,
{
inspect: { dsl: [], response: [] },
totalCount: 500,
refetch: jest.fn(),
data: [],
buckets: [{ key: 'siem-kibana', doc_count: 500 }],
},
(useKibana().services.data.search.search as jest.Mock).mockReturnValue(
of(getMockEqlResponse())
),
]);
wrapper.find('[data-test-subj="queryPreviewButton"] button').at(0).simulate('click');
const mockCalls = (useKibana().services.data.search.search as jest.Mock).mock.calls;
expect(mockCalls.length).toEqual(1);
expect(wrapper.find('[data-test-subj="previewQueryWarning"]').exists()).toBeFalsy();
expect(wrapper.find('[data-test-subj="previewNonEqlQueryHistogram"]').exists()).toBeFalsy();
expect(wrapper.find('[data-test-subj="previewThresholdQueryHistogram"]').exists()).toBeTruthy();
expect(wrapper.find('[data-test-subj="previewEqlQueryHistogram"]').exists()).toBeFalsy();
});
test('it renders noise warning when rule type is threshold, and threshold field is defined, timeframe is last hour and hit average is greater than 1/hour', async () => {
const wrapper = mount(
<ThemeProvider theme={() => ({ eui: euiLightVars, darkMode: false })}>
<TestProviders>
<PreviewQuery
ruleType="query"
dataTestSubj="queryPreviewSelect"
idAria="queryPreview"
query={{ query: { query: 'file where true', language: 'kuery' }, filters: [] }}
index={['foo-*']}
threshold={{ field: 'agent.hostname', value: 200 }}
isDisabled={false}
/>
</TestProviders>
</ThemeProvider>
);
(useMatrixHistogram as jest.Mock).mockReturnValue([
false,
{
inspect: { dsl: [], response: [] },
totalCount: 500,
refetch: jest.fn(),
data: [],
buckets: [
{ key: 'siem-kibana', doc_count: 200 },
{ key: 'siem-windows', doc_count: 300 },
],
},
(useKibana().services.data.search.search as jest.Mock).mockReturnValue(
of(getMockEqlResponse())
),
]);
wrapper.find('[data-test-subj="queryPreviewButton"] button').at(0).simulate('click');
expect(wrapper.find('[data-test-subj="previewQueryWarning"]').exists()).toBeTruthy();
});
test('it renders query histogram when preview button clicked, rule type is threshold, and threshold field is not defined', () => {
const wrapper = mount(
<ThemeProvider theme={() => ({ eui: euiLightVars, darkMode: false })}>
@ -255,4 +411,33 @@ describe('PreviewQuery', () => {
expect(wrapper.find('[data-test-subj="previewThresholdQueryHistogram"]').exists()).toBeFalsy();
expect(wrapper.find('[data-test-subj="previewEqlQueryHistogram"]').exists()).toBeFalsy();
});
test('it hides histogram when timeframe changes', () => {
const wrapper = mount(
<ThemeProvider theme={() => ({ eui: euiLightVars, darkMode: false })}>
<TestProviders>
<PreviewQuery
ruleType="threshold"
dataTestSubj="queryPreviewSelect"
idAria="queryPreview"
query={{ query: { query: 'file where true', language: 'kuery' }, filters: [] }}
index={['foo-*']}
threshold={undefined}
isDisabled={false}
/>
</TestProviders>
</ThemeProvider>
);
wrapper.find('[data-test-subj="queryPreviewButton"] button').at(0).simulate('click');
expect(wrapper.find('[data-test-subj="previewNonEqlQueryHistogram"]').exists()).toBeTruthy();
wrapper
.find('[data-test-subj="queryPreviewTimeframeSelect"] select')
.at(0)
.simulate('change', { target: { value: 'd' } });
expect(wrapper.find('[data-test-subj="previewNonEqlQueryHistogram"]').exists()).toBeFalsy();
});
});

View file

@ -3,7 +3,7 @@
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { Fragment, useCallback, useEffect, useReducer } from 'react';
import React, { Fragment, useCallback, useEffect, useReducer, useRef } from 'react';
import { Unit } from '@elastic/datemath';
import styled from 'styled-components';
import {
@ -110,29 +110,31 @@ export const PreviewQuery = ({
{ inspect, totalCount: matrixHistTotal, refetch, data: matrixHistoData, buckets },
startNonEql,
] = useMatrixHistogram({
errorMessage: i18n.PREVIEW_QUERY_ERROR,
errorMessage: i18n.QUERY_PREVIEW_ERROR,
endDate: fromTime,
startDate: toTime,
filterQuery: queryFilter,
indexNames: index,
histogramType: MatrixHistogramType.events,
stackByField: 'event.category',
threshold,
threshold: ruleType === 'threshold' ? threshold : undefined,
skip: true,
});
const setQueryInfo = useCallback(
(queryBar: FieldValueQueryBar | undefined): void => {
(queryBar: FieldValueQueryBar | undefined, indices: string[], type: Type): void => {
dispatch({
type: 'setQueryInfo',
queryBar,
index,
ruleType,
index: indices,
ruleType: type,
});
},
[dispatch, index, ruleType]
[dispatch]
);
const debouncedSetQueryInfo = useRef(debounce(500, setQueryInfo));
const setTimeframeSelect = useCallback(
(selection: Unit): void => {
dispatch({
@ -190,11 +192,9 @@ export const PreviewQuery = ({
[dispatch]
);
useEffect((): void => {
const debounced = debounce(1000, setQueryInfo);
debounced(query);
}, [setQueryInfo, query]);
useEffect(() => {
debouncedSetQueryInfo.current(query, index, ruleType);
}, [index, query, ruleType]);
useEffect((): void => {
setThresholdValues(threshold, ruleType);
@ -205,12 +205,32 @@ export const PreviewQuery = ({
}, [ruleType, setRuleTypeChange]);
useEffect((): void => {
const totalHits = ruleType === 'eql' ? eqlQueryTotal : matrixHistTotal;
if (isNoisy(totalHits, timeframe)) {
setNoiseWarning();
switch (ruleType) {
case 'eql':
if (isNoisy(eqlQueryTotal, timeframe)) {
setNoiseWarning();
}
break;
case 'threshold':
const totalHits = thresholdFieldExists ? buckets.length : matrixHistTotal;
if (isNoisy(totalHits, timeframe)) {
setNoiseWarning();
}
break;
default:
if (isNoisy(matrixHistTotal, timeframe)) {
setNoiseWarning();
}
}
}, [timeframe, matrixHistTotal, eqlQueryTotal, ruleType, setNoiseWarning]);
}, [
timeframe,
matrixHistTotal,
eqlQueryTotal,
ruleType,
setNoiseWarning,
thresholdFieldExists,
buckets.length,
]);
const handlePreviewEqlQuery = useCallback(
(to: string, from: string): void => {
@ -263,8 +283,9 @@ export const PreviewQuery = ({
options={timeframeOptions}
value={timeframe}
onChange={handleSelectPreviewTimeframe}
aria-label={i18n.PREVIEW_SELECT_ARIA}
aria-label={i18n.QUERY_PREVIEW_SELECT_ARIA}
disabled={isDisabled}
data-test-subj="queryPreviewTimeframeSelect"
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
@ -276,7 +297,7 @@ export const PreviewQuery = ({
onClick={handlePreviewClicked}
data-test-subj="queryPreviewButton"
>
{i18n.PREVIEW_LABEL}
{i18n.QUERY_PREVIEW_BUTTON}
</PreviewButton>
</EuiFlexItem>
</EuiFlexGroup>
@ -307,7 +328,6 @@ export const PreviewQuery = ({
<PreviewEqlQueryHistogram
to={toTime}
from={fromTime}
query={queryString}
totalCount={eqlQueryTotal}
data={eqlQueryData}
inspect={eqlQueryInspect}

View file

@ -33,9 +33,9 @@ describe('queryPreviewReducer', () => {
expect(update).toEqual(initialState);
});
test('should reset showHistogram and warnings if queryBar undefined', () => {
test('should reset showHistogram if queryBar undefined', () => {
const update = reducer(
{ ...initialState, showHistogram: true, warnings: ['uh oh'] },
{ ...initialState, showHistogram: true },
{
type: 'setQueryInfo',
queryBar: undefined,
@ -44,11 +44,10 @@ describe('queryPreviewReducer', () => {
}
);
expect(update.warnings).toEqual([]);
expect(update.showHistogram).toBeFalsy();
});
test('should reset showHistogram and warnings if queryBar defined', () => {
test('should reset showHistogram if queryBar defined', () => {
const update = reducer(
{ ...initialState, showHistogram: true, warnings: ['uh oh'] },
{
@ -62,7 +61,6 @@ describe('queryPreviewReducer', () => {
}
);
expect(update.warnings).toEqual([]);
expect(update.showHistogram).toBeFalsy();
});

View file

@ -82,13 +82,11 @@ export const queryPreviewReducer = () => (state: State, action: Action): State =
filters,
queryFilter,
showHistogram: false,
warnings: [],
};
}
return {
...state,
warnings: [],
showHistogram: false,
};
}

View file

@ -56,12 +56,12 @@ export const PreviewThresholdQueryHistogram = ({
};
}, [buckets]);
const barConfig = useMemo((): ChartSeriesConfigs => getThresholdHistogramConfig(200), []);
const barConfig = useMemo((): ChartSeriesConfigs => getThresholdHistogramConfig(), []);
const subtitle = useMemo(
(): string =>
isLoading
? i18n.PREVIEW_SUBTITLE_LOADING
? i18n.QUERY_PREVIEW_SUBTITLE_LOADING
: i18n.QUERY_PREVIEW_THRESHOLD_WITH_FIELD_TITLE(totalCount),
[isLoading, totalCount]
);
@ -73,7 +73,7 @@ export const PreviewThresholdQueryHistogram = ({
barConfig={barConfig}
title={i18n.QUERY_GRAPH_HITS_TITLE}
subtitle={subtitle}
disclaimer={i18n.PREVIEW_QUERY_DISCLAIMER}
disclaimer={i18n.QUERY_PREVIEW_DISCLAIMER}
isLoading={isLoading}
data-test-subj="thresholdQueryPreviewHistogram"
/>

View file

@ -6,14 +6,14 @@
import { i18n } from '@kbn/i18n';
export const PREVIEW_LABEL = i18n.translate(
'xpack.securitySolution.stepDefineRule.previewQueryLabel',
export const QUERY_PREVIEW_BUTTON = i18n.translate(
'xpack.securitySolution.stepDefineRule.previewQueryButton',
{
defaultMessage: 'Preview results',
}
);
export const PREVIEW_SELECT_ARIA = i18n.translate(
export const QUERY_PREVIEW_SELECT_ARIA = i18n.translate(
'xpack.securitySolution.stepDefineRule.previewQueryAriaLabel',
{
defaultMessage: 'Query preview timeframe select',
@ -85,14 +85,14 @@ export const QUERY_PREVIEW_NO_HITS = i18n.translate(
}
);
export const PREVIEW_QUERY_ERROR = i18n.translate(
export const QUERY_PREVIEW_ERROR = i18n.translate(
'xpack.securitySolution.detectionEngine.queryPreview.queryGraphPreviewError',
{
defaultMessage: 'Error fetching preview',
}
);
export const PREVIEW_QUERY_DISCLAIMER = i18n.translate(
export const QUERY_PREVIEW_DISCLAIMER = i18n.translate(
'xpack.securitySolution.detectionEngine.queryPreview.queryGraphDisclaimer',
{
defaultMessage:
@ -100,7 +100,7 @@ export const PREVIEW_QUERY_DISCLAIMER = i18n.translate(
}
);
export const PREVIEW_QUERY_DISCLAIMER_EQL = i18n.translate(
export const QUERY_PREVIEW_DISCLAIMER_EQL = i18n.translate(
'xpack.securitySolution.detectionEngine.queryPreview.queryGraphDisclaimerEql',
{
defaultMessage:
@ -108,26 +108,24 @@ export const PREVIEW_QUERY_DISCLAIMER_EQL = i18n.translate(
}
);
export const PREVIEW_WARNING_CAP_HIT = (cap: number) =>
i18n.translate(
'xpack.securitySolution.detectionEngine.queryPreview.queryPreviewGraphCapHitWarning',
{
values: { cap },
defaultMessage:
'Hit query cap size of {cap}. This query could produce more hits than the {cap} shown.',
}
);
export const PREVIEW_WARNING_TIMESTAMP = i18n.translate(
'xpack.securitySolution.detectionEngine.queryPreview.queryPreviewGraphTimestampWarning',
{
defaultMessage: 'Unable to find "@timestamp" field on events.',
}
);
export const PREVIEW_SUBTITLE_LOADING = i18n.translate(
export const QUERY_PREVIEW_SUBTITLE_LOADING = i18n.translate(
'xpack.securitySolution.detectionEngine.queryPreview.queryPreviewSubtitleLoading',
{
defaultMessage: '...loading',
}
);
export const QUERY_PREVIEW_EQL_SEQUENCE_TITLE = i18n.translate(
'xpack.securitySolution.detectionEngine.queryPreview.queryPreviewEqlSequenceTitle',
{
defaultMessage: 'No histogram available',
}
);
export const QUERY_PREVIEW_EQL_SEQUENCE_DESCRIPTION = i18n.translate(
'xpack.securitySolution.detectionEngine.queryPreview.queryPreviewEqlSequenceDescription',
{
defaultMessage:
'No histogram is available at this time for EQL sequence queries. You can use the inspect in the top right corner to view query details.',
}
);

View file

@ -5,7 +5,7 @@
*/
import { EuiButtonEmpty, EuiFormRow, EuiSpacer } from '@elastic/eui';
import React, { FC, memo, useCallback, useState, useEffect } from 'react';
import React, { FC, memo, useCallback, useState, useEffect, useMemo } from 'react';
import styled from 'styled-components';
// Prefer importing entire lodash library, e.g. import { get } from "lodash"
// eslint-disable-next-line no-restricted-imports
@ -53,7 +53,7 @@ import {
import { EqlQueryBar } from '../eql_query_bar';
import { ThreatMatchInput } from '../threatmatch_input';
import { useFetchIndex } from '../../../../common/containers/source';
import { PreviewQuery } from '../query_preview';
import { PreviewQuery, Threshold } from '../query_preview';
const CommonUseField = getUseField({ component: Field });
@ -210,6 +210,12 @@ const StepDefineRuleComponent: FC<StepDefineRuleProps> = ({
setOpenTimelineSearch(false);
}, []);
const thresholdFormValue = useMemo((): Threshold | undefined => {
return formThresholdValue != null && formThresholdField != null
? { value: formThresholdValue, field: formThresholdField[0] }
: undefined;
}, [formThresholdField, formThresholdValue]);
const ThresholdInputChildren = useCallback(
({ thresholdField, thresholdValue }) => (
<ThresholdInput
@ -403,11 +409,7 @@ const StepDefineRuleComponent: FC<StepDefineRuleProps> = ({
index={index}
query={formQuery}
isDisabled={queryBarQuery.trim() === '' || !isQueryBarValid || index.length === 0}
threshold={
formThresholdValue != null && formThresholdField != null
? { value: formThresholdValue, field: formThresholdField[0] }
: undefined
}
threshold={thresholdFormValue}
/>
</>
)}

View file

@ -17356,9 +17356,7 @@
"xpack.securitySolution.detectionEngine.queryPreview.queryGraphPreviewInspectTitle": "クエリプレビュー",
"xpack.securitySolution.detectionEngine.queryPreview.queryGraphPreviewNoiseWarning": "イズ警告このルールではイズが多く生じる可能性があります。クエリを絞り込むことを検討してください。これは1時間ごとに1アラートという線形進行に基づいています。",
"xpack.securitySolution.detectionEngine.queryPreview.queryNoHits": "ヒットが見つかりませんでした。",
"xpack.securitySolution.detectionEngine.queryPreview.queryPreviewGraphCapHitWarning": "クエリの上限サイズ{cap}に達しました。このクエリは表示されている{cap}を超えるヒットを生成できませんでした。",
"xpack.securitySolution.detectionEngine.queryPreview.queryPreviewGraphThresholdWithFieldTitle": "{buckets} {buckets, plural, =1 {固有のヒット} other {固有のヒット}}",
"xpack.securitySolution.detectionEngine.queryPreview.queryPreviewGraphTimestampWarning": "イベントで「@timestamp」フィールドが見つかりません",
"xpack.securitySolution.detectionEngine.queryPreview.queryPreviewGraphTitle": "{hits} {hits, plural, =1 {ヒット} other {ヒット}}",
"xpack.securitySolution.detectionEngine.queryPreview.queryPreviewHelpText": "クエリ結果をプレビューするデータのタイムフレームを選択します",
"xpack.securitySolution.detectionEngine.queryPreview.queryPreviewLabel": "クイッククエリプレビュー",
@ -18356,7 +18354,6 @@
"xpack.securitySolution.security.title": "セキュリティ",
"xpack.securitySolution.source.destination.packetsLabel": "パケット",
"xpack.securitySolution.stepDefineRule.previewQueryAriaLabel": "クエリプレビュータイムフレーム選択",
"xpack.securitySolution.stepDefineRule.previewQueryLabel": "結果を表示",
"xpack.securitySolution.system.acceptedAConnectionViaDescription": "次の手段で接続を受け付けました。",
"xpack.securitySolution.system.acceptedDescription": "以下を経由してユーザーを受け入れました。",
"xpack.securitySolution.system.attemptedLoginDescription": "以下を経由してログインを試行しました:",

View file

@ -17374,9 +17374,7 @@
"xpack.securitySolution.detectionEngine.queryPreview.queryGraphPreviewInspectTitle": "查询预览",
"xpack.securitySolution.detectionEngine.queryPreview.queryGraphPreviewNoiseWarning": "噪音警告:此规则可能会导致大量噪音。考虑缩小您的查询范围。这基于每小时 1 条告警的线性级数。",
"xpack.securitySolution.detectionEngine.queryPreview.queryNoHits": "找不到任何命中。",
"xpack.securitySolution.detectionEngine.queryPreview.queryPreviewGraphCapHitWarning": "命中查询上限大小为 {cap}。此查询生成的命中数可能大于显示的 {cap}。",
"xpack.securitySolution.detectionEngine.queryPreview.queryPreviewGraphThresholdWithFieldTitle": "{buckets} 个{buckets, plural, =1 {唯一命中} other {唯一命中}}",
"xpack.securitySolution.detectionEngine.queryPreview.queryPreviewGraphTimestampWarning": "在事件中找不到“@timestamp”字段。",
"xpack.securitySolution.detectionEngine.queryPreview.queryPreviewGraphTitle": "{hits} 个{hits, plural, =1 {命中} other {命中}}",
"xpack.securitySolution.detectionEngine.queryPreview.queryPreviewHelpText": "选择数据的时间范围以预览查询结果",
"xpack.securitySolution.detectionEngine.queryPreview.queryPreviewLabel": "快速查询预览",
@ -18375,7 +18373,6 @@
"xpack.securitySolution.security.title": "安全",
"xpack.securitySolution.source.destination.packetsLabel": "pkts",
"xpack.securitySolution.stepDefineRule.previewQueryAriaLabel": "查询预览时间范围选择",
"xpack.securitySolution.stepDefineRule.previewQueryLabel": "预览结果",
"xpack.securitySolution.system.acceptedAConnectionViaDescription": "已接受连接 - 通过",
"xpack.securitySolution.system.acceptedDescription": "已接受该用户 - 通过",
"xpack.securitySolution.system.attemptedLoginDescription": "已尝试登录 - 通过",