diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/rule_schemas.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/rule_schemas.ts index fffbbb8078705..e55fb9b6d763c 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/rule_schemas.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/rule_schemas.ts @@ -415,7 +415,7 @@ export type CreateRulesSchema = t.TypeOf; 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; diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/rule_preview/index.test.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/rule_preview/index.test.tsx index 200abf294e47b..bd0cfac44f69d 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/rule_preview/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/rule_preview/index.test.tsx @@ -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( + + + + ); + + expect(await wrapper.findByTestId('quickAdvancedToggleButtonGroup')).toBeTruthy(); + }); + + test('it renders timeframe, interval and look-back buttons when advanced query is selected', async () => { + const wrapper = render( + + + + ); + + 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( + + + + ); + + const advancedQueryButton = await wrapper.findByTestId('advancedQuery'); + userEvent.click(advancedQueryButton); + expect(await wrapper.findByTestId('previewInvocationCountWarning')).toBeTruthy(); + }); }); diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/rule_preview/index.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/rule_preview/index.tsx index b7bc9d5593cf3..9744e3fcf720e 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/rule_preview/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/rule_preview/index.tsx @@ -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 = ( @@ -39,6 +49,25 @@ const HelpTextComponent = ( ); +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 = ({ } }, [spaces]); + const [startDate, setStartDate] = useState('now-1h'); + const [endDate, setEndDate] = useState('now'); + + const { form } = useForm({ + defaultValue: advancedOptionsDefaultValue, + options: { stripEmptyFields: false }, + schema, + }); + + const [{ interval: formInterval, lookback: formLookback }] = useFormData({ + 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 = ({ } }, [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(defaultTimeRange); const { addNoiseWarning, @@ -111,6 +191,7 @@ const RulePreviewComponent: React.FC = ({ logs, hasNoiseWarning, isAborted, + showInvocationCountWarning, } = usePreviewRoute({ index, isDisabled, @@ -127,6 +208,7 @@ const RulePreviewComponent: React.FC = ({ 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 = ({ createPreview(); }, [createPreview, startTransaction]); + const onTimeChange = useCallback( + ({ start: newStart, end: newEnd, isInvalid }: OnTimeChangeProps) => { + if (!isInvalid) { + setStartDate(newStart); + setEndDate(newEnd); + } + }, + [] + ); + return ( <> + + + + {showAdvancedOptions && showInvocationCountWarning && ( + <> + + {i18n.QUERY_PREVIEW_INVOCATION_COUNT_WARNING_MESSAGE} + + + + )} = ({ > - setTimeFrame(e.target.value as Unit)} + aria-label={i18n.QUERY_PREVIEW_SELECT_ARIA} + disabled={isDisabled} + data-test-subj="preview-time-frame" + /> + )} = ({ - + {showAdvancedOptions && ( +
+ + + + + + )} {isPreviewRequestInProgress && } {!isPreviewRequestInProgress && previewId && spaceId && ( = ({ addNoiseWarning={addNoiseWarning} spaceId={spaceId} index={index} + advancedOptions={advancedOptions} /> )} diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/rule_preview/preview_histogram.test.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/rule_preview/preview_histogram.test.tsx index 59c0af79391bd..7f4e79c0a41fa 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/rule_preview/preview_histogram.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/rule_preview/preview_histogram.test.tsx @@ -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( + + + + ); + + expect(await wrapper.findByTestId('preview-histogram-loading')).toBeTruthy(); + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/rule_preview/preview_histogram.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/rule_preview/preview_histogram.tsx index 36e765119e5e7..3c38e6e1a1e17 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/rule_preview/preview_histogram.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/rule_preview/preview_histogram.tsx @@ -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, diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/rule_preview/schema.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/rule_preview/schema.tsx new file mode 100644 index 0000000000000..85521359ed25d --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/rules/rule_preview/schema.tsx @@ -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 = { + 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.', + } + ), + }, +}; diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/rule_preview/translations.ts b/x-pack/plugins/security_solution/public/detections/components/rules/rule_preview/translations.ts index 4d8679b91f21b..42db4d75c4951 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/rule_preview/translations.ts +++ b/x-pack/plugins/security_solution/public/detections/components/rules/rule_preview/translations.ts @@ -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', { diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/rule_preview/use_preview_route.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/rule_preview/use_preview_route.tsx index 5b0251669e445..94817129fc9d0 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/rule_preview/use_preview_route.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/rule_preview/use_preview_route.tsx @@ -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(response.logs ?? []); const [isAborted, setIsAborted] = useState(!!response.isAborted); const [hasNoiseWarning, setHasNoiseWarning] = useState(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, }; }; diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/api.test.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/api.test.ts index 28d708743419a..48fa12f03565f 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/api.test.ts +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/api.test.ts @@ -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, }); diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/types.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/types.ts index ed5ebc233f9ad..eaf9b3288dc2d 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/types.ts +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/types.ts @@ -77,7 +77,7 @@ export interface CreateRulesProps { } export interface PreviewRulesProps { - rule: CreateRulesSchema & { invocationCount: number }; + rule: CreateRulesSchema & { invocationCount: number; timeframeEnd: string }; signal: AbortSignal; } diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_preview_rule.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_preview_rule.ts index a31cee9309d6a..165e14ba0d159 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_preview_rule.ts +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_preview_rule.ts @@ -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(null); const [response, setResponse] = useState(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 }; }; diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/helpers.ts b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/helpers.ts index e792a98b15459..bec0746bde5d1 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/helpers.ts +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/helpers.ts @@ -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( defineStepData, @@ -639,6 +648,6 @@ export const formatPreviewRule = ({ scheduleStepData, stepActionsDefaultValue ), - ...scheduleStepData, + ...(!advancedOptions ? scheduleStepData : {}), }; }; diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/types.ts b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/types.ts index 6e7dfe76bf5b1..ce4060dcf6e87 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/types.ts +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/types.ts @@ -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; +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/preview_rules_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/preview_rules_route.ts index 2a0615b17c1ac..4d649ccfaa6ee 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/preview_rules_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/preview_rules_route.ts @@ -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))); diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/group1/preview_rules.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/group1/preview_rules.ts index 4f49e5ec17a6d..3e0dd166a0cd2 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/group1/preview_rules.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/group1/preview_rules.ts @@ -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 }, diff --git a/x-pack/test/detection_engine_api_integration/utils/get_simple_preview_rule.ts b/x-pack/test/detection_engine_api_integration/utils/get_simple_preview_rule.ts index 5232af55b89b6..fa67ae3eeba80 100644 --- a/x-pack/test/detection_engine_api_integration/utils/get_simple_preview_rule.ts +++ b/x-pack/test/detection_engine_api_integration/utils/get_simple_preview_rule.ts @@ -26,4 +26,5 @@ export const getSimplePreviewRule = ( type: 'query', query: 'user.name: root or user.name: admin', invocationCount, + timeframeEnd: new Date().toISOString(), });