Skip to content

Commit 87e7cd9

Browse files
authored
[Security Solution] Account for missing base rule versions in is_customized calculation (#213250)
**Partially addresses: #210358 ## Summary ### Editing of prebuilt rules with missing base versions **When the base version** of a currently installed prebuilt rule **is missing** among the `security-rule` asset saved objects, and the user edits this rule: - We should mark the rule as customized, only if the new rule settings are different from the current rule settings. - For example, adding a new tag should mark the rule as customized. Then, if the user removes this tag, the rule should remain to be marked as customized. This matches the current behavior. - However, if the user saves the rule without making any changes to it, it should keep its `is_customized` field as is. This is different from the current behavior. ### Importing of prebuilt rules with missing base versions **When the base version** of a prebuilt rule that is being imported **is missing** among the `security-rule` asset saved objects, and the user imports this rule: - If this rule is not installed, it should be created with `is_customized` field set to `false`. - If this rule is already installed, it should be updated. - Its `is_customized` field should be set to `true` if the rule from the import payload is not equal to the installed rule. - Its `is_customized` field should be be kept unchanged (`false` or `true`) if the rule from the import payload is equal to the installed rule.
1 parent a81e692 commit 87e7cd9

File tree

15 files changed

+310
-235
lines changed

15 files changed

+310
-235
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License
4+
* 2.0; you may not use this file except in compliance with the Elastic License
5+
* 2.0.
6+
*/
7+
8+
export const createPrebuiltRuleObjectsClient = () => {
9+
return {
10+
fetchInstalledRulesByIds: jest.fn(),
11+
fetchInstalledRules: jest.fn(),
12+
fetchInstalledRuleVersionsByIds: jest.fn(),
13+
fetchInstalledRuleVersions: jest.fn(),
14+
};
15+
};

x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/import_rules/route.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import {
4040
} from '../../../utils/utils';
4141
import { RULE_MANAGEMENT_IMPORT_EXPORT_SOCKET_TIMEOUT_MS } from '../../timeouts';
4242
import { PrebuiltRulesCustomizationDisabledReason } from '../../../../../../../common/detection_engine/prebuilt_rules/prebuilt_rule_customization_status';
43+
import { createPrebuiltRuleObjectsClient } from '../../../../prebuilt_rules/logic/rule_objects/prebuilt_rule_objects_client';
4344

4445
const CHUNK_PARSED_OBJECT_SIZE = 50;
4546

@@ -86,6 +87,7 @@ export const importRulesRoute = (router: SecuritySolutionPluginRouter, config: C
8687
'licensing',
8788
]);
8889

90+
const rulesClient = await ctx.alerting.getRulesClient();
8991
const detectionRulesClient = ctx.securitySolution.getDetectionRulesClient();
9092
const ruleCustomizationStatus = detectionRulesClient.getRuleCustomizationStatus();
9193
const actionsClient = ctx.actions.getActionsClient();
@@ -159,6 +161,7 @@ export const importRulesRoute = (router: SecuritySolutionPluginRouter, config: C
159161
config,
160162
context: ctx.securitySolution,
161163
prebuiltRuleAssetsClient: createPrebuiltRuleAssetsClient(savedObjectsClient),
164+
prebuiltRuleObjectsClient: createPrebuiltRuleObjectsClient(rulesClient),
162165
ruleCustomizationStatus: detectionRulesClient.getRuleCustomizationStatus(),
163166
});
164167

x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/logic/bulk_actions/bulk_edit_rules.ts

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -70,8 +70,8 @@ export const bulkEditRules = async ({
7070
const result = await rulesClient.bulkEdit<RuleParams>({
7171
ids: rules.map((rule) => rule.id),
7272
operations,
73-
paramsModifier: async (rule) => {
74-
const ruleParams = rule.params;
73+
paramsModifier: async (currentRule) => {
74+
const ruleParams = currentRule.params;
7575

7676
await validateBulkEditRule({
7777
mlAuthz,
@@ -85,23 +85,23 @@ export const bulkEditRules = async ({
8585
paramsActions
8686
);
8787

88-
// Update rule source
89-
const updatedRule = {
90-
...rule,
88+
const nextRule = convertAlertingRuleToRuleResponse({
89+
...currentRule,
9190
params: modifiedParams,
92-
};
93-
const ruleResponse = convertAlertingRuleToRuleResponse(updatedRule);
91+
});
92+
9493
let isCustomized = false;
95-
if (ruleResponse.immutable === true) {
94+
if (nextRule.immutable === true) {
9695
isCustomized = calculateIsCustomized({
97-
baseRule: baseVersionsMap.get(ruleResponse.rule_id),
98-
nextRule: ruleResponse,
96+
baseRule: baseVersionsMap.get(nextRule.rule_id),
97+
currentRule: convertAlertingRuleToRuleResponse(currentRule),
98+
nextRule,
9999
ruleCustomizationStatus,
100100
});
101101
}
102102

103103
const ruleSource =
104-
ruleResponse.immutable === true
104+
nextRule.immutable === true
105105
? {
106106
type: 'external' as const,
107107
isCustomized,

x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/mergers/apply_rule_patch.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,8 @@ export const applyRulePatch = async ({
123123
};
124124

125125
nextRule.rule_source = await calculateRuleSource({
126-
rule: nextRule,
126+
nextRule,
127+
currentRule: existingRule,
127128
prebuiltRuleAssetClient,
128129
ruleCustomizationStatus,
129130
});

x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/mergers/apply_rule_update.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,8 @@ export const applyRuleUpdate = async ({
4747
};
4848

4949
nextRule.rule_source = await calculateRuleSource({
50-
rule: nextRule,
50+
nextRule,
51+
currentRule: existingRule,
5152
prebuiltRuleAssetClient,
5253
ruleCustomizationStatus,
5354
});

x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/mergers/rule_source/calculate_is_customized.ts

Lines changed: 41 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,34 +19,68 @@ import {
1919
interface CalculateIsCustomizedArgs {
2020
baseRule: PrebuiltRuleAsset | undefined;
2121
nextRule: RuleResponse;
22+
// Current rule can be undefined in case of importing a prebuilt rule that is not installed
23+
currentRule: RuleResponse | undefined;
2224
ruleCustomizationStatus: PrebuiltRulesCustomizationStatus;
2325
}
2426

2527
export function calculateIsCustomized({
2628
baseRule,
2729
nextRule,
30+
currentRule,
2831
ruleCustomizationStatus,
2932
}: CalculateIsCustomizedArgs) {
3033
if (
3134
ruleCustomizationStatus.customizationDisabledReason ===
3235
PrebuiltRulesCustomizationDisabledReason.FeatureFlag
3336
) {
34-
// We don't want to accidentally mark rules as customized when customization is disabled.
37+
// We don't want to accidentally mark rules as customized when customization
38+
// is disabled.
3539
return false;
3640
}
3741

38-
if (baseRule == null) {
39-
// If the base version is missing, we consider the rule to be customized
42+
if (baseRule) {
43+
// Base version is available, so we can determine the customization status
44+
// by comparing the base version with the next version
45+
return areRulesEqual(convertPrebuiltRuleAssetToRuleResponse(baseRule), nextRule) === false;
46+
}
47+
// Base version is not available, apply a heuristic to determine the
48+
// customization status
49+
50+
if (currentRule == null) {
51+
// Current rule is not installed and base rule is not available, so we can't
52+
// determine if the rule is customized. Defaulting to false.
53+
return false;
54+
}
55+
56+
if (
57+
currentRule.rule_source.type === 'external' &&
58+
currentRule.rule_source.is_customized === true
59+
) {
60+
// If the rule was previously customized, there's no way to determine
61+
// whether the customization remained or was reverted. Keeping it as
62+
// customized in this case.
4063
return true;
4164
}
4265

43-
const baseRuleWithDefaults = convertPrebuiltRuleAssetToRuleResponse(baseRule);
66+
// If the rule has not been customized before, its customization status can be
67+
// determined by comparing the current version with the next version.
68+
return areRulesEqual(currentRule, nextRule) === false;
69+
}
4470

71+
/**
72+
* A helper function to determine if two rules are equal
73+
*
74+
* @param ruleA
75+
* @param ruleB
76+
* @returns true if all rule fields are equal, false otherwise
77+
*/
78+
function areRulesEqual(ruleA: RuleResponse, ruleB: RuleResponse) {
4579
const fieldsDiff = calculateRuleFieldsDiff({
4680
base_version: MissingVersion,
47-
current_version: convertRuleToDiffable(baseRuleWithDefaults),
48-
target_version: convertRuleToDiffable(nextRule),
81+
current_version: convertRuleToDiffable(ruleA),
82+
target_version: convertRuleToDiffable(ruleB),
4983
});
5084

51-
return Object.values(fieldsDiff).some((diff) => diff.has_update);
85+
return Object.values(fieldsDiff).every((diff) => diff.has_update === false);
5286
}

x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/mergers/rule_source/calculate_rule_source.test.ts

Lines changed: 115 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,8 @@ describe('calculateRuleSource', () => {
4848

4949
const result = await calculateRuleSource({
5050
prebuiltRuleAssetClient,
51-
rule,
51+
nextRule: rule,
52+
currentRule: undefined,
5253
ruleCustomizationStatus,
5354
});
5455
expect(result).toEqual({
@@ -65,7 +66,8 @@ describe('calculateRuleSource', () => {
6566

6667
const result = await calculateRuleSource({
6768
prebuiltRuleAssetClient,
68-
rule,
69+
nextRule: rule,
70+
currentRule: rule,
6971
ruleCustomizationStatus,
7072
});
7173
expect(result).toEqual(
@@ -86,7 +88,8 @@ describe('calculateRuleSource', () => {
8688

8789
const result = await calculateRuleSource({
8890
prebuiltRuleAssetClient,
89-
rule,
91+
nextRule: rule,
92+
currentRule: rule,
9093
ruleCustomizationStatus,
9194
});
9295
expect(result).toEqual(
@@ -109,7 +112,8 @@ describe('calculateRuleSource', () => {
109112

110113
const result = await calculateRuleSource({
111114
prebuiltRuleAssetClient,
112-
rule,
115+
nextRule: rule,
116+
currentRule: rule,
113117
ruleCustomizationStatus,
114118
});
115119
expect(result).toEqual(
@@ -130,7 +134,8 @@ describe('calculateRuleSource', () => {
130134

131135
const result = await calculateRuleSource({
132136
prebuiltRuleAssetClient,
133-
rule,
137+
nextRule: rule,
138+
currentRule: rule,
134139
ruleCustomizationStatus: {
135140
isRulesCustomizationEnabled: false,
136141
customizationDisabledReason: PrebuiltRulesCustomizationDisabledReason.FeatureFlag,
@@ -154,7 +159,8 @@ describe('calculateRuleSource', () => {
154159

155160
const result = await calculateRuleSource({
156161
prebuiltRuleAssetClient,
157-
rule,
162+
nextRule: rule,
163+
currentRule: rule,
158164
ruleCustomizationStatus: {
159165
isRulesCustomizationEnabled: false,
160166
customizationDisabledReason: PrebuiltRulesCustomizationDisabledReason.License,
@@ -167,4 +173,107 @@ describe('calculateRuleSource', () => {
167173
})
168174
);
169175
});
176+
177+
describe('missing base versions', () => {
178+
it('return is_customized false when the base version and current version are missing', async () => {
179+
const rule = getSampleRule();
180+
rule.immutable = true;
181+
182+
// No base version
183+
prebuiltRuleAssetClient.fetchAssetsByVersion.mockResolvedValueOnce([]);
184+
185+
const result = await calculateRuleSource({
186+
prebuiltRuleAssetClient,
187+
nextRule: rule,
188+
currentRule: undefined,
189+
ruleCustomizationStatus,
190+
});
191+
expect(result).toEqual(
192+
expect.objectContaining({
193+
type: 'external',
194+
is_customized: false,
195+
})
196+
);
197+
});
198+
199+
it('returns is_customized true when the current version is already customized', async () => {
200+
const rule = getSampleRule();
201+
rule.immutable = true;
202+
rule.rule_source = {
203+
type: 'external',
204+
is_customized: true,
205+
};
206+
207+
// No base version
208+
prebuiltRuleAssetClient.fetchAssetsByVersion.mockResolvedValueOnce([]);
209+
210+
const result = await calculateRuleSource({
211+
prebuiltRuleAssetClient,
212+
nextRule: rule,
213+
currentRule: rule,
214+
ruleCustomizationStatus,
215+
});
216+
expect(result).toEqual(
217+
expect.objectContaining({
218+
type: 'external',
219+
is_customized: true,
220+
})
221+
);
222+
});
223+
224+
it('returns is_customized false when the current version is not customized and the next version has no changes', async () => {
225+
const rule = getSampleRule();
226+
rule.immutable = true;
227+
rule.rule_source = {
228+
type: 'external',
229+
is_customized: false,
230+
};
231+
232+
// No base version
233+
prebuiltRuleAssetClient.fetchAssetsByVersion.mockResolvedValueOnce([]);
234+
235+
const result = await calculateRuleSource({
236+
prebuiltRuleAssetClient,
237+
nextRule: rule,
238+
currentRule: rule,
239+
ruleCustomizationStatus,
240+
});
241+
expect(result).toEqual(
242+
expect.objectContaining({
243+
type: 'external',
244+
is_customized: false,
245+
})
246+
);
247+
});
248+
249+
it('returns is_customized true when the current version is not customized and the next version has changes', async () => {
250+
const rule = getSampleRule();
251+
rule.immutable = true;
252+
rule.rule_source = {
253+
type: 'external',
254+
is_customized: false,
255+
};
256+
257+
const nextRule = {
258+
...rule,
259+
name: 'Updated name',
260+
};
261+
262+
// No base version
263+
prebuiltRuleAssetClient.fetchAssetsByVersion.mockResolvedValueOnce([]);
264+
265+
const result = await calculateRuleSource({
266+
prebuiltRuleAssetClient,
267+
nextRule,
268+
currentRule: rule,
269+
ruleCustomizationStatus,
270+
});
271+
expect(result).toEqual(
272+
expect.objectContaining({
273+
type: 'external',
274+
is_customized: true,
275+
})
276+
);
277+
});
278+
});
170279
});

x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/logic/detection_rules_client/mergers/rule_source/calculate_rule_source.ts

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,29 +16,32 @@ import { calculateIsCustomized } from './calculate_is_customized';
1616

1717
interface CalculateRuleSourceProps {
1818
prebuiltRuleAssetClient: IPrebuiltRuleAssetsClient;
19-
rule: RuleResponse;
19+
nextRule: RuleResponse;
20+
currentRule: RuleResponse | undefined;
2021
ruleCustomizationStatus: PrebuiltRulesCustomizationStatus;
2122
}
2223

2324
export async function calculateRuleSource({
2425
prebuiltRuleAssetClient,
25-
rule,
26+
nextRule,
27+
currentRule,
2628
ruleCustomizationStatus,
2729
}: CalculateRuleSourceProps): Promise<RuleSource> {
28-
if (rule.immutable) {
30+
if (nextRule.immutable) {
2931
// This is a prebuilt rule and, despite the name, they are not immutable. So
3032
// we need to recalculate `ruleSource.isCustomized` based on the rule's contents.
3133
const prebuiltRulesResponse = await prebuiltRuleAssetClient.fetchAssetsByVersion([
3234
{
33-
rule_id: rule.rule_id,
34-
version: rule.version,
35+
rule_id: nextRule.rule_id,
36+
version: nextRule.version,
3537
},
3638
]);
3739
const baseRule: PrebuiltRuleAsset | undefined = prebuiltRulesResponse.at(0);
3840

3941
const isCustomized = calculateIsCustomized({
4042
baseRule,
41-
nextRule: rule,
43+
nextRule,
44+
currentRule,
4245
ruleCustomizationStatus,
4346
});
4447

0 commit comments

Comments
 (0)