[Security Solution] Reduce the _review rule upgrade endpoint response size (#211045)

**Resolves: https://github.com/elastic/kibana/issues/208361**
**Resolves: https://github.com/elastic/kibana/issues/210544**

## Summary

This PR introduces significant memory consumption improvements to the
prebuilt rule endpoints, ensuring users won't encounter OOM errors on
memory-limited Kibana instances.

Memory consumption testing results provided in
https://github.com/elastic/kibana/pull/211045#issuecomment-2689854328.

## Details

This PR implements a number of memory usage optimizations to the
prebuilt rule endpoints with the final goal reducing chances of getting
OOM errors. The changes are extensive and require thorough testing
before merging.

The changes are described by the following bullets

- The most significant change is the addition of pagination to the
`upgrade/_review` endpoint. This endpoint was known for causing OOM
errors due to its large and ever-growing response size. With pagination,
it now returns upgrade information for no more than 20-100 rules at a
time, significantly reducing its memory footprint.
- New backend methods, such as
`ruleObjectsClient.fetchInstalledRuleVersions`, have been introduced.
These methods return rule IDs with their corresponding installed
versions, allowing to build a map of outdated rules without loading all
available rules into memory. Previously, all installed rules, along with
their base and target versions, were fetched unconditionally before
filtering for updates.
- The `stats` data structure of the review endpoint has been deprecated
(it can be safely removed after one Serverless release cycle). Since the
endpoint now returns paginated results, building stats is no longer
feasible due to the limited rule set size fetched on the server side. As
the side effect it required removing related Cypress tests asserting
`Update All` disabled when rules can't be updated.
- All changes to the endpoints are backward-compatible. All previously
required returned structures still present in response. All newly added
structures are optional.
- Upgradeable rule tags are now returned from the prebuilt rule status
endpoint.
- The frontend logic has been updated to move sorting and filtering of
prebuilt rules from the client side to the server side.
- The `upgrade/_perform` endpoint has been rewritten to use lightweight
rule version information rather than full rules to determine upgradeable
rules. Additionally, upgrades are now performed in batches of up to 100
rules, further reducing memory usage.
- A dry run option has been added to the upgrade perform endpoint. This
is needed for the "Update all" rules scenario to determine if any rules
contain conflicts and display a confirmation modal to the user.
- An option to skip conflicting rules has been added to the upgrade
endpoint when called with the `ALL_RULES` mode.
- The `install/_review` endpoint's memory consumption has been optimized
by avoiding loading all rules into memory to determine available rules
for installation. Redundant fetching of all base versions has also been
removed, as they do not participate in the calculation.

---------

Co-authored-by: Maxim Palenov <maxim.palenov@elastic.co>
This commit is contained in:
Dmitrii Shevchenko 2025-03-03 15:03:07 +01:00 committed by GitHub
parent ab44603a1c
commit c4a016eda3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
47 changed files with 1048 additions and 1020 deletions

View file

@ -0,0 +1,29 @@
/*
* 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 { z } from '@kbn/zod';
export enum RuleCustomizationStatus {
CUSTOMIZED = 'CUSTOMIZED',
NOT_CUSTOMIZED = 'NOT_CUSTOMIZED',
}
export type PrebuiltRulesFilter = z.infer<typeof PrebuiltRulesFilter>;
export const PrebuiltRulesFilter = z.object({
/**
* Tags to filter by
*/
tags: z.array(z.string()).optional(),
/**
* Rule name to filter by
*/
name: z.string().optional(),
/**
* Rule customization status to filter by
*/
customization_status: z.nativeEnum(RuleCustomizationStatus).optional(),
});

View file

@ -0,0 +1,24 @@
/*
* 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 { z } from '@kbn/zod';
import { PrebuiltRulesFilter } from './prebuilt_rules_filter';
export enum RuleCustomizationStatus {
CUSTOMIZED = 'CUSTOMIZED',
NOT_CUSTOMIZED = 'NOT_CUSTOMIZED',
}
export type ReviewPrebuiltRuleUpgradeFilter = z.infer<typeof ReviewPrebuiltRuleUpgradeFilter>;
export const ReviewPrebuiltRuleUpgradeFilter = PrebuiltRulesFilter.merge(
z.object({
/**
* Rule IDs to return upgrade info for
*/
rule_ids: z.array(z.string()).optional(),
})
);

View file

@ -8,6 +8,18 @@
export interface GetPrebuiltRulesStatusResponseBody {
/** Aggregated info about all prebuilt rules */
stats: PrebuiltRulesStatusStats;
/**
* Aggregated info about upgradeable prebuilt rules. This fields is optional
* for backward compatibility. After one serverless release cycle, it can be
* made required.
* */
aggregated_fields?: {
upgradeable_rules: {
/** List of all tags of the current versions of upgradeable rules */
tags: string[];
};
};
}
export interface PrebuiltRulesStatusStats {

View file

@ -22,3 +22,4 @@ export * from './model/diff/three_way_diff/three_way_diff_outcome';
export * from './model/diff/three_way_diff/three_way_diff';
export * from './model/diff/three_way_diff/three_way_diff_conflict';
export * from './model/diff/three_way_diff/three_way_merge_outcome';
export * from './common/prebuilt_rules_filter';

View file

@ -10,6 +10,7 @@ import { mapValues } from 'lodash';
import { RuleResponse } from '../../model/rule_schema/rule_schemas.gen';
import { AggregatedPrebuiltRuleError, DiffableAllFields } from '../model';
import { RuleSignatureId, RuleVersion } from '../../model';
import { PrebuiltRulesFilter } from '../common/prebuilt_rules_filter';
export type Mode = z.infer<typeof Mode>;
export const Mode = z.enum(['ALL_RULES', 'SPECIFIC_RULES']);
@ -111,21 +112,31 @@ export const RuleUpgradeSpecifier = z.object({
fields: RuleFieldsToUpgrade.optional(),
});
export type UpgradeConflictResolution = z.infer<typeof UpgradeConflictResolution>;
export const UpgradeConflictResolution = z.enum(['SKIP', 'OVERWRITE']);
export type UpgradeConflictResolutionEnum = typeof UpgradeConflictResolution.enum;
export const UpgradeConflictResolutionEnum = UpgradeConflictResolution.enum;
export type UpgradeSpecificRulesRequest = z.infer<typeof UpgradeSpecificRulesRequest>;
export const UpgradeSpecificRulesRequest = z.object({
mode: z.literal('SPECIFIC_RULES'),
rules: z.array(RuleUpgradeSpecifier).min(1),
pick_version: PickVersionValues.optional(),
on_conflict: UpgradeConflictResolution.optional(),
dry_run: z.boolean().optional(),
});
export type UpgradeAllRulesRequest = z.infer<typeof UpgradeAllRulesRequest>;
export const UpgradeAllRulesRequest = z.object({
mode: z.literal('ALL_RULES'),
pick_version: PickVersionValues.optional(),
filter: PrebuiltRulesFilter.optional(),
on_conflict: UpgradeConflictResolution.optional(),
dry_run: z.boolean().optional(),
});
export type SkipRuleUpgradeReason = z.infer<typeof SkipRuleUpgradeReason>;
export const SkipRuleUpgradeReason = z.enum(['RULE_UP_TO_DATE']);
export const SkipRuleUpgradeReason = z.enum(['RULE_UP_TO_DATE', 'CONFLICT']);
export type SkipRuleUpgradeReasonEnum = typeof SkipRuleUpgradeReason.enum;
export const SkipRuleUpgradeReasonEnum = SkipRuleUpgradeReason.enum;

View file

@ -5,35 +5,86 @@
* 2.0.
*/
import type { RuleObjectId, RuleSignatureId, RuleTagArray } from '../../model';
import { z } from '@kbn/zod';
import { SortOrder, type RuleObjectId, type RuleSignatureId, type RuleTagArray } from '../../model';
import type { PartialRuleDiff } from '../model';
import type { RuleResponse } from '../../model/rule_schema';
import type { RuleResponse, RuleVersion } from '../../model/rule_schema';
import { FindRulesSortField } from '../../rule_management';
import { PrebuiltRulesFilter } from '../common/prebuilt_rules_filter';
export type ReviewRuleUpgradeSort = z.infer<typeof ReviewRuleUpgradeSort>;
export const ReviewRuleUpgradeSort = z.object({
/**
* Field to sort by
*/
field: FindRulesSortField.optional(),
/**
* Sort order
*/
order: SortOrder.optional(),
});
export type ReviewRuleUpgradeRequestBody = z.infer<typeof ReviewRuleUpgradeRequestBody>;
export const ReviewRuleUpgradeRequestBody = z
.object({
filter: PrebuiltRulesFilter.optional(),
sort: ReviewRuleUpgradeSort.optional(),
page: z.coerce.number().int().min(1).optional().default(1),
/**
* Rules per page
*/
per_page: z.coerce.number().int().min(0).optional().default(20),
})
.nullable();
export interface ReviewRuleUpgradeResponseBody {
/** Aggregated info about all rules available for upgrade */
/**
* @deprecated Use the prebuilt rule status API instead. The field is kept
* here for backward compatibility but can be removed after one Serverless
* release.
*/
stats: RuleUpgradeStatsForReview;
/** Info about individual rules: one object per each rule available for upgrade */
rules: RuleUpgradeInfoForReview[];
/** The requested page number */
page: number;
/** The requested number of items per page */
per_page: number;
/** The total number of rules available for upgrade that match the filter criteria */
total: number;
}
export interface RuleUpgradeStatsForReview {
/** Number of installed prebuilt rules available for upgrade (stock + customized) */
/**
* @deprecated Always 0
*/
num_rules_to_upgrade_total: number;
/** Number of installed prebuilt rules with upgrade conflicts (SOLVABLE or NON_SOLVABLE) */
/**
* @deprecated Always 0
*/
num_rules_with_conflicts: number;
/** Number of installed prebuilt rules with NON_SOLVABLE upgrade conflicts */
/**
* @deprecated Always 0
*/
num_rules_with_non_solvable_conflicts: number;
/** A union of all tags of all rules available for upgrade */
/**
* @deprecated Always an empty array
*/
tags: RuleTagArray;
}
export interface RuleUpgradeInfoForReview {
id: RuleObjectId;
rule_id: RuleSignatureId;
version: RuleVersion;
current_rule: RuleResponse;
target_rule: RuleResponse;
diff: PartialRuleDiff;

View file

@ -388,7 +388,7 @@ export const STARTED_TRANSFORM_STATES = new Set([
]);
/**
* How many rules to update at a time is set to 50 from errors coming from
* How many rules to update at a time is set to 20 from errors coming from
* the slow environments such as cloud when the rule updates are > 100 we were
* seeing timeout issues.
*
@ -403,14 +403,14 @@ export const STARTED_TRANSFORM_STATES = new Set([
* Lastly, we saw weird issues where Chrome on upstream 408 timeouts will re-call the REST route
* which in turn could create additional connections we want to avoid.
*
* See file import_rules_route.ts for another area where 50 was chosen, therefore I chose
* 50 here to mimic it as well. If you see this re-opened or what similar to it, consider
* reducing the 50 above to a lower number.
* See file import_rules_route.ts for another area where 20 was chosen, therefore I chose
* 20 here to mimic it as well. If you see this re-opened or what similar to it, consider
* reducing the 20 above to a lower number.
*
* See the original ticket here:
* https://github.com/elastic/kibana/issues/94418
*/
export const MAX_RULES_TO_UPDATE_IN_PARALLEL = 50;
export const MAX_RULES_TO_UPDATE_IN_PARALLEL = 20;
export const LIMITED_CONCURRENCY_ROUTE_TAG_PREFIX = `${APP_ID}:limitedConcurrency`;

View file

@ -22,3 +22,4 @@ export const TAGS_FIELD = 'alert.attributes.tags';
export const PARAMS_TYPE_FIELD = 'alert.attributes.params.type';
export const PARAMS_IMMUTABLE_FIELD = 'alert.attributes.params.immutable';
export const LAST_RUN_OUTCOME_FIELD = 'alert.attributes.lastRun.outcome';
export const IS_CUSTOMIZED_FIELD = 'alert.attributes.params.ruleSource.isCustomized';

View file

@ -7,10 +7,11 @@
import type { Type } from '@kbn/securitysolution-io-ts-alerting-types';
import type { RuleExecutionStatus } from '../../api/detection_engine';
import { RuleExecutionStatusEnum } from '../../api/detection_engine';
import { RuleCustomizationStatus, RuleExecutionStatusEnum } from '../../api/detection_engine';
import { prepareKQLStringParam } from '../../utils/kql';
import {
ENABLED_FIELD,
IS_CUSTOMIZED_FIELD,
LAST_RUN_OUTCOME_FIELD,
PARAMS_IMMUTABLE_FIELD,
PARAMS_TYPE_FIELD,
@ -23,6 +24,8 @@ export const KQL_FILTER_IMMUTABLE_RULES = `${PARAMS_IMMUTABLE_FIELD}: true`;
export const KQL_FILTER_MUTABLE_RULES = `${PARAMS_IMMUTABLE_FIELD}: false`;
export const KQL_FILTER_ENABLED_RULES = `${ENABLED_FIELD}: true`;
export const KQL_FILTER_DISABLED_RULES = `${ENABLED_FIELD}: false`;
export const KQL_FILTER_CUSTOMIZED_RULES = `${IS_CUSTOMIZED_FIELD}: true`;
export const KQL_FILTER_NOT_CUSTOMIZED_RULES = `${IS_CUSTOMIZED_FIELD}: false`;
interface RulesFilterOptions {
filter: string;
@ -32,6 +35,7 @@ interface RulesFilterOptions {
tags: string[];
excludeRuleTypes: Type[];
ruleExecutionStatus: RuleExecutionStatus;
customizationStatus: RuleCustomizationStatus;
ruleIds: string[];
}
@ -50,6 +54,7 @@ export function convertRulesFilterToKQL({
tags,
excludeRuleTypes = [],
ruleExecutionStatus,
customizationStatus,
}: Partial<RulesFilterOptions>): string {
const kql: string[] = [];
@ -85,6 +90,12 @@ export function convertRulesFilterToKQL({
kql.push(`${LAST_RUN_OUTCOME_FIELD}: "failed"`);
}
if (customizationStatus === RuleCustomizationStatus.CUSTOMIZED) {
kql.push(KQL_FILTER_CUSTOMIZED_RULES);
} else if (customizationStatus === RuleCustomizationStatus.NOT_CUSTOMIZED) {
kql.push(KQL_FILTER_NOT_CUSTOMIZED_RULES);
}
return kql.join(' AND ');
}

View file

@ -15,14 +15,14 @@ import type { ActionType, AsApiContract } from '@kbn/actions-plugin/common';
import type { ActionResult } from '@kbn/actions-plugin/server';
import { convertRulesFilterToKQL } from '../../../../common/detection_engine/rule_management/rule_filtering';
import type {
UpgradeSpecificRulesRequest,
PickVersionValues,
PerformRuleUpgradeResponseBody,
InstallSpecificRulesRequest,
PerformRuleInstallationResponseBody,
GetPrebuiltRulesStatusResponseBody,
ReviewRuleUpgradeResponseBody,
ReviewRuleInstallationResponseBody,
ReviewRuleUpgradeRequestBody,
PerformRuleUpgradeRequestBody,
} from '../../../../common/api/detection_engine/prebuilt_rules';
import type {
BulkDuplicateRules,
@ -637,13 +637,16 @@ export const getPrebuiltRulesStatus = async ({
*/
export const reviewRuleUpgrade = async ({
signal,
request,
}: {
signal: AbortSignal | undefined;
request: ReviewRuleUpgradeRequestBody;
}): Promise<ReviewRuleUpgradeResponseBody> =>
KibanaServices.get().http.fetch(REVIEW_RULE_UPGRADE_URL, {
method: 'POST',
version: '1',
signal,
body: JSON.stringify(request),
});
/**
@ -685,23 +688,13 @@ export const performInstallSpecificRules = async (
}),
});
export interface PerformUpgradeRequest {
rules: UpgradeSpecificRulesRequest['rules'];
pickVersion: PickVersionValues;
}
export const performUpgradeSpecificRules = async ({
rules,
pickVersion,
}: PerformUpgradeRequest): Promise<PerformRuleUpgradeResponseBody> =>
export const performUpgradeRules = async (
body: PerformRuleUpgradeRequestBody
): Promise<PerformRuleUpgradeResponseBody> =>
KibanaServices.get().http.fetch(PERFORM_RULE_UPGRADE_URL, {
method: 'POST',
version: '1',
body: JSON.stringify({
mode: 'SPECIFIC_RULES',
rules,
pick_version: pickVersion,
}),
body: JSON.stringify(body),
});
export const bootstrapPrebuiltRules = async (): Promise<BootstrapPrebuiltRulesResponse> =>

View file

@ -4,24 +4,24 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { useCallback } from 'react';
import type { UseQueryOptions } from '@tanstack/react-query';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import type { PrebuiltRulesStatusStats } from '../../../../../../common/api/detection_engine/prebuilt_rules';
import { useCallback } from 'react';
import type { GetPrebuiltRulesStatusResponseBody } from '../../../../../../common/api/detection_engine/prebuilt_rules';
import { GET_PREBUILT_RULES_STATUS_URL } from '../../../../../../common/api/detection_engine/prebuilt_rules';
import { getPrebuiltRulesStatus } from '../../api';
import { DEFAULT_QUERY_OPTIONS } from '../constants';
import { GET_PREBUILT_RULES_STATUS_URL } from '../../../../../../common/api/detection_engine/prebuilt_rules';
export const PREBUILT_RULES_STATUS_QUERY_KEY = ['GET', GET_PREBUILT_RULES_STATUS_URL];
export const useFetchPrebuiltRulesStatusQuery = (
options?: UseQueryOptions<PrebuiltRulesStatusStats>
options?: UseQueryOptions<GetPrebuiltRulesStatusResponseBody>
) => {
return useQuery<PrebuiltRulesStatusStats>(
return useQuery<GetPrebuiltRulesStatusResponseBody>(
PREBUILT_RULES_STATUS_QUERY_KEY,
async ({ signal }) => {
const response = await getPrebuiltRulesStatus({ signal });
return response.stats;
return response;
},
{
...DEFAULT_QUERY_OPTIONS,

View file

@ -9,7 +9,10 @@ import type { UseQueryOptions } from '@tanstack/react-query';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { reviewRuleUpgrade } from '../../api';
import { REVIEW_RULE_UPGRADE_URL } from '../../../../../../common/api/detection_engine/prebuilt_rules/urls';
import type { ReviewRuleUpgradeResponseBody } from '../../../../../../common/api/detection_engine/prebuilt_rules';
import type {
ReviewRuleUpgradeRequestBody,
ReviewRuleUpgradeResponseBody,
} from '../../../../../../common/api/detection_engine/prebuilt_rules';
import { DEFAULT_QUERY_OPTIONS } from '../constants';
import { retryOnRateLimitedError } from './retry_on_rate_limited_error';
import { cappedExponentialBackoff } from './capped_exponential_backoff';
@ -17,12 +20,13 @@ import { cappedExponentialBackoff } from './capped_exponential_backoff';
export const REVIEW_RULE_UPGRADE_QUERY_KEY = ['POST', REVIEW_RULE_UPGRADE_URL];
export const useFetchPrebuiltRulesUpgradeReviewQuery = (
request: ReviewRuleUpgradeRequestBody,
options?: UseQueryOptions<ReviewRuleUpgradeResponseBody>
) => {
return useQuery<ReviewRuleUpgradeResponseBody>(
REVIEW_RULE_UPGRADE_QUERY_KEY,
[...REVIEW_RULE_UPGRADE_QUERY_KEY, request],
async ({ signal }) => {
const response = await reviewRuleUpgrade({ signal });
const response = await reviewRuleUpgrade({ signal, request });
return response;
},
{

View file

@ -6,10 +6,12 @@
*/
import type { UseMutationOptions } from '@tanstack/react-query';
import { useMutation } from '@tanstack/react-query';
import type { PerformRuleUpgradeResponseBody } from '../../../../../../common/api/detection_engine/prebuilt_rules';
import type {
PerformRuleUpgradeRequestBody,
PerformRuleUpgradeResponseBody,
} from '../../../../../../common/api/detection_engine/prebuilt_rules';
import { PERFORM_RULE_UPGRADE_URL } from '../../../../../../common/api/detection_engine/prebuilt_rules/urls';
import type { PerformUpgradeRequest } from '../../api';
import { performUpgradeSpecificRules } from '../../api';
import { performUpgradeRules } from '../../api';
import { useInvalidateFetchCoverageOverviewQuery } from '../use_fetch_coverage_overview_query';
import { useInvalidateFetchRuleManagementFiltersQuery } from '../use_fetch_rule_management_filters_query';
import { useInvalidateFetchRulesSnoozeSettingsQuery } from '../use_fetch_rules_snooze_settings_query';
@ -19,14 +21,14 @@ import { useInvalidateFetchPrebuiltRulesUpgradeReviewQuery } from './use_fetch_p
import { retryOnRateLimitedError } from './retry_on_rate_limited_error';
import { cappedExponentialBackoff } from './capped_exponential_backoff';
export const PERFORM_SPECIFIC_RULES_UPGRADE_KEY = [
'POST',
'SPECIFIC_RULES',
PERFORM_RULE_UPGRADE_URL,
];
export const PERFORM_RULES_UPGRADE_KEY = ['POST', PERFORM_RULE_UPGRADE_URL];
export const usePerformSpecificRulesUpgradeMutation = (
options?: UseMutationOptions<PerformRuleUpgradeResponseBody, unknown, PerformUpgradeRequest>
export const usePerformRulesUpgradeMutation = (
options?: UseMutationOptions<
PerformRuleUpgradeResponseBody,
unknown,
PerformRuleUpgradeRequestBody
>
) => {
const invalidateFindRulesQuery = useInvalidateFindRulesQuery();
const invalidateFetchRulesSnoozeSettings = useInvalidateFetchRulesSnoozeSettingsQuery();
@ -37,13 +39,13 @@ export const usePerformSpecificRulesUpgradeMutation = (
const invalidateRuleStatus = useInvalidateFetchPrebuiltRulesStatusQuery();
const invalidateFetchCoverageOverviewQuery = useInvalidateFetchCoverageOverviewQuery();
return useMutation<PerformRuleUpgradeResponseBody, unknown, PerformUpgradeRequest>(
(args: PerformUpgradeRequest) => {
return performUpgradeSpecificRules(args);
return useMutation<PerformRuleUpgradeResponseBody, unknown, PerformRuleUpgradeRequestBody>(
(args: PerformRuleUpgradeRequestBody) => {
return performUpgradeRules(args);
},
{
...options,
mutationKey: PERFORM_SPECIFIC_RULES_UPGRADE_KEY,
mutationKey: PERFORM_RULES_UPGRADE_KEY,
onSettled: (...args) => {
invalidatePrePackagedRulesStatus();
invalidateFindRulesQuery();

View file

@ -5,18 +5,22 @@
* 2.0.
*/
import { useAppToasts } from '../../../../common/hooks/use_app_toasts';
import { usePerformSpecificRulesUpgradeMutation } from '../../api/hooks/prebuilt_rules/use_perform_specific_rules_upgrade_mutation';
import { usePerformRulesUpgradeMutation } from '../../api/hooks/prebuilt_rules/use_perform_rules_upgrade_mutation';
import * as i18n from './translations';
export const usePerformUpgradeSpecificRules = () => {
export const usePerformUpgradeRules = () => {
const { addError, addSuccess } = useAppToasts();
return usePerformSpecificRulesUpgradeMutation({
return usePerformRulesUpgradeMutation({
onError: (err) => {
addError(err, { title: i18n.RULE_UPGRADE_FAILED });
},
onSuccess: (result) => {
onSuccess: (result, vars) => {
if (vars.dry_run) {
// This is a preflight check, no need to show toast
return;
}
addSuccess(getSuccessToastMessage(result));
},
});

View file

@ -5,7 +5,10 @@
* 2.0.
*/
import type { UseQueryOptions } from '@tanstack/react-query';
import type { ReviewRuleUpgradeResponseBody } from '../../../../../common/api/detection_engine/prebuilt_rules';
import type {
ReviewRuleUpgradeRequestBody,
ReviewRuleUpgradeResponseBody,
} from '../../../../../common/api/detection_engine/prebuilt_rules';
import { useAppToasts } from '../../../../common/hooks/use_app_toasts';
import * as i18n from '../translations';
@ -18,11 +21,12 @@ import { useFetchPrebuiltRulesUpgradeReviewQuery } from '../../api/hooks/prebuil
* @returns useQuery result
*/
export const usePrebuiltRulesUpgradeReview = (
request: ReviewRuleUpgradeRequestBody,
options?: UseQueryOptions<ReviewRuleUpgradeResponseBody>
) => {
const { addError } = useAppToasts();
return useFetchPrebuiltRulesUpgradeReviewQuery({
return useFetchPrebuiltRulesUpgradeReviewQuery(request, {
onError: (error) => addError(error, { title: i18n.RULE_AND_TIMELINE_FETCH_FAILURE }),
...options,
});

View file

@ -11,7 +11,10 @@ import type { RuleSnooze } from '@kbn/alerting-plugin/common';
import type { Type } from '@kbn/securitysolution-io-ts-alerting-types';
import type { NamespaceType } from '@kbn/securitysolution-io-ts-list-types';
import type { RuleSnoozeSettings } from '@kbn/triggers-actions-ui-plugin/public/types';
import type { WarningSchema } from '../../../../common/api/detection_engine';
import type {
RuleCustomizationStatus,
WarningSchema,
} from '../../../../common/api/detection_engine';
import type { RuleExecutionStatus } from '../../../../common/api/detection_engine/rule_monitoring';
import { SortOrder } from '../../../../common/api/detection_engine';
@ -103,7 +106,7 @@ export interface FilterOptions {
excludeRuleTypes?: Type[];
enabled?: boolean; // undefined is to display all the rules
ruleExecutionStatus?: RuleExecutionStatus; // undefined means "all"
ruleSource?: RuleCustomizationEnum[]; // undefined is to display all the rules
ruleSource?: RuleCustomizationStatus[]; // undefined is to display all the rules
showRulesWithGaps?: boolean;
gapSearchRange?: GapRangeValue;
}
@ -209,8 +212,3 @@ export interface FindRulesReferencedByExceptionsProps {
lists: FindRulesReferencedByExceptionsListProp[];
signal?: AbortSignal;
}
export enum RuleCustomizationEnum {
customized = 'CUSTOMIZED',
not_customized = 'NOT_CUSTOMIZED',
}

View file

@ -21,8 +21,8 @@ import { AllRulesTabs } from '../rules_table/rules_table_toolbar';
export const RuleUpdateCallouts = () => {
const { data: prebuiltRulesStatus } = usePrebuiltRulesStatus();
const rulesToInstallCount = prebuiltRulesStatus?.num_prebuilt_rules_to_install ?? 0;
const rulesToUpgradeCount = prebuiltRulesStatus?.num_prebuilt_rules_to_upgrade ?? 0;
const rulesToInstallCount = prebuiltRulesStatus?.stats.num_prebuilt_rules_to_install ?? 0;
const rulesToUpgradeCount = prebuiltRulesStatus?.stats.num_prebuilt_rules_to_upgrade ?? 0;
// Check against rulesInstalledCount since we don't want to show banners if we're showing the empty prompt
const shouldDisplayNewRulesCallout = rulesToInstallCount > 0;

View file

@ -144,7 +144,7 @@ export const AddPrebuiltRulesTableContextProvider = ({
enabled: isUpgradeReviewRequestEnabled({
canUserCRUD,
isUpgradingSecurityPackages,
prebuiltRulesStatus,
prebuiltRulesStatus: prebuiltRulesStatus?.stats,
}),
});

View file

@ -33,7 +33,7 @@ export const RulesTableToolbar = React.memo(() => {
const installedTotal =
(ruleManagementFilters?.rules_summary.custom_count ?? 0) +
(ruleManagementFilters?.rules_summary.prebuilt_installed_count ?? 0);
const updateTotal = prebuiltRulesStatus?.num_prebuilt_rules_to_upgrade ?? 0;
const updateTotal = prebuiltRulesStatus?.stats.num_prebuilt_rules_to_upgrade ?? 0;
const shouldDisplayRuleUpdatesTab = !loading && canUserCRUD && updateTotal > 0;

View file

@ -34,13 +34,6 @@ export const BULK_UPDATE_BUTTON_TOOLTIP_NO_PERMISSIONS = i18n.translate(
}
);
export const BULK_UPDATE_ALL_RULES_BUTTON_TOOLTIP_CONFLICTS = i18n.translate(
'xpack.securitySolution.detectionEngine.rules.upgradeRules.bulkButtons.allRules.conflicts',
{
defaultMessage: 'All rules have conflicts. Update them individually.',
}
);
export const BULK_UPDATE_SELECTED_RULES_BUTTON_TOOLTIP_CONFLICTS = i18n.translate(
'xpack.securitySolution.detectionEngine.rules.upgradeRules.bulkButtons.selectedRules.conflicts',
{

View file

@ -10,7 +10,7 @@ import {
EuiEmptyPrompt,
EuiFlexGroup,
EuiFlexItem,
EuiInMemoryTable,
EuiBasicTable,
EuiProgress,
EuiSkeletonLoading,
EuiSkeletonText,
@ -19,9 +19,10 @@ import {
import React, { useCallback, useState } from 'react';
import type { RuleUpgradeState } from '../../../../rule_management/model/prebuilt_rule_upgrade';
import * as i18n from '../../../../../detections/pages/detection_engine/rules/translations';
import { RULES_TABLE_INITIAL_PAGE_SIZE, RULES_TABLE_PAGE_SIZE_OPTIONS } from '../constants';
import { RULES_TABLE_PAGE_SIZE_OPTIONS } from '../constants';
import { RulesChangelogLink } from '../rules_changelog_link';
import { UpgradePrebuiltRulesTableButtons } from './upgrade_prebuilt_rules_table_buttons';
import type { UpgradePrebuiltRulesSortingOptions } from './upgrade_prebuilt_rules_table_context';
import { useUpgradePrebuiltRulesTableContext } from './upgrade_prebuilt_rules_table_context';
import { UpgradePrebuiltRulesTableFilters } from './upgrade_prebuilt_rules_table_filters';
import { useUpgradePrebuiltRulesTableColumns } from './use_upgrade_prebuilt_rules_table_columns';
@ -44,20 +45,32 @@ export const UpgradePrebuiltRulesTable = React.memo(() => {
ruleUpgradeStates,
hasRulesToUpgrade,
isLoading,
isFetching,
isRefetching,
isUpgradingSecurityPackages,
pagination,
sortingOptions,
},
actions: { setPagination, setSortingOptions },
} = useUpgradePrebuiltRulesTableContext();
const [selected, setSelected] = useState<RuleUpgradeState[]>([]);
const rulesColumns = useUpgradePrebuiltRulesTableColumns();
const shouldShowProgress = isUpgradingSecurityPackages || isRefetching;
const [pageIndex, setPageIndex] = useState(0);
const handleTableChange = useCallback(
({ page: { index } }: CriteriaWithPagination<RuleUpgradeState>) => {
setPageIndex(index);
({ page: { index, size }, sort }: CriteriaWithPagination<RuleUpgradeState>) => {
setPagination({
page: index + 1,
perPage: size,
});
if (sort) {
setSortingOptions({
field: sort.field as UpgradePrebuiltRulesSortingOptions['field'],
order: sort.direction,
});
}
},
[setPageIndex]
[setPagination, setSortingOptions]
);
return (
@ -104,23 +117,31 @@ export const UpgradePrebuiltRulesTable = React.memo(() => {
</EuiFlexItem>
</EuiFlexGroup>
<EuiInMemoryTable
<EuiBasicTable
loading={isFetching}
items={ruleUpgradeStates}
sorting
pagination={{
initialPageSize: RULES_TABLE_INITIAL_PAGE_SIZE,
totalItemCount: pagination.total,
pageSizeOptions: RULES_TABLE_PAGE_SIZE_OPTIONS,
pageIndex,
pageIndex: pagination.page - 1,
pageSize: pagination.perPage,
}}
selection={{
selectable: () => true,
onSelectionChange: setSelected,
initialSelected: selected,
}}
sorting={{
sort: {
// EuiBasicTable has incorrect `sort.field` types which accept only `keyof Item` and reject fields in dot notation
field: sortingOptions.field as keyof RuleUpgradeState,
direction: sortingOptions.order,
},
}}
itemId="rule_id"
data-test-subj="rules-upgrades-table"
columns={rulesColumns}
onTableChange={handleTableChange}
onChange={handleTableChange}
/>
</>
)

View file

@ -21,13 +21,7 @@ export const UpgradePrebuiltRulesTableButtons = ({
selectedRules,
}: UpgradePrebuiltRulesTableButtonsProps) => {
const {
state: {
ruleUpgradeStates,
hasRulesToUpgrade,
loadingRules,
isRefetching,
isUpgradingSecurityPackages,
},
state: { hasRulesToUpgrade, loadingRules, isRefetching, isUpgradingSecurityPackages },
actions: { upgradeRules, upgradeAllRules },
} = useUpgradePrebuiltRulesTableContext();
const { isRulesCustomizationEnabled } = usePrebuiltRulesCustomizationStatus();
@ -43,14 +37,10 @@ export const UpgradePrebuiltRulesTableButtons = ({
const doAllSelectedRulesHaveConflicts =
isRulesCustomizationEnabled &&
selectedRules.every(({ hasUnresolvedConflicts }) => hasUnresolvedConflicts);
const doAllRulesHaveConflicts =
isRulesCustomizationEnabled &&
ruleUpgradeStates.every(({ hasUnresolvedConflicts }) => hasUnresolvedConflicts);
const { selectedRulesButtonTooltip, allRulesButtonTooltip } = useBulkUpdateButtonsTooltipContent({
canUserEditRules,
doAllSelectedRulesHaveConflicts,
doAllRulesHaveConflicts,
isPrebuiltRulesCustomizationEnabled: isRulesCustomizationEnabled,
});
@ -83,12 +73,7 @@ export const UpgradePrebuiltRulesTableButtons = ({
fill
iconType="plusInCircle"
onClick={upgradeAllRules}
disabled={
!canUserEditRules ||
!hasRulesToUpgrade ||
isRequestInProgress ||
doAllRulesHaveConflicts
}
disabled={!canUserEditRules || !hasRulesToUpgrade || isRequestInProgress}
data-test-subj="upgradeAllRulesButton"
>
{i18n.UPDATE_ALL}
@ -103,12 +88,10 @@ export const UpgradePrebuiltRulesTableButtons = ({
const useBulkUpdateButtonsTooltipContent = ({
canUserEditRules,
doAllSelectedRulesHaveConflicts,
doAllRulesHaveConflicts,
isPrebuiltRulesCustomizationEnabled,
}: {
canUserEditRules: boolean | null;
doAllSelectedRulesHaveConflicts: boolean;
doAllRulesHaveConflicts: boolean;
isPrebuiltRulesCustomizationEnabled: boolean;
}) => {
if (!canUserEditRules) {
@ -125,13 +108,6 @@ const useBulkUpdateButtonsTooltipContent = ({
};
}
if (doAllRulesHaveConflicts) {
return {
selectedRulesButtonTooltip: i18n.BULK_UPDATE_SELECTED_RULES_BUTTON_TOOLTIP_CONFLICTS,
allRulesButtonTooltip: i18n.BULK_UPDATE_ALL_RULES_BUTTON_TOOLTIP_CONFLICTS,
};
}
if (doAllSelectedRulesHaveConflicts) {
return {
selectedRulesButtonTooltip: i18n.BULK_UPDATE_SELECTED_RULES_BUTTON_TOOLTIP_CONFLICTS,

View file

@ -10,8 +10,11 @@ import type { Dispatch, SetStateAction } from 'react';
import React, { createContext, useCallback, useContext, useMemo, useState } from 'react';
import { PrebuiltRulesCustomizationDisabledReason } from '../../../../../../common/detection_engine/prebuilt_rules/prebuilt_rule_customization_status';
import type {
FindRulesSortField,
PrebuiltRulesFilter,
RuleFieldsToUpgrade,
RuleUpgradeSpecifier,
SortOrder,
} from '../../../../../../common/api/detection_engine';
import { usePrebuiltRulesCustomizationStatus } from '../../../../rule_management/logic/prebuilt_rules/use_prebuilt_rules_customization_status';
import type { RuleUpgradeState } from '../../../../rule_management/model/prebuilt_rule_upgrade';
@ -24,13 +27,11 @@ import type {
} from '../../../../../../common/api/detection_engine/model/rule_schema';
import { invariant } from '../../../../../../common/utils/invariant';
import { TabContentPadding } from '../../../../rule_management/components/rule_details/rule_details_flyout';
import { usePerformUpgradeSpecificRules } from '../../../../rule_management/logic/prebuilt_rules/use_perform_rule_upgrade';
import { usePerformUpgradeRules } from '../../../../rule_management/logic/prebuilt_rules/use_perform_rule_upgrade';
import { usePrebuiltRulesUpgradeReview } from '../../../../rule_management/logic/prebuilt_rules/use_prebuilt_rules_upgrade_review';
import { RuleDiffTab } from '../../../../rule_management/components/rule_details/rule_diff_tab';
import { FieldUpgradeStateEnum } from '../../../../rule_management/model/prebuilt_rule_upgrade/field_upgrade_state_enum';
import { useRulePreviewFlyout } from '../use_rule_preview_flyout';
import type { UpgradePrebuiltRulesTableFilterOptions } from './use_filter_prebuilt_rules_to_upgrade';
import { useFilterPrebuiltRulesToUpgrade } from './use_filter_prebuilt_rules_to_upgrade';
import { usePrebuiltRulesUpgradeState } from './use_prebuilt_rules_upgrade_state';
import { useOutdatedMlJobsUpgradeModal } from './use_ml_jobs_upgrade_modal';
import { useUpgradeWithConflictsModal } from './use_upgrade_with_conflicts_modal';
@ -39,9 +40,21 @@ import { UpgradeFlyoutSubHeader } from './upgrade_flyout_subheader';
import * as ruleDetailsI18n from '../../../../rule_management/components/rule_details/translations';
import * as i18n from './translations';
import { CustomizationDisabledCallout } from './customization_disabled_callout';
import { RULES_TABLE_INITIAL_PAGE_SIZE } from '../constants';
import type { PaginationOptions } from '../../../../rule_management/logic';
import { usePrebuiltRulesStatus } from '../../../../rule_management/logic/prebuilt_rules/use_prebuilt_rules_status';
const REVIEW_PREBUILT_RULES_UPGRADE_REFRESH_INTERVAL = 5 * 60 * 1000;
export interface UpgradePrebuiltRulesSortingOptions {
field:
| 'current_rule.name'
| 'current_rule.risk_score'
| 'current_rule.severity'
| 'current_rule.last_updated';
order: SortOrder;
}
export interface UpgradePrebuiltRulesTableState {
/**
* Rule upgrade state after applying `filterOptions`
@ -50,7 +63,7 @@ export interface UpgradePrebuiltRulesTableState {
/**
* Currently selected table filter
*/
filterOptions: UpgradePrebuiltRulesTableFilterOptions;
filterOptions: PrebuiltRulesFilter;
/**
* All unique tags for all rules
*/
@ -63,6 +76,10 @@ export interface UpgradePrebuiltRulesTableState {
* Is true then there is no cached data and the query is currently fetching.
*/
isLoading: boolean;
/**
* Is true whenever a request is in-flight, which includes initial loading as well as background refetches.
*/
isFetching: boolean;
/**
* Will be true if the query has been fetched.
*/
@ -84,6 +101,14 @@ export interface UpgradePrebuiltRulesTableState {
* The timestamp for when the rules were successfully fetched
*/
lastUpdated: number;
/**
* Current pagination state
*/
pagination: PaginationOptions;
/**
* Currently selected table sorting
*/
sortingOptions: UpgradePrebuiltRulesSortingOptions;
}
export const PREBUILT_RULE_UPDATE_FLYOUT_ANCHOR = 'updatePrebuiltRulePreview';
@ -92,7 +117,9 @@ export interface UpgradePrebuiltRulesTableActions {
reFetchRules: () => void;
upgradeRules: (ruleIds: RuleSignatureId[]) => void;
upgradeAllRules: () => void;
setFilterOptions: Dispatch<SetStateAction<UpgradePrebuiltRulesTableFilterOptions>>;
setFilterOptions: Dispatch<SetStateAction<PrebuiltRulesFilter>>;
setPagination: Dispatch<SetStateAction<{ page: number; perPage: number }>>;
setSortingOptions: Dispatch<SetStateAction<UpgradePrebuiltRulesSortingOptions>>;
openRulePreview: (ruleId: string) => void;
}
@ -121,35 +148,71 @@ export const UpgradePrebuiltRulesTableContextProvider = ({
}: UpgradePrebuiltRulesTableContextProviderProps) => {
const { isRulesCustomizationEnabled, customizationDisabledReason } =
usePrebuiltRulesCustomizationStatus();
// Use the data from the prebuilt rules status API to determine if there are
// rules to upgrade because it returns information about all rules without filters
const { data: prebuiltRulesStatusResponse } = usePrebuiltRulesStatus();
const hasRulesToUpgrade =
(prebuiltRulesStatusResponse?.stats.num_prebuilt_rules_to_upgrade ?? 0) > 0;
const tags = prebuiltRulesStatusResponse?.aggregated_fields?.upgradeable_rules.tags;
const [loadingRules, setLoadingRules] = useState<RuleSignatureId[]>([]);
const [filterOptions, setFilterOptions] = useState<UpgradePrebuiltRulesTableFilterOptions>({
filter: '',
tags: [],
ruleSource: [],
});
const [filterOptions, setFilterOptions] = useState<PrebuiltRulesFilter>({});
const isUpgradingSecurityPackages = useIsUpgradingSecurityPackages();
const [pagination, setPagination] = useState({
page: 1,
perPage: RULES_TABLE_INITIAL_PAGE_SIZE,
});
const [sortingOptions, setSortingOptions] = useState<UpgradePrebuiltRulesSortingOptions>({
field: 'current_rule.last_updated',
order: 'asc',
});
const findRulesSortField = useMemo<FindRulesSortField>(
() =>
((
{
'current_rule.name': 'name',
'current_rule.risk_score': 'risk_score',
'current_rule.severity': 'severity',
'current_rule.last_updated': 'updated_at',
} as const
)[sortingOptions.field]),
[sortingOptions.field]
);
const {
data: { rules: ruleUpgradeInfos, stats: { tags } } = {
rules: [],
stats: { tags: [] },
},
data: upgradeReviewResponse,
refetch,
dataUpdatedAt,
isFetched,
isLoading,
isFetching,
isRefetching,
} = usePrebuiltRulesUpgradeReview({
refetchInterval: REVIEW_PREBUILT_RULES_UPGRADE_REFRESH_INTERVAL,
keepPreviousData: true, // Use this option so that the state doesn't jump between "success" and "loading" on page change
});
} = usePrebuiltRulesUpgradeReview(
{
page: pagination.page,
per_page: pagination.perPage,
sort: {
field: findRulesSortField,
order: sortingOptions.order,
},
filter: filterOptions,
},
{
refetchInterval: REVIEW_PREBUILT_RULES_UPGRADE_REFRESH_INTERVAL,
keepPreviousData: true, // Use this option so that the state doesn't jump between "success" and "loading" on page change
}
);
const upgradeableRules = useMemo(
() => upgradeReviewResponse?.rules ?? [],
[upgradeReviewResponse]
);
const { rulesUpgradeState, setRuleFieldResolvedValue } =
usePrebuiltRulesUpgradeState(ruleUpgradeInfos);
usePrebuiltRulesUpgradeState(upgradeableRules);
const ruleUpgradeStates = useMemo(() => Object.values(rulesUpgradeState), [rulesUpgradeState]);
const filteredRuleUpgradeStates = useFilterPrebuiltRulesToUpgrade({
filterOptions,
data: ruleUpgradeStates,
});
const {
modal: confirmLegacyMlJobsUpgradeModal,
@ -158,7 +221,7 @@ export const UpgradePrebuiltRulesTableContextProvider = ({
} = useOutdatedMlJobsUpgradeModal();
const { modal: upgradeConflictsModal, confirmConflictsUpgrade } = useUpgradeWithConflictsModal();
const { mutateAsync: upgradeSpecificRulesRequest } = usePerformUpgradeSpecificRules();
const { mutateAsync: upgradeRulesRequest } = usePerformUpgradeRules();
const upgradeRulesToResolved = useCallback(
async (ruleIds: RuleSignatureId[]) => {
@ -189,8 +252,9 @@ export const UpgradePrebuiltRulesTableContextProvider = ({
return;
}
await upgradeSpecificRulesRequest({
pickVersion: 'MERGED',
await upgradeRulesRequest({
mode: 'SPECIFIC_RULES',
pick_version: 'MERGED',
rules: ruleUpgradeSpecifiers,
});
} catch {
@ -201,7 +265,7 @@ export const UpgradePrebuiltRulesTableContextProvider = ({
setLoadingRules((prev) => prev.filter((id) => !upgradedRuleIdsSet.has(id)));
}
},
[confirmLegacyMLJobs, confirmConflictsUpgrade, rulesUpgradeState, upgradeSpecificRulesRequest]
[confirmLegacyMLJobs, confirmConflictsUpgrade, rulesUpgradeState, upgradeRulesRequest]
);
const upgradeRulesToTarget = useCallback(
@ -220,8 +284,9 @@ export const UpgradePrebuiltRulesTableContextProvider = ({
return;
}
await upgradeSpecificRulesRequest({
pickVersion: 'TARGET',
await upgradeRulesRequest({
mode: 'SPECIFIC_RULES',
pick_version: 'TARGET',
rules: ruleUpgradeSpecifiers,
});
} catch {
@ -232,7 +297,7 @@ export const UpgradePrebuiltRulesTableContextProvider = ({
setLoadingRules((prev) => prev.filter((id) => !upgradedRuleIdsSet.has(id)));
}
},
[confirmLegacyMLJobs, rulesUpgradeState, upgradeSpecificRulesRequest]
[confirmLegacyMLJobs, rulesUpgradeState, upgradeRulesRequest]
);
const upgradeRules = useCallback(
@ -246,11 +311,50 @@ export const UpgradePrebuiltRulesTableContextProvider = ({
[isRulesCustomizationEnabled, upgradeRulesToResolved, upgradeRulesToTarget]
);
const upgradeAllRules = useCallback(
// Upgrade all rules, ignoring filter and selection
() => upgradeRules(ruleUpgradeInfos.map((rule) => rule.rule_id)),
[ruleUpgradeInfos, upgradeRules]
);
const upgradeAllRules = useCallback(async () => {
setLoadingRules((prev) => [...prev, ...upgradeableRules.map((rule) => rule.rule_id)]);
try {
// Handle MLJobs modal
if (!(await confirmLegacyMLJobs())) {
return;
}
const dryRunResults = await upgradeRulesRequest({
mode: 'ALL_RULES',
pick_version: isRulesCustomizationEnabled ? 'MERGED' : 'TARGET',
filter: filterOptions,
dry_run: true,
on_conflict: 'SKIP',
});
const hasConflicts = dryRunResults.results.skipped.some(
(skippedRule) => skippedRule.reason === 'CONFLICT'
);
if (hasConflicts && !(await confirmConflictsUpgrade())) {
return;
}
await upgradeRulesRequest({
mode: 'ALL_RULES',
pick_version: isRulesCustomizationEnabled ? 'MERGED' : 'TARGET',
filter: filterOptions,
on_conflict: 'SKIP',
});
} catch {
// Error is handled by the mutation's onError callback, so no need to do anything here
} finally {
setLoadingRules([]);
}
}, [
upgradeableRules,
confirmLegacyMLJobs,
upgradeRulesRequest,
isRulesCustomizationEnabled,
filterOptions,
confirmConflictsUpgrade,
]);
const subHeaderFactory = useCallback(
(rule: RuleResponse) =>
@ -377,12 +481,8 @@ export const UpgradePrebuiltRulesTableContextProvider = ({
setRuleFieldResolvedValue,
]
);
const filteredRules = useMemo(
() => filteredRuleUpgradeStates.map(({ target_rule: targetRule }) => targetRule),
[filteredRuleUpgradeStates]
);
const { rulePreviewFlyout, openRulePreview } = useRulePreviewFlyout({
rules: filteredRules,
rules: ruleUpgradeStates.map(({ target_rule: targetRule }) => targetRule),
subHeaderFactory,
ruleActionsFactory,
extraTabsFactory,
@ -399,6 +499,8 @@ export const UpgradePrebuiltRulesTableContextProvider = ({
upgradeAllRules,
setFilterOptions,
openRulePreview,
setPagination,
setSortingOptions,
}),
[refetch, upgradeRules, upgradeAllRules, openRulePreview]
);
@ -406,31 +508,41 @@ export const UpgradePrebuiltRulesTableContextProvider = ({
const providerValue = useMemo<UpgradePrebuiltRulesContextType>(
() => ({
state: {
ruleUpgradeStates: filteredRuleUpgradeStates,
hasRulesToUpgrade: isFetched && ruleUpgradeInfos.length > 0,
ruleUpgradeStates,
hasRulesToUpgrade,
filterOptions,
tags,
tags: tags ?? [],
isFetched,
isLoading: isLoading || areMlJobsLoading,
isFetching,
isRefetching,
isUpgradingSecurityPackages,
loadingRules,
lastUpdated: dataUpdatedAt,
pagination: {
...pagination,
total: upgradeReviewResponse?.total ?? 0,
},
sortingOptions,
},
actions,
}),
[
ruleUpgradeInfos.length,
filteredRuleUpgradeStates,
ruleUpgradeStates,
hasRulesToUpgrade,
filterOptions,
tags,
isFetched,
isLoading,
areMlJobsLoading,
isFetching,
isRefetching,
isUpgradingSecurityPackages,
loadingRules,
dataUpdatedAt,
pagination,
upgradeReviewResponse?.total,
sortingOptions,
actions,
]
);

View file

@ -9,11 +9,11 @@ import { EuiFilterGroup, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { isEqual } from 'lodash/fp';
import React, { useCallback } from 'react';
import styled from 'styled-components';
import type { RuleCustomizationStatus } from '../../../../../../common/api/detection_engine';
import { usePrebuiltRulesCustomizationStatus } from '../../../../rule_management/logic/prebuilt_rules/use_prebuilt_rules_customization_status';
import type { RuleCustomizationEnum } from '../../../../rule_management/logic';
import * as i18n from './translations';
import { TagsFilterPopover } from '../rules_table_filters/tags_filter_popover';
import { RuleSearchField } from '../rules_table_filters/rule_search_field';
import { TagsFilterPopover } from '../rules_table_filters/tags_filter_popover';
import * as i18n from './translations';
import { useUpgradePrebuiltRulesTableContext } from './upgrade_prebuilt_rules_table_context';
import { RuleCustomizationFilterPopover } from './upgrade_rule_customization_filter_popover';
@ -33,13 +33,13 @@ const UpgradePrebuiltRulesTableFiltersComponent = () => {
const { isRulesCustomizationEnabled } = usePrebuiltRulesCustomizationStatus();
const { tags: selectedTags, ruleSource: selectedRuleSource = [] } = filterOptions;
const { tags: selectedTags, customization_status: customizationStatus } = filterOptions;
const handleOnSearch = useCallback(
(filterString: string) => {
(nameString: string) => {
setFilterOptions((filters) => ({
...filters,
filter: filterString.trim(),
name: nameString.trim(),
}));
},
[setFilterOptions]
@ -57,22 +57,20 @@ const UpgradePrebuiltRulesTableFiltersComponent = () => {
[selectedTags, setFilterOptions]
);
const handleSelectedRuleSource = useCallback(
(newRuleSource: RuleCustomizationEnum[]) => {
if (!isEqual(newRuleSource, selectedRuleSource)) {
setFilterOptions((filters) => ({
...filters,
ruleSource: newRuleSource,
}));
}
const handleCustomizationStatusChange = useCallback(
(newCustomizationStatus: RuleCustomizationStatus | undefined) => {
setFilterOptions((filters) => ({
...filters,
customization_status: newCustomizationStatus,
}));
},
[selectedRuleSource, setFilterOptions]
[setFilterOptions]
);
return (
<FilterWrapper gutterSize="s" justifyContent="flexEnd" wrap>
<RuleSearchField
initialValue={filterOptions.filter}
initialValue={filterOptions.name ?? ''}
onSearch={handleOnSearch}
placeholder={i18n.SEARCH_PLACEHOLDER}
/>
@ -81,8 +79,8 @@ const UpgradePrebuiltRulesTableFiltersComponent = () => {
{isRulesCustomizationEnabled && (
<EuiFilterGroup>
<RuleCustomizationFilterPopover
onSelectedRuleSourceChanged={handleSelectedRuleSource}
selectedRuleSource={selectedRuleSource}
onCustomizationStatusChanged={handleCustomizationStatusChange}
customizationStatus={customizationStatus}
data-test-subj="upgradeRulesRuleCustomizationPopover"
/>
</EuiFilterGroup>
@ -90,7 +88,7 @@ const UpgradePrebuiltRulesTableFiltersComponent = () => {
<EuiFilterGroup>
<TagsFilterPopover
onSelectedTagsChanged={handleSelectedTags}
selectedTags={selectedTags}
selectedTags={selectedTags ?? []}
tags={tags}
data-test-subj="upgradeRulesTagPopover"
/>

View file

@ -5,23 +5,22 @@
* 2.0.
*/
import React, { useState, useMemo } from 'react';
import type { EuiSelectableOption } from '@elastic/eui';
import { EuiFilterButton, EuiPopover, EuiSelectable } from '@elastic/eui';
import { RuleCustomizationEnum } from '../../../../rule_management/logic';
import React, { useMemo, useState } from 'react';
import { RuleCustomizationStatus } from '../../../../../../common/api/detection_engine';
import * as i18n from '../../../../../detections/pages/detection_engine/rules/translations';
import { toggleSelectedGroup } from '../../../../../common/components/ml_popover/jobs_table/filters/toggle_selected_group';
interface RuleCustomizationFilterPopoverProps {
selectedRuleSource: RuleCustomizationEnum[];
onSelectedRuleSourceChanged: (newRuleSource: RuleCustomizationEnum[]) => void;
customizationStatus: RuleCustomizationStatus | undefined;
onCustomizationStatusChanged: (newRuleSource: RuleCustomizationStatus | undefined) => void;
}
const RULE_CUSTOMIZATION_POPOVER_WIDTH = 200;
const RuleCustomizationFilterPopoverComponent = ({
selectedRuleSource,
onSelectedRuleSourceChanged,
customizationStatus,
onCustomizationStatusChanged,
}: RuleCustomizationFilterPopoverProps) => {
const [isRuleCustomizationPopoverOpen, setIsRuleCustomizationPopoverOpen] = useState(false);
@ -29,18 +28,16 @@ const RuleCustomizationFilterPopoverComponent = ({
() => [
{
label: i18n.MODIFIED_LABEL,
key: RuleCustomizationEnum.customized,
checked: selectedRuleSource.includes(RuleCustomizationEnum.customized) ? 'on' : undefined,
key: RuleCustomizationStatus.CUSTOMIZED,
checked: customizationStatus === RuleCustomizationStatus.CUSTOMIZED ? 'on' : undefined,
},
{
label: i18n.UNMODIFIED_LABEL,
key: RuleCustomizationEnum.not_customized,
checked: selectedRuleSource.includes(RuleCustomizationEnum.not_customized)
? 'on'
: undefined,
key: RuleCustomizationStatus.NOT_CUSTOMIZED,
checked: customizationStatus === RuleCustomizationStatus.NOT_CUSTOMIZED ? 'on' : undefined,
},
],
[selectedRuleSource]
[customizationStatus]
);
const handleSelectableOptionsChange = (
@ -48,10 +45,8 @@ const RuleCustomizationFilterPopoverComponent = ({
_: unknown,
changedOption: EuiSelectableOption
) => {
toggleSelectedGroup(
changedOption.key ?? '',
selectedRuleSource,
onSelectedRuleSourceChanged as (args: string[]) => void
onCustomizationStatusChanged(
changedOption.checked === 'on' ? (changedOption.key as RuleCustomizationStatus) : undefined
);
};
@ -62,8 +57,8 @@ const RuleCustomizationFilterPopoverComponent = ({
onClick={() => setIsRuleCustomizationPopoverOpen(!isRuleCustomizationPopoverOpen)}
numFilters={selectableOptions.length}
isSelected={isRuleCustomizationPopoverOpen}
hasActiveFilters={selectedRuleSource.length > 0}
numActiveFilters={selectedRuleSource.length}
hasActiveFilters={customizationStatus != null}
numActiveFilters={customizationStatus != null ? 1 : 0}
data-test-subj="rule-customization-filter-popover-button"
>
{i18n.RULE_SOURCE}

View file

@ -1,48 +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 { useMemo } from 'react';
import type { RuleUpgradeState } from '../../../../rule_management/model/prebuilt_rule_upgrade';
import { RuleCustomizationEnum, type FilterOptions } from '../../../../rule_management/logic/types';
export type UpgradePrebuiltRulesTableFilterOptions = Pick<
FilterOptions,
'filter' | 'tags' | 'ruleSource'
>;
interface UseFilterPrebuiltRulesToUpgradeParams {
data: RuleUpgradeState[];
filterOptions: UpgradePrebuiltRulesTableFilterOptions;
}
export const useFilterPrebuiltRulesToUpgrade = ({
data,
filterOptions,
}: UseFilterPrebuiltRulesToUpgradeParams): RuleUpgradeState[] => {
return useMemo(() => {
const { filter, tags, ruleSource } = filterOptions;
return data.filter((ruleInfo) => {
if (filter && !ruleInfo.current_rule.name.toLowerCase().includes(filter.toLowerCase())) {
return false;
}
if (tags?.length && !tags.every((tag) => ruleInfo.current_rule.tags.includes(tag))) {
return false;
}
if (ruleSource?.length === 1 && ruleInfo.current_rule.rule_source.type === 'external') {
if (ruleSource.includes(RuleCustomizationEnum.customized)) {
return ruleInfo.current_rule.rule_source.is_customized;
}
return ruleInfo.current_rule.rule_source.is_customized === false;
}
return true;
});
}, [filterOptions, data]);
};

View file

@ -429,6 +429,7 @@ function createRuleUpgradeInfoMock(
num_fields_with_non_solvable_conflicts: 0,
fields: {},
},
version: 1,
revision: 1,
...rewrites,
};

View file

@ -33,7 +33,7 @@ export const AddElasticRulesButton = ({
});
const { data: preBuiltRulesStatus } = usePrebuiltRulesStatus();
const newRulesCount = preBuiltRulesStatus?.num_prebuilt_rules_to_install ?? 0;
const newRulesCount = preBuiltRulesStatus?.stats.num_prebuilt_rules_to_install ?? 0;
const ButtonComponent = fill ? EuiButton : EuiButtonEmpty;

View file

@ -12,8 +12,6 @@ import type { SecuritySolutionPluginRouter } from '../../../../../types';
import { buildSiemResponse } from '../../../routes/utils';
import { createPrebuiltRuleAssetsClient } from '../../logic/rule_assets/prebuilt_rule_assets_client';
import { createPrebuiltRuleObjectsClient } from '../../logic/rule_objects/prebuilt_rule_objects_client';
import { fetchRuleVersionsTriad } from '../../logic/rule_versions/fetch_rule_versions_triad';
import { getRuleGroups } from '../../model/rule_groups/get_rule_groups';
export const getPrebuiltRulesStatusRoute = (router: SecuritySolutionPluginRouter) => {
router.versioned
@ -41,19 +39,33 @@ export const getPrebuiltRulesStatusRoute = (router: SecuritySolutionPluginRouter
const ruleAssetsClient = createPrebuiltRuleAssetsClient(soClient);
const ruleObjectsClient = createPrebuiltRuleObjectsClient(rulesClient);
const ruleVersionsMap = await fetchRuleVersionsTriad({
ruleAssetsClient,
ruleObjectsClient,
const currentRuleVersions = await ruleObjectsClient.fetchInstalledRuleVersions();
const latestRuleVersions = await ruleAssetsClient.fetchLatestVersions();
const currentRuleVersionsMap = new Map(
currentRuleVersions.map((rule) => [rule.rule_id, rule])
);
const latestRuleVersionsMap = new Map(
latestRuleVersions.map((rule) => [rule.rule_id, rule])
);
const installableRules = latestRuleVersions.filter(
(rule) => !currentRuleVersionsMap.has(rule.rule_id)
);
const upgradeableRules = currentRuleVersions.filter((rule) => {
const latestVersion = latestRuleVersionsMap.get(rule.rule_id);
return latestVersion != null && rule.version < latestVersion.version;
});
const { currentRules, installableRules, upgradeableRules, totalAvailableRules } =
getRuleGroups(ruleVersionsMap);
const body: GetPrebuiltRulesStatusResponseBody = {
stats: {
num_prebuilt_rules_installed: currentRules.length,
num_prebuilt_rules_installed: currentRuleVersions.length,
num_prebuilt_rules_to_install: installableRules.length,
num_prebuilt_rules_to_upgrade: upgradeableRules.length,
num_prebuilt_rules_total_in_package: totalAvailableRules.length,
num_prebuilt_rules_total_in_package: latestRuleVersions.length,
},
aggregated_fields: {
upgradeable_rules: {
tags: [...new Set(upgradeableRules.flatMap((rule) => rule.tags))],
},
},
};

View file

@ -1,191 +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 { getUpgradeableRules } from './get_upgradeable_rules';
import { ModeEnum, SkipRuleUpgradeReasonEnum } from '../../../../../../common/api/detection_engine';
import type {
RuleResponse,
RuleUpgradeSpecifier,
} from '../../../../../../common/api/detection_engine';
import { getPrebuiltRuleMockOfType } from '../../model/rule_assets/prebuilt_rule_asset.mock';
import { convertPrebuiltRuleAssetToRuleResponse } from '../../../rule_management/logic/detection_rules_client/converters/convert_prebuilt_rule_asset_to_rule_response';
import type { RuleTriad } from '../../model/rule_groups/get_rule_groups';
describe('getUpgradeableRules', () => {
const baseRule = getPrebuiltRuleMockOfType('query');
const createUpgradeableRule = (
ruleId: string,
currentVersion: number,
targetVersion: number
): RuleTriad => {
return {
current: {
...baseRule,
rule_id: ruleId,
version: currentVersion,
revision: 0,
},
target: { ...baseRule, rule_id: ruleId, version: targetVersion },
} as RuleTriad;
};
const mockUpgradeableRule = createUpgradeableRule('rule-1', 1, 2);
const mockCurrentRule: RuleResponse = {
...convertPrebuiltRuleAssetToRuleResponse(baseRule),
rule_id: 'rule-1',
revision: 0,
version: 1,
};
describe('ALL_RULES mode', () => {
it('should return all upgradeable rules when in ALL_RULES mode', () => {
const result = getUpgradeableRules({
rawUpgradeableRules: [mockUpgradeableRule],
currentRules: [mockCurrentRule],
mode: ModeEnum.ALL_RULES,
});
expect(result.upgradeableRules).toEqual([mockUpgradeableRule]);
expect(result.fetchErrors).toEqual([]);
expect(result.skippedRules).toEqual([]);
});
it('should handle empty upgradeable rules list', () => {
const result = getUpgradeableRules({
rawUpgradeableRules: [],
currentRules: [],
mode: ModeEnum.ALL_RULES,
});
expect(result.upgradeableRules).toEqual([]);
expect(result.fetchErrors).toEqual([]);
expect(result.skippedRules).toEqual([]);
});
});
describe('SPECIFIC_RULES mode', () => {
const mockVersionSpecifier: RuleUpgradeSpecifier = {
rule_id: 'rule-1',
revision: 0,
version: 1,
};
it('should return specified upgradeable rules when in SPECIFIC_RULES mode', () => {
const result = getUpgradeableRules({
rawUpgradeableRules: [mockUpgradeableRule],
currentRules: [mockCurrentRule],
versionSpecifiers: [mockVersionSpecifier],
mode: ModeEnum.SPECIFIC_RULES,
});
expect(result.upgradeableRules).toEqual([mockUpgradeableRule]);
expect(result.fetchErrors).toEqual([]);
expect(result.skippedRules).toEqual([]);
});
it('should handle rule not found', () => {
const result = getUpgradeableRules({
rawUpgradeableRules: [mockUpgradeableRule],
currentRules: [mockCurrentRule],
versionSpecifiers: [{ ...mockVersionSpecifier, rule_id: 'nonexistent' }],
mode: ModeEnum.SPECIFIC_RULES,
});
expect(result.upgradeableRules).toEqual([mockUpgradeableRule]);
expect(result.fetchErrors).toHaveLength(1);
expect(result.fetchErrors[0].error.message).toContain(
'Rule with rule_id "nonexistent" and version "1" not found'
);
expect(result.skippedRules).toEqual([]);
});
it('should handle non-upgradeable rule', () => {
const nonUpgradeableRule: RuleResponse = {
...convertPrebuiltRuleAssetToRuleResponse(baseRule),
rule_id: 'rule-2',
revision: 0,
version: 1,
};
const result = getUpgradeableRules({
rawUpgradeableRules: [mockUpgradeableRule],
currentRules: [mockCurrentRule, nonUpgradeableRule],
versionSpecifiers: [mockVersionSpecifier, { ...mockVersionSpecifier, rule_id: 'rule-2' }],
mode: ModeEnum.SPECIFIC_RULES,
});
expect(result.upgradeableRules).toEqual([mockUpgradeableRule]);
expect(result.fetchErrors).toEqual([]);
expect(result.skippedRules).toEqual([
{ rule_id: 'rule-2', reason: SkipRuleUpgradeReasonEnum.RULE_UP_TO_DATE },
]);
});
it('should handle revision mismatch', () => {
const result = getUpgradeableRules({
rawUpgradeableRules: [mockUpgradeableRule],
currentRules: [mockCurrentRule],
versionSpecifiers: [{ ...mockVersionSpecifier, revision: 1 }],
mode: ModeEnum.SPECIFIC_RULES,
});
expect(result.upgradeableRules).toEqual([]);
expect(result.fetchErrors).toHaveLength(1);
expect(result.fetchErrors[0].error.message).toContain(
'Revision mismatch for rule_id rule-1: expected 0, got 1'
);
expect(result.skippedRules).toEqual([]);
});
it('should handle multiple rules with mixed scenarios', () => {
const mockUpgradeableRule2 = createUpgradeableRule('rule-2', 1, 2);
const mockCurrentRule2: RuleResponse = {
...convertPrebuiltRuleAssetToRuleResponse(baseRule),
rule_id: 'rule-2',
revision: 0,
version: 1,
};
const mockCurrentRule3: RuleResponse = {
...convertPrebuiltRuleAssetToRuleResponse(baseRule),
rule_id: 'rule-3',
revision: 1,
version: 1,
};
const result = getUpgradeableRules({
rawUpgradeableRules: [
mockUpgradeableRule,
mockUpgradeableRule2,
createUpgradeableRule('rule-3', 1, 2),
],
currentRules: [mockCurrentRule, mockCurrentRule2, mockCurrentRule3],
versionSpecifiers: [
mockVersionSpecifier,
{ ...mockVersionSpecifier, rule_id: 'rule-2' },
{ ...mockVersionSpecifier, rule_id: 'rule-3', revision: 0 },
{ ...mockVersionSpecifier, rule_id: 'rule-4' },
{ ...mockVersionSpecifier, rule_id: 'rule-5', revision: 1 },
],
mode: ModeEnum.SPECIFIC_RULES,
});
expect(result.upgradeableRules).toEqual([mockUpgradeableRule, mockUpgradeableRule2]);
expect(result.fetchErrors).toHaveLength(3);
expect(result.fetchErrors[0].error.message).toContain(
'Revision mismatch for rule_id rule-3: expected 1, got 0'
);
expect(result.fetchErrors[1].error.message).toContain(
'Rule with rule_id "rule-4" and version "1" not found'
);
expect(result.fetchErrors[2].error.message).toContain(
'Rule with rule_id "rule-5" and version "1" not found'
);
expect(result.skippedRules).toEqual([]);
});
});
});

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 { withSecuritySpanSync } from '../../../../../utils/with_security_span';
import type {
RuleResponse,
RuleUpgradeSpecifier,
SkippedRuleUpgrade,
} from '../../../../../../common/api/detection_engine';
import { ModeEnum, SkipRuleUpgradeReasonEnum } from '../../../../../../common/api/detection_engine';
import type { PromisePoolError } from '../../../../../utils/promise_pool';
import type { Mode } from '../../../../../../common/api/detection_engine/prebuilt_rules';
import type { RuleTriad } from '../../model/rule_groups/get_rule_groups';
export const getUpgradeableRules = ({
rawUpgradeableRules,
currentRules,
versionSpecifiers,
mode,
}: {
rawUpgradeableRules: RuleTriad[];
currentRules: RuleResponse[];
versionSpecifiers?: RuleUpgradeSpecifier[];
mode: Mode;
}) => {
return withSecuritySpanSync(getUpgradeableRules.name, () => {
const upgradeableRules = new Map(
rawUpgradeableRules.map((_rule) => [_rule.current.rule_id, _rule])
);
const fetchErrors: Array<PromisePoolError<{ rule_id: string }, Error>> = [];
const skippedRules: SkippedRuleUpgrade[] = [];
if (mode === ModeEnum.SPECIFIC_RULES) {
const installedRuleIds = new Set(currentRules.map((rule) => rule.rule_id));
const upgradeableRuleIds = new Set(rawUpgradeableRules.map(({ current }) => current.rule_id));
versionSpecifiers?.forEach((rule) => {
// Check that the requested rule was found
if (!installedRuleIds.has(rule.rule_id)) {
fetchErrors.push({
error: new Error(
`Rule with rule_id "${rule.rule_id}" and version "${rule.version}" not found`
),
item: rule,
});
return;
}
// Check that the requested rule is upgradeable
if (!upgradeableRuleIds.has(rule.rule_id)) {
skippedRules.push({
rule_id: rule.rule_id,
reason: SkipRuleUpgradeReasonEnum.RULE_UP_TO_DATE,
});
return;
}
// Check that rule revisions match (no update slipped in since the user reviewed the list)
const currentRevision = currentRules.find(
(currentRule) => currentRule.rule_id === rule.rule_id
)?.revision;
if (rule.revision !== currentRevision) {
fetchErrors.push({
error: new Error(
`Revision mismatch for rule_id ${rule.rule_id}: expected ${currentRevision}, got ${rule.revision}`
),
item: rule,
});
// Remove the rule from the list of upgradeable rules
if (upgradeableRules.has(rule.rule_id)) {
upgradeableRules.delete(rule.rule_id);
}
}
});
}
return {
upgradeableRules: Array.from(upgradeableRules.values()),
fetchErrors,
skippedRules,
};
});
};

View file

@ -0,0 +1,235 @@
/*
* 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 { KibanaRequest, KibanaResponseFactory } from '@kbn/core/server';
import { transformError } from '@kbn/securitysolution-es-utils';
import type {
PerformRuleUpgradeRequestBody,
PerformRuleUpgradeResponseBody,
SkippedRuleUpgrade,
} from '../../../../../../common/api/detection_engine/prebuilt_rules';
import {
ModeEnum,
PickVersionValuesEnum,
SkipRuleUpgradeReasonEnum,
UpgradeConflictResolutionEnum,
} from '../../../../../../common/api/detection_engine/prebuilt_rules';
import type { SecuritySolutionRequestHandlerContext } from '../../../../../types';
import { buildSiemResponse } from '../../../routes/utils';
import { convertPrebuiltRuleAssetToRuleResponse } from '../../../rule_management/logic/detection_rules_client/converters/convert_prebuilt_rule_asset_to_rule_response';
import { aggregatePrebuiltRuleErrors } from '../../logic/aggregate_prebuilt_rule_errors';
import { performTimelinesInstallation } from '../../logic/perform_timelines_installation';
import { createPrebuiltRuleAssetsClient } from '../../logic/rule_assets/prebuilt_rule_assets_client';
import { createPrebuiltRuleObjectsClient } from '../../logic/rule_objects/prebuilt_rule_objects_client';
import { upgradePrebuiltRules } from '../../logic/rule_objects/upgrade_prebuilt_rules';
import { createModifiedPrebuiltRuleAssets } from './create_upgradeable_rules_payload';
import { validatePerformRuleUpgradeRequest } from './validate_perform_rule_upgrade_request';
import type {
RuleResponse,
RuleSignatureId,
RuleVersion,
} from '../../../../../../common/api/detection_engine';
import type { PromisePoolError } from '../../../../../utils/promise_pool';
import { zipRuleVersions } from '../../logic/rule_versions/zip_rule_versions';
import { calculateRuleDiff } from '../../logic/diff/calculate_rule_diff';
import type { RuleTriad } from '../../model/rule_groups/get_rule_groups';
export const performRuleUpgradeHandler = async (
context: SecuritySolutionRequestHandlerContext,
request: KibanaRequest<undefined, undefined, PerformRuleUpgradeRequestBody>,
response: KibanaResponseFactory
) => {
const siemResponse = buildSiemResponse(response);
try {
const ctx = await context.resolve(['core', 'alerting', 'securitySolution']);
const soClient = ctx.core.savedObjects.client;
const rulesClient = await ctx.alerting.getRulesClient();
const detectionRulesClient = ctx.securitySolution.getDetectionRulesClient();
const ruleAssetsClient = createPrebuiltRuleAssetsClient(soClient);
const ruleObjectsClient = createPrebuiltRuleObjectsClient(rulesClient);
const { isRulesCustomizationEnabled } = detectionRulesClient.getRuleCustomizationStatus();
const defaultPickVersion = isRulesCustomizationEnabled
? PickVersionValuesEnum.MERGED
: PickVersionValuesEnum.TARGET;
validatePerformRuleUpgradeRequest({
isRulesCustomizationEnabled,
payload: request.body,
defaultPickVersion,
});
const { mode, dry_run: isDryRun, on_conflict: onConflict } = request.body;
const filter = mode === ModeEnum.ALL_RULES ? request.body.filter : undefined;
const skippedRules: SkippedRuleUpgrade[] = [];
const updatedRules: RuleResponse[] = [];
const ruleErrors: Array<PromisePoolError<{ rule_id: string }>> = [];
const allErrors: PerformRuleUpgradeResponseBody['errors'] = [];
const ruleUpgradeQueue: Array<{
rule_id: RuleSignatureId;
version: RuleVersion;
revision?: number;
}> = [];
if (mode === ModeEnum.ALL_RULES) {
const allLatestVersions = await ruleAssetsClient.fetchLatestVersions();
const latestVersionsMap = new Map(
allLatestVersions.map((version) => [version.rule_id, version])
);
const allCurrentVersions = await ruleObjectsClient.fetchInstalledRuleVersions({
filter,
});
allCurrentVersions.forEach((current) => {
const latest = latestVersionsMap.get(current.rule_id);
if (latest && latest.version > current.version) {
ruleUpgradeQueue.push({
rule_id: current.rule_id,
version: latest.version,
});
}
});
} else if (mode === ModeEnum.SPECIFIC_RULES) {
ruleUpgradeQueue.push(...request.body.rules);
}
const BATCH_SIZE = 100;
while (ruleUpgradeQueue.length > 0) {
const targetRulesForUpgrade = ruleUpgradeQueue.splice(0, BATCH_SIZE);
const [currentRules, latestRules] = await Promise.all([
ruleObjectsClient.fetchInstalledRulesByIds({
ruleIds: targetRulesForUpgrade.map(({ rule_id: ruleId }) => ruleId),
}),
ruleAssetsClient.fetchAssetsByVersion(targetRulesForUpgrade),
]);
const baseRules = await ruleAssetsClient.fetchAssetsByVersion(currentRules);
const ruleVersionsMap = zipRuleVersions(currentRules, baseRules, latestRules);
const upgradeableRules: RuleTriad[] = [];
targetRulesForUpgrade.forEach((targetRule) => {
const ruleVersions = ruleVersionsMap.get(targetRule.rule_id);
const currentVersion = ruleVersions?.current;
const baseVersion = ruleVersions?.base;
const targetVersion = ruleVersions?.target;
// Check that the requested rule was found
if (!currentVersion) {
ruleErrors.push({
error: new Error(
`Rule with rule_id "${targetRule.rule_id}" and version "${targetRule.version}" not found`
),
item: targetRule,
});
return;
}
// Check that the requested rule is upgradeable
if (!targetVersion || targetVersion.version <= currentVersion.version) {
skippedRules.push({
rule_id: targetRule.rule_id,
reason: SkipRuleUpgradeReasonEnum.RULE_UP_TO_DATE,
});
return;
}
// Check that rule revisions match (no update slipped in since the user reviewed the list)
if (targetRule.revision != null && targetRule.revision !== currentVersion.revision) {
ruleErrors.push({
error: new Error(
`Revision mismatch for rule_id ${targetRule.rule_id}: expected ${currentVersion.revision}, got ${targetRule.revision}`
),
item: targetRule,
});
return;
}
// Check there's no conflicts
if (onConflict === UpgradeConflictResolutionEnum.SKIP) {
const ruleDiff = calculateRuleDiff(ruleVersions);
const hasConflict = ruleDiff.ruleDiff.num_fields_with_conflicts > 0;
if (hasConflict) {
skippedRules.push({
rule_id: targetRule.rule_id,
reason: SkipRuleUpgradeReasonEnum.CONFLICT,
});
return;
}
}
// All checks passed, add to the list of rules to upgrade
upgradeableRules.push({
current: currentVersion,
base: baseVersion,
target: targetVersion,
});
});
const { modifiedPrebuiltRuleAssets, processingErrors } = createModifiedPrebuiltRuleAssets({
upgradeableRules,
requestBody: request.body,
defaultPickVersion,
});
ruleErrors.push(...processingErrors);
if (isDryRun) {
updatedRules.push(
...modifiedPrebuiltRuleAssets.map((rule) => convertPrebuiltRuleAssetToRuleResponse(rule))
);
} else {
const { results: upgradeResults, errors: installationErrors } = await upgradePrebuiltRules(
detectionRulesClient,
modifiedPrebuiltRuleAssets
);
ruleErrors.push(...installationErrors);
updatedRules.push(...upgradeResults.map(({ result }) => result));
}
}
allErrors.push(...aggregatePrebuiltRuleErrors(ruleErrors));
if (!isDryRun) {
const { error: timelineInstallationError } = await performTimelinesInstallation(
ctx.securitySolution
);
if (timelineInstallationError) {
allErrors.push({
message: timelineInstallationError,
rules: [],
});
}
}
const body: PerformRuleUpgradeResponseBody = {
summary: {
total: updatedRules.length + skippedRules.length + ruleErrors.length,
skipped: skippedRules.length,
succeeded: updatedRules.length,
failed: ruleErrors.length,
},
results: {
updated: updatedRules,
skipped: skippedRules,
},
errors: allErrors,
};
return response.ok({ body });
} catch (err) {
const error = transformError(err);
return siemResponse.error({
body: error.message,
statusCode: error.statusCode,
});
}
};

View file

@ -5,32 +5,18 @@
* 2.0.
*/
import { transformError } from '@kbn/securitysolution-es-utils';
import { buildRouteValidationWithZod } from '@kbn/zod-helpers';
import {
PERFORM_RULE_UPGRADE_URL,
PerformRuleUpgradeRequestBody,
ModeEnum,
PickVersionValuesEnum,
} from '../../../../../../common/api/detection_engine/prebuilt_rules';
import type { PerformRuleUpgradeResponseBody } from '../../../../../../common/api/detection_engine/prebuilt_rules';
import type { SecuritySolutionPluginRouter } from '../../../../../types';
import { buildSiemResponse } from '../../../routes/utils';
import { aggregatePrebuiltRuleErrors } from '../../logic/aggregate_prebuilt_rule_errors';
import { performTimelinesInstallation } from '../../logic/perform_timelines_installation';
import { createPrebuiltRuleAssetsClient } from '../../logic/rule_assets/prebuilt_rule_assets_client';
import { createPrebuiltRuleObjectsClient } from '../../logic/rule_objects/prebuilt_rule_objects_client';
import { upgradePrebuiltRules } from '../../logic/rule_objects/upgrade_prebuilt_rules';
import { fetchRuleVersionsTriad } from '../../logic/rule_versions/fetch_rule_versions_triad';
import {
PREBUILT_RULES_OPERATION_SOCKET_TIMEOUT_MS,
PREBUILT_RULES_OPERATION_CONCURRENCY,
} from '../../constants';
import { getUpgradeableRules } from './get_upgradeable_rules';
import { createModifiedPrebuiltRuleAssets } from './create_upgradeable_rules_payload';
import { getRuleGroups } from '../../model/rule_groups/get_rule_groups';
import { validatePerformRuleUpgradeRequest } from './validate_perform_rule_upgrade_request';
import { routeLimitedConcurrencyTag } from '../../../../../utils/route_limited_concurrency_tag';
import {
PREBUILT_RULES_OPERATION_CONCURRENCY,
PREBUILT_RULES_OPERATION_SOCKET_TIMEOUT_MS,
} from '../../constants';
import { performRuleUpgradeHandler } from './perform_rule_upgrade_handler';
export const performRuleUpgradeRoute = (router: SecuritySolutionPluginRouter) => {
router.versioned
@ -58,93 +44,6 @@ export const performRuleUpgradeRoute = (router: SecuritySolutionPluginRouter) =>
},
},
},
async (context, request, response) => {
const siemResponse = buildSiemResponse(response);
try {
const ctx = await context.resolve(['core', 'alerting', 'securitySolution', 'licensing']);
const soClient = ctx.core.savedObjects.client;
const rulesClient = await ctx.alerting.getRulesClient();
const detectionRulesClient = ctx.securitySolution.getDetectionRulesClient();
const ruleAssetsClient = createPrebuiltRuleAssetsClient(soClient);
const ruleObjectsClient = createPrebuiltRuleObjectsClient(rulesClient);
const { isRulesCustomizationEnabled } = detectionRulesClient.getRuleCustomizationStatus();
const defaultPickVersion = isRulesCustomizationEnabled
? PickVersionValuesEnum.MERGED
: PickVersionValuesEnum.TARGET;
validatePerformRuleUpgradeRequest({
isRulesCustomizationEnabled,
payload: request.body,
defaultPickVersion,
});
const { mode } = request.body;
const versionSpecifiers = mode === ModeEnum.ALL_RULES ? undefined : request.body.rules;
const ruleTriadsMap = await fetchRuleVersionsTriad({
ruleAssetsClient,
ruleObjectsClient,
versionSpecifiers,
});
const ruleGroups = getRuleGroups(ruleTriadsMap);
const { upgradeableRules, skippedRules, fetchErrors } = getUpgradeableRules({
rawUpgradeableRules: ruleGroups.upgradeableRules,
currentRules: ruleGroups.currentRules,
versionSpecifiers,
mode,
});
const { modifiedPrebuiltRuleAssets, processingErrors } = createModifiedPrebuiltRuleAssets(
{
upgradeableRules,
requestBody: request.body,
defaultPickVersion,
}
);
const { results: updatedRules, errors: installationErrors } = await upgradePrebuiltRules(
detectionRulesClient,
modifiedPrebuiltRuleAssets
);
const ruleErrors = [...fetchErrors, ...processingErrors, ...installationErrors];
const { error: timelineInstallationError } = await performTimelinesInstallation(
ctx.securitySolution
);
const allErrors = aggregatePrebuiltRuleErrors(ruleErrors);
if (timelineInstallationError) {
allErrors.push({
message: timelineInstallationError,
rules: [],
});
}
const body: PerformRuleUpgradeResponseBody = {
summary: {
total: updatedRules.length + skippedRules.length + ruleErrors.length,
skipped: skippedRules.length,
succeeded: updatedRules.length,
failed: ruleErrors.length,
},
results: {
updated: updatedRules.map(({ result }) => result),
skipped: skippedRules,
},
errors: allErrors,
};
return response.ok({ body });
} catch (err) {
const error = transformError(err);
return siemResponse.error({
body: error.message,
statusCode: error.statusCode,
});
}
}
performRuleUpgradeHandler
);
};

View file

@ -16,9 +16,7 @@ import { buildSiemResponse } from '../../../routes/utils';
import { convertPrebuiltRuleAssetToRuleResponse } from '../../../rule_management/logic/detection_rules_client/converters/convert_prebuilt_rule_asset_to_rule_response';
import { createPrebuiltRuleAssetsClient } from '../../logic/rule_assets/prebuilt_rule_assets_client';
import { createPrebuiltRuleObjectsClient } from '../../logic/rule_objects/prebuilt_rule_objects_client';
import { fetchRuleVersionsTriad } from '../../logic/rule_versions/fetch_rule_versions_triad';
import type { PrebuiltRuleAsset } from '../../model/rule_assets/prebuilt_rule_asset';
import { getRuleGroups } from '../../model/rule_groups/get_rule_groups';
export const reviewRuleInstallationHandler = async (
context: SecuritySolutionRequestHandlerContext,
@ -34,15 +32,21 @@ export const reviewRuleInstallationHandler = async (
const ruleAssetsClient = createPrebuiltRuleAssetsClient(soClient);
const ruleObjectsClient = createPrebuiltRuleObjectsClient(rulesClient);
const ruleVersionsMap = await fetchRuleVersionsTriad({
ruleAssetsClient,
ruleObjectsClient,
const allLatestVersions = await ruleAssetsClient.fetchLatestVersions();
const currentRuleVersions = await ruleObjectsClient.fetchInstalledRuleVersions();
const currentRuleVersionsMap = new Map(
currentRuleVersions.map((version) => [version.rule_id, version])
);
const installableRules = allLatestVersions.filter((latestVersion) => {
const currentVersion = currentRuleVersionsMap.get(latestVersion.rule_id);
return !currentVersion;
});
const { installableRules } = getRuleGroups(ruleVersionsMap);
const installableRuleAssets = await ruleAssetsClient.fetchAssetsByVersion(installableRules);
const body: ReviewRuleInstallationResponseBody = {
stats: calculateRuleStats(installableRules),
rules: installableRules.map((prebuiltRuleAsset) =>
stats: calculateRuleStats(installableRuleAssets),
rules: installableRuleAssets.map((prebuiltRuleAsset) =>
convertPrebuiltRuleAssetToRuleResponse(prebuiltRuleAsset)
),
};

View file

@ -0,0 +1,59 @@
/*
* 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 { pickBy } from 'lodash';
import type { RuleResponse } from '../../../../../../common/api/detection_engine/model/rule_schema';
import type {
RuleUpgradeInfoForReview,
ThreeWayDiff,
} from '../../../../../../common/api/detection_engine/prebuilt_rules';
import { ThreeWayDiffOutcome } from '../../../../../../common/api/detection_engine/prebuilt_rules';
import { invariant } from '../../../../../../common/utils/invariant';
import { convertPrebuiltRuleAssetToRuleResponse } from '../../../rule_management/logic/detection_rules_client/converters/convert_prebuilt_rule_asset_to_rule_response';
import type { CalculateRuleDiffResult } from '../../logic/diff/calculate_rule_diff';
export const calculateRuleUpgradeInfo = (
results: CalculateRuleDiffResult[]
): RuleUpgradeInfoForReview[] => {
return results.map((result) => {
const { ruleDiff, ruleVersions } = result;
const installedCurrentVersion = ruleVersions.input.current;
const targetVersion = ruleVersions.input.target;
invariant(installedCurrentVersion != null, 'installedCurrentVersion not found');
invariant(targetVersion != null, 'targetVersion not found');
const targetRule: RuleResponse = {
...convertPrebuiltRuleAssetToRuleResponse(targetVersion),
id: installedCurrentVersion.id,
revision: installedCurrentVersion.revision + 1,
created_at: installedCurrentVersion.created_at,
created_by: installedCurrentVersion.created_by,
updated_at: new Date().toISOString(),
updated_by: installedCurrentVersion.updated_by,
};
return {
id: installedCurrentVersion.id,
rule_id: installedCurrentVersion.rule_id,
revision: installedCurrentVersion.revision,
version: installedCurrentVersion.version,
current_rule: installedCurrentVersion,
target_rule: targetRule,
diff: {
fields: pickBy<ThreeWayDiff<unknown>>(
ruleDiff.fields,
(fieldDiff) =>
fieldDiff.diff_outcome !== ThreeWayDiffOutcome.StockValueNoUpdate &&
fieldDiff.diff_outcome !== ThreeWayDiffOutcome.MissingBaseNoUpdate
),
num_fields_with_updates: ruleDiff.num_fields_with_updates,
num_fields_with_conflicts: ruleDiff.num_fields_with_conflicts,
num_fields_with_non_solvable_conflicts: ruleDiff.num_fields_with_non_solvable_conflicts,
},
};
});
};

View file

@ -7,32 +7,35 @@
import type { KibanaRequest, KibanaResponseFactory } from '@kbn/core/server';
import { transformError } from '@kbn/securitysolution-es-utils';
import { pickBy } from 'lodash';
import type { RuleResponse } from '../../../../../../common/api/detection_engine/model/rule_schema';
import type { ReviewPrebuiltRuleUpgradeFilter } from '../../../../../../common/api/detection_engine/prebuilt_rules/common/review_prebuilt_rules_upgrade_filter';
import type {
ReviewRuleUpgradeRequestBody,
ReviewRuleUpgradeResponseBody,
RuleUpgradeInfoForReview,
RuleUpgradeStatsForReview,
ThreeWayDiff,
ReviewRuleUpgradeSort,
} from '../../../../../../common/api/detection_engine/prebuilt_rules';
import { ThreeWayDiffOutcome } from '../../../../../../common/api/detection_engine/prebuilt_rules';
import { invariant } from '../../../../../../common/utils/invariant';
import type { SecuritySolutionRequestHandlerContext } from '../../../../../types';
import { buildSiemResponse } from '../../../routes/utils';
import { convertPrebuiltRuleAssetToRuleResponse } from '../../../rule_management/logic/detection_rules_client/converters/convert_prebuilt_rule_asset_to_rule_response';
import type { CalculateRuleDiffResult } from '../../logic/diff/calculate_rule_diff';
import { calculateRuleDiff } from '../../logic/diff/calculate_rule_diff';
import type { IPrebuiltRuleAssetsClient } from '../../logic/rule_assets/prebuilt_rule_assets_client';
import { createPrebuiltRuleAssetsClient } from '../../logic/rule_assets/prebuilt_rule_assets_client';
import type { IPrebuiltRuleObjectsClient } from '../../logic/rule_objects/prebuilt_rule_objects_client';
import { createPrebuiltRuleObjectsClient } from '../../logic/rule_objects/prebuilt_rule_objects_client';
import { fetchRuleVersionsTriad } from '../../logic/rule_versions/fetch_rule_versions_triad';
import { getRuleGroups } from '../../model/rule_groups/get_rule_groups';
import type { RuleVersionSpecifier } from '../../logic/rule_versions/rule_version_specifier';
import { zipRuleVersions } from '../../logic/rule_versions/zip_rule_versions';
import { calculateRuleUpgradeInfo } from './calculate_rule_upgrade_info';
const DEFAULT_SORT: ReviewRuleUpgradeSort = {
field: 'name',
order: 'asc',
};
export const reviewRuleUpgradeHandler = async (
context: SecuritySolutionRequestHandlerContext,
request: KibanaRequest,
request: KibanaRequest<undefined, undefined, ReviewRuleUpgradeRequestBody>,
response: KibanaResponseFactory
) => {
const siemResponse = buildSiemResponse(response);
const { page = 1, per_page: perPage = 20, sort = DEFAULT_SORT, filter } = request.body ?? {};
try {
const ctx = await context.resolve(['core', 'alerting']);
@ -41,21 +44,26 @@ export const reviewRuleUpgradeHandler = async (
const ruleAssetsClient = createPrebuiltRuleAssetsClient(soClient);
const ruleObjectsClient = createPrebuiltRuleObjectsClient(rulesClient);
const ruleVersionsMap = await fetchRuleVersionsTriad({
const { diffResults, totalUpgradeableRules } = await calculateUpgradeableRulesDiff({
ruleAssetsClient,
ruleObjectsClient,
});
const { upgradeableRules } = getRuleGroups(ruleVersionsMap);
const ruleDiffCalculationResults = upgradeableRules.map(({ current }) => {
const ruleVersions = ruleVersionsMap.get(current.rule_id);
invariant(ruleVersions != null, 'ruleVersions not found');
return calculateRuleDiff(ruleVersions);
page,
perPage,
sort,
filter,
});
const body: ReviewRuleUpgradeResponseBody = {
stats: calculateRuleStats(ruleDiffCalculationResults),
rules: calculateRuleInfos(ruleDiffCalculationResults),
stats: {
num_rules_to_upgrade_total: 0,
num_rules_with_conflicts: 0,
num_rules_with_non_solvable_conflicts: 0,
tags: [],
},
rules: calculateRuleUpgradeInfo(diffResults),
page,
per_page: perPage,
total: totalUpgradeableRules,
};
return response.ok({ body });
@ -67,72 +75,68 @@ export const reviewRuleUpgradeHandler = async (
});
}
};
const calculateRuleStats = (results: CalculateRuleDiffResult[]): RuleUpgradeStatsForReview => {
const allTags = new Set<string>();
const stats = results.reduce(
(acc, result) => {
acc.num_rules_to_upgrade_total += 1;
interface CalculateUpgradeableRulesDiffArgs {
ruleAssetsClient: IPrebuiltRuleAssetsClient;
ruleObjectsClient: IPrebuiltRuleObjectsClient;
page: number;
perPage: number;
sort: ReviewRuleUpgradeSort;
filter: ReviewPrebuiltRuleUpgradeFilter | undefined;
}
if (result.ruleDiff.num_fields_with_conflicts > 0) {
acc.num_rules_with_conflicts += 1;
}
async function calculateUpgradeableRulesDiff({
ruleAssetsClient,
ruleObjectsClient,
page,
perPage,
sort,
filter,
}: CalculateUpgradeableRulesDiffArgs) {
const allLatestVersions = await ruleAssetsClient.fetchLatestVersions();
const latestVersionsMap = new Map(allLatestVersions.map((version) => [version.rule_id, version]));
if (result.ruleDiff.num_fields_with_non_solvable_conflicts > 0) {
acc.num_rules_with_non_solvable_conflicts += 1;
}
const currentRuleVersions = filter?.rule_ids
? await ruleObjectsClient.fetchInstalledRuleVersionsByIds({
ruleIds: filter.rule_ids,
sortField: sort.field,
sortOrder: sort.order,
})
: await ruleObjectsClient.fetchInstalledRuleVersions({
filter,
sortField: sort.field,
sortOrder: sort.order,
});
const upgradeableRuleIds = currentRuleVersions
.filter((rule) => {
const targetVersion = latestVersionsMap.get(rule.rule_id);
return targetVersion != null && rule.version < targetVersion.version;
})
.map((rule) => rule.rule_id);
const totalUpgradeableRules = upgradeableRuleIds.length;
result.ruleVersions.input.current?.tags.forEach((tag) => allTags.add(tag));
return acc;
},
{
num_rules_to_upgrade_total: 0,
num_rules_with_conflicts: 0,
num_rules_with_non_solvable_conflicts: 0,
}
const pagedRuleIds = upgradeableRuleIds.slice((page - 1) * perPage, page * perPage);
const currentRules = await ruleObjectsClient.fetchInstalledRulesByIds({
ruleIds: pagedRuleIds,
sortField: sort.field,
sortOrder: sort.order,
});
const latestRules = await ruleAssetsClient.fetchAssetsByVersion(
currentRules.map(({ rule_id: ruleId }) => latestVersionsMap.get(ruleId) as RuleVersionSpecifier)
);
const baseRules = await ruleAssetsClient.fetchAssetsByVersion(currentRules);
const ruleVersionsMap = zipRuleVersions(currentRules, baseRules, latestRules);
// Calculate the diff between current, base, and target versions
// Iterate through the current rules array to keep the order of the results
const diffResults = currentRules.map((current) => {
const base = ruleVersionsMap.get(current.rule_id)?.base;
const target = ruleVersionsMap.get(current.rule_id)?.target;
return calculateRuleDiff({ current, base, target });
});
return {
...stats,
tags: Array.from(allTags),
diffResults,
totalUpgradeableRules,
};
};
const calculateRuleInfos = (results: CalculateRuleDiffResult[]): RuleUpgradeInfoForReview[] => {
return results.map((result) => {
const { ruleDiff, ruleVersions } = result;
const installedCurrentVersion = ruleVersions.input.current;
const targetVersion = ruleVersions.input.target;
invariant(installedCurrentVersion != null, 'installedCurrentVersion not found');
invariant(targetVersion != null, 'targetVersion not found');
const targetRule: RuleResponse = {
...convertPrebuiltRuleAssetToRuleResponse(targetVersion),
id: installedCurrentVersion.id,
revision: installedCurrentVersion.revision + 1,
created_at: installedCurrentVersion.created_at,
created_by: installedCurrentVersion.created_by,
updated_at: new Date().toISOString(),
updated_by: installedCurrentVersion.updated_by,
};
return {
id: installedCurrentVersion.id,
rule_id: installedCurrentVersion.rule_id,
revision: installedCurrentVersion.revision,
current_rule: installedCurrentVersion,
target_rule: targetRule,
diff: {
fields: pickBy<ThreeWayDiff<unknown>>(
ruleDiff.fields,
(fieldDiff) =>
fieldDiff.diff_outcome !== ThreeWayDiffOutcome.StockValueNoUpdate &&
fieldDiff.diff_outcome !== ThreeWayDiffOutcome.MissingBaseNoUpdate
),
num_fields_with_updates: ruleDiff.num_fields_with_updates,
num_fields_with_conflicts: ruleDiff.num_fields_with_conflicts,
num_fields_with_non_solvable_conflicts: ruleDiff.num_fields_with_non_solvable_conflicts,
},
};
});
};
}

View file

@ -5,12 +5,16 @@
* 2.0.
*/
import { REVIEW_RULE_UPGRADE_URL } from '../../../../../../common/api/detection_engine/prebuilt_rules';
import type { SecuritySolutionPluginRouter } from '../../../../../types';
import { routeLimitedConcurrencyTag } from '../../../../../utils/route_limited_concurrency_tag';
import { buildRouteValidationWithZod } from '@kbn/zod-helpers';
import {
REVIEW_RULE_UPGRADE_URL,
ReviewRuleUpgradeRequestBody,
} from '../../../../../../common/api/detection_engine/prebuilt_rules';
import { routeLimitedConcurrencyTag } from '../../../../../utils/route_limited_concurrency_tag';
import type { SecuritySolutionPluginRouter } from '../../../../../types';
import {
PREBUILT_RULES_OPERATION_CONCURRENCY,
PREBUILT_RULES_OPERATION_SOCKET_TIMEOUT_MS,
PREBUILT_RULES_UPGRADE_REVIEW_CONCURRENCY,
} from '../../constants';
import { reviewRuleUpgradeHandler } from './review_rule_upgrade_handler';
@ -25,7 +29,7 @@ export const reviewRuleUpgradeRoute = (router: SecuritySolutionPluginRouter) =>
},
},
options: {
tags: [routeLimitedConcurrencyTag(PREBUILT_RULES_OPERATION_CONCURRENCY)],
tags: [routeLimitedConcurrencyTag(PREBUILT_RULES_UPGRADE_REVIEW_CONCURRENCY)],
timeout: {
idleSocket: PREBUILT_RULES_OPERATION_SOCKET_TIMEOUT_MS,
},
@ -34,7 +38,11 @@ export const reviewRuleUpgradeRoute = (router: SecuritySolutionPluginRouter) =>
.addVersion(
{
version: '1',
validate: {},
validate: {
request: {
body: buildRouteValidationWithZod(ReviewRuleUpgradeRequestBody),
},
},
},
reviewRuleUpgradeHandler
);

View file

@ -10,3 +10,11 @@ export const PREBUILT_RULES_OPERATION_SOCKET_TIMEOUT_MS = 1_800_000 as const; //
// Only one rule installation or upgrade request can be processed at a time.
// Multiple requests can lead to high memory usage and unexpected behavior.
export const PREBUILT_RULES_OPERATION_CONCURRENCY = 1;
/**
* Prebuilt rules upgrade review API endpoint max concurrency.
*
* It differs from PREBUILT_RULES_OPERATION_CONCURRENCY since upgrade review API endpoint
* is expected to be requested much more often than the other prebuilt rules API endpoints.
*/
export const PREBUILT_RULES_UPGRADE_REVIEW_CONCURRENCY = 3;

View file

@ -9,36 +9,73 @@ import type { RulesClient } from '@kbn/alerting-plugin/server';
import type {
RuleResponse,
RuleSignatureId,
RuleTagArray,
} from '../../../../../../common/api/detection_engine/model/rule_schema';
import { withSecuritySpan } from '../../../../../utils/with_security_span';
import { findRules } from '../../../rule_management/logic/search/find_rules';
import { getExistingPrepackagedRules } from '../../../rule_management/logic/search/get_existing_prepackaged_rules';
import { internalRuleToAPIResponse } from '../../../rule_management/logic/detection_rules_client/converters/internal_rule_to_api_response';
import { convertRulesFilterToKQL } from '../../../../../../common/detection_engine/rule_management/rule_filtering';
import type {
FindRulesSortField,
PrebuiltRulesFilter,
SortOrder,
} from '../../../../../../common/api/detection_engine';
import { MAX_PREBUILT_RULES_COUNT } from '../../../rule_management/logic/search/get_existing_prepackaged_rules';
import type { RuleVersionSpecifier } from '../rule_versions/rule_version_specifier';
interface FetchAllInstalledRulesArgs {
page?: number;
perPage?: number;
filter?: PrebuiltRulesFilter;
sortField?: FindRulesSortField;
sortOrder?: SortOrder;
}
interface FetchAllInstalledRuleVersionsArgs {
filter?: PrebuiltRulesFilter;
sortField?: FindRulesSortField;
sortOrder?: SortOrder;
}
interface FetchInstalledRuleVersionsByIdsArgs {
ruleIds: RuleSignatureId[];
sortField?: FindRulesSortField;
sortOrder?: SortOrder;
}
interface FetchInstalledRulesByIdsArgs {
ruleIds: RuleSignatureId[];
sortField?: FindRulesSortField;
sortOrder?: SortOrder;
}
export interface IPrebuiltRuleObjectsClient {
fetchAllInstalledRules(): Promise<RuleResponse[]>;
fetchInstalledRulesByIds(ruleIds: string[]): Promise<RuleResponse[]>;
fetchInstalledRulesByIds(args: FetchInstalledRulesByIdsArgs): Promise<RuleResponse[]>;
fetchInstalledRules(args?: FetchAllInstalledRulesArgs): Promise<RuleResponse[]>;
fetchInstalledRuleVersionsByIds(
args: FetchInstalledRuleVersionsByIdsArgs
): Promise<Array<RuleVersionSpecifier & { tags: RuleTagArray }>>;
fetchInstalledRuleVersions(
args?: FetchAllInstalledRuleVersionsArgs
): Promise<Array<RuleVersionSpecifier & { tags: RuleTagArray }>>;
}
export const createPrebuiltRuleObjectsClient = (
rulesClient: RulesClient
): IPrebuiltRuleObjectsClient => {
return {
fetchAllInstalledRules: (): Promise<RuleResponse[]> => {
return withSecuritySpan('IPrebuiltRuleObjectsClient.fetchInstalledRules', async () => {
const rulesData = await getExistingPrepackagedRules({ rulesClient });
const rules = rulesData.map((rule) => internalRuleToAPIResponse(rule));
return rules;
});
},
fetchInstalledRulesByIds: (ruleIds: RuleSignatureId[]): Promise<RuleResponse[]> => {
fetchInstalledRulesByIds: ({ ruleIds, sortField = 'createdAt', sortOrder = 'desc' }) => {
return withSecuritySpan('IPrebuiltRuleObjectsClient.fetchInstalledRulesByIds', async () => {
if (ruleIds.length === 0) {
return [];
}
const { data } = await findRules({
rulesClient,
perPage: ruleIds.length,
page: 1,
sortField: 'createdAt',
sortOrder: 'desc',
sortField,
sortOrder,
fields: undefined,
filter: `alert.attributes.params.ruleId:(${ruleIds.join(' or ')})`,
});
@ -47,5 +84,78 @@ export const createPrebuiltRuleObjectsClient = (
return rules;
});
},
fetchInstalledRules: ({ page, perPage, sortField, sortOrder, filter } = {}) => {
return withSecuritySpan('IPrebuiltRuleObjectsClient.fetchInstalledRules', async () => {
const filterKQL = convertRulesFilterToKQL({
showElasticRules: true,
filter: filter?.name,
tags: filter?.tags,
customizationStatus: filter?.customization_status,
});
const rulesData = await findRules({
rulesClient,
filter: filterKQL,
perPage,
page,
sortField,
sortOrder,
fields: undefined,
});
const rules = rulesData.data.map((rule) => internalRuleToAPIResponse(rule));
return rules;
});
},
fetchInstalledRuleVersionsByIds: ({ ruleIds, sortField, sortOrder }) => {
return withSecuritySpan(
'IPrebuiltRuleObjectsClient.fetchInstalledRuleVersionsByIds',
async () => {
const filterKQL = convertRulesFilterToKQL({
showElasticRules: true,
});
const rulesData = await findRules({
rulesClient,
ruleIds,
filter: filterKQL,
perPage: MAX_PREBUILT_RULES_COUNT,
page: 1,
sortField,
sortOrder,
fields: ['params.ruleId', 'params.version', 'tags'],
});
return rulesData.data.map((rule) => ({
rule_id: rule.params.ruleId,
version: rule.params.version,
tags: rule.tags,
}));
}
);
},
fetchInstalledRuleVersions: ({ filter, sortField, sortOrder } = {}) => {
return withSecuritySpan('IPrebuiltRuleObjectsClient.fetchInstalledRuleVersions', async () => {
const filterKQL = convertRulesFilterToKQL({
showElasticRules: true,
filter: filter?.name,
tags: filter?.tags,
customizationStatus: filter?.customization_status,
});
const rulesData = await findRules({
rulesClient,
filter: filterKQL,
perPage: MAX_PREBUILT_RULES_COUNT,
page: 1,
sortField,
sortOrder,
fields: ['params.ruleId', 'params.version', 'tags'],
});
return rulesData.data.map((rule) => ({
rule_id: rule.params.ruleId,
version: rule.params.version,
tags: rule.tags,
}));
});
},
};
};

View file

@ -5,6 +5,8 @@
* 2.0.
*/
import type { PrebuiltRulesFilter } from '../../../../../../common/api/detection_engine';
import { MAX_PREBUILT_RULES_COUNT } from '../../../rule_management/logic/search/get_existing_prepackaged_rules';
import type { RuleVersions } from '../diff/calculate_rule_diff';
import type { IPrebuiltRuleAssetsClient } from '../rule_assets/prebuilt_rule_assets_client';
import type { IPrebuiltRuleObjectsClient } from '../rule_objects/prebuilt_rule_objects_client';
@ -15,19 +17,25 @@ interface GetRuleVersionsMapArgs {
ruleObjectsClient: IPrebuiltRuleObjectsClient;
ruleAssetsClient: IPrebuiltRuleAssetsClient;
versionSpecifiers?: RuleVersionSpecifier[];
filter?: PrebuiltRulesFilter;
}
export async function fetchRuleVersionsTriad({
ruleObjectsClient,
ruleAssetsClient,
versionSpecifiers,
filter,
}: GetRuleVersionsMapArgs): Promise<Map<string, RuleVersions>> {
const [currentRules, latestRules] = await Promise.all([
versionSpecifiers
? ruleObjectsClient.fetchInstalledRulesByIds(
versionSpecifiers.map(({ rule_id: ruleId }) => ruleId)
)
: ruleObjectsClient.fetchAllInstalledRules(),
? ruleObjectsClient.fetchInstalledRulesByIds({
ruleIds: versionSpecifiers.map(({ rule_id: ruleId }) => ruleId),
})
: ruleObjectsClient.fetchInstalledRules({
filter,
page: 1,
perPage: MAX_PREBUILT_RULES_COUNT,
}),
versionSpecifiers
? ruleAssetsClient.fetchAssetsByVersion(versionSpecifiers)
: ruleAssetsClient.fetchLatestAssets(),

View file

@ -49,16 +49,20 @@ export const getRulesCount = async ({
export const getRules = async ({
rulesClient,
filter,
page = 1,
perPage = MAX_PREBUILT_RULES_COUNT,
}: {
rulesClient: RulesClient;
filter: string;
page?: number;
perPage?: number;
}): Promise<RuleAlertType[]> =>
withSecuritySpan('getRules', async () => {
const rules = await findRules({
rulesClient,
filter,
perPage: MAX_PREBUILT_RULES_COUNT,
page: 1,
perPage,
page,
sortField: 'createdAt',
sortOrder: 'desc',
fields: undefined,
@ -80,11 +84,17 @@ export const getNonPackagedRules = async ({
export const getExistingPrepackagedRules = async ({
rulesClient,
page,
perPage,
}: {
rulesClient: RulesClient;
page?: number;
perPage?: number;
}): Promise<RuleAlertType[]> => {
return getRules({
rulesClient,
page,
perPage,
filter: KQL_FILTER_IMMUTABLE_RULES,
});
};

View file

@ -11,7 +11,6 @@ import { FtrProviderContext } from '../../../../../../ftr_provider_context';
import {
deleteAllPrebuiltRuleAssets,
fetchFirstPrebuiltRuleUpgradeReviewDiff,
reviewPrebuiltRulesToUpgrade,
} from '../../../../utils';
import { setUpRuleUpgrade } from '../../../../utils/rules/prebuilt_rules/set_up_rule_upgrade';
@ -36,241 +35,6 @@ export default ({ getService }: FtrProviderContext): void => {
describe(
withHistoricalVersions ? 'with historical versions' : 'without historical versions',
() => {
describe('stats', () => {
it('returns num of rules with upgrades', async () => {
await setUpRuleUpgrade({
assets: [
{
installed: {
rule_id: 'query-rule',
type: 'query',
version: 1,
},
patch: {},
upgrade: {
rule_id: 'query-rule',
type: 'query',
version: 2,
},
},
{
installed: {
rule_id: 'saved-query-rule',
type: 'query',
version: 1,
},
patch: {},
upgrade: {
rule_id: 'saved-query-rule',
type: 'query',
version: 2,
},
},
],
removeInstalledAssets: !withHistoricalVersions,
deps,
});
const response = await reviewPrebuiltRulesToUpgrade(supertest);
expect(response.stats).toMatchObject({
num_rules_to_upgrade_total: 2,
});
});
it('returns zero conflicts when there are no conflicts', async () => {
await setUpRuleUpgrade({
assets: [
{
installed: {
rule_id: 'query-rule',
type: 'query',
version: 1,
},
patch: {},
upgrade: {
rule_id: 'query-rule',
type: 'query',
version: 2,
},
},
{
installed: {
rule_id: 'saved-query-rule',
type: 'query',
version: 1,
},
patch: {},
upgrade: {
rule_id: 'saved-query-rule',
type: 'query',
version: 2,
},
},
],
removeInstalledAssets: !withHistoricalVersions,
deps,
});
const response = await reviewPrebuiltRulesToUpgrade(supertest);
expect(response.stats).toMatchObject({
num_rules_with_conflicts: 0,
num_rules_with_non_solvable_conflicts: 0,
});
});
it('returns num of rules with conflicts', async () => {
await setUpRuleUpgrade({
assets: [
{
installed: {
rule_id: 'query-rule',
type: 'query',
name: 'Initial name',
version: 1,
},
patch: {
rule_id: 'query-rule',
name: 'Customized name',
},
upgrade: {
rule_id: 'query-rule',
type: 'query',
name: 'Updated name',
version: 2,
},
},
{
installed: {
rule_id: 'saved-query-rule',
type: 'query',
tags: ['tagA'],
version: 1,
},
patch: {
rule_id: 'saved-query-rule',
tags: ['tagB'],
},
upgrade: {
rule_id: 'saved-query-rule',
type: 'query',
tags: ['tagC'],
version: 2,
},
},
],
removeInstalledAssets: !withHistoricalVersions,
deps,
});
const response = await reviewPrebuiltRulesToUpgrade(supertest);
expect(response.stats).toMatchObject({
num_rules_with_conflicts: 2,
});
});
it('returns num of rules with non-solvable conflicts', async () => {
await setUpRuleUpgrade({
assets: [
// Name field has a non-solvable upgrade conflict
{
installed: {
rule_id: 'query-rule',
type: 'query',
name: 'Initial name',
version: 1,
},
patch: {
rule_id: 'query-rule',
name: 'Customized name',
},
upgrade: {
rule_id: 'query-rule',
type: 'query',
name: 'Updated name',
version: 2,
},
},
// tags field values are merged resulting in a solvable upgrade conflict
{
installed: {
rule_id: 'saved-query-rule',
type: 'query',
tags: ['tagA'],
version: 1,
},
patch: {
rule_id: 'saved-query-rule',
tags: ['tagB'],
},
upgrade: {
rule_id: 'saved-query-rule',
type: 'query',
tags: ['tagC'],
version: 2,
},
},
],
removeInstalledAssets: !withHistoricalVersions,
deps,
});
const response = await reviewPrebuiltRulesToUpgrade(supertest);
expect(response.stats).toMatchObject({
// Missing rule's base version doesn't allow to detect non solvable conflicts
num_rules_with_non_solvable_conflicts: withHistoricalVersions ? 1 : 0,
});
});
if (!withHistoricalVersions) {
it('returns num of rules with conflicts caused by missing historical versions', async () => {
await setUpRuleUpgrade({
assets: [
{
installed: {
rule_id: 'query-rule',
type: 'query',
name: 'Initial name',
version: 1,
},
patch: {},
upgrade: {
rule_id: 'query-rule',
type: 'query',
version: 2,
},
},
{
installed: {
rule_id: 'saved-query-rule',
type: 'query',
version: 1,
},
patch: {},
upgrade: {
rule_id: 'saved-query-rule',
type: 'query',
name: 'Updated name',
version: 2,
},
},
],
removeInstalledAssets: true,
deps,
});
const response = await reviewPrebuiltRulesToUpgrade(supertest);
expect(response.stats).toMatchObject({
num_rules_with_conflicts: 2,
});
});
}
});
describe('fields diff stats', () => {
it('returns num of fields with updates', async () => {
await setUpRuleUpgrade({

View file

@ -21,7 +21,6 @@ import {
installPrebuiltRulesPackageByVersion,
performUpgradePrebuiltRules,
reviewPrebuiltRulesToInstall,
reviewPrebuiltRulesToUpgrade,
} from '../../../../utils';
import { deleteAllRules } from '../../../../../../../common/utils/security_solution';
@ -220,13 +219,6 @@ export default ({ getService }: FtrProviderContext): void => {
)
);
// Verify that the upgrade _review endpoint returns the same number of rules to upgrade as the status endpoint
const prebuiltRulesToUpgradeReviewAfterLatestPackageInstallation =
await reviewPrebuiltRulesToUpgrade(supertest);
expect(
prebuiltRulesToUpgradeReviewAfterLatestPackageInstallation.stats.num_rules_to_upgrade_total
).toBe(statusAfterLatestPackageInstallation.stats.num_prebuilt_rules_to_upgrade);
// Call the upgrade _perform endpoint to upgrade all rules to their target version and verify that the number
// of upgraded rules is the same as the one returned by the _review endpoint and the status endpoint
const upgradePrebuiltRulesResponseAfterLatestPackageInstallation =
@ -238,9 +230,6 @@ export default ({ getService }: FtrProviderContext): void => {
expect(upgradePrebuiltRulesResponseAfterLatestPackageInstallation.summary.succeeded).toEqual(
statusAfterLatestPackageInstallation.stats.num_prebuilt_rules_to_upgrade
);
expect(upgradePrebuiltRulesResponseAfterLatestPackageInstallation.summary.succeeded).toEqual(
prebuiltRulesToUpgradeReviewAfterLatestPackageInstallation.stats.num_rules_to_upgrade_total
);
// Get installed rules

View file

@ -5,6 +5,10 @@
* 2.0.
*/
import {
deleteAlertsAndRules,
deletePrebuiltRulesAssets,
} from '../../../../tasks/api_calls/common';
import { createRuleAssetSavedObject } from '../../../../helpers/rules';
import {
getInstallSingleRuleButtonByRuleId,
@ -42,6 +46,8 @@ describe(
{ tags: ['@ess', '@serverless', '@skipInServerlessMKI'] },
() => {
beforeEach(() => {
deletePrebuiltRulesAssets();
deleteAlertsAndRules();
preventPrebuiltRulesPackageInstallation();
login();
visitRulesManagementTable();

View file

@ -114,10 +114,6 @@ describe(
selectRulesByName(['Old rule 1', 'Old rule 2']);
cy.get(UPGRADE_SELECTED_RULES_BUTTON).should('be.disabled');
});
it('should disable `Update all rules` button when all rules have conflicts', () => {
cy.get(UPGRADE_ALL_RULES_BUTTON).should('be.disabled');
});
});
describe('Upgrade of prebuilt rules with and without conflicts', () => {
@ -323,10 +319,6 @@ describe(
]);
cy.get(UPGRADE_SELECTED_RULES_BUTTON).should('be.disabled');
});
it('should disable `Update all rules` button', () => {
cy.get(UPGRADE_ALL_RULES_BUTTON).should('be.disabled');
});
});
}
);

View file

@ -145,22 +145,16 @@ export const bulkCreateRuleAssets = ({
const bulkIndexRequestBody = rules.reduce((body, rule) => {
const document = JSON.stringify(rule);
const documentId = `security-rule:${rule['security-rule'].rule_id}`;
const historicalDocumentId = `${documentId}_${rule['security-rule'].version}`;
const documentIdWithVersion = `${documentId}_${rule['security-rule'].version}`;
const indexRuleAsset = `${JSON.stringify({
index: {
_index: index,
_id: documentId,
},
})}\n${document}\n`;
const indexHistoricalRuleAsset = `${JSON.stringify({
index: {
_index: index,
_id: historicalDocumentId,
_id: documentIdWithVersion,
},
})}\n${document}\n`;
return body.concat(indexRuleAsset, indexHistoricalRuleAsset);
return body.concat(indexHistoricalRuleAsset);
}, '');
cy.task('putMapping', index);

View file

@ -80,6 +80,15 @@ export const interceptUpgradeRequestToFail = (rules: Array<typeof SAMPLE_PREBUIL
skipped: [],
failed: rules.length,
},
results: {
updated: [],
skipped: [],
},
errors: {
message: 'Test error',
status_code: 400,
rules: [{ rule_id: 'test_rule', name: 'Test rule' }],
},
},
delay: 500, // Add delay to give Cypress time to find the loading spinner
}).as('updatePrebuiltRules');
@ -107,7 +116,7 @@ export const assertRuleUpgradeSuccessToastShown = (rules: Array<typeof SAMPLE_PR
const rulesString = rules.length > 1 ? 'rules' : 'rule';
cy.get(TOASTER)
.should('be.visible')
.should('have.text', `${rules.length} ${rulesString} updated successfully.`);
.should('contain', `${rules.length} ${rulesString} updated successfully.`);
};
export const assertRuleUpgradeFailureToastShown = (rules: Array<typeof SAMPLE_PREBUILT_RULE>) => {