diff --git a/cypress/integration/2_rules.spec.js b/cypress/integration/2_rules.spec.js index fd1ca9ac9..2942adaf9 100644 --- a/cypress/integration/2_rules.spec.js +++ b/cypress/integration/2_rules.spec.js @@ -182,6 +182,7 @@ describe('Rules', () => { cy.get('[data-test-subj="rule_author_field"]').type(`${SAMPLE_RULE.author}{enter}`); cy.get('[data-test-subj="detection-visual-editor-0"]').within(() => { + cy.getFieldByLabel('Name').type('{selectall}{backspace}').type('Selection_1'); cy.getFieldByLabel('Key').type('Provider_Name'); cy.getInputByPlaceholder('Value').type('Service Control Manager'); @@ -201,6 +202,11 @@ describe('Rules', () => { force: true, }); + cy.get('[aria-label="Add one more condition"]').click({ force: true }); + + // Enter the author + cy.get('[data-test-subj="rule_author_field"]').type(`${SAMPLE_RULE.author}`); + // Switch to YAML editor cy.get('[data-test-subj="change-editor-type"] label:nth-child(2)').click({ force: true, diff --git a/public/app.scss b/public/app.scss index 52b92d7d5..db4370d1d 100644 --- a/public/app.scss +++ b/public/app.scss @@ -20,6 +20,7 @@ $euiTextColor: $euiColorDarkestShade !default; @import "./pages/Findings/components/CorrelationsTable/CorrelationsTable.scss"; @import "./pages/Rules/components/RuleEditor/RuleEditorForm.scss"; @import "./pages/Rules/components/RuleEditor/DetectionVisualEditor.scss"; +@import "./pages/Rules/components/RuleEditor/components/SelectionExpField.scss"; .selected-radio-panel { background-color: tintOrShade($euiColorPrimary, 90%, 70%); diff --git a/public/pages/Rules/components/RuleEditor/DetectionVisualEditor.tsx b/public/pages/Rules/components/RuleEditor/DetectionVisualEditor.tsx index f3b21f420..b17982763 100644 --- a/public/pages/Rules/components/RuleEditor/DetectionVisualEditor.tsx +++ b/public/pages/Rules/components/RuleEditor/DetectionVisualEditor.tsx @@ -21,23 +21,25 @@ import { EuiRadioGroup, EuiTextArea, EuiButton, - EuiHorizontalRule, EuiModal, EuiModalHeader, EuiModalHeaderTitle, EuiModalBody, EuiModalFooter, EuiFilePicker, - EuiCodeEditor, EuiButtonEmpty, + EuiCallOut, } from '@elastic/eui'; import _ from 'lodash'; import { validateCondition, validateDetectionFieldName } from '../../../../utils/validation'; +import { SelectionExpField } from './components/SelectionExpField'; export interface DetectionVisualEditorProps { detectionYml: string; onChange: (value: string) => void; setIsDetectionInvalid: (isInvalid: boolean) => void; + mode?: string; + isInvalid?: boolean; } interface Errors { @@ -62,7 +64,7 @@ interface SelectionData { selectedRadioId?: string; } -interface Selection { +export interface Selection { name: string; data: SelectionData[]; } @@ -89,7 +91,7 @@ const defaultDetectionObj: DetectionObject = { condition: '', selections: [ { - name: '', + name: 'Selection_1', data: [ { field: '', @@ -128,10 +130,12 @@ export class DetectionVisualEditor extends React.Component< this.props.onChange(this.createDetectionYml()); } - if (Object.keys(this.state.errors.fields).length || !this.validateValuesExist()) { - this.props.setIsDetectionInvalid(true); - } else { - this.props.setIsDetectionInvalid(false); + const isValid = !!Object.keys(this.state.errors.fields).length || !this.validateValuesExist(); + this.props.setIsDetectionInvalid(isValid); + + if (this.props.isInvalid != prevProps.isInvalid) { + this.validateCondition(this.state.detectionObj.condition); + this.validateDatum(this.state.detectionObj.selections); } } @@ -189,7 +193,7 @@ export class DetectionVisualEditor extends React.Component< condition, }; - selections.forEach((selection, idx) => { + selections.forEach((selection) => { const selectionMaps: any = {}; selection.data.forEach((datum) => { @@ -203,83 +207,83 @@ export class DetectionVisualEditor extends React.Component< return dump(compiledDetection); }; - private updateDatumInState = ( - selectionIdx: number, - dataIdx: number, - newDatum: Partial - ) => { + private validateDatum = (selections: Selection[]) => { const { errors } = this.state; - const { condition, selections } = this.state.detectionObj; - const selection = selections[selectionIdx]; - const datum = selection.data[dataIdx]; - const newSelections = [ - ...selections.slice(0, selectionIdx), - { - ...selection, - data: [ - ...selection.data.slice(0, dataIdx), - { - ...datum, - ...newDatum, - }, - ...selection.data.slice(dataIdx + 1), - ], - }, - ...selections.slice(selectionIdx + 1), - ]; - - newSelections.map((selection, selIdx) => { + selections.map((selection, selIdx) => { const fieldNames = new Set(); - selection.data.map((data, idx) => { - if ('field' in newDatum) { + if ('field' in data) { const fieldName = `field_${selIdx}_${idx}`; delete errors.fields[fieldName]; - if (!data.field) { errors.fields[fieldName] = 'Key name is required'; } else if (fieldNames.has(data.field)) { errors.fields[fieldName] = 'Key name already used'; } else { fieldNames.add(data.field); - if (!validateDetectionFieldName(data.field)) { errors.fields[fieldName] = 'Invalid key name.'; } } errors.touched[fieldName] = true; } - - if ('values' in newDatum) { + if ('values' in data) { const valueId = `value_${selIdx}_${idx}`; delete errors.fields[valueId]; if (data.values.length === 1 && !data.values[0]) { errors.fields[valueId] = 'Value is required'; } - errors.touched[valueId] = true; } }); }); - this.setState({ - detectionObj: { - condition, - selections: newSelections, - }, errors, }); }; + private updateDatumInState = ( + selectionIdx: number, + dataIdx: number, + newDatum: Partial + ) => { + const { condition, selections } = this.state.detectionObj; + const selection = selections[selectionIdx]; + const datum = selection.data[dataIdx]; + const newSelections = [ + ...selections.slice(0, selectionIdx), + { + ...selection, + data: [ + ...selection.data.slice(0, dataIdx), + { + ...datum, + ...newDatum, + }, + ...selection.data.slice(dataIdx + 1), + ], + }, + ...selections.slice(selectionIdx + 1), + ]; + + this.setState( + { + detectionObj: { + condition, + selections: newSelections, + }, + }, + () => { + this.validateDatum(newSelections); + } + ); + }; + private updateSelection = (selectionIdx: number, newSelection: Partial) => { const { condition, selections } = this.state.detectionObj; const { errors } = this.state; const selection = selections[selectionIdx]; - if (!selection.name) { - selection.name = `Selection_${selectionIdx + 1}`; - } - delete errors.fields['name']; if (!selection.name) { errors.fields['name'] = 'Selection name is required'; @@ -319,13 +323,12 @@ export class DetectionVisualEditor extends React.Component< ); }; - private updateCondition = (value: string) => { + private validateCondition = (value: string) => { const { errors, detectionObj: { selections }, } = this.state; value = value.trim(); - delete errors.fields['condition']; if (!value) { errors.fields['condition'] = 'Condition is required'; @@ -346,15 +349,27 @@ export class DetectionVisualEditor extends React.Component< }); } } - errors.touched['condition'] = true; - const detectionObj = { ...this.state.detectionObj, condition: value } as DetectionObject; + errors.touched['condition'] = true; this.setState({ - detectionObj, errors, }); }; + private updateCondition = (value: string) => { + value = value.trim(); + + const detectionObj = { ...this.state.detectionObj, condition: value } as DetectionObject; + this.setState( + { + detectionObj, + }, + () => { + this.validateCondition(value); + } + ); + }; + private csvStringToArray = ( csvString: string, delimiter: string = ',', @@ -420,6 +435,10 @@ export class DetectionVisualEditor extends React.Component< ]; }; + private getTextareaHeight = (rowNo: number = 0) => { + return `${rowNo * 25 + 40}px`; + }; + render() { const { detectionObj: { condition, selections }, @@ -431,269 +450,286 @@ export class DetectionVisualEditor extends React.Component< } = this.state; return ( - +
{selections.map((selection, selectionIdx) => { return ( -
- - - - + + + + this.updateSelection(selectionIdx, { name: e.target.value })} - onBlur={(e) => this.updateSelection(selectionIdx, { name: e.target.value })} - value={selection.name || `Selection_${selectionIdx + 1}`} - /> - - -

Define the search identifier in your data the rule will be applied to.

-
-
- - {selections.length > 1 && ( - - { - const newSelections = [...selections]; - newSelections.splice(selectionIdx, 1); - this.setState( - { - detectionObj: { - condition, - selections: newSelections, - }, - }, - () => this.updateCondition(condition) - ); - }} - /> - - )} - -
- - - - {selection.data.map((datum, idx) => { - const radioGroupOptions = this.createRadioGroupOptions(selectionIdx, idx); - const fieldName = `field_${selectionIdx}_${idx}`; - const valueId = `value_${selectionIdx}_${idx}`; - return ( -
- Map {idx + 1}} - extraAction={ - selection.data.length > 1 ? ( - - { - const newData = [...selection.data]; - newData.splice(idx, 1); - this.updateSelection(selectionIdx, { data: newData }); - }} - /> - - ) : null - } - style={{ maxWidth: '70%' }} + error={errors.fields.name} > - - - Key} - > - - this.updateDatumInState(selectionIdx, idx, { - field: e.target.value, - }) - } - onBlur={(e) => - this.updateDatumInState(selectionIdx, idx, { - field: e.target.value, - }) - } - value={datum.field} - /> - - - - Modifier}> - { - this.updateDatumInState(selectionIdx, idx, { - modifier: e[0].value, - }); - }} - onBlur={(e) => {}} - selectedOptions={ - datum.modifier - ? [{ value: datum.modifier, label: datum.modifier }] - : [detectionModifierOptions[0]] - } - /> - - - - - - - { - this.updateDatumInState(selectionIdx, idx, { - selectedRadioId: id as SelectionMapValueRadioId, - }); - }} + + this.updateSelection(selectionIdx, { name: e.target.value }) + } + onBlur={(e) => this.updateSelection(selectionIdx, { name: e.target.value })} + value={selection.name} /> - - - - {datum.selectedRadioId?.includes('list') ? ( - <> - - - + +

Define the search identifier in your data the rule will be applied to.

+
+
+ + {selections.length > 1 && ( + + { + const newSelections = [...selections]; + newSelections.splice(selectionIdx, 1); + this.setState( + { + detectionObj: { + condition, + selections: newSelections, + }, + }, + () => this.updateCondition(condition) + ); + }} + /> + + )} + +
+ + + + {selection.data.map((datum, idx) => { + const radioGroupOptions = this.createRadioGroupOptions(selectionIdx, idx); + const fieldName = `field_${selectionIdx}_${idx}`; + const valueId = `value_${selectionIdx}_${idx}`; + return ( +
+ Map {idx + 1}} + extraAction={ + selection.data.length > 1 ? ( + + { - this.setState({ - fileUploadModalState: { - selectionIdx, - dataIdx: idx, - }, + const newData = [...selection.data]; + newData.splice(idx, 1); + this.updateSelection(selectionIdx, { data: newData }); + }} + /> + + ) : null + } + style={{ maxWidth: '500px' }} + > + + + + + Key} + > + + this.updateDatumInState(selectionIdx, idx, { + field: e.target.value, + }) + } + onBlur={(e) => + this.updateDatumInState(selectionIdx, idx, { + field: e.target.value, + }) + } + value={datum.field} + /> + + + + Modifier}> + { + this.updateDatumInState(selectionIdx, idx, { + modifier: e[0].value, }); }} + onBlur={() => {}} + selectedOptions={ + datum.modifier + ? [{ value: datum.modifier, label: datum.modifier }] + : [detectionModifierOptions[0]] + } + /> + + + + + + + { + this.updateDatumInState(selectionIdx, idx, { + selectedRadioId: id as SelectionMapValueRadioId, + }); + }} + /> + + + + {datum.selectedRadioId?.includes('list') ? ( + <> + + + { + this.setState({ + fileUploadModalState: { + selectionIdx, + dataIdx: idx, + }, + }); + }} + > + Upload file + + + + + - Upload file - - - - { + this.updateDatumInState(selectionIdx, idx, { + values: [], + }); + }} + > + Clear list + + + + + - { + { + const values = e.target.value.split('\n'); this.updateDatumInState(selectionIdx, idx, { - values: [], + values, }); }} - > - Clear list - - - - + onBlur={(e) => { + const values = e.target.value.split('\n'); + this.updateDatumInState(selectionIdx, idx, { + values, + }); + }} + value={datum.values.join('\n')} + compressed={true} + isInvalid={errors.touched[valueId] && !!errors.fields[valueId]} + /> + + + ) : ( - { - const values = e.target.value.split('\n'); this.updateDatumInState(selectionIdx, idx, { - values, + values: [e.target.value, ...datum.values.slice(1)], }); }} onBlur={(e) => { - const values = e.target.value.split('\n'); this.updateDatumInState(selectionIdx, idx, { - values, + values: [e.target.value, ...datum.values.slice(1)], }); }} - value={datum.values.join('\n')} - compressed={true} - isInvalid={errors.touched[valueId] && !!errors.fields[valueId]} + value={datum.values[0]} /> - - ) : ( - - { - this.updateDatumInState(selectionIdx, idx, { - values: [e.target.value, ...datum.values.slice(1)], - }); - }} - onBlur={(e) => { - this.updateDatumInState(selectionIdx, idx, { - values: [e.target.value, ...datum.values.slice(1)], - }); - }} - value={datum.values[0]} - /> - - )} - - - -
- ); - })} - - - - { - const newData = [ - ...selection.data, - { ...defaultDetectionObj.selections[0].data[0] }, - ]; - this.updateSelection(selectionIdx, { data: newData }); - }} - > - Add map - - - - - + )} + + +
+
+ ); + })} + + + + { + const newData = [ + ...selection.data, + { ...defaultDetectionObj.selections[0].data[0] }, + ]; + this.updateSelection(selectionIdx, { data: newData }); + }} + > + Add map + +
+ + {selections.length > 1 && selections.length !== selectionIdx ? ( + + ) : null}
); })} + + } > - this.updateCondition(value)} - onBlur={(e) => { - this.updateCondition(this.state.detectionObj.condition); - }} - data-test-subj={'rule_detection_field'} + onChange={this.updateCondition} + dataTestSubj={'rule_detection_field'} /> @@ -753,8 +784,19 @@ export class DetectionVisualEditor extends React.Component<

Upload a file

- + {selections[fileUploadModalState.selectionIdx].data[fileUploadModalState.dataIdx] + .values[0] && ( + <> + + + + )} {this.state.invalidFile && (

Invalid file.

@@ -787,7 +829,7 @@ export class DetectionVisualEditor extends React.Component< )} - +
); } } diff --git a/public/pages/Rules/components/RuleEditor/RuleEditorForm.tsx b/public/pages/Rules/components/RuleEditor/RuleEditorForm.tsx index c63d2c048..b3cfa4952 100644 --- a/public/pages/Rules/components/RuleEditor/RuleEditorForm.tsx +++ b/public/pages/Rules/components/RuleEditor/RuleEditorForm.tsx @@ -232,6 +232,8 @@ export const RuleEditorForm: React.FC = ({ { if (isInvalid) { diff --git a/public/pages/Rules/components/RuleEditor/components/SelectionExpField.scss b/public/pages/Rules/components/RuleEditor/components/SelectionExpField.scss new file mode 100644 index 000000000..f35813775 --- /dev/null +++ b/public/pages/Rules/components/RuleEditor/components/SelectionExpField.scss @@ -0,0 +1,25 @@ +.selection-exp-field-item { + position: relative; + + .euiExpression__value { + padding: 2px; + border: 1px solid; + border-radius: 4px; + } +} + +.selection-exp-field-item-with-remove { + position: relative; + + .euiExpression__value { + padding: 2px 25px 2px 2px; + border: 1px solid; + border-radius: 4px; + } + + .selection-exp-field-item-remove { + position: absolute; + right: 0; + top: 1px; + } +} diff --git a/public/pages/Rules/components/RuleEditor/components/SelectionExpField.tsx b/public/pages/Rules/components/RuleEditor/components/SelectionExpField.tsx new file mode 100644 index 000000000..23b66f0fa --- /dev/null +++ b/public/pages/Rules/components/RuleEditor/components/SelectionExpField.tsx @@ -0,0 +1,246 @@ +import React, { useEffect, useState } from 'react'; +import { + EuiPopoverTitle, + EuiFlexItem, + EuiFlexGroup, + EuiPopover, + EuiSelect, + EuiButtonIcon, + EuiExpression, +} from '@elastic/eui'; +import * as _ from 'lodash'; +import { Selection } from '../DetectionVisualEditor'; + +interface SelectionExpFieldProps { + selections: Selection[]; + dataTestSubj: string; + onChange: (value: string) => void; + value: string; +} + +interface UsedSelection { + isOpen: boolean; + name: string; + description: string; +} + +export const SelectionExpField: React.FC = ({ + selections, + dataTestSubj, + onChange, + value, +}) => { + const DEFAULT_DESCRIPTION = 'Select'; + const OPERATORS = ['and', 'or', 'not']; + const [usedExpressions, setUsedExpressions] = useState([]); + + useEffect(() => { + let expressions: UsedSelection[] = []; + if (value?.length) { + let values = value.split(' '); + if (OPERATORS.indexOf(values[0]) === -1) values = ['', ...values]; + + let counter = 0; + values.map((val, idx) => { + if (idx % 2 === 0) { + expressions.push({ + description: val, + isOpen: false, + name: '', + }); + counter++; + } else { + const currentIndex = idx - counter; + expressions[currentIndex] = { ...expressions[currentIndex], name: val }; + } + }); + } else { + expressions = []; + } + + setUsedExpressions(expressions); + expressions.length && onChange(getValue(expressions)); + }, [value]); + + const getValue = (usedExp: UsedSelection[]) => { + const expressions = usedExp.map((exp) => [_.toLower(exp.description), exp.name]); + return _.flattenDeep(expressions).join(' '); + }; + + const changeExtValue = ( + event: React.ChangeEvent, + exp: UsedSelection, + idx: number + ) => { + const usedExp = _.cloneDeep(usedExpressions); + usedExp[idx] = { ...usedExp[idx], name: event.target.value }; + setUsedExpressions(usedExp); + onChange(getValue(usedExp)); + }; + + const changeExtDescription = ( + event: React.ChangeEvent, + exp: UsedSelection, + idx: number + ) => { + const usedExp = _.cloneDeep(usedExpressions); + usedExp[idx] = { ...usedExp[idx], description: event.target.value }; + setUsedExpressions(usedExp); + onChange(getValue(usedExp)); + }; + + const openPopover = (idx: number) => { + const usedExp = _.cloneDeep(usedExpressions); + usedExp[idx] = { ...usedExp[idx], isOpen: !usedExp[idx].isOpen }; + setUsedExpressions(usedExp); + }; + + const closePopover = (idx: number) => { + const usedExp = _.cloneDeep(usedExpressions); + usedExp[idx] = { ...usedExp[idx], isOpen: false }; + setUsedExpressions(usedExp); + }; + + const renderOptions = (exp: UsedSelection, idx: number) => ( +
+ + + Selection + changeExtDescription(e, exp, idx)} + options={[ + { value: '', text: '' }, + { value: 'and', text: 'AND' }, + { value: 'or', text: 'OR' }, + { value: 'not', text: 'NOT' }, + ]} + /> + + {selections.length > usedExpressions.length && ( + {renderSelections(exp, idx)} + )} + +
+ ); + + const renderSelections = (exp: UsedSelection, idx: number) => ( +
+ Selections + changeExtValue(e, exp, idx)} + value={exp.name} + options={(() => { + const differences = _.differenceBy(selections, usedExpressions, 'name'); + return [ + { + value: exp.name, + text: exp.name, + }, + ...differences.map((sel) => ({ + value: sel.name, + text: sel.name, + })), + ]; + })()} + /> +
+ ); + + return ( + + {!usedExpressions.length && ( + + + } + isOpen={false} + panelPaddingSize="s" + anchorPosition="rightDown" + closePopover={() => {}} + /> + + )} + {usedExpressions.map((exp, idx) => ( + 1 + ? 'selection-exp-field-item-with-remove' + : 'selection-exp-field-item' + } + > + { + e.preventDefault(); + openPopover(idx); + }} + /> + } + isOpen={exp.isOpen} + closePopover={() => closePopover(idx)} + panelPaddingSize="s" + anchorPosition="rightDown" + > + {renderOptions(exp, idx)} + + {usedExpressions.length > 1 ? ( + { + const usedExp = _.cloneDeep(usedExpressions); + usedExp.splice(idx, 1); + usedExp[0].description = ''; + setUsedExpressions([...usedExp]); + onChange(getValue(usedExp)); + }} + color={'danger'} + iconType="cross" + aria-label={'Remove condition'} + /> + ) : null} + + ))} + {selections.length > usedExpressions.length && ( + + { + const usedExp = _.cloneDeep(usedExpressions); + const differences = _.differenceBy(selections, usedExp, 'name'); + const exp = [ + ...usedExp, + { + description: usedExpressions.length ? 'AND' : '', + isOpen: false, + name: differences[0]?.name, + }, + ]; + setUsedExpressions(exp); + onChange(getValue(exp)); + }} + color={'primary'} + iconType="plusInCircleFilled" + aria-label={'Add one more condition'} + /> + + )} + + ); +}; diff --git a/public/pages/Rules/containers/EditRule/EditRule.tsx b/public/pages/Rules/containers/EditRule/EditRule.tsx index 7623d3818..b5f5c07e8 100644 --- a/public/pages/Rules/containers/EditRule/EditRule.tsx +++ b/public/pages/Rules/containers/EditRule/EditRule.tsx @@ -5,7 +5,7 @@ import { BrowserServices } from '../../../../models/interfaces'; import { RuleEditorContainer } from '../../components/RuleEditor/RuleEditorContainer'; -import React, { useContext } from 'react'; +import React, { useContext, useEffect } from 'react'; import { EuiButton, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { RouteComponentProps } from 'react-router-dom'; import { BREADCRUMBS, ROUTES } from '../../../../utils/constants'; @@ -29,7 +29,10 @@ export const EditRule: React.FC = ({ notifications, }) => { const context = useContext(CoreServicesContext); - setBreadCrumb(BREADCRUMBS.RULES_EDIT, context?.chrome.setBreadcrumbs); + + useEffect(() => { + setBreadCrumb(BREADCRUMBS.RULES_EDIT, context?.chrome.setBreadcrumbs); + }); return (