Skip to content

Commit

Permalink
[Security Solution][Detections] Fixes UI for bulk applying timeline t…
Browse files Browse the repository at this point in the history
…emplate (#129491)

**Addresses:** #129294, #93083, elastic/security-team#2078 (internal)
**Related to:** #128691

## Summary

Summarize your PR. If it involves visual changes include a screenshot or gif.

- [x] Fix bulk resetting timeline template to **None**
- [x] Fix UI copies
- [ ] Add tests
  • Loading branch information
banderror authored Apr 13, 2022
1 parent bbbb8ea commit 62c049b
Show file tree
Hide file tree
Showing 4 changed files with 154 additions and 21 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -13,22 +13,22 @@ export const bulkApplyTimelineTemplate = {
FORM_TITLE: i18n.translate(
'xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.edit.applyTimelineTemplate.formTitle',
{
defaultMessage: 'Apply timeline template',
defaultMessage: 'Apply Timeline template',
}
),

TEMPLATE_SELECTOR_LABEL: i18n.translate(
'xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.edit.applyTimelineTemplate.templateSelectorLabel',
{
defaultMessage: 'Apply timeline template to selected rules',
defaultMessage: 'Apply Timeline template to selected rules',
}
),

TEMPLATE_SELECTOR_HELP_TEXT: i18n.translate(
'xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.edit.applyTimelineTemplate.templateSelectorHelpText',
{
defaultMessage:
'Select which timeline to apply to selected rules when investigating generated alerts.',
'Select which Timeline to apply to selected rules when investigating generated alerts.',
}
),

Expand All @@ -42,8 +42,8 @@ export const bulkApplyTimelineTemplate = {
warningCalloutMessage: (rulesCount: number): JSX.Element => (
<FormattedMessage
id="xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.edit.applyTimelineTemplate.warningCalloutMessage"
defaultMessage="You are about to apply changes to {rulesCount, plural, one {# selected rule} other {# selected rules}}.
If you already applied any templates to these rules, they will be overwritten or (if you select 'None') reset to none."
defaultMessage="You're about to apply changes to {rulesCount, plural, one {# selected rule} other {# selected rules}}.
If you previously applied Timeline templates to these rules, they will be overwritten or (if you select 'None') reset to none."
values={{ rulesCount }}
/>
),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,7 @@ export const BULK_ACTION_DELETE_TAGS = i18n.translate(
export const BULK_ACTION_APPLY_TIMELINE_TEMPLATE = i18n.translate(
'xpack.securitySolution.detectionEngine.rules.allRules.bulkActions.applyTimelineTemplateTitle',
{
defaultMessage: 'Apply timeline template',
defaultMessage: 'Apply Timeline template',
}
);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ import { deleteRules } from '../../rules/delete_rules';
import { duplicateRule } from '../../rules/duplicate_rule';
import { findRules } from '../../rules/find_rules';
import { readRules } from '../../rules/read_rules';
import { patchRules } from '../../rules/patch_rules';
import { editRule } from '../../rules/edit_rule';
import { applyBulkActionEditToRule } from '../../rules/bulk_action_edit';
import { getExportByObjectIds } from '../../rules/get_export_by_object_ids';
import { buildSiemResponse } from '../utils';
Expand Down Expand Up @@ -424,24 +424,18 @@ export const performBulkActionRoute = (
rule,
});

const editedRule = body[BulkAction.edit].reduce(
(acc, action) => applyBulkActionEditToRule(acc, action),
migratedRule
);

const { tags, params: { timelineTitle, timelineId } = {} } = editedRule;
const index = 'index' in editedRule.params ? editedRule.params.index : undefined;

await patchRules({
const updatedRule = await editRule({
rulesClient,
rule: migratedRule,
tags,
index,
timelineTitle,
timelineId,
edit: (ruleToEdit) => {
return body[BulkAction.edit].reduce(
(acc, action) => applyBulkActionEditToRule(acc, action),
ruleToEdit
);
},
});

return editedRule;
return updatedRule;
},
abortSignal: abortController.signal,
});
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

import { cloneDeep, isEqual, pick } from 'lodash';
import { validate } from '@kbn/securitysolution-io-ts-utils';

import type { RulesClient } from '../../../../../alerting/server';
import { RuleAlertType } from '../rules/types';
import { InternalRuleUpdate, internalRuleUpdate } from '../schemas/rule_schemas';
import { addTags } from './add_tags';

class EditRuleError extends Error {
public readonly statusCode: number;
constructor(message: string, statusCode: number) {
super(message);
this.statusCode = statusCode;
}
}

interface EditRuleParams {
/** An instance of RulesClient from the Alerting Framework. */
rulesClient: RulesClient;

/** Original, existing rule to be edited. Needs to be fetched from Elasticsearch via RulesClient. */
rule: RuleAlertType;

/** A function that implements in-memory modifications: returns a new rule object with the changes. */
edit: (rule: RuleAlertType) => RuleAlertType;
}

/** At this point we support editing of only these fields. */
const FIELDS_THAT_CAN_BE_EDITED = ['params', 'tags'] as const;

/**
* Applies in-memory modifications to a given rule and updates it in Elasticsearch via RulesClient.
*
* NOTE: At this point we only support editing of the following fields:
* - rule.params
* - rule.tags
* All other changes made by the `edit` function will be ignored.
*
* @returns The edited rule.
*/
export const editRule = async (params: EditRuleParams): Promise<RuleAlertType> => {
const { rulesClient, rule, edit } = params;
const isPrebuiltRule = rule.params.immutable;
const isCustomRule = !rule.params.immutable;

if (isPrebuiltRule) {
throw new EditRuleError('Elastic rule can`t be edited', 400);
}

const editedRule = applyChanges(rule, edit);

// If the rule wasn't changed by the `edit` function, we don't need to proceed with the update.
if (!isRuleChanged(rule, editedRule)) {
return rule;
}

// We need to increment the rule's version if it is a custom rule. If the rule is an Elastic
// prebuilt rule, we don't want to touch its version - it's managed by the rule authors.
// This check is left here explicitly because we're planning to allow editing for prebuilt rules,
// and the check for isPrebuiltRule above might be removed.
if (isCustomRule) {
editedRule.params.version = editedRule.params.version + 1;
}

const updateData = createUpdateData(rule, editedRule);
await rulesClient.update({
id: rule.id,
data: updateData,
});

// It would be great to return the updated rule returned from the RulesClient.update() call.
// Note that there's a type mismatch between RuleAlertType and the update method result.
return editedRule;
};

const applyChanges = (
originalRule: RuleAlertType,
edit: (rule: RuleAlertType) => RuleAlertType
): RuleAlertType => {
// For safety, deeply clone the rule object before applying edits to it.
const clonedRule = cloneDeep(originalRule);
const editedRule = edit(clonedRule);
const sanitizedRule = validateAndSanitizeChanges(originalRule, editedRule);
return sanitizedRule;
};

const validateAndSanitizeChanges = (
original: RuleAlertType,
changed: RuleAlertType
): RuleAlertType => {
// These checks should never throw unless there's a bug in the passed `edit` function.
if (changed.params.immutable !== original.params.immutable) {
throw new EditRuleError(`Internal rule editing error: can't change "params.immutable"`, 500);
}
if (changed.params.version !== original.params.version) {
throw new EditRuleError(`Internal rule editing error: can't change "params.version"`, 500);
}

return {
...changed,
tags: addTags(changed.tags, changed.params.ruleId, changed.params.immutable),
};
};

const isRuleChanged = (originalRule: RuleAlertType, editedRule: RuleAlertType): boolean => {
const originalData = pick(originalRule, FIELDS_THAT_CAN_BE_EDITED);
const editedData = pick(editedRule, FIELDS_THAT_CAN_BE_EDITED);
return !isEqual(originalData, editedData);
};

const createUpdateData = (
originalRule: RuleAlertType,
editedRule: RuleAlertType
): InternalRuleUpdate => {
const data: InternalRuleUpdate = {
// At this point we intentionally support updating of only these fields:
...pick(editedRule, FIELDS_THAT_CAN_BE_EDITED),
// We omit other fields and get them from the original (unedited) rule:
name: originalRule.name,
schedule: originalRule.schedule,
actions: originalRule.actions,
throttle: originalRule.throttle,
notifyWhen: originalRule.notifyWhen,
};

const [validatedData, validationError] = validate(data, internalRuleUpdate);
if (validationError != null || validatedData === null) {
throw new EditRuleError(`Editing rule would create invalid rule: ${validationError}`, 500);
}

return validatedData;
};

0 comments on commit 62c049b

Please sign in to comment.