Skip to content

Commit

Permalink
[Security Solution] JSON diff view for prebuilt rule upgrade flow (#1…
Browse files Browse the repository at this point in the history
…72535)

## Summary

**Resolves: #169160
**Resolves: #166164
**Docs issue: elastic/security-docs#4371

This PR adds a new "Updates" tab to the prebuilt rules upgrade flyout.
This tab shows a diff between the installed and updated rule JSON
representations.

<img width="1313" alt="Scherm­afbeelding 2023-12-05 om 02 48 37"
src="https://github.com/elastic/kibana/assets/15949146/ec0f95c6-22c6-4ce6-a6cc-0ceee974c6f7">



### Checklist

Delete any items that are not applicable to this PR.

- [x] Any text added follows [EUI's writing
guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses
sentence case text and includes [i18n
support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md)
- [x] Functional changes are communicated to the Docs team. A ticket or
PR is opened in https://github.com/elastic/security-docs. The following
information is included: any feature flags used, affected environments
(Serverless, ESS, or both). ([Docs
issue](elastic/security-docs#4371))
- [ ]
[Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html)
was added for features that require explanation or tutorials ([Docs
issue](elastic/security-docs#4371))
- [ ] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios (will be added
in a follow-up PR)
- [ ] Functional changes are covered with a test plan and automated
tests (will be added in a follow-up PR)
- [x] Any UI touched in this PR is usable by keyboard only (learn more
about [keyboard accessibility](https://webaim.org/techniques/keyboard/))
- [x] Any UI touched in this PR does not create any new axe failures
(run axe in browser:
[FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/),
[Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US))
- [x] This renders correctly on smaller devices using a responsive
layout. (Doesn't look great on phone screen, because viewing diff
requires a lot of horizontal space. Tablets are fine though.)
- [x] This was checked for [cross-browser
compatibility](https://www.elastic.co/support/matrix#matrix_browsers)
- [x] Functional changes are hidden behind a feature flag. If not
hidden, the PR explains why these changes are being implemented in a
long-living feature branch.
- [x] Comprehensive manual testing is done by two engineers: the PR
author and one of the PR reviewers. Changes are tested in both ESS and
Serverless.

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Georgii Gorbachev <georgii.gorbachev@elastic.co>
  • Loading branch information
3 people authored Dec 8, 2023
1 parent 893e5e1 commit e5a6b97
Show file tree
Hide file tree
Showing 19 changed files with 1,004 additions and 70 deletions.
5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -925,6 +925,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",
Expand Down Expand Up @@ -1032,6 +1033,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",
Expand Down Expand Up @@ -1092,6 +1094,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",
Expand Down Expand Up @@ -1348,6 +1351,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",
Expand Down Expand Up @@ -1511,7 +1515,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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"?›
Expand Down Expand Up @@ -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"›
Expand Down Expand Up @@ -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"?›
Expand Down Expand Up @@ -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"?›
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<keyof ExperimentalFeatures>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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%'];
Original file line number Diff line number Diff line change
@@ -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 <number> 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 <span>{'+'}</span>;
}

if (change.type === 'delete') {
return <span>{'-'}</span>;
}

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 (
<>
<Global styles={customCss} />
{children}
</>
);
};

interface DiffViewProps extends Partial<DiffProps> {
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 <number> 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 (
<CustomStyles>
<Diff
/*
"diffType": can be either 'add', 'delete', 'modify', 'rename' or 'copy'.
Passing 'add' or 'delete' would skip rendering one of the sides in split view.
*/
diffType={diffFile.type}
hunks={hunks}
renderGutter={renderGutter}
tokens={tokens}
className={TABLE_CLASS_NAME}
gutterClassName={GUTTER_CLASS_NAME}
codeClassName={CODE_CLASS_NAME}
>
{/* eslint-disable-next-line @typescript-eslint/no-shadow */}
{(hunks) => <Hunks hunks={hunks} oldSource={oldSource} expandRange={expandRange} />}
</Diff>
</CustomStyles>
);
};
Loading

0 comments on commit e5a6b97

Please sign in to comment.