[Security Solution][Detection Engine] adds alert suppression for New Terms rule type (#178294)

## Summary

 - addresses https://github.com/elastic/security-team/issues/8824
 - adds alert suppression for new terms rule type
- fixes `getOpenAlerts` test function, which returned closed alerts as
well
 
### UI

<img width="2294" alt="Screenshot 2024-04-02 at 12 53 26"
src="8398fba4-a06c-464b-87ef-1c5d5a18e37f">
<img width="1651" alt="Screenshot 2024-04-02 at 12 53 46"
src="971ec0da-c1d9-4c96-a4af-7cc8dfae52a4">



### Checklist
- [x] Functional changes are hidden behind a feature flag 

  Feature flag `alertSuppressionForNewTermsRuleEnabled`

- [x] Functional changes are covered with a test plan and automated
tests.

  Test plan: https://github.com/elastic/security-team/pull/9045

- [x] Stability of new and changed tests is verified using the [Flaky
Test Runner](https://ci-stats.kibana.dev/trigger_flaky_test_runner).

Cypress ESS:
https://buildkite.com/elastic/kibana-flaky-test-suite-runner/builds/5547
Cypress Serverless:
https://buildkite.com/elastic/kibana-flaky-test-suite-runner/builds/5548

FTR ESS:
https://buildkite.com/elastic/kibana-flaky-test-suite-runner/builds/5596
FTR Serverless:
https://buildkite.com/elastic/kibana-flaky-test-suite-runner/builds/5597

- [ ] Comprehensive manual testing is done by two engineers: the PR
author and one of the PR reviewers. Changes are tested in both ESS and
Serverless.

- [x] Mapping changes are accompanied by a technical design document. It
can be a GitHub issue or an RFC explaining the changes. The design
document is shared with and approved by the appropriate teams and
individual stakeholders.

Existing AlertSuppression schema field is used for New terms rule, the
one that used for Query and IM rules.

```yml
    alert_suppression:
      $ref: './common_attributes.schema.yaml#/components/schemas/AlertSuppression'
```
where

```yml
    AlertSuppression:
      type: object
      properties:
        group_by:
          $ref: '#/components/schemas/AlertSuppressionGroupBy'
        duration:
          $ref: '#/components/schemas/AlertSuppressionDuration'
        missing_fields_strategy:
          $ref: '#/components/schemas/AlertSuppressionMissingFieldsStrategy'
      required:
        - group_by
   ```

- [x]  Functional changes are communicated to the Docs team. A ticket or PR is opened in https://github.com/elastic/security-docs. The following information is included: any feature flags used, affected environments (Serverless, ESS, or both).

https://github.com/elastic/security-docs/issues/5030
This commit is contained in:
Vitalii Dmyterko 2024-04-09 09:09:03 +01:00 committed by GitHub
parent 42e92d8749
commit 52cfdd6fc2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
65 changed files with 4079 additions and 503 deletions

View file

@ -6811,6 +6811,52 @@ Object {
},
Object {
"properties": Object {
"alertSuppression": Object {
"additionalProperties": false,
"properties": Object {
"duration": Object {
"additionalProperties": false,
"properties": Object {
"unit": Object {
"enum": Array [
"s",
"m",
"h",
],
"type": "string",
},
"value": Object {
"minimum": 1,
"type": "integer",
},
},
"required": Array [
"value",
"unit",
],
"type": "object",
},
"groupBy": Object {
"items": Object {
"type": "string",
},
"maxItems": 3,
"minItems": 1,
"type": "array",
},
"missingFieldsStrategy": Object {
"enum": Array [
"doNotSuppress",
"suppress",
],
"type": "string",
},
},
"required": Array [
"groupBy",
],
"type": "object",
},
"dataViewId": Object {
"type": "string",
},

View file

@ -361,7 +361,8 @@ export const createPersistenceRuleTypeWrapper: CreatePersistenceRuleTypeWrapper
suppressionWindow,
enrichAlerts,
currentTimeOverride,
isRuleExecutionOnly
isRuleExecutionOnly,
maxAlerts
) => {
const ruleDataClientWriter = await ruleDataClient.getWriter({
namespace: options.spaceId,
@ -376,6 +377,8 @@ export const createPersistenceRuleTypeWrapper: CreatePersistenceRuleTypeWrapper
const writeAlerts =
ruleDataClient.isWriteEnabled() && options.services.shouldWriteAlerts();
let alertsWereTruncated = false;
if (writeAlerts && alerts.length > 0) {
const suppressionWindowStart = dateMath.parse(suppressionWindow, {
forceNow: currentTimeOverride,
@ -392,7 +395,12 @@ export const createPersistenceRuleTypeWrapper: CreatePersistenceRuleTypeWrapper
});
if (filteredDuplicates.length === 0) {
return { createdAlerts: [], errors: {}, suppressedAlerts: [] };
return {
createdAlerts: [],
errors: {},
suppressedAlerts: [],
alertsWereTruncated,
};
}
const suppressionAlertSearchRequest = {
@ -473,7 +481,12 @@ export const createPersistenceRuleTypeWrapper: CreatePersistenceRuleTypeWrapper
});
if (nonSuppressedAlerts.length === 0) {
return { createdAlerts: [], errors: {}, suppressedAlerts: [] };
return {
createdAlerts: [],
errors: {},
suppressedAlerts: [],
alertsWereTruncated,
};
}
const { alertCandidates, suppressedAlerts: suppressedInMemoryAlerts } =
@ -535,6 +548,11 @@ export const createPersistenceRuleTypeWrapper: CreatePersistenceRuleTypeWrapper
}
}
if (maxAlerts && enrichedAlerts.length > maxAlerts) {
enrichedAlerts.length = maxAlerts;
alertsWereTruncated = true;
}
const augmentedAlerts = augmentAlerts({
alerts: enrichedAlerts,
options,
@ -548,7 +566,12 @@ export const createPersistenceRuleTypeWrapper: CreatePersistenceRuleTypeWrapper
});
if (bulkResponse == null) {
return { createdAlerts: [], errors: {}, suppressedAlerts: [] };
return {
createdAlerts: [],
errors: {},
suppressedAlerts: [],
alertsWereTruncated: false,
};
}
const createdAlerts = augmentedAlerts
@ -594,10 +617,16 @@ export const createPersistenceRuleTypeWrapper: CreatePersistenceRuleTypeWrapper
createdAlerts,
suppressedAlerts: [...duplicateAlerts, ...suppressedInMemoryAlerts],
errors: errorAggregator(bulkResponse.body, [409]),
alertsWereTruncated,
};
} else {
logger.debug('Writing is disabled.');
return { createdAlerts: [], errors: {}, suppressedAlerts: [] };
return {
createdAlerts: [],
errors: {},
suppressedAlerts: [],
alertsWereTruncated: false,
};
}
},
},

View file

@ -52,11 +52,11 @@ export type SuppressedAlertService = <T extends SuppressionFieldsLatest>(
params: { spaceId: string }
) => Promise<Array<{ _id: string; _source: T }>>,
currentTimeOverride?: Date,
isRuleExecutionOnly?: boolean
isRuleExecutionOnly?: boolean,
maxAlerts?: number
) => Promise<SuppressedAlertServiceResult<T>>;
export interface SuppressedAlertServiceResult<T>
extends Omit<PersistenceAlertServiceResult<T>, 'alertsWereTruncated'> {
export interface SuppressedAlertServiceResult<T> extends PersistenceAlertServiceResult<T> {
suppressedAlerts: Array<{ _id: string; _source: T }>;
}

View file

@ -10,6 +10,7 @@ import { getListArrayMock } from '../../../../detection_engine/schemas/types/lis
import {
getCreateEsqlRulesSchemaMock,
getCreateMachineLearningRulesSchemaMock,
getCreateNewTermsRulesSchemaMock,
getCreateRulesSchemaMock,
getCreateRulesSchemaMockWithDataView,
getCreateSavedQueryRulesSchemaMock,
@ -1267,6 +1268,7 @@ describe('rules schema', () => {
{ ruleType: 'threat_match', ruleMock: getCreateThreatMatchRulesSchemaMock() },
{ ruleType: 'query', ruleMock: getCreateRulesSchemaMock() },
{ ruleType: 'saved_query', ruleMock: getCreateSavedQueryRulesSchemaMock() },
{ ruleType: 'new_terms', ruleMock: getCreateNewTermsRulesSchemaMock() },
];
cases.forEach(({ ruleType, ruleMock }) => {

View file

@ -504,6 +504,7 @@ export const NewTermsRuleOptionalFields = z.object({
index: IndexPatternArray.optional(),
data_view_id: DataViewId.optional(),
filters: RuleFilterArray.optional(),
alert_suppression: AlertSuppression.optional(),
});
export type NewTermsRuleDefaultableFields = z.infer<typeof NewTermsRuleDefaultableFields>;

View file

@ -737,6 +737,8 @@ components:
$ref: './common_attributes.schema.yaml#/components/schemas/DataViewId'
filters:
$ref: './common_attributes.schema.yaml#/components/schemas/RuleFilterArray'
alert_suppression:
$ref: './common_attributes.schema.yaml#/components/schemas/AlertSuppression'
NewTermsRuleDefaultableFields:
type: object

View file

@ -43,5 +43,6 @@ export const SUPPRESSIBLE_ALERT_RULES: Type[] = [
'threshold',
'saved_query',
'query',
'new_terms',
'threat_match',
];

View file

@ -231,11 +231,11 @@ describe('Alert Suppression Rules', () => {
expect(isSuppressibleAlertRule('saved_query')).toBe(true);
expect(isSuppressibleAlertRule('query')).toBe(true);
expect(isSuppressibleAlertRule('threat_match')).toBe(true);
expect(isSuppressibleAlertRule('new_terms')).toBe(true);
// Rule types that don't support alert suppression:
expect(isSuppressibleAlertRule('eql')).toBe(false);
expect(isSuppressibleAlertRule('machine_learning')).toBe(false);
expect(isSuppressibleAlertRule('new_terms')).toBe(false);
expect(isSuppressibleAlertRule('esql')).toBe(false);
});
@ -253,11 +253,11 @@ describe('Alert Suppression Rules', () => {
expect(isSuppressionRuleConfiguredWithDuration('saved_query')).toBe(true);
expect(isSuppressionRuleConfiguredWithDuration('query')).toBe(true);
expect(isSuppressionRuleConfiguredWithDuration('threat_match')).toBe(true);
expect(isSuppressionRuleConfiguredWithDuration('new_terms')).toBe(true);
// Rule types that don't support alert suppression:
expect(isSuppressionRuleConfiguredWithDuration('eql')).toBe(false);
expect(isSuppressionRuleConfiguredWithDuration('machine_learning')).toBe(false);
expect(isSuppressionRuleConfiguredWithDuration('new_terms')).toBe(false);
expect(isSuppressionRuleConfiguredWithDuration('esql')).toBe(false);
});
@ -274,11 +274,11 @@ describe('Alert Suppression Rules', () => {
expect(isSuppressionRuleConfiguredWithGroupBy('saved_query')).toBe(true);
expect(isSuppressionRuleConfiguredWithGroupBy('query')).toBe(true);
expect(isSuppressionRuleConfiguredWithGroupBy('threat_match')).toBe(true);
expect(isSuppressionRuleConfiguredWithGroupBy('new_terms')).toBe(true);
// Rule types that don't support alert suppression:
expect(isSuppressionRuleConfiguredWithGroupBy('eql')).toBe(false);
expect(isSuppressionRuleConfiguredWithGroupBy('machine_learning')).toBe(false);
expect(isSuppressionRuleConfiguredWithGroupBy('new_terms')).toBe(false);
expect(isSuppressionRuleConfiguredWithGroupBy('esql')).toBe(false);
});
@ -300,11 +300,11 @@ describe('Alert Suppression Rules', () => {
expect(isSuppressionRuleConfiguredWithMissingFields('saved_query')).toBe(true);
expect(isSuppressionRuleConfiguredWithMissingFields('query')).toBe(true);
expect(isSuppressionRuleConfiguredWithMissingFields('threat_match')).toBe(true);
expect(isSuppressionRuleConfiguredWithMissingFields('new_terms')).toBe(true);
// Rule types that don't support alert suppression:
expect(isSuppressionRuleConfiguredWithMissingFields('eql')).toBe(false);
expect(isSuppressionRuleConfiguredWithMissingFields('machine_learning')).toBe(false);
expect(isSuppressionRuleConfiguredWithMissingFields('new_terms')).toBe(false);
expect(isSuppressionRuleConfiguredWithMissingFields('esql')).toBe(false);
});

View file

@ -170,6 +170,11 @@ export const allowedExperimentalValues = Object.freeze({
*/
riskEnginePrivilegesRouteEnabled: true,
/**
* Enables alerts suppression for new terms rules
*/
alertSuppressionForNewTermsRuleEnabled: false,
/**
* Enables experimental Experimental S1 integration data to be available in Analyzer
*/

View file

@ -575,7 +575,7 @@ describe('description_step', () => {
});
describe('alert suppression', () => {
const ruleTypesWithoutSuppression: Type[] = ['eql', 'esql', 'machine_learning', 'new_terms'];
const ruleTypesWithoutSuppression: Type[] = ['eql', 'esql', 'machine_learning'];
const suppressionFields = {
groupByDuration: {
unit: 'm',

View file

@ -19,9 +19,9 @@ import {
import {
isEsqlRule,
isNewTermsRule,
isQueryRule,
isThreatMatchRule,
isThresholdRule,
isSuppressionRuleConfiguredWithGroupBy,
} from '../../../../../common/detection_engine/utils';
import { MAX_NUMBER_OF_NEW_TERMS_FIELDS } from '../../../../../common/constants';
import { isMlRule } from '../../../../../common/machine_learning/helpers';
@ -574,79 +574,6 @@ export const schema: FormSchema<DefineStepRule> = {
},
],
},
groupByFields: {
type: FIELD_TYPES.COMBO_BOX,
label: i18n.translate(
'xpack.securitySolution.detectionEngine.createRule.stepDefineRule.groupByFieldsLabel',
{
defaultMessage: 'Suppress alerts by',
}
),
labelAppend: (
<EuiText color="subdued" size="xs">
{i18n.translate(
'xpack.securitySolution.detectionEngine.createRule.stepDefineRule.groupByFieldsLabelAppend',
{
defaultMessage: 'Optional (Technical Preview)',
}
)}
</EuiText>
),
helpText: i18n.translate(
'xpack.securitySolution.detectionEngine.createRule.stepDefineRule.fieldGroupByFieldHelpText',
{
defaultMessage: 'Select field(s) to use for suppressing extra alerts',
}
),
validations: [
{
validator: (
...args: Parameters<ValidationFunc>
): ReturnType<ValidationFunc<{}, ERROR_CODE>> | undefined => {
const [{ formData }] = args;
const needsValidation =
isQueryRule(formData.ruleType) || isThreatMatchRule(formData.ruleType);
if (!needsValidation) {
return;
}
return fieldValidators.maxLengthField({
length: 3,
message: i18n.translate(
'xpack.securitySolution.detectionEngine.validations.stepDefineRule.groupByFieldsMax',
{
defaultMessage: 'Number of grouping fields must be at most 3',
}
),
})(...args);
},
},
],
},
groupByRadioSelection: {},
groupByDuration: {
label: i18n.translate(
'xpack.securitySolution.detectionEngine.createRule.stepDefineRule.groupByDurationValueLabel',
{
defaultMessage: 'Suppress alerts for',
}
),
helpText: i18n.translate(
'xpack.securitySolution.detectionEngine.createRule.stepDefineRule.fieldGroupByDurationValueHelpText',
{
defaultMessage: 'Suppress alerts for',
}
),
value: {},
unit: {},
},
suppressionMissingFields: {
label: i18n.translate(
'xpack.securitySolution.detectionEngine.createRule.stepDefineRule.suppressionMissingFieldsLabel',
{
defaultMessage: 'If a suppression field is missing',
}
),
},
newTermsFields: {
type: FIELD_TYPES.COMBO_BOX,
label: i18n.translate(
@ -718,6 +645,79 @@ export const schema: FormSchema<DefineStepRule> = {
}
),
},
groupByFields: {
type: FIELD_TYPES.COMBO_BOX,
label: i18n.translate(
'xpack.securitySolution.detectionEngine.createRule.stepDefineRule.groupByFieldsLabel',
{
defaultMessage: 'Suppress alerts by',
}
),
labelAppend: (
<EuiText color="subdued" size="xs">
{i18n.translate(
'xpack.securitySolution.detectionEngine.createRule.stepDefineRule.groupByFieldsLabelAppend',
{
defaultMessage: 'Optional (Technical Preview)',
}
)}
</EuiText>
),
helpText: i18n.translate(
'xpack.securitySolution.detectionEngine.createRule.stepDefineRule.fieldGroupByFieldHelpText',
{
defaultMessage: 'Select field(s) to use for suppressing extra alerts',
}
),
validations: [
{
validator: (
...args: Parameters<ValidationFunc>
): ReturnType<ValidationFunc<{}, ERROR_CODE>> | undefined => {
const [{ formData }] = args;
const needsValidation = isSuppressionRuleConfiguredWithGroupBy(formData.ruleType);
if (!needsValidation) {
return;
}
return fieldValidators.maxLengthField({
length: 3,
message: i18n.translate(
'xpack.securitySolution.detectionEngine.validations.stepDefineRule.groupByFieldsMax',
{
defaultMessage: 'Number of grouping fields must be at most 3',
}
),
})(...args);
},
},
],
},
groupByRadioSelection: {},
groupByDuration: {
label: i18n.translate(
'xpack.securitySolution.detectionEngine.createRule.stepDefineRule.groupByDurationValueLabel',
{
defaultMessage: 'Suppress alerts for',
}
),
helpText: i18n.translate(
'xpack.securitySolution.detectionEngine.createRule.stepDefineRule.fieldGroupByDurationValueHelpText',
{
defaultMessage: 'Suppress alerts for',
}
),
value: {},
unit: {},
},
suppressionMissingFields: {
label: i18n.translate(
'xpack.securitySolution.detectionEngine.createRule.stepDefineRule.suppressionMissingFieldsLabel',
{
defaultMessage: 'If a suppression field is missing',
}
),
},
shouldLoadQueryDynamically: {
type: FIELD_TYPES.CHECKBOX,
defaultValue: false,

View file

@ -7,6 +7,8 @@
import { useCallback } from 'react';
import type { DefineStepRule } from '../../../../detections/pages/detection_engine/rules/types';
import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features';
import { isNewTermsRule } from '../../../../../common/detection_engine/utils';
/**
* transforms DefineStepRule fields according to experimental feature flags
@ -14,9 +16,31 @@ import type { DefineStepRule } from '../../../../detections/pages/detection_engi
export const useExperimentalFeatureFieldsTransform = <T extends Partial<DefineStepRule>>(): ((
fields: T
) => T) => {
const transformer = useCallback((fields: T) => {
return fields;
}, []);
const isAlertSuppressionForNewTermsRuleEnabled = useIsExperimentalFeatureEnabled(
'alertSuppressionForNewTermsRuleEnabled'
);
const transformer = useCallback(
(fields: T) => {
const isNewTermsSuppressionDisabled = isNewTermsRule(fields.ruleType)
? !isAlertSuppressionForNewTermsRuleEnabled
: false;
// reset any alert suppression values hidden behind feature flag
if (isNewTermsSuppressionDisabled) {
return {
...fields,
groupByFields: [],
groupByRadioSelection: undefined,
groupByDuration: undefined,
suppressionMissingFields: undefined,
};
}
return fields;
},
[isAlertSuppressionForNewTermsRuleEnabled]
);
return transformer;
};

View file

@ -490,6 +490,7 @@ export const formatDefineStepData = (defineStepData: DefineStepRule): DefineStep
query: ruleFields.queryBar?.query?.query as string,
new_terms_fields: ruleFields.newTermsFields,
history_window_start: `now-${ruleFields.historyWindowSize}`,
...alertSuppressionFields,
}
: isEsqlFields(ruleFields) && !('index' in ruleFields)
? {

View file

@ -645,6 +645,28 @@ const prepareDefinitionSectionListItems = (
});
}
if ('new_terms_fields' in rule && rule.new_terms_fields && rule.new_terms_fields.length > 0) {
definitionSectionListItems.push({
title: (
<span data-test-subj="newTermsFieldsPropertyTitle">
{i18n.NEW_TERMS_FIELDS_FIELD_LABEL}
</span>
),
description: <NewTermsFields newTermsFields={rule.new_terms_fields} />,
});
}
if ('history_window_start' in rule) {
definitionSectionListItems.push({
title: (
<span data-test-subj="newTermsWindowSizePropertyTitle">
{i18n.HISTORY_WINDOW_SIZE_FIELD_LABEL}
</span>
),
description: <HistoryWindowSize historyWindowStart={rule.history_window_start} />,
});
}
if (isSuppressionEnabled && 'alert_suppression' in rule && rule.alert_suppression) {
if ('group_by' in rule.alert_suppression) {
definitionSectionListItems.push({
@ -682,28 +704,6 @@ const prepareDefinitionSectionListItems = (
}
}
if ('new_terms_fields' in rule && rule.new_terms_fields && rule.new_terms_fields.length > 0) {
definitionSectionListItems.push({
title: (
<span data-test-subj="newTermsFieldsPropertyTitle">
{i18n.NEW_TERMS_FIELDS_FIELD_LABEL}
</span>
),
description: <NewTermsFields newTermsFields={rule.new_terms_fields} />,
});
}
if ('history_window_start' in rule) {
definitionSectionListItems.push({
title: (
<span data-test-subj="newTermsWindowSizePropertyTitle">
{i18n.HISTORY_WINDOW_SIZE_FIELD_LABEL}
</span>
),
description: <HistoryWindowSize historyWindowStart={rule.history_window_start} />,
});
}
return definitionSectionListItems;
};

View file

@ -7,8 +7,28 @@
import { renderHook } from '@testing-library/react-hooks';
import type { Type } from '@kbn/securitysolution-io-ts-alerting-types';
import { useAlertSuppression } from './use_alert_suppression';
import * as useIsExperimentalFeatureEnabledMock from '../../../common/hooks/use_experimental_features';
jest
.spyOn(useIsExperimentalFeatureEnabledMock, 'useIsExperimentalFeatureEnabled')
.mockReturnValue(false);
describe('useAlertSuppression', () => {
it('should return isSuppressionEnabled false if rule Type exists in SUPPRESSIBLE_ALERT_RULES and Feature Flag is disabled', () => {
const { result } = renderHook(() => useAlertSuppression('new_terms'));
expect(result.current.isSuppressionEnabled).toBe(false);
});
it('should return isSuppressionEnabled true if rule Type exists in SUPPRESSIBLE_ALERT_RULES and Feature Flag is enabled', () => {
jest
.spyOn(useIsExperimentalFeatureEnabledMock, 'useIsExperimentalFeatureEnabled')
.mockImplementationOnce(() => true);
const { result } = renderHook(() => useAlertSuppression('new_terms'));
expect(result.current.isSuppressionEnabled).toBe(true);
});
it('should return the correct isSuppressionEnabled value fot threat_match rule type', () => {
const { result } = renderHook(() => useAlertSuppression('threat_match'));

View file

@ -7,17 +7,29 @@
import { useCallback } from 'react';
import type { Type } from '@kbn/securitysolution-io-ts-alerting-types';
import { isSuppressibleAlertRule } from '../../../../common/detection_engine/utils';
import { useIsExperimentalFeatureEnabled } from '../../../common/hooks/use_experimental_features';
export interface UseAlertSuppressionReturn {
isSuppressionEnabled: boolean;
}
export const useAlertSuppression = (ruleType: Type | undefined): UseAlertSuppressionReturn => {
const isAlertSuppressionForNewTermsRuleEnabled = useIsExperimentalFeatureEnabled(
'alertSuppressionForNewTermsRuleEnabled'
);
const isSuppressionEnabledForRuleType = useCallback(() => {
if (!ruleType) return false;
if (!ruleType) {
return false;
}
// Remove this condition when the Feature Flag for enabling Suppression in the New terms rule is removed.
if (ruleType === 'new_terms') {
return isSuppressibleAlertRule(ruleType) && isAlertSuppressionForNewTermsRuleEnabled;
}
return isSuppressibleAlertRule(ruleType);
}, [ruleType]);
}, [ruleType, isAlertSuppressionForNewTermsRuleEnabled]);
return {
isSuppressionEnabled: isSuppressionEnabledForRuleType(),

View file

@ -194,6 +194,27 @@ describe('rule_converters', () => {
);
});
test('should accept new_terms alerts suppression params', () => {
const patchParams = {
alert_suppression: {
group_by: ['agent.name'],
duration: { value: 4, unit: 'h' as const },
missing_fields_strategy: 'suppress' as const,
},
};
const rule = getNewTermsRuleParams();
const patchedParams = patchTypeSpecificSnakeToCamel(patchParams, rule);
expect(patchedParams).toEqual(
expect.objectContaining({
alertSuppression: {
groupBy: ['agent.name'],
missingFieldsStrategy: 'suppress',
duration: { value: 4, unit: 'h' },
},
})
);
});
test('should accept machine learning params when existing rule type is machine learning', () => {
const patchParams = {
anomaly_threshold: 5,

View file

@ -199,6 +199,7 @@ export const typeSpecificSnakeToCamel = (
filters: params.filters,
language: params.language ?? 'kuery',
dataViewId: params.data_view_id,
alertSuppression: convertAlertSuppressionToCamel(params.alert_suppression),
};
}
default: {
@ -345,6 +346,8 @@ const patchNewTermsParams = (
filters: params.filters ?? existingRule.filters,
newTermsFields: params.new_terms_fields ?? existingRule.newTermsFields,
historyWindowStart: params.history_window_start ?? existingRule.historyWindowStart,
alertSuppression:
convertAlertSuppressionToCamel(params.alert_suppression) ?? existingRule.alertSuppression,
};
};
@ -643,6 +646,7 @@ export const typeSpecificCamelToSnake = (
filters: params.filters,
language: params.language,
data_view_id: params.dataViewId,
alert_suppression: convertAlertSuppressionToSnake(params.alertSuppression),
};
}
default: {

View file

@ -268,6 +268,7 @@ export const NewTermsSpecificRuleParams = z.object({
filters: RuleFilterArray.optional(),
language: KqlQueryLanguage,
dataViewId: DataViewId.optional(),
alertSuppression: AlertSuppressionCamel.optional(),
});
export type NewTermsRuleParams = BaseRuleParams & NewTermsSpecificRuleParams;

View file

@ -0,0 +1,113 @@
/*
* 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 type { SuppressedAlertService } from '@kbn/rule-registry-plugin/server';
import type { SuppressionFieldsLatest } from '@kbn/rule-registry-plugin/common/schemas';
import type {
SearchAfterAndBulkCreateParams,
SearchAfterAndBulkCreateReturnType,
WrapSuppressedHits,
} from '../types';
import type { AlertSuppressionCamel } from '../../../../../common/api/detection_engine/model/rule_schema';
import { DEFAULT_SUPPRESSION_MISSING_FIELDS_STRATEGY } from '../../../../../common/detection_engine/constants';
import { AlertSuppressionMissingFieldsStrategyEnum } from '../../../../../common/api/detection_engine/model/rule_schema';
import { executeBulkCreateAlerts } from '../utils/bulk_create_suppressed_alerts_in_memory';
import type {
BaseFieldsLatest,
WrappedFieldsLatest,
NewTermsFieldsLatest,
} from '../../../../../common/api/detection_engine/model/alerts';
import { partitionMissingFieldsEvents } from '../utils/partition_missing_fields_events';
import type { EventsAndTerms } from './types';
import type { ExperimentalFeatures } from '../../../../../common';
interface SearchAfterAndBulkCreateSuppressedAlertsParams extends SearchAfterAndBulkCreateParams {
wrapSuppressedHits: WrapSuppressedHits;
alertTimestampOverride: Date | undefined;
alertWithSuppression: SuppressedAlertService;
alertSuppression?: AlertSuppressionCamel;
}
export interface BulkCreateSuppressedAlertsParams
extends Pick<
SearchAfterAndBulkCreateSuppressedAlertsParams,
| 'bulkCreate'
| 'services'
| 'ruleExecutionLogger'
| 'tuple'
| 'alertSuppression'
| 'alertWithSuppression'
| 'alertTimestampOverride'
> {
wrapHits: (
events: EventsAndTerms[]
) => Array<WrappedFieldsLatest<BaseFieldsLatest & NewTermsFieldsLatest>>;
wrapSuppressedHits: (
events: EventsAndTerms[]
) => Array<
WrappedFieldsLatest<BaseFieldsLatest & SuppressionFieldsLatest & NewTermsFieldsLatest>
>;
eventsAndTerms: EventsAndTerms[];
toReturn: SearchAfterAndBulkCreateReturnType;
experimentalFeatures: ExperimentalFeatures | undefined;
}
/**
* wraps, bulk create and suppress alerts in memory, also takes care of missing fields logic.
* If parameter alertSuppression.missingFieldsStrategy configured not to be suppressed, regular alerts will be created for such events without suppression
* This function is similar to x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/utils/bulk_create_suppressed_alerts_in_memory.ts, but
* it operates with new terms specific eventsAndTerms{@link EventsAndTerms} parameter property, instead of regular events as common utility
*/
export const bulkCreateSuppressedNewTermsAlertsInMemory = async ({
eventsAndTerms,
wrapHits,
wrapSuppressedHits,
toReturn,
bulkCreate,
services,
ruleExecutionLogger,
tuple,
alertSuppression,
alertWithSuppression,
alertTimestampOverride,
experimentalFeatures,
}: BulkCreateSuppressedAlertsParams) => {
const suppressOnMissingFields =
(alertSuppression?.missingFieldsStrategy ?? DEFAULT_SUPPRESSION_MISSING_FIELDS_STRATEGY) ===
AlertSuppressionMissingFieldsStrategyEnum.suppress;
let suppressibleEvents = eventsAndTerms;
let unsuppressibleWrappedDocs: Array<WrappedFieldsLatest<BaseFieldsLatest>> = [];
if (!suppressOnMissingFields) {
const partitionedEvents = partitionMissingFieldsEvents(
eventsAndTerms,
alertSuppression?.groupBy || [],
['event']
);
unsuppressibleWrappedDocs = wrapHits(partitionedEvents[1]);
suppressibleEvents = partitionedEvents[0];
}
const suppressibleWrappedDocs = wrapSuppressedHits(suppressibleEvents);
return executeBulkCreateAlerts({
suppressibleWrappedDocs,
unsuppressibleWrappedDocs,
toReturn,
bulkCreate,
services,
ruleExecutionLogger,
tuple,
alertSuppression,
alertWithSuppression,
alertTimestampOverride,
experimentalFeatures,
});
};

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import { isObject } from 'lodash';
import { isObject, chunk } from 'lodash';
import { NEW_TERMS_RULE_TYPE_ID } from '@kbn/securitysolution-rules';
import { DEFAULT_APP_CATEGORIES } from '@kbn/core-application-common';
@ -16,13 +16,16 @@ import type { CreateRuleOptions, SecurityAlertType } from '../types';
import { singleSearchAfter } from '../utils/single_search_after';
import { getFilter } from '../utils/get_filter';
import { wrapNewTermsAlerts } from './wrap_new_terms_alerts';
import type { EventsAndTerms } from './wrap_new_terms_alerts';
import { wrapSuppressedNewTermsAlerts } from './wrap_suppressed_new_terms_alerts';
import { bulkCreateSuppressedNewTermsAlertsInMemory } from './bulk_create_suppressed_alerts_in_memory';
import type { EventsAndTerms } from './types';
import type {
RecentTermsAggResult,
DocFetchAggResult,
NewTermsAggResult,
CreateAlertsHook,
} from './build_new_terms_aggregation';
import type { NewTermsFieldsLatest } from '../../../../../common/api/detection_engine/model/alerts';
import {
buildRecentTermsAgg,
buildNewTermsAgg,
@ -35,14 +38,17 @@ import {
createSearchAfterReturnType,
getUnprocessedExceptionsWarnings,
getMaxSignalsWarning,
getSuppressionMaxSignalsWarning,
} from '../utils/utils';
import { createEnrichEventsFunction } from '../utils/enrichments';
import { getIsAlertSuppressionActive } from '../utils/get_is_alert_suppression_active';
import { multiTermsComposite } from './multi_terms_composite';
import type { GenericBulkCreateResponse } from '../utils/bulk_create_with_suppression';
export const createNewTermsAlertType = (
createOptions: CreateRuleOptions
): SecurityAlertType<NewTermsRuleParams, {}, {}, 'default'> => {
const { logger } = createOptions;
const { logger, licensing } = createOptions;
return {
id: NEW_TERMS_RULE_TYPE_ID,
name: 'New Terms Rule',
@ -104,6 +110,8 @@ export const createNewTermsAlertType = (
alertTimestampOverride,
publicBaseUrl,
inputIndexFields,
experimentalFeatures,
alertWithSuppression,
},
services,
params,
@ -137,6 +145,11 @@ export const createNewTermsAlertType = (
name: 'historyWindowStart',
});
const isAlertSuppressionActive = await getIsAlertSuppressionActive({
alertSuppression: params.alertSuppression,
licensing,
isFeatureDisabled: !experimentalFeatures?.alertSuppressionForNewTermsRuleEnabled,
});
let afterKey;
const result = createSearchAfterReturnType();
@ -191,6 +204,32 @@ export const createNewTermsAlertType = (
const bucketsForField = searchResultWithAggs.aggregations.new_terms.buckets;
const createAlertsHook: CreateAlertsHook = async (aggResult) => {
const wrapHits = (eventsAndTerms: EventsAndTerms[]) =>
wrapNewTermsAlerts({
eventsAndTerms,
spaceId,
completeRule,
mergeStrategy,
indicesToQuery: inputIndex,
alertTimestampOverride,
ruleExecutionLogger,
publicBaseUrl,
});
const wrapSuppressedHits = (eventsAndTerms: EventsAndTerms[]) =>
wrapSuppressedNewTermsAlerts({
eventsAndTerms,
spaceId,
completeRule,
mergeStrategy,
indicesToQuery: inputIndex,
alertTimestampOverride,
ruleExecutionLogger,
publicBaseUrl,
primaryTimestamp,
secondaryTimestamp,
});
const eventsAndTerms: EventsAndTerms[] = (
aggResult?.aggregations?.new_terms.buckets ?? []
).map((bucket) => {
@ -201,27 +240,61 @@ export const createNewTermsAlertType = (
};
});
const wrappedAlerts = wrapNewTermsAlerts({
eventsAndTerms,
spaceId,
completeRule,
mergeStrategy,
indicesToQuery: inputIndex,
alertTimestampOverride,
ruleExecutionLogger,
publicBaseUrl,
});
let bulkCreateResult: Omit<
GenericBulkCreateResponse<NewTermsFieldsLatest>,
'suppressedItemsCount'
> = {
errors: [],
success: true,
enrichmentDuration: '0',
bulkCreateDuration: '0',
createdItemsCount: 0,
createdItems: [],
alertsWereTruncated: false,
};
const bulkCreateResult = await bulkCreate(
wrappedAlerts,
params.maxSignals - result.createdSignalsCount,
createEnrichEventsFunction({
services,
logger: ruleExecutionLogger,
})
);
// wrap and create alerts by chunks
// large number of matches, processed in possibly 10,000 size of events and terms
// can significantly affect Kibana performance
const eventAndTermsChunks = chunk(eventsAndTerms, 5 * params.maxSignals);
addToSearchAfterReturn({ current: result, next: bulkCreateResult });
for (let i = 0; i < eventAndTermsChunks.length; i++) {
const eventAndTermsChunk = eventAndTermsChunks[i];
if (isAlertSuppressionActive) {
bulkCreateResult = await bulkCreateSuppressedNewTermsAlertsInMemory({
eventsAndTerms: eventAndTermsChunk,
toReturn: result,
wrapHits,
bulkCreate,
services,
ruleExecutionLogger,
tuple,
alertSuppression: params.alertSuppression,
wrapSuppressedHits,
alertTimestampOverride,
alertWithSuppression,
experimentalFeatures,
});
} else {
const wrappedAlerts = wrapHits(eventAndTermsChunk);
bulkCreateResult = await bulkCreate(
wrappedAlerts,
params.maxSignals - result.createdSignalsCount,
createEnrichEventsFunction({
services,
logger: ruleExecutionLogger,
})
);
addToSearchAfterReturn({ current: result, next: bulkCreateResult });
}
if (bulkCreateResult.alertsWereTruncated) {
break;
}
}
return bulkCreateResult;
};
@ -229,7 +302,7 @@ export const createNewTermsAlertType = (
// separate route for multiple new terms
// it uses paging through composite aggregation
if (params.newTermsFields.length > 1) {
await multiTermsComposite({
const bulkCreateResult = await multiTermsComposite({
filterArgs,
buckets: bucketsForField,
params,
@ -241,7 +314,12 @@ export const createNewTermsAlertType = (
runOpts: execOptions.runOpts,
afterKey,
createAlertsHook,
isAlertSuppressionActive,
});
if (bulkCreateResult?.alertsWereTruncated) {
break;
}
} else {
// PHASE 2: Take the page of results from Phase 1 and determine if each term exists in the history window.
// The aggregation filters out buckets for terms that exist prior to `tuple.from`, so the buckets in the
@ -326,7 +404,11 @@ export const createNewTermsAlertType = (
const bulkCreateResult = await createAlertsHook(docFetchResultWithAggs);
if (bulkCreateResult.alertsWereTruncated) {
result.warningMessages.push(getMaxSignalsWarning());
result.warningMessages.push(
isAlertSuppressionActive
? getSuppressionMaxSignalsWarning()
: getMaxSignalsWarning()
);
break;
}
}

View file

@ -21,8 +21,9 @@ import type {
CompositeNewTermsAggResult,
CreateAlertsHook,
} from './build_new_terms_aggregation';
import { getMaxSignalsWarning } from '../utils/utils';
import type { NewTermsFieldsLatest } from '../../../../../common/api/detection_engine/model/alerts';
import { getMaxSignalsWarning, getSuppressionMaxSignalsWarning } from '../utils/utils';
import type { GenericBulkCreateResponse } from '../utils/bulk_create_with_suppression';
import type { RuleServices, SearchAfterAndBulkCreateReturnType, RunOpts } from '../types';
@ -47,6 +48,7 @@ interface MultiTermsCompositeArgsBase {
runOpts: RunOpts<NewTermsRuleParams>;
afterKey: Record<string, string | number | null> | undefined;
createAlertsHook: CreateAlertsHook;
isAlertSuppressionActive: boolean;
}
interface MultiTermsCompositeArgs extends MultiTermsCompositeArgsBase {
@ -72,7 +74,10 @@ const multiTermsCompositeNonRetryable = async ({
afterKey,
createAlertsHook,
batchSize,
}: MultiTermsCompositeArgs) => {
isAlertSuppressionActive,
}: MultiTermsCompositeArgs): Promise<
Omit<GenericBulkCreateResponse<NewTermsFieldsLatest>, 'suppressedItemsCount'> | undefined
> => {
const {
ruleExecutionLogger,
tuple,
@ -183,15 +188,15 @@ const multiTermsCompositeNonRetryable = async ({
const bulkCreateResult = await createAlertsHook(docFetchResultWithAggs);
if (bulkCreateResult.alertsWereTruncated) {
result.warningMessages.push(getMaxSignalsWarning());
return result;
result.warningMessages.push(
isAlertSuppressionActive ? getSuppressionMaxSignalsWarning() : getMaxSignalsWarning()
);
return bulkCreateResult;
}
}
internalAfterKey = batch[batch.length - 1]?.key;
}
return result;
};
/**
@ -199,10 +204,14 @@ const multiTermsCompositeNonRetryable = async ({
* We will try to reduce it in twice per each request, three times, up until 125
* Per ES documentation, max_clause_count min value is 1,000 - so with 125 we should be able execute query below max_clause_count value
*/
export const multiTermsComposite = async (args: MultiTermsCompositeArgsBase): Promise<void> => {
export const multiTermsComposite = async (
args: MultiTermsCompositeArgsBase
): Promise<
Omit<GenericBulkCreateResponse<NewTermsFieldsLatest>, 'suppressedItemsCount'> | undefined
> => {
let retryBatchSize = BATCH_SIZE;
const ruleExecutionLogger = args.runOpts.ruleExecutionLogger;
await pRetry(
return pRetry(
async (retryCount) => {
try {
const res = await multiTermsCompositeNonRetryable({ ...args, batchSize: retryBatchSize });
@ -218,7 +227,7 @@ export const multiTermsComposite = async (args: MultiTermsCompositeArgsBase): Pr
].some((errMessage) => e.message.includes(errMessage))
) {
args.result.errors.push(e.message);
return args.result;
return;
}
retryBatchSize = retryBatchSize / 2;

View file

@ -0,0 +1,14 @@
/*
* 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 type * as estypes from '@elastic/elasticsearch/lib/api/types';
import type { SignalSource } from '../types';
export interface EventsAndTerms {
event: estypes.SearchHit<SignalSource>;
newTerms: Array<string | number | null>;
}

View file

@ -0,0 +1,142 @@
/*
* 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 { cloneDeep } from 'lodash';
import {
ALERT_URL,
ALERT_UUID,
ALERT_SUPPRESSION_DOCS_COUNT,
ALERT_INSTANCE_ID,
ALERT_SUPPRESSION_TERMS,
ALERT_SUPPRESSION_START,
ALERT_SUPPRESSION_END,
} from '@kbn/rule-data-utils';
import { ALERT_NEW_TERMS } from '../../../../../common/field_maps/field_names';
import { getCompleteRuleMock, getNewTermsRuleParams } from '../../rule_schema/mocks';
import { ruleExecutionLogMock } from '../../rule_monitoring/mocks';
import { sampleDocNoSortIdWithTimestamp } from '../__mocks__/es_results';
import { wrapSuppressedNewTermsAlerts } from './wrap_suppressed_new_terms_alerts';
const ruleExecutionLogger = ruleExecutionLogMock.forExecutors.create();
const docId = 'd5e8eb51-a6a0-456d-8a15-4b79bfec3d71';
const publicBaseUrl = 'http://somekibanabaseurl.com';
const alertSuppression = {
groupBy: ['source.ip'],
};
const completeRule = getCompleteRuleMock(getNewTermsRuleParams());
completeRule.ruleParams.alertSuppression = alertSuppression;
describe('wrapSuppressedNewTermsAlerts', () => {
test('should create an alert with the correct _id from a document and suppression fields', () => {
const doc = sampleDocNoSortIdWithTimestamp(docId);
const alerts = wrapSuppressedNewTermsAlerts({
eventsAndTerms: [{ event: doc, newTerms: ['127.0.0.1'] }],
spaceId: 'default',
mergeStrategy: 'missingFields',
completeRule,
indicesToQuery: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'],
alertTimestampOverride: undefined,
ruleExecutionLogger,
publicBaseUrl,
primaryTimestamp: '@timestamp',
});
expect(alerts[0]._id).toEqual('3b67aa2ebdc628afc98febc65082d2d83a116d79');
expect(alerts[0]._source[ALERT_UUID]).toEqual('3b67aa2ebdc628afc98febc65082d2d83a116d79');
expect(alerts[0]._source[ALERT_NEW_TERMS]).toEqual(['127.0.0.1']);
expect(alerts[0]._source[ALERT_URL]).toContain(
'http://somekibanabaseurl.com/app/security/alerts/redirect/3b67aa2ebdc628afc98febc65082d2d83a116d79?index=.alerts-security.alerts-default'
);
expect(alerts[0]._source[ALERT_SUPPRESSION_DOCS_COUNT]).toEqual(0);
expect(alerts[0]._source[ALERT_INSTANCE_ID]).toEqual(
'1bf77f90e72d76d9335ad0ce356340a3d9833f96'
);
expect(alerts[0]._source[ALERT_SUPPRESSION_TERMS]).toEqual([
{ field: 'source.ip', value: ['127.0.0.1'] },
]);
expect(alerts[0]._source[ALERT_SUPPRESSION_START]).toBeDefined();
expect(alerts[0]._source[ALERT_SUPPRESSION_END]).toBeDefined();
});
test('should create an alert with a different _id if suppression field is different', () => {
const completeRuleCloned = cloneDeep(completeRule);
completeRuleCloned.ruleParams.alertSuppression = {
groupBy: ['someKey'],
};
const doc = sampleDocNoSortIdWithTimestamp(docId);
const alerts = wrapSuppressedNewTermsAlerts({
eventsAndTerms: [{ event: doc, newTerms: ['127.0.0.1'] }],
spaceId: 'default',
mergeStrategy: 'missingFields',
completeRule: completeRuleCloned,
indicesToQuery: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'],
alertTimestampOverride: undefined,
ruleExecutionLogger,
publicBaseUrl,
primaryTimestamp: '@timestamp',
});
expect(alerts[0]._id).toEqual('3e0436a03b735af12d6e5358cb36d2c3b39425a8');
expect(alerts[0]._source[ALERT_UUID]).toEqual('3e0436a03b735af12d6e5358cb36d2c3b39425a8');
expect(alerts[0]._source[ALERT_URL]).toContain(
'http://somekibanabaseurl.com/app/security/alerts/redirect/3e0436a03b735af12d6e5358cb36d2c3b39425a8?index=.alerts-security.alerts-default'
);
expect(alerts[0]._source[ALERT_SUPPRESSION_DOCS_COUNT]).toEqual(0);
expect(alerts[0]._source[ALERT_INSTANCE_ID]).toEqual(
'01e43acf431fd232bbe230ac523a5d5d1e8a2787'
);
expect(alerts[0]._source[ALERT_SUPPRESSION_TERMS]).toEqual([
{ field: 'someKey', value: ['someValue'] },
]);
});
test('should create an alert with a different _id if the space is different', () => {
const doc = sampleDocNoSortIdWithTimestamp(docId);
const alerts = wrapSuppressedNewTermsAlerts({
eventsAndTerms: [{ event: doc, newTerms: ['127.0.0.1'] }],
spaceId: 'otherSpace',
mergeStrategy: 'missingFields',
completeRule,
indicesToQuery: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'],
alertTimestampOverride: undefined,
ruleExecutionLogger,
publicBaseUrl,
primaryTimestamp: '@timestamp',
});
expect(alerts[0]._id).toEqual('f8a029df9c99e245dc83977153a0612178f3d2e8');
expect(alerts[0]._source[ALERT_UUID]).toEqual('f8a029df9c99e245dc83977153a0612178f3d2e8');
expect(alerts[0]._source[ALERT_URL]).toContain(
'http://somekibanabaseurl.com/s/otherSpace/app/security/alerts/redirect/f8a029df9c99e245dc83977153a0612178f3d2e8?index=.alerts-security.alerts-otherSpace'
);
});
test('should create an alert with a different _id if the newTerms array is different', () => {
const doc = sampleDocNoSortIdWithTimestamp(docId);
const alerts = wrapSuppressedNewTermsAlerts({
eventsAndTerms: [{ event: doc, newTerms: ['127.0.0.2'] }],
spaceId: 'otherSpace',
mergeStrategy: 'missingFields',
completeRule,
indicesToQuery: ['auditbeat-*', 'filebeat-*', 'packetbeat-*', 'winlogbeat-*'],
alertTimestampOverride: undefined,
ruleExecutionLogger,
publicBaseUrl,
primaryTimestamp: '@timestamp',
});
expect(alerts[0]._id).toEqual('cb8684ec72592346d32839b1838e4f4080dc052e');
expect(alerts[0]._source[ALERT_UUID]).toEqual('cb8684ec72592346d32839b1838e4f4080dc052e');
expect(alerts[0]._source[ALERT_NEW_TERMS]).toEqual(['127.0.0.2']);
expect(alerts[0]._source[ALERT_URL]).toContain(
'http://somekibanabaseurl.com/s/otherSpace/app/security/alerts/redirect/cb8684ec72592346d32839b1838e4f4080dc052e?index=.alerts-security.alerts-otherSpace'
);
});
});

View file

@ -0,0 +1,105 @@
/*
* 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 type * as estypes from '@elastic/elasticsearch/lib/api/types';
import objectHash from 'object-hash';
import { TIMESTAMP } from '@kbn/rule-data-utils';
import type { SuppressionFieldsLatest } from '@kbn/rule-registry-plugin/common/schemas';
import type {
BaseFieldsLatest,
NewTermsFieldsLatest,
WrappedFieldsLatest,
} from '../../../../../common/api/detection_engine/model/alerts';
import { ALERT_NEW_TERMS } from '../../../../../common/field_maps/field_names';
import type { ConfigType } from '../../../../config';
import type { CompleteRule, NewTermsRuleParams } from '../../rule_schema';
import { buildReasonMessageForNewTermsAlert } from '../utils/reason_formatters';
import { getSuppressionAlertFields, getSuppressionTerms } from '../utils';
import type { SignalSource } from '../types';
import type { IRuleExecutionLogForExecutors } from '../../rule_monitoring';
import { buildBulkBody } from '../factories/utils/build_bulk_body';
export interface EventsAndTerms {
event: estypes.SearchHit<SignalSource>;
newTerms: Array<string | number | null>;
}
export const wrapSuppressedNewTermsAlerts = ({
eventsAndTerms,
spaceId,
completeRule,
mergeStrategy,
indicesToQuery,
alertTimestampOverride,
ruleExecutionLogger,
publicBaseUrl,
primaryTimestamp,
secondaryTimestamp,
}: {
eventsAndTerms: EventsAndTerms[];
spaceId: string | null | undefined;
completeRule: CompleteRule<NewTermsRuleParams>;
mergeStrategy: ConfigType['alertMergeStrategy'];
indicesToQuery: string[];
alertTimestampOverride: Date | undefined;
ruleExecutionLogger: IRuleExecutionLogForExecutors;
publicBaseUrl: string | undefined;
primaryTimestamp: string;
secondaryTimestamp?: string;
}): Array<WrappedFieldsLatest<NewTermsFieldsLatest & SuppressionFieldsLatest>> => {
return eventsAndTerms.map((eventAndTerms) => {
const event = eventAndTerms.event;
const suppressionTerms = getSuppressionTerms({
alertSuppression: completeRule?.ruleParams?.alertSuppression,
fields: event.fields,
});
const instanceId = objectHash([suppressionTerms, completeRule.alertId, spaceId]);
const id = objectHash([
eventAndTerms.event._index,
eventAndTerms.event._id,
String(eventAndTerms.event._version),
`${spaceId}:${completeRule.alertId}`,
eventAndTerms.newTerms,
suppressionTerms,
]);
const baseAlert: BaseFieldsLatest = buildBulkBody(
spaceId,
completeRule,
event,
mergeStrategy,
[],
true,
buildReasonMessageForNewTermsAlert,
indicesToQuery,
alertTimestampOverride,
ruleExecutionLogger,
id,
publicBaseUrl
);
return {
_id: id,
_index: '',
_source: {
...baseAlert,
[ALERT_NEW_TERMS]: eventAndTerms.newTerms,
...getSuppressionAlertFields({
primaryTimestamp,
secondaryTimestamp,
fields: event.fields,
suppressionTerms,
fallbackTimestamp: baseAlert[TIMESTAMP],
instanceId,
}),
},
};
});
};

View file

@ -0,0 +1,188 @@
/*
* 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 type { SuppressedAlertService } from '@kbn/rule-registry-plugin/server';
import type { SuppressionFieldsLatest } from '@kbn/rule-registry-plugin/common/schemas';
import type {
SearchAfterAndBulkCreateParams,
SearchAfterAndBulkCreateReturnType,
WrapSuppressedHits,
SignalSourceHit,
} from '../types';
import { MAX_SIGNALS_SUPPRESSION_MULTIPLIER } from '../constants';
import { addToSearchAfterReturn } from './utils';
import type { AlertSuppressionCamel } from '../../../../../common/api/detection_engine/model/rule_schema';
import { DEFAULT_SUPPRESSION_MISSING_FIELDS_STRATEGY } from '../../../../../common/detection_engine/constants';
import { partitionMissingFieldsEvents } from './partition_missing_fields_events';
import { AlertSuppressionMissingFieldsStrategyEnum } from '../../../../../common/api/detection_engine/model/rule_schema';
import { createEnrichEventsFunction } from './enrichments';
import { bulkCreateWithSuppression } from './bulk_create_with_suppression';
import type { ExperimentalFeatures } from '../../../../../common';
import type {
BaseFieldsLatest,
WrappedFieldsLatest,
} from '../../../../../common/api/detection_engine/model/alerts';
interface SearchAfterAndBulkCreateSuppressedAlertsParams extends SearchAfterAndBulkCreateParams {
wrapSuppressedHits: WrapSuppressedHits;
alertTimestampOverride: Date | undefined;
alertWithSuppression: SuppressedAlertService;
alertSuppression?: AlertSuppressionCamel;
}
export interface BulkCreateSuppressedAlertsParams
extends Pick<
SearchAfterAndBulkCreateSuppressedAlertsParams,
| 'wrapHits'
| 'bulkCreate'
| 'services'
| 'buildReasonMessage'
| 'ruleExecutionLogger'
| 'tuple'
| 'alertSuppression'
| 'wrapSuppressedHits'
| 'alertWithSuppression'
| 'alertTimestampOverride'
> {
enrichedEvents: SignalSourceHit[];
toReturn: SearchAfterAndBulkCreateReturnType;
experimentalFeatures?: ExperimentalFeatures;
}
/**
* wraps, bulk create and suppress alerts in memory, also takes care of missing fields logic.
* If parameter alertSuppression.missingFieldsStrategy configured not to be suppressed, regular alerts will be created for such events without suppression
*/
export const bulkCreateSuppressedAlertsInMemory = async ({
enrichedEvents,
toReturn,
wrapHits,
bulkCreate,
services,
buildReasonMessage,
ruleExecutionLogger,
tuple,
alertSuppression,
wrapSuppressedHits,
alertWithSuppression,
alertTimestampOverride,
experimentalFeatures,
}: BulkCreateSuppressedAlertsParams) => {
const suppressOnMissingFields =
(alertSuppression?.missingFieldsStrategy ?? DEFAULT_SUPPRESSION_MISSING_FIELDS_STRATEGY) ===
AlertSuppressionMissingFieldsStrategyEnum.suppress;
let suppressibleEvents = enrichedEvents;
let unsuppressibleWrappedDocs: Array<WrappedFieldsLatest<BaseFieldsLatest>> = [];
if (!suppressOnMissingFields) {
const partitionedEvents = partitionMissingFieldsEvents(
enrichedEvents,
alertSuppression?.groupBy || []
);
unsuppressibleWrappedDocs = wrapHits(partitionedEvents[1], buildReasonMessage);
suppressibleEvents = partitionedEvents[0];
}
const suppressibleWrappedDocs = wrapSuppressedHits(suppressibleEvents, buildReasonMessage);
return executeBulkCreateAlerts({
suppressibleWrappedDocs,
unsuppressibleWrappedDocs,
toReturn,
bulkCreate,
services,
ruleExecutionLogger,
tuple,
alertSuppression,
alertWithSuppression,
alertTimestampOverride,
experimentalFeatures,
});
};
export interface ExecuteBulkCreateAlertsParams<T extends SuppressionFieldsLatest & BaseFieldsLatest>
extends Pick<
SearchAfterAndBulkCreateSuppressedAlertsParams,
| 'bulkCreate'
| 'services'
| 'ruleExecutionLogger'
| 'tuple'
| 'alertSuppression'
| 'alertWithSuppression'
| 'alertTimestampOverride'
> {
unsuppressibleWrappedDocs: Array<WrappedFieldsLatest<BaseFieldsLatest>>;
suppressibleWrappedDocs: Array<WrappedFieldsLatest<T>>;
toReturn: SearchAfterAndBulkCreateReturnType;
experimentalFeatures?: ExperimentalFeatures;
}
/**
* creates alerts in ES, both suppressed and unsuppressed
*/
export const executeBulkCreateAlerts = async <
T extends SuppressionFieldsLatest & BaseFieldsLatest
>({
unsuppressibleWrappedDocs,
suppressibleWrappedDocs,
toReturn,
bulkCreate,
services,
ruleExecutionLogger,
tuple,
alertSuppression,
alertWithSuppression,
alertTimestampOverride,
experimentalFeatures,
}: ExecuteBulkCreateAlertsParams<T>) => {
// max signals for suppression includes suppressed and created alerts
// this allows to lift max signals limitation to higher value
// and can detects events beyond default max_signals value
const suppressionMaxSignals = MAX_SIGNALS_SUPPRESSION_MULTIPLIER * tuple.maxSignals;
const suppressionDuration = alertSuppression?.duration;
const suppressionWindow = suppressionDuration
? `now-${suppressionDuration.value}${suppressionDuration.unit}`
: tuple.from.toISOString();
if (unsuppressibleWrappedDocs.length) {
const unsuppressedResult = await bulkCreate(
unsuppressibleWrappedDocs,
tuple.maxSignals - toReturn.createdSignalsCount,
createEnrichEventsFunction({
services,
logger: ruleExecutionLogger,
})
);
addToSearchAfterReturn({ current: toReturn, next: unsuppressedResult });
}
const bulkCreateResult = await bulkCreateWithSuppression({
alertWithSuppression,
ruleExecutionLogger,
wrappedDocs: suppressibleWrappedDocs,
services,
suppressionWindow,
alertTimestampOverride,
isSuppressionPerRuleExecution: !suppressionDuration,
maxAlerts: tuple.maxSignals - toReturn.createdSignalsCount,
experimentalFeatures,
});
addToSearchAfterReturn({ current: toReturn, next: bulkCreateResult });
const alertsWereTruncated =
(toReturn.suppressedAlertsCount ?? 0) + toReturn.createdSignalsCount >= suppressionMaxSignals ||
toReturn.createdSignalsCount >= tuple.maxSignals;
return {
...bulkCreateResult,
alertsWereTruncated,
};
};

View file

@ -21,6 +21,7 @@ import type {
} from '../../../../../common/api/detection_engine/model/alerts';
import type { RuleServices } from '../types';
import { createEnrichEventsFunction } from './enrichments';
import type { ExperimentalFeatures } from '../../../../../common';
export interface GenericBulkCreateResponse<T extends BaseFieldsLatest> {
success: boolean;
@ -30,6 +31,7 @@ export interface GenericBulkCreateResponse<T extends BaseFieldsLatest> {
suppressedItemsCount: number;
createdItems: Array<AlertWithCommonFieldsLatest<T> & { _id: string; _index: string }>;
errors: string[];
alertsWereTruncated: boolean;
}
export const bulkCreateWithSuppression = async <
@ -42,6 +44,8 @@ export const bulkCreateWithSuppression = async <
suppressionWindow,
alertTimestampOverride,
isSuppressionPerRuleExecution,
maxAlerts,
experimentalFeatures,
}: {
alertWithSuppression: SuppressedAlertService;
ruleExecutionLogger: IRuleExecutionLogForExecutors;
@ -50,6 +54,8 @@ export const bulkCreateWithSuppression = async <
suppressionWindow: string;
alertTimestampOverride: Date | undefined;
isSuppressionPerRuleExecution?: boolean;
maxAlerts?: number;
experimentalFeatures?: ExperimentalFeatures;
}): Promise<GenericBulkCreateResponse<T>> => {
if (wrappedDocs.length === 0) {
return {
@ -60,6 +66,7 @@ export const bulkCreateWithSuppression = async <
createdItemsCount: 0,
suppressedItemsCount: 0,
createdItems: [],
alertsWereTruncated: false,
};
}
@ -75,7 +82,7 @@ export const bulkCreateWithSuppression = async <
const enrichAlertsWrapper: typeof enrichAlerts = async (alerts, params) => {
enrichmentsTimeStart = performance.now();
try {
const enrichedAlerts = await enrichAlerts(alerts, params);
const enrichedAlerts = await enrichAlerts(alerts, params, experimentalFeatures);
return enrichedAlerts;
} catch (error) {
ruleExecutionLogger.error(`Alerts enrichment failed: ${error}`);
@ -85,17 +92,19 @@ export const bulkCreateWithSuppression = async <
}
};
const { createdAlerts, errors, suppressedAlerts } = await alertWithSuppression(
wrappedDocs.map((doc) => ({
_id: doc._id,
// `fields` should have already been merged into `doc._source`
_source: doc._source,
})),
suppressionWindow,
enrichAlertsWrapper,
alertTimestampOverride,
isSuppressionPerRuleExecution
);
const { createdAlerts, errors, suppressedAlerts, alertsWereTruncated } =
await alertWithSuppression(
wrappedDocs.map((doc) => ({
_id: doc._id,
// `fields` should have already been merged into `doc._source`
_source: doc._source,
})),
suppressionWindow,
enrichAlertsWrapper,
alertTimestampOverride,
isSuppressionPerRuleExecution,
maxAlerts
);
const end = performance.now();
@ -111,6 +120,7 @@ export const bulkCreateWithSuppression = async <
createdItemsCount: createdAlerts.length,
createdItems: createdAlerts,
suppressedItemsCount: suppressedAlerts.length,
alertsWereTruncated,
};
} else {
return {
@ -121,6 +131,7 @@ export const bulkCreateWithSuppression = async <
createdItemsCount: createdAlerts.length,
createdItems: createdAlerts,
suppressedItemsCount: suppressedAlerts.length,
alertsWereTruncated,
};
}
};

View file

@ -0,0 +1,45 @@
/*
* 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 type { LicensingPluginSetup } from '@kbn/licensing-plugin/server';
import { firstValueFrom } from 'rxjs';
import type { AlertSuppressionCamel } from '../../../../../common/api/detection_engine/model/rule_schema';
interface GetIsAlertSuppressionActiveParams {
alertSuppression: AlertSuppressionCamel | undefined;
isFeatureDisabled: boolean | undefined;
licensing: LicensingPluginSetup;
}
/**
* checks if alert suppression is active:
* - rule should have alert suppression config
* - feature flag should not be disabled
* - license should be platinum
*/
export const getIsAlertSuppressionActive = async ({
licensing,
alertSuppression,
isFeatureDisabled = false,
}: GetIsAlertSuppressionActiveParams) => {
if (isFeatureDisabled) {
return false;
}
const isAlertSuppressionConfigured = Boolean(alertSuppression?.groupBy?.length);
if (!isAlertSuppressionConfigured) {
return false;
}
const license = await firstValueFrom(licensing.license$);
const hasPlatinumLicense = license.hasAtLeast('platinum');
return hasPlatinumLicense;
};

View file

@ -28,3 +28,4 @@ export const createResultObject = <TState extends RuleTypeState>(state: TState)
export * from './get_list_client';
export * from './validate_mutated_params';
export * from './build_timestamp_runtime_mapping';
export * from './suppression_utils';

View file

@ -56,6 +56,63 @@ describe('partitionMissingFieldsEvents', () => {
],
]);
});
it('should partition when fields objects located in event property', () => {
expect(
partitionMissingFieldsEvents(
[
{
event: {
fields: {
'agent.host': 'host-1',
'agent.type': ['test-1', 'test-2'],
'agent.version': 2,
},
_id: '1',
_index: 'index-0',
},
},
{
event: {
fields: {
'agent.host': 'host-1',
'agent.type': ['test-1', 'test-2'],
},
_id: '1',
_index: 'index-0',
},
},
],
['agent.host', 'agent.type', 'agent.version'],
['event']
)
).toEqual([
[
{
event: {
fields: {
'agent.host': 'host-1',
'agent.type': ['test-1', 'test-2'],
'agent.version': 2,
},
_id: '1',
_index: 'index-0',
},
},
],
[
{
event: {
fields: {
'agent.host': 'host-1',
'agent.type': ['test-1', 'test-2'],
},
_id: '1',
_index: 'index-0',
},
},
],
]);
});
it('should partition if two fields are empty', () => {
expect(
partitionMissingFieldsEvents(

View file

@ -6,6 +6,7 @@
*/
import pick from 'lodash/pick';
import get from 'lodash/get';
import partition from 'lodash/partition';
import type { SignalSourceHit } from '../types';
@ -15,16 +16,21 @@ import type { SignalSourceHit } from '../types';
* 1. first one, where no suppressed by field has empty value
* 2. where any of fields is empty
*/
export const partitionMissingFieldsEvents = (
events: SignalSourceHit[],
suppressedBy: string[] = []
): SignalSourceHit[][] => {
export const partitionMissingFieldsEvents = <
T extends SignalSourceHit | { event: SignalSourceHit }
>(
events: T[],
suppressedBy: string[] = [],
// path to fields property within event object. At this point, it can be in root of event object or within event key
fieldsPath: ['event'] | [] = []
): T[][] => {
return partition(events, (event) => {
if (suppressedBy.length === 0) {
return true;
}
const eventFields = get(event, [...fieldsPath, 'fields']);
const hasMissingFields =
Object.keys(pick(event.fields, suppressedBy)).length < suppressedBy.length;
Object.keys(pick(eventFields, suppressedBy)).length < suppressedBy.length;
return !hasMissingFields;
});

View file

@ -7,20 +7,14 @@
import type { SuppressedAlertService } from '@kbn/rule-registry-plugin/server';
import { bulkCreateWithSuppression } from './bulk_create_with_suppression';
import { addToSearchAfterReturn, getSuppressionMaxSignalsWarning } from './utils';
import { getSuppressionMaxSignalsWarning } from './utils';
import type {
SearchAfterAndBulkCreateParams,
SearchAfterAndBulkCreateReturnType,
WrapSuppressedHits,
} from '../types';
import { MAX_SIGNALS_SUPPRESSION_MULTIPLIER } from '../constants';
import { createEnrichEventsFunction } from './enrichments';
import { AlertSuppressionMissingFieldsStrategyEnum } from '../../../../../common/api/detection_engine/model/rule_schema';
import type { AlertSuppressionCamel } from '../../../../../common/api/detection_engine/model/rule_schema';
import { DEFAULT_SUPPRESSION_MISSING_FIELDS_STRATEGY } from '../../../../../common/detection_engine/constants';
import { partitionMissingFieldsEvents } from './partition_missing_fields_events';
interface SearchAfterAndBulkCreateSuppressedAlertsParams extends SearchAfterAndBulkCreateParams {
wrapSuppressedHits: WrapSuppressedHits;
alertTimestampOverride: Date | undefined;
@ -30,6 +24,7 @@ interface SearchAfterAndBulkCreateSuppressedAlertsParams extends SearchAfterAndB
import type { SearchAfterAndBulkCreateFactoryParams } from './search_after_bulk_create_factory';
import { searchAfterAndBulkCreateFactory } from './search_after_bulk_create_factory';
import { bulkCreateSuppressedAlertsInMemory } from './bulk_create_suppressed_alerts_in_memory';
/**
* search_after through documents and re-index using bulk endpoint
@ -55,62 +50,20 @@ export const searchAfterAndBulkCreateSuppressedAlerts = async (
enrichedEvents,
toReturn,
}) => {
// max signals for suppression includes suppressed and created alerts
// this allows to lift max signals limitation to higher value
// and can detects threats beyond default max_signals value
const suppressionMaxSignals = MAX_SIGNALS_SUPPRESSION_MULTIPLIER * tuple.maxSignals;
const suppressionDuration = alertSuppression?.duration;
const suppressionWindow = suppressionDuration
? `now-${suppressionDuration.value}${suppressionDuration.unit}`
: tuple.from.toISOString();
const suppressOnMissingFields =
(alertSuppression?.missingFieldsStrategy ?? DEFAULT_SUPPRESSION_MISSING_FIELDS_STRATEGY) ===
AlertSuppressionMissingFieldsStrategyEnum.suppress;
let suppressibleEvents = enrichedEvents;
if (!suppressOnMissingFields) {
const partitionedEvents = partitionMissingFieldsEvents(
enrichedEvents,
alertSuppression?.groupBy || []
);
const wrappedDocs = wrapHits(partitionedEvents[1], buildReasonMessage);
suppressibleEvents = partitionedEvents[0];
const unsuppressedResult = await bulkCreate(
wrappedDocs,
tuple.maxSignals - toReturn.createdSignalsCount,
createEnrichEventsFunction({
services,
logger: ruleExecutionLogger,
})
);
addToSearchAfterReturn({ current: toReturn, next: unsuppressedResult });
}
const wrappedDocs = wrapSuppressedHits(suppressibleEvents, buildReasonMessage);
const bulkCreateResult = await bulkCreateWithSuppression({
alertWithSuppression,
ruleExecutionLogger,
wrappedDocs,
return bulkCreateSuppressedAlertsInMemory({
wrapHits,
bulkCreate,
services,
suppressionWindow,
buildReasonMessage,
ruleExecutionLogger,
tuple,
alertSuppression,
wrapSuppressedHits,
alertWithSuppression,
alertTimestampOverride,
isSuppressionPerRuleExecution: !suppressionDuration,
enrichedEvents,
toReturn,
});
addToSearchAfterReturn({ current: toReturn, next: bulkCreateResult });
return {
...bulkCreateResult,
alertsWereTruncated:
(toReturn.suppressedAlertsCount ?? 0) + toReturn.createdSignalsCount >=
suppressionMaxSignals || toReturn.createdSignalsCount >= tuple.maxSignals,
};
};
return searchAfterAndBulkCreateFactory({

View file

@ -0,0 +1,125 @@
/*
* 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 {
ALERT_SUPPRESSION_DOCS_COUNT,
ALERT_INSTANCE_ID,
ALERT_SUPPRESSION_TERMS,
ALERT_SUPPRESSION_START,
ALERT_SUPPRESSION_END,
} from '@kbn/rule-data-utils';
import { getSuppressionTerms, getSuppressionAlertFields } from './suppression_utils';
describe('getSuppressionAlertFields', () => {
const suppressionTerms = [
{
field: 'agent.name',
value: ['agent-0'],
},
];
const instanceId = 'mock-id';
const fallbackTimestamp = '2020-10-28T06:30:00.000Z';
const expectedTimestamp = '2022-02-24T06:30:00.000Z';
it('should return suppression fields', () => {
expect(
getSuppressionAlertFields({
primaryTimestamp: '@timestamp',
fields: { '@timestamp': expectedTimestamp },
suppressionTerms,
instanceId,
fallbackTimestamp,
})
).toEqual({
[ALERT_SUPPRESSION_TERMS]: suppressionTerms,
[ALERT_SUPPRESSION_START]: new Date(expectedTimestamp),
[ALERT_SUPPRESSION_END]: new Date(expectedTimestamp),
[ALERT_SUPPRESSION_DOCS_COUNT]: 0,
[ALERT_INSTANCE_ID]: instanceId,
});
});
it('should set suppression boundaries from secondary timestamp field', () => {
expect(
getSuppressionAlertFields({
primaryTimestamp: '@timestamp',
secondaryTimestamp: 'event.ingested',
fields: { 'event.ingested': expectedTimestamp },
suppressionTerms,
instanceId,
fallbackTimestamp,
})
).toEqual(
expect.objectContaining({
[ALERT_SUPPRESSION_START]: new Date(expectedTimestamp),
[ALERT_SUPPRESSION_END]: new Date(expectedTimestamp),
})
);
});
it('should set suppression boundaries from fallback timestamp', () => {
expect(
getSuppressionAlertFields({
primaryTimestamp: '@timestamp',
secondaryTimestamp: 'event.ingested',
fields: {},
suppressionTerms,
instanceId,
fallbackTimestamp,
})
).toEqual(
expect.objectContaining({
[ALERT_SUPPRESSION_START]: new Date(fallbackTimestamp),
[ALERT_SUPPRESSION_END]: new Date(fallbackTimestamp),
})
);
});
});
describe('getSuppressionTerms', () => {
it('should return suppression terms', () => {
expect(
getSuppressionTerms({
alertSuppression: {
groupBy: ['host.name'],
},
fields: { 'host.name': 'localhost-1' },
})
).toEqual([{ field: 'host.name', value: 'localhost-1' }]);
});
it('should return suppression terms array when fields do not have matches', () => {
expect(
getSuppressionTerms({
alertSuppression: {
groupBy: ['host.name'],
},
fields: { 'host.ip': '127.0.0.1' },
})
).toEqual([{ field: 'host.name', value: null }]);
});
it('should return sorted suppression terms array value', () => {
expect(
getSuppressionTerms({
alertSuppression: {
groupBy: ['host.name'],
},
fields: { 'host.name': ['localhost-2', 'localhost-1'] },
})
).toEqual([{ field: 'host.name', value: ['localhost-1', 'localhost-2'] }]);
});
it('should return multiple suppression terms', () => {
expect(
getSuppressionTerms({
alertSuppression: {
groupBy: ['host.name', 'host.ip'],
},
fields: { 'host.name': ['localhost-1'], 'agent.name': 'test', 'host.ip': '127.0.0.1' },
})
).toEqual([
{ field: 'host.name', value: ['localhost-1'] },
{ field: 'host.ip', value: '127.0.0.1' },
]);
});
});

View file

@ -0,0 +1,86 @@
/*
* 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 pick from 'lodash/pick';
import get from 'lodash/get';
import sortBy from 'lodash/sortBy';
import {
ALERT_SUPPRESSION_DOCS_COUNT,
ALERT_INSTANCE_ID,
ALERT_SUPPRESSION_TERMS,
ALERT_SUPPRESSION_START,
ALERT_SUPPRESSION_END,
} from '@kbn/rule-data-utils';
import type { AlertSuppressionCamel } from '../../../../../common/api/detection_engine/model/rule_schema';
interface SuppressionTerm {
field: string;
value: string[] | number[] | null;
}
/**
* returns an object containing the standard suppression fields (ALERT_INSTANCE_ID, ALERT_SUPPRESSION_TERMS, etc), with corresponding values populated from the `fields` parameter.
*/
export const getSuppressionAlertFields = ({
primaryTimestamp,
secondaryTimestamp,
fields,
suppressionTerms,
fallbackTimestamp,
instanceId,
}: {
fields: Record<string, string | number> | undefined;
primaryTimestamp: string;
secondaryTimestamp?: string;
suppressionTerms: SuppressionTerm[];
fallbackTimestamp: string;
instanceId: string;
}) => {
const suppressionTime = new Date(
get(fields, primaryTimestamp) ??
(secondaryTimestamp && get(fields, secondaryTimestamp)) ??
fallbackTimestamp
);
const suppressionFields = {
[ALERT_INSTANCE_ID]: instanceId,
[ALERT_SUPPRESSION_TERMS]: suppressionTerms,
[ALERT_SUPPRESSION_START]: suppressionTime,
[ALERT_SUPPRESSION_END]: suppressionTime,
[ALERT_SUPPRESSION_DOCS_COUNT]: 0,
};
return suppressionFields;
};
/**
* returns an array of {@link SuppressionTerm}s by retrieving the appropriate field values based on the provided alertSuppression configuration
*/
export const getSuppressionTerms = ({
alertSuppression,
fields,
}: {
fields: Record<string, unknown> | undefined;
alertSuppression: AlertSuppressionCamel | undefined;
}): SuppressionTerm[] => {
const suppressedBy = alertSuppression?.groupBy ?? [];
const suppressedProps = pick(fields, suppressedBy) as Record<
string,
string[] | number[] | undefined
>;
const suppressionTerms = suppressedBy.map((field) => {
const value = suppressedProps[field] ?? null;
const sortedValue = Array.isArray(value) ? (sortBy(value) as string[] | number[]) : value;
return {
field,
value: sortedValue,
};
});
return suppressionTerms;
};

View file

@ -6,19 +6,9 @@
*/
import objectHash from 'object-hash';
import pick from 'lodash/pick';
import get from 'lodash/get';
import sortBy from 'lodash/sortBy';
import type { SuppressionFieldsLatest } from '@kbn/rule-registry-plugin/common/schemas';
import {
ALERT_SUPPRESSION_DOCS_COUNT,
ALERT_INSTANCE_ID,
ALERT_SUPPRESSION_TERMS,
ALERT_SUPPRESSION_START,
ALERT_SUPPRESSION_END,
TIMESTAMP,
} from '@kbn/rule-data-utils';
import { TIMESTAMP } from '@kbn/rule-data-utils';
import type { SignalSourceHit } from '../types';
import type {
@ -29,6 +19,7 @@ import type { ConfigType } from '../../../../config';
import type { CompleteRule, ThreatRuleParams } from '../../rule_schema';
import type { IRuleExecutionLogForExecutors } from '../../rule_monitoring';
import { buildBulkBody } from '../factories/utils/build_bulk_body';
import { getSuppressionAlertFields, getSuppressionTerms } from './suppression_utils';
import type { BuildReasonMessage } from './reason_formatters';
@ -62,20 +53,10 @@ export const wrapSuppressedAlerts = ({
primaryTimestamp: string;
secondaryTimestamp?: string;
}): Array<WrappedFieldsLatest<BaseFieldsLatest & SuppressionFieldsLatest>> => {
const suppressedBy = completeRule?.ruleParams?.alertSuppression?.groupBy ?? [];
return events.map((event) => {
const suppressedProps = pick(event.fields, suppressedBy) as Record<
string,
string[] | number[] | undefined
>;
const suppressionTerms = suppressedBy.map((field) => {
const value = suppressedProps[field] ?? null;
const sortedValue = Array.isArray(value) ? (sortBy(value) as string[] | number[]) : value;
return {
field,
value: sortedValue,
};
const suppressionTerms = getSuppressionTerms({
alertSuppression: completeRule?.ruleParams?.alertSuppression,
fields: event.fields,
});
const id = objectHash([
@ -102,22 +83,19 @@ export const wrapSuppressedAlerts = ({
publicBaseUrl
);
const suppressionTime = new Date(
get(event.fields, primaryTimestamp) ??
(secondaryTimestamp && get(event.fields, secondaryTimestamp)) ??
baseAlert[TIMESTAMP]
);
return {
_id: id,
_index: '',
_source: {
...baseAlert,
[ALERT_SUPPRESSION_TERMS]: suppressionTerms,
[ALERT_SUPPRESSION_START]: suppressionTime,
[ALERT_SUPPRESSION_END]: suppressionTime,
[ALERT_SUPPRESSION_DOCS_COUNT]: 0,
[ALERT_INSTANCE_ID]: instanceId,
...getSuppressionAlertFields({
primaryTimestamp,
secondaryTimestamp,
fields: event.fields,
suppressionTerms,
fallbackTimestamp: baseAlert[TIMESTAMP],
instanceId,
}),
},
};
});

View file

@ -67,6 +67,9 @@
"name": {
"type": "keyword"
},
"ip": {
"type": "ip"
},
"uptime": {
"type": "long"
}

View file

@ -79,6 +79,7 @@ export function createTestConfig(options: CreateTestConfigOptions, testFiles?: s
'--xpack.ruleRegistry.unsafe.indexUpgrade.enabled=true',
'--xpack.ruleRegistry.unsafe.legacyMultiTenancy.enabled=true',
`--xpack.securitySolution.enableExperimental=${JSON.stringify([
'alertSuppressionForNewTermsRuleEnabled',
'previewTelemetryUrlEnabled',
'riskScoringPersistence',
'riskScoringRoutesEnabled',

View file

@ -17,5 +17,8 @@ export default createTestConfig({
'testing_ignored.constant',
'/testing_regex*/',
])}`, // See tests within the file "ignore_fields.ts" which use these values in "alertIgnoreFields"
`--xpack.securitySolution.enableExperimental=${JSON.stringify([
'alertSuppressionForNewTermsRuleEnabled',
])}`,
],
});

View file

@ -33,7 +33,7 @@ import { getMaxSignalsWarning as getMaxAlertsWarning } from '@kbn/security-solut
import { ENABLE_ASSET_CRITICALITY_SETTING } from '@kbn/security-solution-plugin/common/constants';
import {
getEqlRuleForAlertTesting,
getOpenAlerts,
getAlerts,
getPreviewAlerts,
previewRule,
dataGeneratorFactory,
@ -98,7 +98,7 @@ export default ({ getService }: FtrProviderContext) => {
query: specificQueryForTests,
};
const createdRule = await createRule(supertest, log, rule);
const alerts = await getOpenAlerts(supertest, log, es, createdRule);
const alerts = await getAlerts(supertest, log, es, createdRule);
expect(alerts.hits.hits.length).eql(1);
const fullAlert = alerts.hits.hits[0]._source;
if (!fullAlert) {

View file

@ -17,7 +17,7 @@ import { ENABLE_ASSET_CRITICALITY_SETTING } from '@kbn/security-solution-plugin/
import {
getPreviewAlerts,
previewRule,
getOpenAlerts,
getAlerts,
dataGeneratorFactory,
previewRuleWithExceptionEntries,
removeRandomValuedPropertiesFromAlert,
@ -83,7 +83,7 @@ export default ({ getService }: FtrProviderContext) => {
});
const createdRule = await createRule(supertest, log, rule);
const alerts = await getOpenAlerts(supertest, log, es, createdRule);
const alerts = await getAlerts(supertest, log, es, createdRule);
expect(alerts.hits.hits.length).toBe(1);
expect(removeRandomValuedPropertiesFromAlert(alerts.hits.hits[0]._source)).toEqual({
@ -786,7 +786,7 @@ export default ({ getService }: FtrProviderContext) => {
const createdRule = await createRule(supertest, log, rule);
// first rule run should generate 100 alerts from first 3 batches of index documents
const alertsResponseFromFirstRuleExecution = await getOpenAlerts(
const alertsResponseFromFirstRuleExecution = await getAlerts(
supertest,
log,
es,
@ -811,7 +811,7 @@ export default ({ getService }: FtrProviderContext) => {
enabled: true,
});
const alertsResponse = await getOpenAlerts(
const alertsResponse = await getAlerts(
supertest,
log,
es,

View file

@ -13,6 +13,7 @@ export default ({ loadTestFile }: FtrProviderContext): void => {
loadTestFile(require.resolve('./esql'));
loadTestFile(require.resolve('./machine_learning'));
loadTestFile(require.resolve('./new_terms'));
loadTestFile(require.resolve('./new_terms_alert_suppression'));
loadTestFile(require.resolve('./saved_query'));
loadTestFile(require.resolve('./threat_match'));
loadTestFile(require.resolve('./threat_match_alert_suppression'));

View file

@ -37,7 +37,7 @@ import {
import {
executeSetupModuleRequest,
forceStartDatafeeds,
getOpenAlerts,
getAlerts,
getPreviewAlerts,
previewRule,
previewRuleWithExceptionEntries,
@ -96,7 +96,7 @@ export default ({ getService }: FtrProviderContext) => {
// First test creates a real rule - remaining tests use preview API
it('should create 1 alert from ML rule when record meets anomaly_threshold', async () => {
const createdRule = await createRule(supertest, log, rule);
const alerts = await getOpenAlerts(supertest, log, es, createdRule);
const alerts = await getAlerts(supertest, log, es, createdRule);
expect(alerts.hits.hits.length).toBe(1);
const alert = alerts.hits.hits[0];

View file

@ -15,7 +15,7 @@ import { getCreateNewTermsRulesSchemaMock } from '@kbn/security-solution-plugin/
import { getMaxSignalsWarning as getMaxAlertsWarning } from '@kbn/security-solution-plugin/server/lib/detection_engine/rule_types/utils/utils';
import { ENABLE_ASSET_CRITICALITY_SETTING } from '@kbn/security-solution-plugin/common/constants';
import {
getOpenAlerts,
getAlerts,
getPreviewAlerts,
previewRule,
dataGeneratorFactory,
@ -108,7 +108,7 @@ export default ({ getService }: FtrProviderContext) => {
};
const createdRule = await createRule(supertest, log, rule);
const alerts = await getOpenAlerts(supertest, log, es, createdRule);
const alerts = await getAlerts(supertest, log, es, createdRule);
expect(alerts.hits.hits.length).eql(1);
expect(removeRandomValuedPropertiesFromAlert(alerts.hits.hits[0]._source)).eql({

View file

@ -51,7 +51,7 @@ import { deleteAllExceptions } from '../../../../../lists_and_exception_lists/ut
import {
createExceptionList,
createExceptionListItem,
getOpenAlerts,
getAlerts,
getPreviewAlerts,
getSimpleRule,
previewRule,
@ -127,7 +127,7 @@ export default ({ getService }: FtrProviderContext) => {
query: `_id:${ID}`,
};
const createdRule = await createRule(supertest, log, rule);
const alerts = await getOpenAlerts(supertest, log, es, createdRule);
const alerts = await getAlerts(supertest, log, es, createdRule);
expect(alerts.hits.hits.length).greaterThan(0);
expect(alerts.hits.hits[0]._source?.['kibana.alert.ancestors'][0].id).eql(ID);
});
@ -811,7 +811,7 @@ export default ({ getService }: FtrProviderContext) => {
},
};
const createdRule = await createRule(supertest, log, rule);
const alerts = await getOpenAlerts(supertest, log, es, createdRule);
const alerts = await getAlerts(supertest, log, es, createdRule);
expect(alerts.hits.hits.length).eql(1);
expect(alerts.hits.hits[0]._source).to.eql({
...alerts.hits.hits[0]._source,
@ -841,7 +841,7 @@ export default ({ getService }: FtrProviderContext) => {
await patchRule(supertest, log, { id: createdRule.id, enabled: false });
await patchRule(supertest, log, { id: createdRule.id, enabled: true });
const afterTimestamp = new Date();
const secondAlerts = await getOpenAlerts(
const secondAlerts = await getAlerts(
supertest,
log,
es,
@ -892,7 +892,7 @@ export default ({ getService }: FtrProviderContext) => {
},
};
const createdRule = await createRule(supertest, log, rule);
const alerts = await getOpenAlerts(supertest, log, es, createdRule);
const alerts = await getAlerts(supertest, log, es, createdRule);
// Close the alert. Subsequent rule executions should ignore this closed alert
// for suppression purposes.
@ -917,7 +917,7 @@ export default ({ getService }: FtrProviderContext) => {
await patchRule(supertest, log, { id: createdRule.id, enabled: false });
await patchRule(supertest, log, { id: createdRule.id, enabled: true });
const afterTimestamp = new Date();
const secondAlerts = await getOpenAlerts(
const secondAlerts = await getAlerts(
supertest,
log,
es,
@ -2357,7 +2357,7 @@ export default ({ getService }: FtrProviderContext) => {
.set('elastic-api-version', '2023-10-31')
.expect(200);
const alertsAfterEnable = await getOpenAlerts(supertest, log, es, ruleBody, 'succeeded');
const alertsAfterEnable = await getAlerts(supertest, log, es, ruleBody, 'succeeded');
expect(alertsAfterEnable.hits.hits.length > 0).eql(true);
});
});

View file

@ -16,7 +16,7 @@ import {
ALERT_ORIGINAL_TIME,
ALERT_ORIGINAL_EVENT,
} from '@kbn/security-solution-plugin/common/field_maps/field_names';
import { getOpenAlerts } from '../../../../utils';
import { getAlerts } from '../../../../utils';
import {
createRule,
deleteAllRules,
@ -63,7 +63,7 @@ export default ({ getService }: FtrProviderContext) => {
saved_id: 'doesnt-exist',
};
const createdRule = await createRule(supertest, log, rule);
const alerts = await getOpenAlerts(supertest, log, es, createdRule);
const alerts = await getAlerts(supertest, log, es, createdRule);
const alert = alerts.hits.hits[0]._source;
expect(alert).eql({
...alert,

View file

@ -38,7 +38,7 @@ import {
import { RuleExecutionStatusEnum } from '@kbn/security-solution-plugin/common/api/detection_engine/rule_monitoring';
import { getMaxSignalsWarning as getMaxAlertsWarning } from '@kbn/security-solution-plugin/server/lib/detection_engine/rule_types/utils/utils';
import { ENABLE_ASSET_CRITICALITY_SETTING } from '@kbn/security-solution-plugin/common/constants';
import { previewRule, getOpenAlerts, getPreviewAlerts } from '../../../../utils';
import { previewRule, getAlerts, getPreviewAlerts } from '../../../../utils';
import {
deleteAllAlerts,
deleteAllRules,
@ -175,7 +175,7 @@ export default ({ getService }: FtrProviderContext) => {
const rule: ThreatMatchRuleCreateProps = createThreatMatchRule();
const createdRule = await createRule(supertest, log, rule);
const alerts = await getOpenAlerts(
const alerts = await getAlerts(
supertest,
log,
es,
@ -356,7 +356,7 @@ export default ({ getService }: FtrProviderContext) => {
});
const createdRule = await createRule(supertest, log, rule);
const alerts = await getOpenAlerts(
const alerts = await getAlerts(
supertest,
log,
es,
@ -558,7 +558,7 @@ export default ({ getService }: FtrProviderContext) => {
const createdRuleTerm = await createRule(supertest, log, termRule);
const createdRuleMatch = await createRule(supertest, log, matchRule);
const alertsTerm = await getOpenAlerts(
const alertsTerm = await getAlerts(
supertest,
log,
es,
@ -566,7 +566,7 @@ export default ({ getService }: FtrProviderContext) => {
RuleExecutionStatusEnum.succeeded,
100
);
const alertsMatch = await getOpenAlerts(
const alertsMatch = await getAlerts(
supertest,
log,
es,

View file

@ -334,8 +334,8 @@ export default ({ getService }: FtrProviderContext) => {
const secondDocument = {
id,
'@timestamp': secondTimestamp,
agent: {
name: 'agent-1',
host: {
name: 'host-a',
},
};
// Add new documents, then disable and re-enable to trigger another rule run. The second doc should

View file

@ -27,7 +27,7 @@ import { getMaxSignalsWarning as getMaxAlertsWarning } from '@kbn/security-solut
import { ENABLE_ASSET_CRITICALITY_SETTING } from '@kbn/security-solution-plugin/common/constants';
import { createRule } from '../../../../../../../common/utils/security_solution';
import {
getOpenAlerts,
getAlerts,
getPreviewAlerts,
getThresholdRuleForAlertTesting,
previewRule,
@ -66,7 +66,7 @@ export default ({ getService }: FtrProviderContext) => {
},
};
const createdRule = await createRule(supertest, log, rule);
const alerts = await getOpenAlerts(supertest, log, es, createdRule);
const alerts = await getAlerts(supertest, log, es, createdRule);
expect(alerts.hits.hits.length).toEqual(1);
const fullAlert = alerts.hits.hits[0]._source;
if (!fullAlert) {
@ -332,7 +332,7 @@ export default ({ getService }: FtrProviderContext) => {
},
};
const createdRule = await createRule(supertest, log, rule);
const alerts = await getOpenAlerts(supertest, log, es, createdRule);
const alerts = await getAlerts(supertest, log, es, createdRule);
expect(alerts.hits.hits.length).toEqual(1);
});

View file

@ -25,7 +25,7 @@ import { RuleExecutionStatusEnum } from '@kbn/security-solution-plugin/common/ap
import { ALERT_ORIGINAL_TIME } from '@kbn/security-solution-plugin/common/field_maps/field_names';
import { createRule } from '../../../../../../../common/utils/security_solution';
import {
getOpenAlerts,
getAlerts,
getPreviewAlerts,
getThresholdRuleForAlertTesting,
previewRule,
@ -85,7 +85,7 @@ export default ({ getService }: FtrProviderContext) => {
interval: '30m',
};
const createdRule = await createRule(supertest, log, rule);
const alerts = await getOpenAlerts(supertest, log, es, createdRule);
const alerts = await getAlerts(supertest, log, es, createdRule);
expect(alerts.hits.hits.length).toEqual(1);
// suppression start equal to alert timestamp
@ -122,7 +122,7 @@ export default ({ getService }: FtrProviderContext) => {
await patchRule(supertest, log, { id: createdRule.id, enabled: false });
await patchRule(supertest, log, { id: createdRule.id, enabled: true });
const afterTimestamp = new Date();
const secondAlerts = await getOpenAlerts(
const secondAlerts = await getAlerts(
supertest,
log,
es,
@ -177,7 +177,7 @@ export default ({ getService }: FtrProviderContext) => {
interval: '30m',
};
const createdRule = await createRule(supertest, log, rule);
const alerts = await getOpenAlerts(supertest, log, es, createdRule);
const alerts = await getAlerts(supertest, log, es, createdRule);
// Close the alert. Subsequent rule executions should ignore this closed alert
// for suppression purposes.
@ -202,7 +202,7 @@ export default ({ getService }: FtrProviderContext) => {
await patchRule(supertest, log, { id: createdRule.id, enabled: false });
await patchRule(supertest, log, { id: createdRule.id, enabled: true });
const afterTimestamp = new Date();
const secondAlerts = await getOpenAlerts(
const secondAlerts = await getAlerts(
supertest,
log,
es,

View file

@ -14,7 +14,7 @@ import {
} from '@kbn/security-solution-plugin/common/api/detection_engine';
import { ALERT_ORIGINAL_TIME } from '@kbn/security-solution-plugin/common/field_maps/field_names';
import { getOpenAlerts, getEqlRuleForAlertTesting } from '../../../utils';
import { getAlerts, getEqlRuleForAlertTesting } from '../../../utils';
import {
createAlertsIndex,
deleteAllRules,
@ -251,7 +251,7 @@ export default ({ getService }: FtrProviderContext) => {
};
const createdRule = await createRule(supertest, log, rule);
const alertsOpen = await getOpenAlerts(
const alertsOpen = await getAlerts(
supertest,
log,
es,
@ -330,7 +330,7 @@ export default ({ getService }: FtrProviderContext) => {
timestamp_override_fallback_disabled: true,
};
const createdRule = await createRule(supertest, log, rule);
const alertsOpen = await getOpenAlerts(
const alertsOpen = await getAlerts(
supertest,
log,
es,

View file

@ -0,0 +1,48 @@
/*
* 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 type SuperTest from 'supertest';
import type { Client } from '@elastic/elasticsearch';
import type { ToolingLog } from '@kbn/tooling-log';
import {
RuleExecutionStatus,
RuleExecutionStatusEnum,
} from '@kbn/security-solution-plugin/common/api/detection_engine/rule_monitoring';
import type { RuleResponse } from '@kbn/security-solution-plugin/common/api/detection_engine';
import { refreshIndex } from '..';
import { getAlertsByIds, waitForRuleStatus } from '../../../../../common/utils/security_solution';
export type GetAlerts = (
supertest: SuperTest.SuperTest<SuperTest.Test>,
log: ToolingLog,
es: Client,
rule: RuleResponse,
status?: RuleExecutionStatus,
size?: number,
afterDate?: Date
) => ReturnType<typeof getAlertsByIds>;
/**
* returns all alerts: opened and closed
*/
export const getAlerts: GetAlerts = async (
supertest,
log,
es,
rule,
status = RuleExecutionStatusEnum.succeeded,
size,
afterDate
) => {
await waitForRuleStatus(status, { supertest, log, id: rule.id, afterDate });
// Critically important that we wait for rule success AND refresh the write index in that order before we
// assert that no Alerts were created. Otherwise, Alerts could be written but not available to query yet
// when we search, causing tests that check that Alerts are NOT created to pass when they should fail.
await refreshIndex(es, '.alerts-security.alerts-default*');
return getAlertsByIds(supertest, log, [rule.id], size);
};

View file

@ -5,31 +5,19 @@
* 2.0.
*/
import type SuperTest from 'supertest';
import type { Client } from '@elastic/elasticsearch';
import type { ToolingLog } from '@kbn/tooling-log';
import {
RuleExecutionStatus,
RuleExecutionStatusEnum,
} from '@kbn/security-solution-plugin/common/api/detection_engine/rule_monitoring';
import type { RuleResponse } from '@kbn/security-solution-plugin/common/api/detection_engine';
import { ALERT_WORKFLOW_STATUS } from '@kbn/rule-data-utils';
import { getAlerts } from './get_alerts';
import type { GetAlerts } from './get_alerts';
import { refreshIndex } from '..';
import { getAlertsByIds, waitForRuleStatus } from '../../../../../common/utils/security_solution';
/**
* returns only alerts with open status
*/
export const getOpenAlerts: GetAlerts = async (...args) => {
const alerts = await getAlerts(...args);
export const getOpenAlerts = async (
supertest: SuperTest.SuperTest<SuperTest.Test>,
log: ToolingLog,
es: Client,
rule: RuleResponse,
status: RuleExecutionStatus = RuleExecutionStatusEnum.succeeded,
size?: number,
afterDate?: Date
) => {
await waitForRuleStatus(status, { supertest, log, id: rule.id, afterDate });
// Critically important that we wait for rule success AND refresh the write index in that order before we
// assert that no Alerts were created. Otherwise, Alerts could be written but not available to query yet
// when we search, causing tests that check that Alerts are NOT created to pass when they should fail.
await refreshIndex(es, '.alerts-security.alerts-default*');
return getAlertsByIds(supertest, log, [rule.id], size);
alerts.hits.hits = alerts.hits.hits.filter(
(alert) => alert?._source?.[ALERT_WORKFLOW_STATUS] === 'open'
);
return alerts;
};

View file

@ -7,6 +7,7 @@
export * from './wait_for_alert_to_complete';
export * from './wait_for_alert_to_complete';
export * from './get_alerts';
export * from './get_open_alerts';
export * from './remove_random_valued_properties_from_alert';
export * from './set_alert_status';

View file

@ -45,6 +45,7 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) {
'--xpack.alerting.rules.minimumScheduleInterval.value=1s',
'--xpack.ruleRegistry.unsafe.legacyMultiTenancy.enabled=true',
`--xpack.securitySolution.enableExperimental=${JSON.stringify([
'alertSuppressionForNewTermsRuleEnabled',
'chartEmbeddablesEnabled',
])}`,
// mock cloud to enable the guided onboarding tour in e2e tests

View file

@ -0,0 +1,60 @@
/*
* 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 {
selectThresholdRuleType,
selectIndicatorMatchType,
selectNewTermsRuleType,
} from '../../../../tasks/create_new_rule';
import { login } from '../../../../tasks/login';
import { visit } from '../../../../tasks/navigation';
import {
ALERT_SUPPRESSION_FIELDS_INPUT,
THRESHOLD_ENABLE_SUPPRESSION_CHECKBOX,
} from '../../../../screens/create_new_rule';
import { CREATE_RULE_URL } from '../../../../urls/navigation';
describe(
'Detection rules, Alert Suppression for Essentials tier',
{
tags: ['@serverless'],
env: {
ftrConfig: {
productTypes: [
{ product_line: 'security', product_tier: 'essentials' },
{ product_line: 'endpoint', product_tier: 'essentials' },
],
// alertSuppressionForNewTermsRuleEnabled feature flag is also enabled in a global config
kbnServerArgs: [
`--xpack.securitySolution.enableExperimental=${JSON.stringify([
'alertSuppressionForNewTermsRuleEnabled',
])}`,
],
},
},
},
() => {
beforeEach(() => {
login();
visit(CREATE_RULE_URL);
});
it('Alert suppression is enabled for essentials tier for rule types that support it', () => {
// default custom query rule
cy.get(ALERT_SUPPRESSION_FIELDS_INPUT).should('be.enabled');
selectIndicatorMatchType();
cy.get(ALERT_SUPPRESSION_FIELDS_INPUT).should('be.enabled');
selectNewTermsRuleType();
cy.get(ALERT_SUPPRESSION_FIELDS_INPUT).should('be.enabled');
selectThresholdRuleType();
cy.get(THRESHOLD_ENABLE_SUPPRESSION_CHECKBOX).should('be.enabled');
});
}
);

View file

@ -0,0 +1,62 @@
/*
* 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 {
THRESHOLD_ENABLE_SUPPRESSION_CHECKBOX,
ALERT_SUPPRESSION_DURATION_INPUT,
} from '../../../../screens/create_new_rule';
import {
selectIndicatorMatchType,
selectNewTermsRuleType,
selectThresholdRuleType,
openSuppressionFieldsTooltipAndCheckLicense,
} from '../../../../tasks/create_new_rule';
import { startBasicLicense } from '../../../../tasks/api_calls/licensing';
import { login } from '../../../../tasks/login';
import { visit } from '../../../../tasks/navigation';
import { CREATE_RULE_URL } from '../../../../urls/navigation';
import { TOOLTIP } from '../../../../screens/common';
import { deleteAlertsAndRules } from '../../../../tasks/api_calls/common';
describe(
'Detection rules, Common flows Alert Suppression',
{
tags: ['@ess'],
},
() => {
describe('Create rule form', () => {
beforeEach(() => {
deleteAlertsAndRules();
login();
visit(CREATE_RULE_URL);
startBasicLicense();
});
it('can not create rule with rule execution suppression on basic license for all rules with enabled suppression', () => {
// Default query rule
openSuppressionFieldsTooltipAndCheckLicense();
selectIndicatorMatchType();
openSuppressionFieldsTooltipAndCheckLicense();
selectNewTermsRuleType();
openSuppressionFieldsTooltipAndCheckLicense();
selectThresholdRuleType();
cy.get(THRESHOLD_ENABLE_SUPPRESSION_CHECKBOX).should('be.disabled');
cy.get(THRESHOLD_ENABLE_SUPPRESSION_CHECKBOX).parent().trigger('mouseover');
// Platinum license is required, tooltip on disabled alert suppression checkbox should tell this
cy.get(TOOLTIP).contains('Platinum license');
cy.get(ALERT_SUPPRESSION_DURATION_INPUT).eq(0).should('be.disabled');
cy.get(ALERT_SUPPRESSION_DURATION_INPUT).eq(1).should('be.disabled');
});
});
}
);

View file

@ -7,10 +7,6 @@
import { getNewThreatIndicatorRule } from '../../../../objects/rule';
import {
ALERT_SUPPRESSION_FIELDS_INPUT,
ALERT_SUPPRESSION_FIELDS,
} from '../../../../screens/create_new_rule';
import {
SUPPRESS_FOR_DETAILS,
DETAILS_TITLE,
@ -20,7 +16,6 @@ import {
ALERT_SUPPRESSION_INSUFFICIENT_LICENSING_ICON,
} from '../../../../screens/rule_details';
import { selectIndicatorMatchType } from '../../../../tasks/create_new_rule';
import { startBasicLicense } from '../../../../tasks/api_calls/licensing';
import { createRule } from '../../../../tasks/api_calls/rules';
import { login } from '../../../../tasks/login';
@ -47,16 +42,6 @@ describe(
startBasicLicense();
});
it('can not create rule with rule execution suppression on basic license', () => {
selectIndicatorMatchType();
cy.get(ALERT_SUPPRESSION_FIELDS_INPUT).should('be.disabled');
cy.get(ALERT_SUPPRESSION_FIELDS).trigger('mouseover');
// Platinum license is required, tooltip on disabled alert suppression checkbox should tell this
cy.get(TOOLTIP).contains('Platinum license');
});
it('shows upselling message on rule details with suppression on basic license', () => {
const rule = getNewThreatIndicatorRule();

View file

@ -41,6 +41,9 @@ import {
TIMELINE_TEMPLATE_DETAILS,
NEW_TERMS_HISTORY_WINDOW_DETAILS,
NEW_TERMS_FIELDS_DETAILS,
SUPPRESS_BY_DETAILS,
SUPPRESS_FOR_DETAILS,
SUPPRESS_MISSING_FIELD,
} from '../../../../screens/rule_details';
import { getDetails, waitForTheRuleToBeExecuted } from '../../../../tasks/rule_details';
@ -49,94 +52,153 @@ import { deleteAlertsAndRules } from '../../../../tasks/api_calls/common';
import {
createAndEnableRule,
fillAboutRuleAndContinue,
fillDefineNewTermsRule,
fillDefineNewTermsRuleAndContinue,
fillScheduleRuleAndContinue,
selectNewTermsRuleType,
waitForAlertsToPopulate,
fillAlertSuppressionFields,
fillAboutRuleMinimumAndContinue,
createRuleWithoutEnabling,
skipScheduleRuleAction,
continueFromDefineStep,
selectAlertSuppressionPerInterval,
setAlertSuppressionDuration,
selectDoNotSuppressForMissingFields,
} from '../../../../tasks/create_new_rule';
import { login } from '../../../../tasks/login';
import { visit } from '../../../../tasks/navigation';
import { CREATE_RULE_URL } from '../../../../urls/navigation';
import { openRuleManagementPageViaBreadcrumbs } from '../../../../tasks/rules_management';
describe('New Terms rules', { tags: ['@ess', '@serverless'] }, () => {
describe('Detection rules, New Terms', () => {
const rule = getNewTermsRule();
const expectedUrls = rule.references?.join('');
const expectedFalsePositives = rule.false_positives?.join('');
const expectedTags = rule.tags?.join('');
const mitreAttack = rule.threat;
const expectedMitre = formatMitreAttackDescription(mitreAttack ?? []);
const expectedNumberOfRules = 1;
describe(
'New Terms rules',
{
tags: ['@ess', '@serverless'],
env: {
// alertSuppressionForNewTermsRuleEnabled feature flag is also enabled in a global config
kbnServerArgs: [
`--xpack.securitySolution.enableExperimental=${JSON.stringify([
'alertSuppressionForNewTermsRuleEnabled',
])}`,
],
},
},
() => {
describe('Detection rules, New Terms', () => {
const rule = getNewTermsRule();
const expectedUrls = rule.references?.join('');
const expectedFalsePositives = rule.false_positives?.join('');
const expectedTags = rule.tags?.join('');
const mitreAttack = rule.threat;
const expectedMitre = formatMitreAttackDescription(mitreAttack ?? []);
const expectedNumberOfRules = 1;
beforeEach(() => {
deleteAlertsAndRules();
login();
beforeEach(() => {
deleteAlertsAndRules();
login();
visit(CREATE_RULE_URL);
selectNewTermsRuleType();
});
it('Creates and enables a new terms rule', function () {
fillDefineNewTermsRuleAndContinue(rule);
fillAboutRuleAndContinue(rule);
fillScheduleRuleAndContinue(rule);
createAndEnableRule();
openRuleManagementPageViaBreadcrumbs();
cy.get(CUSTOM_RULES_BTN).should('have.text', 'Custom rules (1)');
expectNumberOfRules(RULES_MANAGEMENT_TABLE, expectedNumberOfRules);
cy.get(RULE_NAME).should('have.text', rule.name);
cy.get(RISK_SCORE).should('have.text', rule.risk_score);
cy.get(SEVERITY).should('have.text', 'High');
cy.get(RULE_SWITCH).should('have.attr', 'aria-checked', 'true');
goToRuleDetailsOf(rule.name);
cy.get(RULE_NAME_HEADER).should('contain', `${rule.name}`);
cy.get(ABOUT_RULE_DESCRIPTION).should('have.text', rule.description);
cy.get(ABOUT_DETAILS).within(() => {
getDetails(SEVERITY_DETAILS).should('have.text', 'High');
getDetails(RISK_SCORE_DETAILS).should('have.text', rule.risk_score);
getDetails(REFERENCE_URLS_DETAILS).should((details) => {
expect(removeExternalLinkText(details.text())).equal(expectedUrls);
});
getDetails(FALSE_POSITIVES_DETAILS).should('have.text', expectedFalsePositives);
getDetails(MITRE_ATTACK_DETAILS).should((mitre) => {
expect(removeExternalLinkText(mitre.text())).equal(expectedMitre);
});
getDetails(TAGS_DETAILS).should('have.text', expectedTags);
});
cy.get(INVESTIGATION_NOTES_TOGGLE).click();
cy.get(ABOUT_INVESTIGATION_NOTES).should('have.text', INVESTIGATION_NOTES_MARKDOWN);
cy.get(DEFINITION_DETAILS).within(() => {
getDetails(INDEX_PATTERNS_DETAILS).should('have.text', getIndexPatterns().join(''));
getDetails(CUSTOM_QUERY_DETAILS).should('have.text', rule.query);
getDetails(RULE_TYPE_DETAILS).should('have.text', 'New Terms');
getDetails(TIMELINE_TEMPLATE_DETAILS).should('have.text', 'None');
getDetails(NEW_TERMS_FIELDS_DETAILS).should('have.text', 'host.name');
getDetails(NEW_TERMS_HISTORY_WINDOW_DETAILS).should('have.text', '51000h');
});
cy.get(SCHEDULE_DETAILS).within(() => {
getDetails(RUNS_EVERY_DETAILS).should('have.text', `${rule.interval}`);
const humanizedDuration = getHumanizedDuration(
rule.from ?? 'now-6m',
rule.interval ?? '5m'
);
getDetails(ADDITIONAL_LOOK_BACK_DETAILS).should('have.text', `${humanizedDuration}`);
});
waitForTheRuleToBeExecuted();
waitForAlertsToPopulate();
cy.get(ALERT_DATA_GRID)
.invoke('text')
.then((text) => {
expect(text).contains(rule.name);
expect(text).contains(rule.severity);
expect(text).contains(rule.risk_score);
});
});
it('Creates rule rule with time interval suppression', () => {
const SUPPRESS_BY_FIELDS = ['agent.hostname', 'agent.type'];
fillDefineNewTermsRule(rule);
// fill suppress by fields and select non-default suppression options
fillAlertSuppressionFields(SUPPRESS_BY_FIELDS);
selectAlertSuppressionPerInterval();
setAlertSuppressionDuration(45, 'm');
selectDoNotSuppressForMissingFields();
continueFromDefineStep();
// ensures details preview works correctly
cy.get(DEFINITION_DETAILS).within(() => {
getDetails(SUPPRESS_BY_DETAILS).should('have.text', SUPPRESS_BY_FIELDS.join(''));
getDetails(SUPPRESS_FOR_DETAILS).should('have.text', '45m');
getDetails(SUPPRESS_MISSING_FIELD).should(
'have.text',
'Do not suppress alerts for events with missing fields'
);
});
fillAboutRuleMinimumAndContinue(rule);
skipScheduleRuleAction();
createRuleWithoutEnabling();
cy.get(DEFINITION_DETAILS).within(() => {
getDetails(SUPPRESS_BY_DETAILS).should('have.text', SUPPRESS_BY_FIELDS.join(''));
getDetails(SUPPRESS_FOR_DETAILS).should('have.text', '45m');
getDetails(SUPPRESS_MISSING_FIELD).should(
'have.text',
'Do not suppress alerts for events with missing fields'
);
});
});
});
it('Creates and enables a new terms rule', function () {
visit(CREATE_RULE_URL);
selectNewTermsRuleType();
fillDefineNewTermsRuleAndContinue(rule);
fillAboutRuleAndContinue(rule);
fillScheduleRuleAndContinue(rule);
createAndEnableRule();
openRuleManagementPageViaBreadcrumbs();
cy.get(CUSTOM_RULES_BTN).should('have.text', 'Custom rules (1)');
expectNumberOfRules(RULES_MANAGEMENT_TABLE, expectedNumberOfRules);
cy.get(RULE_NAME).should('have.text', rule.name);
cy.get(RISK_SCORE).should('have.text', rule.risk_score);
cy.get(SEVERITY).should('have.text', 'High');
cy.get(RULE_SWITCH).should('have.attr', 'aria-checked', 'true');
goToRuleDetailsOf(rule.name);
cy.get(RULE_NAME_HEADER).should('contain', `${rule.name}`);
cy.get(ABOUT_RULE_DESCRIPTION).should('have.text', rule.description);
cy.get(ABOUT_DETAILS).within(() => {
getDetails(SEVERITY_DETAILS).should('have.text', 'High');
getDetails(RISK_SCORE_DETAILS).should('have.text', rule.risk_score);
getDetails(REFERENCE_URLS_DETAILS).should((details) => {
expect(removeExternalLinkText(details.text())).equal(expectedUrls);
});
getDetails(FALSE_POSITIVES_DETAILS).should('have.text', expectedFalsePositives);
getDetails(MITRE_ATTACK_DETAILS).should((mitre) => {
expect(removeExternalLinkText(mitre.text())).equal(expectedMitre);
});
getDetails(TAGS_DETAILS).should('have.text', expectedTags);
});
cy.get(INVESTIGATION_NOTES_TOGGLE).click();
cy.get(ABOUT_INVESTIGATION_NOTES).should('have.text', INVESTIGATION_NOTES_MARKDOWN);
cy.get(DEFINITION_DETAILS).within(() => {
getDetails(INDEX_PATTERNS_DETAILS).should('have.text', getIndexPatterns().join(''));
getDetails(CUSTOM_QUERY_DETAILS).should('have.text', rule.query);
getDetails(RULE_TYPE_DETAILS).should('have.text', 'New Terms');
getDetails(TIMELINE_TEMPLATE_DETAILS).should('have.text', 'None');
getDetails(NEW_TERMS_FIELDS_DETAILS).should('have.text', 'host.name');
getDetails(NEW_TERMS_HISTORY_WINDOW_DETAILS).should('have.text', '51000h');
});
cy.get(SCHEDULE_DETAILS).within(() => {
getDetails(RUNS_EVERY_DETAILS).should('have.text', `${rule.interval}`);
const humanizedDuration = getHumanizedDuration(
rule.from ?? 'now-6m',
rule.interval ?? '5m'
);
getDetails(ADDITIONAL_LOOK_BACK_DETAILS).should('have.text', `${humanizedDuration}`);
});
waitForTheRuleToBeExecuted();
waitForAlertsToPopulate();
cy.get(ALERT_DATA_GRID)
.invoke('text')
.then((text) => {
expect(text).contains(rule.name);
expect(text).contains(rule.severity);
expect(text).contains(rule.risk_score);
});
});
});
});
}
);

View file

@ -1,40 +0,0 @@
/*
* 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 {
ALERT_SUPPRESSION_DURATION_INPUT,
THRESHOLD_ENABLE_SUPPRESSION_CHECKBOX,
} from '../../../../screens/create_new_rule';
import { deleteAlertsAndRules } from '../../../../tasks/api_calls/common';
import { startBasicLicense } from '../../../../tasks/api_calls/licensing';
import { selectThresholdRuleType } from '../../../../tasks/create_new_rule';
import { login } from '../../../../tasks/login';
import { visit } from '../../../../tasks/navigation';
import { CREATE_RULE_URL } from '../../../../urls/navigation';
import { TOOLTIP } from '../../../../screens/common';
describe('Threshold rules, ESS basic license', { tags: ['@ess'] }, () => {
beforeEach(() => {
deleteAlertsAndRules();
login();
visit(CREATE_RULE_URL);
startBasicLicense();
});
it('Alert suppression is disabled for basic license', () => {
selectThresholdRuleType();
cy.get(THRESHOLD_ENABLE_SUPPRESSION_CHECKBOX).should('be.disabled');
cy.get(THRESHOLD_ENABLE_SUPPRESSION_CHECKBOX).parent().trigger('mouseover');
// Platinum license is required, tooltip on disabled alert suppression checkbox should tell this
cy.get(TOOLTIP).contains('Platinum license');
cy.get(ALERT_SUPPRESSION_DURATION_INPUT).eq(0).should('be.disabled');
cy.get(ALERT_SUPPRESSION_DURATION_INPUT).eq(1).should('be.disabled');
});
});

View file

@ -1,42 +0,0 @@
/*
* 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 { THRESHOLD_ENABLE_SUPPRESSION_CHECKBOX } from '../../../../screens/create_new_rule';
import { deleteAlertsAndRules } from '../../../../tasks/api_calls/common';
import { selectThresholdRuleType } from '../../../../tasks/create_new_rule';
import { login } from '../../../../tasks/login';
import { visit } from '../../../../tasks/navigation';
import { CREATE_RULE_URL } from '../../../../urls/navigation';
describe(
'Threshold rules, Serverless essentials license',
{
tags: ['@serverless'],
env: {
ftrConfig: {
productTypes: [
{ product_line: 'security', product_tier: 'essentials' },
{ product_line: 'endpoint', product_tier: 'essentials' },
],
},
},
},
() => {
beforeEach(() => {
deleteAlertsAndRules();
login();
visit(CREATE_RULE_URL);
});
it('Alert suppression is enabled for essentials', () => {
selectThresholdRuleType();
cy.get(THRESHOLD_ENABLE_SUPPRESSION_CHECKBOX).should('be.enabled');
});
}
);

View file

@ -0,0 +1,108 @@
/*
* 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 { getNewTermsRule } from '../../../../objects/rule';
import {
SUPPRESS_FOR_DETAILS,
DEFINITION_DETAILS,
SUPPRESS_MISSING_FIELD,
SUPPRESS_BY_DETAILS,
} from '../../../../screens/rule_details';
import {
ALERT_SUPPRESSION_DURATION_INPUT,
ALERT_SUPPRESSION_FIELDS,
ALERT_SUPPRESSION_MISSING_FIELDS_SUPPRESS,
} from '../../../../screens/create_new_rule';
import { createRule } from '../../../../tasks/api_calls/rules';
import { RULES_MANAGEMENT_URL } from '../../../../urls/rules_management';
import { getDetails } from '../../../../tasks/rule_details';
import { deleteAlertsAndRules } from '../../../../tasks/api_calls/common';
import { login } from '../../../../tasks/login';
import { editFirstRule } from '../../../../tasks/alerts_detection_rules';
import { saveEditedRule } from '../../../../tasks/edit_rule';
import {
selectAlertSuppressionPerRuleExecution,
selectDoNotSuppressForMissingFields,
fillAlertSuppressionFields,
} from '../../../../tasks/create_new_rule';
import { visit } from '../../../../tasks/navigation';
const SUPPRESS_BY_FIELDS = ['agent.hostname', 'agent.type'];
const rule = getNewTermsRule();
describe(
'Detection rules, New terms, Edit',
{
tags: ['@ess', '@serverless'],
env: {
// alertSuppressionForNewTermsRuleEnabled feature flag is also enabled in a global config
kbnServerArgs: [
`--xpack.securitySolution.enableExperimental=${JSON.stringify([
'alertSuppressionForNewTermsRuleEnabled',
])}`,
],
},
},
() => {
beforeEach(() => {
login();
deleteAlertsAndRules();
});
describe('with suppression configured', () => {
beforeEach(() => {
createRule({
...rule,
alert_suppression: {
group_by: SUPPRESS_BY_FIELDS.slice(0, 1),
duration: { value: 20, unit: 'm' },
missing_fields_strategy: 'suppress',
},
});
});
it('displays suppress options correctly on edit form and allows its editing', () => {
visit(RULES_MANAGEMENT_URL);
editFirstRule();
// check saved suppression settings
cy.get(ALERT_SUPPRESSION_DURATION_INPUT)
.eq(0)
.should('be.enabled')
.should('have.value', 20);
cy.get(ALERT_SUPPRESSION_DURATION_INPUT)
.eq(1)
.should('be.enabled')
.should('have.value', 'm');
cy.get(ALERT_SUPPRESSION_FIELDS).should('contain', SUPPRESS_BY_FIELDS.slice(0, 1).join(''));
cy.get(ALERT_SUPPRESSION_MISSING_FIELDS_SUPPRESS).should('be.checked');
selectAlertSuppressionPerRuleExecution();
selectDoNotSuppressForMissingFields();
fillAlertSuppressionFields(SUPPRESS_BY_FIELDS.slice(1));
saveEditedRule();
cy.get(DEFINITION_DETAILS).within(() => {
getDetails(SUPPRESS_BY_DETAILS).should('have.text', SUPPRESS_BY_FIELDS.join(''));
getDetails(SUPPRESS_FOR_DETAILS).should('have.text', 'One rule execution');
getDetails(SUPPRESS_MISSING_FIELD).should(
'have.text',
'Do not suppress alerts for events with missing fields'
);
});
});
});
}
);

View file

@ -326,6 +326,7 @@ describe('Detection rules, Prebuilt Rules Installation and Update workflow', ()
$state: { store: 'appState' },
},
],
alert_suppression: undefined,
});
const ESQL_RULE = createRuleAssetSavedObject({

View file

@ -32,6 +32,8 @@ import { convertHistoryStartToSize, getHumanizedDuration } from '../helpers/rule
import {
ABOUT_CONTINUE_BTN,
ALERT_SUPPRESSION_DURATION_INPUT,
ALERT_SUPPRESSION_FIELDS,
ALERT_SUPPRESSION_FIELDS_INPUT,
ALERT_SUPPRESSION_FIELDS_COMBO_BOX,
ALERT_SUPPRESSION_MISSING_FIELDS_DO_NOT_SUPPRESS,
THRESHOLD_ENABLE_SUPPRESSION_CHECKBOX,
@ -145,6 +147,7 @@ import { ruleFields } from '../data/detection_engine';
import { waitForAlerts } from './alerts';
import { refreshPage } from './security_header';
import { EMPTY_ALERT_TABLE } from '../screens/alerts';
import { TOOLTIP } from '../screens/common';
export const createAndEnableRule = () => {
cy.get(CREATE_AND_ENABLE_BTN).click();
@ -541,14 +544,12 @@ export const fillDefineEqlRuleAndContinue = (rule: EqlRuleCreateProps) => {
cy.get(DEFINE_CONTINUE_BUTTON).should('exist').click({ force: true });
};
export const fillDefineNewTermsRuleAndContinue = (rule: NewTermsRuleCreateProps) => {
export const fillDefineNewTermsRule = (rule: NewTermsRuleCreateProps) => {
cy.get(CUSTOM_QUERY_INPUT)
.first()
.type(rule.query || '');
cy.get(NEW_TERMS_INPUT_AREA).find(INPUT).click();
cy.get(NEW_TERMS_INPUT_AREA).find(INPUT).type(rule.new_terms_fields[0], { delay: 35 });
cy.get(EUI_FILTER_SELECT_ITEM).click();
cy.get(NEW_TERMS_INPUT_AREA).find(INPUT).type(`${rule.new_terms_fields[0]}{enter}`);
cy.focused().type('{esc}'); // Close combobox dropdown so next inputs can be interacted with
const historySize = convertHistoryStartToSize(rule.history_window_start);
@ -557,6 +558,10 @@ export const fillDefineNewTermsRuleAndContinue = (rule: NewTermsRuleCreateProps)
cy.get(NEW_TERMS_INPUT_AREA).find(NEW_TERMS_HISTORY_SIZE).type('{selectAll}');
cy.get(NEW_TERMS_INPUT_AREA).find(NEW_TERMS_HISTORY_SIZE).type(historySizeNumber);
cy.get(NEW_TERMS_INPUT_AREA).find(NEW_TERMS_HISTORY_TIME_TYPE).select(historySizeType);
};
export const fillDefineNewTermsRuleAndContinue = (rule: NewTermsRuleCreateProps) => {
fillDefineNewTermsRule(rule);
cy.get(DEFINE_CONTINUE_BUTTON).should('exist').click({ force: true });
};
@ -874,6 +879,18 @@ export const setAlertSuppressionDuration = (interval: number, timeUnit: 's' | 'm
cy.get(ALERT_SUPPRESSION_DURATION_INPUT).eq(1).select(timeUnit);
};
/**
* Opens tooltip on disabled suppression fields and checks if it contains requirement for Platinum license.
*
* Suppression fields are disabled when app has insufficient license
*/
export const openSuppressionFieldsTooltipAndCheckLicense = () => {
cy.get(ALERT_SUPPRESSION_FIELDS_INPUT).should('be.disabled');
cy.get(ALERT_SUPPRESSION_FIELDS).trigger('mouseover');
// Platinum license is required, tooltip on disabled alert suppression checkbox should tell this
cy.get(TOOLTIP).contains('Platinum license');
};
export const checkLoadQueryDynamically = () => {
cy.get(LOAD_QUERY_DYNAMICALLY_CHECKBOX).click({ force: true });
cy.get(LOAD_QUERY_DYNAMICALLY_CHECKBOX).should('be.checked');

View file

@ -34,6 +34,9 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) {
{ product_line: 'endpoint', product_tier: 'complete' },
{ product_line: 'cloud', product_tier: 'complete' },
])}`,
`--xpack.securitySolution.enableExperimental=${JSON.stringify([
'alertSuppressionForNewTermsRuleEnabled',
])}`,
],
},
testRunner: SecuritySolutionConfigurableCypressTestRunner,