[Security Solution] Account for missing base rule versions in is_customized calculation (#213250)

**Partially addresses: https://github.com/elastic/kibana/issues/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.
This commit is contained in:
Dmitrii Shevchenko 2025-03-06 19:22:17 +01:00 committed by GitHub
parent a81e692556
commit 87e7cd94d1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 310 additions and 235 deletions

View file

@ -0,0 +1,15 @@
/*
* 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.
*/
export const createPrebuiltRuleObjectsClient = () => {
return {
fetchInstalledRulesByIds: jest.fn(),
fetchInstalledRules: jest.fn(),
fetchInstalledRuleVersionsByIds: jest.fn(),
fetchInstalledRuleVersions: jest.fn(),
};
};

View file

@ -40,6 +40,7 @@ import {
} from '../../../utils/utils';
import { RULE_MANAGEMENT_IMPORT_EXPORT_SOCKET_TIMEOUT_MS } from '../../timeouts';
import { PrebuiltRulesCustomizationDisabledReason } from '../../../../../../../common/detection_engine/prebuilt_rules/prebuilt_rule_customization_status';
import { createPrebuiltRuleObjectsClient } from '../../../../prebuilt_rules/logic/rule_objects/prebuilt_rule_objects_client';
const CHUNK_PARSED_OBJECT_SIZE = 50;
@ -86,6 +87,7 @@ export const importRulesRoute = (router: SecuritySolutionPluginRouter, config: C
'licensing',
]);
const rulesClient = await ctx.alerting.getRulesClient();
const detectionRulesClient = ctx.securitySolution.getDetectionRulesClient();
const ruleCustomizationStatus = detectionRulesClient.getRuleCustomizationStatus();
const actionsClient = ctx.actions.getActionsClient();
@ -159,6 +161,7 @@ export const importRulesRoute = (router: SecuritySolutionPluginRouter, config: C
config,
context: ctx.securitySolution,
prebuiltRuleAssetsClient: createPrebuiltRuleAssetsClient(savedObjectsClient),
prebuiltRuleObjectsClient: createPrebuiltRuleObjectsClient(rulesClient),
ruleCustomizationStatus: detectionRulesClient.getRuleCustomizationStatus(),
});

View file

@ -70,8 +70,8 @@ export const bulkEditRules = async ({
const result = await rulesClient.bulkEdit<RuleParams>({
ids: rules.map((rule) => rule.id),
operations,
paramsModifier: async (rule) => {
const ruleParams = rule.params;
paramsModifier: async (currentRule) => {
const ruleParams = currentRule.params;
await validateBulkEditRule({
mlAuthz,
@ -85,23 +85,23 @@ export const bulkEditRules = async ({
paramsActions
);
// Update rule source
const updatedRule = {
...rule,
const nextRule = convertAlertingRuleToRuleResponse({
...currentRule,
params: modifiedParams,
};
const ruleResponse = convertAlertingRuleToRuleResponse(updatedRule);
});
let isCustomized = false;
if (ruleResponse.immutable === true) {
if (nextRule.immutable === true) {
isCustomized = calculateIsCustomized({
baseRule: baseVersionsMap.get(ruleResponse.rule_id),
nextRule: ruleResponse,
baseRule: baseVersionsMap.get(nextRule.rule_id),
currentRule: convertAlertingRuleToRuleResponse(currentRule),
nextRule,
ruleCustomizationStatus,
});
}
const ruleSource =
ruleResponse.immutable === true
nextRule.immutable === true
? {
type: 'external' as const,
isCustomized,

View file

@ -123,7 +123,8 @@ export const applyRulePatch = async ({
};
nextRule.rule_source = await calculateRuleSource({
rule: nextRule,
nextRule,
currentRule: existingRule,
prebuiltRuleAssetClient,
ruleCustomizationStatus,
});

View file

@ -47,7 +47,8 @@ export const applyRuleUpdate = async ({
};
nextRule.rule_source = await calculateRuleSource({
rule: nextRule,
nextRule,
currentRule: existingRule,
prebuiltRuleAssetClient,
ruleCustomizationStatus,
});

View file

@ -19,34 +19,68 @@ import {
interface CalculateIsCustomizedArgs {
baseRule: PrebuiltRuleAsset | undefined;
nextRule: RuleResponse;
// Current rule can be undefined in case of importing a prebuilt rule that is not installed
currentRule: RuleResponse | undefined;
ruleCustomizationStatus: PrebuiltRulesCustomizationStatus;
}
export function calculateIsCustomized({
baseRule,
nextRule,
currentRule,
ruleCustomizationStatus,
}: CalculateIsCustomizedArgs) {
if (
ruleCustomizationStatus.customizationDisabledReason ===
PrebuiltRulesCustomizationDisabledReason.FeatureFlag
) {
// We don't want to accidentally mark rules as customized when customization is disabled.
// We don't want to accidentally mark rules as customized when customization
// is disabled.
return false;
}
if (baseRule == null) {
// If the base version is missing, we consider the rule to be customized
if (baseRule) {
// Base version is available, so we can determine the customization status
// by comparing the base version with the next version
return areRulesEqual(convertPrebuiltRuleAssetToRuleResponse(baseRule), nextRule) === false;
}
// Base version is not available, apply a heuristic to determine the
// customization status
if (currentRule == null) {
// Current rule is not installed and base rule is not available, so we can't
// determine if the rule is customized. Defaulting to false.
return false;
}
if (
currentRule.rule_source.type === 'external' &&
currentRule.rule_source.is_customized === true
) {
// If the rule was previously customized, there's no way to determine
// whether the customization remained or was reverted. Keeping it as
// customized in this case.
return true;
}
const baseRuleWithDefaults = convertPrebuiltRuleAssetToRuleResponse(baseRule);
// If the rule has not been customized before, its customization status can be
// determined by comparing the current version with the next version.
return areRulesEqual(currentRule, nextRule) === false;
}
/**
* A helper function to determine if two rules are equal
*
* @param ruleA
* @param ruleB
* @returns true if all rule fields are equal, false otherwise
*/
function areRulesEqual(ruleA: RuleResponse, ruleB: RuleResponse) {
const fieldsDiff = calculateRuleFieldsDiff({
base_version: MissingVersion,
current_version: convertRuleToDiffable(baseRuleWithDefaults),
target_version: convertRuleToDiffable(nextRule),
current_version: convertRuleToDiffable(ruleA),
target_version: convertRuleToDiffable(ruleB),
});
return Object.values(fieldsDiff).some((diff) => diff.has_update);
return Object.values(fieldsDiff).every((diff) => diff.has_update === false);
}

View file

@ -48,7 +48,8 @@ describe('calculateRuleSource', () => {
const result = await calculateRuleSource({
prebuiltRuleAssetClient,
rule,
nextRule: rule,
currentRule: undefined,
ruleCustomizationStatus,
});
expect(result).toEqual({
@ -65,7 +66,8 @@ describe('calculateRuleSource', () => {
const result = await calculateRuleSource({
prebuiltRuleAssetClient,
rule,
nextRule: rule,
currentRule: rule,
ruleCustomizationStatus,
});
expect(result).toEqual(
@ -86,7 +88,8 @@ describe('calculateRuleSource', () => {
const result = await calculateRuleSource({
prebuiltRuleAssetClient,
rule,
nextRule: rule,
currentRule: rule,
ruleCustomizationStatus,
});
expect(result).toEqual(
@ -109,7 +112,8 @@ describe('calculateRuleSource', () => {
const result = await calculateRuleSource({
prebuiltRuleAssetClient,
rule,
nextRule: rule,
currentRule: rule,
ruleCustomizationStatus,
});
expect(result).toEqual(
@ -130,7 +134,8 @@ describe('calculateRuleSource', () => {
const result = await calculateRuleSource({
prebuiltRuleAssetClient,
rule,
nextRule: rule,
currentRule: rule,
ruleCustomizationStatus: {
isRulesCustomizationEnabled: false,
customizationDisabledReason: PrebuiltRulesCustomizationDisabledReason.FeatureFlag,
@ -154,7 +159,8 @@ describe('calculateRuleSource', () => {
const result = await calculateRuleSource({
prebuiltRuleAssetClient,
rule,
nextRule: rule,
currentRule: rule,
ruleCustomizationStatus: {
isRulesCustomizationEnabled: false,
customizationDisabledReason: PrebuiltRulesCustomizationDisabledReason.License,
@ -167,4 +173,107 @@ describe('calculateRuleSource', () => {
})
);
});
describe('missing base versions', () => {
it('return is_customized false when the base version and current version are missing', async () => {
const rule = getSampleRule();
rule.immutable = true;
// No base version
prebuiltRuleAssetClient.fetchAssetsByVersion.mockResolvedValueOnce([]);
const result = await calculateRuleSource({
prebuiltRuleAssetClient,
nextRule: rule,
currentRule: undefined,
ruleCustomizationStatus,
});
expect(result).toEqual(
expect.objectContaining({
type: 'external',
is_customized: false,
})
);
});
it('returns is_customized true when the current version is already customized', async () => {
const rule = getSampleRule();
rule.immutable = true;
rule.rule_source = {
type: 'external',
is_customized: true,
};
// No base version
prebuiltRuleAssetClient.fetchAssetsByVersion.mockResolvedValueOnce([]);
const result = await calculateRuleSource({
prebuiltRuleAssetClient,
nextRule: rule,
currentRule: rule,
ruleCustomizationStatus,
});
expect(result).toEqual(
expect.objectContaining({
type: 'external',
is_customized: true,
})
);
});
it('returns is_customized false when the current version is not customized and the next version has no changes', async () => {
const rule = getSampleRule();
rule.immutable = true;
rule.rule_source = {
type: 'external',
is_customized: false,
};
// No base version
prebuiltRuleAssetClient.fetchAssetsByVersion.mockResolvedValueOnce([]);
const result = await calculateRuleSource({
prebuiltRuleAssetClient,
nextRule: rule,
currentRule: rule,
ruleCustomizationStatus,
});
expect(result).toEqual(
expect.objectContaining({
type: 'external',
is_customized: false,
})
);
});
it('returns is_customized true when the current version is not customized and the next version has changes', async () => {
const rule = getSampleRule();
rule.immutable = true;
rule.rule_source = {
type: 'external',
is_customized: false,
};
const nextRule = {
...rule,
name: 'Updated name',
};
// No base version
prebuiltRuleAssetClient.fetchAssetsByVersion.mockResolvedValueOnce([]);
const result = await calculateRuleSource({
prebuiltRuleAssetClient,
nextRule,
currentRule: rule,
ruleCustomizationStatus,
});
expect(result).toEqual(
expect.objectContaining({
type: 'external',
is_customized: true,
})
);
});
});
});

View file

@ -16,29 +16,32 @@ import { calculateIsCustomized } from './calculate_is_customized';
interface CalculateRuleSourceProps {
prebuiltRuleAssetClient: IPrebuiltRuleAssetsClient;
rule: RuleResponse;
nextRule: RuleResponse;
currentRule: RuleResponse | undefined;
ruleCustomizationStatus: PrebuiltRulesCustomizationStatus;
}
export async function calculateRuleSource({
prebuiltRuleAssetClient,
rule,
nextRule,
currentRule,
ruleCustomizationStatus,
}: CalculateRuleSourceProps): Promise<RuleSource> {
if (rule.immutable) {
if (nextRule.immutable) {
// This is a prebuilt rule and, despite the name, they are not immutable. So
// we need to recalculate `ruleSource.isCustomized` based on the rule's contents.
const prebuiltRulesResponse = await prebuiltRuleAssetClient.fetchAssetsByVersion([
{
rule_id: rule.rule_id,
version: rule.version,
rule_id: nextRule.rule_id,
version: nextRule.version,
},
]);
const baseRule: PrebuiltRuleAsset | undefined = prebuiltRulesResponse.at(0);
const isCustomized = calculateIsCustomized({
baseRule,
nextRule: rule,
nextRule,
currentRule,
ruleCustomizationStatus,
});

View file

@ -17,7 +17,8 @@ const ruleCustomizationStatus: PrebuiltRulesCustomizationStatus = {
describe('calculateRuleSourceForImport', () => {
it('calculates as internal if no asset is found', () => {
const result = calculateRuleSourceForImport({
rule: getRulesSchemaMock(),
importedRule: getRulesSchemaMock(),
currentRule: undefined,
prebuiltRuleAssetsByRuleId: {},
isKnownPrebuiltRule: false,
ruleCustomizationStatus,
@ -31,12 +32,58 @@ describe('calculateRuleSourceForImport', () => {
});
});
it('calculates as modified external type if an asset is found without a matching version', () => {
it('calculates as not modified external type if an asset is found without a matching version and no current rule present', () => {
const rule = getRulesSchemaMock();
rule.rule_id = 'rule_id';
const result = calculateRuleSourceForImport({
rule,
importedRule: rule,
currentRule: undefined,
prebuiltRuleAssetsByRuleId: {},
isKnownPrebuiltRule: true,
ruleCustomizationStatus,
});
expect(result).toEqual({
ruleSource: {
type: 'external',
is_customized: false,
},
immutable: true,
});
});
it('calculates as non modified external type if an asset is found without a matching version and current rule present without changes', () => {
const rule = getRulesSchemaMock();
rule.rule_id = 'rule_id';
const result = calculateRuleSourceForImport({
importedRule: rule,
currentRule: rule,
prebuiltRuleAssetsByRuleId: {},
isKnownPrebuiltRule: true,
ruleCustomizationStatus,
});
expect(result).toEqual({
ruleSource: {
type: 'external',
is_customized: false,
},
immutable: true,
});
});
it('calculates as modified external type if an asset is found without a matching version and current rule present with changes', () => {
const rule = getRulesSchemaMock();
rule.rule_id = 'rule_id';
const result = calculateRuleSourceForImport({
importedRule: rule,
currentRule: {
...rule,
name: 'new name',
},
prebuiltRuleAssetsByRuleId: {},
isKnownPrebuiltRule: true,
ruleCustomizationStatus,
@ -57,7 +104,8 @@ describe('calculateRuleSourceForImport', () => {
const prebuiltRuleAssetsByRuleId = { rule_id: getPrebuiltRuleMock({ rule_id: 'rule_id' }) };
const result = calculateRuleSourceForImport({
rule,
importedRule: rule,
currentRule: undefined,
prebuiltRuleAssetsByRuleId,
isKnownPrebuiltRule: true,
ruleCustomizationStatus,
@ -78,7 +126,8 @@ describe('calculateRuleSourceForImport', () => {
const prebuiltRuleAssetsByRuleId = { rule_id: getPrebuiltRuleMock(rule) };
const result = calculateRuleSourceForImport({
rule,
importedRule: rule,
currentRule: undefined,
prebuiltRuleAssetsByRuleId,
isKnownPrebuiltRule: true,
ruleCustomizationStatus,

View file

@ -6,12 +6,13 @@
*/
import type {
RuleResponse,
RuleSource,
ValidatedRuleToImport,
} from '../../../../../../common/api/detection_engine';
import type { PrebuiltRulesCustomizationStatus } from '../../../../../../common/detection_engine/prebuilt_rules/prebuilt_rule_customization_status';
import type { PrebuiltRuleAsset } from '../../../prebuilt_rules';
import { calculateRuleSourceFromAsset } from './calculate_rule_source_from_asset';
import { calculateIsCustomized } from '../detection_rules_client/mergers/rule_source/calculate_is_customized';
import { convertRuleToImportToRuleResponse } from './converters/convert_rule_to_import_to_rule_response';
/**
@ -26,31 +27,44 @@ import { convertRuleToImportToRuleResponse } from './converters/convert_rule_to_
* @returns The calculated rule_source and immutable fields for the rule
*/
export const calculateRuleSourceForImport = ({
rule,
importedRule,
currentRule,
prebuiltRuleAssetsByRuleId,
isKnownPrebuiltRule,
ruleCustomizationStatus,
}: {
rule: ValidatedRuleToImport;
importedRule: ValidatedRuleToImport;
currentRule: RuleResponse | undefined;
prebuiltRuleAssetsByRuleId: Record<string, PrebuiltRuleAsset>;
isKnownPrebuiltRule: boolean;
ruleCustomizationStatus: PrebuiltRulesCustomizationStatus;
}): { ruleSource: RuleSource; immutable: boolean } => {
const assetWithMatchingVersion = prebuiltRuleAssetsByRuleId[rule.rule_id];
if (!isKnownPrebuiltRule) {
return {
ruleSource: { type: 'internal' },
immutable: false,
};
}
const baseRule = prebuiltRuleAssetsByRuleId[importedRule.rule_id];
// We convert here so that RuleSource calculation can
// continue to deal only with RuleResponses. The fields missing from the
// incoming rule are not actually needed for the calculation, but only to
// satisfy the type system.
const ruleResponseForImport = convertRuleToImportToRuleResponse(rule);
const ruleSource = calculateRuleSourceFromAsset({
rule: ruleResponseForImport,
assetWithMatchingVersion,
isKnownPrebuiltRule,
const nextRule = convertRuleToImportToRuleResponse(importedRule);
const isCustomized = calculateIsCustomized({
baseRule,
nextRule,
currentRule,
ruleCustomizationStatus,
});
return {
ruleSource,
immutable: ruleSource.type === 'external',
ruleSource: {
type: 'external',
is_customized: isCustomized,
},
immutable: true,
};
};

View file

@ -1,85 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { calculateRuleSourceFromAsset } from './calculate_rule_source_from_asset';
import { getRulesSchemaMock } from '../../../../../../common/api/detection_engine/model/rule_schema/mocks';
import { getPrebuiltRuleMock } from '../../../prebuilt_rules/mocks';
import type { PrebuiltRulesCustomizationStatus } from '../../../../../../common/detection_engine/prebuilt_rules/prebuilt_rule_customization_status';
const ruleCustomizationStatus: PrebuiltRulesCustomizationStatus = {
isRulesCustomizationEnabled: true,
};
describe('calculateRuleSourceFromAsset', () => {
it('calculates as internal if no asset is found', () => {
const result = calculateRuleSourceFromAsset({
rule: getRulesSchemaMock(),
assetWithMatchingVersion: undefined,
isKnownPrebuiltRule: false,
ruleCustomizationStatus,
});
expect(result).toEqual({
type: 'internal',
});
});
it('calculates as customized external type if an asset is found matching rule_id but not version', () => {
const ruleToImport = getRulesSchemaMock();
const result = calculateRuleSourceFromAsset({
rule: ruleToImport,
assetWithMatchingVersion: undefined,
isKnownPrebuiltRule: true,
ruleCustomizationStatus,
});
expect(result).toEqual({
type: 'external',
is_customized: true,
});
});
describe('matching rule_id and version is found', () => {
it('calculates as customized external type if the imported rule has all fields unchanged from the asset', () => {
const ruleToImport = getRulesSchemaMock();
const result = calculateRuleSourceFromAsset({
rule: getRulesSchemaMock(), // version 1
assetWithMatchingVersion: getPrebuiltRuleMock({
...ruleToImport,
version: 1, // version 1 (same version as imported rule)
// no other overwrites -> no differences
}),
isKnownPrebuiltRule: true,
ruleCustomizationStatus,
});
expect(result).toEqual({
type: 'external',
is_customized: false,
});
});
it('calculates as non-customized external type the imported rule has fields which differ from the asset', () => {
const ruleToImport = getRulesSchemaMock();
const result = calculateRuleSourceFromAsset({
rule: getRulesSchemaMock(), // version 1
assetWithMatchingVersion: getPrebuiltRuleMock({
...ruleToImport,
version: 1, // version 1 (same version as imported rule)
name: 'Customized name', // mock a customization
}),
isKnownPrebuiltRule: true,
ruleCustomizationStatus,
});
expect(result).toEqual({
type: 'external',
is_customized: true,
});
});
});
});

View file

@ -1,58 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { RuleResponse, RuleSource } from '../../../../../../common/api/detection_engine';
import type { PrebuiltRulesCustomizationStatus } from '../../../../../../common/detection_engine/prebuilt_rules/prebuilt_rule_customization_status';
import type { PrebuiltRuleAsset } from '../../../prebuilt_rules';
import { calculateIsCustomized } from '../detection_rules_client/mergers/rule_source/calculate_is_customized';
/**
* Calculates rule_source for a rule based on two pieces of information:
* 1. The prebuilt rule asset that matches the specified rule_id and version
* 2. Whether a prebuilt rule with the specified rule_id is currently installed
*
* @param rule The rule for which rule_source is being calculated
* @param assetWithMatchingVersion The prebuilt rule asset that matches the specified rule_id and version
* @param isKnownPrebuiltRule Whether a prebuilt rule with the specified rule_id is currently installed
*
* @returns The calculated rule_source
*/
export const calculateRuleSourceFromAsset = ({
rule,
assetWithMatchingVersion,
isKnownPrebuiltRule,
ruleCustomizationStatus,
}: {
rule: RuleResponse;
assetWithMatchingVersion: PrebuiltRuleAsset | undefined;
isKnownPrebuiltRule: boolean;
ruleCustomizationStatus: PrebuiltRulesCustomizationStatus;
}): RuleSource => {
if (!isKnownPrebuiltRule) {
return {
type: 'internal',
};
}
if (assetWithMatchingVersion == null) {
return {
type: 'external',
is_customized: ruleCustomizationStatus.isRulesCustomizationEnabled ? true : false,
};
}
const isCustomized = calculateIsCustomized({
baseRule: assetWithMatchingVersion,
nextRule: rule,
ruleCustomizationStatus,
});
return {
type: 'external',
is_customized: isCustomized,
};
};

View file

@ -10,13 +10,14 @@ import type {
ValidatedRuleToImport,
} from '../../../../../../../common/api/detection_engine';
import { createPrebuiltRuleAssetsClient as createPrebuiltRuleAssetsClientMock } from '../../../../prebuilt_rules/logic/rule_assets/__mocks__/prebuilt_rule_assets_client';
import { createPrebuiltRuleObjectsClient as createPrebuiltRuleObjectsClientMock } from '../../../../prebuilt_rules/logic/rule_objects/__mocks__/prebuilt_rule_objects_client';
import { createMockConfig, requestContextMock } from '../../../../routes/__mocks__';
import { getPrebuiltRuleMock } from '../../../../prebuilt_rules/mocks';
import { createRuleSourceImporter } from './rule_source_importer';
import * as calculateRuleSourceModule from '../calculate_rule_source_for_import';
describe('ruleSourceImporter', () => {
let ruleAssetsClientMock: ReturnType<typeof createPrebuiltRuleAssetsClientMock>;
let ruleObjectsClientMock: ReturnType<typeof createPrebuiltRuleObjectsClientMock>;
let config: ReturnType<typeof createMockConfig>;
let context: ReturnType<typeof requestContextMock.create>['securitySolution'];
let ruleToImport: RuleToImport;
@ -30,12 +31,15 @@ describe('ruleSourceImporter', () => {
ruleAssetsClientMock.fetchLatestAssets.mockResolvedValue([{}]);
ruleAssetsClientMock.fetchLatestVersions.mockResolvedValue([]);
ruleAssetsClientMock.fetchAssetsByVersion.mockResolvedValue([]);
ruleObjectsClientMock = createPrebuiltRuleObjectsClientMock();
ruleObjectsClientMock.fetchInstalledRulesByIds.mockResolvedValue([]);
ruleToImport = { rule_id: 'rule-1', version: 1 } as RuleToImport;
subject = createRuleSourceImporter({
context,
config,
prebuiltRuleAssetsClient: ruleAssetsClientMock,
prebuiltRuleObjectsClient: ruleObjectsClientMock,
ruleCustomizationStatus: { isRulesCustomizationEnabled: true },
});
});
@ -112,7 +116,6 @@ describe('ruleSourceImporter', () => {
describe('#calculateRuleSource()', () => {
let rule: ValidatedRuleToImport;
let calculatorSpy: jest.SpyInstance;
beforeEach(() => {
rule = { rule_id: 'validated-rule', version: 1 } as ValidatedRuleToImport;
@ -124,23 +127,6 @@ describe('ruleSourceImporter', () => {
getPrebuiltRuleMock({ rule_id: 'rule-2' }),
getPrebuiltRuleMock({ rule_id: 'validated-rule' }),
]);
calculatorSpy = jest
.spyOn(calculateRuleSourceModule, 'calculateRuleSourceForImport')
.mockReturnValue({ ruleSource: { type: 'internal' }, immutable: false });
});
it('invokes calculateRuleSourceForImport with the correct arguments', async () => {
await subject.setup([rule]);
await subject.calculateRuleSource(rule);
expect(calculatorSpy).toHaveBeenCalledTimes(1);
expect(calculatorSpy).toHaveBeenCalledWith(
expect.objectContaining({
rule,
prebuiltRuleAssetsByRuleId: { 'rule-1': expect.objectContaining({ rule_id: 'rule-1' }) },
isKnownPrebuiltRule: true,
})
);
});
it('throws an error if the rule is not known to the calculator', async () => {
@ -157,23 +143,5 @@ describe('ruleSourceImporter', () => {
`"Rule validated-rule was not registered during setup."`
);
});
describe('for rules set up without a version', () => {
it('invokes the calculator with the correct arguments', async () => {
await subject.setup([{ ...rule, version: undefined }]);
await subject.calculateRuleSource(rule);
expect(calculatorSpy).toHaveBeenCalledTimes(1);
expect(calculatorSpy).toHaveBeenCalledWith(
expect.objectContaining({
rule,
prebuiltRuleAssetsByRuleId: {
'rule-1': expect.objectContaining({ rule_id: 'rule-1' }),
},
isKnownPrebuiltRule: true,
})
);
});
});
});
});

View file

@ -15,6 +15,7 @@
import type { SecuritySolutionApiRequestHandlerContext } from '../../../../../../types';
import type { ConfigType } from '../../../../../../config';
import type {
RuleResponse,
RuleToImport,
ValidatedRuleToImport,
} from '../../../../../../../common/api/detection_engine';
@ -24,6 +25,7 @@ import { ensureLatestRulesPackageInstalled } from '../../../../prebuilt_rules/lo
import { calculateRuleSourceForImport } from '../calculate_rule_source_for_import';
import type { CalculatedRuleSource, IRuleSourceImporter } from './rule_source_importer_interface';
import type { PrebuiltRulesCustomizationStatus } from '../../../../../../../common/detection_engine/prebuilt_rules/prebuilt_rule_customization_status';
import type { IPrebuiltRuleObjectsClient } from '../../../../prebuilt_rules/logic/rule_objects/prebuilt_rule_objects_client';
interface RuleSpecifier {
rule_id: string;
@ -96,25 +98,30 @@ export class RuleSourceImporter implements IRuleSourceImporter {
private context: SecuritySolutionApiRequestHandlerContext;
private config: ConfigType;
private ruleAssetsClient: IPrebuiltRuleAssetsClient;
private ruleObjectsClient: IPrebuiltRuleObjectsClient;
private ruleCustomizationStatus: PrebuiltRulesCustomizationStatus;
private latestPackagesInstalled: boolean = false;
private matchingAssetsByRuleId: Record<string, PrebuiltRuleAsset> = {};
private knownRules: RuleSpecifier[] = [];
private currentRulesById: Record<string, RuleResponse> = {};
private rulesToImport: RuleSpecifier[] = [];
private availableRuleAssetIds: Set<string> = new Set();
constructor({
config,
context,
prebuiltRuleAssetsClient,
prebuiltRuleObjectsClient,
ruleCustomizationStatus,
}: {
config: ConfigType;
context: SecuritySolutionApiRequestHandlerContext;
prebuiltRuleAssetsClient: IPrebuiltRuleAssetsClient;
prebuiltRuleObjectsClient: IPrebuiltRuleObjectsClient;
ruleCustomizationStatus: PrebuiltRulesCustomizationStatus;
}) {
this.config = config;
this.ruleAssetsClient = prebuiltRuleAssetsClient;
this.ruleObjectsClient = prebuiltRuleObjectsClient;
this.context = context;
this.ruleCustomizationStatus = ruleCustomizationStatus;
}
@ -130,9 +137,12 @@ export class RuleSourceImporter implements IRuleSourceImporter {
this.latestPackagesInstalled = true;
}
this.knownRules = rules.map((rule) => ({ rule_id: rule.rule_id, version: rule.version }));
this.rulesToImport = rules.map((rule) => ({ rule_id: rule.rule_id, version: rule.version }));
this.matchingAssetsByRuleId = await this.fetchMatchingAssetsByRuleId();
this.availableRuleAssetIds = new Set(await this.fetchAvailableRuleAssetIds());
this.currentRulesById = await this.fetchInstalledRulesByIds(
this.rulesToImport.map((rule) => rule.rule_id)
);
}
public isPrebuiltRule(rule: RuleToImport): boolean {
@ -145,7 +155,8 @@ export class RuleSourceImporter implements IRuleSourceImporter {
this.validateRuleInput(rule);
return calculateRuleSourceForImport({
rule,
importedRule: rule,
currentRule: this.currentRulesById[rule.rule_id],
prebuiltRuleAssetsByRuleId: this.matchingAssetsByRuleId,
isKnownPrebuiltRule: this.availableRuleAssetIds.has(rule.rule_id),
ruleCustomizationStatus: this.ruleCustomizationStatus,
@ -155,7 +166,7 @@ export class RuleSourceImporter implements IRuleSourceImporter {
private async fetchMatchingAssetsByRuleId(): Promise<Record<string, PrebuiltRuleAsset>> {
this.validateSetupState();
const matchingAssets = await fetchMatchingAssets({
rules: this.knownRules,
rules: this.rulesToImport,
ruleAssetsClient: this.ruleAssetsClient,
});
@ -165,11 +176,18 @@ export class RuleSourceImporter implements IRuleSourceImporter {
}, {});
}
private async fetchInstalledRulesByIds(ruleIds: string[]): Promise<Record<string, RuleResponse>> {
const currentRules = await this.ruleObjectsClient.fetchInstalledRulesByIds({
ruleIds,
});
return Object.fromEntries(currentRules.map((rule) => [rule.rule_id, rule]));
}
private async fetchAvailableRuleAssetIds(): Promise<string[]> {
this.validateSetupState();
return fetchAvailableRuleAssetIds({
rules: this.knownRules,
rules: this.rulesToImport,
ruleAssetsClient: this.ruleAssetsClient,
});
}
@ -185,7 +203,7 @@ export class RuleSourceImporter implements IRuleSourceImporter {
private validateRuleInput(rule: RuleToImport) {
if (
!this.knownRules.some(
!this.rulesToImport.some(
(knownRule) =>
knownRule.rule_id === rule.rule_id &&
(knownRule.version === rule.version || knownRule.version == null)
@ -200,17 +218,20 @@ export const createRuleSourceImporter = ({
config,
context,
prebuiltRuleAssetsClient,
prebuiltRuleObjectsClient,
ruleCustomizationStatus,
}: {
config: ConfigType;
context: SecuritySolutionApiRequestHandlerContext;
prebuiltRuleAssetsClient: IPrebuiltRuleAssetsClient;
prebuiltRuleObjectsClient: IPrebuiltRuleObjectsClient;
ruleCustomizationStatus: PrebuiltRulesCustomizationStatus;
}): RuleSourceImporter => {
return new RuleSourceImporter({
config,
context,
prebuiltRuleAssetsClient,
prebuiltRuleObjectsClient,
ruleCustomizationStatus,
});
};

View file

@ -271,7 +271,7 @@ export default ({ getService }: FtrProviderContext): void => {
expect(importedRule).toMatchObject({
rule_id: rule.rule_id,
version: 9999,
rule_source: { type: 'external', is_customized: true },
rule_source: { type: 'external', is_customized: false },
immutable: true,
});
});