diff --git a/cypress/integration/2_rules.spec.js b/cypress/integration/2_rules.spec.js index afe1b461e..21f5f271f 100644 --- a/cypress/integration/2_rules.spec.js +++ b/cypress/integration/2_rules.spec.js @@ -5,8 +5,9 @@ import { PLUGIN_NAME, TWENTY_SECONDS_TIMEOUT } from '../support/constants'; +const uniqueId = Cypress._.random(0, 1e6); const SAMPLE_RULE = { - name: 'Cypress test rule', + name: `Cypress test rule ${uniqueId}`, logType: 'windows', description: 'This is a rule used to test the rule creation workflow. Not for production use.', detection: @@ -26,6 +27,26 @@ const SAMPLE_RULE = { status: 'experimental', }; +const YAML_RULE_LINES = [ + `title: ${SAMPLE_RULE.name}`, + `description:`, + `${SAMPLE_RULE.description}`, + `level: ${SAMPLE_RULE.severity}`, + `tags:`, + `- ${SAMPLE_RULE.tags[0]}`, + `- ${SAMPLE_RULE.tags[1]}`, + `- ${SAMPLE_RULE.tags[2]}`, + `references:`, + `- '${SAMPLE_RULE.references}'`, + `falsepositives:`, + `- ${SAMPLE_RULE.falsePositive}`, + `author: ${SAMPLE_RULE.author}`, + `status: ${SAMPLE_RULE.status}`, + `logsource:`, + `product: ${SAMPLE_RULE.logType}`, + ...SAMPLE_RULE.detection.replaceAll(' ', '').replaceAll('{backspace}', '').split('\n'), +]; + describe('Rules', () => { before(() => { // Deleting pre-existing test rules @@ -93,6 +114,20 @@ describe('Rules', () => { SAMPLE_RULE.status ); + // Switch to YAML editor + cy.get( + '[data-test-subj="change-editor-type"] label:nth-child(2)', + TWENTY_SECONDS_TIMEOUT + ).click({ + force: true, + }); + + YAML_RULE_LINES.forEach((line) => + cy + .get('[data-test-subj="rule_yaml_editor"]', TWENTY_SECONDS_TIMEOUT) + .contains(line, TWENTY_SECONDS_TIMEOUT) + ); + // Click "create" button cy.get('[data-test-subj="create_rule_button"]', TWENTY_SECONDS_TIMEOUT).click({ force: true, diff --git a/models/interfaces.ts b/models/interfaces.ts index a2982c8fc..3c393422f 100644 --- a/models/interfaces.ts +++ b/models/interfaces.ts @@ -9,11 +9,11 @@ export interface Rule { log_source: string; title: string; description: string; - tags: { value: string }[]; - false_positives: { value: string }[]; + tags: Array<{ value: string }>; + false_positives: Array<{ value: string }>; level: string; status: string; - references: { value: string }[]; + references: Array<{ value: string }>; author: string; detection: string; } diff --git a/public/pages/Rules/components/RuleEditor/RuleEditor.tsx b/public/pages/Rules/components/RuleEditor/RuleEditor.tsx index 305569059..6eab679e8 100644 --- a/public/pages/Rules/components/RuleEditor/RuleEditor.tsx +++ b/public/pages/Rules/components/RuleEditor/RuleEditor.tsx @@ -3,291 +3,83 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { BrowserServices } from '../../../../models/interfaces'; -import React, { ChangeEvent, useState } from 'react'; +import React, { useState } from 'react'; import { ContentPanel } from '../../../../components/ContentPanel'; -import { - EuiFlexGroup, - EuiFlexItem, - EuiFormRow, - EuiFieldText, - EuiSelect, - EuiSpacer, - EuiTextArea, - EuiComboBox, - EuiCodeEditor, - EuiComboBoxOptionOption, -} from '@elastic/eui'; +import { EuiSpacer, EuiButtonGroup } from '@elastic/eui'; import { Rule } from '../../../../../models/interfaces'; -import { FieldTextArray } from './FieldTextArray'; -import { ruleStatus, ruleTypes } from '../../utils/constants'; -import { - authorErrorString, - AUTHOR_REGEX, - descriptionErrorString, - nameErrorString, - validateDescription, - validateName, -} from '../../../../utils/validation'; +import { RuleEditorFormState, ruleEditorStateDefaultValue } from './RuleEditorFormState'; +import { mapFormToRule, mapRuleToForm } from './mappers'; +import { VisualRuleEditor } from './VisualRuleEditor'; +import { YamlRuleEditor } from './YamlRuleEditor'; export interface RuleEditorProps { - services: BrowserServices; title: string; FooterActions: React.FC<{ rule: Rule }>; rule?: Rule; } -export const RuleEditor: React.FC = ({ title, rule, FooterActions }) => { - const [name, setName] = useState(rule?.title || ''); - const onNameChange = (e: ChangeEvent) => { - setName(e.target.value); - }; - const [nameError, setNameError] = useState(''); - const onNameBlur = (e: ChangeEvent) => { - if (!validateName(e.target.value)) { - setNameError(nameErrorString); - } else { - setNameError(''); - } - }; - - const [logType, setLogType] = useState(rule?.category || ''); - const onLogTypeChange = (e: ChangeEvent) => { - setLogType(e.target.value); - }; - - const [description, setDescription] = useState(rule?.description || ''); - const onDescriptionChange = (e: ChangeEvent) => { - setDescription(e.target.value); - }; - const [descriptionError, setDescriptionError] = useState(''); - const onDescriptionBlur = (e: ChangeEvent) => { - if (!validateDescription(e.target.value)) { - setDescriptionError(descriptionErrorString); - } else { - setDescriptionError(''); - } - }; - - const [level, setLevel] = useState(rule?.level || ''); - const onLevelChange = (e: ChangeEvent) => { - setLevel(e.target.value); - }; - - const [tags, setTags] = useState(rule?.tags.map((tag) => ({ label: tag.value })) || []); - const onTagsChange = (selectedOptions: EuiComboBoxOptionOption[]) => { - setTags(selectedOptions.map((option) => ({ label: option.label }))); - }; - const onCreateTag = (value: string) => { - setTags([...tags, { label: value }]); - }; +export interface VisualEditorFormErrorsState { + nameError: string | null; + descriptionError: string | null; + authorError: string | null; +} - const [author, setAuthor] = useState(rule?.author || ''); - const onAuthorChange = (e: ChangeEvent) => { - setAuthor(e.target.value); - }; - const [authorError, setAuthorError] = useState(''); - const onAuthorBlur = (e: ChangeEvent) => { - if (!validateName(e.target.value, AUTHOR_REGEX)) { - setAuthorError(authorErrorString); - } else { - setAuthorError(''); - } - }; +const editorTypes = [ + { + id: 'visual', + label: 'Visual Editor', + }, + { + id: 'yaml', + label: 'YAML Editor', + }, +]; - const [status, setRuleStatus] = useState(rule?.status || ''); - const onStatusChange = (e: ChangeEvent) => { - setRuleStatus(e.target.value); - }; +export const RuleEditor: React.FC = ({ title, rule, FooterActions }) => { + const [ruleEditorFormState, setRuleEditorFormState] = useState( + rule + ? { ...mapRuleToForm(rule), id: ruleEditorStateDefaultValue.id } + : ruleEditorStateDefaultValue + ); - const [detection, setDetection] = useState(rule?.detection || ''); - const onDetectionChange = (value: string) => { - setDetection(value); - }; + const [selectedEditorType, setSelectedEditorType] = useState('visual'); - const [references, setReferences] = useState( - rule?.references.map((ref) => ref.value) || [''] - ); - const onReferenceAdd = () => { - setReferences([...references, '']); - }; - const onReferenceEdit = (value: string, index: number) => { - setReferences([...references.slice(0, index), value, ...references.slice(index + 1)]); - }; - const onReferenceRemove = (index: number) => { - const newRefs = [...references]; - newRefs.splice(index, 1); - setReferences(newRefs); + const onEditorTypeChange = (optionId: string) => { + setSelectedEditorType(optionId); }; - const [falsePositives, setFalsePositives] = useState( - rule?.false_positives.map((falsePositive) => falsePositive.value) || [''] - ); - const onFalsePositiveAdd = () => { - setFalsePositives([...falsePositives, '']); - }; - const onFalsePositiveEdit = (value: string, index: number) => { - setFalsePositives([ - ...falsePositives.slice(0, index), - value, - ...falsePositives.slice(index + 1), - ]); - }; - const onFalsePositiveRemove = (index: number) => { - const newFalsePositives = [...falsePositives]; - newFalsePositives.splice(index, 1); - setFalsePositives(newFalsePositives); + const getRule = (): Rule => { + return mapFormToRule(ruleEditorFormState); }; - const getRule = (): Rule => { - return { - id: '25b9c01c-350d-4b95-bed1-836d04a4f324', - category: logType, - title: name, - description: description, - status: status, - author: author, - references: references.map((ref) => ({ value: ref })), - tags: tags.map((tag) => ({ value: tag.label })), - log_source: '', - detection: detection, - level: level, - false_positives: falsePositives.map((falsePositive) => ({ value: falsePositive })), - }; + const onYamlRuleEditorChange = (value: Rule) => { + const formState = mapRuleToForm(value); + setRuleEditorFormState(formState); }; return ( <> - - - - - - - - - ({ value: type, text: type }))} - onChange={onLogTypeChange} - value={logType} - required - data-test-subj={'rule_type_dropdown'} - /> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - onEditorTypeChange(id)} /> - - - - - + {selectedEditorType === 'visual' && ( + - - - - - - ({ value: status, text: status }))} - onChange={onStatusChange} - value={status} - required - data-test-subj={'rule_status_dropdown'} + )} + {selectedEditorType === 'yaml' && ( + - - + )} diff --git a/public/pages/Rules/components/RuleEditor/RuleEditorFormState.ts b/public/pages/Rules/components/RuleEditor/RuleEditorFormState.ts new file mode 100644 index 000000000..ccd8dd0c4 --- /dev/null +++ b/public/pages/Rules/components/RuleEditor/RuleEditorFormState.ts @@ -0,0 +1,36 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { EuiComboBoxOptionOption } from '@elastic/eui'; + +export interface RuleEditorFormState { + id: string; + log_source: string; + logType: string; + name: string; + description: string; + status: string; + author: string; + references: string[]; + tags: EuiComboBoxOptionOption[]; + detection: string; + level: string; + falsePositives: string[]; +} + +export const ruleEditorStateDefaultValue: RuleEditorFormState = { + id: '25b9c01c-350d-4b95-bed1-836d04a4f324', + log_source: '', + logType: '', + name: '', + description: '', + status: '', + author: '', + references: [''], + tags: [], + detection: '', + level: '', + falsePositives: [''], +}; diff --git a/public/pages/Rules/components/RuleEditor/VisualRuleEditor.tsx b/public/pages/Rules/components/RuleEditor/VisualRuleEditor.tsx new file mode 100644 index 000000000..03126ab52 --- /dev/null +++ b/public/pages/Rules/components/RuleEditor/VisualRuleEditor.tsx @@ -0,0 +1,318 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { ChangeEvent, useState } from 'react'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, + EuiFieldText, + EuiSelect, + EuiSpacer, + EuiTextArea, + EuiComboBox, + EuiCodeEditor, + EuiComboBoxOptionOption, +} from '@elastic/eui'; +import { FieldTextArray } from './FieldTextArray'; +import { ruleStatus, ruleTypes } from '../../utils/constants'; +import { + authorErrorString, + AUTHOR_REGEX, + descriptionErrorString, + nameErrorString, + validateDescription, + validateName, +} from '../../../../utils/validation'; +import { RuleEditorFormState } from './RuleEditorFormState'; + +export interface VisualRuleEditorProps { + ruleEditorFormState: RuleEditorFormState; + setRuleEditorFormState: React.Dispatch>; +} + +export interface VisualEditorFormErrorsState { + nameError: string | null; + descriptionError: string | null; + authorError: string | null; +} + +export const VisualRuleEditor: React.FC = ({ + ruleEditorFormState, + setRuleEditorFormState, +}) => { + const [visualEditorErrors, setVisualEditorErrors] = useState({ + nameError: null, + descriptionError: null, + authorError: null, + }); + + const onNameChange = (e: ChangeEvent) => { + const { value: name } = e.target; + setRuleEditorFormState((prevState) => ({ ...prevState, name })); + }; + const onNameBlur = (e: ChangeEvent) => { + if (!validateName(e.target.value)) { + setVisualEditorErrors((prevState) => ({ ...prevState, nameError: nameErrorString })); + } else { + setVisualEditorErrors((prevState) => ({ ...prevState, nameError: null })); + } + }; + + const onLogTypeChange = (e: ChangeEvent) => { + const { value: logType } = e.target; + setRuleEditorFormState((prevState) => ({ ...prevState, logType })); + }; + + const onDescriptionChange = (e: ChangeEvent) => { + const { value: description } = e.target; + setRuleEditorFormState((prevState) => ({ ...prevState, description })); + }; + const onDescriptionBlur = (e: ChangeEvent) => { + if (!validateDescription(e.target.value)) { + setVisualEditorErrors((prevState) => ({ + ...prevState, + descriptionError: descriptionErrorString, + })); + } else { + setVisualEditorErrors((prevState) => ({ ...prevState, descriptionError: null })); + } + }; + + const onLevelChange = (e: ChangeEvent) => { + const { value: level } = e.target; + setRuleEditorFormState((prevState) => ({ ...prevState, level })); + }; + + const onTagsChange = (selectedOptions: EuiComboBoxOptionOption[]) => { + const tags = selectedOptions.map((option) => ({ label: option.label })); + setRuleEditorFormState((prevState) => ({ ...prevState, tags })); + }; + const onCreateTag = (value: string) => { + setRuleEditorFormState((prevState) => ({ + ...prevState, + tags: [...prevState.tags, { label: value }], + })); + }; + + const onAuthorChange = (e: ChangeEvent) => { + const { value: author } = e.target; + setRuleEditorFormState((prevState) => ({ ...prevState, author })); + }; + + const onAuthorBlur = (e: ChangeEvent) => { + if (!validateName(e.target.value, AUTHOR_REGEX)) { + setVisualEditorErrors((prevState) => ({ ...prevState, authorError: authorErrorString })); + } else { + setVisualEditorErrors((prevState) => ({ ...prevState, authorError: null })); + } + }; + + const onStatusChange = (e: ChangeEvent) => { + const { value: status } = e.target; + setRuleEditorFormState((prevState) => ({ ...prevState, status })); + }; + + const onDetectionChange = (value: string) => { + setRuleEditorFormState((prevState) => ({ ...prevState, detection: value })); + }; + + const onReferenceAdd = () => { + setRuleEditorFormState((prevState) => ({ + ...prevState, + references: [...prevState.references, ''], + })); + }; + const onReferenceEdit = (value: string, index: number) => { + setRuleEditorFormState((prevState) => ({ + ...prevState, + references: [ + ...prevState.references.slice(0, index), + value, + ...prevState.references.slice(index + 1), + ], + })); + }; + const onReferenceRemove = (index: number) => { + setRuleEditorFormState((prevState) => { + const newRefs = [...prevState.references]; + newRefs.splice(index, 1); + return { + ...prevState, + references: newRefs, + }; + }); + }; + + const onFalsePositiveAdd = () => { + setRuleEditorFormState((prevState) => ({ + ...prevState, + falsePositives: [...prevState.falsePositives, ''], + })); + }; + const onFalsePositiveEdit = (value: string, index: number) => { + setRuleEditorFormState((prevState) => ({ + ...prevState, + falsePositives: [ + ...prevState.falsePositives.slice(0, index), + value, + ...prevState.falsePositives.slice(index + 1), + ], + })); + }; + const onFalsePositiveRemove = (index: number) => { + setRuleEditorFormState((prevState) => { + const newFalsePositives = [...prevState.falsePositives]; + newFalsePositives.splice(index, 1); + return { + ...prevState, + falsePositives: newFalsePositives, + }; + }); + }; + + return ( + <> + + + + + + + + + ({ value: type, text: type }))} + onChange={onLogTypeChange} + value={ruleEditorFormState.logType} + required + data-test-subj={'rule_type_dropdown'} + /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ({ value: status, text: status }))} + onChange={onStatusChange} + value={ruleEditorFormState.status} + required + data-test-subj={'rule_status_dropdown'} + /> + + + + + ); +}; diff --git a/public/pages/Rules/components/RuleEditor/YamlRuleEditor.test.tsx b/public/pages/Rules/components/RuleEditor/YamlRuleEditor.test.tsx new file mode 100644 index 000000000..1c7bfb84a --- /dev/null +++ b/public/pages/Rules/components/RuleEditor/YamlRuleEditor.test.tsx @@ -0,0 +1,54 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { render } from '@testing-library/react'; +import { YamlRuleEditor } from './YamlRuleEditor'; + +describe(' spec', () => { + it('renders the component', () => { + const { container } = render( + {}} + rule={{ + id: '25b9c01c-350d-4b95-bed1-836d04a4f324', + category: 'windows', + title: 'Testing rule', + description: 'Testing Description', + status: 'experimental', + author: 'Bhabesh Raj', + references: [ + { + value: 'https://securelist.com/operation-tunnelsnake-and-moriya-rootkit/101831', + }, + ], + tags: [ + { + value: 'attack.persistence', + }, + { + value: 'attack.privilege_escalation', + }, + { + value: 'attack.t1543.003', + }, + ], + log_source: '', + detection: + 'selection:\n Provider_Name: Service Control Manager\n EventID: 7045\n ServiceName: ZzNetSvc\ncondition: selection\n', + level: 'high', + false_positives: [ + { + value: 'Unknown', + }, + ], + }} + > +
Testing YamlRuleEditor
+
+ ); + expect(container.firstChild).toMatchSnapshot(); + }); +}); diff --git a/public/pages/Rules/components/RuleEditor/YamlRuleEditor.tsx b/public/pages/Rules/components/RuleEditor/YamlRuleEditor.tsx new file mode 100644 index 000000000..abdb234a0 --- /dev/null +++ b/public/pages/Rules/components/RuleEditor/YamlRuleEditor.tsx @@ -0,0 +1,205 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useState } from 'react'; +import { dump, load } from 'js-yaml'; +import { EuiFormRow, EuiCodeEditor, EuiLink, EuiSpacer, EuiText, EuiForm } from '@elastic/eui'; +import FormFieldHeader from '../../../../components/FormFieldHeader'; +import { Rule } from '../../../../../models/interfaces'; +import { + AUTHOR_REGEX, + validateDescription, + validateName, + authorErrorString, + descriptionErrorString, + titleErrorString, +} from '../../../../utils/validation'; + +export interface YamlRuleEditorProps { + rule: Rule; + change: React.Dispatch; +} + +export interface YamlEditorState { + errors: string[] | null; + value?: string; +} + +const mapYamlObjectToYamlString = (rule: Rule): string => { + try { + if (!rule.detection) { + const { detection, ...ruleWithoutDetection } = rule; + return dump(ruleWithoutDetection); + } else { + return dump(rule); + } + } catch (error: any) { + console.warn('Security Analytics - Rule Eritor - Yaml dump', error); + return ''; + } +}; + +const mapRuleToYamlObject = (rule: Rule): any => { + let detection = undefined; + if (rule.detection) { + try { + detection = load(rule.detection); + } catch {} + } + + const yamlObject: any = { + id: rule.id, + logsource: { product: rule.category }, + title: rule.title, + description: rule.description, + tags: rule.tags.map((tag) => tag.value), + falsepositives: rule.false_positives.map((falsePositive) => falsePositive.value), + level: rule.level, + status: rule.status, + references: rule.references.map((reference) => reference.value), + author: rule.author, + detection, + }; + + return yamlObject; +}; + +const mapYamlObjectToRule = (obj: any): Rule => { + let detection = ''; + if (obj.detection) { + try { + detection = dump(obj.detection); + } catch {} + } + const rule: Rule = { + id: obj.id, + category: obj.logsource ? obj.logsource.product : undefined, + log_source: '', + title: obj.title, + description: obj.description, + tags: obj.tags ? obj.tags.map((tag: string) => ({ value: tag })) : undefined, + false_positives: obj.falsepositives + ? obj.falsepositives.map((falsePositive: string) => ({ value: falsePositive })) + : undefined, + level: obj.level, + status: obj.status, + references: obj.references + ? obj.references.map((reference: string) => ({ value: reference })) + : undefined, + author: obj.author, + detection, + }; + + return rule; +}; + +const validateRule = (rule: Rule): string[] | null => { + const requiredFiledsValidationErrors: Array = []; + + if (!rule.title) { + requiredFiledsValidationErrors.push('Title is required'); + } + if (!rule.category) { + requiredFiledsValidationErrors.push('Logsource is required'); + } + if (!rule.level) { + requiredFiledsValidationErrors.push('Level is required'); + } + if (!rule.author) { + requiredFiledsValidationErrors.push('Author is required'); + } + if (!rule.status) { + requiredFiledsValidationErrors.push('Status is required'); + } + + if (requiredFiledsValidationErrors.length > 0) { + return requiredFiledsValidationErrors; + } + + if (!validateName(rule.title, AUTHOR_REGEX)) { + return [titleErrorString]; + } + if (!validateDescription(rule.description)) { + return [descriptionErrorString]; + } + if (!validateName(rule.author, AUTHOR_REGEX)) { + return [authorErrorString]; + } + + return null; +}; + +export const YamlRuleEditor: React.FC = ({ rule, change }) => { + const yamlObject = mapRuleToYamlObject(rule); + + const [state, setState] = useState({ + errors: null, + value: mapYamlObjectToYamlString(yamlObject), + }); + + const onChange = (value: string) => { + setState((prevState) => ({ ...prevState, value })); + }; + + const onBlur = () => { + if (!state.value) { + setState((prevState) => ({ ...prevState, errors: ['Rule cannot be empty'] })); + return; + } + try { + const yamlObject = load(state.value); + + const rule = mapYamlObjectToRule(yamlObject); + + const errors = validateRule(rule); + + if (errors && errors.length > 0) { + setState((prevState) => ({ ...prevState, errors: errors })); + return; + } + + change(rule); + setState((prevState) => ({ ...prevState, errors: null })); + } catch (error) { + setState((prevState) => ({ ...prevState, errors: ['Invalid YAML'] })); + + console.warn('Security Analytics - Rule Eritor - Yaml load', error); + } + }; + + return ( + <> + 0} + error={state.errors} + component="form" + > + } + fullWidth={true} + > + <> + + Use the YAML editor to define a sigma rule. See{' '} + + Sigma specification + {' '} + for rule structure and schema. + + + + + + + + ); +}; diff --git a/public/pages/Rules/components/RuleEditor/__snapshots__/YamlRuleEditor.test.tsx.snap b/public/pages/Rules/components/RuleEditor/__snapshots__/YamlRuleEditor.test.tsx.snap new file mode 100644 index 000000000..ec1223fd0 --- /dev/null +++ b/public/pages/Rules/components/RuleEditor/__snapshots__/YamlRuleEditor.test.tsx.snap @@ -0,0 +1,166 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` spec renders the component 1`] = ` +
+
+
+ +
+
+
+
+ Use the YAML editor to define a sigma rule. See + + + Sigma specification + + + for rule structure and schema. +
+
+
+
+ +
+