From 692bde9cf345810f818696c9dc49aa592b3b36d5 Mon Sep 17 00:00:00 2001 From: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Date: Fri, 8 Dec 2023 11:36:18 -0500 Subject: [PATCH] [8.12] [Security Solution] JSON diff view for prebuilt rule upgrade flow (#172535) (#172957) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Backport This will backport the following commits from `main` to `8.12`: - [[Security Solution] JSON diff view for prebuilt rule upgrade flow (#172535)](https://github.com/elastic/kibana/pull/172535) ### Questions ? Please refer to the [Backport tool documentation](https://github.com/sqren/backport) Co-authored-by: Nikita Indik --- package.json | 5 +- .../add_messages_to_report.test.ts | 16 +- .../common/experimental_features.ts | 7 + .../components/rule_details/constants.ts | 3 +- .../rule_details/json_diff/diff_view.tsx | 252 +++++++++++++++ .../rule_details/json_diff/hunks.tsx | 124 ++++++++ .../rule_details/json_diff/mark_edits.tsx | 290 ++++++++++++++++++ .../rule_details/json_diff/translations.ts | 46 +++ .../rule_details/json_diff/unidiff.d.ts | 23 ++ .../rule_details/rule_about_section.tsx | 6 +- .../rule_details/rule_definition_section.tsx | 6 +- .../rule_details/rule_details_flyout.tsx | 27 +- .../components/rule_details/rule_diff_tab.tsx | 76 +++++ .../rule_details/rule_overview_tab.tsx | 88 +++--- .../rule_details/rule_schedule_section.tsx | 7 +- .../components/rule_details/translations.ts | 7 + .../upgrade_prebuilt_rules_table_context.tsx | 41 ++- .../review_rule_upgrade_route.ts | 5 + yarn.lock | 45 ++- 19 files changed, 1004 insertions(+), 70 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/json_diff/diff_view.tsx create mode 100644 x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/json_diff/hunks.tsx create mode 100644 x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/json_diff/mark_edits.tsx create mode 100644 x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/json_diff/translations.ts create mode 100644 x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/json_diff/unidiff.d.ts create mode 100644 x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/rule_diff_tab.tsx diff --git a/package.json b/package.json index a94bc4859e370..b3c9becfbb0ff 100644 --- a/package.json +++ b/package.json @@ -922,6 +922,7 @@ "deep-freeze-strict": "^1.1.1", "deepmerge": "^4.2.2", "del": "^6.1.0", + "diff": "^5.1.0", "elastic-apm-node": "^4.2.0", "email-addresses": "^5.0.0", "execa": "^5.1.1", @@ -1029,6 +1030,7 @@ "react": "^17.0.2", "react-ace": "^7.0.5", "react-color": "^2.13.8", + "react-diff-view": "^3.2.0", "react-dom": "^17.0.2", "react-dropzone": "^4.2.9", "react-fast-compare": "^2.0.4", @@ -1089,6 +1091,7 @@ "type-detect": "^4.0.8", "typescript-fsa": "^3.0.0", "typescript-fsa-reducers": "^1.2.2", + "unidiff": "^1.0.4", "unified": "9.2.2", "use-resize-observer": "^9.1.0", "usng.js": "^0.4.5", @@ -1345,6 +1348,7 @@ "@types/dedent": "^0.7.0", "@types/deep-freeze-strict": "^1.1.0", "@types/delete-empty": "^2.0.0", + "@types/diff": "^5.0.8", "@types/ejs": "^3.0.6", "@types/enzyme": "^3.10.12", "@types/eslint": "^8.44.2", @@ -1508,7 +1512,6 @@ "debug": "^2.6.9", "delete-empty": "^2.0.0", "dependency-check": "^4.1.0", - "diff": "^4.0.1", "dpdm": "3.9.0", "ejs": "^3.1.8", "enzyme": "^3.11.0", diff --git a/packages/kbn-failed-test-reporter-cli/failed_tests_reporter/add_messages_to_report.test.ts b/packages/kbn-failed-test-reporter-cli/failed_tests_reporter/add_messages_to_report.test.ts index 3b71823ee6bde..02197cf54fab0 100644 --- a/packages/kbn-failed-test-reporter-cli/failed_tests_reporter/add_messages_to_report.test.ts +++ b/packages/kbn-failed-test-reporter-cli/failed_tests_reporter/add_messages_to_report.test.ts @@ -53,10 +53,10 @@ it('rewrites ftr reports with minimal changes', async () => { reportPath: Path.resolve(__dirname, './__fixtures__/ftr_report.xml'), }); - expect(createPatch('ftr.xml', FTR_REPORT, xml, { context: 0 })).toMatchInlineSnapshot(` + expect(createPatch('ftr.xml', FTR_REPORT, xml)).toMatchInlineSnapshot(` Index: ftr.xml =================================================================== - --- ftr.xml [object Object] + --- ftr.xml +++ ftr.xml @@ -1,53 +1,56 @@ ‹?xml version="1.0" encoding="utf-8"?› @@ -149,10 +149,10 @@ it('rewrites jest reports with minimal changes', async () => { reportPath: Path.resolve(__dirname, './__fixtures__/jest_report.xml'), }); - expect(createPatch('jest.xml', JEST_REPORT, xml, { context: 0 })).toMatchInlineSnapshot(` + expect(createPatch('jest.xml', JEST_REPORT, xml)).toMatchInlineSnapshot(` Index: jest.xml =================================================================== - --- jest.xml [object Object] + --- jest.xml +++ jest.xml @@ -3,13 +3,17 @@ ‹testsuite name="x-pack/legacy/plugins/code/server/lsp/abstract_launcher.test.ts" timestamp="2019-06-07T03:42:21" time="14.504" tests="5" failures="1" skipped="0" file="/var/lib/jenkins/workspace/elastic+kibana+master/JOB/x-pack-intake/node/immutable/kibana/x-pack/legacy/plugins/code/server/lsp/abstract_launcher.test.ts"› @@ -196,10 +196,10 @@ it('rewrites mocha reports with minimal changes', async () => { reportPath: Path.resolve(__dirname, './__fixtures__/mocha_report.xml'), }); - expect(createPatch('mocha.xml', MOCHA_REPORT, xml, { context: 0 })).toMatchInlineSnapshot(` + expect(createPatch('mocha.xml', MOCHA_REPORT, xml)).toMatchInlineSnapshot(` Index: mocha.xml =================================================================== - --- mocha.xml [object Object] + --- mocha.xml +++ mocha.xml @@ -1,13 +1,16 @@ ‹?xml version="1.0" encoding="utf-8"?› @@ -273,10 +273,10 @@ it('rewrites cypress reports with minimal changes', async () => { reportPath: Path.resolve(__dirname, './__fixtures__/cypress_report.xml'), }); - expect(createPatch('cypress.xml', CYPRESS_REPORT, xml, { context: 0 })).toMatchInlineSnapshot(` + expect(createPatch('cypress.xml', CYPRESS_REPORT, xml)).toMatchInlineSnapshot(` Index: cypress.xml =================================================================== - --- cypress.xml [object Object] + --- cypress.xml +++ cypress.xml @@ -1,25 +1,16 @@ -‹?xml version="1.0" encoding="UTF-8"?› diff --git a/x-pack/plugins/security_solution/common/experimental_features.ts b/x-pack/plugins/security_solution/common/experimental_features.ts index e36b22e190b95..328cdf3a35219 100644 --- a/x-pack/plugins/security_solution/common/experimental_features.ts +++ b/x-pack/plugins/security_solution/common/experimental_features.ts @@ -156,6 +156,13 @@ export const allowedExperimentalValues = Object.freeze({ * Enables SentinelOne manual host manipulation actions */ sentinelOneManualHostActionsEnabled: false, + + /* + * 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. + */ + jsonPrebuiltRulesDiffingEnabled: 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 1561be4f4d8a4..4d6bcd542b866 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,4 +5,5 @@ * 2.0. */ -export const DESCRIPTION_LIST_COLUMN_WIDTHS: [string, string] = ['50%', '50%']; +export const DEFAULT_DESCRIPTION_LIST_COLUMN_WIDTHS: [string, string] = ['50%', '50%']; +export const LARGE_DESCRIPTION_LIST_COLUMN_WIDTHS: [string, string] = ['30%', '70%']; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/json_diff/diff_view.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/json_diff/diff_view.tsx new file mode 100644 index 0000000000000..8905d0aca4e6f --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/json_diff/diff_view.tsx @@ -0,0 +1,252 @@ +/* + * 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 { css, Global } from '@emotion/react'; +import { + Diff, + useSourceExpansion, + useMinCollapsedLines, + parseDiff, + tokenize, +} from 'react-diff-view'; +import 'react-diff-view/style/index.css'; +import type { + RenderGutter, + HunkData, + TokenizeOptions, + DiffProps, + HunkTokens, +} from 'react-diff-view'; +import unidiff from 'unidiff'; +import { useEuiTheme } from '@elastic/eui'; +import { Hunks } from './hunks'; +import { markEdits, DiffMethod } from './mark_edits'; + +interface UseExpandReturn { + expandRange: (start: number, end: number) => void; + hunks: HunkData[]; +} + +/** + * @param {HunkData[]} hunks - An array of hunk objects representing changes in a section of a string. Sections normally span multiple lines. + * @param {string} oldSource - Original string, before changes + * @returns {UseExpandReturn} - "expandRange" is function that triggers expansion, "hunks" is an array of hunks with hidden section expanded. + * + * @description + * Sections of diff without changes are hidden by default, because they are not present in the "hunks" array. + * "useExpand" allows to show these hidden sections when user clicks on "Expand hidden lines" button. + * Calling "expandRange" basically merges two adjacent hunks into one: + * - takes first hunk + * - appends all the lines between the first hunk and the second hunk + * - finally appends the second hunk + * returned "hunks" is the resulting array of hunks with hidden section expanded. + */ +const useExpand = (hunks: HunkData[], oldSource: string): UseExpandReturn => { + const [hunksWithSourceExpanded, expandRange] = useSourceExpansion(hunks, oldSource); + const hunksWithMinLinesCollapsed = useMinCollapsedLines(0, hunksWithSourceExpanded, oldSource); + + return { + expandRange, + hunks: hunksWithMinLinesCollapsed, + }; +}; + +const useTokens = ( + hunks: HunkData[], + diffMethod: DiffMethod, + oldSource: string +): HunkTokens | undefined => { + if (!hunks) { + return undefined; + } + + const options: TokenizeOptions = { + oldSource, + highlight: false, + enhancers: [ + /* + This custom "markEdits" function is a slightly modified version of "markEdits" + enhancer from react-diff-view with added support for word-level highlighting. + */ + markEdits(hunks, diffMethod), + ], + }; + + try { + /* + Synchroniously apply all the enhancers to the hunks and return an array of tokens. + */ + return tokenize(hunks, options); + } catch (ex) { + return undefined; + } +}; + +const renderGutter: RenderGutter = ({ change }) => { + /* + Custom gutter: rendering "+" or "-" so the diff is readable by colorblind people. + */ + if (change.type === 'insert') { + return {'+'}; + } + + if (change.type === 'delete') { + return {'-'}; + } + + return null; +}; + +const convertToDiffFile = (oldSource: string, newSource: string) => { + /* + "diffLines" call converts two strings of text into an array of Change objects. + */ + const changes = unidiff.diffLines(oldSource, newSource); + + /* + Then "formatLines" takes an array of Change objects and turns it into a single "unified diff" string. + More info about the "unified diff" format: https://en.wikipedia.org/wiki/Diff_utility#Unified_format + Unified diff is a string with change markers added. Looks something like: + ` + @@ -3,16 +3,15 @@ + "author": ["Elastic"], + - "from": "now-540s", + + "from": "now-9m", + "history_window_start": "now-14d", + ` + */ + const unifiedDiff: string = unidiff.formatLines(changes, { + context: 3, + }); + + /* + "parseDiff" converts a unified diff string into a gitdiff-parser File object. + + File object contains some metadata and the "hunks" property - an array of Hunk objects. + Hunks represent changed lines of code plus a few unchanged lines above and below for context. + */ + const [diffFile] = parseDiff(unifiedDiff, { + nearbySequences: 'zip', + }); + + return diffFile; +}; + +const TABLE_CLASS_NAME = 'rule-update-diff-table'; +const CODE_CLASS_NAME = 'rule-update-diff-code'; +const GUTTER_CLASS_NAME = 'rule-update-diff-gutter'; + +const CustomStyles: React.FC = ({ children }) => { + const { euiTheme } = useEuiTheme(); + + const customCss = css` + .${TABLE_CLASS_NAME} .diff-gutter-col { + width: ${euiTheme.size.xl}; + } + + .${CODE_CLASS_NAME}.diff-code, .${GUTTER_CLASS_NAME}.diff-gutter { + background: transparent; + } + + .${GUTTER_CLASS_NAME}:nth-child(3) { + border-left: 1px solid ${euiTheme.colors.mediumShade}; + } + + .${GUTTER_CLASS_NAME}.diff-gutter-delete { + color: ${euiTheme.colors.dangerText}; + font-weight: bold; + } + + .${GUTTER_CLASS_NAME}.diff-gutter-insert { + color: ${euiTheme.colors.successText}; + font-weight: bold; + } + + .${CODE_CLASS_NAME}.diff-code { + padding: 0 ${euiTheme.size.l} 0 ${euiTheme.size.m}; + } + + .${CODE_CLASS_NAME}.diff-code-delete .diff-code-edit, + .${CODE_CLASS_NAME}.diff-code-insert .diff-code-edit { + background: transparent; + } + + .${CODE_CLASS_NAME}.diff-code-delete .diff-code-edit { + color: ${euiTheme.colors.dangerText}; + text-decoration: line-through; + } + + .${CODE_CLASS_NAME}.diff-code-insert .diff-code-edit { + color: ${euiTheme.colors.successText}; + text-decoration: underline; + } + `; + + return ( + <> + + {children} + + ); +}; + +interface DiffViewProps extends Partial { + oldSource: string; + newSource: string; + diffMethod?: DiffMethod; +} + +export const DiffView = ({ + oldSource, + newSource, + diffMethod = DiffMethod.WORDS, +}: DiffViewProps) => { + /* + "react-diff-view" components consume diffs not as a strings, but as something they call "hunks". + So we first need to convert our "before" and "after" strings into these "hunk" objects. + "hunks" describe changed sections of code plus a few unchanged lines above and below for context. + */ + + /* + "diffFile" is essentially an object containing an array of hunks plus some metadata. + */ + const diffFile = useMemo(() => convertToDiffFile(oldSource, newSource), [oldSource, newSource]); + + /* + Sections of diff without changes are hidden by default, because they are not present in the "hunks" array. + "useExpand" allows to show these hidden sections when a user clicks on "Expand hidden lines" button. + */ + const { expandRange, hunks } = useExpand(diffFile.hunks, oldSource); + + /* + Go over each hunk and extract tokens from it. For example, split strings into words or characters, + so we can highlight them later. + */ + const tokens = useTokens(hunks, diffMethod, oldSource); + + return ( + + + {/* eslint-disable-next-line @typescript-eslint/no-shadow */} + {(hunks) => } + + + ); +}; 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 new file mode 100644 index 0000000000000..a1bada553bab7 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/json_diff/hunks.tsx @@ -0,0 +1,124 @@ +/* + * 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 type { ReactElement } from 'react'; +import { Hunk, Decoration, getCollapsedLinesCountBetween } from 'react-diff-view'; +import type { HunkData, DecorationProps } from 'react-diff-view'; +import { EuiSpacer, EuiIcon, EuiLink, EuiFlexGroup, EuiText } from '@elastic/eui'; +import * as i18n from './translations'; + +interface UnfoldButtonProps extends Omit { + start: number; + end: number; + onExpand: (start: number, end: number) => void; +} + +const UnfoldButton = ({ start, end, onExpand, ...props }: UnfoldButtonProps) => { + const expand = useCallback(() => onExpand(start, end), [onExpand, start, end]); + + const linesCount = end - start; + + return ( + + + {start > 1 && } + + + + + {i18n.EXPAND_UNCHANGED_LINES(linesCount)} + + + + + + + ); +}; + +interface UnfoldCollapsedProps { + previousHunk: HunkData; + currentHunk?: HunkData; + linesCount: number; + onExpand: (start: number, end: number) => void; +} + +const UnfoldCollapsed = ({ + previousHunk, + currentHunk, + linesCount, + onExpand, +}: UnfoldCollapsedProps) => { + if (!currentHunk) { + const nextStart = previousHunk.oldStart + previousHunk.oldLines; + const collapsedLines = linesCount - nextStart + 1; + + if (collapsedLines <= 0) { + return null; + } + + return ; + } + + const collapsedLines = getCollapsedLinesCountBetween(previousHunk, currentHunk); + + if (!previousHunk) { + if (!collapsedLines) { + return null; + } + + return ; + } + + const collapsedStart = previousHunk.oldStart + previousHunk.oldLines; + const collapsedEnd = currentHunk.oldStart; + + return ; +}; + +interface HunksProps { + hunks: HunkData[]; + oldSource: string; + expandRange: (start: number, end: number) => void; +} + +export const Hunks = ({ hunks, oldSource, expandRange }: HunksProps) => { + const linesCount = oldSource.split('\n').length; + + const hunkElements = hunks.reduce((children: ReactElement[], hunk: HunkData, index: number) => { + const previousElement = children[children.length - 1]; + + children.push( + + ); + + children.push(); + + const isLastHunk = index === hunks.length - 1; + if (isLastHunk && oldSource) { + children.push( + + ); + } + + return children; + }, []); + + return <>{hunkElements}; +}; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/json_diff/mark_edits.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/json_diff/mark_edits.tsx new file mode 100644 index 0000000000000..95eaefdc37f15 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/json_diff/mark_edits.tsx @@ -0,0 +1,290 @@ +/* + * 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 { findIndex, flatMap, flatten } from 'lodash'; +import * as diff from 'diff'; +import type { Change as DiffJsChange } from 'diff'; +import { isDelete, isInsert, isNormal, pickRanges } from 'react-diff-view'; +import type { ChangeData, HunkData, RangeTokenNode, TokenizeEnhancer } from 'react-diff-view'; + +enum DmpChangeType { + DELETE = -1, + EQUAL = 0, + INSERT = 1, +} + +type Diff = [DmpChangeType, string]; + +type StringDiffFn = (oldString: string, newString: string) => DiffJsChange[]; + +interface JsDiff { + diffChars: StringDiffFn; + diffWords: StringDiffFn; + diffWordsWithSpace: StringDiffFn; + diffLines: StringDiffFn; + diffTrimmedLines: StringDiffFn; + diffSentences: StringDiffFn; + diffCss: StringDiffFn; +} + +const jsDiff: JsDiff = diff; + +export enum DiffMethod { + CHARS = 'diffChars', + WORDS = 'diffWords', + WORDS_WITH_SPACE = 'diffWordsWithSpace', + LINES = 'diffLines', + TRIMMED_LINES = 'diffTrimmedLines', + SENTENCES = 'diffSentences', + CSS = 'diffCss', +} + +/** + * @param {ChangeData[]} changes - An array representing the changes in the block. + * Each hunk represents a section of a string and includes information about the changes in that section. + * Sections normally span multiple lines. + * @param {DiffMethod} diffMethod - Diffing algorithm to use for token extraction. For example, "diffWords" will tokenize the string into words. + * + * @returns {TokenizeEnhancer} A react-diff-view plugin that processes diff hunks and returns an array of tokens. + * Tokens are then used to render "added" / "removed" diff highlighting. + * + * @description + * Converts the given ChangeData array to two strings representing the old source and new source of a change block. + * The format of the strings is as follows: + */ +function findChangeBlocks(changes: ChangeData[]): ChangeData[][] { + const start = findIndex(changes, (change) => !isNormal(change)); + + if (start === -1) { + return []; + } + + const end = findIndex(changes, (change) => !!isNormal(change), start); + + if (end === -1) { + return [changes.slice(start)]; + } + + return [changes.slice(start, end), ...findChangeBlocks(changes.slice(end))]; +} + +function groupDiffs(diffs: Diff[]): [Diff[], Diff[]] { + return diffs.reduce<[Diff[], Diff[]]>( + // eslint-disable-next-line @typescript-eslint/no-shadow + ([oldDiffs, newDiffs], diff) => { + const [type] = diff; + + switch (type) { + case DmpChangeType.INSERT: + newDiffs.push(diff); + break; + case DmpChangeType.DELETE: + oldDiffs.push(diff); + break; + default: + oldDiffs.push(diff); + newDiffs.push(diff); + break; + } + + return [oldDiffs, newDiffs]; + }, + [[], []] + ); +} + +/** + * @param {Diff[]} diffs An array of changes in the diff-match-patch format + * @returns {Diff[][]} An array of arrays, where changes are grouped by a line number. + */ +function splitDiffToLines(diffs: Diff[]): Diff[][] { + return diffs.reduce( + (lines, [type, value]) => { + const currentLines = value.split('\n'); + + const [currentLineRemaining, ...nextLines] = currentLines.map( + (line: string): Diff => [type, line] + ); + const next: Diff[][] = [ + ...lines.slice(0, -1), + [...lines[lines.length - 1], currentLineRemaining], + ...nextLines.map((line) => [line]), + ]; + return next; + }, + [[]] + ); +} + +/** + * @param {Diff[]} diffs An array of changes within a single line in the diff-match-patch format + * @param {number} lineNumber Line number where the changes are found + * @returns {RangeTokenNode[]} Array of "edit" objects where each item contains + * info about line number and start / end character positions. + */ +function diffsToEdits(diffs: Diff[], lineNumber: number): RangeTokenNode[] { + const output = diffs.reduce<[RangeTokenNode[], number]>( + // eslint-disable-next-line @typescript-eslint/no-shadow + (output, diff) => { + const [edits, start] = output; + const [type, value] = diff; + if (type !== DmpChangeType.EQUAL) { + const edit: RangeTokenNode = { + type: 'edit', + lineNumber, + start, + length: value.length, + }; + edits.push(edit); + } + + return [edits, start + value.length]; + }, + [[], 0] + ); + + return output[0]; +} + +/** + * @param {Diff[][]} linesOfDiffs - Changes in a diff-match-patch format, grouped by a line number. + * @param {number} startLineNumber - Line number of the first line. + * @returns {RangeTokenNode[]} Flattened array of "edit" objects where each item contains + * info about line number and start / end character positions. + */ +function convertToLinesOfEdits(linesOfDiffs: Diff[][], startLineNumber: number): RangeTokenNode[] { + return flatMap(linesOfDiffs, (diffs, i) => diffsToEdits(diffs, startLineNumber + i)); +} + +/** + * @param {DiffMethod} diffMethod - Diffing algorithm to use for token extraction. + * @param {string} oldSource - A substring of the original source string. + * @param {string} newSource - A corresponding substring of the new source string. + * @returns {[Diff[], Diff[]]} Two arrays of changes in the diff-match-patch format. + * Every item is a tuple of two values: [, ]. + * + * @description Runs two strings through the chosen diffing algorithm using the "diff" library to determine + * which parts of the original string were added / removed / unchanged. Then returns an array of changes in + * the diff-match-patch diff format. + */ +function diffBy(diffMethod: DiffMethod, oldSource: string, newSource: string): [Diff[], Diff[]] { + /* Diff two substrings using the "diff" library */ + const jsDiffChanges: DiffJsChange[] = jsDiff[diffMethod](oldSource, newSource); + /* Convert the result to the diff-match-patch format, because that's the format react-diff-view methods expect */ + const diffs: Diff[] = diff.convertChangesToDMP(jsDiffChanges); + + if (diffs.length <= 1) { + return [[], []]; + } + + /* Split diff-match-patch formatted diffs into two arrays: one for the old source and one for the new source */ + return groupDiffs(diffs); +} + +const getLineNumber = (change: ChangeData | undefined) => { + if (!change || isNormal(change)) { + return undefined; + } + + return change.lineNumber; +}; + +/** + * @param {ChangeData[]} changes - An array of сhange objects. Each change object represents changes in a single line. + * @param {DiffMethod} diffMethod - Diffing algorithm to use for token extraction. + * @returns {[RangeTokenNode[], RangeTokenNode[]]} A tuple containing two arrays of RangeTokenNodes - one for + * the old source and another one for the new source. Each RangeTokenNode contains information about line numbers + * and character positions of changes. + * + * @description This function processes change objects and determines exactly which segments of the orginal string changed. + * It diffs old and new substrings and computes at which character position each change starts and ends, + * taking the diffing algorithm into account (by char, by word, by sentence, etc.) + */ +function diffChangeBlock( + changes: ChangeData[], + diffMethod: DiffMethod +): [RangeTokenNode[], RangeTokenNode[]] { + /* + Convert an array of change objects into two strings representing the old source and the new source of a change block. + Basically, recreate parts of the original strings from change objects so we can pass these strings to the text diffing library. + */ + const [oldSourceSnippet, newSourceSnippet] = changes.reduce( + // eslint-disable-next-line @typescript-eslint/no-shadow + ([oldSourceSnippet, newSourceSnippet], change) => + isDelete(change) + ? [oldSourceSnippet + (oldSourceSnippet ? '\n' : '') + change.content, newSourceSnippet] + : [oldSourceSnippet, newSourceSnippet + (newSourceSnippet ? '\n' : '') + change.content], + ['', ''] + ); + + /* + * Run the chosen diffing algorithm with an "old" and a "new" substrings as input. + * The result is an array of changes in the diff-match-patch format. + */ + const [oldDiffs, newDiffs] = diffBy(diffMethod, oldSourceSnippet, newSourceSnippet); + + if (oldDiffs.length === 0 && newDiffs.length === 0) { + return [[], []]; + } + + const oldStartLineNumber = getLineNumber(changes.find(isDelete)); + const newStartLineNumber = getLineNumber(changes.find(isInsert)); + + if (oldStartLineNumber === undefined || newStartLineNumber === undefined) { + throw new Error('Could not find start line number for edit'); + } + + /* + * Group changes by a line number they are found in, then determine start / end character + * positions of each change. + */ + const oldEdits = convertToLinesOfEdits(splitDiffToLines(oldDiffs), oldStartLineNumber); + const newEdits = convertToLinesOfEdits(splitDiffToLines(newDiffs), newStartLineNumber); + + return [oldEdits, newEdits]; +} + +/** + * @param {HunkData[]} hunks - An array of hunk objects. + * Each hunk represents a section of a string and includes information about the changes in that section. + * Sections normally span multiple lines. + * @param {DiffMethod} diffMethod - Diffing algorithm to use for token extraction. For example, "diffWords" will tokenize the string into words. + * + * @returns {TokenizeEnhancer} A react-diff-view plugin that processes diff hunks and returns an array of tokens. + * Tokens are then used to render "added" / "removed" diff highlighting. + * + * @description + * Converts the given ChangeData array to two strings representing the old source and new source of a change block. + * The format of the strings is as follows: + */ +export function markEdits(hunks: HunkData[], diffMethod: DiffMethod): TokenizeEnhancer { + /* + changeBlocks is an array that contains information about the lines that have changes (additions or deletions). + Unchanged lines are not included. + */ + const changeBlocks = flatMap( + hunks.map((hunk) => hunk.changes), + findChangeBlocks + ); + + const [oldEdits, newEdits] = changeBlocks + /* + diffChangeBlock diffs two substrings and determines character positions of changes, + taking the diffing algorithm into account (by char, by word, by sentence, etc.) + */ + .map((changes) => diffChangeBlock(changes, diffMethod)) + .reduce( + // eslint-disable-next-line @typescript-eslint/no-shadow + ([oldEdits, newEdits], [currentOld, currentNew]) => [ + oldEdits.concat(currentOld), + newEdits.concat(currentNew), + ], + [[], []] + ); + + return pickRanges(flatten(oldEdits), flatten(newEdits)); +} diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/json_diff/translations.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/json_diff/translations.ts new file mode 100644 index 0000000000000..8720a6b57f510 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/json_diff/translations.ts @@ -0,0 +1,46 @@ +/* + * 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'; + +export const EXPAND_UNCHANGED_LINES = (linesCount: number) => + i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.upgradeRules.expandHiddenDiffLinesLabel', + { + values: { linesCount }, + defaultMessage: + 'Expand {linesCount} unchanged {linesCount, plural, one {line} other {lines}}', + } + ); + +export const BASE_VERSION = i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.upgradeRules.baseVersionLabel', + { + defaultMessage: 'Base version', + } +); + +export const BASE_VERSION_DESCRIPTION = i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.upgradeRules.baseVersionDescriptionLabel', + { + defaultMessage: 'Shows currently installed rule', + } +); + +export const UPDATED_VERSION = i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.upgradeRules.updatedVersionLabel', + { + defaultMessage: 'Update', + } +); + +export const UPDATED_VERSION_DESCRIPTION = i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.upgradeRules.updatedVersionDescriptionLabel', + { + defaultMessage: 'Shows rule that will be installed', + } +); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/json_diff/unidiff.d.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/json_diff/unidiff.d.ts new file mode 100644 index 0000000000000..d7fa438700e97 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/json_diff/unidiff.d.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. + */ + +declare module 'unidiff' { + interface Change { + count?: number | undefined; + value: string; + added?: boolean | undefined; + removed?: boolean | undefined; + } + + export interface FormatOptions { + context?: number; + } + + export function diffLines(x: string, y: string): Change[]; + + export function formatLines(line: Change[], options?: FormatOptions): string; +} diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/rule_about_section.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/rule_about_section.tsx index 282d3fcc8439a..125a018a1304a 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/rule_about_section.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/rule_about_section.tsx @@ -33,7 +33,7 @@ import { filterEmptyThreats } from '../../../rule_creation_ui/pages/rule_creatio import { ThreatEuiFlexGroup } from '../../../../detections/components/rules/description_step/threat_description'; import { BadgeList } from './badge_list'; -import { DESCRIPTION_LIST_COLUMN_WIDTHS } from './constants'; +import { DEFAULT_DESCRIPTION_LIST_COLUMN_WIDTHS } from './constants'; import * as i18n from './translations'; const OverrideColumn = styled(EuiFlexItem)` @@ -426,12 +426,14 @@ const prepareAboutSectionListItems = ( export interface RuleAboutSectionProps extends React.ComponentProps { rule: Partial; + columnWidths?: EuiDescriptionListProps['columnWidths']; hideName?: boolean; hideDescription?: boolean; } export const RuleAboutSection = ({ rule, + columnWidths = DEFAULT_DESCRIPTION_LIST_COLUMN_WIDTHS, hideName, hideDescription, ...descriptionListProps @@ -445,7 +447,7 @@ export const RuleAboutSection = ({ type={descriptionListProps.type ?? 'column'} rowGutterSize={descriptionListProps.rowGutterSize ?? 'm'} listItems={aboutSectionListItems} - columnWidths={DESCRIPTION_LIST_COLUMN_WIDTHS} + columnWidths={columnWidths} data-test-subj="listItemColumnStepRuleDescription" {...descriptionListProps} /> diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/rule_definition_section.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/rule_definition_section.tsx index 0d284ed4eea07..c2c85fca93f03 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/rule_definition_section.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/rule_definition_section.tsx @@ -52,7 +52,7 @@ import { useSecurityJobs } from '../../../../common/components/ml_popover/hooks/ import { useKibana } from '../../../../common/lib/kibana/kibana_react'; import { TechnicalPreviewBadge } from '../../../../detections/components/rules/technical_preview_badge'; import { BadgeList } from './badge_list'; -import { DESCRIPTION_LIST_COLUMN_WIDTHS } from './constants'; +import { DEFAULT_DESCRIPTION_LIST_COLUMN_WIDTHS } from './constants'; import * as i18n from './translations'; import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features'; import type { ExperimentalFeatures } from '../../../../../common/experimental_features'; @@ -724,6 +724,7 @@ const prepareDefinitionSectionListItems = ( export interface RuleDefinitionSectionProps extends React.ComponentProps { rule: Partial; + columnWidths?: EuiDescriptionListProps['columnWidths']; isInteractive?: boolean; dataTestSubj?: string; } @@ -731,6 +732,7 @@ export interface RuleDefinitionSectionProps export const RuleDefinitionSection = ({ rule, isInteractive = false, + columnWidths = DEFAULT_DESCRIPTION_LIST_COLUMN_WIDTHS, dataTestSubj, ...descriptionListProps }: RuleDefinitionSectionProps) => { @@ -756,7 +758,7 @@ export const RuleDefinitionSection = ({ type={descriptionListProps.type ?? 'column'} rowGutterSize={descriptionListProps.rowGutterSize ?? 'm'} listItems={definitionSectionListItems} - columnWidths={DESCRIPTION_LIST_COLUMN_WIDTHS} + columnWidths={columnWidths} data-test-subj="listItemColumnStepRuleDescription" {...descriptionListProps} /> diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/rule_details_flyout.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/rule_details_flyout.tsx index aa221b6cdb147..c2d00c819253a 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/rule_details_flyout.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/rule_details_flyout.tsx @@ -21,11 +21,15 @@ import { EuiFlexGroup, EuiFlexItem, } from '@elastic/eui'; -import type { EuiTabbedContentTab, EuiTabbedContentProps } from '@elastic/eui'; +import type { EuiTabbedContentTab, EuiTabbedContentProps, EuiFlyoutProps } from '@elastic/eui'; import type { RuleResponse } from '../../../../../common/api/detection_engine/model/rule_schema'; import { RuleOverviewTab, useOverviewTabSections } from './rule_overview_tab'; import { RuleInvestigationGuideTab } from './rule_investigation_guide_tab'; +import { + DEFAULT_DESCRIPTION_LIST_COLUMN_WIDTHS, + LARGE_DESCRIPTION_LIST_COLUMN_WIDTHS, +} from './constants'; import * as i18n from './translations'; @@ -95,13 +99,15 @@ const tabPaddingClassName = css` padding: 0 ${euiThemeVars.euiSizeM} ${euiThemeVars.euiSizeXL} ${euiThemeVars.euiSizeM}; `; -const TabContentPadding: React.FC = ({ children }) => ( +export const TabContentPadding: React.FC = ({ children }) => (
{children}
); interface RuleDetailsFlyoutProps { rule: RuleResponse; ruleActions?: React.ReactNode; + size?: EuiFlyoutProps['size']; + extraTabs?: EuiTabbedContentTab[]; dataTestSubj?: string; closeFlyout: () => void; } @@ -109,6 +115,8 @@ interface RuleDetailsFlyoutProps { export const RuleDetailsFlyout = ({ rule, ruleActions, + size = 'm', + extraTabs = [], dataTestSubj, closeFlyout, }: RuleDetailsFlyoutProps) => { @@ -122,13 +130,18 @@ export const RuleDetailsFlyout = ({ ), }), - [rule, expandedOverviewSections, toggleOverviewSection] + [rule, size, expandedOverviewSections, toggleOverviewSection] ); const investigationGuideTab: EuiTabbedContentTab = useMemo( @@ -146,11 +159,11 @@ export const RuleDetailsFlyout = ({ const tabs = useMemo(() => { if (rule.note) { - return [overviewTab, investigationGuideTab]; + return [...extraTabs, overviewTab, investigationGuideTab]; } else { - return [overviewTab]; + return [...extraTabs, overviewTab]; } - }, [overviewTab, investigationGuideTab, rule.note]); + }, [overviewTab, investigationGuideTab, rule.note, extraTabs]); const [selectedTabId, setSelectedTabId] = useState(tabs[0].id); const selectedTab = tabs.find((tab) => tab.id === selectedTabId) ?? tabs[0]; @@ -168,7 +181,7 @@ export const RuleDetailsFlyout = ({ return ( ): string => + stringify(jsObject, { space: 2 }); + +interface RuleDiffTabProps { + oldRule: RuleResponse; + newRule: RuleResponse; +} + +export const RuleDiffTab = ({ oldRule, newRule }: RuleDiffTabProps) => { + const [oldSource, newSource] = useMemo(() => { + const visibleOldRuleProperties = omit(oldRule, 'revision'); + const visibleNewRuleProperties = omit(newRule, 'revision'); + + return [ + sortAndStringifyJson(visibleOldRuleProperties), + sortAndStringifyJson(visibleNewRuleProperties), + ]; + }, [oldRule, newRule]); + + return ( + <> + + + + + + +
{i18n.BASE_VERSION}
+
+
+ + + +
{i18n.UPDATED_VERSION}
+
+
+
+ + +
+ + ); +}; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/rule_overview_tab.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/rule_overview_tab.tsx index 9f8be8c180f94..7fd3cc270286c 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/rule_overview_tab.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/rule_overview_tab.tsx @@ -14,11 +14,13 @@ import { EuiHorizontalRule, useGeneratedHtmlId, } from '@elastic/eui'; +import type { EuiDescriptionListProps } from '@elastic/eui'; import type { RuleResponse } from '../../../../../common/api/detection_engine/model/rule_schema'; import { RuleAboutSection, Description } from './rule_about_section'; import { RuleDefinitionSection } from './rule_definition_section'; import { RuleScheduleSection } from './rule_schedule_section'; import { RuleSetupGuideSection } from './rule_setup_guide_section'; +import { DEFAULT_DESCRIPTION_LIST_COLUMN_WIDTHS } from './constants'; import * as i18n from './translations'; @@ -87,52 +89,56 @@ const ExpandableSection = ({ title, isOpen, toggle, children }: ExpandableSectio interface RuleOverviewTabProps { rule: RuleResponse; + columnWidths?: EuiDescriptionListProps['columnWidths']; expandedOverviewSections: Record; toggleOverviewSection: Record void>; } export const RuleOverviewTab = ({ rule, + columnWidths = DEFAULT_DESCRIPTION_LIST_COLUMN_WIDTHS, expandedOverviewSections, toggleOverviewSection, -}: RuleOverviewTabProps) => ( - <> - - - {rule.description && } - - - - - - - - - - - {rule.setup && ( - <> - - - - - - )} - -); +}: RuleOverviewTabProps) => { + return ( + <> + + + {rule.description && } + + + + + + + + + + + {rule.setup && ( + <> + + + + + + )} + + ); +}; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/rule_schedule_section.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/rule_schedule_section.tsx index 938499ff4f31b..0d77961a5206d 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/rule_schedule_section.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/components/rule_details/rule_schedule_section.tsx @@ -7,9 +7,10 @@ import React from 'react'; import { EuiDescriptionList, EuiText } from '@elastic/eui'; +import type { EuiDescriptionListProps } from '@elastic/eui'; import type { RuleResponse } from '../../../../../common/api/detection_engine/model/rule_schema'; import { getHumanizedDuration } from '../../../../detections/pages/detection_engine/rules/helpers'; -import { DESCRIPTION_LIST_COLUMN_WIDTHS } from './constants'; +import { DEFAULT_DESCRIPTION_LIST_COLUMN_WIDTHS } from './constants'; import * as i18n from './translations'; interface IntervalProps { @@ -35,10 +36,12 @@ const From = ({ from, interval }: FromProps) => ( export interface RuleScheduleSectionProps extends React.ComponentProps { rule: Partial; + columnWidths?: EuiDescriptionListProps['columnWidths']; } export const RuleScheduleSection = ({ rule, + columnWidths = DEFAULT_DESCRIPTION_LIST_COLUMN_WIDTHS, ...descriptionListProps }: RuleScheduleSectionProps) => { if (!rule.interval || !rule.from) { @@ -64,7 +67,7 @@ export const RuleScheduleSection = ({ type={descriptionListProps.type ?? 'column'} rowGutterSize={descriptionListProps.rowGutterSize ?? 'm'} listItems={ruleSectionListItems} - columnWidths={DESCRIPTION_LIST_COLUMN_WIDTHS} + columnWidths={columnWidths} {...descriptionListProps} /> 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 1d159bf24a392..dffdf89d31feb 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 @@ -21,6 +21,13 @@ export const INVESTIGATION_GUIDE_TAB_LABEL = i18n.translate( } ); +export const UPDATES_TAB_LABEL = i18n.translate( + 'xpack.securitySolution.detectionEngine.ruleDetails.updatesTabLabel', + { + defaultMessage: 'Updates', + } +); + 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_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 290f85ade3a03..4931943c3c114 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 @@ -8,6 +8,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 { 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'; @@ -24,10 +25,15 @@ import type { UpgradePrebuiltRulesTableFilterOptions } from './use_filter_prebui 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 } from '../../../../rule_management/components/rule_details/rule_details_flyout'; -import * as i18n from './translations'; - +import { + RuleDetailsFlyout, + 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 { useIsExperimentalFeatureEnabled } from '../../../../../common/hooks/use_experimental_features'; +import * as ruleDetailsI18n from '../../../../rule_management/components/rule_details/translations'; +import * as i18n from './translations'; export interface UpgradePrebuiltRulesTableState { /** @@ -111,6 +117,10 @@ export const UpgradePrebuiltRulesTableContextProvider = ({ tags: [], }); + const isJsonPrebuiltRulesDiffingEnabled = useIsExperimentalFeatureEnabled( + 'jsonPrebuiltRulesDiffingEnabled' + ); + const isUpgradingSecurityPackages = useIsUpgradingSecurityPackages(); const { @@ -257,6 +267,29 @@ export const UpgradePrebuiltRulesTableContextProvider = ({ actions, ]); + const extraTabs = useMemo(() => { + const activeRule = + isJsonPrebuiltRulesDiffingEnabled && + previewedRule && + filteredRules.find(({ id }) => id === previewedRule.id); + + if (!activeRule) { + return []; + } + + return [ + { + id: 'updates', + name: ruleDetailsI18n.UPDATES_TAB_LABEL, + content: ( + + + + ), + }, + ]; + }, [previewedRule, filteredRules, isJsonPrebuiltRulesDiffingEnabled]); + return ( <> @@ -271,6 +304,7 @@ export const UpgradePrebuiltRulesTableContextProvider = ({ {previewedRule && ( } + extraTabs={extraTabs} /> )} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/review_rule_upgrade/review_rule_upgrade_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/review_rule_upgrade/review_rule_upgrade_route.ts index b0d53f2c43a9d..1fcb36b592d56 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/review_rule_upgrade/review_rule_upgrade_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/prebuilt_rules/api/review_rule_upgrade/review_rule_upgrade_route.ts @@ -100,6 +100,11 @@ const calculateRuleInfos = (results: CalculateRuleDiffResult[]): RuleUpgradeInfo const targetRule: RuleResponse = { ...convertPrebuiltRuleAssetToRuleResponse(targetVersion), id: installedCurrentVersion.id, + revision: installedCurrentVersion.revision + 1, + created_at: installedCurrentVersion.created_at, + created_by: installedCurrentVersion.created_by, + updated_at: new Date().toISOString(), + updated_by: installedCurrentVersion.updated_by, }; return { diff --git a/yarn.lock b/yarn.lock index 8810a2af01b82..7b4a82fda6bbe 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9021,6 +9021,11 @@ resolved "https://registry.yarnpkg.com/@types/delete-empty/-/delete-empty-2.0.0.tgz#1647ae9e68f708a6ba778531af667ec55bc61964" integrity sha512-sq+kwx8zA9BSugT9N+Jr8/uWjbHMZ+N/meJEzRyT3gmLq/WMtx/iSIpvdpmBUi/cvXl6Kzpvve8G2ESkabFwmg== +"@types/diff@^5.0.8": + version "5.0.8" + resolved "https://registry.yarnpkg.com/@types/diff/-/diff-5.0.8.tgz#28dc501cc3e7c62d4c5d096afe20755170acf276" + integrity sha512-kR0gRf0wMwpxQq6ME5s+tWk9zVCfJUl98eRkD05HWWRbhPB/eu4V1IbyZAsvzC1Gn4znBJ0HN01M4DGXdBEV8Q== + "@types/ejs@^3.0.6": version "3.0.6" resolved "https://registry.yarnpkg.com/@types/ejs/-/ejs-3.0.6.tgz#aca442289df623bfa8e47c23961f0357847b83fe" @@ -14949,7 +14954,7 @@ diacritics@^1.3.0: resolved "https://registry.yarnpkg.com/diacritics/-/diacritics-1.3.0.tgz#3efa87323ebb863e6696cebb0082d48ff3d6f7a1" integrity sha1-PvqHMj67hj5mls67AILUj/PW96E= -diff-match-patch@^1.0.0, diff-match-patch@^1.0.4: +diff-match-patch@^1.0.0, diff-match-patch@^1.0.4, diff-match-patch@^1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/diff-match-patch/-/diff-match-patch-1.0.5.tgz#abb584d5f10cd1196dfc55aa03701592ae3f7b37" integrity sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw== @@ -14969,7 +14974,7 @@ diff-sequences@^29.4.3: resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-29.4.3.tgz#9314bc1fabe09267ffeca9cbafc457d8499a13f2" integrity sha512-ofrBgwpPhCD85kMKtE9RYFFq6OC1A89oW2vvgWZNCwxrUpRUILopY7lsYyMDSjc8g6U6aiO0Qubg6r4Wgt5ZnA== -diff@5.0.0, diff@^5.0.0: +diff@5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/diff/-/diff-5.0.0.tgz#7ed6ad76d859d030787ec35855f5b1daf31d852b" integrity sha512-/VTCrvm5Z0JGty/BWHljh+BAiw3IK+2j87NGMu8Nwc/f48WoDAC395uomO9ZD117ZOBaHmkX1oyLvkVM/aIT3w== @@ -14989,6 +14994,11 @@ diff@^4.0.1: resolved "https://registry.yarnpkg.com/diff/-/diff-4.0.2.tgz#60f3aecb89d5fae520c11aa19efc2bb982aade7d" integrity sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A== +diff@^5.0.0, diff@^5.1.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/diff/-/diff-5.1.0.tgz#bc52d298c5ea8df9194800224445ed43ffc87e40" + integrity sha512-D+mk+qE8VC/PAUrlAU34N+VfXev0ghe5ywmpqrawphmVZc1bEfn56uo9qpyGp1p4xpzOHkSW4ztBd6L7Xx4ACw== + diffie-hellman@^5.0.0: version "5.0.2" resolved "https://registry.yarnpkg.com/diffie-hellman/-/diffie-hellman-5.0.2.tgz#b5835739270cfe26acf632099fded2a07f209e5e" @@ -17546,6 +17556,11 @@ git-hooks-list@1.0.3: resolved "https://registry.yarnpkg.com/git-hooks-list/-/git-hooks-list-1.0.3.tgz#be5baaf78203ce342f2f844a9d2b03dba1b45156" integrity sha512-Y7wLWcrLUXwk2noSka166byGCvhMtDRpgHdzCno1UQv/n/Hegp++a2xBWJL1lJarnKD3SWaljD+0z1ztqxuKyQ== +gitdiff-parser@^0.3.1: + version "0.3.1" + resolved "https://registry.yarnpkg.com/gitdiff-parser/-/gitdiff-parser-0.3.1.tgz#5eb3e66eb7862810ba962fab762134071601baa5" + integrity sha512-YQJnY8aew65id8okGxKCksH3efDCJ9HzV7M9rsvd65habf39Pkh4cgYJ27AaoDMqo1X98pgNJhNMrm/kpV7UVQ== + github-from-package@0.0.0: version "0.0.0" resolved "https://registry.yarnpkg.com/github-from-package/-/github-from-package-0.0.0.tgz#97fb5d96bfde8973313f20e8288ef9a167fa64ce" @@ -25360,6 +25375,18 @@ react-colorful@^5.1.2: resolved "https://registry.yarnpkg.com/react-colorful/-/react-colorful-5.5.1.tgz#29d9c4e496f2ca784dd2bb5053a3a4340cfaf784" integrity sha512-M1TJH2X3RXEt12sWkpa6hLc/bbYS0H6F4rIqjQZ+RxNBstpY67d9TrFXtqdZwhpmBXcCwEi7stKqFue3ZRkiOg== +react-diff-view@^3.2.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/react-diff-view/-/react-diff-view-3.2.0.tgz#8fbf04782d78423903a59202ce7533f6312c1cc3" + integrity sha512-p58XoqMxgt71ujpiDQTs9Za3nqTawt1E4bTzKsYSqr8I8br6cjQj1b66HxGnV8Yrc6MD6iQPqS1aZiFoGqEw+g== + dependencies: + classnames "^2.3.2" + diff-match-patch "^1.0.5" + gitdiff-parser "^0.3.1" + lodash "^4.17.21" + shallow-equal "^3.1.0" + warning "^4.0.3" + react-docgen-typescript@^2.0.0, react-docgen-typescript@^2.1.1: version "2.2.2" resolved "https://registry.yarnpkg.com/react-docgen-typescript/-/react-docgen-typescript-2.2.2.tgz#4611055e569edc071204aadb20e1c93e1ab1659c" @@ -27226,6 +27253,11 @@ shallow-copy@~0.0.1: resolved "https://registry.yarnpkg.com/shallow-copy/-/shallow-copy-0.0.1.tgz#415f42702d73d810330292cc5ee86eae1a11a170" integrity sha1-QV9CcC1z2BAzApLMXuhurhoRoXA= +shallow-equal@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/shallow-equal/-/shallow-equal-3.1.0.tgz#e7a54bac629c7f248eff6c2f5b63122ba4320bec" + integrity sha512-pfVOw8QZIXpMbhBWvzBISicvToTiM5WBF1EeAUZDDSb5Dt29yl4AYbyywbJFSEsRUMr7gJaxqCdr4L3tQf9wVg== + shallowequal@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/shallowequal/-/shallowequal-1.1.0.tgz#188d521de95b9087404fd4dcb68b13df0ae4e7f8" @@ -29525,6 +29557,13 @@ unicode-trie@^2.0.0: pako "^0.2.5" tiny-inflate "^1.0.0" +unidiff@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/unidiff/-/unidiff-1.0.4.tgz#45096a898285821c51e22e84be4215c05d6511cd" + integrity sha512-ynU0vsAXw0ir8roa+xPCUHmnJ5goc5BTM2Kuc3IJd8UwgaeRs7VSD5+eeaQL+xp1JtB92hu/Zy/Lgy7RZcr1pQ== + dependencies: + diff "^5.1.0" + unified@9.2.0: version "9.2.0" resolved "https://registry.yarnpkg.com/unified/-/unified-9.2.0.tgz#67a62c627c40589edebbf60f53edfd4d822027f8" @@ -30534,7 +30573,7 @@ walker@^1.0.7, walker@^1.0.8, walker@~1.0.5: dependencies: makeerror "1.0.12" -warning@^4.0.2: +warning@^4.0.2, warning@^4.0.3: version "4.0.3" resolved "https://registry.yarnpkg.com/warning/-/warning-4.0.3.tgz#16e9e077eb8a86d6af7d64aa1e05fd85b4678ca3" integrity sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==