Skip to content

Commit

Permalink
[Security Solution] Allow bulk upgrade rules with solvable conflicts (e…
Browse files Browse the repository at this point in the history
…lastic#213285)

**Partially addresses:** elastic#210358

## Summary

This PR implements functionality allowing users to bulk upgrade rules with solvable conflicts.

## Details

The main focus of this PR is to allow users to bulk upgrade rules with solvable conflicts. To achieve that the following was done

- `upgrade/_perform` dry run functionality was extended to take into account rule upgrade specifiers with resolved value
- `upgrade/_perform`'s `on_conflict` param was extended with `UPGRADE_SOLVABLE` to allow bulk upgrading rules with solvable conflicts
- UI logic updated accordingly to display rule upgrade modal when users have to make a choice to upgrade only rules without conflicts or upgrade also rules with solvable conflicts
- conflict state badges were added to the rule upgrade table

It includes changes from elastic#213027 with some modifications.

## Screenshots

<img width="1723" alt="Screenshot 2025-03-06 at 12 13 04" src="https://github.com/user-attachments/assets/b786e813-268d-49a2-80cc-81fa95d14e85" />
<img width="1724" alt="Screenshot 2025-03-06 at 12 13 30" src="https://github.com/user-attachments/assets/e5e38bd9-78a3-4026-a7ea-892bd7153938" />
<img width="1723" alt="Screenshot 2025-03-06 at 12 13 51" src="https://github.com/user-attachments/assets/d58872c3-f197-49ad-b4f3-5f45fb1efac2" />
<img width="1723" alt="Screenshot 2025-03-06 at 12 14 04" src="https://github.com/user-attachments/assets/667a6ab2-2fdb-430d-9589-1c4a6e5cdc8b" />
<img width="1722" alt="Screenshot 2025-03-06 at 12 14 17" src="https://github.com/user-attachments/assets/07f4cffe-4398-4fd5-8350-a3a2978d7dcd" />

(cherry picked from commit f2077db)
  • Loading branch information
maximpn committed Mar 6, 2025
1 parent f9385bd commit 61b6378
Show file tree
Hide file tree
Showing 17 changed files with 574 additions and 112 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
import { z } from '@kbn/zod';
import { mapValues } from 'lodash';
import { RuleResponse } from '../../model/rule_schema/rule_schemas.gen';
import { AggregatedPrebuiltRuleError, DiffableAllFields } from '../model';
import { AggregatedPrebuiltRuleError, DiffableAllFields, ThreeWayDiffConflict } from '../model';
import { RuleSignatureId, RuleVersion } from '../../model';
import { PrebuiltRulesFilter } from '../common/prebuilt_rules_filter';

Expand Down Expand Up @@ -113,7 +113,7 @@ export const RuleUpgradeSpecifier = z.object({
});

export type UpgradeConflictResolution = z.infer<typeof UpgradeConflictResolution>;
export const UpgradeConflictResolution = z.enum(['SKIP', 'OVERWRITE']);
export const UpgradeConflictResolution = z.enum(['SKIP', 'UPGRADE_SOLVABLE']);
export type UpgradeConflictResolutionEnum = typeof UpgradeConflictResolution.enum;
export const UpgradeConflictResolutionEnum = UpgradeConflictResolution.enum;

Expand All @@ -140,12 +140,25 @@ export const SkipRuleUpgradeReason = z.enum(['RULE_UP_TO_DATE', 'CONFLICT']);
export type SkipRuleUpgradeReasonEnum = typeof SkipRuleUpgradeReason.enum;
export const SkipRuleUpgradeReasonEnum = SkipRuleUpgradeReason.enum;

export type SkippedRuleUpgrade = z.infer<typeof SkippedRuleUpgrade>;
export const SkippedRuleUpgrade = z.object({
export type RuleUpToDateSkipReason = z.infer<typeof RuleUpToDateSkipReason>;
export const RuleUpToDateSkipReason = z.object({
reason: z.literal(SkipRuleUpgradeReasonEnum.RULE_UP_TO_DATE),
rule_id: z.string(),
});

export type UpgradeConflictSkipReason = z.infer<typeof UpgradeConflictSkipReason>;
export const UpgradeConflictSkipReason = z.object({
reason: z.literal(SkipRuleUpgradeReasonEnum.CONFLICT),
rule_id: z.string(),
reason: SkipRuleUpgradeReason,
conflict: z.nativeEnum(ThreeWayDiffConflict),
});

export type SkippedRuleUpgrade = z.infer<typeof SkippedRuleUpgrade>;
export const SkippedRuleUpgrade = z.discriminatedUnion('reason', [
RuleUpToDateSkipReason,
UpgradeConflictSkipReason,
]);

export type PerformRuleUpgradeResponseBody = z.infer<typeof PerformRuleUpgradeResponseBody>;
export const PerformRuleUpgradeResponseBody = z.object({
summary: z.object({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -575,7 +575,7 @@ const RuleDetailsPageComponent: React.FC<DetectionEngineComponentProps> = ({
<EuiConfirmModal
title={ruleI18n.SINGLE_DELETE_CONFIRMATION_TITLE}
onCancel={handleDeletionCancel}
onConfirm={handleDeletionConfirm}
onConfirm={() => handleDeletionConfirm()}
confirmButtonText={ruleI18n.DELETE_CONFIRMATION_CONFIRM}
cancelButtonText={ruleI18n.DELETE_CONFIRMATION_CANCEL}
buttonColor="danger"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,16 +15,23 @@ import { useIsUpgradingSecurityPackages } from '../logic/use_upgrade_security_pa
import { usePrebuiltRulesCustomizationStatus } from '../logic/prebuilt_rules/use_prebuilt_rules_customization_status';
import { usePerformUpgradeRules } from '../logic/prebuilt_rules/use_perform_rule_upgrade';
import { usePrebuiltRulesUpgradeReview } from '../logic/prebuilt_rules/use_prebuilt_rules_upgrade_review';
import type {
FindRulesSortField,
RuleFieldsToUpgrade,
RuleResponse,
RuleSignatureId,
RuleUpgradeSpecifier,
import type { PerformRuleUpgradeRequestBody } from '../../../../common/api/detection_engine';
import {
type FindRulesSortField,
type RuleFieldsToUpgrade,
type RuleResponse,
type RuleSignatureId,
type RuleUpgradeSpecifier,
ThreeWayDiffConflict,
SkipRuleUpgradeReasonEnum,
UpgradeConflictResolutionEnum,
} from '../../../../common/api/detection_engine';
import { usePrebuiltRulesUpgradeState } from '../../rule_management_ui/components/rules_table/upgrade_prebuilt_rules_table/use_prebuilt_rules_upgrade_state';
import { useOutdatedMlJobsUpgradeModal } from '../../rule_management_ui/components/rules_table/upgrade_prebuilt_rules_table/use_ml_jobs_upgrade_modal';
import { useUpgradeWithConflictsModal } from '../../rule_management_ui/components/rules_table/upgrade_prebuilt_rules_table/use_upgrade_with_conflicts_modal';
import {
ConfirmRulesUpgrade,
useUpgradeWithConflictsModal,
} from '../../rule_management_ui/components/rules_table/upgrade_prebuilt_rules_table/use_upgrade_with_conflicts_modal';
import * as ruleDetailsI18n from '../components/rule_details/translations';
import * as i18n from '../../rule_management_ui/components/rules_table/upgrade_prebuilt_rules_table/translations';
import { UpgradeFlyoutSubHeader } from '../../rule_management_ui/components/rules_table/upgrade_prebuilt_rules_table/upgrade_flyout_subheader';
Expand All @@ -36,6 +43,7 @@ import { RuleDiffTab } from '../components/rule_details/rule_diff_tab';
import { useRulePreviewFlyout } from '../../rule_management_ui/components/rules_table/use_rule_preview_flyout';
import type { UpgradePrebuiltRulesSortingOptions } from '../../rule_management_ui/components/rules_table/upgrade_prebuilt_rules_table/upgrade_prebuilt_rules_table_context';
import { RULES_TABLE_INITIAL_PAGE_SIZE } from '../../rule_management_ui/components/rules_table/constants';
import type { RulesConflictStats } from '../../rule_management_ui/components/rules_table/upgrade_prebuilt_rules_table/use_upgrade_with_conflicts_modal/upgrade_modal';

const REVIEW_PREBUILT_RULES_UPGRADE_REFRESH_INTERVAL = 5 * 60 * 1000;

Expand Down Expand Up @@ -100,46 +108,34 @@ export function usePrebuiltRulesUpgrade({
const { modal: upgradeConflictsModal, confirmConflictsUpgrade } = useUpgradeWithConflictsModal();

const { mutateAsync: upgradeRulesRequest } = usePerformUpgradeRules();
const upgradeRulesWithDryRun = useRulesUpgradeWithDryRun(confirmConflictsUpgrade);

const upgradeRulesToResolved = useCallback(
async (ruleIds: RuleSignatureId[]) => {
const conflictRuleIdsSet = new Set(
ruleIds.filter(
(ruleId) =>
rulesUpgradeState[ruleId].diff.num_fields_with_conflicts > 0 &&
rulesUpgradeState[ruleId].hasUnresolvedConflicts
)
);

const upgradingRuleIds = ruleIds.filter((ruleId) => !conflictRuleIdsSet.has(ruleId));
const ruleUpgradeSpecifiers: RuleUpgradeSpecifier[] = upgradingRuleIds.map((ruleId) => ({
const ruleUpgradeSpecifiers: RuleUpgradeSpecifier[] = ruleIds.map((ruleId) => ({
rule_id: ruleId,
version: rulesUpgradeState[ruleId].target_rule.version,
revision: rulesUpgradeState[ruleId].revision,
fields: constructRuleFieldsToUpgrade(rulesUpgradeState[ruleId]),
}));

setLoadingRules((prev) => [...prev, ...upgradingRuleIds]);
setLoadingRules((prev) => [...prev, ...ruleIds]);

try {
// Handle MLJobs modal
if (!(await confirmLegacyMLJobs())) {
return;
}

if (conflictRuleIdsSet.size > 0 && !(await confirmConflictsUpgrade())) {
return;
}

await upgradeRulesRequest({
await upgradeRulesWithDryRun({
mode: 'SPECIFIC_RULES',
pick_version: 'MERGED',
rules: ruleUpgradeSpecifiers,
});
} catch {
// Error is handled by the mutation's onError callback, so no need to do anything here
} finally {
const upgradedRuleIdsSet = new Set(upgradingRuleIds);
const upgradedRuleIdsSet = new Set(ruleIds);

if (onUpgrade) {
onUpgrade();
Expand All @@ -148,13 +144,7 @@ export function usePrebuiltRulesUpgrade({
setLoadingRules((prev) => prev.filter((id) => !upgradedRuleIdsSet.has(id)));
}
},
[
rulesUpgradeState,
confirmLegacyMLJobs,
confirmConflictsUpgrade,
upgradeRulesRequest,
onUpgrade,
]
[rulesUpgradeState, confirmLegacyMLJobs, upgradeRulesWithDryRun, onUpgrade]
);

const upgradeRulesToTarget = useCallback(
Expand Down Expand Up @@ -213,27 +203,10 @@ export function usePrebuiltRulesUpgrade({
return;
}

const dryRunResults = await upgradeRulesRequest({
mode: 'ALL_RULES',
pick_version: isRulesCustomizationEnabled ? 'MERGED' : 'TARGET',
filter,
dry_run: true,
on_conflict: 'SKIP',
});

const hasConflicts = dryRunResults.results.skipped.some(
(skippedRule) => skippedRule.reason === 'CONFLICT'
);

if (hasConflicts && !(await confirmConflictsUpgrade())) {
return;
}

await upgradeRulesRequest({
await upgradeRulesWithDryRun({
mode: 'ALL_RULES',
pick_version: isRulesCustomizationEnabled ? 'MERGED' : 'TARGET',
filter,
on_conflict: 'SKIP',
});
} catch {
// Error is handled by the mutation's onError callback, so no need to do anything here
Expand All @@ -242,11 +215,10 @@ export function usePrebuiltRulesUpgrade({
}
}, [
upgradeableRules,
upgradeRulesWithDryRun,
confirmLegacyMLJobs,
upgradeRulesRequest,
isRulesCustomizationEnabled,
filter,
confirmConflictsUpgrade,
]);

const subHeaderFactory = useCallback(
Expand Down Expand Up @@ -405,6 +377,68 @@ export function usePrebuiltRulesUpgrade({
};
}

/**
* Upgrades rules in two steps
* - first fires a dry run request to check for rule upgrade conflicts. If there are conflicts
* it calls `confirmConflictsUpgrade()` and await its result.
* - second it either fires a request to upgrade rules or exits depending on user's choice
*/
function useRulesUpgradeWithDryRun(
confirmConflictsUpgrade: (
conflictsStats: RulesConflictStats
) => Promise<ConfirmRulesUpgrade | boolean>
) {
const { mutateAsync: upgradeRulesRequest } = usePerformUpgradeRules();

return useCallback(
async (requestParams: PerformRuleUpgradeRequestBody) => {
const dryRunResults = await upgradeRulesRequest({
...requestParams,
dry_run: true,
on_conflict: UpgradeConflictResolutionEnum.SKIP,
});

const numOfRulesWithSolvableConflicts = dryRunResults.results.skipped.filter(
(x) =>
x.reason === SkipRuleUpgradeReasonEnum.CONFLICT &&
x.conflict === ThreeWayDiffConflict.SOLVABLE
).length;
const numOfRulesWithNonSolvableConflicts = dryRunResults.results.skipped.filter(
(x) =>
x.reason === SkipRuleUpgradeReasonEnum.CONFLICT &&
x.conflict === ThreeWayDiffConflict.NON_SOLVABLE
).length;

if (numOfRulesWithSolvableConflicts === 0 && numOfRulesWithNonSolvableConflicts === 0) {
// There are no rule with conflicts
await upgradeRulesRequest({
...requestParams,
on_conflict: UpgradeConflictResolutionEnum.SKIP,
});
} else {
const result = await confirmConflictsUpgrade({
numOfRulesWithoutConflicts: dryRunResults.results.updated.length,
numOfRulesWithSolvableConflicts,
numOfRulesWithNonSolvableConflicts,
});

if (!result) {
return;
}

await upgradeRulesRequest({
...requestParams,
on_conflict:
result === ConfirmRulesUpgrade.WithSolvableConflicts
? UpgradeConflictResolutionEnum.UPGRADE_SOLVABLE
: UpgradeConflictResolutionEnum.SKIP,
});
}
},
[upgradeRulesRequest, confirmConflictsUpgrade]
);
}

function constructRuleFieldsToUpgrade(ruleUpgradeState: RuleUpgradeState): RuleFieldsToUpgrade {
const ruleFieldsToUpgrade: Record<string, unknown> = {};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,17 @@
* 2.0.
*/

import { type RuleUpgradeInfoForReview } from '../../../../../common/api/detection_engine';
import {
type ThreeWayDiffConflict,
type RuleUpgradeInfoForReview,
} from '../../../../../common/api/detection_engine';
import type { FieldsUpgradeState } from './fields_upgrade_state';

export interface RuleUpgradeState extends RuleUpgradeInfoForReview {
/**
* Original rule conflict state calculated from fields diff.
*/
conflict: ThreeWayDiffConflict;
/**
* Stores a record of customizable field names mapped to field upgrade state.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@

import { useCallback, useRef } from 'react';

type UseAsyncConfirmationReturn = [
initConfirmation: () => Promise<boolean>,
confirm: () => void,
type UseAsyncConfirmationReturn<ConfirmResult = unknown> = [
initConfirmation: () => Promise<ConfirmResult | boolean>,
confirm: (result?: ConfirmResult) => void,
cancel: () => void
];

Expand All @@ -19,14 +19,15 @@ interface UseAsyncConfirmationArgs {
}

// TODO move to common hooks
export const useAsyncConfirmation = ({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const useAsyncConfirmation = <ConfirmResult = any>({
onInit,
onFinish,
}: UseAsyncConfirmationArgs): UseAsyncConfirmationReturn => {
const confirmationPromiseRef = useRef<(result: boolean) => void>();
}: UseAsyncConfirmationArgs): UseAsyncConfirmationReturn<ConfirmResult> => {
const confirmationPromiseRef = useRef<(result: ConfirmResult | boolean) => void>();

const confirm = useCallback(() => {
confirmationPromiseRef.current?.(true);
const confirm = useCallback((result?: ConfirmResult) => {
confirmationPromiseRef.current?.(result ?? true);
}, []);

const cancel = useCallback(() => {
Expand All @@ -36,7 +37,7 @@ export const useAsyncConfirmation = ({
const initConfirmation = useCallback(() => {
onInit();

return new Promise<boolean>((resolve) => {
return new Promise<ConfirmResult | boolean>((resolve) => {
confirmationPromiseRef.current = resolve;
}).finally(() => {
onFinish();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -275,7 +275,7 @@ export const RulesTables = React.memo<RulesTableProps>(({ selectedTab }) => {
: i18n.BULK_DELETE_CONFIRMATION_TITLE
}
onCancel={handleDeletionCancel}
onConfirm={handleDeletionConfirm}
onConfirm={() => handleDeletionConfirm()}
confirmButtonText={i18n.DELETE_CONFIRMATION_CONFIRM}
cancelButtonText={i18n.DELETE_CONFIRMATION_CANCEL}
buttonColor="danger"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,9 @@ export const UpgradePrebuiltRulesTableButtons = ({

const doAllSelectedRulesHaveConflicts =
isRulesCustomizationEnabled &&
selectedRules.every(({ hasUnresolvedConflicts }) => hasUnresolvedConflicts);
selectedRules.every(
({ hasNonSolvableUnresolvedConflicts }) => hasNonSolvableUnresolvedConflicts
);

const { selectedRulesButtonTooltip, allRulesButtonTooltip } = useBulkUpdateButtonsTooltipContent({
canUserEditRules,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@ export function usePrebuiltRulesUpgradeState(

state[ruleUpgradeInfo.rule_id] = {
...ruleUpgradeInfo,
conflict: getWorstConflictLevelAmongFields(ruleUpgradeInfo.diff.fields),
fieldsUpgradeState,
hasUnresolvedConflicts: isRulesCustomizationEnabled
? hasRuleTypeChange || hasFieldConflicts
Expand Down Expand Up @@ -212,3 +213,22 @@ function calcFieldsState(

return fieldsState;
}

function getWorstConflictLevelAmongFields(
fieldsDiff: FieldsDiff<Record<string, unknown>>
): ThreeWayDiffConflict {
let mostSevereFieldConflict = ThreeWayDiffConflict.NONE;

for (const { conflict } of Object.values<{ conflict: ThreeWayDiffConflict }>(fieldsDiff)) {
if (conflict === ThreeWayDiffConflict.NON_SOLVABLE) {
// return early as there is no higher severity
return ThreeWayDiffConflict.NON_SOLVABLE;
}

if (conflict === ThreeWayDiffConflict.SOLVABLE) {
mostSevereFieldConflict = ThreeWayDiffConflict.SOLVABLE;
}
}

return mostSevereFieldConflict;
}
Loading

0 comments on commit 61b6378

Please sign in to comment.