[Security Solution] Allow users to edit max_signals field for custom rules (#179680)

**Resolves: https://github.com/elastic/kibana/issues/173593**
**Fixes: https://github.com/elastic/kibana/issues/164234**

## Summary

Adds a number component in the create and edit rule forms so that users
are able to customize the `max_signals` value for custom rules from the
UI. Also adds validations to the rule API's for invalid values being
passed in.

This PR also exposes the `xpack.alerting.rules.run.alerts.max` config
setting from the alerting framework to the frontend and backend so that
we can validate against it as it supersedes our own `max_signals` value.

[Flaky test run (internal)

](https://buildkite.com/elastic/kibana-flaky-test-suite-runner/builds/5601)

### Screenshots
**Form component**
<p align="center">
<img width="887" alt="Screenshot 2024-04-08 at 11 02 12 PM"
src="58cd2f6d-61b6-4343-8025-ff867c050dd7">
</p>

**Details Page**
<p align="center">
<img width="595" alt="Screenshot 2024-04-08 at 11 04 04 PM"
src="d2c61593-3d35-408e-b047-b4d1f68898f8">
</p>

**Error state**
<p align="center">
<img width="857" alt="Screenshot 2024-04-08 at 11 01 55 PM"
src="86e64280-7b81-46f2-b223-fde8c20066c8">
</p>

**Warning state**
<p align="center">
<img width="601" alt="Screenshot 2024-04-16 at 3 20 00 PM"
src="eab07d62-3d3e-4c85-8468-36c3e56c5a99">
</p>

### Checklist

Delete any items that are not applicable to this PR.

- [x] Any text added follows [EUI's writing
guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses
sentence case text and includes [i18n
support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md)
- [x]
[Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html)
was added for features that require explanation or tutorials
- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
- [x] [Flaky Test
Runner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1) was
used on any tests changed
- [x] Any UI touched in this PR is usable by keyboard only (learn more
about [keyboard accessibility](https://webaim.org/techniques/keyboard/))


### For maintainers

- [ ] This was checked for breaking API changes and was [labeled
appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)

---------

Co-authored-by: Juan Pablo Djeredjian <jpdjeredjian@gmail.com>
This commit is contained in:
Davis Plumlee 2024-05-03 15:00:08 -04:00 committed by GitHub
parent 8575fc52c1
commit f841763d50
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
54 changed files with 636 additions and 81 deletions

View file

@ -330,6 +330,7 @@ export default function ({ getService }: PluginFunctionalProviderContext) {
'xpack.stack_connectors.enableExperimental (array)',
'xpack.trigger_actions_ui.enableExperimental (array)',
'xpack.trigger_actions_ui.enableGeoTrackingThresholdAlert (boolean)',
'xpack.alerting.rules.run.alerts.max (number)',
'xpack.upgrade_assistant.featureSet.migrateSystemIndices (boolean)',
'xpack.upgrade_assistant.featureSet.mlSnapshots (boolean)',
'xpack.upgrade_assistant.featureSet.reindexCorrectiveActions (boolean)',

View file

@ -17,6 +17,7 @@ const createSetupContract = (): Setup => ({
const createStartContract = (): Start => ({
getNavigation: jest.fn(),
getMaxAlertsPerRun: jest.fn(),
});
export const alertingPluginMock = {

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import { AlertingPublicPlugin } from './plugin';
import { AlertingPublicPlugin, AlertingUIConfig } from './plugin';
import { coreMock } from '@kbn/core/public/mocks';
import {
createManagementSectionMock,
@ -17,7 +17,17 @@ jest.mock('./services/rule_api', () => ({
loadRuleType: jest.fn(),
}));
const mockInitializerContext = coreMock.createPluginInitializerContext();
const mockAlertingUIConfig: AlertingUIConfig = {
rules: {
run: {
alerts: {
max: 1000,
},
},
},
};
const mockInitializerContext = coreMock.createPluginInitializerContext(mockAlertingUIConfig);
const management = managementPluginMock.createSetupContract();
const mockSection = createManagementSectionMock();

View file

@ -57,6 +57,7 @@ export interface PluginSetupContract {
}
export interface PluginStartContract {
getNavigation: (ruleId: Rule['id']) => Promise<string | undefined>;
getMaxAlertsPerRun: () => number;
}
export interface AlertingPluginSetup {
management: ManagementSetup;
@ -69,13 +70,28 @@ export interface AlertingPluginStart {
data: DataPublicPluginStart;
}
export interface AlertingUIConfig {
rules: {
run: {
alerts: {
max: number;
};
};
};
}
export class AlertingPublicPlugin
implements
Plugin<PluginSetupContract, PluginStartContract, AlertingPluginSetup, AlertingPluginStart>
{
private alertNavigationRegistry?: AlertNavigationRegistry;
private config: AlertingUIConfig;
readonly maxAlertsPerRun: number;
constructor(private readonly initContext: PluginInitializerContext) {}
constructor(private readonly initContext: PluginInitializerContext) {
this.config = this.initContext.config.get<AlertingUIConfig>();
this.maxAlertsPerRun = this.config.rules.run.alerts.max;
}
public setup(core: CoreSetup, plugins: AlertingPluginSetup) {
this.alertNavigationRegistry = new AlertNavigationRegistry();
@ -150,6 +166,9 @@ export class AlertingPublicPlugin
return rule.viewInAppRelativeUrl;
}
},
getMaxAlertsPerRun: () => {
return this.maxAlertsPerRun;
},
};
}
}

View file

@ -80,7 +80,7 @@ export type AlertingConfig = TypeOf<typeof configSchema>;
export type RulesConfig = TypeOf<typeof rulesSchema>;
export type AlertingRulesConfig = Pick<
AlertingConfig['rules'],
'minimumScheduleInterval' | 'maxScheduledPerMinute'
'minimumScheduleInterval' | 'maxScheduledPerMinute' | 'run'
> & {
isUsingSecurity: boolean;
};

View file

@ -7,8 +7,7 @@
import type { PublicMethodsOf } from '@kbn/utility-types';
import { PluginConfigDescriptor, PluginInitializerContext } from '@kbn/core/server';
import { RulesClient as RulesClientClass } from './rules_client';
import { configSchema } from './config';
import { AlertsConfigType } from './types';
import { AlertingConfig, configSchema } from './config';
export type RulesClient = PublicMethodsOf<RulesClientClass>;
@ -79,8 +78,11 @@ export const plugin = async (initContext: PluginInitializerContext) => {
return new AlertingPlugin(initContext);
};
export const config: PluginConfigDescriptor<AlertsConfigType> = {
export const config: PluginConfigDescriptor<AlertingConfig> = {
schema: configSchema,
exposeToBrowser: {
rules: { run: { alerts: { max: true } } },
},
deprecations: ({ renameFromRoot, deprecate }) => [
renameFromRoot('xpack.alerts.healthCheck', 'xpack.alerting.healthCheck', { level: 'warning' }),
renameFromRoot(

View file

@ -163,6 +163,7 @@ describe('Alerting Plugin', () => {
maxScheduledPerMinute: 10000,
isUsingSecurity: false,
minimumScheduleInterval: { value: '1m', enforce: false },
run: { alerts: { max: 1000 }, actions: { max: 1000 } },
});
expect(setupContract.frameworkAlerts.enabled()).toEqual(false);

View file

@ -458,7 +458,7 @@ export class AlertingPlugin {
},
getConfig: () => {
return {
...pick(this.config.rules, ['minimumScheduleInterval', 'maxScheduledPerMinute']),
...pick(this.config.rules, ['minimumScheduleInterval', 'maxScheduledPerMinute', 'run']),
isUsingSecurity: this.licenseState ? !!this.licenseState.getIsSecurityEnabled() : false,
};
},

View file

@ -123,7 +123,6 @@ components:
references:
$ref: './common_attributes.schema.yaml#/components/schemas/RuleReferenceArray'
# maxSignals not used in ML rules but probably should be used
max_signals:
$ref: './common_attributes.schema.yaml#/components/schemas/MaxSignals'
threat:

View file

@ -58,6 +58,7 @@ import { fieldFormatsMock } from '@kbn/field-formats-plugin/common/mocks';
import { indexPatternFieldEditorPluginMock } from '@kbn/data-view-field-editor-plugin/public/mocks';
import { UpsellingService } from '@kbn/security-solution-upselling/service';
import { calculateBounds } from '@kbn/data-plugin/common';
import { alertingPluginMock } from '@kbn/alerting-plugin/public/mocks';
const mockUiSettings: Record<string, unknown> = {
[DEFAULT_TIME_RANGE]: { from: 'now-15m', to: 'now', mode: 'quick' },
@ -128,6 +129,7 @@ export const createStartServicesMock = (
const cloud = cloudMock.createStart();
const mockSetHeaderActionMenu = jest.fn();
const mockTimelineFilterManager = createFilterManagerMock();
const alerting = alertingPluginMock.createStartContract();
/*
* Below mocks are needed by unified field list
@ -250,6 +252,7 @@ export const createStartServicesMock = (
dataViewFieldEditor: indexPatternFieldEditorPluginMock.createStartContract(),
upselling: new UpsellingService(),
timelineFilterManager: mockTimelineFilterManager,
alerting,
} as unknown as StartServices;
};

View file

@ -263,7 +263,7 @@ describe('description_step', () => {
mockLicenseService
);
expect(result.length).toEqual(13);
expect(result.length).toEqual(14);
});
});
@ -768,6 +768,33 @@ describe('description_step', () => {
});
});
});
describe('maxSignals', () => {
test('returns default "max signals" description', () => {
const result: ListItems[] = getDescriptionItem(
'maxSignals',
'Max alerts per run',
mockAboutStep,
mockFilterManager,
mockLicenseService
);
expect(result[0].title).toEqual('Max alerts per run');
expect(result[0].description).toEqual(100);
});
test('returns empty array when "value" is a undefined', () => {
const result: ListItems[] = getDescriptionItem(
'maxSignals',
'Max alerts per run',
{ ...mockAboutStep, maxSignals: undefined },
mockFilterManager,
mockLicenseService
);
expect(result.length).toEqual(0);
});
});
});
});
});

View file

@ -342,6 +342,9 @@ export const getDescriptionItem = (
return get('isBuildingBlock', data)
? [{ title: i18n.BUILDING_BLOCK_LABEL, description: i18n.BUILDING_BLOCK_DESCRIPTION }]
: [];
} else if (field === 'maxSignals') {
const value: number | undefined = get(field, data);
return value ? [{ title: label, description: value }] : [];
}
const description: string = get(field, data);

View file

@ -0,0 +1,99 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useMemo, useCallback } from 'react';
import type { EuiFieldNumberProps } from '@elastic/eui';
import { EuiTextColor, EuiFormRow, EuiFieldNumber, EuiIcon } from '@elastic/eui';
import type { FieldHook } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib';
import { css } from '@emotion/css';
import { DEFAULT_MAX_SIGNALS } from '../../../../../common/constants';
import * as i18n from './translations';
import { useKibana } from '../../../../common/lib/kibana';
interface MaxSignalsFieldProps {
dataTestSubj: string;
field: FieldHook<number | ''>;
idAria: string;
isDisabled: boolean;
placeholder?: string;
}
const MAX_SIGNALS_FIELD_WIDTH = 200;
export const MaxSignals: React.FC<MaxSignalsFieldProps> = ({
dataTestSubj,
field,
idAria,
isDisabled,
placeholder,
}): JSX.Element => {
const { setValue, value } = field;
const { alerting } = useKibana().services;
const maxAlertsPerRun = alerting.getMaxAlertsPerRun();
const [isInvalid, error] = useMemo(() => {
if (typeof value === 'number' && !isNaN(value) && value <= 0) {
return [true, i18n.GREATER_THAN_ERROR];
}
return [false];
}, [value]);
const hasWarning = useMemo(
() => typeof value === 'number' && !isNaN(value) && value > maxAlertsPerRun,
[maxAlertsPerRun, value]
);
const handleMaxSignalsChange: EuiFieldNumberProps['onChange'] = useCallback(
(e) => {
const maxSignalsValue = (e.target as HTMLInputElement).value;
// Has to handle an empty string as the field is optional
setValue(maxSignalsValue !== '' ? Number(maxSignalsValue.trim()) : '');
},
[setValue]
);
const helpText = useMemo(() => {
const textToRender = [];
if (hasWarning) {
textToRender.push(
<EuiTextColor color="warning">{i18n.LESS_THAN_WARNING(maxAlertsPerRun)}</EuiTextColor>
);
}
textToRender.push(i18n.MAX_SIGNALS_HELP_TEXT(DEFAULT_MAX_SIGNALS));
return textToRender;
}, [hasWarning, maxAlertsPerRun]);
return (
<EuiFormRow
css={css`
.euiFormControlLayout {
width: ${MAX_SIGNALS_FIELD_WIDTH}px;
}
`}
describedByIds={idAria ? [idAria] : undefined}
fullWidth
helpText={helpText}
label={field.label}
labelAppend={field.labelAppend}
isInvalid={isInvalid}
error={error}
>
<EuiFieldNumber
isInvalid={isInvalid}
value={value as EuiFieldNumberProps['value']}
onChange={handleMaxSignalsChange}
isLoading={field.isValidating}
placeholder={placeholder}
data-test-subj={dataTestSubj}
disabled={isDisabled}
append={hasWarning ? <EuiIcon size="s" type="warning" color="warning" /> : undefined}
/>
</EuiFormRow>
);
};
MaxSignals.displayName = 'MaxSignals';

View file

@ -0,0 +1,35 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
export const GREATER_THAN_ERROR = i18n.translate(
'xpack.securitySolution.detectionEngine.createRule.stepAboutRule.maxAlertsFieldGreaterThanError',
{
defaultMessage: 'Max alerts must be greater than 0.',
}
);
export const LESS_THAN_WARNING = (maxNumber: number) =>
i18n.translate(
'xpack.securitySolution.detectionEngine.createRule.stepAboutRule.maxAlertsFieldLessThanWarning',
{
values: { maxNumber },
defaultMessage:
'Kibana only allows a maximum of {maxNumber} {maxNumber, plural, =1 {alert} other {alerts}} per rule run.',
}
);
export const MAX_SIGNALS_HELP_TEXT = (defaultNumber: number) =>
i18n.translate(
'xpack.securitySolution.detectionEngine.createRule.stepAboutRule.fieldMaxAlertsHelpText',
{
values: { defaultNumber },
defaultMessage:
'The maximum number of alerts the rule will create each time it runs. Default is {defaultNumber}.',
}
);

View file

@ -5,6 +5,7 @@
* 2.0.
*/
import { DEFAULT_MAX_SIGNALS } from '../../../../../common/constants';
import type { AboutStepRule } from '../../../../detections/pages/detection_engine/rules/types';
import { fillEmptySeverityMappings } from '../../../../detections/pages/detection_engine/rules/helpers';
@ -33,5 +34,6 @@ export const stepAboutDefaultValue: AboutStepRule = {
timestampOverride: '',
threat: threatDefault,
note: '',
maxSignals: DEFAULT_MAX_SIGNALS,
setup: '',
};

View file

@ -34,6 +34,8 @@ import {
stepDefineDefaultValue,
} from '../../../../detections/pages/detection_engine/rules/utils';
import type { FormHook } from '../../../../shared_imports';
import { useKibana as mockUseKibana } from '../../../../common/lib/kibana/__mocks__';
import { useKibana } from '../../../../common/lib/kibana';
jest.mock('../../../../common/lib/kibana');
jest.mock('../../../../common/containers/source');
@ -50,6 +52,7 @@ jest.mock('@elastic/eui', () => {
},
};
});
const mockedUseKibana = mockUseKibana();
export const stepDefineStepMLRule: DefineStepRule = {
ruleType: 'machine_learning',
@ -118,6 +121,7 @@ describe('StepAboutRuleComponent', () => {
indexPatterns: stubIndexPattern,
},
]);
(useKibana as jest.Mock).mockReturnValue(mockedUseKibana);
useGetInstalledJobMock = (useGetInstalledJob as jest.Mock).mockImplementation(() => ({
jobs: [],
}));
@ -282,6 +286,7 @@ describe('StepAboutRuleComponent', () => {
},
],
investigationFields: [],
maxSignals: 100,
};
await act(async () => {
@ -343,6 +348,7 @@ describe('StepAboutRuleComponent', () => {
},
],
investigationFields: [],
maxSignals: 100,
};
await act(async () => {

View file

@ -34,12 +34,16 @@ import { SeverityField } from '../severity_mapping';
import { RiskScoreField } from '../risk_score_mapping';
import { AutocompleteField } from '../autocomplete_field';
import { useFetchIndex } from '../../../../common/containers/source';
import { DEFAULT_INDICATOR_SOURCE_PATH } from '../../../../../common/constants';
import {
DEFAULT_INDICATOR_SOURCE_PATH,
DEFAULT_MAX_SIGNALS,
} from '../../../../../common/constants';
import { useKibana } from '../../../../common/lib/kibana';
import { useRuleIndices } from '../../../rule_management/logic/use_rule_indices';
import { EsqlAutocomplete } from '../esql_autocomplete';
import { MultiSelectFieldsAutocomplete } from '../multi_select_fields';
import { useInvestigationFields } from '../../hooks/use_investigation_fields';
import { MaxSignals } from '../max_signals';
const CommonUseField = getUseField({ component: Field });
@ -327,6 +331,18 @@ const StepAboutRuleComponent: FC<StepAboutRuleProps> = ({
/>
</EuiFormRow>
<EuiSpacer size="l" />
<EuiFormRow fullWidth>
<UseField
path="maxSignals"
component={MaxSignals}
componentProps={{
idAria: 'detectionEngineStepAboutRuleMaxSignals',
dataTestSubj: 'detectionEngineStepAboutRuleMaxSignals',
isDisabled: isLoading,
placeholder: DEFAULT_MAX_SIGNALS,
}}
/>
</EuiFormRow>
{isThreatMatchRuleValue && (
<>
<CommonUseField

View file

@ -100,6 +100,16 @@ export const schema: FormSchema<AboutStepRule> = {
),
labelAppend: OptionalFieldLabel,
},
maxSignals: {
type: FIELD_TYPES.NUMBER,
label: i18n.translate(
'xpack.securitySolution.detectionEngine.createRule.stepAboutRuleForm.fieldMaxAlertsLabel',
{
defaultMessage: 'Max alerts per run',
}
),
labelAppend: OptionalFieldLabel,
},
isAssociatedToEndpointList: {
type: FIELD_TYPES.CHECKBOX,
label: i18n.translate(

View file

@ -691,12 +691,38 @@ describe('helpers', () => {
tags: ['tag1', 'tag2'],
threat: getThreatMock(),
investigation_fields: { field_names: ['foo', 'bar'] },
max_signals: 100,
setup: '# this is some setup documentation',
};
expect(result).toEqual(expected);
});
// Users are allowed to input 0 in the form, but value is validated in the API layer
test('returns formatted object with max_signals set to 0', () => {
const mockDataWithZeroMaxSignals: AboutStepRule = {
...mockData,
maxSignals: 0,
};
const result = formatAboutStepData(mockDataWithZeroMaxSignals);
expect(result.max_signals).toEqual(0);
});
// Strings or empty values are replaced with undefined and overriden with the default value of 1000
test('returns formatted object with undefined max_signals for non-integer values inputs', () => {
const mockDataWithNonIntegerMaxSignals: AboutStepRule = {
...mockData,
// @ts-expect-error
maxSignals: '',
};
const result = formatAboutStepData(mockDataWithNonIntegerMaxSignals);
expect(result.max_signals).toEqual(undefined);
});
test('returns formatted object with endpoint exceptions_list', () => {
const result = formatAboutStepData(
{
@ -773,6 +799,7 @@ describe('helpers', () => {
tags: ['tag1', 'tag2'],
threat: getThreatMock(),
investigation_fields: { field_names: ['foo', 'bar'] },
max_signals: 100,
setup: '# this is some setup documentation',
};
@ -799,6 +826,7 @@ describe('helpers', () => {
tags: ['tag1', 'tag2'],
threat: getThreatMock(),
investigation_fields: { field_names: ['foo', 'bar'] },
max_signals: 100,
setup: '# this is some setup documentation',
};
@ -844,6 +872,7 @@ describe('helpers', () => {
tags: ['tag1', 'tag2'],
threat: getThreatMock(),
investigation_fields: { field_names: ['foo', 'bar'] },
max_signals: 100,
setup: '# this is some setup documentation',
};
@ -898,6 +927,7 @@ describe('helpers', () => {
},
],
investigation_fields: { field_names: ['foo', 'bar'] },
max_signals: 100,
setup: '# this is some setup documentation',
};
@ -928,6 +958,7 @@ describe('helpers', () => {
timestamp_override: 'event.ingest',
timestamp_override_fallback_disabled: true,
investigation_fields: { field_names: ['foo', 'bar'] },
max_signals: 100,
setup: '# this is some setup documentation',
};
@ -959,6 +990,7 @@ describe('helpers', () => {
timestamp_override_fallback_disabled: undefined,
threat: getThreatMock(),
investigation_fields: undefined,
max_signals: 100,
setup: '# this is some setup documentation',
};
@ -989,6 +1021,7 @@ describe('helpers', () => {
threat_indicator_path: undefined,
timestamp_override: undefined,
timestamp_override_fallback_disabled: undefined,
max_signals: 100,
setup: '# this is some setup documentation',
};
@ -1019,6 +1052,7 @@ describe('helpers', () => {
threat_indicator_path: undefined,
timestamp_override: undefined,
timestamp_override_fallback_disabled: undefined,
max_signals: 100,
setup: '# this is some setup documentation',
};

View file

@ -559,6 +559,7 @@ export const formatAboutStepData = (
threat,
isAssociatedToEndpointList,
isBuildingBlock,
maxSignals,
note,
ruleNameOverride,
threatIndicatorPath,
@ -613,6 +614,7 @@ export const formatAboutStepData = (
timestamp_override: timestampOverride !== '' ? timestampOverride : undefined,
timestamp_override_fallback_disabled: timestampOverrideFallbackDisabled,
...(!isEmpty(note) ? { note } : {}),
max_signals: Number.isSafeInteger(maxSignals) ? maxSignals : undefined,
...rest,
};
return resp;

View file

@ -141,7 +141,6 @@ const CreateRulePageComponent: React.FC = () => {
const [indicesConfig] = useUiSetting$<string[]>(DEFAULT_INDEX_KEY);
const [threatIndicesConfig] = useUiSetting$<string[]>(DEFAULT_THREAT_INDEX_KEY);
const defineStepDefault = useMemo(
() => ({
...stepDefineDefaultValue,
@ -150,6 +149,7 @@ const CreateRulePageComponent: React.FC = () => {
}),
[indicesConfig, threatIndicesConfig]
);
const kibanaAbsoluteUrl = useMemo(
() =>
application.getUrlForApp(`${APP_UI_ID}`, {

View file

@ -414,7 +414,6 @@ const EditRulePageComponent: FC<{ rule: RuleResponse }> = ({ rule }) => {
rule?.exceptions_list
),
...(ruleId ? { id: ruleId } : {}),
...(rule != null ? { max_signals: rule.max_signals } : {}),
});
displaySuccessToast(i18n.SUCCESSFULLY_SAVED_RULE(rule?.name ?? ''), dispatchToaster);

View file

@ -240,6 +240,16 @@ const TimestampOverride = ({ timestampOverride }: TimestampOverrideProps) => (
</EuiText>
);
interface MaxSignalsProps {
maxSignals: number;
}
const MaxSignals = ({ maxSignals }: MaxSignalsProps) => (
<EuiText size="s" data-test-subj="maxSignalsPropertyValue">
{maxSignals}
</EuiText>
);
interface TagsProps {
tags: string[];
}
@ -414,6 +424,13 @@ const prepareAboutSectionListItems = (
});
}
if (rule.max_signals) {
aboutSectionListItems.push({
title: <span data-test-subj="maxSignalsPropertyTitle">{i18n.MAX_SIGNALS_FIELD_LABEL}</span>,
description: <MaxSignals maxSignals={rule.max_signals} />,
});
}
if (rule.tags && rule.tags.length > 0) {
aboutSectionListItems.push({
title: <span data-test-subj="tagsPropertyTitle">{i18n.TAGS_FIELD_LABEL}</span>,

View file

@ -342,3 +342,10 @@ export const FROM_FIELD_LABEL = i18n.translate(
defaultMessage: 'Additional look-back time',
}
);
export const MAX_SIGNALS_FIELD_LABEL = i18n.translate(
'xpack.securitySolution.detectionEngine.ruleDetails.maxAlertsFieldLabel',
{
defaultMessage: 'Max alerts per run',
}
);

View file

@ -199,6 +199,7 @@ export const mockAboutStepRule = (): AboutStepRule => ({
note: '# this is some markdown documentation',
setup: '# this is some setup documentation',
investigationFields: ['foo', 'bar'],
maxSignals: 100,
});
export const mockActionsStepRule = (enabled = false): ActionsStepRule => ({

View file

@ -146,6 +146,7 @@ describe('rule helpers', () => {
timestampOverride: 'event.ingested',
timestampOverrideFallbackDisabled: false,
investigationFields: [],
maxSignals: 100,
setup: '# this is some setup documentation',
};
const scheduleRuleStepData = { from: '0s', interval: '5m' };

View file

@ -240,6 +240,7 @@ export const getAboutStepsData = (rule: RuleResponse, detailsView: boolean): Abo
investigation_fields: investigationFields,
tags,
threat,
max_signals: maxSignals,
} = rule;
const threatIndicatorPath =
'threat_indicator_path' in rule ? rule.threat_indicator_path : undefined;
@ -272,6 +273,7 @@ export const getAboutStepsData = (rule: RuleResponse, detailsView: boolean): Abo
investigationFields: investigationFields?.field_names ?? [],
threat: threat as Threats,
threatIndicatorPath,
maxSignals,
setup,
};
};

View file

@ -102,6 +102,7 @@ export interface AboutStepRule {
threatIndicatorPath?: string;
threat: Threats;
note: string;
maxSignals?: number;
setup: SetupGuide;
}
@ -249,6 +250,7 @@ export interface AboutStepRuleJson {
timestamp_override_fallback_disabled?: boolean;
note?: string;
investigation_fields?: InvestigationFields;
max_signals?: number;
}
export interface ScheduleStepRuleJson {

View file

@ -58,6 +58,7 @@ import type { DataViewEditorStart } from '@kbn/data-view-editor-plugin/public';
import type { UpsellingService } from '@kbn/security-solution-upselling/service';
import type { ChartsPluginStart } from '@kbn/charts-plugin/public';
import type { SavedSearchPublicPluginStart } from '@kbn/saved-search-plugin/public';
import type { PluginStartContract } from '@kbn/alerting-plugin/public/plugin';
import type { ResolverPluginSetup } from './resolver/types';
import type { Inspect } from '../common/search_strategy';
import type { Detections } from './detections';
@ -147,6 +148,7 @@ export interface StartPlugins {
dataViewEditor: DataViewEditorStart;
charts: ChartsPluginStart;
savedSearch: SavedSearchPublicPluginStart;
alerting: PluginStartContract;
core: CoreStart;
}

View file

@ -24,6 +24,7 @@ import type { QueryRuleParams, RuleParams } from '../../rule_schema';
// this is only used in tests
import { createDefaultAlertExecutorOptions } from '@kbn/rule-registry-plugin/server/utils/rule_executor.test_helpers';
import { getCompleteRuleMock } from '../../rule_schema/mocks';
import { DEFAULT_MAX_ALERTS } from '@kbn/alerting-plugin/server/config';
export const createRuleTypeMocks = (
ruleType: string = 'query',
@ -45,6 +46,7 @@ export const createRuleTypeMocks = (
registerType: ({ executor }) => {
alertExecutor = executor;
},
getConfig: () => ({ run: { alerts: { max: DEFAULT_MAX_ALERTS } } }),
} as AlertingPluginSetupContract;
const scheduleActions = jest.fn();

View file

@ -75,6 +75,7 @@ export const createSecurityRuleTypeWrapper: CreateSecurityRuleTypeWrapper =
version,
isPreview,
experimentalFeatures,
alerting,
}) =>
(type) => {
const { alertIgnoreFields: ignoreFields, alertMergeStrategy: mergeStrategy } = config;
@ -306,7 +307,12 @@ export const createSecurityRuleTypeWrapper: CreateSecurityRuleTypeWrapper =
wroteWarningStatus = true;
}
const { tuples, remainingGap } = getRuleRangeTuples({
const {
tuples,
remainingGap,
wroteWarningStatus: rangeTuplesWarningStatus,
warningStatusMessage: rangeTuplesWarningMessage,
} = await getRuleRangeTuples({
startedAt,
previousStartedAt,
from,
@ -314,7 +320,12 @@ export const createSecurityRuleTypeWrapper: CreateSecurityRuleTypeWrapper =
interval,
maxSignals: maxSignals ?? DEFAULT_MAX_SIGNALS,
ruleExecutionLogger,
alerting,
});
if (rangeTuplesWarningStatus) {
wroteWarningStatus = rangeTuplesWarningStatus;
warningMessage = rangeTuplesWarningMessage;
}
if (remainingGap.asMilliseconds() > 0) {
hasError = true;

View file

@ -46,6 +46,7 @@ describe('Custom Query Alerts', () => {
ruleExecutionLoggerFactory: () => Promise.resolve(ruleExecutionLogMock.forExecutors.create()),
version: '8.3',
publicBaseUrl,
alerting,
});
const eventsTelemetry = createMockTelemetryEventsSender(true);

View file

@ -137,6 +137,7 @@ export interface CreateSecurityRuleTypeWrapperProps {
version: string;
isPreview?: boolean;
experimentalFeatures?: ExperimentalFeatures;
alerting: SetupPlugins['alerting'];
}
export type CreateSecurityRuleTypeWrapper = (

View file

@ -49,6 +49,7 @@ import { ruleExecutionLogMock } from '../../rule_monitoring/mocks';
import type { BuildReasonMessage } from './reason_formatters';
import type { QueryRuleParams } from '../../rule_schema';
import { SERVER_APP_ID } from '../../../../../common/constants';
import type { PluginSetupContract } from '@kbn/alerting-plugin/server';
describe('searchAfterAndBulkCreate', () => {
let mockService: RuleExecutorServicesMock;
@ -58,6 +59,7 @@ describe('searchAfterAndBulkCreate', () => {
let wrapHits: WrapHits;
let inputIndexPattern: string[] = [];
let listClient = listMock.getListClient();
let alerting: PluginSetupContract;
const ruleExecutionLogger = ruleExecutionLogMock.forExecutors.create();
const someGuids = Array.from({ length: 13 }).map(() => uuidv4());
const sampleParams = getQueryRuleParams();
@ -82,22 +84,27 @@ describe('searchAfterAndBulkCreate', () => {
sampleParams.maxSignals = 30;
let tuple: RuleRangeTuple;
beforeEach(() => {
beforeEach(async () => {
jest.clearAllMocks();
buildReasonMessage = jest.fn().mockResolvedValue('some alert reason message');
listClient = listMock.getListClient();
listClient.searchListItemByValues = jest.fn().mockResolvedValue([]);
inputIndexPattern = ['auditbeat-*'];
mockService = alertsMock.createRuleExecutorServices();
tuple = getRuleRangeTuples({
previousStartedAt: new Date(),
startedAt: new Date(),
from: sampleParams.from,
to: sampleParams.to,
interval: '5m',
maxSignals: sampleParams.maxSignals,
ruleExecutionLogger,
}).tuples[0];
alerting = alertsMock.createSetup();
alerting.getConfig = jest.fn().mockReturnValue({ run: { alerts: { max: 1000 } } });
tuple = (
await getRuleRangeTuples({
previousStartedAt: new Date(),
startedAt: new Date(),
from: sampleParams.from,
to: sampleParams.to,
interval: '5m',
maxSignals: sampleParams.maxSignals,
ruleExecutionLogger,
alerting,
})
).tuples[0];
mockPersistenceServices = createPersistenceServicesMock();
bulkCreate = bulkCreateFactory(
mockPersistenceServices.alertWithPersistence,

View file

@ -65,6 +65,7 @@ import type { ShardError } from '../../../types';
import { ruleExecutionLogMock } from '../../rule_monitoring/mocks';
import type { GenericBulkCreateResponse } from '../factories';
import type { BaseFieldsLatest } from '../../../../../common/api/detection_engine/model/alerts';
import type { PluginSetupContract } from '@kbn/alerting-plugin/server';
describe('utils', () => {
const anchor = '2020-01-01T06:06:06.666Z';
@ -442,63 +443,84 @@ describe('utils', () => {
});
describe('getRuleRangeTuples', () => {
test('should return a single tuple if no gap', () => {
const { tuples, remainingGap } = getRuleRangeTuples({
previousStartedAt: moment().subtract(30, 's').toDate(),
startedAt: moment().subtract(30, 's').toDate(),
interval: '30s',
from: 'now-30s',
to: 'now',
maxSignals: 20,
ruleExecutionLogger,
});
let alerting: PluginSetupContract;
beforeEach(() => {
alerting = alertsMock.createSetup();
alerting.getConfig = jest.fn().mockReturnValue({ run: { alerts: { max: 1000 } } });
});
test('should return a single tuple if no gap', async () => {
const { tuples, remainingGap, wroteWarningStatus, warningStatusMessage } =
await getRuleRangeTuples({
previousStartedAt: moment().subtract(30, 's').toDate(),
startedAt: moment().subtract(30, 's').toDate(),
interval: '30s',
from: 'now-30s',
to: 'now',
maxSignals: 20,
ruleExecutionLogger,
alerting,
});
const someTuple = tuples[0];
expect(moment(someTuple.to).diff(moment(someTuple.from), 's')).toEqual(30);
expect(tuples.length).toEqual(1);
expect(remainingGap.asMilliseconds()).toEqual(0);
expect(wroteWarningStatus).toEqual(false);
expect(warningStatusMessage).toEqual(undefined);
});
test('should return a single tuple if malformed interval prevents gap calculation', () => {
const { tuples, remainingGap } = getRuleRangeTuples({
previousStartedAt: moment().subtract(30, 's').toDate(),
startedAt: moment().subtract(30, 's').toDate(),
interval: 'invalid',
from: 'now-30s',
to: 'now',
maxSignals: 20,
ruleExecutionLogger,
});
test('should return a single tuple if malformed interval prevents gap calculation', async () => {
const { tuples, remainingGap, wroteWarningStatus, warningStatusMessage } =
await getRuleRangeTuples({
previousStartedAt: moment().subtract(30, 's').toDate(),
startedAt: moment().subtract(30, 's').toDate(),
interval: 'invalid',
from: 'now-30s',
to: 'now',
maxSignals: 20,
ruleExecutionLogger,
alerting,
});
const someTuple = tuples[0];
expect(moment(someTuple.to).diff(moment(someTuple.from), 's')).toEqual(30);
expect(tuples.length).toEqual(1);
expect(remainingGap.asMilliseconds()).toEqual(0);
expect(wroteWarningStatus).toEqual(false);
expect(warningStatusMessage).toEqual(undefined);
});
test('should return two tuples if gap and previouslyStartedAt', () => {
const { tuples, remainingGap } = getRuleRangeTuples({
previousStartedAt: moment().subtract(65, 's').toDate(),
startedAt: moment().toDate(),
interval: '50s',
from: 'now-55s',
to: 'now',
maxSignals: 20,
ruleExecutionLogger,
});
test('should return two tuples if gap and previouslyStartedAt', async () => {
const { tuples, remainingGap, wroteWarningStatus, warningStatusMessage } =
await getRuleRangeTuples({
previousStartedAt: moment().subtract(65, 's').toDate(),
startedAt: moment().toDate(),
interval: '50s',
from: 'now-55s',
to: 'now',
maxSignals: 20,
ruleExecutionLogger,
alerting,
});
const someTuple = tuples[1];
expect(moment(someTuple.to).diff(moment(someTuple.from), 's')).toEqual(55);
expect(remainingGap.asMilliseconds()).toEqual(0);
expect(wroteWarningStatus).toEqual(false);
expect(warningStatusMessage).toEqual(undefined);
});
test('should return five tuples when give long gap', () => {
const { tuples, remainingGap } = getRuleRangeTuples({
previousStartedAt: moment().subtract(65, 's').toDate(), // 64 is 5 times the interval + lookback, which will trigger max lookback
startedAt: moment().toDate(),
interval: '10s',
from: 'now-13s',
to: 'now',
maxSignals: 20,
ruleExecutionLogger,
});
test('should return five tuples when give long gap', async () => {
const { tuples, remainingGap, wroteWarningStatus, warningStatusMessage } =
await getRuleRangeTuples({
previousStartedAt: moment().subtract(65, 's').toDate(), // 64 is 5 times the interval + lookback, which will trigger max lookback
startedAt: moment().toDate(),
interval: '10s',
from: 'now-13s',
to: 'now',
maxSignals: 20,
ruleExecutionLogger,
alerting,
});
expect(tuples.length).toEqual(5);
tuples.forEach((item, index) => {
if (index === 0) {
@ -509,22 +531,67 @@ describe('utils', () => {
expect(item.from.diff(tuples[index - 1].from, 's')).toEqual(10);
});
expect(remainingGap.asMilliseconds()).toEqual(12000);
expect(wroteWarningStatus).toEqual(false);
expect(warningStatusMessage).toEqual(undefined);
});
test('should return a single tuple when give a negative gap (rule ran sooner than expected)', () => {
const { tuples, remainingGap } = getRuleRangeTuples({
previousStartedAt: moment().subtract(-15, 's').toDate(),
startedAt: moment().subtract(-15, 's').toDate(),
interval: '10s',
from: 'now-13s',
to: 'now',
maxSignals: 20,
ruleExecutionLogger,
});
test('should return a single tuple when give a negative gap (rule ran sooner than expected)', async () => {
const { tuples, remainingGap, wroteWarningStatus, warningStatusMessage } =
await getRuleRangeTuples({
previousStartedAt: moment().subtract(-15, 's').toDate(),
startedAt: moment().subtract(-15, 's').toDate(),
interval: '10s',
from: 'now-13s',
to: 'now',
maxSignals: 20,
ruleExecutionLogger,
alerting,
});
expect(tuples.length).toEqual(1);
const someTuple = tuples[0];
expect(moment(someTuple.to).diff(moment(someTuple.from), 's')).toEqual(13);
expect(remainingGap.asMilliseconds()).toEqual(0);
expect(wroteWarningStatus).toEqual(false);
expect(warningStatusMessage).toEqual(undefined);
});
test('should use alerting framework max alerts value if maxSignals is greater than limit', async () => {
alerting.getConfig = jest.fn().mockReturnValue({ run: { alerts: { max: 10 } } });
const { tuples, wroteWarningStatus, warningStatusMessage } = await getRuleRangeTuples({
previousStartedAt: moment().subtract(30, 's').toDate(),
startedAt: moment().subtract(30, 's').toDate(),
interval: '30s',
from: 'now-30s',
to: 'now',
maxSignals: 20,
ruleExecutionLogger,
alerting,
});
const someTuple = tuples[0];
expect(someTuple.maxSignals).toEqual(10);
expect(tuples.length).toEqual(1);
expect(wroteWarningStatus).toEqual(true);
expect(warningStatusMessage).toEqual(
"The rule's max alerts per run setting (20) is greater than the Kibana alerting limit (10). The rule will only write a maximum of 10 alerts per rule run."
);
});
test('should use maxSignals value if maxSignals is less than alerting framework limit', async () => {
const { tuples, wroteWarningStatus, warningStatusMessage } = await getRuleRangeTuples({
previousStartedAt: moment().subtract(30, 's').toDate(),
startedAt: moment().subtract(30, 's').toDate(),
interval: '30s',
from: 'now-30s',
to: 'now',
maxSignals: 20,
ruleExecutionLogger,
alerting,
});
const someTuple = tuples[0];
expect(someTuple.maxSignals).toEqual(20);
expect(tuples.length).toEqual(1);
expect(wroteWarningStatus).toEqual(false);
expect(warningStatusMessage).toEqual(undefined);
});
});

View file

@ -26,6 +26,7 @@ import type {
import type {
AlertInstanceContext,
AlertInstanceState,
PluginSetupContract,
RuleExecutorServices,
} from '@kbn/alerting-plugin/server';
import { parseDuration } from '@kbn/alerting-plugin/server';
@ -410,7 +411,7 @@ export const errorAggregator = (
}, Object.create(null));
};
export const getRuleRangeTuples = ({
export const getRuleRangeTuples = async ({
startedAt,
previousStartedAt,
from,
@ -418,6 +419,7 @@ export const getRuleRangeTuples = ({
interval,
maxSignals,
ruleExecutionLogger,
alerting,
}: {
startedAt: Date;
previousStartedAt: Date | null | undefined;
@ -426,18 +428,33 @@ export const getRuleRangeTuples = ({
interval: string;
maxSignals: number;
ruleExecutionLogger: IRuleExecutionLogForExecutors;
alerting: PluginSetupContract;
}) => {
const originalFrom = dateMath.parse(from, { forceNow: startedAt });
const originalTo = dateMath.parse(to, { forceNow: startedAt });
let wroteWarningStatus = false;
let warningStatusMessage;
if (originalFrom == null || originalTo == null) {
throw new Error('Failed to parse date math of rule.from or rule.to');
}
const maxAlertsAllowed = alerting.getConfig().run.alerts.max;
let maxSignalsToUse = maxSignals;
if (maxSignals > maxAlertsAllowed) {
maxSignalsToUse = maxAlertsAllowed;
warningStatusMessage = `The rule's max alerts per run setting (${maxSignals}) is greater than the Kibana alerting limit (${maxAlertsAllowed}). The rule will only write a maximum of ${maxAlertsAllowed} alerts per rule run.`;
await ruleExecutionLogger.logStatusChange({
newStatus: RuleExecutionStatusEnum['partial failure'],
message: warningStatusMessage,
});
wroteWarningStatus = true;
}
const tuples = [
{
to: originalTo,
from: originalFrom,
maxSignals,
maxSignals: maxSignalsToUse,
},
];
@ -448,7 +465,7 @@ export const getRuleRangeTuples = ({
interval
)}"`
);
return { tuples, remainingGap: moment.duration(0) };
return { tuples, remainingGap: moment.duration(0), wroteWarningStatus, warningStatusMessage };
}
const gap = getGapBetweenRuns({
@ -464,7 +481,7 @@ export const getRuleRangeTuples = ({
const catchupTuples = getCatchupTuples({
originalTo,
originalFrom,
ruleParamsMaxSignals: maxSignals,
ruleParamsMaxSignals: maxSignalsToUse,
catchup,
intervalDuration,
});
@ -480,6 +497,8 @@ export const getRuleRangeTuples = ({
return {
tuples: tuples.reverse(),
remainingGap: moment.duration(remainingGapMilliseconds),
wroteWarningStatus,
warningStatusMessage,
};
};

View file

@ -308,6 +308,7 @@ export class Plugin implements ISecuritySolutionPlugin {
this.ruleMonitoringService.createRuleExecutionLogClientForExecutors,
version: pluginContext.env.packageInfo.version,
experimentalFeatures: config.experimentalFeatures,
alerting: plugins.alerting,
};
const queryRuleAdditionalOptions: CreateQueryRuleAdditionalOptions = {

View file

@ -43,6 +43,7 @@ export const createStartServicesMock = (): TriggersAndActionsUiServices => {
getNavigation: jest.fn(async (id) =>
id === 'alert-with-nav' ? { path: '/alert' } : undefined
),
getMaxAlertsPerRun: jest.fn(),
},
history: scopedHistoryMock.create(),
setBreadcrumbs: jest.fn(),

View file

@ -40,7 +40,7 @@ const ruleTypes = [
];
describe('createConfigRoute', () => {
it('registers the route and returns config if user is authorized', async () => {
it('registers the route and returns exposed config values if user is authorized', async () => {
const router = httpServiceMock.createRouter();
const logger = loggingSystemMock.create().get();
const mockRulesClient = rulesClientMock.create();
@ -54,6 +54,7 @@ describe('createConfigRoute', () => {
isUsingSecurity: true,
maxScheduledPerMinute: 10000,
minimumScheduleInterval: { value: '1m', enforce: false },
run: { alerts: { max: 1000 }, actions: { max: 100000 } },
}),
getRulesClientWithRequest: async () => mockRulesClient,
});
@ -88,6 +89,7 @@ describe('createConfigRoute', () => {
isUsingSecurity: true,
maxScheduledPerMinute: 10000,
minimumScheduleInterval: { value: '1m', enforce: false },
run: { alerts: { max: 1000 }, actions: { max: 100000 } },
}),
getRulesClientWithRequest: async () => mockRulesClient,
});

View file

@ -50,8 +50,16 @@ export function createConfigRoute({
// Check that user has access to at least one rule type
const rulesClient = await getRulesClientWithRequest(req);
const ruleTypes = Array.from(await rulesClient.listRuleTypes());
const { minimumScheduleInterval, maxScheduledPerMinute, isUsingSecurity } = alertingConfig(); // Only returns exposed config values
if (ruleTypes.length > 0) {
return res.ok({ body: alertingConfig() });
return res.ok({
body: {
minimumScheduleInterval,
maxScheduledPerMinute,
isUsingSecurity,
},
});
} else {
return res.forbidden({
body: { message: `Unauthorized to access config` },

View file

@ -134,12 +134,13 @@ export default ({ getService }: FtrProviderContext): void => {
});
});
it('should export rules with defaultbale fields when values are set', async () => {
it('should export rules with defaultable fields when values are set', async () => {
const defaultableFields: BaseDefaultableFields = {
related_integrations: [
{ package: 'package-a', version: '^1.2.3' },
{ package: 'package-b', integration: 'integration-b', version: '~1.1.1' },
],
max_signals: 100,
setup: '# some setup markdown',
};
const mockRule = getCustomQueryRuleParams(defaultableFields);
@ -315,6 +316,7 @@ export default ({ getService }: FtrProviderContext): void => {
const ruleId = 'ruleId';
const ruleToDuplicate = getCustomQueryRuleParams({
rule_id: ruleId,
max_signals: 100,
setup: '# some setup markdown',
related_integrations: [
{ package: 'package-a', version: '^1.2.3' },

View file

@ -72,6 +72,7 @@ export default ({ getService }: FtrProviderContext) => {
it('should create a rule with defaultable fields', async () => {
const expectedRule = getCustomQueryRuleParams({
rule_id: 'rule-1',
max_signals: 200,
setup: '# some setup markdown',
related_integrations: [
{ package: 'package-a', version: '^1.2.3' },
@ -175,6 +176,37 @@ export default ({ getService }: FtrProviderContext) => {
status_code: 409,
});
});
describe('max_signals', () => {
beforeEach(async () => {
await deleteAllRules(supertest, log);
});
it('creates a rule with max_signals defaulted to 100 when not present', async () => {
const { body } = await securitySolutionApi
.createRule({
body: getCustomQueryRuleParams(),
})
.expect(200);
expect(body.max_signals).toEqual(100);
});
it('does NOT create a rule when max_signals is less than 1', async () => {
const { body } = await securitySolutionApi
.createRule({
body: {
...getCustomQueryRuleParams(),
max_signals: 0,
},
})
.expect(400);
expect(body.message).toBe(
'[request body]: max_signals: Number must be greater than or equal to 1'
);
});
});
});
});
};

View file

@ -71,6 +71,7 @@ export default ({ getService }: FtrProviderContext): void => {
it('should create a rule with defaultable fields', async () => {
const expectedRule = getCustomQueryRuleParams({
rule_id: 'rule-1',
max_signals: 200,
setup: '# some setup markdown',
related_integrations: [
{ package: 'package-a', version: '^1.2.3' },

View file

@ -51,6 +51,7 @@ export default ({ getService }: FtrProviderContext): void => {
it('should export defaultable fields when values are set', async () => {
const defaultableFields: BaseDefaultableFields = {
max_signals: 200,
related_integrations: [
{ package: 'package-a', version: '^1.2.3' },
{ package: 'package-b', integration: 'integration-b', version: '~1.1.1' },

View file

@ -119,6 +119,7 @@ export default ({ getService }: FtrProviderContext): void => {
it('should be able to import rules with defaultable fields', async () => {
const defaultableFields: BaseDefaultableFields = {
max_signals: 100,
setup: '# some setup markdown',
related_integrations: [
{ package: 'package-a', version: '^1.2.3' },

View file

@ -63,6 +63,7 @@ export default ({ getService }: FtrProviderContext) => {
it('should patch defaultable fields', async () => {
const expectedRule = getCustomQueryRuleParams({
rule_id: 'rule-1',
max_signals: 200,
setup: '# some setup markdown',
related_integrations: [
{ package: 'package-a', version: '^1.2.3' },
@ -78,6 +79,7 @@ export default ({ getService }: FtrProviderContext) => {
.patchRule({
body: {
rule_id: 'rule-1',
max_signals: expectedRule.max_signals,
setup: expectedRule.setup,
related_integrations: expectedRule.related_integrations,
},
@ -229,6 +231,31 @@ export default ({ getService }: FtrProviderContext) => {
message: 'rule_id: "fake_id" not found',
});
});
describe('max signals', () => {
afterEach(async () => {
await deleteAllRules(supertest, log);
});
it('does NOT patch a rule when max_signals is less than 1', async () => {
await securitySolutionApi.createRule({
body: getCustomQueryRuleParams({ rule_id: 'rule-1', max_signals: 100 }),
});
const { body } = await securitySolutionApi
.patchRule({
body: {
rule_id: 'rule-1',
max_signals: 0,
},
})
.expect(400);
expect(body.message).toEqual(
'[request body]: max_signals: Number must be greater than or equal to 1'
);
});
});
});
});
};

View file

@ -62,6 +62,7 @@ export default ({ getService }: FtrProviderContext) => {
it('should patch defaultable fields', async () => {
const expectedRule = getCustomQueryRuleParams({
rule_id: 'rule-1',
max_signals: 200,
setup: '# some setup markdown',
related_integrations: [
{ package: 'package-a', version: '^1.2.3' },
@ -78,6 +79,7 @@ export default ({ getService }: FtrProviderContext) => {
body: [
{
rule_id: 'rule-1',
max_signals: expectedRule.max_signals,
setup: expectedRule.setup,
related_integrations: expectedRule.related_integrations,
},

View file

@ -68,6 +68,7 @@ export default ({ getService }: FtrProviderContext) => {
it('should update a rule with defaultable fields', async () => {
const expectedRule = getCustomQueryRuleParams({
rule_id: 'rule-1',
max_signals: 200,
setup: '# some setup markdown',
related_integrations: [
{ package: 'package-a', version: '^1.2.3' },
@ -225,6 +226,53 @@ export default ({ getService }: FtrProviderContext) => {
message: 'rule_id: "fake_id" not found',
});
});
describe('max signals', () => {
afterEach(async () => {
await deleteAllRules(supertest, log);
});
it('should reset max_signals field to default value on update when not present', async () => {
const expectedRule = getCustomQueryRuleParams({
rule_id: 'rule-1',
max_signals: 100,
});
await securitySolutionApi.createRule({
body: getCustomQueryRuleParams({ rule_id: 'rule-1', max_signals: 200 }),
});
const { body: updatedRuleResponse } = await securitySolutionApi
.updateRule({
body: getCustomQueryRuleParams({
rule_id: 'rule-1',
max_signals: undefined,
}),
})
.expect(200);
expect(updatedRuleResponse).toMatchObject(expectedRule);
});
it('does NOT update a rule when max_signals is less than 1', async () => {
await securitySolutionApi.createRule({
body: getCustomQueryRuleParams({ rule_id: 'rule-1', max_signals: 100 }),
});
const { body } = await securitySolutionApi
.updateRule({
body: getCustomQueryRuleParams({
rule_id: 'rule-1',
max_signals: 0,
}),
})
.expect(400);
expect(body.message).toEqual(
'[request body]: max_signals: Number must be greater than or equal to 1'
);
});
});
});
});
};

View file

@ -67,6 +67,7 @@ export default ({ getService }: FtrProviderContext) => {
it('should update a rule with defaultable fields', async () => {
const expectedRule = getCustomQueryRuleParams({
rule_id: 'rule-1',
max_signals: 200,
setup: '# some setup markdown',
related_integrations: [
{ package: 'package-a', version: '^1.2.3' },

View file

@ -25,6 +25,7 @@ import type {
RuleName,
RuleReferenceArray,
RuleTagArray,
MaxSignals,
SetupGuide,
} from '@kbn/security-solution-plugin/common/api/detection_engine';
@ -45,6 +46,7 @@ interface RuleFields {
threat: Threat;
threatSubtechnique: ThreatSubtechnique;
threatTechnique: ThreatTechnique;
maxSignals: MaxSignals;
setup: SetupGuide;
}
@ -93,4 +95,5 @@ export const ruleFields: RuleFields = {
name: 'OS Credential Dumping',
reference: 'https://attack.mitre.org/techniques/T1003',
},
maxSignals: 100,
};

View file

@ -16,6 +16,7 @@ import {
SCHEDULE_CONTINUE_BUTTON,
} from '../../../../screens/create_new_rule';
import {
MAX_SIGNALS_DETAILS,
DESCRIPTION_SETUP_GUIDE_BUTTON,
DESCRIPTION_SETUP_GUIDE_CONTENT,
RULE_NAME_HEADER,
@ -29,6 +30,7 @@ import {
fillDescription,
fillFalsePositiveExamples,
fillFrom,
fillMaxSignals,
fillNote,
fillReferenceUrls,
fillRelatedIntegrations,
@ -81,6 +83,7 @@ describe('Common rule creation flows', { tags: ['@ess', '@serverless'] }, () =>
fillThreatTechnique();
fillThreatSubtechnique();
fillCustomInvestigationFields();
fillMaxSignals();
fillNote();
fillSetup();
cy.get(ABOUT_CONTINUE_BTN).click();
@ -103,6 +106,7 @@ describe('Common rule creation flows', { tags: ['@ess', '@serverless'] }, () =>
// UI redirects to rule creation page of a created rule
cy.get(RULE_NAME_HEADER).should('contain', ruleFields.ruleName);
cy.get(MAX_SIGNALS_DETAILS).should('contain', ruleFields.maxSignals);
cy.get(DESCRIPTION_SETUP_GUIDE_BUTTON).click();
cy.get(DESCRIPTION_SETUP_GUIDE_CONTENT).should('contain', 'test setup markdown'); // Markdown formatting should be removed

View file

@ -132,6 +132,8 @@ export const INDICATOR_MATCH_TYPE = '[data-test-subj="threatMatchRuleType"]';
export const INPUT = '[data-test-subj="input"]';
export const MAX_SIGNALS_INPUT = '[data-test-subj="detectionEngineStepAboutRuleMaxSignals"]';
export const INVESTIGATION_NOTES_TEXTAREA =
'[data-test-subj="detectionEngineStepAboutRuleNote"] textarea';

View file

@ -156,6 +156,8 @@ export const ALERT_SUPPRESSION_INSUFFICIENT_LICENSING_ICON =
export const HIGHLIGHTED_ROWS_IN_TABLE =
'[data-test-subj="euiDataGridBody"] .alertsTableHighlightedRow';
export const MAX_SIGNALS_DETAILS = '[data-test-subj="maxSignalsPropertyValue"]';
export const DESCRIPTION_SETUP_GUIDE_BUTTON = '[data-test-subj="stepAboutDetailsToggle-setup"]';
export const DESCRIPTION_SETUP_GUIDE_CONTENT = '[data-test-subj="stepAboutDetailsSetupContent"]';

View file

@ -125,6 +125,7 @@ import {
ALERTS_INDEX_BUTTON,
INVESTIGATIONS_INPUT,
QUERY_BAR_ADD_FILTER,
MAX_SIGNALS_INPUT,
SETUP_GUIDE_TEXTAREA,
RELATED_INTEGRATION_COMBO_BOX_INPUT,
} from '../screens/create_new_rule';
@ -198,6 +199,13 @@ export const expandAdvancedSettings = () => {
cy.get(ADVANCED_SETTINGS_BTN).click({ force: true });
};
export const fillMaxSignals = (maxSignals: number = ruleFields.maxSignals) => {
cy.get(MAX_SIGNALS_INPUT).clear({ force: true });
cy.get(MAX_SIGNALS_INPUT).type(maxSignals.toString());
return maxSignals;
};
export const fillNote = (note: string = ruleFields.investigationGuide) => {
cy.get(INVESTIGATION_NOTES_TEXTAREA).clear({ force: true });
cy.get(INVESTIGATION_NOTES_TEXTAREA).type(note, { force: true });