[Security Solution][Detections] Preview Rule: Make it possible to configure the time interval and look-back time (#137102)

* [Security Solution][Detections] Preview Rule: Make it possible to configure the time interval and look-back time (#4362)

* Fix CI

* Design updates

* Design updates

* [CI] Auto-commit changed files from 'node scripts/precommit_hook.js --ref HEAD~1..HEAD --fix'

* Fix CI

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Ievgen Sorokopud 2022-07-26 18:48:21 +02:00 committed by GitHub
parent e0280ea2f1
commit 8c005cf15e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 438 additions and 38 deletions

View file

@ -415,7 +415,7 @@ export type CreateRulesSchema = t.TypeOf<typeof createRulesSchema>;
export const previewRulesSchema = t.intersection([
sharedCreateSchema,
createTypeSpecific,
t.type({ invocationCount: t.number }),
t.type({ invocationCount: t.number, timeframeEnd: t.string }),
]);
export type PreviewRulesSchema = t.TypeOf<typeof previewRulesSchema>;

View file

@ -7,6 +7,7 @@
import React from 'react';
import { render } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { TestProviders } from '../../../../common/mock';
import type { RulePreviewProps } from '.';
@ -131,4 +132,51 @@ describe('PreviewQuery', () => {
expect(await wrapper.queryByTestId('[data-test-subj="preview-histogram-panel"]')).toBeNull();
});
test('it renders quick/advanced query toggle button', async () => {
const wrapper = render(
<TestProviders>
<RulePreview {...defaultProps} />
</TestProviders>
);
expect(await wrapper.findByTestId('quickAdvancedToggleButtonGroup')).toBeTruthy();
});
test('it renders timeframe, interval and look-back buttons when advanced query is selected', async () => {
const wrapper = render(
<TestProviders>
<RulePreview {...defaultProps} />
</TestProviders>
);
expect(await wrapper.findByTestId('quickAdvancedToggleButtonGroup')).toBeTruthy();
const advancedQueryButton = await wrapper.findByTestId('advancedQuery');
userEvent.click(advancedQueryButton);
expect(await wrapper.findByTestId('detectionEnginePreviewRuleInterval')).toBeTruthy();
expect(await wrapper.findByTestId('detectionEnginePreviewRuleLookback')).toBeTruthy();
});
test('it renders invocation count warning when advanced query is selected and warning flag is set to true', async () => {
(usePreviewRoute as jest.Mock).mockReturnValue({
hasNoiseWarning: false,
addNoiseWarning: jest.fn(),
createPreview: jest.fn(),
clearPreview: jest.fn(),
logs: [],
isPreviewRequestInProgress: false,
previewId: undefined,
showInvocationCountWarning: true,
});
const wrapper = render(
<TestProviders>
<RulePreview {...defaultProps} />
</TestProviders>
);
const advancedQueryButton = await wrapper.findByTestId('advancedQuery');
userEvent.click(advancedQueryButton);
expect(await wrapper.findByTestId('previewInvocationCountWarning')).toBeTruthy();
});
});

View file

@ -6,17 +6,23 @@
*/
import React, { useState, useEffect, useMemo, useCallback } from 'react';
import dateMath from '@kbn/datemath';
import type { Unit } from '@kbn/datemath';
import type { ThreatMapping, Type } from '@kbn/securitysolution-io-ts-alerting-types';
import styled from 'styled-components';
import type { EuiButtonGroupOptionProps, OnTimeChangeProps } from '@elastic/eui';
import {
EuiButtonGroup,
EuiCallOut,
EuiFlexGroup,
EuiFlexItem,
EuiSelect,
EuiFormRow,
EuiButton,
EuiSpacer,
EuiSuperDatePicker,
} from '@elastic/eui';
import moment from 'moment';
import { useSecurityJobs } from '../../../../common/components/ml_popover/hooks/use_security_jobs';
import type { FieldValueQueryBar } from '../query_bar';
import * as i18n from './translations';
@ -31,6 +37,10 @@ import { isJobStarted } from '../../../../../common/machine_learning/helpers';
import type { EqlOptionsSelected } from '../../../../../common/search_strategy';
import { useStartTransaction } from '../../../../common/lib/apm/use_start_transaction';
import { SINGLE_RULE_ACTIONS } from '../../../../common/lib/apm/user_actions';
import { Form, UseField, useForm, useFormData } from '../../../../shared_imports';
import { ScheduleItem } from '../schedule_item_form';
import type { AdvancedPreviewForm } from '../../../pages/detection_engine/rules/types';
import { schema } from './schema';
const HelpTextComponent = (
<EuiFlexGroup direction="column" gutterSize="none">
@ -39,6 +49,25 @@ const HelpTextComponent = (
</EuiFlexGroup>
);
const timeRanges = [
{ start: 'now/d', end: 'now', label: 'Today' },
{ start: 'now/w', end: 'now', label: 'This week' },
{ start: 'now-15m', end: 'now', label: 'Last 15 minutes' },
{ start: 'now-30m', end: 'now', label: 'Last 30 minutes' },
{ start: 'now-1h', end: 'now', label: 'Last 1 hour' },
{ start: 'now-24h', end: 'now', label: 'Last 24 hours' },
{ start: 'now-7d', end: 'now', label: 'Last 7 days' },
{ start: 'now-30d', end: 'now', label: 'Last 30 days' },
];
const QUICK_QUERY_SELECT_ID = 'quickQuery';
const ADVANCED_QUERY_SELECT_ID = 'advancedQuery';
const advancedOptionsDefaultValue = {
interval: '5m',
lookback: '1m',
};
export interface RulePreviewProps {
index: string[];
isDisabled: boolean;
@ -92,6 +121,20 @@ const RulePreviewComponent: React.FC<RulePreviewProps> = ({
}
}, [spaces]);
const [startDate, setStartDate] = useState('now-1h');
const [endDate, setEndDate] = useState('now');
const { form } = useForm<AdvancedPreviewForm>({
defaultValue: advancedOptionsDefaultValue,
options: { stripEmptyFields: false },
schema,
});
const [{ interval: formInterval, lookback: formLookback }] = useFormData<AdvancedPreviewForm>({
form,
watch: ['interval', 'lookback'],
});
const areRelaventMlJobsRunning = useMemo(() => {
if (ruleType !== 'machine_learning') {
return true; // Don't do the expensive logic if we don't need it
@ -102,6 +145,43 @@ const RulePreviewComponent: React.FC<RulePreviewProps> = ({
}
}, [jobs, machineLearningJobId, ruleType, isMlLoading]);
const [queryPreviewIdSelected, setQueryPreviewRadioIdSelected] = useState(QUICK_QUERY_SELECT_ID);
// Callback for when user toggles between Quick query and Advanced query preview
const onChangeDataSource = (optionId: string) => {
setQueryPreviewRadioIdSelected(optionId);
};
const quickAdvancedToggleButtonOptions: EuiButtonGroupOptionProps[] = useMemo(
() => [
{
id: QUICK_QUERY_SELECT_ID,
label: i18n.QUICK_PREVIEW_TOGGLE_BUTTON,
'data-test-subj': `rule-preview-toggle-${QUICK_QUERY_SELECT_ID}`,
},
{
id: ADVANCED_QUERY_SELECT_ID,
label: i18n.ADVANCED_PREVIEW_TOGGLE_BUTTON,
'data-test-subj': `rule-index-toggle-${ADVANCED_QUERY_SELECT_ID}`,
},
],
[]
);
const showAdvancedOptions = queryPreviewIdSelected === ADVANCED_QUERY_SELECT_ID;
const advancedOptions = useMemo(
() =>
showAdvancedOptions && startDate && endDate && formInterval && formLookback
? {
timeframeStart: dateMath.parse(startDate) || moment().subtract(1, 'hour'),
timeframeEnd: dateMath.parse(endDate) || moment(),
interval: formInterval,
lookback: formLookback,
}
: undefined,
[endDate, formInterval, formLookback, showAdvancedOptions, startDate]
);
const [timeFrame, setTimeFrame] = useState<Unit>(defaultTimeRange);
const {
addNoiseWarning,
@ -111,6 +191,7 @@ const RulePreviewComponent: React.FC<RulePreviewProps> = ({
logs,
hasNoiseWarning,
isAborted,
showInvocationCountWarning,
} = usePreviewRoute({
index,
isDisabled,
@ -127,6 +208,7 @@ const RulePreviewComponent: React.FC<RulePreviewProps> = ({
eqlOptions,
newTermsFields,
historyWindowSize,
advancedOptions,
});
// Resets the timeFrame to default when rule type is changed because not all time frames are supported by all rule types
@ -141,8 +223,40 @@ const RulePreviewComponent: React.FC<RulePreviewProps> = ({
createPreview();
}, [createPreview, startTransaction]);
const onTimeChange = useCallback(
({ start: newStart, end: newEnd, isInvalid }: OnTimeChangeProps) => {
if (!isInvalid) {
setStartDate(newStart);
setEndDate(newEnd);
}
},
[]
);
return (
<>
<EuiSpacer />
<EuiButtonGroup
legend="Quick query or advanced query preview selector"
data-test-subj="quickAdvancedToggleButtonGroup"
idSelected={queryPreviewIdSelected}
onChange={onChangeDataSource}
options={quickAdvancedToggleButtonOptions}
color="primary"
/>
<EuiSpacer />
{showAdvancedOptions && showInvocationCountWarning && (
<>
<EuiCallOut
color="warning"
title={i18n.QUERY_PREVIEW_INVOCATION_COUNT_WARNING_TITLE}
data-test-subj="previewInvocationCountWarning"
>
{i18n.QUERY_PREVIEW_INVOCATION_COUNT_WARNING_MESSAGE}
</EuiCallOut>
<EuiSpacer />
</>
)}
<EuiFormRow
label={i18n.QUERY_PREVIEW_LABEL}
helpText={HelpTextComponent}
@ -153,15 +267,26 @@ const RulePreviewComponent: React.FC<RulePreviewProps> = ({
>
<EuiFlexGroup>
<EuiFlexItem grow={1}>
<Select
id="preview-time-frame"
options={getTimeframeOptions(ruleType)}
value={timeFrame}
onChange={(e) => setTimeFrame(e.target.value as Unit)}
aria-label={i18n.QUERY_PREVIEW_SELECT_ARIA}
disabled={isDisabled}
data-test-subj="preview-time-frame"
/>
{showAdvancedOptions ? (
<EuiSuperDatePicker
start={startDate}
end={endDate}
onTimeChange={onTimeChange}
showUpdateButton={false}
isDisabled={isDisabled}
commonlyUsedRanges={timeRanges}
/>
) : (
<Select
id="preview-time-frame"
options={getTimeframeOptions(ruleType)}
value={timeFrame}
onChange={(e) => setTimeFrame(e.target.value as Unit)}
aria-label={i18n.QUERY_PREVIEW_SELECT_ARIA}
disabled={isDisabled}
data-test-subj="preview-time-frame"
/>
)}
</EuiFlexItem>
<EuiFlexItem grow={false}>
<PreviewButton
@ -176,7 +301,31 @@ const RulePreviewComponent: React.FC<RulePreviewProps> = ({
</EuiFlexItem>
</EuiFlexGroup>
</EuiFormRow>
<EuiSpacer size="s" />
{showAdvancedOptions && (
<Form form={form} data-test-subj="previewRule">
<EuiSpacer size="s" />
<UseField
path="interval"
component={ScheduleItem}
componentProps={{
idAria: 'detectionEnginePreviewRuleInterval',
isDisabled,
dataTestSubj: 'detectionEnginePreviewRuleInterval',
}}
/>
<UseField
path="lookback"
component={ScheduleItem}
componentProps={{
idAria: 'detectionEnginePreviewRuleLookback',
isDisabled,
dataTestSubj: 'detectionEnginePreviewRuleLookback',
minimumValue: 1,
}}
/>
<EuiSpacer size="s" />
</Form>
)}
{isPreviewRequestInProgress && <LoadingHistogram />}
{!isPreviewRequestInProgress && previewId && spaceId && (
<PreviewHistogram
@ -186,6 +335,7 @@ const RulePreviewComponent: React.FC<RulePreviewProps> = ({
addNoiseWarning={addNoiseWarning}
spaceId={spaceId}
index={index}
advancedOptions={advancedOptions}
/>
)}
<PreviewLogsComponent logs={logs} hasNoiseWarning={hasNoiseWarning} isAborted={isAborted} />

View file

@ -7,6 +7,7 @@
import React from 'react';
import { render } from '@testing-library/react';
import moment from 'moment';
import { useGlobalTime } from '../../../../common/containers/use_global_time';
import { TestProviders } from '../../../../common/mock';
@ -96,4 +97,62 @@ describe('PreviewHistogram', () => {
expect(await wrapper.findByTestId('preview-histogram-loading')).toBeTruthy();
});
});
describe('when advanced options passed', () => {
test('it uses timeframeStart and timeframeEnd to specify the time range of the preview', async () => {
const format = 'YYYY-MM-DD HH:mm:ss';
const start = '2015-03-12 05:17:10';
const end = '2020-03-12 05:17:10';
const usePreviewHistogramMock = usePreviewHistogram as jest.Mock;
usePreviewHistogramMock.mockReturnValue([
true,
{
inspect: { dsl: [], response: [] },
totalCount: 1,
refetch: jest.fn(),
data: [],
buckets: [],
},
]);
usePreviewHistogramMock.mockImplementation(
({ startDate, endDate }: { startDate: string; endDate: string }) => {
expect(startDate).toEqual('2015-03-12T09:17:10.000Z');
expect(endDate).toEqual('2020-03-12T09:17:10.000Z');
return [
true,
{
inspect: { dsl: [], response: [] },
totalCount: 1,
refetch: jest.fn(),
data: [],
buckets: [],
},
];
}
);
const wrapper = render(
<TestProviders>
<PreviewHistogram
addNoiseWarning={jest.fn()}
timeFrame="M"
previewId={'test-preview-id'}
spaceId={'default'}
ruleType={'query'}
index={['']}
advancedOptions={{
timeframeStart: moment(start, format),
timeframeEnd: moment(end, format),
interval: '5m',
lookback: '1m',
}}
/>
</TestProviders>
);
expect(await wrapper.findByTestId('preview-histogram-loading')).toBeTruthy();
});
});
});

View file

@ -41,6 +41,7 @@ import { useGlobalFullScreen } from '../../../../common/containers/use_full_scre
import { InspectButtonContainer } from '../../../../common/components/inspect';
import { timelineActions } from '../../../../timelines/store/timeline';
import type { State } from '../../../../common/store';
import type { AdvancedPreviewOptions } from '../../../pages/detection_engine/rules/types';
const LoadingChart = styled(EuiLoadingChart)`
display: block;
@ -63,6 +64,7 @@ interface PreviewHistogramProps {
spaceId: string;
ruleType: Type;
index: string[];
advancedOptions?: AdvancedPreviewOptions;
}
const DEFAULT_HISTOGRAM_HEIGHT = 300;
@ -74,14 +76,22 @@ export const PreviewHistogram = ({
spaceId,
ruleType,
index,
advancedOptions,
}: PreviewHistogramProps) => {
const dispatch = useDispatch();
const { setQuery, isInitializing } = useGlobalTime();
const { timelines: timelinesUi } = useKibana().services;
const from = useMemo(() => `now-1${timeFrame}`, [timeFrame]);
const to = useMemo(() => 'now', []);
const startDate = useMemo(() => formatDate(from), [from]);
const endDate = useMemo(() => formatDate(to), [to]);
const startDate = useMemo(
() => (advancedOptions ? advancedOptions.timeframeStart.toISOString() : formatDate(from)),
[from, advancedOptions]
);
const endDate = useMemo(
() => (advancedOptions ? advancedOptions.timeframeEnd.toISOString() : formatDate(to)),
[to, advancedOptions]
);
const alertsEndDate = useMemo(() => formatDate(to), [to]);
const isEqlRule = useMemo(() => ruleType === 'eql', [ruleType]);
const isMlRule = useMemo(() => ruleType === 'machine_learning', [ruleType]);
@ -204,7 +214,7 @@ export const PreviewHistogram = ({
dataProviders,
deletedEventIds,
disabledCellActions: FIELDS_WITHOUT_CELL_ACTIONS,
end: endDate,
end: alertsEndDate,
entityType: 'events',
filters: [],
globalFullScreen,

View file

@ -0,0 +1,43 @@
/*
* 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.
*/
/* istanbul ignore file */
import { i18n } from '@kbn/i18n';
import { OptionalFieldLabel } from '../optional_field_label';
import type { AdvancedPreviewForm } from '../../../pages/detection_engine/rules/types';
import type { FormSchema } from '../../../../shared_imports';
export const schema: FormSchema<AdvancedPreviewForm> = {
interval: {
label: i18n.translate('xpack.securitySolution.detectionEngine.previewRule.fieldIntervalLabel', {
defaultMessage: 'Runs every (Rule interval)',
}),
helpText: i18n.translate(
'xpack.securitySolution.detectionEngine.previewRule.fieldIntervalHelpText',
{
defaultMessage: 'Rules run periodically and detect alerts within the specified time frame.',
}
),
},
lookback: {
label: i18n.translate(
'xpack.securitySolution.detectionEngine.previewRule.fieldAdditionalLookBackLabel',
{
defaultMessage: 'Additional look-back time',
}
),
labelAppend: OptionalFieldLabel,
helpText: i18n.translate(
'xpack.securitySolution.detectionEngine.previewRule.fieldAdditionalLookBackHelpText',
{
defaultMessage: 'Adds time to the look-back period to prevent missed alerts.',
}
),
},
};

View file

@ -30,6 +30,20 @@ export const QUERY_PREVIEW_BUTTON = i18n.translate(
}
);
export const QUICK_PREVIEW_TOGGLE_BUTTON = i18n.translate(
'xpack.securitySolution.stepDefineRule.quickPreviewToggleButton',
{
defaultMessage: 'Quick query preview',
}
);
export const ADVANCED_PREVIEW_TOGGLE_BUTTON = i18n.translate(
'xpack.securitySolution.stepDefineRule.advancedPreviewToggleButton',
{
defaultMessage: 'Advanced query preview',
}
);
export const PREVIEW_TIMEOUT_WARNING = i18n.translate(
'xpack.securitySolution.stepDefineRule.previewTimeoutWarning',
{
@ -47,7 +61,7 @@ export const QUERY_PREVIEW_SELECT_ARIA = i18n.translate(
export const QUERY_PREVIEW_LABEL = i18n.translate(
'xpack.securitySolution.detectionEngine.queryPreview.queryPreviewLabel',
{
defaultMessage: 'Quick query preview',
defaultMessage: 'Timeframe',
}
);
@ -58,6 +72,20 @@ export const QUERY_PREVIEW_HELP_TEXT = i18n.translate(
}
);
export const QUERY_PREVIEW_INVOCATION_COUNT_WARNING_TITLE = i18n.translate(
'xpack.securitySolution.detectionEngine.queryPreview.queryPreviewInvocationCountWarningTitle',
{
defaultMessage: 'Rule preview timeframe might cause timeout',
}
);
export const QUERY_PREVIEW_INVOCATION_COUNT_WARNING_MESSAGE = i18n.translate(
'xpack.securitySolution.detectionEngine.queryPreview.queryPreviewInvocationCountWarningMessage',
{
defaultMessage: `The timeframe and rule interval that you selected for previewing this rule might cause timeout or take long time to execute. Try to decrease the timeframe and/or increase the interval if preview has timed out (this won't affect the actual rule run).`,
}
);
export const QUERY_GRAPH_COUNT = i18n.translate(
'xpack.securitySolution.detectionEngine.queryPreview.queryGraphCountLabel',
{

View file

@ -14,6 +14,7 @@ import { formatPreviewRule } from '../../../pages/detection_engine/rules/create/
import type { FieldValueThreshold } from '../threshold_input';
import type { RulePreviewLogs } from '../../../../../common/detection_engine/schemas/request';
import type { EqlOptionsSelected } from '../../../../../common/search_strategy';
import type { AdvancedPreviewOptions } from '../../../pages/detection_engine/rules/types';
interface PreviewRouteParams {
isDisabled: boolean;
@ -31,6 +32,7 @@ interface PreviewRouteParams {
eqlOptions: EqlOptionsSelected;
newTermsFields: string[];
historyWindowSize: string;
advancedOptions?: AdvancedPreviewOptions;
}
export const usePreviewRoute = ({
@ -49,10 +51,14 @@ export const usePreviewRoute = ({
eqlOptions,
newTermsFields,
historyWindowSize,
advancedOptions,
}: PreviewRouteParams) => {
const [isRequestTriggered, setIsRequestTriggered] = useState(false);
const { isLoading, response, rule, setRule } = usePreviewRule(timeFrame);
const { isLoading, showInvocationCountWarning, response, rule, setRule } = usePreviewRule({
timeframe: timeFrame,
advancedOptions,
});
const [logs, setLogs] = useState<RulePreviewLogs[]>(response.logs ?? []);
const [isAborted, setIsAborted] = useState<boolean>(!!response.isAborted);
const [hasNoiseWarning, setHasNoiseWarning] = useState<boolean>(false);
@ -92,6 +98,7 @@ export const usePreviewRoute = ({
eqlOptions,
newTermsFields,
historyWindowSize,
advancedOptions,
]);
useEffect(() => {
@ -112,6 +119,7 @@ export const usePreviewRoute = ({
eqlOptions,
newTermsFields,
historyWindowSize,
advancedOptions,
})
);
}
@ -133,6 +141,7 @@ export const usePreviewRoute = ({
eqlOptions,
newTermsFields,
historyWindowSize,
advancedOptions,
]);
return {
@ -144,5 +153,6 @@ export const usePreviewRoute = ({
previewId: response.previewId ?? '',
logs,
isAborted,
showInvocationCountWarning,
};
};

View file

@ -96,9 +96,12 @@ describe('Detections Rules API', () => {
test('POSTs rule', async () => {
const payload = getCreateRulesSchemaMock();
await previewRule({ rule: { ...payload, invocationCount: 1 }, signal: abortCtrl.signal });
await previewRule({
rule: { ...payload, invocationCount: 1, timeframeEnd: '2015-03-12 05:17:10' },
signal: abortCtrl.signal,
});
expect(fetchMock).toHaveBeenCalledWith('/api/detection_engine/rules/preview', {
body: '{"description":"Detecting root and admin users","name":"Query with a rule id","query":"user.name: root or user.name: admin","severity":"high","type":"query","risk_score":55,"language":"kuery","rule_id":"rule-1","invocationCount":1}',
body: '{"description":"Detecting root and admin users","name":"Query with a rule id","query":"user.name: root or user.name: admin","severity":"high","type":"query","risk_score":55,"language":"kuery","rule_id":"rule-1","invocationCount":1,"timeframeEnd":"2015-03-12 05:17:10"}',
method: 'POST',
signal: abortCtrl.signal,
});

View file

@ -77,7 +77,7 @@ export interface CreateRulesProps {
}
export interface PreviewRulesProps {
rule: CreateRulesSchema & { invocationCount: number };
rule: CreateRulesSchema & { invocationCount: number; timeframeEnd: string };
signal: AbortSignal;
}

View file

@ -5,7 +5,8 @@
* 2.0.
*/
import { useEffect, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import moment from 'moment';
import type { Unit } from '@kbn/datemath';
import {
@ -22,6 +23,10 @@ import type {
import { previewRule } from './api';
import * as i18n from './translations';
import { transformOutput } from './transforms';
import type { AdvancedPreviewOptions } from '../../../pages/detection_engine/rules/types';
import { getTimeTypeValue } from '../../../pages/detection_engine/rules/create/helpers';
const REASONABLE_INVOCATION_COUNT = 200;
const emptyPreviewRule: PreviewResponse = {
previewId: undefined,
@ -29,14 +34,20 @@ const emptyPreviewRule: PreviewResponse = {
isAborted: false,
};
export const usePreviewRule = (timeframe: Unit = 'h') => {
export const usePreviewRule = ({
timeframe = 'h',
advancedOptions,
}: {
timeframe: Unit;
advancedOptions?: AdvancedPreviewOptions;
}) => {
const [rule, setRule] = useState<CreateRulesSchema | null>(null);
const [response, setResponse] = useState<PreviewResponse>(emptyPreviewRule);
const [isLoading, setIsLoading] = useState(false);
const { addError } = useAppToasts();
let invocationCount = RULE_PREVIEW_INVOCATION_COUNT.HOUR;
let interval = RULE_PREVIEW_INTERVAL.HOUR;
let from = RULE_PREVIEW_FROM.HOUR;
let interval: string = RULE_PREVIEW_INTERVAL.HOUR;
let from: string = RULE_PREVIEW_FROM.HOUR;
switch (timeframe) {
case 'd':
@ -55,6 +66,28 @@ export const usePreviewRule = (timeframe: Unit = 'h') => {
from = RULE_PREVIEW_FROM.MONTH;
break;
}
const timeframeEnd = useMemo(
() => (advancedOptions ? advancedOptions.timeframeEnd.toISOString() : moment().toISOString()),
[advancedOptions]
);
if (advancedOptions) {
const timeframeDuration =
(advancedOptions.timeframeEnd.valueOf() / 1000 -
advancedOptions.timeframeStart.valueOf() / 1000) *
1000;
const { unit: intervalUnit, value: intervalValue } = getTimeTypeValue(advancedOptions.interval);
const { unit: lookbackUnit, value: lookbackValue } = getTimeTypeValue(advancedOptions.lookback);
const duration = moment.duration(intervalValue, intervalUnit as 's' | 'm' | 'h');
duration.add(lookbackValue, lookbackUnit as 's' | 'm' | 'h');
const ruleIntervalDuration = duration.asMilliseconds();
invocationCount = Math.max(Math.ceil(timeframeDuration / ruleIntervalDuration), 1);
interval = advancedOptions.interval;
from = `now-${duration.asSeconds()}s`;
}
const showInvocationCountWarning = invocationCount > REASONABLE_INVOCATION_COUNT;
useEffect(() => {
if (!rule) {
@ -79,6 +112,7 @@ export const usePreviewRule = (timeframe: Unit = 'h') => {
from,
}),
invocationCount,
timeframeEnd,
},
signal: abortCtrl.signal,
});
@ -101,7 +135,7 @@ export const usePreviewRule = (timeframe: Unit = 'h') => {
isSubscribed = false;
abortCtrl.abort();
};
}, [rule, addError, invocationCount, from, interval]);
}, [rule, addError, invocationCount, from, interval, timeframeEnd]);
return { isLoading, response, rule, setRule };
return { isLoading, showInvocationCountWarning, response, rule, setRule };
};

View file

@ -39,6 +39,7 @@ import type {
ActionsStepRuleJson,
RuleStepsFormData,
RuleStep,
AdvancedPreviewOptions,
} from '../types';
import { DataSourceType } from '../types';
import type { FieldValueQueryBar } from '../../../../components/rules/query_bar';
@ -591,6 +592,7 @@ export const formatPreviewRule = ({
eqlOptions,
newTermsFields,
historyWindowSize,
advancedOptions,
}: {
index: string[];
dataViewId?: string;
@ -606,6 +608,7 @@ export const formatPreviewRule = ({
eqlOptions: EqlOptionsSelected;
newTermsFields: string[];
historyWindowSize: string;
advancedOptions?: AdvancedPreviewOptions;
}): CreateRulesSchema => {
const defineStepData = {
...stepDefineDefaultValue,
@ -628,10 +631,16 @@ export const formatPreviewRule = ({
name: 'Preview Rule',
description: 'Preview Rule',
};
const scheduleStepData = {
let scheduleStepData = {
from: `now-${timeFrame === 'M' ? '25h' : timeFrame === 'd' ? '65m' : '6m'}`,
interval: `${timeFrame === 'M' ? '1d' : timeFrame === 'd' ? '1h' : '5m'}`,
};
if (advancedOptions) {
scheduleStepData = {
interval: advancedOptions.interval,
from: advancedOptions.lookback,
};
}
return {
...formatRule<CreateRulesSchema>(
defineStepData,
@ -639,6 +648,6 @@ export const formatPreviewRule = ({
scheduleStepData,
stepActionsDefaultValue
),
...scheduleStepData,
...(!advancedOptions ? scheduleStepData : {}),
};
};

View file

@ -241,3 +241,15 @@ export interface ActionsStepRuleJson {
throttle?: string | null;
meta?: unknown;
}
export interface AdvancedPreviewForm {
interval: string;
lookback: string;
}
export interface AdvancedPreviewOptions {
timeframeStart: moment.Moment;
timeframeEnd: moment.Moment;
interval: string;
lookback: string;
}

View file

@ -52,7 +52,6 @@ import {
createNewTermsAlertType,
} from '../../rule_types';
import { createSecurityRuleTypeWrapper } from '../../rule_types/create_security_rule_type_wrapper';
import { RULE_PREVIEW_INVOCATION_COUNT } from '../../../../../common/detection_engine/constants';
import { assertUnreachable } from '../../../../../common/utility_types';
import { wrapSearchSourceClient } from './utils/wrap_search_source_client';
@ -91,15 +90,9 @@ export const previewRulesRoute = async (
const savedObjectsClient = coreContext.savedObjects.client;
const siemClient = (await context.securitySolution).getAppClient();
const timeframeEnd = request.body.timeframeEnd;
let invocationCount = request.body.invocationCount;
if (
![
RULE_PREVIEW_INVOCATION_COUNT.HOUR,
RULE_PREVIEW_INVOCATION_COUNT.DAY,
RULE_PREVIEW_INVOCATION_COUNT.WEEK,
RULE_PREVIEW_INVOCATION_COUNT.MONTH,
].includes(invocationCount)
) {
if (invocationCount < 1) {
return response.ok({
body: { logs: [{ errors: ['Invalid invocation count'], warnings: [], duration: 0 }] },
});
@ -204,7 +197,7 @@ export const previewRulesRoute = async (
isAborted = true;
}, PREVIEW_TIMEOUT_SECONDS * 1000);
const startedAt = moment();
const startedAt = moment(timeframeEnd);
const parsedDuration = parseDuration(internalRule.schedule.interval) ?? 0;
startedAt.subtract(moment.duration(parsedDuration * (invocationCount - 1)));

View file

@ -62,7 +62,7 @@ export default ({ getService }: FtrProviderContext) => {
const { body } = await supertest
.post(DETECTION_ENGINE_RULES_PREVIEW)
.set('kbn-xsrf', 'true')
.send(getSimplePreviewRule('', 3))
.send(getSimplePreviewRule('', 0))
.expect(200);
const { logs } = getSimpleRulePreviewOutput(undefined, [
{ errors: ['Invalid invocation count'], warnings: [], duration: 0 },

View file

@ -26,4 +26,5 @@ export const getSimplePreviewRule = (
type: 'query',
query: 'user.name: root or user.name: admin',
invocationCount,
timeframeEnd: new Date().toISOString(),
});