diff --git a/x-pack/plugins/security_solution/common/api/detection_engine/prebuilt_rules/model/diff/diffable_rule/diffable_rule.ts b/x-pack/plugins/security_solution/common/api/detection_engine/prebuilt_rules/model/diff/diffable_rule/diffable_rule.ts index ac53a3c695d47..9bb6fc10031d2 100644 --- a/x-pack/plugins/security_solution/common/api/detection_engine/prebuilt_rules/model/diff/diffable_rule/diffable_rule.ts +++ b/x-pack/plugins/security_solution/common/api/detection_engine/prebuilt_rules/model/diff/diffable_rule/diffable_rule.ts @@ -239,6 +239,7 @@ export const DiffableNewTermsFields = buildSchema({ * NOTE: Every top-level field in a DiffableRule MUST BE LOGICALLY INDEPENDENT from other * top-level fields. */ + export type DiffableRule = t.TypeOf; export const DiffableRule = t.intersection([ DiffableCommonFields, @@ -262,6 +263,7 @@ export type DiffableAllFields = DiffableCommonFields & Omit & Omit & Omit & + Omit & Omit & Omit & Omit & diff --git a/x-pack/plugins/security_solution/common/api/detection_engine/prebuilt_rules/model/diff/rule_diff/rule_diff.ts b/x-pack/plugins/security_solution/common/api/detection_engine/prebuilt_rules/model/diff/rule_diff/rule_diff.ts index 91f8b39ff70e0..d990bb3dbf461 100644 --- a/x-pack/plugins/security_solution/common/api/detection_engine/prebuilt_rules/model/diff/rule_diff/rule_diff.ts +++ b/x-pack/plugins/security_solution/common/api/detection_engine/prebuilt_rules/model/diff/rule_diff/rule_diff.ts @@ -73,3 +73,26 @@ export interface PartialRuleDiff { fields: Partial; has_conflict: boolean; } + +export type RuleFieldsDiffWithDataSource = + | CustomQueryFieldsDiff + | SavedQueryFieldsDiff + | EqlFieldsDiff + | ThreatMatchFieldsDiff + | ThresholdFieldsDiff + | NewTermsFieldsDiff; + +export type RuleFieldsDiffWithKqlQuery = + | CustomQueryFieldsDiff + | SavedQueryFieldsDiff + | ThreatMatchFieldsDiff + | ThresholdFieldsDiff + | NewTermsFieldsDiff; + +export type RuleFieldsDiffWithEqlQuery = EqlFieldsDiff; + +export type RuleFieldsDiffWithEsqlQuery = EsqlFieldsDiff; + +export type RuleFieldsDiffWithThreatQuery = ThreatMatchFieldsDiff; + +export type RuleFieldsDiffWithThreshold = ThresholdFieldsDiff; diff --git a/x-pack/plugins/security_solution/common/experimental_features.ts b/x-pack/plugins/security_solution/common/experimental_features.ts index e7c5cf104b7c7..2cbcdfc9416bb 100644 --- a/x-pack/plugins/security_solution/common/experimental_features.ts +++ b/x-pack/plugins/security_solution/common/experimental_features.ts @@ -178,6 +178,12 @@ export const allowedExperimentalValues = Object.freeze({ * Enables experimental "Updates" tab in the prebuilt rule upgrade flyout. * This tab shows the JSON diff between the installed prebuilt rule * version and the latest available version. + * + * Ticket: https://github.com/elastic/kibana/issues/169160 + * Owners: https://github.com/orgs/elastic/teams/security-detection-rule-management + * Added: on Dec 06, 2023 in https://github.com/elastic/kibana/pull/172535 + * Turned: on Dec 20, 2023 in https://github.com/elastic/kibana/pull/173368 + * Expires: on Feb 20, 2024 */ jsonPrebuiltRulesDiffingEnabled: true, /* @@ -185,6 +191,15 @@ export const allowedExperimentalValues = Object.freeze({ * */ timelineEsqlTabDisabled: false, + + /** + * Enables per-field rule diffs tab in the prebuilt rule upgrade flyout + * + * Ticket: https://github.com/elastic/kibana/issues/166489 + * Owners: https://github.com/orgs/elastic/teams/security-detection-rule-management + * Added: on Feb 12, 2023 in https://github.com/elastic/kibana/pull/174564 + */ + perFieldPrebuiltRulesDiffingEnabled: false, }); type ExperimentalConfigKeys = Array; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/constants.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/constants.ts index 4d6bcd542b866..45cb8f0633e65 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/constants.ts +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/constants.ts @@ -5,5 +5,73 @@ * 2.0. */ +import type { DiffableAllFields } from '../../../../../common/api/detection_engine'; + export const DEFAULT_DESCRIPTION_LIST_COLUMN_WIDTHS: [string, string] = ['50%', '50%']; export const LARGE_DESCRIPTION_LIST_COLUMN_WIDTHS: [string, string] = ['30%', '70%']; + +export const ABOUT_UPGRADE_FIELD_ORDER: Array = [ + 'version', + 'name', + 'description', + 'author', + 'building_block', + 'severity', + 'severity_mapping', + 'risk_score', + 'risk_score_mapping', + 'references', + 'false_positives', + 'license', + 'rule_name_override', + 'threat', + 'threat_indicator_path', + 'timestamp_override', + 'tags', +]; + +export const DEFINITION_UPGRADE_FIELD_ORDER: Array = [ + 'data_source', + 'type', + 'kql_query', + 'eql_query', + 'event_category_override', + 'timestamp_field', + 'tiebreaker_field', + 'esql_query', + 'anomaly_threshold', + 'machine_learning_job_id', + 'related_integrations', + 'required_fields', + 'timeline_template', + 'threshold', + 'threat_index', + 'threat_mapping', + 'threat_query', + 'threat_indicator_path', + 'concurrent_searches', + 'items_per_search', + 'alert_suppression', + 'new_terms_fields', + 'history_window_start', + 'max_signals', +]; + +export const SCHEDULE_UPGRADE_FIELD_ORDER: Array = ['rule_schedule']; + +export const SETUP_UPGRADE_FIELD_ORDER: Array = ['setup', 'note']; + +/** + * This order is derived from a combination of the Rule Details Flyout display order + * and the `DiffableRule` type that is returned from the rule diff API endpoint + */ +export const UPGRADE_FIELD_ORDER: Array = [ + // 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,