-
- {reference.value}
-
-
+
+
+
+
+ Source
+ {prePackaged ? 'Sigma' : 'Custom'}
+
+ {prePackaged ? (
+
+ License
+
+ Detection Rule License (DLR)
+
+
+ ) : null}
+
+
+
+
+
+
+ Rule level
+ {ruleData.level}
+
+
+
+
+
+
Tags
+ {ruleData.tags.length > 0 ? (
+
+ {ruleData.tags.map((tag: any, i: number) => (
+
+ {tag.value}
+
+ ))}
+
+ ) : (
+
{DEFAULT_EMPTY_DATA}
+ )}
+
+
+
+
+
References
+ {ruleData.references.length > 0 ? (
+ ruleData.references.map((reference: any, i: number) => (
+
+
+ {reference.value}
+
+
+
+ ))
+ ) : (
+
{DEFAULT_EMPTY_DATA}
+ )}
+
+
+
+
False positive cases
+
+ {ruleData.false_positives.length > 0 ? (
+ ruleData.false_positives.map((falsepositive: any, i: number) => (
+
+ {falsepositive.value}
+
+
+ ))
+ ) : (
+
{DEFAULT_EMPTY_DATA}
+ )}
- ))
- ) : (
-
{DEFAULT_EMPTY_DATA}
- )}
-
-
-
False positive cases
-
- {ruleData.false_positives.length > 0 ? (
- ruleData.false_positives.map((falsepositive: any, i: number) => (
-
- {falsepositive.value}
-
-
- ))
- ) : (
-
{DEFAULT_EMPTY_DATA}
- )}
-
-
-
-
-
Rule Status
-
{ruleData.status}
-
-
-
-
-
- {ruleData.detection}
-
-
+
+
+
Rule Status
+
{ruleData.status}
+
+
+
+
+
+ {ruleData.detection}
+
+
+ >
+ )}
+ {selectedEditorType === 'yaml' && (
+
+
+
+ )}
);
};
diff --git a/public/pages/Rules/components/RuleContentViewer/RuleContentYamlViewer.test.tsx b/public/pages/Rules/components/RuleContentViewer/RuleContentYamlViewer.test.tsx
new file mode 100644
index 000000000..8c1571c8d
--- /dev/null
+++ b/public/pages/Rules/components/RuleContentViewer/RuleContentYamlViewer.test.tsx
@@ -0,0 +1,51 @@
+/*
+ * Copyright OpenSearch Contributors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import React from 'react';
+import { render } from '@testing-library/react';
+import { RuleContentYamlViewer } from './RuleContentYamlViewer';
+
+describe('
spec', () => {
+ it('renders the component', () => {
+ const { container } = render(
+
+ );
+ expect(container.firstChild).toMatchSnapshot();
+ });
+});
diff --git a/public/pages/Rules/components/RuleContentViewer/RuleContentYamlViewer.tsx b/public/pages/Rules/components/RuleContentViewer/RuleContentYamlViewer.tsx
new file mode 100644
index 000000000..2ef66cc0c
--- /dev/null
+++ b/public/pages/Rules/components/RuleContentViewer/RuleContentYamlViewer.tsx
@@ -0,0 +1,24 @@
+/*
+ * Copyright OpenSearch Contributors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { EuiCodeBlock } from '@elastic/eui';
+import React from 'react';
+import { mapRuleToYamlObject, mapYamlObjectToYamlString } from '../../utils/mappers';
+import { Rule } from '../../../../../models/interfaces';
+
+export interface RuleContentYamlViewerProps {
+ rule: Rule;
+}
+
+export const RuleContentYamlViewer: React.FC
= ({ rule }) => {
+ const yamlObject = mapRuleToYamlObject(rule);
+ const ruleYaml = mapYamlObjectToYamlString(yamlObject);
+
+ return (
+
+ {ruleYaml}
+
+ );
+};
diff --git a/public/pages/Rules/components/RuleContentViewer/__snapshots__/RuleContentViewer.test.tsx.snap b/public/pages/Rules/components/RuleContentViewer/__snapshots__/RuleContentViewer.test.tsx.snap
new file mode 100644
index 000000000..891a04d01
--- /dev/null
+++ b/public/pages/Rules/components/RuleContentViewer/__snapshots__/RuleContentViewer.test.tsx.snap
@@ -0,0 +1,341 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[` spec renders the component 1`] = `
+
+
+
+
+
+
+
+
+ My Rule
+
+
+
+
+
+ dns
+
+
+
+
+
+
+ My Rule
+
+
+
+
+
+ 2022-11-22T23:00:00.000Z
+
+
+
+ aleksandar
+
+
+
+
+
+
+ Custom
+
+
+
+
+
+
+ high
+
+
+
+
+
+ -
+
+
+
+
+
+ -
+
+
+
+
+
+
+
+ stable
+
+
+
+
+
+`;
diff --git a/public/pages/Rules/components/RuleContentViewer/__snapshots__/RuleContentYamlViewer.test.tsx.snap b/public/pages/Rules/components/RuleContentViewer/__snapshots__/RuleContentYamlViewer.test.tsx.snap
new file mode 100644
index 000000000..6b1c742ef
--- /dev/null
+++ b/public/pages/Rules/components/RuleContentViewer/__snapshots__/RuleContentYamlViewer.test.tsx.snap
@@ -0,0 +1,426 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[` spec renders the component 1`] = `
+
+
+
+
+
+ id
+
+
+ :
+
+ 25b9c01c
+
+ -
+
+ 350d
+
+ -
+
+ 4b95
+
+ -
+
+ bed1
+
+ -
+
+ 836d04a4f324
+
+
+
+
+
+ logsource
+
+
+ :
+
+
+
+
+
+
+
+ product
+
+
+ :
+
+ windows
+
+
+
+
+
+ title
+
+
+ :
+
+ Testing rule
+
+
+
+
+
+ description
+
+
+ :
+
+ Testing Description
+
+
+
+
+
+ tags
+
+
+ :
+
+
+
+
+
+
+
+ -
+
+ attack.persistence
+
+
+
+
+
+ -
+
+ attack.privilege_escalation
+
+
+
+
+
+ -
+
+ attack.t1543.003
+
+
+
+
+
+ falsepositives
+
+
+ :
+
+
+
+
+
+
+
+ -
+
+ Unknown
+
+
+
+
+
+ level
+
+
+ :
+
+ high
+
+
+
+
+
+ status
+
+
+ :
+
+ experimental
+
+
+
+
+
+ references
+
+
+ :
+
+
+
+
+
+
+
+ -
+
+
+
+ 'https://securelist.com/operation-tunnelsnake-and-moriya-rootkit/101831'
+
+
+
+
+
+
+
+ author
+
+
+ :
+
+ Bhabesh Raj
+
+
+
+
+
+ detection
+
+
+ :
+
+
+
+
+
+
+
+ selection
+
+
+ :
+
+
+
+
+
+
+
+ Provider_Name
+
+
+ :
+
+ Service Control Manager
+
+
+
+
+
+ EventID
+
+
+ :
+
+
+
+ 7045
+
+
+
+
+
+
+
+ ServiceName
+
+
+ :
+
+ ZzNetSvc
+
+
+
+
+
+ condition
+
+
+ :
+
+ selection
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`;
diff --git a/public/pages/Rules/components/RuleEditor/FieldTextArray.tsx b/public/pages/Rules/components/RuleEditor/FieldTextArray.tsx
index 1c8499622..65d3aeb48 100644
--- a/public/pages/Rules/components/RuleEditor/FieldTextArray.tsx
+++ b/public/pages/Rules/components/RuleEditor/FieldTextArray.tsx
@@ -14,7 +14,8 @@ import {
import React, { ChangeEvent } from 'react';
export interface FieldTextArrayProps {
- label: string;
+ label: string | React.ReactNode;
+ name: string;
fields: string[];
addButtonName: string;
onFieldEdit: (value: string, fieldIndex: number) => void;
@@ -25,6 +26,7 @@ export interface FieldTextArrayProps {
export const FieldTextArray: React.FC = ({
addButtonName,
label,
+ name,
fields,
onFieldEdit,
onFieldRemove,
@@ -43,7 +45,7 @@ export const FieldTextArray: React.FC = ({
onChange={(e: ChangeEvent) => {
onFieldEdit(e.target.value, index);
}}
- data-test-subj={`rule_${label
+ data-test-subj={`rule_${name
.toLowerCase()
.replaceAll(' ', '_')}_field_${index}`}
/>
diff --git a/public/pages/Rules/components/RuleEditor/FormSubmitionErrorToastNotification.tsx b/public/pages/Rules/components/RuleEditor/FormSubmitionErrorToastNotification.tsx
new file mode 100644
index 000000000..5ed467456
--- /dev/null
+++ b/public/pages/Rules/components/RuleEditor/FormSubmitionErrorToastNotification.tsx
@@ -0,0 +1,33 @@
+/*
+ * Copyright OpenSearch Contributors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import { useEffect, useState } from 'react';
+import { useFormikContext } from 'formik';
+import { NotificationsStart } from 'opensearch-dashboards/public';
+import { errorNotificationToast } from '../../../../utils/helpers';
+
+export const FormSubmissionErrorToastNotification = ({
+ notifications,
+}: {
+ notifications?: NotificationsStart;
+}) => {
+ const { submitCount, isValid } = useFormikContext();
+ const [prevSubmitCount, setPrevSubmitCount] = useState(submitCount);
+
+ useEffect(() => {
+ if (isValid) return;
+
+ if (submitCount === prevSubmitCount) return;
+
+ setPrevSubmitCount(submitCount);
+
+ errorNotificationToast(
+ notifications!,
+ 'create',
+ 'rule',
+ 'Some fields are invalid. Fix all highlighted error(s) before continuing.'
+ );
+ }, [submitCount, isValid]);
+ return null;
+};
diff --git a/public/pages/Rules/components/RuleEditor/RuleEditorContainer.tsx b/public/pages/Rules/components/RuleEditor/RuleEditorContainer.tsx
new file mode 100644
index 000000000..55b6f639b
--- /dev/null
+++ b/public/pages/Rules/components/RuleEditor/RuleEditorContainer.tsx
@@ -0,0 +1,92 @@
+/*
+ * Copyright OpenSearch Contributors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import React, { useCallback } from 'react';
+import { RouteComponentProps } from 'react-router-dom';
+import { NotificationsStart } from 'opensearch-dashboards/public';
+import { RuleService } from '../../../../services';
+import { ROUTES } from '../../../../utils/constants';
+import { EuiSpacer } from '@elastic/eui';
+import { Rule } from '../../../../../models/interfaces';
+import { RuleEditorFormModel, ruleEditorStateDefaultValue } from './RuleEditorFormModel';
+import { mapFormToRule, mapRuleToForm } from './mappers';
+import { RuleEditorForm } from './RuleEditorForm';
+import { validateRule } from '../../utils/helpers';
+import { errorNotificationToast } from '../../../../utils/helpers';
+
+export interface RuleEditorProps {
+ title: string;
+ rule?: Rule;
+ history: RouteComponentProps['history'];
+ notifications?: NotificationsStart;
+ ruleService: RuleService;
+ mode: 'create' | 'edit';
+}
+
+export interface VisualEditorFormErrorsState {
+ nameError: string | null;
+ descriptionError: string | null;
+ authorError: string | null;
+}
+
+export const RuleEditorContainer: React.FC = ({
+ history,
+ notifications,
+ title,
+ rule,
+ ruleService,
+ mode,
+}) => {
+ const initialRuleValue = rule
+ ? { ...mapRuleToForm(rule), id: ruleEditorStateDefaultValue.id }
+ : ruleEditorStateDefaultValue;
+
+ const onSubmit = async (values: RuleEditorFormModel) => {
+ const submitingRule = mapFormToRule(values);
+ if (!validateRule(submitingRule, notifications!, 'create')) {
+ return;
+ }
+
+ let result;
+ if (mode === 'edit') {
+ if (!rule) {
+ console.error('No rule id found');
+ return;
+ }
+ result = await ruleService.updateRule(rule?.id, submitingRule.category, submitingRule);
+ } else {
+ result = await ruleService.createRule(submitingRule);
+ }
+
+ if (!result.ok) {
+ errorNotificationToast(
+ notifications!,
+ mode === 'create' ? 'create' : 'save',
+ 'rule',
+ result.error
+ );
+ } else {
+ history.replace(ROUTES.RULES);
+ }
+ };
+
+ const goToRulesList = useCallback(() => {
+ history.replace(ROUTES.RULES);
+ }, [history]);
+
+ return (
+ <>
+
+
+ >
+ );
+};
diff --git a/public/pages/Rules/components/RuleEditor/RuleEditorForm.tsx b/public/pages/Rules/components/RuleEditor/RuleEditorForm.tsx
new file mode 100644
index 000000000..6a04dbd20
--- /dev/null
+++ b/public/pages/Rules/components/RuleEditor/RuleEditorForm.tsx
@@ -0,0 +1,423 @@
+/*
+ * Copyright OpenSearch Contributors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import React, { useState } from 'react';
+import { Formik, Form, FormikErrors } from 'formik';
+import { NotificationsStart } from 'opensearch-dashboards/public';
+import {
+ EuiFlexGroup,
+ EuiFlexItem,
+ EuiFormRow,
+ EuiFieldText,
+ EuiButton,
+ EuiSpacer,
+ EuiTextArea,
+ EuiComboBox,
+ EuiCodeEditor,
+ EuiButtonGroup,
+ EuiText,
+} from '@elastic/eui';
+import { ContentPanel } from '../../../../components/ContentPanel';
+import { FieldTextArray } from './FieldTextArray';
+import { ruleStatus, ruleTypes } from '../../utils/constants';
+import { AUTHOR_REGEX, validateDescription, validateName } from '../../../../utils/validation';
+import { RuleEditorFormModel } from './RuleEditorFormModel';
+import { FormSubmissionErrorToastNotification } from './FormSubmitionErrorToastNotification';
+import { YamlRuleEditorComponent } from './components/YamlRuleEditorComponent/YamlRuleEditorComponent';
+import { mapFormToRule, mapRuleToForm } from './mappers';
+import { RuleTagsComboBox } from './components/YamlRuleEditorComponent/RuleTagsComboBox';
+
+export interface VisualRuleEditorProps {
+ initialValue: RuleEditorFormModel;
+ notifications?: NotificationsStart;
+ submit: (values: RuleEditorFormModel) => void;
+ cancel: () => void;
+ mode: 'create' | 'edit';
+ title: string;
+}
+
+const editorTypes = [
+ {
+ id: 'visual',
+ label: 'Visual Editor',
+ },
+ {
+ id: 'yaml',
+ label: 'YAML Editor',
+ },
+];
+
+export const RuleEditorForm: React.FC = ({
+ initialValue,
+ notifications,
+ submit,
+ cancel,
+ mode,
+ title,
+}) => {
+ const [selectedEditorType, setSelectedEditorType] = useState('visual');
+
+ const onEditorTypeChange = (optionId: string) => {
+ setSelectedEditorType(optionId);
+ };
+
+ return (
+ {
+ const errors: FormikErrors = {};
+
+ if (!values.name) {
+ errors.name = 'Rule name is required';
+ } else {
+ if (!validateName(values.name)) {
+ errors.name = 'Invalid rule name.';
+ }
+ }
+
+ if (values.description && !validateDescription(values.description)) {
+ errors.description = 'Invalid description.';
+ }
+
+ if (!values.logType) {
+ errors.logType = 'Log type is required';
+ }
+
+ if (!values.detection) {
+ errors.detection = 'Detection is required';
+ }
+
+ if (!values.level) {
+ errors.level = 'Rule level is required';
+ }
+
+ if (!values.author) {
+ errors.author = 'Author name is required';
+ } else {
+ if (!validateName(values.author, AUTHOR_REGEX)) {
+ errors.author = 'Invalid author.';
+ }
+ }
+
+ if (!values.status) {
+ errors.status = 'Rule status is required';
+ }
+
+ return errors;
+ }}
+ onSubmit={(values, { setSubmitting }) => {
+ setSubmitting(false);
+ submit(values);
+ }}
+ >
+ {(props) => (
+
+ )}
+
+ );
+};
diff --git a/public/pages/Rules/components/RuleEditor/RuleEditorFormModel.ts b/public/pages/Rules/components/RuleEditor/RuleEditorFormModel.ts
new file mode 100644
index 000000000..a46a199bb
--- /dev/null
+++ b/public/pages/Rules/components/RuleEditor/RuleEditorFormModel.ts
@@ -0,0 +1,36 @@
+/*
+ * Copyright OpenSearch Contributors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { EuiComboBoxOptionOption } from '@elastic/eui';
+
+export interface RuleEditorFormModel {
+ 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: RuleEditorFormModel = {
+ 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/components/YamlRuleEditorComponent/RuleTagsComboBox.tsx b/public/pages/Rules/components/RuleEditor/components/YamlRuleEditorComponent/RuleTagsComboBox.tsx
new file mode 100644
index 000000000..d0e1e207c
--- /dev/null
+++ b/public/pages/Rules/components/RuleEditor/components/YamlRuleEditorComponent/RuleTagsComboBox.tsx
@@ -0,0 +1,67 @@
+/*
+ * Copyright OpenSearch Contributors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import React, { useState } from 'react';
+import { EuiFormRow, EuiText, EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui';
+
+export interface RuleTagsComboBoxProps {
+ onCreateOption: (
+ searchValue: string,
+ options: EuiComboBoxOptionOption[]
+ ) => boolean | void;
+ onBlur: any;
+ onChange: ((options: EuiComboBoxOptionOption[]) => void) | undefined;
+ selectedOptions: EuiComboBoxOptionOption[];
+}
+
+const STARTS_WITH = 'attack.';
+
+const isValid = (value: string) => {
+ if (value === '') return true;
+ return value.startsWith(STARTS_WITH) && value.length > STARTS_WITH.length;
+};
+
+export const RuleTagsComboBox: React.FC = ({
+ onCreateOption,
+ onBlur,
+ onChange,
+ selectedOptions,
+}) => {
+ const [isCurrentlyTypingValueInvalid, setIsCurrentlyTypingValueInvalid] = useState(false);
+
+ const onSearchChange = (searchValue: string) => {
+ setIsCurrentlyTypingValueInvalid(!isValid(searchValue));
+ };
+
+ return (
+ <>
+
+ Tags
+ - optional
+
+ }
+ isInvalid={isCurrentlyTypingValueInvalid}
+ error={isCurrentlyTypingValueInvalid ? 'Invalid tag' : ''}
+ helpText={`Tags must start with '${STARTS_WITH}'`}
+ >
+
+ isValid(searchValue) && onCreateOption(searchValue, options)
+ }
+ onBlur={onBlur}
+ data-test-subj={'rule_tags_dropdown'}
+ selectedOptions={selectedOptions}
+ isInvalid={isCurrentlyTypingValueInvalid}
+ />
+
+ >
+ );
+};
diff --git a/public/pages/Rules/components/RuleEditor/components/YamlRuleEditorComponent/YamlRuleEditorComponent.test.tsx b/public/pages/Rules/components/RuleEditor/components/YamlRuleEditorComponent/YamlRuleEditorComponent.test.tsx
new file mode 100644
index 000000000..a0f1759b3
--- /dev/null
+++ b/public/pages/Rules/components/RuleEditor/components/YamlRuleEditorComponent/YamlRuleEditorComponent.test.tsx
@@ -0,0 +1,101 @@
+/*
+ * Copyright OpenSearch Contributors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import React from 'react';
+import { render } from '@testing-library/react';
+import { YamlRuleEditorComponent } from './YamlRuleEditorComponent';
+
+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',
+ },
+ ],
+ }}
+ isInvalid={false}
+ >
+ Testing YamlRuleEditor
+
+ );
+ expect(container.firstChild).toMatchSnapshot();
+ });
+
+ it('renders the component - invalid state', () => {
+ 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',
+ },
+ ],
+ }}
+ isInvalid={true}
+ errors={['Validation error message']}
+ >
+ Testing YamlRuleEditor
+
+ );
+ expect(container.firstChild).toMatchSnapshot();
+ });
+});
diff --git a/public/pages/Rules/components/RuleEditor/components/YamlRuleEditorComponent/YamlRuleEditorComponent.tsx b/public/pages/Rules/components/RuleEditor/components/YamlRuleEditorComponent/YamlRuleEditorComponent.tsx
new file mode 100644
index 000000000..e8917122f
--- /dev/null
+++ b/public/pages/Rules/components/RuleEditor/components/YamlRuleEditorComponent/YamlRuleEditorComponent.tsx
@@ -0,0 +1,119 @@
+/*
+ * Copyright OpenSearch Contributors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import React, { useState } from 'react';
+import { load } from 'js-yaml';
+import { EuiFormRow, EuiCodeEditor, EuiLink, EuiSpacer, EuiText, EuiCallOut } from '@elastic/eui';
+import FormFieldHeader from '../../../../../../components/FormFieldHeader';
+import { Rule } from '../../../../../../../models/interfaces';
+import {
+ mapRuleToYamlObject,
+ mapYamlObjectToYamlString,
+ mapYamlObjectToRule,
+} from '../../../../utils/mappers';
+
+export interface YamlRuleEditorComponentProps {
+ rule: Rule;
+ change: React.Dispatch;
+ isInvalid: boolean;
+ errors?: string[];
+}
+
+export interface YamlEditorState {
+ errors: string[] | null;
+ value?: string;
+}
+
+export const YamlRuleEditorComponent: React.FC = ({
+ rule,
+ change,
+ isInvalid,
+ errors,
+}) => {
+ 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);
+
+ change(rule);
+
+ setState((prevState) => ({ ...prevState, errors: null }));
+ } catch (error) {
+ setState((prevState) => ({ ...prevState, errors: ['Invalid YAML'] }));
+
+ console.warn('Security Analytics - Rule Eritor - Yaml load', error);
+ }
+ };
+
+ const renderErrors = () => {
+ if (state.errors && state.errors.length > 0) {
+ return (
+
+
+ {state.errors.map((error, i) => (
+ - {error}
+ ))}
+
+
+ );
+ } else if (isInvalid && errors && errors.length > 0) {
+ return (
+
+
+ {errors.map((error, i) => (
+ - {error}
+ ))}
+
+
+ );
+ } else {
+ return null;
+ }
+ };
+
+ return (
+ <>
+ {renderErrors()}
+
+ } 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/components/YamlRuleEditorComponent/__snapshots__/YamlRuleEditorComponent.test.tsx.snap b/public/pages/Rules/components/RuleEditor/components/YamlRuleEditorComponent/__snapshots__/YamlRuleEditorComponent.test.tsx.snap
new file mode 100644
index 000000000..9f9c27b6e
--- /dev/null
+++ b/public/pages/Rules/components/RuleEditor/components/YamlRuleEditorComponent/__snapshots__/YamlRuleEditorComponent.test.tsx.snap
@@ -0,0 +1,36 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[` spec renders the component - invalid state 1`] = `
+
+
+
+
+
+ -
+ Validation error message
+
+
+
+
+
+`;
+
+exports[` spec renders the component 1`] = `
+
+`;
diff --git a/public/pages/Rules/components/RuleEditor/mappers.ts b/public/pages/Rules/components/RuleEditor/mappers.ts
new file mode 100644
index 000000000..029853367
--- /dev/null
+++ b/public/pages/Rules/components/RuleEditor/mappers.ts
@@ -0,0 +1,48 @@
+/*
+ * Copyright OpenSearch Contributors
+ * SPDX-License-Identifier: Apache-2.0
+ */
+import { Rule } from '../../../../../models/interfaces';
+import { RuleEditorFormModel, ruleEditorStateDefaultValue } from './RuleEditorFormModel';
+
+export const mapFormToRule = (formState: RuleEditorFormModel): Rule => {
+ return {
+ id: formState.id,
+ category: formState.logType,
+ title: formState.name,
+ description: formState.description,
+ status: formState.status,
+ author: formState.author,
+ references: formState.references.map((ref) => ({ value: ref })),
+ tags: formState.tags.map((tag) => ({ value: tag.label })),
+ log_source: formState.log_source,
+ detection: formState.detection,
+ level: formState.level,
+ false_positives: formState.falsePositives.map((falsePositive) => ({
+ value: falsePositive,
+ })),
+ };
+};
+
+export const mapRuleToForm = (rule: Rule): RuleEditorFormModel => {
+ return {
+ id: rule.id,
+ log_source: rule.log_source,
+ logType: rule.category,
+ name: rule.title,
+ description: rule.description,
+ status: rule.status,
+ author: rule.author,
+ references: rule.references
+ ? rule.references.map((ref) => ref.value)
+ : ruleEditorStateDefaultValue.references,
+ tags: rule.tags
+ ? rule.tags.map((tag) => ({ label: tag.value }))
+ : ruleEditorStateDefaultValue.tags,
+ detection: rule.detection,
+ level: rule.level,
+ falsePositives: rule.false_positives
+ ? rule.false_positives.map((falsePositive) => falsePositive.value)
+ : ruleEditorStateDefaultValue.falsePositives,
+ };
+};
diff --git a/public/pages/Rules/containers/CreateRule/CreateRule.tsx b/public/pages/Rules/containers/CreateRule/CreateRule.tsx
index f694a7ee0..a9efe49c9 100644
--- a/public/pages/Rules/containers/CreateRule/CreateRule.tsx
+++ b/public/pages/Rules/containers/CreateRule/CreateRule.tsx
@@ -4,16 +4,13 @@
*/
import { BrowserServices } from '../../../../models/interfaces';
-import { RuleEditor } from '../../components/RuleEditor/RuleEditor';
+import { RuleEditorContainer } from '../../components/RuleEditor/RuleEditorContainer';
import React, { useContext } from 'react';
-import { EuiButton, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { RouteComponentProps } from 'react-router-dom';
-import { BREADCRUMBS, ROUTES } from '../../../../utils/constants';
-import { Rule } from '../../../../../models/interfaces';
+import { BREADCRUMBS } from '../../../../utils/constants';
import { CoreServicesContext } from '../../../../components/core_services';
import { NotificationsStart } from 'opensearch-dashboards/public';
-import { errorNotificationToast } from '../../../../utils/helpers';
-import { setBreadCrumb, validateRule } from '../../utils/helpers';
+import { setBreadCrumb } from '../../utils/helpers';
export interface CreateRuleProps {
services: BrowserServices;
@@ -24,33 +21,14 @@ export interface CreateRuleProps {
export const CreateRule: React.FC = ({ history, services, notifications }) => {
const context = useContext(CoreServicesContext);
setBreadCrumb(BREADCRUMBS.RULES_CREATE, context?.chrome.setBreadcrumbs);
- const footerActions: React.FC<{ rule: Rule }> = ({ rule }) => {
- const onCreate = async () => {
- if (!validateRule(rule, notifications!, 'create')) {
- return;
- }
- const createRuleRes = await services.ruleService.createRule(rule);
- if (!createRuleRes.ok) {
- errorNotificationToast(notifications!, 'create', 'rule', createRuleRes.error);
- } else {
- history.replace(ROUTES.RULES);
- }
- };
-
- return (
-
-
- history.replace(ROUTES.RULES)}>Cancel
-
-
-
- Create
-
-
-
- );
- };
-
- return ;
+ return (
+
+ );
};
diff --git a/public/pages/Rules/containers/DuplicateRule/DuplicateRule.tsx b/public/pages/Rules/containers/DuplicateRule/DuplicateRule.tsx
index 3c389679f..b8a1cddc0 100644
--- a/public/pages/Rules/containers/DuplicateRule/DuplicateRule.tsx
+++ b/public/pages/Rules/containers/DuplicateRule/DuplicateRule.tsx
@@ -4,7 +4,7 @@
*/
import { BrowserServices } from '../../../../models/interfaces';
-import { RuleEditor } from '../../components/RuleEditor/RuleEditor';
+import { RuleEditorContainer } from '../../components/RuleEditor/RuleEditorContainer';
import React, { useContext } from 'react';
import { EuiButton, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { RouteComponentProps } from 'react-router-dom';
@@ -59,11 +59,13 @@ export const DuplicateRule: React.FC = ({
};
return (
-
);
};
diff --git a/public/pages/Rules/containers/EditRule/EditRule.tsx b/public/pages/Rules/containers/EditRule/EditRule.tsx
index 6eb092cba..d946b7e78 100644
--- a/public/pages/Rules/containers/EditRule/EditRule.tsx
+++ b/public/pages/Rules/containers/EditRule/EditRule.tsx
@@ -4,7 +4,7 @@
*/
import { BrowserServices } from '../../../../models/interfaces';
-import { RuleEditor } from '../../components/RuleEditor/RuleEditor';
+import { RuleEditorContainer } from '../../components/RuleEditor/RuleEditorContainer';
import React, { useContext } from 'react';
import { EuiButton, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { RouteComponentProps } from 'react-router-dom';
@@ -30,45 +30,15 @@ export const EditRule: React.FC = ({
}) => {
const context = useContext(CoreServicesContext);
setBreadCrumb(BREADCRUMBS.RULES_EDIT, context?.chrome.setBreadcrumbs);
- const footerActions: React.FC<{ rule: Rule }> = ({ rule }) => {
- const onSave = async () => {
- if (!validateRule(rule, notifications!, 'save')) {
- return;
- }
-
- const editRuleRes = await services.ruleService.updateRule(
- location.state.ruleItem._id,
- rule.category,
- rule
- );
-
- if (!editRuleRes.ok) {
- errorNotificationToast(notifications!, 'save', 'rule', editRuleRes.error);
- } else {
- history.replace(ROUTES.RULES);
- }
- };
-
- return (
-
-
- history.replace(ROUTES.RULES)}>Cancel
-
-
-
- Save changes
-
-
-
- );
- };
return (
-
);
};
diff --git a/public/pages/Rules/containers/ImportRule/ImportRule.tsx b/public/pages/Rules/containers/ImportRule/ImportRule.tsx
index e85cfb4de..eee2a126f 100644
--- a/public/pages/Rules/containers/ImportRule/ImportRule.tsx
+++ b/public/pages/Rules/containers/ImportRule/ImportRule.tsx
@@ -4,7 +4,7 @@
*/
import { BrowserServices } from '../../../../models/interfaces';
-import { RuleEditor } from '../../components/RuleEditor/RuleEditor';
+import { RuleEditorContainer } from '../../components/RuleEditor/RuleEditorContainer';
import React, { useCallback, useContext, useEffect, useState } from 'react';
import { EuiButton, EuiFilePicker, EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui';
import { BREADCRUMBS, ROUTES } from '../../../../utils/constants';
@@ -72,10 +72,12 @@ export const ImportRule: React.FC = ({ history, services, notif
})) || [],
};
setContent(
-
);
diff --git a/public/pages/Rules/utils/constants.ts b/public/pages/Rules/utils/constants.ts
index d4acf1d2d..70c3ead2e 100644
--- a/public/pages/Rules/utils/constants.ts
+++ b/public/pages/Rules/utils/constants.ts
@@ -24,4 +24,4 @@ export const ruleSeverity: { name: string; value: string }[] = [
export const ruleSource: string[] = ['Sigma', 'Custom'];
-export const ruleStatus: string[] = ['Select a rule status', 'experimental', 'test', 'stable'];
+export const ruleStatus: string[] = ['experimental', 'test', 'stable'];
diff --git a/public/pages/Rules/utils/mappers.ts b/public/pages/Rules/utils/mappers.ts
new file mode 100644
index 000000000..9721b66b2
--- /dev/null
+++ b/public/pages/Rules/utils/mappers.ts
@@ -0,0 +1,70 @@
+import { dump, load } from 'js-yaml';
+import { Rule } from '../../../../models/interfaces';
+
+export 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 '';
+ }
+};
+
+export 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;
+};
+
+export 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;
+};
diff --git a/yarn.lock b/yarn.lock
index 604b135a9..29a171b75 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -2166,6 +2166,11 @@ deep-is@~0.1.3:
resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.4.tgz#a6f2dce612fadd2ef1f519b73551f17e85199831"
integrity sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==
+deepmerge@^2.1.1:
+ version "2.2.1"
+ resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-2.2.1.tgz#5d3ff22a01c00f645405a2fbc17d0778a1801170"
+ integrity sha512-R9hc1Xa/NOBi9WRVUWg19rl1UB7Tt4kuPd+thNJgFZoxXsTz7ncaPaeIm+40oSGuP33DfMb4sZt1QIGiJzC4EA==
+
deepmerge@^4.2.2:
version "4.2.2"
resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-4.2.2.tgz#44d2ea3679b8f4d4ffba33f03d865fc1e7bf4955"
@@ -2795,6 +2800,19 @@ form-data@~2.3.2:
combined-stream "^1.0.6"
mime-types "^2.1.12"
+formik@^2.2.6:
+ version "2.2.9"
+ resolved "https://registry.yarnpkg.com/formik/-/formik-2.2.9.tgz#8594ba9c5e2e5cf1f42c5704128e119fc46232d0"
+ integrity sha512-LQLcISMmf1r5at4/gyJigGn0gOwFbeEAlji+N9InZF6LIMXnFNkO42sCI8Jt84YZggpD4cPWObAZaxpEFtSzNA==
+ dependencies:
+ deepmerge "^2.1.1"
+ hoist-non-react-statics "^3.3.0"
+ lodash "^4.17.21"
+ lodash-es "^4.17.21"
+ react-fast-compare "^2.0.1"
+ tiny-warning "^1.0.2"
+ tslib "^1.10.0"
+
fragment-cache@^0.2.1:
version "0.2.1"
resolved "https://registry.yarnpkg.com/fragment-cache/-/fragment-cache-0.2.1.tgz#4290fad27f13e89be7f33799c6bc5a0abfff0d19"
@@ -3055,6 +3073,13 @@ hmac-drbg@^1.0.1:
minimalistic-assert "^1.0.0"
minimalistic-crypto-utils "^1.0.1"
+hoist-non-react-statics@^3.3.0:
+ version "3.3.2"
+ resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz#ece0acaf71d62c2969c2ec59feff42a4b1a85b45"
+ integrity sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==
+ dependencies:
+ react-is "^16.7.0"
+
hosted-git-info@^2.1.4, hosted-git-info@^2.8.9:
version "2.8.9"
resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.8.9.tgz#dffc0bf9a21c02209090f2aa69429e1414daf3f9"
@@ -4163,6 +4188,11 @@ locate-path@^5.0.0:
dependencies:
p-locate "^4.1.0"
+lodash-es@^4.17.21:
+ version "4.17.21"
+ resolved "https://registry.yarnpkg.com/lodash-es/-/lodash-es-4.17.21.tgz#43e626c46e6591b7750beb2b50117390c609e3ee"
+ integrity sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==
+
lodash.once@^4.1.1:
version "4.1.1"
resolved "https://registry.yarnpkg.com/lodash.once/-/lodash.once-4.1.1.tgz#0dd3971213c7c56df880977d504c88fb471a97ac"
@@ -5034,7 +5064,12 @@ randomfill@^1.0.3:
randombytes "^2.0.5"
safe-buffer "^5.1.0"
-react-is@^16.8.4:
+react-fast-compare@^2.0.1:
+ version "2.0.4"
+ resolved "https://registry.yarnpkg.com/react-fast-compare/-/react-fast-compare-2.0.4.tgz#e84b4d455b0fec113e0402c329352715196f81f9"
+ integrity sha512-suNP+J1VU1MWFKcyt7RtjiSWUjvidmQSlqu+eHslq+342xCbGTYmC0mEhPCOHxlW0CywylOC1u2DFAT+bv4dBw==
+
+react-is@^16.7.0, react-is@^16.8.4:
version "16.13.1"
resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4"
integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==
@@ -5800,6 +5835,11 @@ timers-browserify@^2.0.4:
dependencies:
setimmediate "^1.0.4"
+tiny-warning@^1.0.2:
+ version "1.0.3"
+ resolved "https://registry.yarnpkg.com/tiny-warning/-/tiny-warning-1.0.3.tgz#94a30db453df4c643d0fd566060d60a875d84754"
+ integrity sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==
+
tmp@~0.2.1:
version "0.2.1"
resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.2.1.tgz#8457fc3037dcf4719c251367a1af6500ee1ccf14"
@@ -5890,7 +5930,7 @@ ts-loader@^6.2.1:
micromatch "^4.0.0"
semver "^6.0.0"
-tslib@^1.9.0:
+tslib@^1.10.0, tslib@^1.9.0:
version "1.14.1"
resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00"
integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==