[Security Solution][Detection Engine] adds simplified bulk edit for alert suppression (#223090)

## Summary

- addresses https://github.com/elastic/security-team/issues/9190
(issue's description does not contain details, for product requirements
refer to
https://github.com/elastic/security-team/issues/9190#issuecomment-2943723763)
- adds simplified bulk editing, when user can only overwrite or remove
alert suppression for multiple rules

### DEMO



https://github.com/user-attachments/assets/88dc2953-e3fa-44c3-b896-ff533c66553f

### Feature flag

```yml
xpack.securitySolution.enableExperimental:
  - bulkEditAlertSuppressionEnabled
```

### Flaky test runner

FTR -
https://buildkite.com/elastic/kibana-flaky-test-suite-runner/builds/8360
Cypress -
https://buildkite.com/elastic/kibana-flaky-test-suite-runner/builds/8361

### Docs issue

https://github.com/elastic/docs-content/issues/1719

### Test plan

https://github.com/elastic/security-team/pull/12813

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Maxim Palenov <maxim.palenov@elastic.co>
This commit is contained in:
Vitalii Dmyterko 2025-06-19 15:20:12 +01:00 committed by GitHub
parent 884e51ae49
commit 40dccf51a2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
52 changed files with 2857 additions and 27 deletions

View file

@ -8395,6 +8395,47 @@ paths:
ids:
- 9e946bfc-3118-4c77-bb25-67d781191921
example27:
description: The following request set alert suppression to the rules with the specified IDs.
summary: Edit - Set alert suppression to rules (idempotent)
value:
action: edit
edit:
- type: set_alert_suppression
value:
duration:
unit: h
value: 1
group_by:
- source.ip
missing_fields_strategy: suppress
ids:
- 12345678-1234-1234-1234-1234567890ab
- 87654321-4321-4321-4321-0987654321ba
example28:
description: The following request set alert suppression to threshold rules with the specified IDs.
summary: Edit - Set alert suppression to threshold rules (idempotent)
value:
action: edit
edit:
- type: set_alert_suppression_for_threshold
value:
duration:
unit: h
value: 1
ids:
- 12345678-1234-1234-1234-1234567890ab
- 87654321-4321-4321-4321-0987654321ba
example29:
description: The following request removes alert suppression from the rules with the specified IDs. If the rules do not have alert suppression, no changes are made.
summary: Edit - Removes alert suppression from rules (idempotent)
value:
action: edit
edit:
- type: delete_alert_suppression
ids:
- 12345678-1234-1234-1234-1234567890ab
- 87654321-4321-4321-4321-0987654321ba
example30:
description: The following request triggers the filling of gaps for the specified rule ids and time range
summary: Fill Gaps - Manually trigger the filling of gaps for specified rules
value:
@ -58896,6 +58937,21 @@ components:
- $ref: '#/components/schemas/Security_Detections_API_BulkActionEditPayloadTimeline'
- $ref: '#/components/schemas/Security_Detections_API_BulkActionEditPayloadRuleActions'
- $ref: '#/components/schemas/Security_Detections_API_BulkActionEditPayloadSchedule'
- $ref: '#/components/schemas/Security_Detections_API_BulkActionEditPayloadAlertSuppression'
Security_Detections_API_BulkActionEditPayloadAlertSuppression:
anyOf:
- $ref: '#/components/schemas/Security_Detections_API_BulkActionEditPayloadSetAlertSuppression'
- $ref: '#/components/schemas/Security_Detections_API_BulkActionEditPayloadSetAlertSuppressionForThreshold'
- $ref: '#/components/schemas/Security_Detections_API_BulkActionEditPayloadDeleteAlertSuppression'
Security_Detections_API_BulkActionEditPayloadDeleteAlertSuppression:
type: object
properties:
type:
enum:
- delete_alert_suppression
type: string
required:
- type
Security_Detections_API_BulkActionEditPayloadIndexPatterns:
description: |
Edits index patterns of rulesClient.
@ -59001,6 +59057,30 @@ components:
required:
- type
- value
Security_Detections_API_BulkActionEditPayloadSetAlertSuppression:
type: object
properties:
type:
enum:
- set_alert_suppression
type: string
value:
$ref: '#/components/schemas/Security_Detections_API_AlertSuppression'
required:
- type
- value
Security_Detections_API_BulkActionEditPayloadSetAlertSuppressionForThreshold:
type: object
properties:
type:
enum:
- set_alert_suppression_for_threshold
type: string
value:
$ref: '#/components/schemas/Security_Detections_API_ThresholdAlertSuppression'
required:
- type
- value
Security_Detections_API_BulkActionEditPayloadTags:
description: |
Edits tags of rules.
@ -59054,6 +59134,8 @@ components:
- ESQL_INDEX_PATTERN
- MANUAL_RULE_RUN_FEATURE
- MANUAL_RULE_RUN_DISABLED_RULE
- THRESHOLD_RULE_TYPE_IN_SUPPRESSION
- UNSUPPORTED_RULE_IN_SUPPRESSION_FOR_THRESHOLD
- RULE_FILL_GAPS_DISABLED_RULE
type: string
Security_Detections_API_BulkActionSkipResult:

View file

@ -10070,6 +10070,47 @@ paths:
ids:
- 9e946bfc-3118-4c77-bb25-67d781191921
example27:
description: The following request set alert suppression to the rules with the specified IDs.
summary: Edit - Set alert suppression to rules (idempotent)
value:
action: edit
edit:
- type: set_alert_suppression
value:
duration:
unit: h
value: 1
group_by:
- source.ip
missing_fields_strategy: suppress
ids:
- 12345678-1234-1234-1234-1234567890ab
- 87654321-4321-4321-4321-0987654321ba
example28:
description: The following request set alert suppression to threshold rules with the specified IDs.
summary: Edit - Set alert suppression to threshold rules (idempotent)
value:
action: edit
edit:
- type: set_alert_suppression_for_threshold
value:
duration:
unit: h
value: 1
ids:
- 12345678-1234-1234-1234-1234567890ab
- 87654321-4321-4321-4321-0987654321ba
example29:
description: The following request removes alert suppression from the rules with the specified IDs. If the rules do not have alert suppression, no changes are made.
summary: Edit - Removes alert suppression from rules (idempotent)
value:
action: edit
edit:
- type: delete_alert_suppression
ids:
- 12345678-1234-1234-1234-1234567890ab
- 87654321-4321-4321-4321-0987654321ba
example30:
description: The following request triggers the filling of gaps for the specified rule ids and time range
summary: Fill Gaps - Manually trigger the filling of gaps for specified rules
value:
@ -68268,6 +68309,21 @@ components:
- $ref: '#/components/schemas/Security_Detections_API_BulkActionEditPayloadTimeline'
- $ref: '#/components/schemas/Security_Detections_API_BulkActionEditPayloadRuleActions'
- $ref: '#/components/schemas/Security_Detections_API_BulkActionEditPayloadSchedule'
- $ref: '#/components/schemas/Security_Detections_API_BulkActionEditPayloadAlertSuppression'
Security_Detections_API_BulkActionEditPayloadAlertSuppression:
anyOf:
- $ref: '#/components/schemas/Security_Detections_API_BulkActionEditPayloadSetAlertSuppression'
- $ref: '#/components/schemas/Security_Detections_API_BulkActionEditPayloadSetAlertSuppressionForThreshold'
- $ref: '#/components/schemas/Security_Detections_API_BulkActionEditPayloadDeleteAlertSuppression'
Security_Detections_API_BulkActionEditPayloadDeleteAlertSuppression:
type: object
properties:
type:
enum:
- delete_alert_suppression
type: string
required:
- type
Security_Detections_API_BulkActionEditPayloadIndexPatterns:
description: |
Edits index patterns of rulesClient.
@ -68373,6 +68429,30 @@ components:
required:
- type
- value
Security_Detections_API_BulkActionEditPayloadSetAlertSuppression:
type: object
properties:
type:
enum:
- set_alert_suppression
type: string
value:
$ref: '#/components/schemas/Security_Detections_API_AlertSuppression'
required:
- type
- value
Security_Detections_API_BulkActionEditPayloadSetAlertSuppressionForThreshold:
type: object
properties:
type:
enum:
- set_alert_suppression_for_threshold
type: string
value:
$ref: '#/components/schemas/Security_Detections_API_ThresholdAlertSuppression'
required:
- type
- value
Security_Detections_API_BulkActionEditPayloadTags:
description: |
Edits tags of rules.
@ -68426,6 +68506,8 @@ components:
- ESQL_INDEX_PATTERN
- MANUAL_RULE_RUN_FEATURE
- MANUAL_RULE_RUN_DISABLED_RULE
- THRESHOLD_RULE_TYPE_IN_SUPPRESSION
- UNSUPPORTED_RULE_IN_SUPPRESSION_FOR_THRESHOLD
- RULE_FILL_GAPS_DISABLED_RULE
type: string
Security_Detections_API_BulkActionSkipResult:

View file

@ -29,7 +29,9 @@ import {
InvestigationFields,
TimelineTemplateId,
TimelineTemplateTitle,
AlertSuppression,
} from '../../model/rule_schema/common_attributes.gen';
import { ThresholdAlertSuppression } from '../../model/rule_schema/specific_attributes/threshold_attributes.gen';
export type BulkEditSkipReason = z.infer<typeof BulkEditSkipReason>;
export const BulkEditSkipReason = z.literal('RULE_NOT_MODIFIED');
@ -59,6 +61,8 @@ export const BulkActionsDryRunErrCode = z.enum([
'ESQL_INDEX_PATTERN',
'MANUAL_RULE_RUN_FEATURE',
'MANUAL_RULE_RUN_DISABLED_RULE',
'THRESHOLD_RULE_TYPE_IN_SUPPRESSION',
'UNSUPPORTED_RULE_IN_SUPPRESSION_FOR_THRESHOLD',
'RULE_FILL_GAPS_DISABLED_RULE',
]);
export type BulkActionsDryRunErrCodeEnum = typeof BulkActionsDryRunErrCode.enum;
@ -258,6 +262,9 @@ export const BulkActionEditType = z.enum([
'add_investigation_fields',
'delete_investigation_fields',
'set_investigation_fields',
'delete_alert_suppression',
'set_alert_suppression',
'set_alert_suppression_for_threshold',
]);
export type BulkActionEditTypeEnum = typeof BulkActionEditType.enum;
export const BulkActionEditTypeEnum = BulkActionEditType.enum;
@ -382,6 +389,41 @@ export const BulkActionEditPayloadTimeline = z.object({
}),
});
export type BulkActionEditPayloadSetAlertSuppression = z.infer<
typeof BulkActionEditPayloadSetAlertSuppression
>;
export const BulkActionEditPayloadSetAlertSuppression = z.object({
type: z.literal('set_alert_suppression'),
value: AlertSuppression,
});
export type BulkActionEditPayloadSetAlertSuppressionForThreshold = z.infer<
typeof BulkActionEditPayloadSetAlertSuppressionForThreshold
>;
export const BulkActionEditPayloadSetAlertSuppressionForThreshold = z.object({
type: z.literal('set_alert_suppression_for_threshold'),
value: ThresholdAlertSuppression,
});
export type BulkActionEditPayloadDeleteAlertSuppression = z.infer<
typeof BulkActionEditPayloadDeleteAlertSuppression
>;
export const BulkActionEditPayloadDeleteAlertSuppression = z.object({
type: z.literal('delete_alert_suppression'),
});
export const BulkActionEditPayloadAlertSuppressionInternal = z.union([
BulkActionEditPayloadSetAlertSuppression,
BulkActionEditPayloadSetAlertSuppressionForThreshold,
BulkActionEditPayloadDeleteAlertSuppression,
]);
export type BulkActionEditPayloadAlertSuppression = z.infer<
typeof BulkActionEditPayloadAlertSuppressionInternal
>;
export const BulkActionEditPayloadAlertSuppression =
BulkActionEditPayloadAlertSuppressionInternal as z.ZodType<BulkActionEditPayloadAlertSuppression>;
export const BulkActionEditPayloadInternal = z.union([
BulkActionEditPayloadTags,
BulkActionEditPayloadIndexPatterns,
@ -389,6 +431,7 @@ export const BulkActionEditPayloadInternal = z.union([
BulkActionEditPayloadTimeline,
BulkActionEditPayloadRuleActions,
BulkActionEditPayloadSchedule,
BulkActionEditPayloadAlertSuppression,
]);
export type BulkActionEditPayload = z.infer<typeof BulkActionEditPayloadInternal>;

View file

@ -20,3 +20,13 @@ export const getPerformBulkActionEditSchemaMock = (): PerformRulesBulkActionRequ
action: BulkActionTypeEnum.edit,
[BulkActionTypeEnum.edit]: [{ type: BulkActionEditTypeEnum.add_tags, value: ['tag1'] }],
});
export const getPerformBulkActionEditAlertSuppressionSchemaMock =
(): PerformRulesBulkActionRequestBody => ({
query: '',
ids: undefined,
action: BulkActionTypeEnum.edit,
[BulkActionTypeEnum.edit]: [
{ type: BulkActionEditTypeEnum.set_alert_suppression, value: { group_by: ['field1'] } },
],
});

View file

@ -360,8 +360,48 @@ paths:
eventAction: trigger
timestamp: 2023-10-31T00:00:00Z
group: default3
example27:
summary: Edit - Set alert suppression to rules (idempotent)
description: The following request set alert suppression to the rules with the specified IDs.
value:
ids:
- '12345678-1234-1234-1234-1234567890ab'
- '87654321-4321-4321-4321-0987654321ba'
action: 'edit'
edit:
- type: 'set_alert_suppression'
value:
group_by:
- 'source.ip'
duration:
value: 1
unit: 'h'
missing_fields_strategy: 'suppress'
example28:
summary: Edit - Set alert suppression to threshold rules (idempotent)
description: The following request set alert suppression to threshold rules with the specified IDs.
value:
ids:
- '12345678-1234-1234-1234-1234567890ab'
- '87654321-4321-4321-4321-0987654321ba'
action: 'edit'
edit:
- type: 'set_alert_suppression_for_threshold'
value:
duration:
value: 1
unit: 'h'
example29:
summary: Edit - Removes alert suppression from rules (idempotent)
description: The following request removes alert suppression from the rules with the specified IDs. If the rules do not have alert suppression, no changes are made.
value:
ids:
- '12345678-1234-1234-1234-1234567890ab'
- '87654321-4321-4321-4321-0987654321ba'
action: 'edit'
edit:
- type: 'delete_alert_suppression'
example30:
summary: Fill Gaps - Manually trigger the filling of gaps for specified rules
description: The following request triggers the filling of gaps for the specified rule ids and time range
value:
@ -1060,6 +1100,8 @@ components:
- ESQL_INDEX_PATTERN
- MANUAL_RULE_RUN_FEATURE
- MANUAL_RULE_RUN_DISABLED_RULE
- THRESHOLD_RULE_TYPE_IN_SUPPRESSION
- UNSUPPORTED_RULE_IN_SUPPRESSION_FOR_THRESHOLD
- RULE_FILL_GAPS_DISABLED_RULE
NormalizedRuleError:
@ -1333,6 +1375,9 @@ components:
- add_investigation_fields
- delete_investigation_fields
- set_investigation_fields
- delete_alert_suppression
- set_alert_suppression
- set_alert_suppression_for_threshold
# Per rulesClient.bulkEdit rules actions operation contract (x-pack/platform/plugins/shared/alerting/server/rules_client/rules_client.ts) normalized rule action object is expected (NormalizedAlertAction) as value for the edit operation
NormalizedRuleAction:
@ -1505,6 +1550,48 @@ components:
- type
- value
BulkActionEditPayloadSetAlertSuppression:
type: object
properties:
type:
type: string
enum:
- set_alert_suppression
value:
$ref: '../../model/rule_schema/common_attributes.schema.yaml#/components/schemas/AlertSuppression'
required:
- type
- value
BulkActionEditPayloadSetAlertSuppressionForThreshold:
type: object
properties:
type:
type: string
enum:
- set_alert_suppression_for_threshold
value:
$ref: '../../model/rule_schema/specific_attributes/threshold_attributes.schema.yaml#/components/schemas/ThresholdAlertSuppression'
required:
- type
- value
BulkActionEditPayloadDeleteAlertSuppression:
type: object
properties:
type:
type: string
enum:
- delete_alert_suppression
required:
- type
BulkActionEditPayloadAlertSuppression:
anyOf:
- $ref: '#/components/schemas/BulkActionEditPayloadSetAlertSuppression'
- $ref: '#/components/schemas/BulkActionEditPayloadSetAlertSuppressionForThreshold'
- $ref: '#/components/schemas/BulkActionEditPayloadDeleteAlertSuppression'
BulkActionEditPayload:
anyOf:
- $ref: '#/components/schemas/BulkActionEditPayloadTags'
@ -1513,6 +1600,8 @@ components:
- $ref: '#/components/schemas/BulkActionEditPayloadTimeline'
- $ref: '#/components/schemas/BulkActionEditPayloadRuleActions'
- $ref: '#/components/schemas/BulkActionEditPayloadSchedule'
- $ref: '#/components/schemas/BulkActionEditPayloadAlertSuppression'
BulkEditRules:
allOf:

View file

@ -217,7 +217,7 @@ describe('Perform bulk action request schema', () => {
expectParseError(result);
expect(stringifyZodError(result.error)).toMatchInlineSnapshot(
`"action: Invalid literal value, expected \\"delete\\", action: Invalid literal value, expected \\"disable\\", action: Invalid literal value, expected \\"enable\\", action: Invalid literal value, expected \\"export\\", action: Invalid literal value, expected \\"duplicate\\", and 15 more"`
`"action: Invalid literal value, expected \\"delete\\", action: Invalid literal value, expected \\"disable\\", action: Invalid literal value, expected \\"enable\\", action: Invalid literal value, expected \\"export\\", action: Invalid literal value, expected \\"duplicate\\", and 20 more"`
);
});
@ -279,7 +279,7 @@ describe('Perform bulk action request schema', () => {
expectParseError(result);
expect(stringifyZodError(result.error)).toMatchInlineSnapshot(
`"action: Invalid literal value, expected \\"delete\\", action: Invalid literal value, expected \\"disable\\", action: Invalid literal value, expected \\"enable\\", action: Invalid literal value, expected \\"export\\", action: Invalid literal value, expected \\"duplicate\\", and 15 more"`
`"action: Invalid literal value, expected \\"delete\\", action: Invalid literal value, expected \\"disable\\", action: Invalid literal value, expected \\"enable\\", action: Invalid literal value, expected \\"export\\", action: Invalid literal value, expected \\"duplicate\\", and 20 more"`
);
});
@ -385,6 +385,136 @@ describe('Perform bulk action request schema', () => {
});
});
describe('alert suppression', () => {
test('valid request: delete_alert_suppression edit action', () => {
const payload: PerformRulesBulkActionRequestBody = {
query: 'name: test',
action: BulkActionTypeEnum.edit,
[BulkActionTypeEnum.edit]: [
{
type: BulkActionEditTypeEnum.delete_alert_suppression,
},
],
};
const result = PerformRulesBulkActionRequestBody.safeParse(payload);
expectParseSuccess(result);
expect(result.data).toEqual(payload);
});
test('valid request: set_alert_suppression edit action', () => {
const payload: PerformRulesBulkActionRequestBody = {
query: 'name: test',
action: BulkActionTypeEnum.edit,
[BulkActionTypeEnum.edit]: [
{
type: BulkActionEditTypeEnum.set_alert_suppression,
value: {
group_by: ['field-1', 'field-2', 'field-3'],
duration: { value: 10, unit: 'h' },
missing_fields_strategy: 'doNotSuppress',
},
},
],
};
const result = PerformRulesBulkActionRequestBody.safeParse(payload);
expectParseSuccess(result);
expect(result.data).toEqual(payload);
});
test('valid request: set_alert_suppression_for_threshold edit action', () => {
const payload: PerformRulesBulkActionRequestBody = {
query: 'name: test',
action: BulkActionTypeEnum.edit,
[BulkActionTypeEnum.edit]: [
{
type: BulkActionEditTypeEnum.set_alert_suppression,
value: {
group_by: ['field-1', 'field-2', 'field-3'],
duration: { value: 10, unit: 'h' },
missing_fields_strategy: 'doNotSuppress',
},
},
],
};
const result = PerformRulesBulkActionRequestBody.safeParse(payload);
expectParseSuccess(result);
expect(result.data).toEqual(payload);
});
test('invalid request: set_alert_suppression with too many group_by fields', () => {
const payload = {
query: 'name: test',
action: BulkActionTypeEnum.edit,
[BulkActionTypeEnum.edit]: [
{
type: BulkActionEditTypeEnum.set_alert_suppression,
value: {
group_by: ['field-1', 'field-2', 'field-3', 'field-4'],
},
},
],
};
const result = PerformRulesBulkActionRequestBody.safeParse(payload);
expectParseError(result);
expect(stringifyZodError(result.error)).toMatchInlineSnapshot(
`"edit.0.value.group_by: Array must contain at most 3 element(s)"`
);
});
test('invalid request: set_alert_suppression with empty group_by fields', () => {
const payload = {
query: 'name: test',
action: BulkActionTypeEnum.edit,
[BulkActionTypeEnum.edit]: [
{
type: BulkActionEditTypeEnum.set_alert_suppression,
value: {
group_by: [],
},
},
],
};
const result = PerformRulesBulkActionRequestBody.safeParse(payload);
expectParseError(result);
expect(stringifyZodError(result.error)).toMatchInlineSnapshot(
`"edit.0.value.group_by: Array must contain at least 1 element(s)"`
);
});
test('invalid request: set_alert_suppression with negative duration', () => {
const payload = {
query: 'name: test',
action: BulkActionTypeEnum.edit,
[BulkActionTypeEnum.edit]: [
{
type: BulkActionEditTypeEnum.set_alert_suppression,
value: {
group_by: ['field-1'],
duration: { value: -5, unit: 'm' },
},
},
],
};
const result = PerformRulesBulkActionRequestBody.safeParse(payload);
expectParseError(result);
expect(stringifyZodError(result.error)).toMatchInlineSnapshot(
`"edit.0.value.duration.value: Number must be greater than or equal to 1"`
);
});
});
describe('timeline', () => {
test('invalid request: wrong timeline payload type', () => {
const payload = {
@ -397,7 +527,7 @@ describe('Perform bulk action request schema', () => {
expectParseError(result);
expect(stringifyZodError(result.error)).toMatchInlineSnapshot(
`"action: Invalid literal value, expected \\"delete\\", action: Invalid literal value, expected \\"disable\\", action: Invalid literal value, expected \\"enable\\", action: Invalid literal value, expected \\"export\\", action: Invalid literal value, expected \\"duplicate\\", and 13 more"`
`"action: Invalid literal value, expected \\"delete\\", action: Invalid literal value, expected \\"disable\\", action: Invalid literal value, expected \\"enable\\", action: Invalid literal value, expected \\"export\\", action: Invalid literal value, expected \\"duplicate\\", and 18 more"`
);
});
@ -419,7 +549,7 @@ describe('Perform bulk action request schema', () => {
expectParseError(result);
expect(stringifyZodError(result.error)).toMatchInlineSnapshot(
`"action: Invalid literal value, expected \\"delete\\", action: Invalid literal value, expected \\"disable\\", action: Invalid literal value, expected \\"enable\\", action: Invalid literal value, expected \\"export\\", action: Invalid literal value, expected \\"duplicate\\", and 16 more"`
`"action: Invalid literal value, expected \\"delete\\", action: Invalid literal value, expected \\"disable\\", action: Invalid literal value, expected \\"enable\\", action: Invalid literal value, expected \\"export\\", action: Invalid literal value, expected \\"duplicate\\", and 21 more"`
);
});
@ -457,7 +587,7 @@ describe('Perform bulk action request schema', () => {
expectParseError(result);
expect(stringifyZodError(result.error)).toMatchInlineSnapshot(
`"action: Invalid literal value, expected \\"delete\\", action: Invalid literal value, expected \\"disable\\", action: Invalid literal value, expected \\"enable\\", action: Invalid literal value, expected \\"export\\", action: Invalid literal value, expected \\"duplicate\\", and 13 more"`
`"action: Invalid literal value, expected \\"delete\\", action: Invalid literal value, expected \\"disable\\", action: Invalid literal value, expected \\"enable\\", action: Invalid literal value, expected \\"export\\", action: Invalid literal value, expected \\"duplicate\\", and 18 more"`
);
});
@ -502,7 +632,7 @@ describe('Perform bulk action request schema', () => {
expectParseError(result);
expect(stringifyZodError(result.error)).toMatchInlineSnapshot(
`"action: Invalid literal value, expected \\"delete\\", action: Invalid literal value, expected \\"disable\\", action: Invalid literal value, expected \\"enable\\", action: Invalid literal value, expected \\"export\\", action: Invalid literal value, expected \\"duplicate\\", and 16 more"`
`"action: Invalid literal value, expected \\"delete\\", action: Invalid literal value, expected \\"disable\\", action: Invalid literal value, expected \\"enable\\", action: Invalid literal value, expected \\"export\\", action: Invalid literal value, expected \\"duplicate\\", and 21 more"`
);
});
@ -524,7 +654,7 @@ describe('Perform bulk action request schema', () => {
expectParseError(result);
expect(stringifyZodError(result.error)).toMatchInlineSnapshot(
`"action: Invalid literal value, expected \\"delete\\", action: Invalid literal value, expected \\"disable\\", action: Invalid literal value, expected \\"enable\\", action: Invalid literal value, expected \\"export\\", action: Invalid literal value, expected \\"duplicate\\", and 16 more"`
`"action: Invalid literal value, expected \\"delete\\", action: Invalid literal value, expected \\"disable\\", action: Invalid literal value, expected \\"enable\\", action: Invalid literal value, expected \\"export\\", action: Invalid literal value, expected \\"duplicate\\", and 21 more"`
);
});
@ -562,7 +692,7 @@ describe('Perform bulk action request schema', () => {
expectParseError(result);
expect(stringifyZodError(result.error)).toMatchInlineSnapshot(
`"action: Invalid literal value, expected \\"delete\\", action: Invalid literal value, expected \\"disable\\", action: Invalid literal value, expected \\"enable\\", action: Invalid literal value, expected \\"export\\", action: Invalid literal value, expected \\"duplicate\\", and 13 more"`
`"action: Invalid literal value, expected \\"delete\\", action: Invalid literal value, expected \\"disable\\", action: Invalid literal value, expected \\"enable\\", action: Invalid literal value, expected \\"export\\", action: Invalid literal value, expected \\"duplicate\\", and 18 more"`
);
});
@ -584,7 +714,7 @@ describe('Perform bulk action request schema', () => {
expectParseError(result);
expect(stringifyZodError(result.error)).toMatchInlineSnapshot(
`"action: Invalid literal value, expected \\"delete\\", action: Invalid literal value, expected \\"disable\\", action: Invalid literal value, expected \\"enable\\", action: Invalid literal value, expected \\"export\\", action: Invalid literal value, expected \\"duplicate\\", and 17 more"`
`"action: Invalid literal value, expected \\"delete\\", action: Invalid literal value, expected \\"disable\\", action: Invalid literal value, expected \\"enable\\", action: Invalid literal value, expected \\"export\\", action: Invalid literal value, expected \\"duplicate\\", and 22 more"`
);
});

View file

@ -12,6 +12,7 @@ import type {
BulkActionEditPayloadSchedule,
BulkActionEditPayloadTags,
BulkActionEditPayloadTimeline,
BulkActionEditPayloadAlertSuppression,
} from './bulk_actions_route.gen';
/**
@ -29,4 +30,5 @@ export type BulkActionEditForRuleParams =
| BulkActionEditPayloadIndexPatterns
| BulkActionEditPayloadInvestigationFields
| BulkActionEditPayloadTimeline
| BulkActionEditPayloadAlertSuppression
| BulkActionEditPayloadSchedule;

View file

@ -86,4 +86,13 @@ describe('convertRulesFilterToKQL', () => {
expect(kql).toBe('NOT alert.attributes.params.type: ("machine_learning" OR "saved_query")');
});
it('handles presence of "includeRuleTypes" properly', () => {
const kql = convertRulesFilterToKQL({
...filterOptions,
includeRuleTypes: ['query', 'eql'],
});
expect(kql).toBe('alert.attributes.params.type: ("query" OR "eql")');
});
});

View file

@ -37,6 +37,7 @@ interface RulesFilterOptions {
ruleExecutionStatus: RuleExecutionStatus;
customizationStatus: RuleCustomizationStatus;
ruleIds: string[];
includeRuleTypes?: Type[];
}
/**
@ -55,6 +56,7 @@ export function convertRulesFilterToKQL({
excludeRuleTypes = [],
ruleExecutionStatus,
customizationStatus,
includeRuleTypes = [],
}: Partial<RulesFilterOptions>): string {
const kql: string[] = [];
@ -82,6 +84,10 @@ export function convertRulesFilterToKQL({
kql.push(`NOT ${convertRuleTypesToKQL(excludeRuleTypes)}`);
}
if (includeRuleTypes.length) {
kql.push(convertRuleTypesToKQL(includeRuleTypes));
}
if (ruleExecutionStatus === RuleExecutionStatusEnum.succeeded) {
kql.push(`${LAST_RUN_OUTCOME_FIELD}: "succeeded"`);
} else if (ruleExecutionStatus === RuleExecutionStatusEnum['partial failure']) {

View file

@ -198,6 +198,11 @@ export const allowedExperimentalValues = Object.freeze({
*/
filterProcessDescendantsForEventFiltersEnabled: true,
/**
* Enables the rule's bulk action to manage alert suppression
*/
bulkEditAlertSuppressionEnabled: false,
/**
* Enables the new data ingestion hub
*/

View file

@ -2057,6 +2057,54 @@ paths:
ids:
- 9e946bfc-3118-4c77-bb25-67d781191921
example27:
description: >-
The following request set alert suppression to the rules with
the specified IDs.
summary: Edit - Set alert suppression to rules (idempotent)
value:
action: edit
edit:
- type: set_alert_suppression
value:
duration:
unit: h
value: 1
group_by:
- source.ip
missing_fields_strategy: suppress
ids:
- 12345678-1234-1234-1234-1234567890ab
- 87654321-4321-4321-4321-0987654321ba
example28:
description: >-
The following request set alert suppression to threshold rules
with the specified IDs.
summary: Edit - Set alert suppression to threshold rules (idempotent)
value:
action: edit
edit:
- type: set_alert_suppression_for_threshold
value:
duration:
unit: h
value: 1
ids:
- 12345678-1234-1234-1234-1234567890ab
- 87654321-4321-4321-4321-0987654321ba
example29:
description: >-
The following request removes alert suppression from the rules
with the specified IDs. If the rules do not have alert
suppression, no changes are made.
summary: Edit - Removes alert suppression from rules (idempotent)
value:
action: edit
edit:
- type: delete_alert_suppression
ids:
- 12345678-1234-1234-1234-1234567890ab
- 87654321-4321-4321-4321-0987654321ba
example30:
description: >-
The following request triggers the filling of gaps for the
specified rule ids and time range
@ -4493,6 +4541,22 @@ components:
- $ref: '#/components/schemas/BulkActionEditPayloadTimeline'
- $ref: '#/components/schemas/BulkActionEditPayloadRuleActions'
- $ref: '#/components/schemas/BulkActionEditPayloadSchedule'
- $ref: '#/components/schemas/BulkActionEditPayloadAlertSuppression'
BulkActionEditPayloadAlertSuppression:
anyOf:
- $ref: '#/components/schemas/BulkActionEditPayloadSetAlertSuppression'
- $ref: >-
#/components/schemas/BulkActionEditPayloadSetAlertSuppressionForThreshold
- $ref: '#/components/schemas/BulkActionEditPayloadDeleteAlertSuppression'
BulkActionEditPayloadDeleteAlertSuppression:
type: object
properties:
type:
enum:
- delete_alert_suppression
type: string
required:
- type
BulkActionEditPayloadIndexPatterns:
description: >
Edits index patterns of rulesClient.
@ -4631,6 +4695,30 @@ components:
required:
- type
- value
BulkActionEditPayloadSetAlertSuppression:
type: object
properties:
type:
enum:
- set_alert_suppression
type: string
value:
$ref: '#/components/schemas/AlertSuppression'
required:
- type
- value
BulkActionEditPayloadSetAlertSuppressionForThreshold:
type: object
properties:
type:
enum:
- set_alert_suppression_for_threshold
type: string
value:
$ref: '#/components/schemas/ThresholdAlertSuppression'
required:
- type
- value
BulkActionEditPayloadTags:
description: >
Edits tags of rules.
@ -4692,6 +4780,8 @@ components:
- ESQL_INDEX_PATTERN
- MANUAL_RULE_RUN_FEATURE
- MANUAL_RULE_RUN_DISABLED_RULE
- THRESHOLD_RULE_TYPE_IN_SUPPRESSION
- UNSUPPORTED_RULE_IN_SUPPRESSION_FOR_THRESHOLD
- RULE_FILL_GAPS_DISABLED_RULE
type: string
BulkActionSkipResult:

View file

@ -1921,6 +1921,54 @@ paths:
ids:
- 9e946bfc-3118-4c77-bb25-67d781191921
example27:
description: >-
The following request set alert suppression to the rules with
the specified IDs.
summary: Edit - Set alert suppression to rules (idempotent)
value:
action: edit
edit:
- type: set_alert_suppression
value:
duration:
unit: h
value: 1
group_by:
- source.ip
missing_fields_strategy: suppress
ids:
- 12345678-1234-1234-1234-1234567890ab
- 87654321-4321-4321-4321-0987654321ba
example28:
description: >-
The following request set alert suppression to threshold rules
with the specified IDs.
summary: Edit - Set alert suppression to threshold rules (idempotent)
value:
action: edit
edit:
- type: set_alert_suppression_for_threshold
value:
duration:
unit: h
value: 1
ids:
- 12345678-1234-1234-1234-1234567890ab
- 87654321-4321-4321-4321-0987654321ba
example29:
description: >-
The following request removes alert suppression from the rules
with the specified IDs. If the rules do not have alert
suppression, no changes are made.
summary: Edit - Removes alert suppression from rules (idempotent)
value:
action: edit
edit:
- type: delete_alert_suppression
ids:
- 12345678-1234-1234-1234-1234567890ab
- 87654321-4321-4321-4321-0987654321ba
example30:
description: >-
The following request triggers the filling of gaps for the
specified rule ids and time range
@ -3823,6 +3871,22 @@ components:
- $ref: '#/components/schemas/BulkActionEditPayloadTimeline'
- $ref: '#/components/schemas/BulkActionEditPayloadRuleActions'
- $ref: '#/components/schemas/BulkActionEditPayloadSchedule'
- $ref: '#/components/schemas/BulkActionEditPayloadAlertSuppression'
BulkActionEditPayloadAlertSuppression:
anyOf:
- $ref: '#/components/schemas/BulkActionEditPayloadSetAlertSuppression'
- $ref: >-
#/components/schemas/BulkActionEditPayloadSetAlertSuppressionForThreshold
- $ref: '#/components/schemas/BulkActionEditPayloadDeleteAlertSuppression'
BulkActionEditPayloadDeleteAlertSuppression:
type: object
properties:
type:
enum:
- delete_alert_suppression
type: string
required:
- type
BulkActionEditPayloadIndexPatterns:
description: >
Edits index patterns of rulesClient.
@ -3961,6 +4025,30 @@ components:
required:
- type
- value
BulkActionEditPayloadSetAlertSuppression:
type: object
properties:
type:
enum:
- set_alert_suppression
type: string
value:
$ref: '#/components/schemas/AlertSuppression'
required:
- type
- value
BulkActionEditPayloadSetAlertSuppressionForThreshold:
type: object
properties:
type:
enum:
- set_alert_suppression_for_threshold
type: string
value:
$ref: '#/components/schemas/ThresholdAlertSuppression'
required:
- type
- value
BulkActionEditPayloadTags:
description: >
Edits tags of rules.
@ -4022,6 +4110,8 @@ components:
- ESQL_INDEX_PATTERN
- MANUAL_RULE_RUN_FEATURE
- MANUAL_RULE_RUN_DISABLED_RULE
- THRESHOLD_RULE_TYPE_IN_SUPPRESSION
- UNSUPPORTED_RULE_IN_SUPPRESSION_FOR_THRESHOLD
- RULE_FILL_GAPS_DISABLED_RULE
type: string
BulkActionSkipResult:

View file

@ -49,6 +49,11 @@ export enum TELEMETRY_EVENT {
SET_INVESTIGATION_FIELDS = 'set_investigation_fields',
DELETE_INVESTIGATION_FIELDS = 'delete_investigation_fields',
// Bulk edit alert suppression
SET_ALERT_SUPPRESSION_FOR_THRESHOLD = 'set_alert_suppression_for_threshold',
SET_ALERT_SUPPRESSION = 'set_alert_suppression',
DELETE_ALERT_SUPPRESSION = 'delete_alert_suppression',
// AI assistant on rule creation form
OPEN_ASSISTANT_ON_RULE_QUERY_ERROR = 'open_assistant_on_rule_query_error',
}

View file

@ -187,6 +187,34 @@ export const BULK_ACTION_DELETE_INVESTIGATION_FIELDS = i18n.translate(
}
);
export const BULK_ACTION_ALERT_SUPPRESSION = i18n.translate(
'xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.alertSuppressionTitle',
{
defaultMessage: 'Alert suppression',
}
);
export const BULK_ACTION_SET_ALERT_SUPPRESSION = i18n.translate(
'xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.setAlertSuppression',
{
defaultMessage: 'Apply alert suppression',
}
);
export const BULK_ACTION_SET_ALERT_SUPPRESSION_FOR_THRESHOLD = i18n.translate(
'xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.setAlertSuppressionForThreshold',
{
defaultMessage: 'Apply alert suppression to threshold rules',
}
);
export const BULK_ACTION_DELETE_ALERT_SUPPRESSION = i18n.translate(
'xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.deleteAlertSuppression',
{
defaultMessage: 'Remove alert suppression',
}
);
export const BULK_ACTION_APPLY_TIMELINE_TEMPLATE = i18n.translate(
'xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.applyTimelineTemplateTitle',
{

View file

@ -20,6 +20,7 @@ interface AlertSuppressionEditProps {
disabled?: boolean;
disabledText?: string;
warningText?: string;
fullWidth?: boolean;
}
export const AlertSuppressionEdit = memo(function AlertSuppressionEdit({
@ -28,6 +29,7 @@ export const AlertSuppressionEdit = memo(function AlertSuppressionEdit({
disabled,
disabledText,
warningText,
fullWidth,
}: AlertSuppressionEditProps): JSX.Element {
const [{ [ALERT_SUPPRESSION_FIELDS_FIELD_NAME]: suppressionFields }] = useFormData<{
[ALERT_SUPPRESSION_FIELDS_FIELD_NAME]: string[];
@ -41,6 +43,7 @@ export const AlertSuppressionEdit = memo(function AlertSuppressionEdit({
suppressibleFields={suppressibleFields}
labelAppend={labelAppend}
disabled={disabled}
fullWidth={fullWidth}
/>
{warningText && (
<EuiText size="xs" color="warning" data-test-subj="alertSuppressionWarning">

View file

@ -17,12 +17,14 @@ interface SuppressionFieldsSelectorProps {
suppressibleFields: DataViewFieldBase[];
labelAppend?: React.ReactNode;
disabled?: boolean;
fullWidth?: boolean;
}
export function SuppressionFieldsSelector({
suppressibleFields,
labelAppend,
disabled,
fullWidth,
}: SuppressionFieldsSelectorProps): JSX.Element {
return (
<EuiFormRow
@ -38,6 +40,7 @@ export function SuppressionFieldsSelector({
componentProps={{
browserFields: suppressibleFields,
isDisabled: disabled,
fullWidth,
}}
/>
</>

View file

@ -109,6 +109,7 @@ export interface FilterOptions {
ruleSource?: RuleCustomizationStatus[]; // undefined is to display all the rules
showRulesWithGaps?: boolean;
gapSearchRange?: GapRangeValue;
includeRuleTypes?: Type[];
}
export interface FetchRulesResponse {

View file

@ -111,4 +111,31 @@ describe('Component BulkEditRuleErrorsList', () => {
expect(screen.getByText(value)).toBeInTheDocument();
});
test.each([
[
BulkActionsDryRunErrCodeEnum.THRESHOLD_RULE_TYPE_IN_SUPPRESSION,
"2 threshold rules can't be edited.",
],
[
BulkActionsDryRunErrCodeEnum.UNSUPPORTED_RULE_IN_SUPPRESSION_FOR_THRESHOLD,
"2 rules can't be edited.",
],
])('should render correct message for "%s" errorCode', (errorCode, value) => {
const ruleErrors: DryRunResult['ruleErrors'] = [
{
message: 'test failure',
errorCode,
ruleIds: ['rule:1', 'rule:2'],
},
];
render(
<BulkActionRuleErrorsList bulkAction={BulkActionTypeEnum.edit} ruleErrors={ruleErrors} />,
{
wrapper: TestProviders,
}
);
expect(screen.getByText(new RegExp(value, 'i'))).toBeInTheDocument();
});
});

View file

@ -14,7 +14,10 @@ import {
BulkActionTypeEnum,
BulkActionsDryRunErrCodeEnum,
} from '../../../../../../common/api/detection_engine/rule_management';
import {
BULK_ACTION_SET_ALERT_SUPPRESSION,
BULK_ACTION_SET_ALERT_SUPPRESSION_FOR_THRESHOLD,
} from '../../../../common/translations';
import type { DryRunResult, BulkActionForConfirmation } from './types';
import { usePrebuiltRuleCustomizationUpsellingMessage } from '../../../../rule_management/logic/prebuilt_rules/use_prebuilt_rule_customization_upselling_message';
@ -84,6 +87,32 @@ const BulkEditRuleErrorItem = ({
/>
</li>
);
case BulkActionsDryRunErrCodeEnum.THRESHOLD_RULE_TYPE_IN_SUPPRESSION:
return (
<li key={message}>
<FormattedMessage
id="xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.thresholdRuleInSuppressionDescription"
defaultMessage="{rulesCount, plural, =1 {# threshold rule} other {# threshold rules}} can't be edited. To bulk-apply alert suppression {rulesCount, plural, =1 {to this rule} other {to these rules}}, use the {actionStrong} option."
values={{
rulesCount,
actionStrong: <strong>{BULK_ACTION_SET_ALERT_SUPPRESSION_FOR_THRESHOLD}</strong>,
}}
/>
</li>
);
case BulkActionsDryRunErrCodeEnum.UNSUPPORTED_RULE_IN_SUPPRESSION_FOR_THRESHOLD:
return (
<li key={message}>
<FormattedMessage
id="xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.unsupportedRulesInThresholdSuppressionDescription"
defaultMessage="{rulesCount, plural, =1 {# rule} other {# rules}} can't be edited. To bulk-apply alert suppression {rulesCount, plural, =1 {to this rule} other {to these rules}}, use the {actionStrong} option."
values={{
rulesCount,
actionStrong: <strong>{BULK_ACTION_SET_ALERT_SUPPRESSION}</strong>,
}}
/>
</li>
);
default:
return (
<li key={message}>

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 React from 'react';
import { EuiConfirmModal } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import { METRIC_TYPE, track, TELEMETRY_EVENT } from '../../../../../common/lib/telemetry';
import type { BulkActionEditPayloadDeleteAlertSuppression } from '../../../../../../common/api/detection_engine/rule_management';
import { BulkActionEditTypeEnum } from '../../../../../../common/api/detection_engine/rule_management';
import { bulkAlertSuppression as i18n } from './translations';
interface Props {
rulesCount: number;
onCancel: () => void;
onConfirm: (bulkActionEditPayload: BulkActionEditPayloadDeleteAlertSuppression) => void;
}
export const BulkEditDeleteAlertSuppressionConfirmation: React.FC<Props> = ({
rulesCount,
onCancel,
onConfirm,
}) => (
<EuiConfirmModal
title={i18n.DELETE_CONFIRMATION_TITLE}
onCancel={onCancel}
onConfirm={() => {
onConfirm({
type: BulkActionEditTypeEnum.delete_alert_suppression,
});
track(METRIC_TYPE.CLICK, TELEMETRY_EVENT.SET_ALERT_SUPPRESSION_FOR_THRESHOLD);
}}
confirmButtonText={i18n.DELETE_CONFIRMATION_CONFIRM}
cancelButtonText={i18n.DELETE_CONFIRMATION_CANCEL}
buttonColor="danger"
defaultFocusedButton="confirm"
data-test-subj="deleteRulesConfirmationModal"
>
<FormattedMessage
id="xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.edit.alertSuppression.deleteAlertSuppressionConfirmationModalBody"
defaultMessage="This action will remove alert suppression from {rulesCount, plural, one {the chosen rule} other {{rulesCountStrong} rules}}. Click {deleteStrong} to continue."
values={{
rulesCount,
rulesCountStrong: <strong>{rulesCount}</strong>,
deleteStrong: (
<strong>
<FormattedMessage
id="xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.edit.alertSuppression.deleteAlertSuppressionConfirmationModalBodyDeleteBtnLabel"
defaultMessage="Delete"
/>
</strong>
),
}}
/>
</EuiConfirmModal>
);
BulkEditDeleteAlertSuppressionConfirmation.displayName =
'BulkEditDeleteAlertSuppressionConfirmation';

View file

@ -19,6 +19,8 @@ import { TimelineTemplateForm } from './forms/timeline_template_form';
import { RuleActionsForm } from './forms/rule_actions_form';
import { ScheduleForm } from './forms/schedule_form';
import { InvestigationFieldsForm } from './forms/investigation_fields_form';
import { SetAlertSuppressionForm } from './forms/set_alert_suppression_form';
import { SetAlertSuppressionForThresholdForm } from './forms/set_alert_suppression_for_threshold_form';
interface BulkEditFlyoutProps {
onClose: () => void;
@ -44,6 +46,12 @@ const BulkEditFlyoutComponent = ({ editAction, ...props }: BulkEditFlyoutProps)
case BulkActionEditTypeEnum.set_investigation_fields:
return <InvestigationFieldsForm {...props} editAction={editAction} />;
case BulkActionEditTypeEnum.set_alert_suppression:
return <SetAlertSuppressionForm {...props} editAction={editAction} />;
case BulkActionEditTypeEnum.set_alert_suppression_for_threshold:
return <SetAlertSuppressionForThresholdForm {...props} editAction={editAction} />;
case BulkActionEditTypeEnum.set_timeline:
return <TimelineTemplateForm {...props} />;

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 React from 'react';
import { EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiText, EuiIcon, EuiFormRow } from '@elastic/eui';
import { METRIC_TYPE, track, TELEMETRY_EVENT } from '../../../../../../common/lib/telemetry';
import { BulkActionEditTypeEnum } from '../../../../../../../common/api/detection_engine/rule_management';
import type { BulkActionEditPayload } from '../../../../../../../common/api/detection_engine/rule_management';
import type { AlertSuppressionDuration } from '../../../../../../../common/api/detection_engine/model/rule_schema/common_attributes.gen';
import { useForm, UseMultiFields } from '../../../../../../shared_imports';
import { BulkEditFormWrapper } from './bulk_edit_form_wrapper';
import { ALERT_SUPPRESSION_DEFAULT_DURATION } from '../../../../../rule_creation/components/alert_suppression_edit';
import { bulkAlertSuppression as i18n } from '../translations';
import { DurationInput } from '../../../../../rule_creation/components/duration_input';
type FormData = AlertSuppressionDuration;
const initialFormData: FormData = ALERT_SUPPRESSION_DEFAULT_DURATION;
interface AlertSuppressionFormProps {
editAction: BulkActionEditTypeEnum['set_alert_suppression_for_threshold'];
rulesCount: number;
onClose: () => void;
onConfirm: (bulkActionEditPayload: BulkActionEditPayload) => void;
}
export const SetAlertSuppressionForThresholdForm = React.memo(function SetAlertSuppressionForm({
editAction,
rulesCount,
onClose,
onConfirm,
}: AlertSuppressionFormProps) {
const { form } = useForm<FormData>({
defaultValue: initialFormData,
});
const handleSubmit = async () => {
const { data, isValid } = await form.submit();
if (!isValid) {
return;
}
onConfirm({
value: {
duration: data,
},
type: BulkActionEditTypeEnum.set_alert_suppression_for_threshold,
});
track(METRIC_TYPE.CLICK, TELEMETRY_EVENT.SET_ALERT_SUPPRESSION_FOR_THRESHOLD);
};
return (
<BulkEditFormWrapper
form={form}
onClose={onClose}
onSubmit={handleSubmit}
title={i18n.SET_FOR_THRESHOLD_TITLE}
>
<EuiFlexGroup gutterSize="s">
<EuiFlexItem grow={false}>
<EuiIcon type="iInCircle" />
</EuiFlexItem>
<EuiFlexItem>
<EuiText size="xs">{i18n.SUPPRESSION_FOR_THRESHOLD_INFO_TEXT}</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="l" />
<EuiFormRow
data-test-subj="alertSuppressionDuration"
label={i18n.DURATION_PER_TIME_PERIOD_LABEL}
helpText={i18n.DURATION_PER_TIME_PERIOD_HELP_TEXT}
>
<UseMultiFields<{
suppressionDurationValue: number | undefined;
suppressionDurationUnit: string;
}>
fields={{
suppressionDurationValue: {
path: `value`,
},
suppressionDurationUnit: {
path: `unit`,
},
}}
>
{({ suppressionDurationValue, suppressionDurationUnit }) => (
<DurationInput
isDisabled={false}
durationValueField={suppressionDurationValue}
durationUnitField={suppressionDurationUnit}
aria-label={i18n.DURATION_PER_TIME_PERIOD_INPUT}
minimumValue={1}
/>
)}
</UseMultiFields>
</EuiFormRow>
</BulkEditFormWrapper>
);
});

View file

@ -0,0 +1,135 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiText, EuiIcon } from '@elastic/eui';
import { useKibana } from '../../../../../../common/lib/kibana';
import { DEFAULT_INDEX_KEY } from '../../../../../../../common/constants';
import { METRIC_TYPE, track, TELEMETRY_EVENT } from '../../../../../../common/lib/telemetry';
import { DEFAULT_SUPPRESSION_MISSING_FIELDS_STRATEGY } from '../../../../../../../common/detection_engine/constants';
import { useFetchIndex } from '../../../../../../common/containers/source';
import { BulkActionEditTypeEnum } from '../../../../../../../common/api/detection_engine/rule_management';
import type { BulkActionEditPayload } from '../../../../../../../common/api/detection_engine/rule_management';
import type {
AlertSuppressionMissingFieldsStrategy,
AlertSuppressionDuration,
} from '../../../../../../../common/api/detection_engine/model/rule_schema/common_attributes.gen';
import { useForm, fieldValidators } from '../../../../../../shared_imports';
import type { FormSchema } from '../../../../../../shared_imports';
import { BulkEditFormWrapper } from './bulk_edit_form_wrapper';
import {
AlertSuppressionEdit,
ALERT_SUPPRESSION_MISSING_FIELDS_FIELD_NAME,
ALERT_SUPPRESSION_FIELDS_FIELD_NAME,
ALERT_SUPPRESSION_DURATION_TYPE_FIELD_NAME,
ALERT_SUPPRESSION_DURATION_FIELD_NAME,
ALERT_SUPPRESSION_DEFAULT_DURATION,
} from '../../../../../rule_creation/components/alert_suppression_edit';
import { AlertSuppressionDurationType } from '../../../../../common/types';
import { bulkAlertSuppression as i18n } from '../translations';
import { useTermsAggregationFields } from '../../../../../../common/hooks/use_terms_aggregation_fields';
interface AlertSuppressionFormData {
alertSuppressionFields: string[];
alertSuppressionDurationType: AlertSuppressionDurationType;
alertSuppressionDuration: AlertSuppressionDuration;
alertSuppressionMissingFields?: AlertSuppressionMissingFieldsStrategy;
}
const formSchema: FormSchema<AlertSuppressionFormData> = {
[ALERT_SUPPRESSION_FIELDS_FIELD_NAME]: {
validations: [
{
validator: fieldValidators.emptyField(i18n.SUPPRESSION_REQUIRED_ERROR),
},
{
validator: fieldValidators.maxLengthField({
message: i18n.SUPPRESSION_MAX_LENGTH_ERROR,
length: 3,
}),
},
],
},
};
const initialFormData: AlertSuppressionFormData = {
[ALERT_SUPPRESSION_FIELDS_FIELD_NAME]: [],
[ALERT_SUPPRESSION_DURATION_TYPE_FIELD_NAME]: AlertSuppressionDurationType.PerRuleExecution,
[ALERT_SUPPRESSION_DURATION_FIELD_NAME]: ALERT_SUPPRESSION_DEFAULT_DURATION,
[ALERT_SUPPRESSION_MISSING_FIELDS_FIELD_NAME]: DEFAULT_SUPPRESSION_MISSING_FIELDS_STRATEGY,
};
interface AlertSuppressionFormProps {
editAction: BulkActionEditTypeEnum['set_alert_suppression'];
rulesCount: number;
onClose: () => void;
onConfirm: (bulkActionEditPayload: BulkActionEditPayload) => void;
}
export const SetAlertSuppressionForm = React.memo(function SetAlertSuppressionForm({
editAction,
rulesCount,
onClose,
onConfirm,
}: AlertSuppressionFormProps) {
const { form } = useForm({
defaultValue: initialFormData,
schema: formSchema,
});
const { uiSettings } = useKibana().services;
const defaultPatterns = uiSettings.get<string[]>(DEFAULT_INDEX_KEY);
const [_, { indexPatterns }] = useFetchIndex(defaultPatterns, false);
const suppressibleFields = useTermsAggregationFields(indexPatterns?.fields);
const handleSubmit = async () => {
const { data, isValid } = await form.submit();
if (!isValid) {
return;
}
const durationValue =
data[ALERT_SUPPRESSION_DURATION_TYPE_FIELD_NAME] ===
AlertSuppressionDurationType.PerTimePeriod
? data[ALERT_SUPPRESSION_DURATION_FIELD_NAME]
: undefined;
const suppressionPayload = {
value: {
group_by: data.alertSuppressionFields,
missing_fields_strategy: data[ALERT_SUPPRESSION_MISSING_FIELDS_FIELD_NAME],
duration: durationValue,
},
type: BulkActionEditTypeEnum.set_alert_suppression,
};
onConfirm(suppressionPayload);
track(METRIC_TYPE.CLICK, TELEMETRY_EVENT.SET_ALERT_SUPPRESSION);
};
return (
<BulkEditFormWrapper
form={form}
onClose={onClose}
onSubmit={handleSubmit}
title={i18n.SET_TITLE}
>
<EuiFlexGroup gutterSize="s">
<EuiFlexItem grow={false}>
<EuiIcon type="iInCircle" />
</EuiFlexItem>
<EuiFlexItem>
<EuiText size="xs">{i18n.SUPPRESSION_INFO_TEXT}</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="l" />
<AlertSuppressionEdit suppressibleFields={suppressibleFields} fullWidth />
</BulkEditFormWrapper>
);
});

View file

@ -190,3 +190,81 @@ export const ML_RULES_UNAVAILABLE = (totalRules: number) =>
defaultMessage:
'{totalRules} {totalRules, plural, =1 {rule requires} other {rules require}} Machine Learning to enable.',
});
export const bulkAlertSuppression = {
SUPPRESSION_MAX_LENGTH_ERROR: i18n.translate(
'xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.edit.alertSuppression.alertSuppressionMaxLengthErrorMessage',
{
defaultMessage: 'Number of suppress by fields must be at most 3.',
}
),
SET_TITLE: i18n.translate(
'xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.edit.alertSuppression.addTitle',
{
defaultMessage: 'Apply alert suppression',
}
),
SET_FOR_THRESHOLD_TITLE: i18n.translate(
'xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.edit.alertSuppression.addTitle',
{
defaultMessage: 'Apply alert suppression to threshold rules',
}
),
SUPPRESSION_REQUIRED_ERROR: i18n.translate(
'xpack.securitySolution.detectionEngine.components.allRules.bulkActions.edit.alertSuppressionRequiredErrorMessage',
{
defaultMessage: 'A minimum of one suppression field is required.',
}
),
SUPPRESSION_INFO_TEXT: i18n.translate(
'xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.edit.alertSuppression.infoText',
{
defaultMessage:
'Existing alert suppression settings will be overwritten for all of the selected rules, except for threshold rules.',
}
),
SUPPRESSION_FOR_THRESHOLD_INFO_TEXT: i18n.translate(
'xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.edit.alertSuppressionForThreshold.infoText',
{
defaultMessage:
'Existing alert suppression settings will be overwritten for all of the selected threshold rules.',
}
),
DELETE_CONFIRMATION_TITLE: i18n.translate(
'xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.edit.alertSuppression.bulkDeleteConfirmationTitle',
{
defaultMessage: 'Confirm bulk removal of alert suppression',
}
),
DELETE_CONFIRMATION_CONFIRM: i18n.translate(
'xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.edit.alertSuppression.deleteConfirmationConfirm',
{
defaultMessage: 'Delete',
}
),
DELETE_CONFIRMATION_CANCEL: i18n.translate(
'xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.edit.alertSuppression.deleteConfirmationCancel',
{
defaultMessage: 'Cancel',
}
),
DURATION_PER_TIME_PERIOD_INPUT: i18n.translate(
'xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.edit.alertSuppression.perTimePeriodInput',
{
defaultMessage: 'Per time period',
}
),
DURATION_PER_TIME_PERIOD_LABEL: i18n.translate(
'xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.edit.alertSuppression.perTimePeriodLabel',
{
defaultMessage: 'Suppression interval',
}
),
DURATION_PER_TIME_PERIOD_HELP_TEXT: i18n.translate(
'xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.edit.alertSuppression.perTimePeriodHelpText',
{
defaultMessage:
'Suppress alerts for the selected rules within a repeating time interval. To ensure suppression is appropriately applied, avoid choosing an interval thats shorter than the rules run schedule.',
}
),
};

View file

@ -48,6 +48,10 @@ import { computeDryRunEditPayload } from './utils/compute_dry_run_edit_payload';
import { transformExportDetailsToDryRunResult } from './utils/dry_run_result';
import { prepareSearchParams } from './utils/prepare_search_params';
import { ManualRuleRunEventTypes } from '../../../../../common/lib/telemetry';
import { useIsExperimentalFeatureEnabled } from '../../../../../common/hooks/use_experimental_features';
import { useUpsellingMessage } from '../../../../../common/hooks/use_upselling';
import { useLicense } from '../../../../../common/hooks/use_license';
import { MINIMUM_LICENSE_FOR_SUPPRESSION } from '../../../../../../common/detection_engine/constants';
interface UseBulkActionsArgs {
filterOptions: FilterOptions;
@ -104,6 +108,13 @@ export const useBulkActions = ({
};
}, [kql, filterOptions]);
const isBulkEditAlertSuppressionFeatureEnabled = useIsExperimentalFeatureEnabled(
'bulkEditAlertSuppressionEnabled'
);
const alertSuppressionUpsellingMessage = useUpsellingMessage('alert_suppression_rule_form');
const license = useLicense();
const isAlertSuppressionLicenseValid = license.isAtLeast(MINIMUM_LICENSE_FOR_SUPPRESSION);
const getBulkItemsPopoverContent = useCallback(
(closePopover: () => void): EuiContextMenuPanelDescriptor[] => {
const selectedRules = rules.filter(({ id }) => selectedRuleIds.includes(id));
@ -365,6 +376,7 @@ export const useBulkActions = ({
const isDeleteDisabled = containsLoading || selectedRuleIds.length === 0;
const isEditDisabled =
missingActionPrivileges || containsLoading || selectedRuleIds.length === 0;
const isAlertSuppressionDisabled = isEditDisabled || !isAlertSuppressionLicenseValid;
return [
{
@ -417,6 +429,20 @@ export const useBulkActions = ({
disabled: isEditDisabled,
panel: 3,
},
...(isBulkEditAlertSuppressionFeatureEnabled
? [
{
key: i18n.BULK_ACTION_ALERT_SUPPRESSION,
name: i18n.BULK_ACTION_ALERT_SUPPRESSION,
'data-test-subj': 'alertSuppressionBulkEditRule',
disabled: isAlertSuppressionDisabled,
toolTipContent: isAlertSuppressionLicenseValid
? undefined
: alertSuppressionUpsellingMessage,
panel: 4,
},
]
: []),
{
key: i18n.BULK_ACTION_ADD_RULE_ACTIONS,
name: i18n.BULK_ACTION_ADD_RULE_ACTIONS,
@ -581,6 +607,36 @@ export const useBulkActions = ({
},
],
},
{
id: 4,
title: i18n.BULK_ACTION_MENU_TITLE,
items: [
{
key: i18n.BULK_ACTION_SET_ALERT_SUPPRESSION,
name: i18n.BULK_ACTION_SET_ALERT_SUPPRESSION,
'data-test-subj': 'setAlertSuppressionBulkEditRule',
onClick: handleBulkEdit(BulkActionEditTypeEnum.set_alert_suppression),
disabled: isAlertSuppressionDisabled,
toolTipProps: { position: 'right' },
},
{
key: i18n.BULK_ACTION_SET_ALERT_SUPPRESSION_FOR_THRESHOLD,
name: i18n.BULK_ACTION_SET_ALERT_SUPPRESSION_FOR_THRESHOLD,
'data-test-subj': 'setAlertSuppressionForThresholdBulkEditRule',
onClick: handleBulkEdit(BulkActionEditTypeEnum.set_alert_suppression_for_threshold),
disabled: isAlertSuppressionDisabled,
toolTipProps: { position: 'right' },
},
{
key: i18n.BULK_ACTION_DELETE_ALERT_SUPPRESSION,
name: i18n.BULK_ACTION_DELETE_ALERT_SUPPRESSION,
'data-test-subj': 'deleteAlertSuppressionBulkEditRule',
onClick: handleBulkEdit(BulkActionEditTypeEnum.delete_alert_suppression),
disabled: isAlertSuppressionDisabled,
toolTipProps: { position: 'right' },
},
],
},
];
},
[
@ -605,8 +661,11 @@ export const useBulkActions = ({
executeBulkActionsDryRun,
filterOptions,
completeBulkEditForm,
isBulkEditAlertSuppressionFeatureEnabled,
startServices,
canCreateTimelines,
isAlertSuppressionLicenseValid,
alertSuppressionUpsellingMessage,
globalQuery,
]
);

View file

@ -18,6 +18,12 @@ describe('computeDryRunEditPayload', () => {
[BulkActionEditTypeEnum.set_index_patterns, []],
[BulkActionEditTypeEnum.delete_index_patterns, []],
[BulkActionEditTypeEnum.add_index_patterns, []],
[BulkActionEditTypeEnum.delete_alert_suppression, undefined],
[BulkActionEditTypeEnum.set_alert_suppression, { group_by: ['test_field'] }],
[
BulkActionEditTypeEnum.set_alert_suppression_for_threshold,
{ duration: { unit: 'm', value: 4 } },
],
[BulkActionEditTypeEnum.add_tags, []],
[BulkActionEditTypeEnum.delete_index_patterns, []],
[BulkActionEditTypeEnum.set_tags, []],
@ -25,7 +31,6 @@ describe('computeDryRunEditPayload', () => {
])('should return correct payload for bulk edit action %s', (editAction, value) => {
const payload = computeDryRunEditPayload(editAction);
expect(payload).toHaveLength(1);
expect(payload?.[0].type).toEqual(editAction);
expect(payload?.[0].value).toEqual(value);
expect(payload?.[0]).toEqual({ type: editAction, value });
});
});

View file

@ -30,6 +30,27 @@ export function computeDryRunEditPayload(editAction: BulkActionEditType): BulkAc
},
];
case BulkActionEditTypeEnum.set_alert_suppression_for_threshold:
return [
{
type: editAction,
value: { duration: { unit: 'm', value: 4 } },
},
];
case BulkActionEditTypeEnum.delete_alert_suppression:
return [
{
type: editAction,
},
];
case BulkActionEditTypeEnum.set_alert_suppression:
return [
{
type: editAction,
value: { group_by: ['test_field'] },
},
];
case BulkActionEditTypeEnum.add_index_patterns:
case BulkActionEditTypeEnum.delete_index_patterns:
case BulkActionEditTypeEnum.set_index_patterns:

View file

@ -82,6 +82,26 @@ describe('prepareSearchParams', () => {
showElasticRules: false,
},
],
[
BulkActionsDryRunErrCodeEnum.THRESHOLD_RULE_TYPE_IN_SUPPRESSION,
{
filter: '',
tags: [],
showCustomRules: false,
showElasticRules: false,
excludeRuleTypes: ['threshold'],
},
],
[
BulkActionsDryRunErrCodeEnum.UNSUPPORTED_RULE_IN_SUPPRESSION_FOR_THRESHOLD,
{
filter: '',
tags: [],
showCustomRules: false,
showElasticRules: false,
includeRuleTypes: ['threshold'],
},
],
[
undefined,
{

View file

@ -60,6 +60,18 @@ export const prepareSearchParams = ({
excludeRuleTypes: [...(modifiedFilterOptions.excludeRuleTypes ?? []), 'esql'],
};
break;
case BulkActionsDryRunErrCodeEnum.THRESHOLD_RULE_TYPE_IN_SUPPRESSION:
modifiedFilterOptions = {
...modifiedFilterOptions,
excludeRuleTypes: [...(modifiedFilterOptions.excludeRuleTypes ?? []), 'threshold'],
};
break;
case BulkActionsDryRunErrCodeEnum.UNSUPPORTED_RULE_IN_SUPPRESSION_FOR_THRESHOLD:
modifiedFilterOptions = {
...modifiedFilterOptions,
includeRuleTypes: [...(modifiedFilterOptions.includeRuleTypes ?? []), 'threshold'],
};
break;
}
});

View file

@ -46,6 +46,8 @@ import { ManualRuleRunModal } from '../../../rule_gaps/components/manual_rule_ru
import { BulkManualRuleRunLimitErrorModal } from './bulk_actions/bulk_manual_rule_run_limit_error_modal';
import { RulesWithGapsOverviewPanel } from '../../../rule_gaps/components/rules_with_gaps_overview_panel';
import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features';
import { BulkEditDeleteAlertSuppressionConfirmation } from './bulk_actions/bulk_edit_delete_alert_suprression_confirmation';
import { BulkActionEditTypeEnum } from '../../../../../common/api/detection_engine/rule_management';
const INITIAL_SORT_FIELD = 'enabled';
@ -313,14 +315,23 @@ export const RulesTables = React.memo<RulesTableProps>(({ selectedTab }) => {
rulesCount={rulesCount}
/>
)}
{isBulkEditFlyoutVisible && bulkEditActionType !== undefined && (
<BulkEditFlyout
rulesCount={bulkActionsDryRunResult?.succeededRulesCount ?? 0}
editAction={bulkEditActionType}
onClose={handleBulkEditFormCancel}
onConfirm={handleBulkEditFormConfirm}
/>
)}
{isBulkEditFlyoutVisible &&
bulkEditActionType &&
(bulkEditActionType === BulkActionEditTypeEnum.delete_alert_suppression ? (
<BulkEditDeleteAlertSuppressionConfirmation
rulesCount={bulkActionsDryRunResult?.succeededRulesCount ?? 0}
onCancel={handleBulkEditFormCancel}
onConfirm={handleBulkEditFormConfirm}
/>
) : (
<BulkEditFlyout
rulesCount={bulkActionsDryRunResult?.succeededRulesCount ?? 0}
editAction={bulkEditActionType}
onClose={handleBulkEditFormCancel}
onConfirm={handleBulkEditFormConfirm}
/>
))}
{shouldShowRulesTable && (
<>
{selectedTab === AllRulesTabs.monitoring && storeGapsInEventLogEnabled && (

View file

@ -38,6 +38,7 @@ import {
import {
getBulkDisableRuleActionSchemaMock,
getPerformBulkActionEditSchemaMock,
getPerformBulkActionEditAlertSuppressionSchemaMock,
} from '../../../../../common/api/detection_engine/rule_management/mocks';
import { getCreateRulesSchemaMock } from '../../../../../common/api/detection_engine/model/rule_schema/mocks';
@ -128,6 +129,13 @@ export const getBulkActionEditRequest = () =>
body: getPerformBulkActionEditSchemaMock(),
});
export const getBulkActionEditAlertSuppressionRequest = () =>
requestMock.create({
method: 'patch',
path: DETECTION_ENGINE_RULES_BULK_ACTION,
body: getPerformBulkActionEditAlertSuppressionSchemaMock(),
});
export const getPrivilegeRequest = (options: { auth?: { isAuthenticated: boolean } } = {}) =>
requestMock.create({
method: 'get',

View file

@ -37,7 +37,7 @@ export const registerRuleManagementRoutes = (
deleteRuleRoute(router);
// Rules bulk actions
performBulkActionRoute(router, ml);
performBulkActionRoute(router, ml, config);
// Rules export/import
exportRulesRoute(router, config, logger);

View file

@ -14,6 +14,7 @@ import {
getBulkActionEditRequest,
getFindResultWithSingleHit,
getFindResultWithMultiHits,
getBulkActionEditAlertSuppressionRequest,
} from '../../../../routes/__mocks__/request_responses';
import { requestContextMock, serverMock, requestMock } from '../../../../routes/__mocks__';
import { performBulkActionRoute } from './route';
@ -22,6 +23,7 @@ import {
getBulkDisableRuleActionSchemaMock,
} from '../../../../../../../common/api/detection_engine/rule_management/mocks';
import { BulkActionsDryRunErrCodeEnum } from '../../../../../../../common/api/detection_engine';
import type { ConfigType } from '../../../../../../config';
jest.mock('../../../../../machine_learning/authz');
@ -32,6 +34,9 @@ describe('Perform bulk action route', () => {
let { clients, context } = requestContextMock.createTools();
let ml: ReturnType<typeof mlServicesMock.createSetupContract>;
const mockRule = getFindResultWithSingleHit().data[0];
const experimentalFeatures = {
bulkEditAlertSuppressionEnabled: true,
} as ConfigType['experimentalFeatures'];
beforeEach(async () => {
server = serverMock.create();
@ -45,7 +50,9 @@ describe('Perform bulk action route', () => {
errors: [],
total: 1,
});
performBulkActionRoute(server.router, ml);
performBulkActionRoute(server.router, ml, {
experimentalFeatures,
} as ConfigType);
});
describe('status codes', () => {
@ -106,6 +113,32 @@ describe('Perform bulk action route', () => {
status_code: 400,
});
});
it('returns 403 if alert suppression license is not sufficient', async () => {
(context.licensing.license.hasAtLeast as jest.Mock).mockReturnValue(false);
const response = await server.inject(
getBulkActionEditAlertSuppressionRequest(),
requestContextMock.convertContext(context)
);
expect(response.body).toEqual({
message: 'Alert suppression is enabled with platinum license or above.',
status_code: 403,
});
});
it('returns 403 for dry run mode if alert suppression license is not sufficient', async () => {
(context.licensing.license.hasAtLeast as jest.Mock).mockReturnValue(false);
const response = await server.inject(
{ ...getBulkActionEditAlertSuppressionRequest(), query: { dry_run: 'true' } },
requestContextMock.convertContext(context)
);
expect(response.body).toEqual({
message: 'Alert suppression is enabled with platinum license or above.',
status_code: 403,
});
});
});
describe('rules execution failures', () => {
@ -753,6 +786,52 @@ describe('Perform bulk action route', () => {
});
});
describe('Perform bulk action route, experimental feature bulkEditAlertSuppressionEnabled is disabled', () => {
let server: ReturnType<typeof serverMock.create>;
let { clients, context } = requestContextMock.createTools();
let ml: ReturnType<typeof mlServicesMock.createSetupContract>;
const experimentalFeatures = {} as ConfigType['experimentalFeatures'];
beforeEach(() => {
server = serverMock.create();
({ clients, context } = requestContextMock.createTools());
ml = mlServicesMock.createSetupContract();
clients.rulesClient.find.mockResolvedValue(getFindResultWithSingleHit());
performBulkActionRoute(server.router, ml, {
experimentalFeatures,
} as ConfigType);
});
it('returns error if experimental feature bulkEditAlertSuppressionEnabled is not enabled for alert suppression bulk action', async () => {
const response = await server.inject(
getBulkActionEditAlertSuppressionRequest(),
requestContextMock.convertContext(context)
);
expect(response.status).toEqual(400);
expect(response.body).toEqual({
message:
'Bulk alert suppression actions are not supported. Use "experimentalFeatures.bulkEditAlertSuppressionEnabled" config field to enable it.',
status_code: 400,
});
});
it('returns error for dry run mode if experimental feature bulkEditAlertSuppressionEnabled is not enabled for alert suppression bulk action', async () => {
const response = await server.inject(
{ ...getBulkActionEditAlertSuppressionRequest(), query: { dry_run: 'true' } },
requestContextMock.convertContext(context)
);
expect(response.status).toEqual(400);
expect(response.body).toEqual({
message:
'Bulk alert suppression actions are not supported. Use "experimentalFeatures.bulkEditAlertSuppressionEnabled" config field to enable it.',
status_code: 400,
});
});
});
function someBulkActionResults() {
return {
created: expect.any(Array),

View file

@ -43,6 +43,8 @@ import { bulkEnableDisableRules } from './bulk_enable_disable_rules';
import { fetchRulesByQueryOrIds } from './fetch_rules_by_query_or_ids';
import { bulkScheduleBackfill } from './bulk_schedule_rule_run';
import { createPrebuiltRuleAssetsClient } from '../../../../prebuilt_rules/logic/rule_assets/prebuilt_rule_assets_client';
import type { ConfigType } from '../../../../../../config';
import { checkAlertSuppressionBulkEditSupport } from '../../../logic/bulk_actions/check_alert_suppression_bulk_edit_support';
import { bulkScheduleRuleGapFilling } from './bulk_schedule_rule_gap_filling';
const MAX_RULES_TO_PROCESS_TOTAL = 10000;
@ -97,7 +99,8 @@ const validateBulkAction = (
export const performBulkActionRoute = (
router: SecuritySolutionPluginRouter,
ml: SetupPlugins['ml']
ml: SetupPlugins['ml'],
config: ConfigType
) => {
router.versioned
.post({
@ -343,6 +346,16 @@ export const performBulkActionRoute = (
}
case BulkActionTypeEnum.edit: {
const suppressionSupportError = await checkAlertSuppressionBulkEditSupport({
editActions: body.edit,
licensing: ctx.licensing,
experimentalFeatures: config.experimentalFeatures,
});
if (suppressionSupportError) {
return siemResponse.error(suppressionSupportError);
}
if (isDryRun) {
// during dry run only validation is getting performed and rule is not saved in ES
const bulkActionOutcome = await initPromisePool({

View file

@ -0,0 +1,46 @@
/*
* 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 { LicensingApiRequestHandlerContext } from '@kbn/licensing-plugin/server';
import { MINIMUM_LICENSE_FOR_SUPPRESSION } from '../../../../../../common/detection_engine/constants';
import type { ConfigType } from '../../../../../config';
import type { BulkActionEditPayload } from '../../../../../../common/api/detection_engine/rule_management';
import { hasAlertSuppressionBulkEditAction } from './utils';
export const checkAlertSuppressionBulkEditSupport = async ({
editActions,
experimentalFeatures,
licensing,
}: {
editActions: BulkActionEditPayload[];
experimentalFeatures: ConfigType['experimentalFeatures'];
licensing: LicensingApiRequestHandlerContext;
}) => {
const hasAlertSuppressionActions = hasAlertSuppressionBulkEditAction(editActions);
const isAlertSuppressionEnabled = experimentalFeatures.bulkEditAlertSuppressionEnabled;
if (hasAlertSuppressionActions) {
if (!isAlertSuppressionEnabled) {
return {
body: `Bulk alert suppression actions are not supported. Use "experimentalFeatures.bulkEditAlertSuppressionEnabled" config field to enable it.`,
statusCode: 400,
};
}
const isAlertSuppressionLicenseValid = await licensing.license.hasAtLeast(
MINIMUM_LICENSE_FOR_SUPPRESSION
);
if (!isAlertSuppressionLicenseValid) {
return {
body: `Alert suppression is enabled with ${MINIMUM_LICENSE_FOR_SUPPRESSION} license or above.`,
statusCode: 403,
};
}
}
return undefined;
};

View file

@ -7,6 +7,7 @@
import { addItemsToArray, deleteItemsFromArray, ruleParamsModifier } from './rule_params_modifier';
import { BulkActionEditTypeEnum } from '../../../../../../common/api/detection_engine/rule_management';
import { AlertSuppressionMissingFieldsStrategyEnum } from '../../../../../../common/api/detection_engine/model/rule_schema/common_attributes.gen';
import type { RuleAlertType } from '../../../rule_schema';
describe('addItemsToArray', () => {
@ -685,6 +686,311 @@ describe('ruleParamsModifier', () => {
});
});
describe('alert_suppression', () => {
describe('delete_alert_suppression action', () => {
test.each([
[
'removes alert suppression',
{
existingAlertSuppression: {
groupBy: ['field-1', 'field-2', 'field-3'],
missingFieldsStrategy: AlertSuppressionMissingFieldsStrategyEnum.suppress,
},
resultingAlertSuppression: undefined,
isParamsUpdateSkipped: false,
ruleType: 'query',
},
],
[
'skips updates if suppression is not configured',
{
existingAlertSuppression: undefined,
resultingAlertSuppression: undefined,
isParamsUpdateSkipped: true,
ruleType: 'query',
},
],
[
'removes alert suppression in threshold rule',
{
existingAlertSuppression: {
duration: { value: 5, unit: 'h' },
},
resultingAlertSuppression: undefined,
isParamsUpdateSkipped: false,
ruleType: 'threshold',
},
],
[
'skips updates if suppression is not configured in threshold rule',
{
existingAlertSuppression: undefined,
resultingAlertSuppression: undefined,
isParamsUpdateSkipped: true,
ruleType: 'query',
},
],
])(
'should delete alert suppression, case:"%s"',
(
caseName,
{ existingAlertSuppression, resultingAlertSuppression, isParamsUpdateSkipped, ruleType }
) => {
const { modifiedParams, isParamsUpdateSkipped: isUpdateSkipped } = ruleParamsModifier(
{
...ruleParamsMock,
alertSuppression: existingAlertSuppression,
type: ruleType,
} as RuleAlertType['params'],
[
{
type: BulkActionEditTypeEnum.delete_alert_suppression,
},
]
);
expect(modifiedParams).toHaveProperty('alertSuppression', resultingAlertSuppression);
expect(isParamsUpdateSkipped).toBe(isUpdateSkipped);
}
);
});
describe('set_alert_suppression action', () => {
test.each([
[
'3 existing groupBy fields overwritten with 2 of them = 2 groupBy fields',
{
existingAlertSuppression: {
groupBy: ['field-1', 'field-2', 'field-3'],
duration: { value: 1, unit: 'h' },
missingFieldsStrategy: AlertSuppressionMissingFieldsStrategyEnum.suppress,
},
alertSuppressionToSet: {
group_by: ['field-2', 'field-3'],
missingFieldsStrategy: AlertSuppressionMissingFieldsStrategyEnum.suppress,
},
resultingAlertSuppression: {
groupBy: ['field-2', 'field-3'],
missingFieldsStrategy: AlertSuppressionMissingFieldsStrategyEnum.suppress,
},
isParamsUpdateSkipped: false,
ruleType: 'query',
},
],
[
'`undefined` existing alert suppression overwritten with 2 groupBy fields = 2 groupBy fields',
{
existingAlertSuppression: undefined,
alertSuppressionToSet: {
group_by: ['field-1', 'field-2'],
duration: { value: 5, unit: 'h' as const },
missingFieldsStrategy: AlertSuppressionMissingFieldsStrategyEnum.suppress,
},
resultingAlertSuppression: {
groupBy: ['field-1', 'field-2'],
duration: { value: 5, unit: 'h' },
missingFieldsStrategy: AlertSuppressionMissingFieldsStrategyEnum.suppress,
},
isParamsUpdateSkipped: false,
ruleType: 'query',
},
],
[
'sets missingFieldsStrategy to default when it is not set in action',
{
existingAlertSuppression: {
groupBy: ['field-1', 'field-2', 'field-3'],
missingFieldsStrategy: AlertSuppressionMissingFieldsStrategyEnum.doNotSuppress,
},
alertSuppressionToSet: {
group_by: ['field-x'],
},
resultingAlertSuppression: {
groupBy: ['field-x'],
missingFieldsStrategy: AlertSuppressionMissingFieldsStrategyEnum.suppress,
},
isParamsUpdateSkipped: false,
ruleType: 'query',
},
],
[
'skips update when existing alert suppression is the same as action',
{
existingAlertSuppression: {
groupBy: ['field-1', 'field-2'],
duration: { value: 5, unit: 'h' },
missingFieldsStrategy: AlertSuppressionMissingFieldsStrategyEnum.suppress,
},
alertSuppressionToSet: {
group_by: ['field-1', 'field-2'],
missing_fields_strategy: AlertSuppressionMissingFieldsStrategyEnum.suppress,
duration: { value: 5, unit: 'h' as const },
},
resultingAlertSuppression: {
groupBy: ['field-1', 'field-2'],
duration: { value: 5, unit: 'h' },
missingFieldsStrategy: AlertSuppressionMissingFieldsStrategyEnum.suppress,
},
isParamsUpdateSkipped: true,
ruleType: 'query',
},
],
[
'skips update when existing alert suppression is the same as action for absent duration',
{
existingAlertSuppression: {
groupBy: ['field-1', 'field-2', 'field-3'],
missingFieldsStrategy: AlertSuppressionMissingFieldsStrategyEnum.suppress,
},
alertSuppressionToSet: {
group_by: ['field-1', 'field-2', 'field-3'],
missing_fields_strategy: AlertSuppressionMissingFieldsStrategyEnum.suppress,
},
resultingAlertSuppression: {
groupBy: ['field-1', 'field-2', 'field-3'],
missingFieldsStrategy: AlertSuppressionMissingFieldsStrategyEnum.suppress,
},
isParamsUpdateSkipped: true,
ruleType: 'query',
},
],
])(
'should set alert suppression, case:"%s"',
(
caseName,
{
existingAlertSuppression,
alertSuppressionToSet,
resultingAlertSuppression,
isParamsUpdateSkipped,
ruleType,
}
) => {
const { modifiedParams, isParamsUpdateSkipped: isUpdateSkipped } = ruleParamsModifier(
{
...ruleParamsMock,
alertSuppression: existingAlertSuppression,
type: ruleType,
} as RuleAlertType['params'],
[
{
type: BulkActionEditTypeEnum.set_alert_suppression,
value: alertSuppressionToSet,
},
]
);
expect(modifiedParams).toHaveProperty('alertSuppression', resultingAlertSuppression);
expect(isParamsUpdateSkipped).toBe(isUpdateSkipped);
}
);
test('should throw error when applied to threshold rule', () => {
expect(() =>
ruleParamsModifier({ type: 'threshold' } as RuleAlertType['params'], [
{
type: BulkActionEditTypeEnum.set_alert_suppression,
value: {
group_by: ['field-1', 'field-2', 'field-3'],
missing_fields_strategy: AlertSuppressionMissingFieldsStrategyEnum.suppress,
},
},
])
).toThrow(
"Threshold rule doesn't support this action. Use 'set_alert_suppression_for_threshold' action instead"
);
});
});
describe('set_alert_suppression_for_threshold action', () => {
test.each([
[
'overwrites existing alert suppression with new duration',
{
existingAlertSuppression: {
duration: { value: 1, unit: 'h' },
},
alertSuppressionToSet: {
duration: { value: 30, unit: 'm' as const },
},
resultingAlertSuppression: {
duration: { value: 30, unit: 'm' },
},
isParamsUpdateSkipped: false,
},
],
[
'set new duration when existing suppression is undefined',
{
existingAlertSuppression: undefined,
alertSuppressionToSet: {
duration: { value: 5, unit: 'h' as const },
},
resultingAlertSuppression: {
duration: { value: 5, unit: 'h' },
},
isParamsUpdateSkipped: false,
},
],
[
'skips update when existing alert suppression is the same as action',
{
existingAlertSuppression: {
duration: { value: 5, unit: 'h' },
},
alertSuppressionToSet: {
duration: { value: 5, unit: 'h' as const },
},
resultingAlertSuppression: {
duration: { value: 5, unit: 'h' },
},
isParamsUpdateSkipped: true,
ruleType: 'query',
},
],
])(
'should set alert suppression, case:"%s"',
(
caseName,
{
existingAlertSuppression,
alertSuppressionToSet,
resultingAlertSuppression,
isParamsUpdateSkipped,
}
) => {
const { modifiedParams, isParamsUpdateSkipped: isUpdateSkipped } = ruleParamsModifier(
{
...ruleParamsMock,
alertSuppression: existingAlertSuppression,
type: 'threshold',
} as RuleAlertType['params'],
[
{
type: BulkActionEditTypeEnum.set_alert_suppression_for_threshold,
value: alertSuppressionToSet,
},
]
);
expect(modifiedParams).toHaveProperty('alertSuppression', resultingAlertSuppression);
expect(isParamsUpdateSkipped).toBe(isUpdateSkipped);
}
);
test('should throw error when applied not to threshold rule', () => {
expect(() =>
ruleParamsModifier({ type: 'new_terms' } as RuleAlertType['params'], [
{
type: BulkActionEditTypeEnum.set_alert_suppression_for_threshold,
value: {
duration: { value: 30, unit: 'm' as const },
},
},
])
).toThrow(
"new_terms rule type doesn't support this action. Use 'set_alert_suppression' action instead."
);
});
});
});
describe('timeline', () => {
test('should set timeline', () => {
const { modifiedParams, isParamsUpdateSkipped } = ruleParamsModifier(ruleParamsMock, [

View file

@ -11,10 +11,16 @@ import type {
BulkActionEditForRuleParams,
BulkActionEditPayloadIndexPatterns,
BulkActionEditPayloadInvestigationFields,
BulkActionEditPayloadSetAlertSuppression,
} from '../../../../../../common/api/detection_engine/rule_management';
import { BulkActionEditTypeEnum } from '../../../../../../common/api/detection_engine/rule_management';
import { invariant } from '../../../../../../common/utils/invariant';
import { calculateFromValue } from '../../../rule_types/utils/utils';
import type {
AlertSuppressionCamel,
AlertSuppressionDuration,
} from '../../../../../../common/api/detection_engine/model/rule_schema/common_attributes.gen';
import { DEFAULT_SUPPRESSION_MISSING_FIELDS_STRATEGY } from '../../../../../../common/detection_engine/constants';
export const addItemsToArray = <T>(arr: T[], items: T[]): T[] =>
Array.from(new Set([...arr, ...items]));
@ -104,6 +110,26 @@ const shouldSkipInvestigationFieldsBulkAction = (
return false;
};
const hasMatchingDuration = (
duration: AlertSuppressionDuration | undefined,
actionDuration: AlertSuppressionDuration | undefined
) => duration?.value === actionDuration?.value && duration?.unit === actionDuration?.unit;
const shouldSkipAddAlertSuppressionBulkAction = (
alertSuppression: AlertSuppressionCamel | undefined,
action: BulkActionEditPayloadSetAlertSuppression
) => {
if (!hasMatchingDuration(alertSuppression?.duration, action.value.duration)) {
return false;
}
if (alertSuppression?.missingFieldsStrategy !== action.value.missing_fields_strategy) {
return false;
}
return action.value.group_by.every((field) => alertSuppression?.groupBy?.includes(field));
};
// eslint-disable-next-line complexity
const applyBulkActionEditToRuleParams = (
existingRuleParams: RuleAlertType['params'],
@ -113,7 +139,7 @@ const applyBulkActionEditToRuleParams = (
isActionSkipped: boolean;
} => {
let ruleParams = { ...existingRuleParams };
// If the action is succesfully applied and the rule params are modified,
// If the action is successfully applied and the rule params are modified,
// we update the following flag to false. As soon as the current function
// returns this flag as false, at least once, for any action, we know that
// the rule needs to be marked as having its params updated.
@ -241,6 +267,53 @@ const applyBulkActionEditToRuleParams = (
ruleParams.investigationFields = action.value;
break;
}
// alert suppression actions
case BulkActionEditTypeEnum.delete_alert_suppression: {
if (!ruleParams?.alertSuppression) {
isActionSkipped = true;
break;
}
ruleParams.alertSuppression = undefined;
break;
}
case BulkActionEditTypeEnum.set_alert_suppression: {
invariant(
ruleParams.type !== 'threshold',
"Threshold rule doesn't support this action. Use 'set_alert_suppression_for_threshold' action instead"
);
if (shouldSkipAddAlertSuppressionBulkAction(ruleParams?.alertSuppression, action)) {
isActionSkipped = true;
break;
}
ruleParams.alertSuppression = {
groupBy: action.value.group_by,
missingFieldsStrategy:
action.value.missing_fields_strategy ?? DEFAULT_SUPPRESSION_MISSING_FIELDS_STRATEGY,
duration: action.value.duration,
};
break;
}
case BulkActionEditTypeEnum.set_alert_suppression_for_threshold: {
invariant(
ruleParams.type === 'threshold',
`${ruleParams.type} rule type doesn't support this action. Use 'set_alert_suppression' action instead.`
);
if (hasMatchingDuration(ruleParams?.alertSuppression?.duration, action.value.duration)) {
isActionSkipped = true;
break;
}
ruleParams.alertSuppression = {
duration: action.value.duration,
};
break;
}
// timeline actions
case BulkActionEditTypeEnum.set_timeline: {
ruleParams = {

View file

@ -0,0 +1,56 @@
/*
* 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 { BulkActionEditTypeEnum } from '../../../../../../common/api/detection_engine/rule_management';
import type { BulkActionEditPayload } from '../../../../../../common/api/detection_engine/rule_management';
import { hasAlertSuppressionBulkEditAction } from './utils';
describe('hasAlertSuppressionBulkEditAction', () => {
it('returns true if actions include set_alert_suppression_for_threshold', () => {
const actions: BulkActionEditPayload[] = [
{
type: BulkActionEditTypeEnum.set_alert_suppression_for_threshold,
value: { duration: { unit: 'm', value: 4 } },
},
];
expect(hasAlertSuppressionBulkEditAction(actions)).toBe(true);
});
it('returns true if actions include delete_alert_suppression', () => {
const actions: BulkActionEditPayload[] = [
{ type: BulkActionEditTypeEnum.delete_alert_suppression },
];
expect(hasAlertSuppressionBulkEditAction(actions)).toBe(true);
});
it('returns true if actions include set_alert_suppression', () => {
const actions: BulkActionEditPayload[] = [
{ type: BulkActionEditTypeEnum.set_alert_suppression, value: { group_by: ['test-'] } },
];
expect(hasAlertSuppressionBulkEditAction(actions)).toBe(true);
});
it('returns false if actions do not include any suppression actions', () => {
const actions: BulkActionEditPayload[] = [
{ type: BulkActionEditTypeEnum.add_tags, value: ['tag1'] },
{ type: BulkActionEditTypeEnum.set_index_patterns, value: [] },
];
expect(hasAlertSuppressionBulkEditAction(actions)).toBe(false);
});
it('returns true if at least one action is a suppression action among others', () => {
const actions: BulkActionEditPayload[] = [
{ type: BulkActionEditTypeEnum.add_tags, value: ['tag1'] },
{ type: BulkActionEditTypeEnum.set_alert_suppression, value: { group_by: ['test-'] } },
];
expect(hasAlertSuppressionBulkEditAction(actions)).toBe(true);
});
it('returns false for empty actions array', () => {
expect(hasAlertSuppressionBulkEditAction([])).toBe(false);
});
});

View file

@ -11,6 +11,7 @@ import type {
BulkActionEditType,
NormalizedRuleAction,
ThrottleForBulkActions,
BulkActionEditPayload,
} from '../../../../../../common/api/detection_engine/rule_management';
import { BulkActionEditTypeEnum } from '../../../../../../common/api/detection_engine/rule_management';
import { transformToActionFrequency } from '../../normalization/rule_actions';
@ -31,6 +32,31 @@ export const isIndexPatternsBulkEditAction = (editAction: BulkActionEditType) =>
return indexPatternsActions.includes(editAction);
};
/**
* helper utility that defines whether bulk edit action is related to alert suppression, i.e. one of:
* 'set_alert_suppression_for_threshold', 'delete_alert_suppression', 'set_alert_suppression'
* @param editAction {@link BulkActionEditType}
* @returns {boolean}
*/
const isAlertSuppressionBulkEditAction = (editAction: BulkActionEditType) => {
const bulkActions: BulkActionEditType[] = [
BulkActionEditTypeEnum.set_alert_suppression_for_threshold,
BulkActionEditTypeEnum.delete_alert_suppression,
BulkActionEditTypeEnum.set_alert_suppression,
];
return bulkActions.includes(editAction);
};
/**
* Checks if any of the actions is related to alert suppression, i.e. one of:
* 'set_alert_suppression_for_threshold', 'delete_alert_suppression', 'set_alert_suppression'
* @param actions {@link BulkActionEditPayload[][]}
* @returns {boolean}
*/
export const hasAlertSuppressionBulkEditAction = (actions: BulkActionEditPayload[]): boolean => {
return actions.some((action) => isAlertSuppressionBulkEditAction(action.type));
};
/**
* Separates system actions from actions and performs necessary transformations for
* alerting rules client bulk edit operations.

View file

@ -15,7 +15,7 @@ import {
BulkActionsDryRunErrCodeEnum,
} from '../../../../../../common/api/detection_engine/rule_management';
import type { PrebuiltRulesCustomizationStatus } from '../../../../../../common/detection_engine/prebuilt_rules/prebuilt_rule_customization_status';
import { isEsqlRule } from '../../../../../../common/detection_engine/utils';
import { isEsqlRule, isThresholdRule } from '../../../../../../common/detection_engine/utils';
import { isMlRule } from '../../../../../../common/machine_learning/helpers';
import { invariant } from '../../../../../../common/utils/invariant';
import type { MlAuthz } from '../../../../machine_learning/authz';
@ -180,4 +180,28 @@ export const dryRunValidateBulkEditRule = async ({
),
BulkActionsDryRunErrCodeEnum.ESQL_INDEX_PATTERN
);
// if rule is threshold, set_alert_suppression action can't be applied to it
await throwDryRunError(
() =>
invariant(
!isThresholdRule(rule.params.type) ||
!edit.some((action) => action.type === BulkActionEditTypeEnum.set_alert_suppression),
"Threshold rule doesn't support this action. Use 'set_alert_suppression_for_threshold' action instead"
),
BulkActionsDryRunErrCodeEnum.THRESHOLD_RULE_TYPE_IN_SUPPRESSION
);
// if rule noy threshold, set_alert_suppression_for_threshold action can't be applied to it
await throwDryRunError(
() =>
invariant(
isThresholdRule(rule.params.type) ||
!edit.some(
(action) => action.type === BulkActionEditTypeEnum.set_alert_suppression_for_threshold
),
"Rule type doesn't support this action. Use 'set_alert_suppression' action instead."
),
BulkActionsDryRunErrCodeEnum.UNSUPPORTED_RULE_IN_SUPPRESSION_FOR_THRESHOLD
);
};

View file

@ -108,6 +108,7 @@ export function createTestConfig(options: CreateTestConfigOptions, testFiles?: s
'previewTelemetryUrlEnabled',
'riskScoringPersistence',
'riskScoringRoutesEnabled',
'bulkEditAlertSuppressionEnabled',
])}`,
`--plugin-path=${path.resolve(
__dirname,

View file

@ -37,6 +37,9 @@ export function createTestConfig(options: CreateTestConfigOptions) {
...svlSharedConfig.get('kbnTestServer.serverArgs'),
'--serverless=security',
`--xpack.actions.preconfigured=${JSON.stringify(PRECONFIGURED_ACTION_CONNECTORS)}`,
`--xpack.securitySolution.enableExperimental=${JSON.stringify([
'bulkEditAlertSuppressionEnabled',
])}`,
...(options.kbnTestServerArgs || []),
`--plugin-path=${path.resolve(
__dirname,

View file

@ -12,6 +12,7 @@ export default function ({ loadTestFile }: FtrProviderContext) {
loadTestFile(require.resolve('./perform_bulk_action_dry_run'));
loadTestFile(require.resolve('./perform_bulk_action_dry_run_ess'));
loadTestFile(require.resolve('./perform_bulk_action'));
loadTestFile(require.resolve('./perform_bulk_action_suppression'));
loadTestFile(require.resolve('./perform_bulk_action_ess'));
loadTestFile(require.resolve('./perform_bulk_enable_disable.ts'));
});

View file

@ -10,7 +10,12 @@ import {
BulkActionEditTypeEnum,
} from '@kbn/security-solution-plugin/common/api/detection_engine/rule_management';
import moment from 'moment';
import { getCustomQueryRuleParams, getSimpleMlRule, getSimpleRule } from '../../../utils';
import {
getCustomQueryRuleParams,
getSimpleMlRule,
getSimpleRule,
getThresholdRuleForAlertTesting,
} from '../../../utils';
import {
createRule,
createAlertsIndex,
@ -565,5 +570,100 @@ export default ({ getService }: FtrProviderContext): void => {
});
});
});
// skips serverless MKI due to feature flag
describe('@skipInServerlessMKI alert suppression', () => {
it('should return error when attempting to apply set_alert_suppression bulk action to a threshold rule', async () => {
const createdRule = await createRule(
supertest,
log,
getThresholdRuleForAlertTesting(['*'], 'ruleId')
);
const { body } = await securitySolutionApi
.performRulesBulkAction({
query: { dry_run: true },
body: {
ids: [createdRule.id],
action: BulkActionTypeEnum.edit,
[BulkActionTypeEnum.edit]: [
{
type: BulkActionEditTypeEnum.set_alert_suppression,
value: { group_by: ['host.name'], duration: { value: 5, unit: 'm' as const } },
},
],
},
})
.expect(500);
expect(body.attributes.summary).toEqual({
failed: 1,
skipped: 0,
succeeded: 0,
total: 1,
});
expect(body.attributes.errors).toHaveLength(1);
expect(body.attributes.errors[0]).toEqual({
err_code: 'THRESHOLD_RULE_TYPE_IN_SUPPRESSION',
message:
"Threshold rule doesn't support this action. Use 'set_alert_suppression_for_threshold' action instead",
rules: [
{
id: createdRule.id,
name: createdRule.name,
},
],
status_code: 500,
});
});
it('should return error when attempting to apply set_alert_suppression_for_threshold bulk action to a non-threshold rule', async () => {
const createdRule = await createRule(
supertest,
log,
getCustomQueryRuleParams({
rule_id: 'rule-1',
})
);
const { body } = await securitySolutionApi
.performRulesBulkAction({
query: { dry_run: true },
body: {
ids: [createdRule.id],
action: BulkActionTypeEnum.edit,
[BulkActionTypeEnum.edit]: [
{
type: BulkActionEditTypeEnum.set_alert_suppression_for_threshold,
value: { duration: { value: 5, unit: 'm' as const } },
},
],
},
})
.expect(500);
expect(body.attributes.summary).toEqual({
failed: 1,
skipped: 0,
succeeded: 0,
total: 1,
});
expect(body.attributes.errors).toHaveLength(1);
expect(body.attributes.errors[0]).toEqual({
err_code: 'UNSUPPORTED_RULE_IN_SUPPRESSION_FOR_THRESHOLD',
message:
"Rule type doesn't support this action. Use 'set_alert_suppression' action instead.",
rules: [
{
id: createdRule.id,
name: createdRule.name,
},
],
status_code: 500,
});
});
});
});
};

View file

@ -0,0 +1,432 @@
/*
* 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 expect from 'expect';
import {
BulkActionTypeEnum,
BulkActionEditTypeEnum,
} from '@kbn/security-solution-plugin/common/api/detection_engine/rule_management';
import { AlertSuppressionMissingFieldsStrategyEnum } from '@kbn/security-solution-plugin/common/api/detection_engine/model/rule_schema/common_attributes.gen';
import { getThresholdRuleForAlertTesting, getCustomQueryRuleParams } from '../../../utils';
import { createRule, deleteAllRules } from '../../../../../../common/utils/security_solution';
import { FtrProviderContext } from '../../../../../ftr_provider_context';
export default ({ getService }: FtrProviderContext): void => {
const supertest = getService('supertest');
const log = getService('log');
const securitySolutionApi = getService('securitySolutionApi');
// skips serverless MKI due to feature flag
describe('@ess @serverless @skipInServerlessMKI perform_bulk_action suppression', () => {
beforeEach(async () => {
await deleteAllRules(supertest, log);
});
describe('set_alert_suppression action', () => {
it('should overwrite suppression in a rule', async () => {
const ruleId = 'ruleId';
const existingSuppression = {
group_by: ['field1'],
duration: { value: 5, unit: 'm' as const },
missing_fields_strategy: AlertSuppressionMissingFieldsStrategyEnum.suppress,
};
const suppressionToSet = {
group_by: ['field2'],
duration: { value: 10, unit: 'm' as const },
missing_fields_strategy: AlertSuppressionMissingFieldsStrategyEnum.suppress,
};
const resultingSuppression = {
group_by: ['field2'],
duration: { value: 10, unit: 'm' },
missing_fields_strategy: AlertSuppressionMissingFieldsStrategyEnum.suppress,
};
await createRule(
supertest,
log,
getCustomQueryRuleParams({ rule_id: ruleId, alert_suppression: existingSuppression })
);
const { body: bulkEditResponse } = await securitySolutionApi
.performRulesBulkAction({
query: { dry_run: false },
body: {
action: BulkActionTypeEnum.edit,
[BulkActionTypeEnum.edit]: [
{
type: BulkActionEditTypeEnum.set_alert_suppression,
value: suppressionToSet,
},
],
},
})
.expect(200);
expect(bulkEditResponse.attributes.summary).toEqual({
failed: 0,
skipped: 0,
succeeded: 1,
total: 1,
});
// Check that the updated rule is returned with the response
expect(bulkEditResponse.attributes.results.updated[0].alert_suppression).toEqual(
resultingSuppression
);
// Check that the updates have been persisted
const { body: updatedRule } = await securitySolutionApi
.readRule({
query: { rule_id: ruleId },
})
.expect(200);
expect(updatedRule.alert_suppression).toEqual(resultingSuppression);
});
it('should set suppression to rules without configured suppression', async () => {
const suppressionToSet = {
group_by: ['field2'],
};
const resultingSuppression = {
group_by: ['field2'],
missing_fields_strategy: AlertSuppressionMissingFieldsStrategyEnum.suppress,
};
await Promise.all([
createRule(supertest, log, getCustomQueryRuleParams({ rule_id: 'id_1' })),
createRule(supertest, log, getCustomQueryRuleParams({ rule_id: 'id_2' })),
]);
const { body: bulkEditResponse } = await securitySolutionApi
.performRulesBulkAction({
query: { dry_run: false },
body: {
action: BulkActionTypeEnum.edit,
[BulkActionTypeEnum.edit]: [
{
type: BulkActionEditTypeEnum.set_alert_suppression,
value: suppressionToSet,
},
],
},
})
.expect(200);
expect(bulkEditResponse.attributes.summary).toEqual({
failed: 0,
skipped: 0,
succeeded: 2,
total: 2,
});
// Check that the updated rule is returned with the response
expect(bulkEditResponse.attributes.results.updated[0].alert_suppression).toEqual(
resultingSuppression
);
// Check that the updates have been persisted
const { body: updatedRule } = await securitySolutionApi
.readRule({
query: { rule_id: 'id_1' },
})
.expect(200);
expect(updatedRule.alert_suppression).toEqual(resultingSuppression);
});
it('should return error when trying to set suppression to threshold rule', async () => {
const ruleId = 'ruleId';
const existingSuppression = { duration: { value: 5, unit: 'm' as const } };
const suppressionToSet = {
group_by: ['field2'],
};
await createRule(supertest, log, {
...getThresholdRuleForAlertTesting(['*'], ruleId),
alert_suppression: existingSuppression,
});
const { body: bulkEditResponse } = await securitySolutionApi
.performRulesBulkAction({
query: { dry_run: false },
body: {
action: BulkActionTypeEnum.edit,
[BulkActionTypeEnum.edit]: [
{
type: BulkActionEditTypeEnum.set_alert_suppression,
value: suppressionToSet,
},
],
},
})
.expect(500);
expect(bulkEditResponse.attributes.summary).toEqual({
failed: 1,
skipped: 0,
succeeded: 0,
total: 1,
});
expect(bulkEditResponse.attributes.errors).toHaveLength(1);
expect(bulkEditResponse.attributes.errors[0].message).toBe(
"Threshold rule doesn't support this action. Use 'set_alert_suppression_for_threshold' action instead"
);
// Check that the updates did not apply to the rule
const { body: updatedRule } = await securitySolutionApi
.readRule({
query: { rule_id: ruleId },
})
.expect(200);
expect(updatedRule.alert_suppression).toEqual(existingSuppression);
});
it('should set suppression to rules without configured suppression and throw error on threshold one', async () => {
const suppressionToSet = {
group_by: ['field2'],
};
await Promise.all([
createRule(supertest, log, getCustomQueryRuleParams({ rule_id: 'id_1' })),
createRule(supertest, log, getThresholdRuleForAlertTesting(['*'], 'id_2')),
]);
const { body: bulkEditResponse } = await securitySolutionApi
.performRulesBulkAction({
query: { dry_run: false },
body: {
action: BulkActionTypeEnum.edit,
[BulkActionTypeEnum.edit]: [
{
type: BulkActionEditTypeEnum.set_alert_suppression,
value: suppressionToSet,
},
],
},
})
.expect(500);
expect(bulkEditResponse.attributes.summary).toEqual({
failed: 1,
skipped: 0,
succeeded: 1,
total: 2,
});
});
});
describe('delete_alert_suppression action', () => {
it('should delete suppression from rules', async () => {
await Promise.all([
createRule(
supertest,
log,
getCustomQueryRuleParams({
rule_id: 'id_1',
alert_suppression: {
group_by: ['field2'],
duration: { value: 10, unit: 'm' },
missing_fields_strategy: AlertSuppressionMissingFieldsStrategyEnum.suppress,
},
})
),
createRule(supertest, log, {
...getThresholdRuleForAlertTesting(['*'], 'id_2'),
alert_suppression: {
duration: { value: 10, unit: 'm' },
},
}),
]);
const { body: bulkEditResponse } = await securitySolutionApi
.performRulesBulkAction({
query: { dry_run: false },
body: {
query: '',
action: BulkActionTypeEnum.edit,
[BulkActionTypeEnum.edit]: [
{
type: BulkActionEditTypeEnum.delete_alert_suppression,
},
],
},
})
.expect(200);
expect(bulkEditResponse.attributes.summary).toEqual({
failed: 0,
skipped: 0,
succeeded: 2,
total: 2,
});
// Check that the updated rule is returned with the response
expect(bulkEditResponse.attributes.results.updated[0].alert_suppression).toEqual(undefined);
// Check that the updates have been persisted
const updatedRules = await Promise.all([
securitySolutionApi.readRule({
query: { rule_id: 'id_1' },
}),
await securitySolutionApi.readRule({
query: { rule_id: 'id_2' },
}),
]);
expect(updatedRules[0].body.alert_suppression).toEqual(undefined);
expect(updatedRules[1].body.alert_suppression).toEqual(undefined);
});
});
describe('set_alert_suppression_for_threshold action', () => {
it('should overwrite suppression in a rule', async () => {
const ruleId = 'ruleId';
const existingSuppression = {
duration: { value: 5, unit: 'm' as const },
};
const suppressionToSet = {
duration: { value: 10, unit: 'm' as const },
};
const resultingSuppression = {
duration: { value: 10, unit: 'm' },
};
await createRule(supertest, log, {
...getThresholdRuleForAlertTesting(['*'], ruleId),
alert_suppression: existingSuppression,
});
const { body: bulkEditResponse } = await securitySolutionApi
.performRulesBulkAction({
query: { dry_run: false },
body: {
action: BulkActionTypeEnum.edit,
[BulkActionTypeEnum.edit]: [
{
type: BulkActionEditTypeEnum.set_alert_suppression_for_threshold,
value: suppressionToSet,
},
],
},
})
.expect(200);
expect(bulkEditResponse.attributes.summary).toEqual({
failed: 0,
skipped: 0,
succeeded: 1,
total: 1,
});
// Check that the updated rule is returned with the response
expect(bulkEditResponse.attributes.results.updated[0].alert_suppression).toEqual(
resultingSuppression
);
// Check that the updates have been persisted
const { body: updatedRule } = await securitySolutionApi
.readRule({
query: { rule_id: ruleId },
})
.expect(200);
expect(updatedRule.alert_suppression).toEqual(resultingSuppression);
});
it('should set suppression to rule without configured suppression', async () => {
const ruleId = 'ruleId';
const suppressionToSet = {
duration: { value: 10, unit: 'm' as const },
};
const resultingSuppression = {
duration: { value: 10, unit: 'm' },
};
await createRule(supertest, log, getThresholdRuleForAlertTesting(['*'], ruleId));
const { body: bulkEditResponse } = await securitySolutionApi
.performRulesBulkAction({
query: { dry_run: false },
body: {
action: BulkActionTypeEnum.edit,
[BulkActionTypeEnum.edit]: [
{
type: BulkActionEditTypeEnum.set_alert_suppression_for_threshold,
value: suppressionToSet,
},
],
},
})
.expect(200);
expect(bulkEditResponse.attributes.summary).toEqual({
failed: 0,
skipped: 0,
succeeded: 1,
total: 1,
});
// Check that the updated rule is returned with the response
expect(bulkEditResponse.attributes.results.updated[0].alert_suppression).toEqual(
resultingSuppression
);
// Check that the updates have been persisted
const { body: updatedRule } = await securitySolutionApi
.readRule({
query: { rule_id: ruleId },
})
.expect(200);
expect(updatedRule.alert_suppression).toEqual(resultingSuppression);
});
it('should return error when trying to set suppression not to threshold rule', async () => {
const ruleId = 'ruleId';
await createRule(supertest, log, getCustomQueryRuleParams({ rule_id: ruleId }));
const { body: bulkEditResponse } = await securitySolutionApi
.performRulesBulkAction({
query: { dry_run: false },
body: {
action: BulkActionTypeEnum.edit,
[BulkActionTypeEnum.edit]: [
{
type: BulkActionEditTypeEnum.set_alert_suppression_for_threshold,
value: { duration: { value: 10, unit: 'm' } },
},
],
},
})
.expect(500);
expect(bulkEditResponse.attributes.summary).toEqual({
failed: 1,
skipped: 0,
succeeded: 0,
total: 1,
});
expect(bulkEditResponse.attributes.errors).toHaveLength(1);
expect(bulkEditResponse.attributes.errors[0].message).toBe(
"query rule type doesn't support this action. Use 'set_alert_suppression' action instead."
);
// Check that the updates did not apply to the rule
const { body: updatedRule } = await securitySolutionApi
.readRule({
query: { rule_id: ruleId },
})
.expect(200);
expect(updatedRule.alert_suppression).toEqual(undefined);
});
});
});
};

View file

@ -54,6 +54,9 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) {
// packages listed in fleet_packages.json
// See: https://elastic.slack.com/archives/CNMNXV4RG/p1683033379063079
`--xpack.fleet.developer.bundledPackageLocation=./inexistentDir`,
`--xpack.securitySolution.enableExperimental=${JSON.stringify([
'bulkEditAlertSuppressionEnabled',
])}`,
'--csp.strict=false',
'--csp.warnLegacyBrowsers=false',
],

View file

@ -0,0 +1,230 @@
/*
* 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 { deleteAlertsAndRules } from '../../../../../tasks/api_calls/common';
import { MODAL_CONFIRMATION_BODY } from '../../../../../screens/alerts_detection_rules';
import { RULES_BULK_EDIT_FORM_TITLE } from '../../../../../screens/rules_bulk_actions';
import {
DEFINITION_DETAILS,
SUPPRESS_BY_DETAILS,
SUPPRESS_FOR_DETAILS,
SUPPRESS_MISSING_FIELD,
} from '../../../../../screens/rule_details';
import {
selectAllRules,
goToRuleDetailsOf,
getRulesManagementTableRows,
disableAutoRefresh,
} from '../../../../../tasks/alerts_detection_rules';
import {
waitForBulkEditActionToFinish,
submitBulkEditForm,
clickSetAlertSuppressionMenuItem,
confirmBulkEditAction,
clickSetAlertSuppressionForThresholdMenuItem,
clickDeleteAlertSuppressionMenuItem,
} from '../../../../../tasks/rules_bulk_actions';
import {
fillAlertSuppressionFields,
selectAlertSuppressionPerInterval,
setAlertSuppressionDuration,
selectDoNotSuppressForMissingFields,
} from '../../../../../tasks/create_new_rule';
import { getDetails, assertDetailsNotExist } from '../../../../../tasks/rule_details';
import { login } from '../../../../../tasks/login';
import { visitRulesManagementTable } from '../../../../../tasks/rules_management';
import { createRule } from '../../../../../tasks/api_calls/rules';
import {
getEqlRule,
getNewThreatIndicatorRule as getNewIMRule,
getNewRule,
getNewThresholdRule,
getMachineLearningRule,
getNewTermsRule,
getEsqlRule,
} from '../../../../../objects/rule';
const queryRule = getNewRule({ rule_id: '1', name: 'Query rule', enabled: false });
const eqlRule = getEqlRule({ rule_id: '2', name: 'EQL Rule', enabled: false });
const mlRule = getMachineLearningRule({ rule_id: '3', name: 'ML Rule', enabled: false });
const imRule = getNewIMRule({ rule_id: '4', name: 'IM Rule', enabled: false });
const newTermsRule = getNewTermsRule({ rule_id: '5', name: 'New Terms Rule', enabled: false });
const esqlRule = getEsqlRule({ rule_id: '6', name: 'ES|QL Rule', enabled: false });
const thresholdRule = getNewThresholdRule({ rule_id: '7', name: 'Threshold Rule', enabled: false });
// skipInServerlessMKI because of experiment feature flag
describe(
'Bulk Edit - Alert Suppression',
{ tags: ['@ess', '@serverless', '@skipInServerlessMKI'] },
() => {
beforeEach(() => {
login();
deleteAlertsAndRules();
});
describe('Rules without suppression', () => {
beforeEach(() => {
createRule(queryRule);
createRule(eqlRule);
createRule(mlRule);
createRule(imRule);
createRule(newTermsRule);
createRule(esqlRule);
createRule(thresholdRule);
visitRulesManagementTable();
disableAutoRefresh();
});
it('Set alert suppression', () => {
const skippedCount = 1; // Threshold rule is skipped
getRulesManagementTableRows().then((rows) => {
selectAllRules();
clickSetAlertSuppressionMenuItem();
cy.get(MODAL_CONFIRMATION_BODY).contains(
`${skippedCount} threshold rule can't be edited. To bulk-apply alert suppression to this rule, use the Apply alert suppression to threshold rules option.`
);
confirmBulkEditAction();
cy.get(RULES_BULK_EDIT_FORM_TITLE).should('have.text', 'Apply alert suppression');
fillAlertSuppressionFields(['source.ip']);
selectAlertSuppressionPerInterval();
setAlertSuppressionDuration(2, 'h');
selectDoNotSuppressForMissingFields();
submitBulkEditForm();
waitForBulkEditActionToFinish({ updatedCount: rows.length - skippedCount });
// check if one of the rules has been updated
goToRuleDetailsOf(eqlRule.name);
cy.get(DEFINITION_DETAILS).within(() => {
getDetails(SUPPRESS_BY_DETAILS).should('have.text', 'source.ip');
getDetails(SUPPRESS_FOR_DETAILS).should('have.text', '2h');
getDetails(SUPPRESS_MISSING_FIELD).should(
'have.text',
'Do not suppress alerts for events with missing fields'
);
});
});
});
it('Set alert suppression for threshold rules', () => {
const skippedCount = 6; // Non threshold rules are skipped
getRulesManagementTableRows().then((rows) => {
selectAllRules();
clickSetAlertSuppressionForThresholdMenuItem();
cy.get(MODAL_CONFIRMATION_BODY).contains(
`${skippedCount} rules can't be edited. To bulk-apply alert suppression to these rules, use the Apply alert suppression option.`
);
confirmBulkEditAction();
cy.get(RULES_BULK_EDIT_FORM_TITLE).should(
'have.text',
'Apply alert suppression to threshold rules'
);
setAlertSuppressionDuration(50, 'm');
submitBulkEditForm();
waitForBulkEditActionToFinish({ updatedCount: rows.length - skippedCount });
// check if one of the rules has been updated
goToRuleDetailsOf(thresholdRule.name);
cy.get(DEFINITION_DETAILS).within(() => {
getDetails(SUPPRESS_FOR_DETAILS).should('have.text', '50m');
});
});
});
});
describe('Rules with suppression', () => {
beforeEach(() => {
const commonOverrides = {
alert_suppression: {
group_by: ['destination.ip'],
duration: { value: 30, unit: 'm' as const },
missing_fields_strategy: 'suppress' as const,
},
enabled: false,
};
createRule({ ...queryRule, ...commonOverrides });
createRule({ ...eqlRule, ...commonOverrides });
createRule({ ...mlRule, ...commonOverrides });
createRule({ ...imRule, ...commonOverrides });
createRule({ ...newTermsRule, ...commonOverrides });
createRule({ ...esqlRule, ...commonOverrides });
createRule({
...thresholdRule,
enabled: false,
alert_suppression: { duration: { value: 1, unit: 'h' as const } },
});
visitRulesManagementTable();
disableAutoRefresh();
});
it('Delete alert suppression', () => {
getRulesManagementTableRows().then((rows) => {
selectAllRules();
clickDeleteAlertSuppressionMenuItem();
cy.get(MODAL_CONFIRMATION_BODY).contains(
`This action will remove alert suppression from 7 rules. Click Delete to continue.`
);
confirmBulkEditAction();
waitForBulkEditActionToFinish({ updatedCount: rows.length });
// check if one of the rules has been updated and suppression is removed
goToRuleDetailsOf(newTermsRule.name);
cy.get(DEFINITION_DETAILS).within(() => {
assertDetailsNotExist(SUPPRESS_BY_DETAILS);
assertDetailsNotExist(SUPPRESS_FOR_DETAILS);
assertDetailsNotExist(SUPPRESS_MISSING_FIELD);
});
});
});
it('Overwrites existing alert suppression', () => {
const skippedCount = 1; // Threshold rule is skipped
getRulesManagementTableRows().then((rows) => {
selectAllRules();
clickSetAlertSuppressionMenuItem();
confirmBulkEditAction();
fillAlertSuppressionFields(['agent.name']);
submitBulkEditForm();
waitForBulkEditActionToFinish({ updatedCount: rows.length - skippedCount });
// check if one of the rules has been updated
goToRuleDetailsOf(esqlRule.name);
cy.get(DEFINITION_DETAILS).within(() => {
getDetails(SUPPRESS_BY_DETAILS).should('have.text', 'agent.name');
getDetails(SUPPRESS_FOR_DETAILS).should('have.text', 'One rule execution');
getDetails(SUPPRESS_MISSING_FIELD).should(
'have.text',
'Suppress and group alerts for events with missing fields'
);
});
});
});
});
}
);

View file

@ -0,0 +1,51 @@
/*
* 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 { deleteAlertsAndRules } from '../../../../../tasks/api_calls/common';
import { ALERT_SUPPRESSION_RULE_BULK_MENU_ITEM } from '../../../../../screens/rules_bulk_actions';
import { TOOLTIP } from '../../../../../screens/common';
import {
selectAllRules,
getRulesManagementTableRows,
disableAutoRefresh,
} from '../../../../../tasks/alerts_detection_rules';
import { clickBulkActionsButton } from '../../../../../tasks/rules_bulk_actions';
import { login } from '../../../../../tasks/login';
import { visitRulesManagementTable } from '../../../../../tasks/rules_management';
import { startBasicLicense } from '../../../../../tasks/api_calls/licensing';
import { createRule } from '../../../../../tasks/api_calls/rules';
import { getNewRule } from '../../../../../objects/rule';
const queryRule = getNewRule({ rule_id: '1', name: 'Query rule', enabled: false });
describe('Bulk Edit - Alert Suppression, Basic License', { tags: ['@ess'] }, () => {
beforeEach(() => {
login();
deleteAlertsAndRules();
startBasicLicense();
});
beforeEach(() => {
createRule(queryRule);
visitRulesManagementTable();
disableAutoRefresh();
});
it('bulk suppression is disabled and and upselling message is shown on hover', () => {
getRulesManagementTableRows().then((rows) => {
selectAllRules();
clickBulkActionsButton();
cy.get(ALERT_SUPPRESSION_RULE_BULK_MENU_ITEM).should('be.disabled');
cy.get(`${ALERT_SUPPRESSION_RULE_BULK_MENU_ITEM}`).parent().trigger('mouseover');
// Platinum license is required for this option to be enabled
cy.get(TOOLTIP).contains('Platinum license');
});
});
});

View file

@ -0,0 +1,76 @@
/*
* 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 { deleteAlertsAndRules } from '../../../../../tasks/api_calls/common';
import { DEFINITION_DETAILS, SUPPRESS_BY_DETAILS } from '../../../../../screens/rule_details';
import {
selectAllRules,
goToRuleDetailsOf,
getRulesManagementTableRows,
disableAutoRefresh,
} from '../../../../../tasks/alerts_detection_rules';
import {
waitForBulkEditActionToFinish,
submitBulkEditForm,
clickSetAlertSuppressionMenuItem,
} from '../../../../../tasks/rules_bulk_actions';
import { fillAlertSuppressionFields } from '../../../../../tasks/create_new_rule';
import { getDetails } from '../../../../../tasks/rule_details';
import { login } from '../../../../../tasks/login';
import { visitRulesManagementTable } from '../../../../../tasks/rules_management';
import { createRule } from '../../../../../tasks/api_calls/rules';
import { getNewRule } from '../../../../../objects/rule';
const queryRule = getNewRule({ rule_id: '1', name: 'Query rule', enabled: false });
// skipInServerlessMKI because of experiment feature flag
describe(
'Bulk Edit - Alert Suppression, Essentials Serverless tier',
{
tags: ['@serverless', '@skipInServerlessMKI'],
env: {
ftrConfig: {
productTypes: [{ product_line: 'security', product_tier: 'essentials' }],
},
},
},
() => {
beforeEach(() => {
login();
deleteAlertsAndRules();
});
beforeEach(() => {
createRule(queryRule);
visitRulesManagementTable();
disableAutoRefresh();
});
it('Set alert suppression', () => {
getRulesManagementTableRows().then((rows) => {
selectAllRules();
clickSetAlertSuppressionMenuItem();
fillAlertSuppressionFields(['source.ip']);
submitBulkEditForm();
waitForBulkEditActionToFinish({ updatedCount: rows.length });
// check if one of the rules has been updated
goToRuleDetailsOf(queryRule.name);
cy.get(DEFINITION_DETAILS).within(() => {
getDetails(SUPPRESS_BY_DETAILS).should('have.text', 'source.ip');
});
});
});
}
);

View file

@ -102,6 +102,19 @@ export const RULES_BULK_EDIT_OVERWRITE_INVESTIGATION_FIELDS_CHECKBOX =
export const RULES_BULK_EDIT_INVESTIGATION_FIELDS_WARNING =
'[data-test-subj="bulkEditRulesInvestigationFieldsWarning"]';
// ALERT SUPPRESSION
export const ALERT_SUPPRESSION_RULE_BULK_MENU_ITEM =
'[data-test-subj="alertSuppressionBulkEditRule"]';
export const SET_ALERT_SUPPRESSION_RULE_BULK_MENU_ITEM =
'[data-test-subj="setAlertSuppressionBulkEditRule"]';
export const SET_ALERT_SUPPRESSION_FOR_THRESHOLD_BULK_MENU_ITEM =
'[data-test-subj="setAlertSuppressionForThresholdBulkEditRule"]';
export const DELETE_ALERT_SUPPRESSION_RULE_BULK_MENU_ITEM =
'[data-test-subj="deleteAlertSuppressionBulkEditRule"]';
// ENABLE/DISABLE
export const ENABLE_RULE_BULK_BTN = '[data-test-subj="enableRuleBulk"]';

View file

@ -58,6 +58,10 @@ import {
UPDATE_SCHEDULE_LOOKBACK_INPUT,
UPDATE_SCHEDULE_MENU_ITEM,
UPDATE_SCHEDULE_TIME_UNIT_SELECT,
ALERT_SUPPRESSION_RULE_BULK_MENU_ITEM,
SET_ALERT_SUPPRESSION_RULE_BULK_MENU_ITEM,
DELETE_ALERT_SUPPRESSION_RULE_BULK_MENU_ITEM,
SET_ALERT_SUPPRESSION_FOR_THRESHOLD_BULK_MENU_ITEM,
} from '../screens/rules_bulk_actions';
import { SCHEDULE_DETAILS } from '../screens/rule_details';
@ -281,6 +285,32 @@ export const checkOverwriteInvestigationFieldsCheckbox = () => {
.should('be.checked');
};
// edit alert suppression
export const clickBulkActionsButton = () => {
cy.get(BULK_ACTIONS_BTN).click();
};
const clickAlertSuppressionMenuItem = () => {
clickBulkActionsButton();
cy.get(ALERT_SUPPRESSION_RULE_BULK_MENU_ITEM).click();
};
export const clickSetAlertSuppressionMenuItem = () => {
clickAlertSuppressionMenuItem();
cy.get(SET_ALERT_SUPPRESSION_RULE_BULK_MENU_ITEM).click();
};
export const clickSetAlertSuppressionForThresholdMenuItem = () => {
clickAlertSuppressionMenuItem();
cy.get(SET_ALERT_SUPPRESSION_FOR_THRESHOLD_BULK_MENU_ITEM).click();
};
export const clickDeleteAlertSuppressionMenuItem = () => {
clickAlertSuppressionMenuItem();
cy.get(DELETE_ALERT_SUPPRESSION_RULE_BULK_MENU_ITEM).click();
};
// EDIT-SCHEDULE
export const clickUpdateScheduleMenuItem = () => {
cy.get(BULK_ACTIONS_BTN).click();
@ -445,3 +475,9 @@ export const scheduleManualRuleRunForSelectedRules = (
}
cy.get(MODAL_CONFIRMATION_BTN).click();
};
// Confirmation modal
export const confirmBulkEditAction = () => {
cy.get(MODAL_CONFIRMATION_BTN).click();
};

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([
'bulkEditAlertSuppressionEnabled',
])}`,
'--csp.strict=false',
'--csp.warnLegacyBrowsers=false',
],