/* * Copyright OpenSearch Contributors * SPDX-License-Identifier: Apache-2.0 */ import React, { useCallback, useContext, useEffect, useState } from 'react'; import { Form, Formik, FormikErrors, FormikTouched } from 'formik'; import { ContentPanel } from '../../../components/ContentPanel'; import { DataStore } from '../../../store/DataStore'; import { correlationRuleStateDefaultValue } from './CorrelationRuleFormModel'; import { NotificationsStart } from 'opensearch-dashboards/public'; import { EuiButton, EuiFlexGroup, EuiFlexItem, EuiFormRow, EuiText, EuiComboBox, EuiFieldText, EuiSpacer, EuiTitle, EuiPanel, EuiAccordion, EuiButtonIcon, EuiToolTip, EuiButtonGroup, EuiHorizontalRule, } from '@elastic/eui'; import { ruleTypes } from '../../Rules/utils/constants'; import { CorrelationRuleAction, CorrelationRuleModel, CorrelationRuleQuery, } from '../../../../types'; import { BREADCRUMBS, ROUTES } from '../../../utils/constants'; import { CoreServicesContext } from '../../../components/core_services'; import { RouteComponentProps, useParams } from 'react-router-dom'; import { validateName } from '../../../utils/validation'; import { FieldMappingService, IndexService } from '../../../services'; import { errorNotificationToast } from '../../../utils/helpers'; export interface CreateCorrelationRuleProps { indexService: IndexService; fieldMappingService: FieldMappingService; history: RouteComponentProps< any, any, { rule: CorrelationRuleModel; isReadOnly: boolean } >['history']; notifications: NotificationsStart | null; } export interface CorrelationOptions { label: string; value: string; } export const CreateCorrelationRule: React.FC = ( props: CreateCorrelationRuleProps ) => { const correlationStore = DataStore.correlations; const [indices, setIndices] = useState([]); const [logFields, setLogFields] = useState([]); const validateCorrelationRule = useCallback((rule: CorrelationRuleModel) => { if (!rule.name) { return 'Invalid rule name'; } let error = ''; const invalidQuery = rule.queries.some((query, index) => { const invalidIndex = !query.index; if (invalidIndex) { error = `Invalid index for query ${index + 1}`; return true; } const invalidlogType = !query.logType; if (invalidlogType) { error = `Invalid log type for query ${index + 1}`; return true; } return query.conditions.some((cond) => { const invalid = !cond.name || !cond.value; if (invalid) { error = `Invalid fields for query ${index + 1}`; return true; } return false; }); }); if (invalidQuery) { return error; } return undefined; }, []); const params = useParams<{ ruleId: string }>(); const [initialValues, setInitialValues] = useState({ ...correlationRuleStateDefaultValue, }); const [action, setAction] = useState('Create'); useEffect(() => { if (props.history.location.state?.rule) { setAction('Edit'); setInitialValues(props.history.location.state?.rule); } else if (params.ruleId) { const setInitialRuleValues = async () => { const ruleRes = await correlationStore.getCorrelationRule(params.ruleId); if (ruleRes) { setInitialValues(ruleRes); } }; setAction('Edit'); setInitialRuleValues(); } }, []); const submit = async (values: any) => { let error; if ((error = validateCorrelationRule(values))) { errorNotificationToast(props.notifications, action, 'rule', error); return; } if (action === 'Edit') { await correlationStore.updateCorrelationRule(values); } else { await correlationStore.createCorrelationRule(values); } props.history.push(ROUTES.CORRELATION_RULES); }; const context = useContext(CoreServicesContext); const parseOptions = (indices: string[]) => { return indices.map( (index: string): CorrelationOptions => ({ label: index, value: index, }) ); }; const getIndices = useCallback(async () => { try { const indicesResponse = await props.indexService.getIndices(); if (indicesResponse.ok) { const indicesNames = parseOptions( indicesResponse.response.indices.map((index) => index.index) ); setIndices(indicesNames); } } catch (error: any) {} }, [props.indexService.getIndices]); useEffect(() => { getIndices(); }, [props.indexService]); const getLogFields = useCallback( async (indexName: string) => { if (indexName) { const result = await props.indexService.getIndexFields(indexName); if (result?.ok) { let fields: { label: string; value: string; }[] = result.response?.map((field) => ({ label: field, value: field, })); setLogFields(fields); } } }, [props.fieldMappingService?.getMappingsView] ); const createForm = ( correlationQueries: CorrelationRuleQuery[], touchedInputs: FormikTouched, formikErrors: FormikErrors, props: any ) => { return ( <> {correlationQueries.map((query, queryIdx) => { const isInvalidInputForQuery = (field: 'logType' | 'index'): boolean => { return ( !!touchedInputs.queries?.[queryIdx]?.[field] && !!(formikErrors.queries?.[queryIdx] as FormikErrors)?.[field] ); }; return ( <>

Query {queryIdx + 1}

} extraAction={ correlationQueries.length > 2 ? ( { const newQueries = [...correlationQueries]; newQueries.splice(queryIdx, 1); props.setFieldValue('queries', newQueries); }} /> ) : null } initialIsOpen={true} >

Data source

Select index} isInvalid={isInvalidInputForQuery('index')} error={ (formikErrors.queries?.[queryIdx] as FormikErrors) ?.index } > { props.handleChange(`queries[${queryIdx}].index`)(val); }} onChange={(e) => { props.handleChange(`queries[${queryIdx}].index`)( e[0]?.value ? e[0].value : '' ); getLogFields(e[0]?.value ? e[0].value : ''); }} onBlur={props.handleBlur(`queries[${queryIdx}].index`)} selectedOptions={ query.index ? [{ value: query.index, label: query.index }] : [] } isClearable={true} /> Log type} isInvalid={isInvalidInputForQuery('logType')} error={ (formikErrors.queries?.[queryIdx] as FormikErrors) ?.logType } > ({ value: label.toLowerCase(), label, }))} singleSelection={{ asPlainText: true }} onChange={(e) => { props.handleChange(`queries[${queryIdx}].logType`)( e[0]?.value ? e[0].value : '' ); }} onBlur={props.handleBlur(`queries[${queryIdx}].logType`)} selectedOptions={ query.logType ? [ { value: query.logType, label: ruleTypes.find( (logType) => logType.label.toLowerCase() === query.logType.toLowerCase() )?.label || query.logType, }, ] : [] } isClearable={true} onCreateOption={(e) => { props.handleChange(`queries[${queryIdx}].logType`)(e); }} />

Fields

{query.conditions.map((condition, conditionIdx) => { const fieldNameInput = ( { props.handleChange( `queries[${queryIdx}].conditions[${conditionIdx}].name` )(e[0]?.value ? e[0].value : ''); }} onBlur={props.handleBlur( `queries[${queryIdx}].conditions[${conditionIdx}].name` )} selectedOptions={ condition.name ? [{ value: condition.name, label: condition.name }] : [] } onCreateOption={(e) => { props.handleChange( `queries[${queryIdx}].conditions[${conditionIdx}].name` )(e); }} isClearable={true} /> ); const fieldValueInput = ( { props.handleChange( `queries[${queryIdx}].conditions[${conditionIdx}].value` )(e); }} onBlur={props.handleBlur( `queries[${queryIdx}].conditions[${conditionIdx}].value` )} value={condition.value} /> ); const conditionToggleButtons = [ { id: 'AND', label: 'AND' }, // { id: 'OR', label: 'OR' }, ]; const conditionButtonGroup = ( { props.handleChange( `queries[${queryIdx}].conditions[${conditionIdx}].condition` )(e); }} className={'correlation_rule_field_condition'} /> ); const firstFieldRow = ( Field}> {fieldNameInput} Field value}> {fieldValueInput} ); const fieldRowWithCondition = ( {conditionButtonGroup} {firstFieldRow} ); return ( <> 1 ? ( { const newCases = [...query.conditions]; newCases.splice(conditionIdx, 1); props.setFieldValue( `queries[${queryIdx}].conditions`, newCases ); }} /> ) : null } style={{ maxWidth: '500px' }} > {conditionIdx === 0 ? firstFieldRow : fieldRowWithCondition} ); })} { props.setFieldValue(`queries[${queryIdx}].conditions`, [ ...query.conditions, ...correlationRuleStateDefaultValue.queries[0].conditions, ]); }} iconType={'plusInCircle'} > Add field
); })} { props.setFieldValue('queries', [ ...correlationQueries, { ...correlationRuleStateDefaultValue.queries[0] }, ]); }} iconType={'plusInCircle'} fullWidth={true} > Add query ); }; useEffect(() => { context?.chrome.setBreadcrumbs([ BREADCRUMBS.SECURITY_ANALYTICS, BREADCRUMBS.CORRELATIONS, BREADCRUMBS.CORRELATION_RULES, BREADCRUMBS.CORRELATIONS_RULE_CREATE, ]); }, []); return ( <>

{`${action} correlation rule`}

{action === 'Create' ? 'Create a' : 'Edit'} correlation rule to define threat scenarios of interest between different log sources. { const errors: FormikErrors = {}; if (!values.name) { errors.name = 'Rule name is required'; } else { if (!validateName(values.name)) { errors.name = 'Invalid rule name.'; } } return errors; }} onSubmit={(values, { setSubmitting }) => { setSubmitting(false); submit(values); }} enableReinitialize={true} > {({ values: { name, queries }, touched, errors, ...props }) => { return (
Name } isInvalid={touched.name && !!errors?.name} error={errors.name} helpText={ 'Rule name must contain 5-50 characters. Valid characters are a-z, A-Z, 0-9, hyphens, spaces, and underscores.' } > { props.handleChange('name')(e); }} onBlur={props.handleBlur('name')} value={name} /> {createForm(queries, touched, errors, props)} {action === 'Create' || action === 'Edit' ? ( <> Cancel { props.handleSubmit(); }} fill={true} > {action === 'Edit' ? 'Update' : 'Create '} correlation rule ) : null} ); }}
); };