[Security Solution] Extend upgrade prebuilt rules context with conflict resolution functionality (#191721)

**Addresses:** https://github.com/elastic/kibana/issues/171520

## Summary

This PR implements necessary `UpgradePrebuiltRulesTableContext` changes to provide uses a way to resolve conflicts manually by providing field's resolved value.

## Details

During prebuilt rules upgrading users may encounter solvable and non-solvable conflicts between customized and target rule versions. Three-Way-Diff field component allow to specify a desired resolve value user expects to be in the rule after upgrading. It's also possible to customize rules during the upgrading process.

Current functionality is informational only without an ability to customize prebuilt rules. As the core part of that process it's required to manage the upgrading state and provide necessary data for downstream components rendering field diffs and accepting user input.

**This PR extends** `UpgradePrebuiltRulesTableContext` with rule upgrade state and provides it to `ThreeWayDiffTab` stub component. It's planned to add implementation to `ThreeWayDiffTab` in follow up PRs.

**On top of that** `UpgradePrebuiltRulesTableContext` and `AddPrebuiltRulesTableContext` were symmetrically refactored from architecture point of view to improve encapsulation by separation of concerns which leads to slight complexity reduction.

### Feature flag `prebuiltRulesCustomizationEnabled`

`ThreeWayDiffTab` is hidden under a feature flag `prebuiltRulesCustomizationEnabled`. It accepts a `finalDiffableRule` which represents rule fields the user expects to see in the upgraded rule. `finalDiffableRule`  is a combination of field resolved values and target rule fields where resolved values have precedence.
This commit is contained in:
Maxim Palenov 2024-09-11 02:35:51 +03:00 committed by GitHub
parent bc8fc413c4
commit 66af356731
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
30 changed files with 601 additions and 368 deletions

View file

@ -7,8 +7,8 @@
import type { RequiredOptional } from '@kbn/zod-helpers';
import { requiredOptional } from '@kbn/zod-helpers';
import { DEFAULT_MAX_SIGNALS } from '../../../../../../../common/constants';
import { assertUnreachable } from '../../../../../../../common/utility_types';
import { DEFAULT_MAX_SIGNALS } from '../../../constants';
import { assertUnreachable } from '../../../utility_types';
import type {
EqlRule,
EqlRuleCreateProps,
@ -27,8 +27,7 @@ import type {
ThreatMatchRuleCreateProps,
ThresholdRule,
ThresholdRuleCreateProps,
} from '../../../../../../../common/api/detection_engine/model/rule_schema';
import type { PrebuiltRuleAsset } from '../../../model/rule_assets/prebuilt_rule_asset';
} from '../../../api/detection_engine/model/rule_schema';
import type {
DiffableCommonFields,
DiffableCustomQueryFields,
@ -40,7 +39,8 @@ import type {
DiffableSavedQueryFields,
DiffableThreatMatchFields,
DiffableThresholdFields,
} from '../../../../../../../common/api/detection_engine/prebuilt_rules';
} from '../../../api/detection_engine/prebuilt_rules';
import { addEcsToRequiredFields } from '../../rule_management/utils';
import { extractBuildingBlockObject } from './extract_building_block_object';
import {
extractInlineKqlQuery,
@ -53,13 +53,12 @@ import { extractRuleNameOverrideObject } from './extract_rule_name_override_obje
import { extractRuleSchedule } from './extract_rule_schedule';
import { extractTimelineTemplateReference } from './extract_timeline_template_reference';
import { extractTimestampOverrideObject } from './extract_timestamp_override_object';
import { addEcsToRequiredFields } from '../../../../rule_management/utils/utils';
/**
* Normalizes a given rule to the form which is suitable for passing to the diff algorithm.
* Read more in the JSDoc description of DiffableRule.
*/
export const convertRuleToDiffable = (rule: RuleResponse | PrebuiltRuleAsset): DiffableRule => {
export const convertRuleToDiffable = (rule: RuleResponse): DiffableRule => {
const commonFields = extractDiffableCommonFields(rule);
switch (rule.type) {
@ -109,7 +108,7 @@ export const convertRuleToDiffable = (rule: RuleResponse | PrebuiltRuleAsset): D
};
const extractDiffableCommonFields = (
rule: RuleResponse | PrebuiltRuleAsset
rule: RuleResponse
): RequiredOptional<DiffableCommonFields> => {
return {
// --------------------- REQUIRED FIELDS

View file

@ -0,0 +1,18 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { RuleResponse } from '../../../api/detection_engine/model/rule_schema';
import type { BuildingBlockObject } from '../../../api/detection_engine/prebuilt_rules';
export const extractBuildingBlockObject = (rule: RuleResponse): BuildingBlockObject | undefined => {
if (rule.building_block_type == null) {
return undefined;
}
return {
type: rule.building_block_type,
};
};

View file

@ -11,14 +11,14 @@ import type {
KqlQueryLanguage,
RuleFilterArray,
RuleQuery,
} from '../../../../../../../common/api/detection_engine/model/rule_schema';
} from '../../../api/detection_engine/model/rule_schema';
import type {
InlineKqlQuery,
RuleEqlQuery,
RuleEsqlQuery,
RuleKqlQuery,
} from '../../../../../../../common/api/detection_engine/prebuilt_rules';
import { KqlQueryType } from '../../../../../../../common/api/detection_engine/prebuilt_rules';
} from '../../../api/detection_engine/prebuilt_rules';
import { KqlQueryType } from '../../../api/detection_engine/prebuilt_rules';
export const extractRuleKqlQuery = (
query: RuleQuery | undefined,

View file

@ -8,9 +8,9 @@
import type {
DataViewId,
IndexPatternArray,
} from '../../../../../../../common/api/detection_engine/model/rule_schema';
import type { RuleDataSource } from '../../../../../../../common/api/detection_engine/prebuilt_rules';
import { DataSourceType } from '../../../../../../../common/api/detection_engine/prebuilt_rules';
} from '../../../api/detection_engine/model/rule_schema';
import type { RuleDataSource } from '../../../api/detection_engine/prebuilt_rules';
import { DataSourceType } from '../../../api/detection_engine/prebuilt_rules';
export const extractRuleDataSource = (
indexPatterns: IndexPatternArray | undefined,

View file

@ -5,12 +5,11 @@
* 2.0.
*/
import type { RuleResponse } from '../../../../../../../common/api/detection_engine/model/rule_schema';
import type { RuleNameOverrideObject } from '../../../../../../../common/api/detection_engine/prebuilt_rules';
import type { PrebuiltRuleAsset } from '../../../model/rule_assets/prebuilt_rule_asset';
import type { RuleResponse } from '../../../api/detection_engine/model/rule_schema';
import type { RuleNameOverrideObject } from '../../../api/detection_engine/prebuilt_rules';
export const extractRuleNameOverrideObject = (
rule: RuleResponse | PrebuiltRuleAsset
rule: RuleResponse
): RuleNameOverrideObject | undefined => {
if (rule.rule_name_override == null) {
return undefined;

View file

@ -9,14 +9,10 @@ import moment from 'moment';
import dateMath from '@elastic/datemath';
import { parseDuration } from '@kbn/alerting-plugin/common';
import type {
RuleMetadata,
RuleResponse,
} from '../../../../../../../common/api/detection_engine/model/rule_schema';
import type { RuleSchedule } from '../../../../../../../common/api/detection_engine/prebuilt_rules';
import type { PrebuiltRuleAsset } from '../../../model/rule_assets/prebuilt_rule_asset';
import type { RuleMetadata, RuleResponse } from '../../../api/detection_engine/model/rule_schema';
import type { RuleSchedule } from '../../../api/detection_engine/prebuilt_rules';
export const extractRuleSchedule = (rule: RuleResponse | PrebuiltRuleAsset): RuleSchedule => {
export const extractRuleSchedule = (rule: RuleResponse): RuleSchedule => {
const interval = rule.interval ?? '5m';
const from = rule.from ?? 'now-6m';
const to = rule.to ?? 'now';

View file

@ -5,12 +5,11 @@
* 2.0.
*/
import type { RuleResponse } from '../../../../../../../common/api/detection_engine/model/rule_schema';
import type { TimelineTemplateReference } from '../../../../../../../common/api/detection_engine/prebuilt_rules';
import type { PrebuiltRuleAsset } from '../../../model/rule_assets/prebuilt_rule_asset';
import type { RuleResponse } from '../../../api/detection_engine/model/rule_schema';
import type { TimelineTemplateReference } from '../../../api/detection_engine/prebuilt_rules';
export const extractTimelineTemplateReference = (
rule: RuleResponse | PrebuiltRuleAsset
rule: RuleResponse
): TimelineTemplateReference | undefined => {
if (rule.timeline_id == null) {
return undefined;

View file

@ -5,12 +5,11 @@
* 2.0.
*/
import type { RuleResponse } from '../../../../../../../common/api/detection_engine/model/rule_schema';
import type { TimestampOverrideObject } from '../../../../../../../common/api/detection_engine/prebuilt_rules';
import type { PrebuiltRuleAsset } from '../../../model/rule_assets/prebuilt_rule_asset';
import type { RuleResponse } from '../../../api/detection_engine/model/rule_schema';
import type { TimestampOverrideObject } from '../../../api/detection_engine/prebuilt_rules';
export const extractTimestampOverrideObject = (
rule: RuleResponse | PrebuiltRuleAsset
rule: RuleResponse
): TimestampOverrideObject | undefined => {
if (rule.timestamp_override == null) {
return undefined;

View file

@ -0,0 +1,25 @@
/*
* 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 { ecsFieldMap } from '@kbn/alerts-as-data-utils';
import type { RequiredField, RequiredFieldInput } from '../../api/detection_engine';
/*
Computes the boolean "ecs" property value for each required field based on the ECS field map.
"ecs" property indicates whether the required field is an ECS field or not.
*/
export const addEcsToRequiredFields = (requiredFields?: RequiredFieldInput[]): RequiredField[] =>
(requiredFields ?? []).map((requiredFieldWithoutEcs) => {
const isEcsField = Boolean(
ecsFieldMap[requiredFieldWithoutEcs.name]?.type === requiredFieldWithoutEcs.type
);
return {
...requiredFieldWithoutEcs,
ecs: isEcsField,
};
});

View file

@ -0,0 +1,22 @@
/*
* 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 React from 'react';
import type { DiffableRule } from '../../../../../common/api/detection_engine';
import type { SetFieldResolvedValueFn } from '../../../rule_management_ui/components/rules_table/upgrade_prebuilt_rules_table/use_prebuilt_rules_upgrade_state';
interface ThreeWayDiffTabProps {
finalDiffableRule: DiffableRule;
setFieldResolvedValue: SetFieldResolvedValueFn;
}
export function ThreeWayDiffTab({
finalDiffableRule,
setFieldResolvedValue,
}: ThreeWayDiffTabProps): JSX.Element {
return <>{JSON.stringify(finalDiffableRule)}</>;
}

View file

@ -28,6 +28,13 @@ export const UPDATES_TAB_LABEL = i18n.translate(
}
);
export const DIFF_TAB_LABEL = i18n.translate(
'xpack.securitySolution.detectionEngine.ruleDetails.diffTabLabel',
{
defaultMessage: 'Diff',
}
);
export const JSON_VIEW_UPDATES_TAB_LABEL = i18n.translate(
'xpack.securitySolution.detectionEngine.ruleDetails.jsonViewUpdatesTabLabel',
{

View file

@ -1,47 +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 React, { useCallback } from 'react';
import { invariant } from '../../../../../common/utils/invariant';
import type { RuleObjectId } from '../../../../../common/api/detection_engine';
import type { RuleResponse } from '../../../../../common/api/detection_engine/model/rule_schema';
export interface RuleDetailsFlyoutState {
previewedRule: RuleResponse | null;
}
export interface RuleDetailsFlyoutActions {
openRulePreview: (ruleId: RuleObjectId) => void;
closeRulePreview: () => void;
}
export const useRuleDetailsFlyout = (
rules: RuleResponse[]
): RuleDetailsFlyoutState & RuleDetailsFlyoutActions => {
const [previewedRule, setRuleForPreview] = React.useState<RuleResponse | null>(null);
const openRulePreview = useCallback(
(ruleId: RuleObjectId) => {
const ruleToShowInFlyout = rules.find((rule) => {
return rule.id === ruleId;
});
invariant(ruleToShowInFlyout, `Rule with id ${ruleId} not found`);
setRuleForPreview(ruleToShowInFlyout);
},
[rules, setRuleForPreview]
);
const closeRulePreview = useCallback(() => {
setRuleForPreview(null);
}, []);
return {
openRulePreview,
closeRulePreview,
previewedRule,
};
};

View file

@ -13,13 +13,18 @@ import * as i18n from './translations';
export const AddPrebuiltRulesHeaderButtons = () => {
const {
state: { rules, selectedRules, loadingRules, isRefetching, isUpgradingSecurityPackages },
state: {
selectedRules,
loadingRules,
isRefetching,
isUpgradingSecurityPackages,
hasRulesToInstall,
},
actions: { installAllRules, installSelectedRules },
} = useAddPrebuiltRulesTableContext();
const [{ loading: isUserDataLoading, canUserCRUD }] = useUserData();
const canUserEditRules = canUserCRUD && !isUserDataLoading;
const isRulesAvailableForInstall = rules.length > 0;
const numberOfSelectedRules = selectedRules.length ?? 0;
const shouldDisplayInstallSelectedRulesButton = numberOfSelectedRules > 0;
@ -46,7 +51,7 @@ export const AddPrebuiltRulesHeaderButtons = () => {
iconType="plusInCircle"
data-test-subj="installAllRulesButton"
onClick={installAllRules}
disabled={!canUserEditRules || !isRulesAvailableForInstall || isRequestInProgress}
disabled={!canUserEditRules || !hasRulesToInstall || isRequestInProgress}
aria-label={i18n.INSTALL_ALL_ARIA_LABEL}
>
{i18n.INSTALL_ALL}

View file

@ -32,8 +32,7 @@ export const AddPrebuiltRulesTable = React.memo(() => {
const {
state: {
rules,
filteredRules,
isFetched,
hasRulesToInstall,
isLoading,
isRefetching,
selectedRules,
@ -43,8 +42,6 @@ export const AddPrebuiltRulesTable = React.memo(() => {
} = addRulesTableContext;
const rulesColumns = useAddPrebuiltRulesTableColumns();
const isTableEmpty = isFetched && rules.length === 0;
const shouldShowProgress = isUpgradingSecurityPackages || isRefetching;
return (
@ -66,7 +63,7 @@ export const AddPrebuiltRulesTable = React.memo(() => {
</>
}
loadedContent={
isTableEmpty ? (
!hasRulesToInstall ? (
<AddPrebuiltRulesTableNoItemsMessage />
) : (
<>
@ -80,7 +77,7 @@ export const AddPrebuiltRulesTable = React.memo(() => {
</EuiFlexGroup>
<EuiInMemoryTable
items={filteredRules}
items={rules}
sorting
pagination={{
initialPageSize: RULES_TABLE_INITIAL_PAGE_SIZE,

View file

@ -20,21 +20,16 @@ import {
import { usePrebuiltRulesInstallReview } from '../../../../rule_management/logic/prebuilt_rules/use_prebuilt_rules_install_review';
import type { AddPrebuiltRulesTableFilterOptions } from './use_filter_prebuilt_rules_to_install';
import { useFilterPrebuiltRulesToInstall } from './use_filter_prebuilt_rules_to_install';
import { useRuleDetailsFlyout } from '../../../../rule_management/components/rule_details/use_rule_details_flyout';
import { useRulePreviewFlyout } from '../use_rule_preview_flyout';
import type { RuleResponse } from '../../../../../../common/api/detection_engine/model/rule_schema';
import { RuleDetailsFlyout } from '../../../../rule_management/components/rule_details/rule_details_flyout';
import * as i18n from './translations';
import { isUpgradeReviewRequestEnabled } from './add_prebuilt_rules_utils';
export interface AddPrebuiltRulesTableState {
/**
* Rules available to be installed
* Rules available to be installed after applying `filterOptions`
*/
rules: RuleResponse[];
/**
* Rules to display in table after applying filters
*/
filteredRules: RuleResponse[];
/**
* Currently selected table filter
*/
@ -43,6 +38,10 @@ export interface AddPrebuiltRulesTableState {
* All unique tags for all rules
*/
tags: string[];
/**
* Indicates whether there are rules (without filters applied) available to install.
*/
hasRulesToInstall: boolean;
/**
* Is true then there is no cached data and the query is currently fetching.
*/
@ -95,6 +94,8 @@ interface AddPrebuiltRulesTableContextProviderProps {
children: React.ReactNode;
}
const PREBUILT_RULE_INSTALL_FLYOUT_ANCHOR = 'installPrebuiltRulePreview';
export const AddPrebuiltRulesTableContextProvider = ({
children,
}: AddPrebuiltRulesTableContextProviderProps) => {
@ -138,15 +139,6 @@ export const AddPrebuiltRulesTableContextProvider = ({
const filteredRules = useFilterPrebuiltRulesToInstall({ filterOptions, rules });
const { openRulePreview, closeRulePreview, previewedRule } = useRuleDetailsFlyout(filteredRules);
const isPreviewRuleLoading =
previewedRule?.rule_id && loadingRules.includes(previewedRule.rule_id);
const canPreviewedRuleBeInstalled =
!userInfoLoading &&
canUserCRUD &&
!(isPreviewRuleLoading || isRefetching || isUpgradingSecurityPackages);
const installOneRule = useCallback(
async (ruleId: RuleSignatureId) => {
const rule = rules.find((r) => r.rule_id === ruleId);
@ -187,6 +179,47 @@ export const AddPrebuiltRulesTableContextProvider = ({
}
}, [installAllRulesRequest, rules]);
const ruleActionsFactory = useCallback(
(rule: RuleResponse, closeRulePreview: () => void) => {
const isPreviewRuleLoading = loadingRules.includes(rule.rule_id);
const canPreviewedRuleBeInstalled =
!userInfoLoading &&
canUserCRUD &&
!(isPreviewRuleLoading || isRefetching || isUpgradingSecurityPackages);
return (
<EuiButton
disabled={!canPreviewedRuleBeInstalled}
onClick={() => {
installOneRule(rule.rule_id);
closeRulePreview();
}}
fill
data-test-subj="installPrebuiltRuleFromFlyoutButton"
>
{i18n.INSTALL_BUTTON_LABEL}
</EuiButton>
);
},
[
loadingRules,
userInfoLoading,
canUserCRUD,
isRefetching,
isUpgradingSecurityPackages,
installOneRule,
]
);
const { rulePreviewFlyout, openRulePreview } = useRulePreviewFlyout({
rules: filteredRules,
ruleActionsFactory,
flyoutProps: {
id: PREBUILT_RULE_INSTALL_FLYOUT_ANCHOR,
dataTestSubj: PREBUILT_RULE_INSTALL_FLYOUT_ANCHOR,
},
});
const actions = useMemo(
() => ({
setFilterOptions,
@ -203,10 +236,10 @@ export const AddPrebuiltRulesTableContextProvider = ({
const providerValue = useMemo<AddPrebuiltRulesContextType>(() => {
return {
state: {
rules,
filteredRules,
rules: filteredRules,
filterOptions,
tags,
hasRulesToInstall: isFetched && rules.length > 0,
isFetched,
isLoading,
loadingRules,
@ -236,26 +269,7 @@ export const AddPrebuiltRulesTableContextProvider = ({
<AddPrebuiltRulesTableContext.Provider value={providerValue}>
<>
{children}
{previewedRule && (
<RuleDetailsFlyout
rule={previewedRule}
dataTestSubj="installPrebuiltRulePreview"
closeFlyout={closeRulePreview}
ruleActions={
<EuiButton
disabled={!canPreviewedRuleBeInstalled}
onClick={() => {
installOneRule(previewedRule.rule_id ?? '');
closeRulePreview();
}}
fill
data-test-subj="installPrebuiltRuleFromFlyoutButton"
>
{i18n.INSTALL_BUTTON_LABEL}
</EuiButton>
}
/>
)}
{rulePreviewFlyout}
</>
</AddPrebuiltRulesTableContext.Provider>
);

View file

@ -15,7 +15,7 @@ import {
EuiSkeletonText,
EuiSkeletonTitle,
} from '@elastic/eui';
import React from 'react';
import React, { useMemo, useState } from 'react';
import * as i18n from '../../../../../detections/pages/detection_engine/rules/translations';
import { RULES_TABLE_INITIAL_PAGE_SIZE, RULES_TABLE_PAGE_SIZE_OPTIONS } from '../constants';
import { RulesChangelogLink } from '../rules_changelog_link';
@ -23,6 +23,7 @@ import { UpgradePrebuiltRulesTableButtons } from './upgrade_prebuilt_rules_table
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';
import type { RuleUpgradeState } from './use_prebuilt_rules_upgrade_state';
const NO_ITEMS_MESSAGE = (
<EuiEmptyPrompt
@ -38,23 +39,22 @@ const NO_ITEMS_MESSAGE = (
*/
export const UpgradePrebuiltRulesTable = React.memo(() => {
const upgradeRulesTableContext = useUpgradePrebuiltRulesTableContext();
const [selected, setSelected] = useState<RuleUpgradeState[]>([]);
const {
state: {
rules,
filteredRules,
isFetched,
rulesUpgradeState,
hasRulesToUpgrade,
isLoading,
selectedRules,
isRefetching,
isUpgradingSecurityPackages,
},
actions: { selectRules },
} = upgradeRulesTableContext;
const ruleUpgradeStatesArray = useMemo(
() => Object.values(rulesUpgradeState),
[rulesUpgradeState]
);
const rulesColumns = useUpgradePrebuiltRulesTableColumns();
const isTableEmpty = isFetched && rules.length === 0;
const shouldShowProgress = isUpgradingSecurityPackages || isRefetching;
return (
@ -76,7 +76,7 @@ export const UpgradePrebuiltRulesTable = React.memo(() => {
</>
}
loadedContent={
isTableEmpty ? (
!hasRulesToUpgrade ? (
NO_ITEMS_MESSAGE
) : (
<>
@ -95,14 +95,14 @@ export const UpgradePrebuiltRulesTable = React.memo(() => {
<UpgradePrebuiltRulesTableFilters />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<UpgradePrebuiltRulesTableButtons />
<UpgradePrebuiltRulesTableButtons selectedRules={selected} />
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
<EuiInMemoryTable
items={filteredRules}
items={ruleUpgradeStatesArray}
sorting
pagination={{
initialPageSize: RULES_TABLE_INITIAL_PAGE_SIZE,
@ -110,8 +110,8 @@ export const UpgradePrebuiltRulesTable = React.memo(() => {
}}
selection={{
selectable: () => true,
onSelectionChange: selectRules,
initialSelected: selectedRules,
onSelectionChange: setSelected,
initialSelected: selected,
}}
itemId="rule_id"
data-test-subj="rules-upgrades-table"

View file

@ -6,25 +6,35 @@
*/
import { EuiButton, EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner } from '@elastic/eui';
import React from 'react';
import React, { useCallback } from 'react';
import { useUserData } from '../../../../../detections/components/user_info';
import * as i18n from './translations';
import { useUpgradePrebuiltRulesTableContext } from './upgrade_prebuilt_rules_table_context';
import type { RuleUpgradeState } from './use_prebuilt_rules_upgrade_state';
export const UpgradePrebuiltRulesTableButtons = () => {
interface UpgradePrebuiltRulesTableButtonsProps {
selectedRules: RuleUpgradeState[];
}
export const UpgradePrebuiltRulesTableButtons = ({
selectedRules,
}: UpgradePrebuiltRulesTableButtonsProps) => {
const {
state: { rules, selectedRules, loadingRules, isRefetching, isUpgradingSecurityPackages },
actions: { upgradeAllRules, upgradeSelectedRules },
state: { hasRulesToUpgrade, loadingRules, isRefetching, isUpgradingSecurityPackages },
actions: { upgradeAllRules, upgradeRules },
} = useUpgradePrebuiltRulesTableContext();
const [{ loading: isUserDataLoading, canUserCRUD }] = useUserData();
const canUserEditRules = canUserCRUD && !isUserDataLoading;
const isRulesAvailableForUpgrade = rules.length > 0;
const numberOfSelectedRules = selectedRules.length ?? 0;
const shouldDisplayUpgradeSelectedRulesButton = numberOfSelectedRules > 0;
const isRuleUpgrading = loadingRules.length > 0;
const isRequestInProgress = isRuleUpgrading || isRefetching || isUpgradingSecurityPackages;
const upgradeSelectedRules = useCallback(
() => upgradeRules(selectedRules.map((rule) => rule.rule_id)),
[selectedRules, upgradeRules]
);
return (
<EuiFlexGroup alignItems="center" gutterSize="s" responsive={false} wrap={true}>
@ -47,7 +57,7 @@ export const UpgradePrebuiltRulesTableButtons = () => {
fill
iconType="plusInCircle"
onClick={upgradeAllRules}
disabled={!canUserEditRules || !isRulesAvailableForUpgrade || isRequestInProgress}
disabled={!canUserEditRules || !hasRulesToUpgrade || isRequestInProgress}
data-test-subj="upgradeAllRulesButton"
>
{i18n.UPDATE_ALL}

View file

@ -8,14 +8,17 @@
import type { Dispatch, SetStateAction } from 'react';
import React, { createContext, useCallback, useContext, useMemo, useState } from 'react';
import { EuiButton, EuiToolTip } from '@elastic/eui';
import type { EuiTabbedContentTab } from '@elastic/eui';
import { useIsExperimentalFeatureEnabled } from '../../../../../common/hooks/use_experimental_features';
import { ThreeWayDiffTab } from '../../../../rule_management/components/rule_details/three_way_diff_tab';
import { PerFieldRuleDiffTab } from '../../../../rule_management/components/rule_details/per_field_rule_diff_tab';
import { useIsUpgradingSecurityPackages } from '../../../../rule_management/logic/use_upgrade_security_packages';
import { useInstalledSecurityJobs } from '../../../../../common/components/ml/hooks/use_installed_security_jobs';
import { useBoolState } from '../../../../../common/hooks/use_bool_state';
import { affectedJobIds } from '../../../../../detections/components/callouts/ml_job_compatibility_callout/affected_job_ids';
import type { RuleUpgradeInfoForReview } from '../../../../../../common/api/detection_engine/prebuilt_rules';
import type { RuleSignatureId } from '../../../../../../common/api/detection_engine/model/rule_schema';
import type {
RuleResponse,
RuleSignatureId,
} from '../../../../../../common/api/detection_engine/model/rule_schema';
import { invariant } from '../../../../../../common/utils/invariant';
import {
usePerformUpgradeAllRules,
@ -25,25 +28,20 @@ import { usePrebuiltRulesUpgradeReview } from '../../../../rule_management/logic
import type { UpgradePrebuiltRulesTableFilterOptions } from './use_filter_prebuilt_rules_to_upgrade';
import { useFilterPrebuiltRulesToUpgrade } from './use_filter_prebuilt_rules_to_upgrade';
import { useAsyncConfirmation } from '../rules_table/use_async_confirmation';
import { useRuleDetailsFlyout } from '../../../../rule_management/components/rule_details/use_rule_details_flyout';
import {
RuleDetailsFlyout,
TabContentPadding,
} from '../../../../rule_management/components/rule_details/rule_details_flyout';
import { TabContentPadding } from '../../../../rule_management/components/rule_details/rule_details_flyout';
import { RuleDiffTab } from '../../../../rule_management/components/rule_details/rule_diff_tab';
import { MlJobUpgradeModal } from '../../../../../detections/components/modals/ml_job_upgrade_modal';
import * as ruleDetailsI18n from '../../../../rule_management/components/rule_details/translations';
import * as i18n from './translations';
import type { RulesUpgradeState } from './use_prebuilt_rules_upgrade_state';
import { usePrebuiltRulesUpgradeState } from './use_prebuilt_rules_upgrade_state';
import { useRulePreviewFlyout } from '../use_rule_preview_flyout';
export interface UpgradePrebuiltRulesTableState {
/**
* Rules available to be updated
* Rule upgrade state after applying `filterOptions`
*/
rules: RuleUpgradeInfoForReview[];
/**
* Rules to display in table after applying filters
*/
filteredRules: RuleUpgradeInfoForReview[];
rulesUpgradeState: RulesUpgradeState;
/**
* Currently selected table filter
*/
@ -52,6 +50,10 @@ export interface UpgradePrebuiltRulesTableState {
* All unique tags for all rules
*/
tags: string[];
/**
* Indicates whether there are rules (without filters applied) to upgrade.
*/
hasRulesToUpgrade: boolean;
/**
* Is true then there is no cached data and the query is currently fetching.
*/
@ -78,21 +80,15 @@ export interface UpgradePrebuiltRulesTableState {
* The timestamp for when the rules were successfully fetched
*/
lastUpdated: number;
/**
* Rule rows selected in EUI InMemory Table
*/
selectedRules: RuleUpgradeInfoForReview[];
}
export const PREBUILT_RULE_UPDATE_FLYOUT_ANCHOR = 'updatePrebuiltRulePreview';
export interface UpgradePrebuiltRulesTableActions {
reFetchRules: () => void;
upgradeOneRule: (ruleId: string) => void;
upgradeSelectedRules: () => void;
upgradeRules: (ruleIds: RuleSignatureId[]) => void;
upgradeAllRules: () => void;
setFilterOptions: Dispatch<SetStateAction<UpgradePrebuiltRulesTableFilterOptions>>;
selectRules: (rules: RuleUpgradeInfoForReview[]) => void;
openRulePreview: (ruleId: string) => void;
}
@ -112,8 +108,10 @@ interface UpgradePrebuiltRulesTableContextProviderProps {
export const UpgradePrebuiltRulesTableContextProvider = ({
children,
}: UpgradePrebuiltRulesTableContextProviderProps) => {
const isPrebuiltRulesCustomizationEnabled = useIsExperimentalFeatureEnabled(
'prebuiltRulesCustomizationEnabled'
);
const [loadingRules, setLoadingRules] = useState<RuleSignatureId[]>([]);
const [selectedRules, setSelectedRules] = useState<RuleUpgradeInfoForReview[]>([]);
const [filterOptions, setFilterOptions] = useState<UpgradePrebuiltRulesTableFilterOptions>({
filter: '',
tags: [],
@ -122,7 +120,7 @@ export const UpgradePrebuiltRulesTableContextProvider = ({
const isUpgradingSecurityPackages = useIsUpgradingSecurityPackages();
const {
data: { rules, stats: { tags } } = {
data: { rules: ruleUpgradeInfos, stats: { tags } } = {
rules: [],
stats: { tags: [] },
},
@ -135,20 +133,12 @@ export const UpgradePrebuiltRulesTableContextProvider = ({
refetchInterval: false, // Disable automatic refetching since request is expensive
keepPreviousData: true, // Use this option so that the state doesn't jump between "success" and "loading" on page change
});
const { mutateAsync: upgradeAllRulesRequest } = usePerformUpgradeAllRules();
const { mutateAsync: upgradeSpecificRulesRequest } = usePerformUpgradeSpecificRules();
const filteredRules = useFilterPrebuiltRulesToUpgrade({ filterOptions, rules });
const { openRulePreview, closeRulePreview, previewedRule } = useRuleDetailsFlyout(
filteredRules.map((upgradeInfo) => upgradeInfo.target_rule)
);
const canPreviewedRuleBeUpgraded = Boolean(
(previewedRule?.rule_id && loadingRules.includes(previewedRule.rule_id)) ||
isRefetching ||
isUpgradingSecurityPackages
);
const filteredRuleUpgradeInfos = useFilterPrebuiltRulesToUpgrade({
filterOptions,
rules: ruleUpgradeInfos,
});
const { rulesUpgradeState, setFieldResolvedValue } =
usePrebuiltRulesUpgradeState(filteredRuleUpgradeInfos);
// Wrapper to add confirmation modal for users who may be running older ML Jobs that would
// be overridden by updating their rules. For details, see: https://github.com/elastic/kibana/issues/128121
@ -163,51 +153,36 @@ export const UpgradePrebuiltRulesTableContextProvider = ({
const shouldConfirmUpgrade = legacyJobsInstalled.length > 0;
const upgradeOneRule = useCallback(
async (ruleId: RuleSignatureId) => {
const rule = rules.find((r) => r.rule_id === ruleId);
invariant(rule, `Rule with id ${ruleId} not found`);
const { mutateAsync: upgradeAllRulesRequest } = usePerformUpgradeAllRules();
const { mutateAsync: upgradeSpecificRulesRequest } = usePerformUpgradeSpecificRules();
setLoadingRules((prev) => [...prev, ruleId]);
const upgradeRules = useCallback(
async (ruleIds: RuleSignatureId[]) => {
const rulesToUpgrade = ruleIds.map((ruleId) => ({
rule_id: ruleId,
version:
rulesUpgradeState[ruleId].diff.fields.version?.target_version ??
rulesUpgradeState[ruleId].current_rule.version,
revision: rulesUpgradeState[ruleId].revision,
}));
setLoadingRules((prev) => [...prev, ...rulesToUpgrade.map((r) => r.rule_id)]);
try {
if (shouldConfirmUpgrade && !(await confirmUpgrade())) {
return;
}
await upgradeSpecificRulesRequest([
{
rule_id: ruleId,
version: rule.diff.fields.version?.target_version ?? rule.current_rule.version,
revision: rule.revision,
},
]);
await upgradeSpecificRulesRequest(rulesToUpgrade);
} finally {
setLoadingRules((prev) => prev.filter((id) => id !== ruleId));
setLoadingRules((prev) =>
prev.filter((id) => !rulesToUpgrade.some((r) => r.rule_id === id))
);
}
},
[confirmUpgrade, rules, shouldConfirmUpgrade, upgradeSpecificRulesRequest]
[confirmUpgrade, shouldConfirmUpgrade, rulesUpgradeState, upgradeSpecificRulesRequest]
);
const upgradeSelectedRules = useCallback(async () => {
const rulesToUpgrade = selectedRules.map((rule) => ({
rule_id: rule.rule_id,
version: rule.diff.fields.version?.target_version ?? rule.current_rule.version,
revision: rule.revision,
}));
setLoadingRules((prev) => [...prev, ...rulesToUpgrade.map((r) => r.rule_id)]);
try {
if (shouldConfirmUpgrade && !(await confirmUpgrade())) {
return;
}
await upgradeSpecificRulesRequest(rulesToUpgrade);
} finally {
setLoadingRules((prev) => prev.filter((id) => !rulesToUpgrade.some((r) => r.rule_id === id)));
setSelectedRules([]);
}
}, [confirmUpgrade, selectedRules, shouldConfirmUpgrade, upgradeSpecificRulesRequest]);
const upgradeAllRules = useCallback(async () => {
// Unselect all rules so that the table doesn't show the "bulk actions" bar
setLoadingRules((prev) => [...prev, ...rules.map((r) => r.rule_id)]);
setLoadingRules((prev) => [...prev, ...ruleUpgradeInfos.map((r) => r.rule_id)]);
try {
if (shouldConfirmUpgrade && !(await confirmUpgrade())) {
return;
@ -215,43 +190,137 @@ export const UpgradePrebuiltRulesTableContextProvider = ({
await upgradeAllRulesRequest();
} finally {
setLoadingRules([]);
setSelectedRules([]);
}
}, [confirmUpgrade, rules, shouldConfirmUpgrade, upgradeAllRulesRequest]);
}, [confirmUpgrade, ruleUpgradeInfos, shouldConfirmUpgrade, upgradeAllRulesRequest]);
const ruleActionsFactory = useCallback(
(rule: RuleResponse, closeRulePreview: () => void) => (
<EuiButton
disabled={
loadingRules.includes(rule.rule_id) ||
isRefetching ||
isUpgradingSecurityPackages ||
rulesUpgradeState[rule.rule_id]?.hasUnresolvedConflicts
}
onClick={() => {
upgradeRules([rule.rule_id]);
closeRulePreview();
}}
fill
data-test-subj="updatePrebuiltRuleFromFlyoutButton"
>
{i18n.UPDATE_BUTTON_LABEL}
</EuiButton>
),
[rulesUpgradeState, loadingRules, isRefetching, isUpgradingSecurityPackages, upgradeRules]
);
const extraTabsFactory = useCallback(
(rule: RuleResponse) => {
const ruleUpgradeState = rulesUpgradeState[rule.rule_id];
if (!ruleUpgradeState) {
return [];
}
const extraTabs = [
{
id: 'updates',
name: (
<EuiToolTip position="top" content={i18n.UPDATE_FLYOUT_PER_FIELD_TOOLTIP_DESCRIPTION}>
<>{ruleDetailsI18n.UPDATES_TAB_LABEL}</>
</EuiToolTip>
),
content: (
<TabContentPadding>
<PerFieldRuleDiffTab ruleDiff={ruleUpgradeState.diff} />
</TabContentPadding>
),
},
{
id: 'jsonViewUpdates',
name: (
<EuiToolTip position="top" content={i18n.UPDATE_FLYOUT_JSON_VIEW_TOOLTIP_DESCRIPTION}>
<>{ruleDetailsI18n.JSON_VIEW_UPDATES_TAB_LABEL}</>
</EuiToolTip>
),
content: (
<TabContentPadding>
<RuleDiffTab
oldRule={ruleUpgradeState.current_rule}
newRule={ruleUpgradeState.target_rule}
/>
</TabContentPadding>
),
},
];
if (isPrebuiltRulesCustomizationEnabled) {
extraTabs.unshift({
id: 'diff',
name: (
<EuiToolTip position="top" content={i18n.UPDATE_FLYOUT_PER_FIELD_TOOLTIP_DESCRIPTION}>
<>{ruleDetailsI18n.DIFF_TAB_LABEL}</>
</EuiToolTip>
),
content: (
<TabContentPadding>
<ThreeWayDiffTab
finalDiffableRule={ruleUpgradeState.finalRule}
setFieldResolvedValue={setFieldResolvedValue}
/>
</TabContentPadding>
),
});
}
return extraTabs;
},
[rulesUpgradeState, setFieldResolvedValue, isPrebuiltRulesCustomizationEnabled]
);
const filteredRules = useMemo(
() => filteredRuleUpgradeInfos.map((rule) => rule.target_rule),
[filteredRuleUpgradeInfos]
);
const { rulePreviewFlyout, openRulePreview } = useRulePreviewFlyout({
rules: filteredRules,
ruleActionsFactory,
extraTabsFactory,
flyoutProps: {
id: PREBUILT_RULE_UPDATE_FLYOUT_ANCHOR,
dataTestSubj: PREBUILT_RULE_UPDATE_FLYOUT_ANCHOR,
},
});
const actions = useMemo<UpgradePrebuiltRulesTableActions>(
() => ({
reFetchRules: refetch,
upgradeOneRule,
upgradeSelectedRules,
upgradeRules,
upgradeAllRules,
setFilterOptions,
selectRules: setSelectedRules,
openRulePreview,
}),
[refetch, upgradeOneRule, upgradeSelectedRules, upgradeAllRules, openRulePreview]
[refetch, upgradeRules, upgradeAllRules, openRulePreview]
);
const providerValue = useMemo<UpgradePrebuiltRulesContextType>(() => {
return {
state: {
rules,
filteredRules,
rulesUpgradeState,
hasRulesToUpgrade: isFetched && ruleUpgradeInfos.length > 0,
filterOptions,
tags,
isFetched,
isLoading: isLoading && loadingJobs,
isLoading: isLoading || loadingJobs,
isRefetching,
isUpgradingSecurityPackages,
selectedRules,
loadingRules,
lastUpdated: dataUpdatedAt,
},
actions,
};
}, [
rules,
filteredRules,
ruleUpgradeInfos,
rulesUpgradeState,
filterOptions,
tags,
isFetched,
@ -259,49 +328,11 @@ export const UpgradePrebuiltRulesTableContextProvider = ({
loadingJobs,
isRefetching,
isUpgradingSecurityPackages,
selectedRules,
loadingRules,
dataUpdatedAt,
actions,
]);
const extraTabs = useMemo<EuiTabbedContentTab[]>(() => {
const activeRule = previewedRule && filteredRules.find(({ id }) => id === previewedRule.id);
if (!activeRule) {
return [];
}
return [
{
id: 'updates',
name: (
<EuiToolTip position="top" content={i18n.UPDATE_FLYOUT_PER_FIELD_TOOLTIP_DESCRIPTION}>
<>{ruleDetailsI18n.UPDATES_TAB_LABEL}</>
</EuiToolTip>
),
content: (
<TabContentPadding>
<PerFieldRuleDiffTab ruleDiff={activeRule.diff} />
</TabContentPadding>
),
},
{
id: 'jsonViewUpdates',
name: (
<EuiToolTip position="top" content={i18n.UPDATE_FLYOUT_JSON_VIEW_TOOLTIP_DESCRIPTION}>
<>{ruleDetailsI18n.JSON_VIEW_UPDATES_TAB_LABEL}</>
</EuiToolTip>
),
content: (
<TabContentPadding>
<RuleDiffTab oldRule={activeRule.current_rule} newRule={activeRule.target_rule} />
</TabContentPadding>
),
},
];
}, [previewedRule, filteredRules]);
return (
<UpgradePrebuiltRulesTableContext.Provider value={providerValue}>
<>
@ -313,29 +344,7 @@ export const UpgradePrebuiltRulesTableContextProvider = ({
/>
)}
{children}
{previewedRule && (
<RuleDetailsFlyout
rule={previewedRule}
size="l"
id={PREBUILT_RULE_UPDATE_FLYOUT_ANCHOR}
dataTestSubj="updatePrebuiltRulePreview"
closeFlyout={closeRulePreview}
ruleActions={
<EuiButton
disabled={canPreviewedRuleBeUpgraded}
onClick={() => {
upgradeOneRule(previewedRule.rule_id ?? '');
closeRulePreview();
}}
fill
data-test-subj="updatePrebuiltRuleFromFlyoutButton"
>
{i18n.UPDATE_BUTTON_LABEL}
</EuiButton>
}
extraTabs={extraTabs}
/>
)}
{rulePreviewFlyout}
</>
</UpgradePrebuiltRulesTableContext.Provider>
);

View file

@ -0,0 +1,130 @@
/*
* 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 { useCallback, useMemo, useState } from 'react';
import type { FieldsDiff } from '../../../../../../common/api/detection_engine';
import {
ThreeWayDiffConflict,
type DiffableAllFields,
type DiffableRule,
type RuleObjectId,
type RuleSignatureId,
type RuleUpgradeInfoForReview,
} from '../../../../../../common/api/detection_engine';
import { convertRuleToDiffable } from '../../../../../../common/detection_engine/prebuilt_rules/diff/convert_rule_to_diffable';
export interface RuleUpgradeState extends RuleUpgradeInfoForReview {
/**
* Rule containing desired values users expect to see in the upgraded rule.
*/
finalRule: DiffableRule;
/**
* Indicates whether there are conflicts blocking rule upgrading.
*/
hasUnresolvedConflicts: boolean;
}
export type RulesUpgradeState = Record<RuleSignatureId, RuleUpgradeState>;
export type SetFieldResolvedValueFn<
FieldName extends keyof DiffableAllFields = keyof DiffableAllFields
> = (params: {
ruleId: RuleObjectId;
fieldName: FieldName;
resolvedValue: DiffableAllFields[FieldName];
}) => void;
type RuleResolvedConflicts = Partial<DiffableAllFields>;
type RulesResolvedConflicts = Record<string, RuleResolvedConflicts>;
interface UseRulesUpgradeStateResult {
rulesUpgradeState: RulesUpgradeState;
setFieldResolvedValue: SetFieldResolvedValueFn;
}
export function usePrebuiltRulesUpgradeState(
ruleUpgradeInfos: RuleUpgradeInfoForReview[]
): UseRulesUpgradeStateResult {
const [rulesResolvedConflicts, setRulesResolvedConflicts] = useState<RulesResolvedConflicts>({});
const setFieldResolvedValue = useCallback((...[params]: Parameters<SetFieldResolvedValueFn>) => {
setRulesResolvedConflicts((prevRulesResolvedConflicts) => ({
...prevRulesResolvedConflicts,
[params.ruleId]: {
...(prevRulesResolvedConflicts[params.ruleId] ?? {}),
[params.fieldName]: params.resolvedValue,
},
}));
}, []);
const rulesUpgradeState = useMemo(() => {
const state: RulesUpgradeState = {};
for (const ruleUpgradeInfo of ruleUpgradeInfos) {
state[ruleUpgradeInfo.rule_id] = {
...ruleUpgradeInfo,
finalRule: calcFinalDiffableRule(
ruleUpgradeInfo,
rulesResolvedConflicts[ruleUpgradeInfo.rule_id] ?? {}
),
hasUnresolvedConflicts:
getUnacceptedConflictsCount(
ruleUpgradeInfo.diff.fields,
rulesResolvedConflicts[ruleUpgradeInfo.rule_id] ?? {}
) > 0,
};
}
return state;
}, [ruleUpgradeInfos, rulesResolvedConflicts]);
return {
rulesUpgradeState,
setFieldResolvedValue,
};
}
function calcFinalDiffableRule(
ruleUpgradeInfo: RuleUpgradeInfoForReview,
ruleResolvedConflicts: RuleResolvedConflicts
): DiffableRule {
return {
...convertRuleToDiffable(ruleUpgradeInfo.target_rule),
...convertRuleFieldsDiffToDiffable(ruleUpgradeInfo.diff.fields),
...ruleResolvedConflicts,
} as DiffableRule;
}
/**
* Assembles a `DiffableRule` from rule fields diff `merge_value`s.
*/
function convertRuleFieldsDiffToDiffable(
ruleFieldsDiff: FieldsDiff<Record<string, unknown>>
): Partial<DiffableRule> {
const mergeVersionRule: Record<string, unknown> = {};
for (const fieldName of Object.keys(ruleFieldsDiff)) {
mergeVersionRule[fieldName] = ruleFieldsDiff[fieldName].merged_version;
}
return mergeVersionRule;
}
function getUnacceptedConflictsCount(
ruleFieldsDiff: FieldsDiff<Record<string, unknown>>,
ruleResolvedConflicts: RuleResolvedConflicts
): number {
const fieldNames = Object.keys(ruleFieldsDiff);
const fieldNamesWithConflict = fieldNames.filter(
(fieldName) => ruleFieldsDiff[fieldName].conflict !== ThreeWayDiffConflict.NONE
);
const fieldNamesWithConflictSet = new Set(fieldNamesWithConflict);
for (const resolvedConflictField of Object.keys(ruleResolvedConflicts)) {
if (fieldNamesWithConflictSet.has(resolvedConflictField)) {
fieldNamesWithConflictSet.delete(resolvedConflictField);
}
}
return fieldNamesWithConflictSet.size;
}

View file

@ -10,7 +10,6 @@ import { EuiBadge, EuiButtonEmpty, EuiLink, EuiLoadingSpinner, EuiText } from '@
import React, { useMemo } from 'react';
import { RulesTableEmptyColumnName } from '../rules_table_empty_column_name';
import { SHOW_RELATED_INTEGRATIONS_SETTING } from '../../../../../../common/constants';
import type { RuleUpgradeInfoForReview } from '../../../../../../common/api/detection_engine/prebuilt_rules';
import type { RuleSignatureId } from '../../../../../../common/api/detection_engine/model/rule_schema';
import { PopoverItems } from '../../../../../common/components/popover_items';
import { useUiSetting$ } from '../../../../../common/lib/kibana';
@ -23,8 +22,9 @@ import type { Rule } from '../../../../rule_management/logic';
import { getNormalizedSeverity } from '../helpers';
import type { UpgradePrebuiltRulesTableActions } from './upgrade_prebuilt_rules_table_context';
import { useUpgradePrebuiltRulesTableContext } from './upgrade_prebuilt_rules_table_context';
import type { RuleUpgradeState } from './use_prebuilt_rules_upgrade_state';
export type TableColumn = EuiBasicTableColumn<RuleUpgradeInfoForReview>;
export type TableColumn = EuiBasicTableColumn<RuleUpgradeState>;
interface RuleNameProps {
name: string;
@ -51,10 +51,9 @@ const RuleName = ({ name, ruleId }: RuleNameProps) => {
const RULE_NAME_COLUMN: TableColumn = {
field: 'current_rule.name',
name: i18n.COLUMN_RULE,
render: (
value: RuleUpgradeInfoForReview['current_rule']['name'],
rule: RuleUpgradeInfoForReview
) => <RuleName name={value} ruleId={rule.id} />,
render: (value: RuleUpgradeState['current_rule']['name'], ruleUpgradeState: RuleUpgradeState) => (
<RuleName name={value} ruleId={ruleUpgradeState.id} />
),
sortable: true,
truncateText: true,
width: '60%',
@ -106,30 +105,30 @@ const INTEGRATIONS_COLUMN: TableColumn = {
};
const createUpgradeButtonColumn = (
upgradeOneRule: UpgradePrebuiltRulesTableActions['upgradeOneRule'],
upgradeRules: UpgradePrebuiltRulesTableActions['upgradeRules'],
loadingRules: RuleSignatureId[],
isDisabled: boolean
): TableColumn => ({
field: 'rule_id',
name: <RulesTableEmptyColumnName name={i18n.UPDATE_RULE_BUTTON} />,
render: (ruleId: RuleUpgradeInfoForReview['rule_id']) => {
render: (ruleId: RuleSignatureId, record) => {
const isRuleUpgrading = loadingRules.includes(ruleId);
const isUpgradeButtonDisabled = isRuleUpgrading || isDisabled;
const isUpgradeButtonDisabled = isRuleUpgrading || isDisabled || record.hasUnresolvedConflicts;
const spinner = (
<EuiLoadingSpinner
size="s"
data-test-subj={`upgradeSinglePrebuiltRuleButton-loadingSpinner-${ruleId}`}
/>
);
return (
<EuiButtonEmpty
size="s"
disabled={isUpgradeButtonDisabled}
onClick={() => upgradeOneRule(ruleId)}
onClick={() => upgradeRules([ruleId])}
data-test-subj={`upgradeSinglePrebuiltRuleButton-${ruleId}`}
>
{isRuleUpgrading ? (
<EuiLoadingSpinner
size="s"
data-test-subj={`upgradeSinglePrebuiltRuleButton-loadingSpinner-${ruleId}`}
/>
) : (
i18n.UPDATE_RULE_BUTTON
)}
{isRuleUpgrading ? spinner : i18n.UPDATE_RULE_BUTTON}
</EuiButtonEmpty>
);
},
@ -143,9 +142,8 @@ export const useUpgradePrebuiltRulesTableColumns = (): TableColumn[] => {
const [showRelatedIntegrations] = useUiSetting$<boolean>(SHOW_RELATED_INTEGRATIONS_SETTING);
const {
state: { loadingRules, isRefetching, isUpgradingSecurityPackages },
actions: { upgradeOneRule },
actions: { upgradeRules },
} = useUpgradePrebuiltRulesTableContext();
const isDisabled = isRefetching || isUpgradingSecurityPackages;
return useMemo(
@ -169,15 +167,15 @@ export const useUpgradePrebuiltRulesTableColumns = (): TableColumn[] => {
field: 'current_rule.severity',
name: i18n.COLUMN_SEVERITY,
render: (value: Rule['severity']) => <SeverityBadge value={value} />,
sortable: ({ current_rule: { severity } }: RuleUpgradeInfoForReview) =>
sortable: ({ current_rule: { severity } }: RuleUpgradeState) =>
getNormalizedSeverity(severity),
truncateText: true,
width: '12%',
},
...(hasCRUDPermissions
? [createUpgradeButtonColumn(upgradeOneRule, loadingRules, isDisabled)]
? [createUpgradeButtonColumn(upgradeRules, loadingRules, isDisabled)]
: []),
],
[hasCRUDPermissions, loadingRules, isDisabled, showRelatedIntegrations, upgradeOneRule]
[hasCRUDPermissions, loadingRules, isDisabled, showRelatedIntegrations, upgradeRules]
);
};

View file

@ -0,0 +1,77 @@
/*
* 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 { ReactNode } from 'react';
import React, { useCallback, useState, useMemo } from 'react';
import type { EuiTabbedContentTab } from '@elastic/eui';
import { invariant } from '../../../../../common/utils/invariant';
import type { RuleObjectId } from '../../../../../common/api/detection_engine';
import type { RuleResponse } from '../../../../../common/api/detection_engine/model/rule_schema';
import { RuleDetailsFlyout } from '../../../rule_management/components/rule_details/rule_details_flyout';
interface UseRulePreviewFlyoutParams {
rules: RuleResponse[];
ruleActionsFactory: (rule: RuleResponse, closeRulePreview: () => void) => ReactNode;
extraTabsFactory?: (rule: RuleResponse) => EuiTabbedContentTab[];
flyoutProps: RulePreviewFlyoutProps;
}
interface RulePreviewFlyoutProps {
/**
* Rule preview flyout unique id used in HTML
*/
id: string;
dataTestSubj: string;
}
interface UseRulePreviewFlyoutResult {
rulePreviewFlyout: ReactNode;
openRulePreview: (ruleId: RuleObjectId) => void;
closeRulePreview: () => void;
}
export function useRulePreviewFlyout({
rules,
extraTabsFactory,
ruleActionsFactory,
flyoutProps,
}: UseRulePreviewFlyoutParams): UseRulePreviewFlyoutResult {
const [rule, setRuleForPreview] = useState<RuleResponse | undefined>();
const closeRulePreview = useCallback(() => setRuleForPreview(undefined), []);
const ruleActions = useMemo(
() => rule && ruleActionsFactory(rule, closeRulePreview),
[rule, ruleActionsFactory, closeRulePreview]
);
const extraTabs = useMemo(
() => (rule && extraTabsFactory ? extraTabsFactory(rule) : []),
[rule, extraTabsFactory]
);
return {
rulePreviewFlyout: rule && (
<RuleDetailsFlyout
rule={rule}
size="l"
id={flyoutProps.id}
dataTestSubj={flyoutProps.dataTestSubj}
closeFlyout={closeRulePreview}
ruleActions={ruleActions}
extraTabs={extraTabs}
/>
),
openRulePreview: useCallback(
(ruleId: RuleObjectId) => {
const ruleToShowInFlyout = rules.find((x) => x.id === ruleId);
invariant(ruleToShowInFlyout, `Rule with id ${ruleId} not found`);
setRuleForPreview(ruleToShowInFlyout);
},
[rules, setRuleForPreview]
),
closeRulePreview,
};
}

View file

@ -18,9 +18,10 @@ import {
import type { RuleResponse } from '../../../../../../common/api/detection_engine/model/rule_schema';
import { invariant } from '../../../../../../common/utils/invariant';
import type { PrebuiltRuleAsset } from '../../model/rule_assets/prebuilt_rule_asset';
import { convertRuleToDiffable } from '../../../../../../common/detection_engine/prebuilt_rules/diff/convert_rule_to_diffable';
import { convertPrebuiltRuleAssetToRuleResponse } from '../../../rule_management/logic/detection_rules_client/converters/convert_prebuilt_rule_asset_to_rule_response';
import { calculateRuleFieldsDiff } from './calculation/calculate_rule_fields_diff';
import { convertRuleToDiffable } from './normalization/convert_rule_to_diffable';
export interface RuleVersions {
current?: RuleResponse;
@ -65,13 +66,19 @@ export const calculateRuleDiff = (args: RuleVersions): CalculateRuleDiffResult =
const { base, current, target } = args;
invariant(current != null, 'current version is required');
const diffableCurrentVersion = convertRuleToDiffable(current);
const diffableCurrentVersion = convertRuleToDiffable(
convertPrebuiltRuleAssetToRuleResponse(current)
);
invariant(target != null, 'target version is required');
const diffableTargetVersion = convertRuleToDiffable(target);
const diffableTargetVersion = convertRuleToDiffable(
convertPrebuiltRuleAssetToRuleResponse(target)
);
// Base version is optional
const diffableBaseVersion = base ? convertRuleToDiffable(base) : undefined;
const diffableBaseVersion = base
? convertRuleToDiffable(convertPrebuiltRuleAssetToRuleResponse(base))
: undefined;
const fieldsDiff = calculateRuleFieldsDiff({
base_version: diffableBaseVersion || MissingVersion,

View file

@ -1,21 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { RuleResponse } from '../../../../../../../common/api/detection_engine/model/rule_schema';
import type { BuildingBlockObject } from '../../../../../../../common/api/detection_engine/prebuilt_rules';
import type { PrebuiltRuleAsset } from '../../../model/rule_assets/prebuilt_rule_asset';
export const extractBuildingBlockObject = (
rule: RuleResponse | PrebuiltRuleAsset
): BuildingBlockObject | undefined => {
if (rule.building_block_type == null) {
return undefined;
}
return {
type: rule.building_block_type,
};
};

View file

@ -6,8 +6,8 @@
*/
import { v4 as uuidv4 } from 'uuid';
import { addEcsToRequiredFields } from '../../../../../../../common/detection_engine/rule_management/utils';
import { RuleResponse } from '../../../../../../../common/api/detection_engine/model/rule_schema';
import { addEcsToRequiredFields } from '../../../utils/utils';
import type { PrebuiltRuleAsset } from '../../../../prebuilt_rules';
import { RULE_DEFAULTS } from '../mergers/apply_rule_defaults';

View file

@ -9,6 +9,7 @@ import type { UpdateRuleData } from '@kbn/alerting-plugin/server/application/rul
import type { ActionsClient } from '@kbn/actions-plugin/server';
import type { RuleActionCamel } from '@kbn/securitysolution-io-ts-alerting-types';
import { addEcsToRequiredFields } from '../../../../../../../common/detection_engine/rule_management/utils';
import type {
RuleResponse,
TypeSpecificCreateProps,
@ -25,7 +26,7 @@ import { assertUnreachable } from '../../../../../../../common/utility_types';
import { convertObjectKeysToCamelCase } from '../../../../../../utils/object_case_converters';
import type { RuleParams, TypeSpecificRuleParams } from '../../../../rule_schema';
import { transformToActionFrequency } from '../../../normalization/rule_actions';
import { addEcsToRequiredFields, separateActionsAndSystemAction } from '../../../utils/utils';
import { separateActionsAndSystemAction } from '../../../utils/utils';
/**
* These are the fields that are added to the rule response that are not part of the rule params

View file

@ -6,6 +6,7 @@
*/
import { v4 as uuidv4 } from 'uuid';
import { addEcsToRequiredFields } from '../../../../../../../common/detection_engine/rule_management/utils';
import type {
RuleCreateProps,
RuleSource,
@ -20,7 +21,6 @@ import {
normalizeThresholdObject,
} from '../../../../../../../common/detection_engine/utils';
import { assertUnreachable } from '../../../../../../../common/utility_types';
import { addEcsToRequiredFields } from '../../../utils/utils';
export const RULE_DEFAULTS = {
enabled: false,

View file

@ -7,6 +7,7 @@
import { BadRequestError } from '@kbn/securitysolution-es-utils';
import { stringifyZodError } from '@kbn/zod-helpers';
import { addEcsToRequiredFields } from '../../../../../../../common/detection_engine/rule_management/utils';
import type {
EqlRule,
EqlRuleResponseFields,
@ -44,7 +45,6 @@ import {
} from '../../../../../../../common/detection_engine/utils';
import { assertUnreachable } from '../../../../../../../common/utility_types';
import type { IPrebuiltRuleAssetsClient } from '../../../../prebuilt_rules/logic/rule_assets/prebuilt_rule_assets_client';
import { addEcsToRequiredFields } from '../../../utils/utils';
import { calculateRuleSource } from './rule_source/calculate_rule_source';
interface ApplyRulePatchProps {

View file

@ -9,7 +9,7 @@ import type { RuleResponse } from '../../../../../../../../common/api/detection_
import { MissingVersion } from '../../../../../../../../common/api/detection_engine';
import type { PrebuiltRuleAsset } from '../../../../../prebuilt_rules';
import { calculateRuleFieldsDiff } from '../../../../../prebuilt_rules/logic/diff/calculation/calculate_rule_fields_diff';
import { convertRuleToDiffable } from '../../../../../prebuilt_rules/logic/diff/normalization/convert_rule_to_diffable';
import { convertRuleToDiffable } from '../../../../../../../../common/detection_engine/prebuilt_rules/diff/convert_rule_to_diffable';
import { convertPrebuiltRuleAssetToRuleResponse } from '../../converters/convert_prebuilt_rule_asset_to_rule_response';
export function calculateIsCustomized(

View file

@ -9,8 +9,6 @@ import { partition, isEmpty } from 'lodash/fp';
import pMap from 'p-map';
import { v4 as uuidv4 } from 'uuid';
import { ecsFieldMap } from '@kbn/alerts-as-data-utils';
import type { ActionsClient, FindActionResult } from '@kbn/actions-plugin/server';
import type { FindResult, PartialRule } from '@kbn/alerting-plugin/server';
import type { SavedObjectsClientContract } from '@kbn/core/server';
@ -18,8 +16,6 @@ import type { RuleAction } from '@kbn/securitysolution-io-ts-alerting-types';
import type {
InvestigationFields,
RequiredField,
RequiredFieldInput,
RuleResponse,
RuleAction as RuleActionSchema,
} from '../../../../../common/api/detection_engine/model/rule_schema';
@ -375,22 +371,6 @@ export const migrateLegacyInvestigationFields = (
return investigationFields;
};
/*
Computes the boolean "ecs" property value for each required field based on the ECS field map.
"ecs" property indicates whether the required field is an ECS field or not.
*/
export const addEcsToRequiredFields = (requiredFields?: RequiredFieldInput[]): RequiredField[] =>
(requiredFields ?? []).map((requiredFieldWithoutEcs) => {
const isEcsField = Boolean(
ecsFieldMap[requiredFieldWithoutEcs.name]?.type === requiredFieldWithoutEcs.type
);
return {
...requiredFieldWithoutEcs,
ecs: isEcsField,
};
});
export const separateActionsAndSystemAction = (
actionsClient: ActionsClient,
actions: RuleActionSchema[] | undefined

View file

@ -145,15 +145,24 @@ export const bulkCreateRuleAssets = ({
const url = `${Cypress.env('ELASTICSEARCH_URL')}/${index}/_bulk?refresh`;
const bulkIndexRequestBody = rules.reduce((body, rule) => {
const indexOperation = {
const document = JSON.stringify(rule);
const documentId = `security-rule:${rule['security-rule'].rule_id}`;
const historicalDocumentId = `${documentId}_${rule['security-rule'].version}`;
const indexRuleAsset = `${JSON.stringify({
index: {
_index: index,
_id: `security-rule:${rule['security-rule'].rule_id}`,
_id: documentId,
},
};
})}\n${document}\n`;
const indexHistoricalRuleAsset = `${JSON.stringify({
index: {
_index: index,
_id: historicalDocumentId,
},
})}\n${document}\n`;
const documentData = JSON.stringify(rule);
return body.concat(JSON.stringify(indexOperation), '\n', documentData, '\n');
return body.concat(indexRuleAsset, indexHistoricalRuleAsset);
}, '');
rootRequest({