= [
+ // Rule About fields
+ ...ABOUT_UPGRADE_FIELD_ORDER,
+ // Rule Definition fields
+ ...DEFINITION_UPGRADE_FIELD_ORDER,
+ // Rule Schedule fields
+ ...SCHEDULE_UPGRADE_FIELD_ORDER,
+ // Rule Setup fields
+ ...SETUP_UPGRADE_FIELD_ORDER,
+];
diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/diff_components/field_diff.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/diff_components/field_diff.tsx
new file mode 100644
index 0000000000000..b817d6ab3882c
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/diff_components/field_diff.tsx
@@ -0,0 +1,66 @@
+/*
+ * 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 { EuiFlexGroup, EuiHorizontalRule, EuiTitle } from '@elastic/eui';
+import { camelCase, startCase } from 'lodash';
+import React from 'react';
+import { DiffView } from '../json_diff/diff_view';
+import { RuleDiffPanelWrapper } from './panel_wrapper';
+import type { FormattedFieldDiff, FieldDiff } from '../../../model/rule_details/rule_field_diff';
+import { fieldToDisplayNameMap } from './translations';
+
+const SubFieldComponent = ({
+ currentVersion,
+ targetVersion,
+ fieldName,
+ shouldShowSeparator,
+ shouldShowSubtitles,
+}: FieldDiff & {
+ shouldShowSeparator: boolean;
+ shouldShowSubtitles: boolean;
+}) => (
+
+
+ {shouldShowSubtitles ? (
+
+ {fieldToDisplayNameMap[fieldName] ?? startCase(camelCase(fieldName))}
+
+ ) : null}
+
+ {shouldShowSeparator ? : null}
+
+
+);
+
+export interface FieldDiffComponentProps {
+ ruleDiffs: FormattedFieldDiff;
+ fieldsGroupName: string;
+}
+
+export const FieldGroupDiffComponent = ({
+ ruleDiffs,
+ fieldsGroupName,
+}: FieldDiffComponentProps) => {
+ const { fieldDiffs, shouldShowSubtitles } = ruleDiffs;
+ return (
+
+ {fieldDiffs.map(({ currentVersion, targetVersion, fieldName: specificFieldName }, index) => {
+ const shouldShowSeparator = index !== fieldDiffs.length - 1;
+ return (
+
+ );
+ })}
+
+ );
+};
diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/diff_components/header_bar.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/diff_components/header_bar.tsx
new file mode 100644
index 0000000000000..2f1e7f0207ecd
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/diff_components/header_bar.tsx
@@ -0,0 +1,60 @@
+/*
+ * 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 {
+ EuiFlexGroup,
+ EuiHorizontalRule,
+ EuiIconTip,
+ EuiSpacer,
+ EuiTitle,
+ useEuiTheme,
+} from '@elastic/eui';
+import React from 'react';
+import { css } from '@emotion/css';
+import * as i18n from '../json_diff/translations';
+
+export const RuleDiffHeaderBar = () => {
+ const { euiTheme } = useEuiTheme();
+ return (
+
+
+
+
+
+
+ {i18n.CURRENT_RULE_VERSION}
+
+
+
+
+
+ {i18n.ELASTIC_UPDATE_VERSION}
+
+
+
+
+
+ );
+};
diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/diff_components/index.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/diff_components/index.ts
new file mode 100644
index 0000000000000..6effbcf3af931
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/diff_components/index.ts
@@ -0,0 +1,11 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+export * from './field_diff';
+export * from './header_bar';
+export * from './panel_wrapper';
+export * from './rule_diff_section';
diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/diff_components/panel_wrapper.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/diff_components/panel_wrapper.tsx
new file mode 100644
index 0000000000000..789e9abf43395
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/diff_components/panel_wrapper.tsx
@@ -0,0 +1,43 @@
+/*
+ * 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 { EuiAccordion, EuiSplitPanel, EuiTitle, useEuiTheme } from '@elastic/eui';
+import { camelCase, startCase } from 'lodash';
+import { css } from '@emotion/css';
+import React from 'react';
+import { fieldToDisplayNameMap } from './translations';
+
+interface RuleDiffPanelWrapperProps {
+ fieldName: string;
+ children: React.ReactNode;
+}
+
+export const RuleDiffPanelWrapper = ({ fieldName, children }: RuleDiffPanelWrapperProps) => {
+ const { euiTheme } = useEuiTheme();
+
+ return (
+
+
+ {fieldToDisplayNameMap[fieldName] ?? startCase(camelCase(fieldName))}
+
+ }
+ >
+ {children}
+
+
+ );
+};
diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/diff_components/rule_diff_section.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/diff_components/rule_diff_section.tsx
new file mode 100644
index 0000000000000..fcfdf930fbd0f
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/diff_components/rule_diff_section.tsx
@@ -0,0 +1,43 @@
+/*
+ * 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 { EuiAccordion, EuiSpacer, EuiTitle } from '@elastic/eui';
+import React from 'react';
+import { css } from '@emotion/css';
+import type { FieldsGroupDiff } from '../../../model/rule_details/rule_field_diff';
+import { FieldGroupDiffComponent } from './field_diff';
+
+interface RuleDiffSectionProps {
+ title: string;
+ fieldGroups: FieldsGroupDiff[];
+}
+
+export const RuleDiffSection = ({ title, fieldGroups }: RuleDiffSectionProps) => (
+ <>
+
+
+ {title}
+
+ }
+ >
+ {fieldGroups.map(({ fieldsGroupName, formattedDiffs }) => {
+ return (
+
+
+
+
+ );
+ })}
+
+ >
+);
diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/diff_components/translations.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/diff_components/translations.ts
new file mode 100644
index 0000000000000..d8b6503f08a11
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/diff_components/translations.ts
@@ -0,0 +1,73 @@
+/*
+ * 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 { i18n } from '@kbn/i18n';
+import {
+ REFERENCES_FIELD_LABEL,
+ RISK_SCORE_MAPPING_FIELD_LABEL,
+ SEVERITY_MAPPING_FIELD_LABEL,
+ THREAT_INDICATOR_PATH_LABEL,
+ INDEX_FIELD_LABEL,
+ DATA_VIEW_ID_FIELD_LABEL,
+ THREAT_FIELD_LABEL,
+ ANOMALY_THRESHOLD_FIELD_LABEL,
+ MACHINE_LEARNING_JOB_ID_FIELD_LABEL,
+ THREAT_INDEX_FIELD_LABEL,
+ THREAT_MAPPING_FIELD_LABEL,
+ HISTORY_WINDOW_SIZE_FIELD_LABEL,
+} from '../translations';
+
+/**
+ * Used when fields have different display names or formats than their corresponding rule object fields
+ */
+export const fieldToDisplayNameMap: Record = {
+ data_source: i18n.translate(
+ 'xpack.securitySolution.detectionEngine.rules.upgradeRuleFields.dataSourceLabel',
+ {
+ defaultMessage: 'Data source',
+ }
+ ),
+ note: i18n.translate('xpack.securitySolution.detectionEngine.rules.upgradeRuleFields.noteLabel', {
+ defaultMessage: 'Investigation guide',
+ }),
+ severity_mapping: SEVERITY_MAPPING_FIELD_LABEL,
+ risk_score_mapping: RISK_SCORE_MAPPING_FIELD_LABEL,
+ references: REFERENCES_FIELD_LABEL,
+ threat_indicator_path: THREAT_INDICATOR_PATH_LABEL,
+ index_patterns: INDEX_FIELD_LABEL,
+ data_view_id: DATA_VIEW_ID_FIELD_LABEL,
+ threat: THREAT_FIELD_LABEL,
+ eql_query: i18n.translate(
+ 'xpack.securitySolution.detectionEngine.rules.upgradeRuleFields.eqlQueryLabel',
+ {
+ defaultMessage: 'EQL query',
+ }
+ ),
+ kql_query: i18n.translate(
+ 'xpack.securitySolution.detectionEngine.rules.upgradeRuleFields.kqlQueryLabel',
+ {
+ defaultMessage: 'KQL query',
+ }
+ ),
+ threat_query: i18n.translate(
+ 'xpack.securitySolution.detectionEngine.rules.upgradeRuleFields.threatQueryLabel',
+ {
+ defaultMessage: 'Indicator index query',
+ }
+ ),
+ esql_query: i18n.translate(
+ 'xpack.securitySolution.detectionEngine.rules.upgradeRuleFields.esqlQueryLabel',
+ {
+ defaultMessage: 'ESQL query',
+ }
+ ),
+ anomaly_threshold: ANOMALY_THRESHOLD_FIELD_LABEL,
+ machine_learning_job_id: MACHINE_LEARNING_JOB_ID_FIELD_LABEL,
+ threat_index: THREAT_INDEX_FIELD_LABEL,
+ threat_mapping: THREAT_MAPPING_FIELD_LABEL,
+ history_window_start: HISTORY_WINDOW_SIZE_FIELD_LABEL,
+};
diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/helpers.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/helpers.ts
new file mode 100644
index 0000000000000..f08187800789d
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/helpers.ts
@@ -0,0 +1,38 @@
+/*
+ * 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 { FieldsGroupDiff } from '../../model/rule_details/rule_field_diff';
+import {
+ ABOUT_UPGRADE_FIELD_ORDER,
+ DEFINITION_UPGRADE_FIELD_ORDER,
+ SCHEDULE_UPGRADE_FIELD_ORDER,
+ SETUP_UPGRADE_FIELD_ORDER,
+} from './constants';
+
+export const getSectionedFieldDiffs = (fields: FieldsGroupDiff[]) => {
+ const aboutFields = [];
+ const definitionFields = [];
+ const scheduleFields = [];
+ const setupFields = [];
+ for (const field of fields) {
+ if (ABOUT_UPGRADE_FIELD_ORDER.includes(field.fieldsGroupName)) {
+ aboutFields.push(field);
+ } else if (DEFINITION_UPGRADE_FIELD_ORDER.includes(field.fieldsGroupName)) {
+ definitionFields.push(field);
+ } else if (SCHEDULE_UPGRADE_FIELD_ORDER.includes(field.fieldsGroupName)) {
+ scheduleFields.push(field);
+ } else if (SETUP_UPGRADE_FIELD_ORDER.includes(field.fieldsGroupName)) {
+ setupFields.push(field);
+ }
+ }
+ return {
+ aboutFields,
+ definitionFields,
+ scheduleFields,
+ setupFields,
+ };
+};
diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/json_diff/hunks.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/json_diff/hunks.tsx
index a1bada553bab7..ebff2d04cf6b2 100644
--- a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/json_diff/hunks.tsx
+++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/json_diff/hunks.tsx
@@ -93,6 +93,12 @@ export const Hunks = ({ hunks, oldSource, expandRange }: HunksProps) => {
const hunkElements = hunks.reduce((children: ReactElement[], hunk: HunkData, index: number) => {
const previousElement = children[children.length - 1];
+ // If old source doesn't exist, we don't render expandable sections
+ if (!oldSource) {
+ children.push();
+ return children;
+ }
+
children.push(
{
+ if (!fieldValue) {
+ return ''; // The JSON diff package we use does not accept `undefined` as a valid entry, empty string renders the equivalent of no field
+ }
+
+ if (typeof fieldValue === 'string') {
+ return fieldValue;
+ }
+ return stringify(fieldValue, { space: 2 });
+};
+
+export const getFieldDiffsForDataSource = (
+ dataSourceThreeWayDiff: AllFieldsDiff['data_source']
+): FieldDiff[] => {
+ const currentType = sortAndStringifyJson(dataSourceThreeWayDiff.current_version?.type);
+ const targetType = sortAndStringifyJson(dataSourceThreeWayDiff.target_version?.type);
+
+ const currentIndexPatterns = sortAndStringifyJson(
+ dataSourceThreeWayDiff.current_version?.type === 'index_patterns' &&
+ dataSourceThreeWayDiff.current_version?.index_patterns
+ );
+ const targetIndexPatterns = sortAndStringifyJson(
+ dataSourceThreeWayDiff.target_version?.type === 'index_patterns' &&
+ dataSourceThreeWayDiff.target_version?.index_patterns
+ );
+ const currentDataViewId = sortAndStringifyJson(
+ dataSourceThreeWayDiff.current_version?.type === 'data_view' &&
+ dataSourceThreeWayDiff.current_version?.data_view_id
+ );
+ const targetDataViewId = sortAndStringifyJson(
+ dataSourceThreeWayDiff.target_version?.type === 'data_view' &&
+ dataSourceThreeWayDiff.target_version?.data_view_id
+ );
+
+ const hasTypeChanged = currentType !== targetType;
+ return [
+ ...(hasTypeChanged
+ ? [
+ {
+ fieldName: 'type',
+ currentVersion: currentType,
+ targetVersion: targetType,
+ },
+ ]
+ : []),
+ ...(currentIndexPatterns !== targetIndexPatterns
+ ? [
+ {
+ fieldName: 'index_patterns',
+ currentVersion: currentIndexPatterns,
+ targetVersion: targetIndexPatterns,
+ },
+ ]
+ : []),
+ ...(currentDataViewId !== targetDataViewId
+ ? [
+ {
+ fieldName: 'data_view_id',
+ currentVersion: currentDataViewId,
+ targetVersion: targetDataViewId,
+ },
+ ]
+ : []),
+ ];
+};
+
+export const getFieldDiffsForKqlQuery = (
+ kqlQueryThreeWayDiff: RuleFieldsDiffWithKqlQuery['kql_query']
+): FieldDiff[] => {
+ const currentType = sortAndStringifyJson(kqlQueryThreeWayDiff.current_version?.type);
+ const targetType = sortAndStringifyJson(kqlQueryThreeWayDiff.target_version?.type);
+
+ const currentQuery = sortAndStringifyJson(
+ kqlQueryThreeWayDiff.current_version?.type === 'inline_query' &&
+ kqlQueryThreeWayDiff.current_version?.query
+ );
+ const targetQuery = sortAndStringifyJson(
+ kqlQueryThreeWayDiff.target_version?.type === 'inline_query' &&
+ kqlQueryThreeWayDiff.target_version?.query
+ );
+
+ const currentLanguage = sortAndStringifyJson(
+ kqlQueryThreeWayDiff.current_version?.type === 'inline_query' &&
+ kqlQueryThreeWayDiff.current_version?.language
+ );
+ const targetLanguage = sortAndStringifyJson(
+ kqlQueryThreeWayDiff.target_version?.type === 'inline_query' &&
+ kqlQueryThreeWayDiff.target_version?.language
+ );
+
+ const currentFilters = sortAndStringifyJson(
+ kqlQueryThreeWayDiff.current_version?.type === 'inline_query' &&
+ kqlQueryThreeWayDiff.current_version?.filters
+ );
+ const targetFilters = sortAndStringifyJson(
+ kqlQueryThreeWayDiff.target_version?.type === 'inline_query' &&
+ kqlQueryThreeWayDiff.target_version?.filters
+ );
+
+ const currentSavedQueryId = sortAndStringifyJson(
+ kqlQueryThreeWayDiff.current_version?.type === 'saved_query' &&
+ kqlQueryThreeWayDiff.current_version?.saved_query_id
+ );
+ const targetSavedQueryId = sortAndStringifyJson(
+ kqlQueryThreeWayDiff.target_version?.type === 'saved_query' &&
+ kqlQueryThreeWayDiff.target_version?.saved_query_id
+ );
+
+ const hasTypeChanged = currentType !== targetType;
+
+ return [
+ ...(hasTypeChanged
+ ? [
+ {
+ fieldName: 'type',
+ currentVersion: currentType,
+ targetVersion: targetType,
+ },
+ ]
+ : []),
+ ...(currentQuery !== targetQuery
+ ? [
+ {
+ fieldName: 'query',
+ currentVersion: currentQuery,
+ targetVersion: targetQuery,
+ },
+ ]
+ : []),
+ ...(currentLanguage !== targetLanguage
+ ? [
+ {
+ fieldName: 'language',
+ currentVersion: currentLanguage,
+ targetVersion: targetLanguage,
+ },
+ ]
+ : []),
+ ...(currentFilters !== targetFilters
+ ? [
+ {
+ fieldName: 'filters',
+ currentVersion: currentFilters,
+ targetVersion: targetFilters,
+ },
+ ]
+ : []),
+ ...(currentSavedQueryId !== targetSavedQueryId
+ ? [
+ {
+ fieldName: 'saved_query_id',
+ currentVersion: currentSavedQueryId,
+ targetVersion: targetSavedQueryId,
+ },
+ ]
+ : []),
+ ];
+};
+
+export const getFieldDiffsForEqlQuery = (eqlQuery: AllFieldsDiff['eql_query']): FieldDiff[] => {
+ const currentQuery = sortAndStringifyJson(eqlQuery.current_version?.query);
+ const targetQuery = sortAndStringifyJson(eqlQuery.target_version?.query);
+
+ const currentFilters = sortAndStringifyJson(eqlQuery.current_version?.filters);
+ const targetFilters = sortAndStringifyJson(eqlQuery.target_version?.filters);
+ return [
+ ...(currentQuery !== targetQuery
+ ? [
+ {
+ fieldName: 'query',
+ currentVersion: currentQuery,
+ targetVersion: targetQuery,
+ },
+ ]
+ : []),
+ ...(currentFilters !== targetFilters
+ ? [
+ {
+ fieldName: 'filters',
+ currentVersion: currentFilters,
+ targetVersion: targetFilters,
+ },
+ ]
+ : []),
+ ];
+};
+
+export const getFieldDiffsForEsqlQuery = (esqlQuery: AllFieldsDiff['esql_query']): FieldDiff[] => {
+ const currentQuery = sortAndStringifyJson(esqlQuery.current_version?.query);
+ const targetQuery = sortAndStringifyJson(esqlQuery.target_version?.query);
+
+ const currentLanguage = sortAndStringifyJson(esqlQuery.current_version?.language);
+ const targetLanguage = sortAndStringifyJson(esqlQuery.target_version?.language);
+ return [
+ ...(currentQuery !== targetQuery
+ ? [
+ {
+ fieldName: 'query',
+ currentVersion: currentQuery,
+ targetVersion: targetQuery,
+ },
+ ]
+ : []),
+ ...(currentLanguage !== targetLanguage
+ ? [
+ {
+ fieldName: 'language',
+ currentVersion: currentLanguage,
+ targetVersion: targetLanguage,
+ },
+ ]
+ : []),
+ ];
+};
+
+export const getFieldDiffsForThreatQuery = (
+ threatQuery: AllFieldsDiff['threat_query']
+): FieldDiff[] => {
+ const currentQuery = sortAndStringifyJson(threatQuery.current_version?.query);
+ const targetQuery = sortAndStringifyJson(threatQuery.target_version?.query);
+
+ const currentLanguage = sortAndStringifyJson(threatQuery.current_version?.language);
+ const targetLanguage = sortAndStringifyJson(threatQuery.target_version?.language);
+
+ const currentFilters = sortAndStringifyJson(threatQuery.current_version?.filters);
+ const targetFilters = sortAndStringifyJson(threatQuery.target_version?.filters);
+ return [
+ ...(currentQuery !== targetQuery
+ ? [
+ {
+ fieldName: 'query',
+ currentVersion: currentQuery,
+ targetVersion: targetQuery,
+ },
+ ]
+ : []),
+ ...(currentLanguage !== targetLanguage
+ ? [
+ {
+ fieldName: 'language',
+ currentVersion: currentLanguage,
+ targetVersion: targetLanguage,
+ },
+ ]
+ : []),
+ ...(currentFilters !== targetFilters
+ ? [
+ {
+ fieldName: 'filters',
+ currentVersion: currentFilters,
+ targetVersion: targetFilters,
+ },
+ ]
+ : []),
+ ];
+};
+
+export const getFieldDiffsForRuleSchedule = (
+ ruleScheduleThreeWayDiff: AllFieldsDiff['rule_schedule']
+): FieldDiff[] => {
+ return [
+ ...(ruleScheduleThreeWayDiff.current_version?.interval !==
+ ruleScheduleThreeWayDiff.target_version?.interval
+ ? [
+ {
+ fieldName: 'interval',
+ currentVersion: sortAndStringifyJson(
+ ruleScheduleThreeWayDiff.current_version?.interval
+ ),
+ targetVersion: sortAndStringifyJson(ruleScheduleThreeWayDiff.target_version?.interval),
+ },
+ ]
+ : []),
+ ...(ruleScheduleThreeWayDiff.current_version?.lookback !==
+ ruleScheduleThreeWayDiff.target_version?.lookback
+ ? [
+ {
+ fieldName: 'lookback',
+ currentVersion: sortAndStringifyJson(
+ ruleScheduleThreeWayDiff.current_version?.lookback
+ ),
+ targetVersion: sortAndStringifyJson(ruleScheduleThreeWayDiff.target_version?.lookback),
+ },
+ ]
+ : []),
+ ];
+};
+
+export const getFieldDiffsForRuleNameOverride = (
+ ruleNameOverrideThreeWayDiff: AllFieldsDiff['rule_name_override']
+): FieldDiff[] => {
+ const currentFieldName = sortAndStringifyJson(
+ ruleNameOverrideThreeWayDiff.current_version?.field_name
+ );
+ const targetFieldName = sortAndStringifyJson(
+ ruleNameOverrideThreeWayDiff.target_version?.field_name
+ );
+ return [
+ ...(currentFieldName !== targetFieldName
+ ? [
+ {
+ fieldName: 'field_name',
+ currentVersion: currentFieldName,
+ targetVersion: targetFieldName,
+ },
+ ]
+ : []),
+ ];
+};
+
+export const getFieldDiffsForTimestampOverride = (
+ timestampOverrideThreeWayDiff: AllFieldsDiff['timestamp_override']
+): FieldDiff[] => {
+ const currentFieldName = sortAndStringifyJson(
+ timestampOverrideThreeWayDiff.current_version?.field_name
+ );
+ const targetFieldName = sortAndStringifyJson(
+ timestampOverrideThreeWayDiff.target_version?.field_name
+ );
+ const currentVersionFallbackDisabled = sortAndStringifyJson(
+ timestampOverrideThreeWayDiff.current_version?.fallback_disabled
+ );
+ const targetVersionFallbackDisabled = sortAndStringifyJson(
+ timestampOverrideThreeWayDiff.target_version?.fallback_disabled
+ );
+
+ return [
+ ...(currentFieldName !== targetFieldName
+ ? [
+ {
+ fieldName: 'field_name',
+ currentVersion: currentFieldName,
+ targetVersion: targetFieldName,
+ },
+ ]
+ : []),
+ ...(currentVersionFallbackDisabled !== targetVersionFallbackDisabled
+ ? [
+ {
+ fieldName: 'fallback_disabled',
+ currentVersion: currentVersionFallbackDisabled,
+ targetVersion: targetVersionFallbackDisabled,
+ },
+ ]
+ : []),
+ ];
+};
+
+export const getFieldDiffsForTimelineTemplate = (
+ timelineTemplateThreeWayDiff: AllFieldsDiff['timeline_template']
+): FieldDiff[] => {
+ const currentTimelineId = sortAndStringifyJson(
+ timelineTemplateThreeWayDiff.current_version?.timeline_id
+ );
+ const targetTimelineId = sortAndStringifyJson(
+ timelineTemplateThreeWayDiff.target_version?.timeline_id
+ );
+
+ const currentTimelineTitle = sortAndStringifyJson(
+ timelineTemplateThreeWayDiff.current_version?.timeline_title
+ );
+ const targetTimelineTitle = sortAndStringifyJson(
+ timelineTemplateThreeWayDiff.target_version?.timeline_title
+ );
+ return [
+ ...(currentTimelineId !== targetTimelineId
+ ? [
+ {
+ fieldName: 'timeline_id',
+ currentVersion: currentTimelineId,
+ targetVersion: targetTimelineId,
+ },
+ ]
+ : []),
+ ...(currentTimelineTitle !== targetTimelineTitle
+ ? [
+ {
+ fieldName: 'timeline_title',
+ currentVersion: currentTimelineTitle,
+ targetVersion: targetTimelineTitle,
+ },
+ ]
+ : []),
+ ];
+};
+
+export const getFieldDiffsForBuildingBlock = (
+ buildingBlockThreeWayDiff: AllFieldsDiff['building_block']
+): FieldDiff[] => {
+ const currentType = sortAndStringifyJson(buildingBlockThreeWayDiff.current_version?.type);
+ const targetType = sortAndStringifyJson(buildingBlockThreeWayDiff.target_version?.type);
+ return [
+ ...(currentType !== targetType
+ ? [
+ {
+ fieldName: 'type',
+ currentVersion: currentType,
+ targetVersion: targetType,
+ },
+ ]
+ : []),
+ ];
+};
+
+export const getFieldDiffsForThreshold = (
+ thresholdThreeWayDiff: AllFieldsDiff['threshold']
+): FieldDiff[] => {
+ const currentField = sortAndStringifyJson(thresholdThreeWayDiff.current_version?.field);
+ const targetField = sortAndStringifyJson(thresholdThreeWayDiff.target_version?.field);
+ const currentValue = sortAndStringifyJson(thresholdThreeWayDiff.current_version?.value);
+ const targetValue = sortAndStringifyJson(thresholdThreeWayDiff.target_version?.value);
+ const currentCardinality = sortAndStringifyJson(
+ thresholdThreeWayDiff.current_version?.cardinality
+ );
+ const targetCardinality = sortAndStringifyJson(thresholdThreeWayDiff.target_version?.cardinality);
+
+ return [
+ ...(currentField !== targetField
+ ? [
+ {
+ fieldName: 'field',
+ currentVersion: currentField,
+ targetVersion: targetField,
+ },
+ ]
+ : []),
+ ...(currentValue !== targetValue
+ ? [
+ {
+ fieldName: 'value',
+ currentVersion: currentValue,
+ targetVersion: targetValue,
+ },
+ ]
+ : []),
+ ...(currentCardinality !== targetCardinality
+ ? [
+ {
+ fieldName: 'cardinality',
+ currentVersion: currentCardinality,
+ targetVersion: targetCardinality,
+ },
+ ]
+ : []),
+ ];
+};
diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/per_field_diff/get_formatted_field_diff.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/per_field_diff/get_formatted_field_diff.ts
new file mode 100644
index 0000000000000..8f150efbf6677
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/per_field_diff/get_formatted_field_diff.ts
@@ -0,0 +1,133 @@
+/*
+ * 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 {
+ AllFieldsDiff,
+ RuleFieldsDiff,
+ RuleFieldsDiffWithDataSource,
+ RuleFieldsDiffWithKqlQuery,
+ RuleFieldsDiffWithEqlQuery,
+ RuleFieldsDiffWithThreshold,
+ RuleFieldsDiffWithEsqlQuery,
+ RuleFieldsDiffWithThreatQuery,
+} from '../../../../../../common/api/detection_engine';
+import type { FormattedFieldDiff } from '../../../model/rule_details/rule_field_diff';
+import {
+ getFieldDiffsForDataSource,
+ getFieldDiffsForKqlQuery,
+ getFieldDiffsForEqlQuery,
+ getFieldDiffsForRuleSchedule,
+ getFieldDiffsForRuleNameOverride,
+ getFieldDiffsForTimestampOverride,
+ getFieldDiffsForTimelineTemplate,
+ getFieldDiffsForBuildingBlock,
+ sortAndStringifyJson,
+ getFieldDiffsForThreshold,
+ getFieldDiffsForEsqlQuery,
+ getFieldDiffsForThreatQuery,
+} from './get_field_diffs_for_grouped_fields';
+
+export const getFormattedFieldDiffGroups = (
+ fieldName: keyof AllFieldsDiff,
+ fields: Partial
+): FormattedFieldDiff => {
+ /**
+ * Field types that contain groupings of rule fields must be formatted differently to compare and render
+ * each individual nested field and to satisfy types
+ *
+ * Setting shouldShowSubtitles to `true` displays the grouped field names in the rendered diff component
+ */
+ switch (fieldName) {
+ case 'data_source':
+ const dataSourceThreeWayDiff = (fields as RuleFieldsDiffWithDataSource)[fieldName];
+ return {
+ shouldShowSubtitles: true,
+ fieldDiffs: getFieldDiffsForDataSource(dataSourceThreeWayDiff),
+ };
+ case 'kql_query':
+ const kqlQueryThreeWayDiff = (fields as RuleFieldsDiffWithKqlQuery)[fieldName];
+ return {
+ shouldShowSubtitles: true,
+ fieldDiffs: getFieldDiffsForKqlQuery(kqlQueryThreeWayDiff),
+ };
+ case 'eql_query':
+ const eqlQueryThreeWayDiff = (fields as RuleFieldsDiffWithEqlQuery)[fieldName];
+ return {
+ shouldShowSubtitles: true,
+ fieldDiffs: getFieldDiffsForEqlQuery(eqlQueryThreeWayDiff),
+ };
+ case 'esql_query':
+ const esqlQueryThreeWayDiff = (fields as RuleFieldsDiffWithEsqlQuery)[fieldName];
+ return {
+ shouldShowSubtitles: true,
+ fieldDiffs: getFieldDiffsForEsqlQuery(esqlQueryThreeWayDiff),
+ };
+ case 'threat_query':
+ const threatQueryThreeWayDiff = (fields as RuleFieldsDiffWithThreatQuery)[fieldName];
+ return {
+ shouldShowSubtitles: true,
+ fieldDiffs: getFieldDiffsForThreatQuery(threatQueryThreeWayDiff),
+ };
+ case 'rule_schedule':
+ const ruleScheduleThreeWayDiff = fields[fieldName] as AllFieldsDiff['rule_schedule'];
+ return {
+ shouldShowSubtitles: true,
+ fieldDiffs: getFieldDiffsForRuleSchedule(ruleScheduleThreeWayDiff),
+ };
+ case 'rule_name_override':
+ const ruleNameOverrideThreeWayDiff = fields[fieldName] as AllFieldsDiff['rule_name_override'];
+ return {
+ shouldShowSubtitles: true,
+ fieldDiffs: getFieldDiffsForRuleNameOverride(ruleNameOverrideThreeWayDiff),
+ };
+ case 'timestamp_override':
+ const timestampOverrideThreeWayDiff = fields[
+ fieldName
+ ] as AllFieldsDiff['timestamp_override'];
+ return {
+ shouldShowSubtitles: true,
+ fieldDiffs: getFieldDiffsForTimestampOverride(timestampOverrideThreeWayDiff),
+ };
+ case 'timeline_template':
+ const timelineTemplateThreeWayDiff = fields[fieldName] as AllFieldsDiff['timeline_template'];
+ return {
+ shouldShowSubtitles: true,
+ fieldDiffs: getFieldDiffsForTimelineTemplate(timelineTemplateThreeWayDiff),
+ };
+ case 'building_block':
+ const buildingBlockThreeWayDiff = fields[fieldName] as AllFieldsDiff['building_block'];
+ return {
+ shouldShowSubtitles: true,
+ fieldDiffs: getFieldDiffsForBuildingBlock(buildingBlockThreeWayDiff),
+ };
+ case 'threshold':
+ const thresholdThreeWayDiff = (fields as RuleFieldsDiffWithThreshold)[
+ fieldName
+ ] as AllFieldsDiff['threshold'];
+ return {
+ shouldShowSubtitles: true,
+ fieldDiffs: getFieldDiffsForThreshold(thresholdThreeWayDiff),
+ };
+ default:
+ const fieldThreeWayDiff = (fields as AllFieldsDiff)[fieldName];
+ const currentVersionField = sortAndStringifyJson(fieldThreeWayDiff.current_version);
+ const targetVersionField = sortAndStringifyJson(fieldThreeWayDiff.target_version);
+ return {
+ shouldShowSubtitles: false,
+ fieldDiffs:
+ currentVersionField !== targetVersionField
+ ? [
+ {
+ fieldName,
+ currentVersion: currentVersionField,
+ targetVersion: targetVersionField,
+ },
+ ]
+ : [],
+ };
+ }
+};
diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/per_field_rule_diff_tab.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/per_field_rule_diff_tab.tsx
new file mode 100644
index 0000000000000..2e39428a0dc12
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/per_field_rule_diff_tab.tsx
@@ -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 React, { useMemo } from 'react';
+import type { PartialRuleDiff, RuleFieldsDiff } from '../../../../../common/api/detection_engine';
+import { getFormattedFieldDiffGroups } from './per_field_diff/get_formatted_field_diff';
+import { UPGRADE_FIELD_ORDER } from './constants';
+import { RuleDiffHeaderBar, RuleDiffSection } from './diff_components';
+import { getSectionedFieldDiffs } from './helpers';
+import type { FieldsGroupDiff } from '../../model/rule_details/rule_field_diff';
+import * as i18n from './translations';
+
+interface PerFieldRuleDiffTabProps {
+ ruleDiff: PartialRuleDiff;
+}
+
+export const PerFieldRuleDiffTab = ({ ruleDiff }: PerFieldRuleDiffTabProps) => {
+ const fieldsToRender = useMemo(() => {
+ const fields: FieldsGroupDiff[] = [];
+ for (const field of Object.keys(ruleDiff.fields)) {
+ const typedField = field as keyof RuleFieldsDiff;
+ const formattedDiffs = getFormattedFieldDiffGroups(typedField, ruleDiff.fields);
+ fields.push({ formattedDiffs, fieldsGroupName: typedField });
+ }
+ const sortedFields = fields.sort(
+ (a, b) =>
+ UPGRADE_FIELD_ORDER.indexOf(a.fieldsGroupName) -
+ UPGRADE_FIELD_ORDER.indexOf(b.fieldsGroupName)
+ );
+ return sortedFields;
+ }, [ruleDiff.fields]);
+
+ const { aboutFields, definitionFields, scheduleFields, setupFields } = useMemo(
+ () => getSectionedFieldDiffs(fieldsToRender),
+ [fieldsToRender]
+ );
+
+ return (
+ <>
+
+ {aboutFields.length !== 0 && (
+
+ )}
+ {definitionFields.length !== 0 && (
+
+ )}
+ {scheduleFields.length !== 0 && (
+
+ )}
+ {setupFields.length !== 0 && (
+
+ )}
+ >
+ );
+};
diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/rule_diff_tab.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/rule_diff_tab.tsx
index fc1fa754f9694..d801f46e85696 100644
--- a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/rule_diff_tab.tsx
+++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/rule_diff_tab.tsx
@@ -167,7 +167,7 @@ export const RuleDiffTab = ({ oldRule, newRule }: RuleDiffTabProps) => {
size="m"
/>
- {i18n.UPDATED_VERSION}
+ {i18n.ELASTIC_UPDATE_VERSION}
diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/translations.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/translations.ts
index dffdf89d31feb..9bc90aee27717 100644
--- a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/translations.ts
+++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/translations.ts
@@ -28,6 +28,13 @@ export const UPDATES_TAB_LABEL = i18n.translate(
}
);
+export const JSON_VIEW_UPDATES_TAB_LABEL = i18n.translate(
+ 'xpack.securitySolution.detectionEngine.ruleDetails.jsonViewUpdatesTabLabel',
+ {
+ defaultMessage: 'JSON view',
+ }
+);
+
export const DISMISS_BUTTON_LABEL = i18n.translate(
'xpack.securitySolution.detectionEngine.ruleDetails.dismissButtonLabel',
{
diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/model/rule_details/rule_field_diff.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_management/model/rule_details/rule_field_diff.ts
new file mode 100644
index 0000000000000..b5121d182e34f
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/model/rule_details/rule_field_diff.ts
@@ -0,0 +1,23 @@
+/*
+ * 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 { AllFieldsDiff } from '../../../../../common/api/detection_engine';
+
+export interface FieldDiff {
+ currentVersion: string;
+ targetVersion: string;
+ fieldName: string;
+}
+export interface FormattedFieldDiff {
+ shouldShowSubtitles: boolean;
+ fieldDiffs: FieldDiff[];
+}
+
+export interface FieldsGroupDiff {
+ formattedDiffs: FormattedFieldDiff;
+ fieldsGroupName: keyof AllFieldsDiff;
+}
diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/upgrade_prebuilt_rules_table/upgrade_prebuilt_rules_table_context.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/upgrade_prebuilt_rules_table/upgrade_prebuilt_rules_table_context.tsx
index 4931943c3c114..d5a5938688925 100644
--- a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/upgrade_prebuilt_rules_table/upgrade_prebuilt_rules_table_context.tsx
+++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/upgrade_prebuilt_rules_table/upgrade_prebuilt_rules_table_context.tsx
@@ -9,6 +9,7 @@ import type { Dispatch, SetStateAction } from 'react';
import React, { createContext, useCallback, useContext, useMemo, useState } from 'react';
import { EuiButton } from '@elastic/eui';
import type { EuiTabbedContentTab } from '@elastic/eui';
+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';
@@ -121,6 +122,10 @@ export const UpgradePrebuiltRulesTableContextProvider = ({
'jsonPrebuiltRulesDiffingEnabled'
);
+ const isPerFieldPrebuiltRulesDiffingEnabled = useIsExperimentalFeatureEnabled(
+ 'perFieldPrebuiltRulesDiffingEnabled'
+ );
+
const isUpgradingSecurityPackages = useIsUpgradingSecurityPackages();
const {
@@ -268,27 +273,46 @@ export const UpgradePrebuiltRulesTableContextProvider = ({
]);
const extraTabs = useMemo(() => {
- const activeRule =
- isJsonPrebuiltRulesDiffingEnabled &&
- previewedRule &&
- filteredRules.find(({ id }) => id === previewedRule.id);
+ const activeRule = previewedRule && filteredRules.find(({ id }) => id === previewedRule.id);
if (!activeRule) {
return [];
}
return [
- {
- id: 'updates',
- name: ruleDetailsI18n.UPDATES_TAB_LABEL,
- content: (
-
-
-
- ),
- },
+ ...(isPerFieldPrebuiltRulesDiffingEnabled
+ ? [
+ {
+ id: 'updates',
+ name: ruleDetailsI18n.UPDATES_TAB_LABEL,
+ content: (
+
+
+
+ ),
+ },
+ ]
+ : []),
+ ...(isJsonPrebuiltRulesDiffingEnabled
+ ? [
+ {
+ id: 'jsonViewUpdates',
+ name: ruleDetailsI18n.JSON_VIEW_UPDATES_TAB_LABEL,
+ content: (
+
+
+
+ ),
+ },
+ ]
+ : []),
];
- }, [previewedRule, filteredRules, isJsonPrebuiltRulesDiffingEnabled]);
+ }, [
+ previewedRule,
+ filteredRules,
+ isJsonPrebuiltRulesDiffingEnabled,
+ isPerFieldPrebuiltRulesDiffingEnabled,
+ ]);
return (
diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/logic/diff/calculation/calculate_rule_fields_diff.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/logic/diff/calculation/calculate_rule_fields_diff.ts
index 5fbb874bb71cb..5639cc07ebedd 100644
--- a/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/logic/diff/calculation/calculate_rule_fields_diff.ts
+++ b/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/logic/diff/calculation/calculate_rule_fields_diff.ts
@@ -318,6 +318,7 @@ const allFieldsDiffAlgorithms: FieldsDiffAlgorithmsFor = {
...customQueryFieldsDiffAlgorithms,
...savedQueryFieldsDiffAlgorithms,
...eqlFieldsDiffAlgorithms,
+ ...esqlFieldsDiffAlgorithms,
...threatMatchFieldsDiffAlgorithms,
...thresholdFieldsDiffAlgorithms,
...machineLearningFieldsDiffAlgorithms,