diff --git a/public/components/ContentPanel/ContentPanel.tsx b/public/components/ContentPanel/ContentPanel.tsx index 57871bc65..c92ca6acc 100644 --- a/public/components/ContentPanel/ContentPanel.tsx +++ b/public/components/ContentPanel/ContentPanel.tsx @@ -38,7 +38,7 @@ const renderSubTitleText = (subTitleText: string | JSX.Element): JSX.Element | n return subTitleText; }; -const ContentPanel: React.SFC = ({ +const ContentPanel = ({ title = '', titleSize = 'm', subTitleText = '', @@ -48,7 +48,7 @@ const ContentPanel: React.SFC = ({ children, hideHeaderBorder = false, className = '', -}) => ( +}: ContentPanelProps): JSX.Element => ( = ({ +export const FormFieldHeader = ({ headerTitle = '', optionalField = false, toolTipIconType = 'questionInCircle', toolTipPosition = 'top', toolTipText = '', -}) => { +}: FormFieldHeaderProps): JSX.Element => { return ( {headerTitle} diff --git a/public/pages/Correlations/containers/CorrelationRuleFormModel.ts b/public/pages/Correlations/containers/CorrelationRuleFormModel.ts index f244ae4c2..6bad7ef45 100644 --- a/public/pages/Correlations/containers/CorrelationRuleFormModel.ts +++ b/public/pages/Correlations/containers/CorrelationRuleFormModel.ts @@ -7,6 +7,7 @@ import { CorrelationRuleModel } from '../../../../types'; export const correlationRuleStateDefaultValue: CorrelationRuleModel = { name: '', + time_window: 60000, queries: [ { logType: '', @@ -18,6 +19,7 @@ export const correlationRuleStateDefaultValue: CorrelationRuleModel = { }, ], index: '', + field: '', }, { logType: '', @@ -29,6 +31,7 @@ export const correlationRuleStateDefaultValue: CorrelationRuleModel = { }, ], index: '', + field: '', }, ], }; diff --git a/public/pages/Correlations/containers/CreateCorrelationRule.tsx b/public/pages/Correlations/containers/CreateCorrelationRule.tsx index eaa6dd592..28423331f 100644 --- a/public/pages/Correlations/containers/CreateCorrelationRule.tsx +++ b/public/pages/Correlations/containers/CreateCorrelationRule.tsx @@ -25,10 +25,15 @@ import { EuiButtonIcon, EuiToolTip, EuiButtonGroup, - EuiHorizontalRule, + EuiSelect, + EuiSelectOption, + EuiFieldNumber, + EuiCheckableCard, + htmlIdGenerator, } from '@elastic/eui'; import { ruleTypes } from '../../Rules/utils/constants'; import { + CorrelationRule, CorrelationRuleAction, CorrelationRuleModel, CorrelationRuleQuery, @@ -56,6 +61,28 @@ export interface CorrelationOptions { value: string; } +const parseTime = (time: number) => { + const minutes = Math.floor(time / 60000); + const hours = Math.floor(minutes / 60); + + if (hours > 0 && minutes % 60 === 0) { + return { + interval: hours, + unit: 'HOURS', + }; + } else { + return { + interval: minutes, + unit: 'MINUTES', + }; + } +}; + +const unitOptions: EuiSelectOption[] = [ + { value: 'MINUTES', text: 'Minutes' }, + { value: 'HOURS', text: 'Hours' }, +]; + export const CreateCorrelationRule: React.FC = ( props: CreateCorrelationRuleProps ) => { @@ -64,48 +91,81 @@ export const CreateCorrelationRule: React.FC = ( const [logFieldsByIndex, setLogFieldsByIndex] = useState<{ [index: string]: CorrelationOptions[]; }>({}); - 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 params = useParams<{ ruleId: string }>(); + const [initialValues, setInitialValues] = useState({ + ...correlationRuleStateDefaultValue, + }); + const [action, setAction] = useState('Create'); + const [logTypeOptions, setLogTypeOptions] = useState([]); + const [period, setPeriod] = useState({ interval: 1, unit: 'MINUTES' }); + const [dataFilterEnabled, setDataFilterEnabled] = useState(false); + const [groupByEnabled, setGroupByEnabled] = useState(false); + + const validateCorrelationRule = useCallback( + (rule: CorrelationRuleModel) => { + if (!rule.name) { + return 'Invalid rule name'; } - const invalidlogType = !query.logType; - if (invalidlogType) { - error = `Invalid log type for query ${index + 1}`; - return true; + if ( + Number.isNaN(rule.time_window) || + rule.time_window > 86400000 || + rule.time_window < 60000 + ) { + return 'Invalid time window.'; } - return query.conditions.some((cond) => { - const invalid = !cond.name || !cond.value; - if (invalid) { - error = `Invalid fields for query ${index + 1}`; + 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; + } + + if (!dataFilterEnabled && !groupByEnabled) { + error = 'Select at least one query type'; + return true; + } + + const invalidDataFilter = + dataFilterEnabled && + query.conditions.some((cond) => { + const invalid = !cond.name || !cond.value; + if (invalid) { + error = `Invalid fields for query ${index + 1}`; + return true; + } + + return false; + }); + + if (invalidDataFilter) { + return true; + } + + if (groupByEnabled && rule.queries.some((q) => !q.field)) { + error = 'Select valid field for group by'; return true; } return false; }); - }); - if (invalidQuery) { - return error; - } + if (invalidQuery) { + return error; + } - return undefined; - }, []); - const params = useParams<{ ruleId: string }>(); - const [initialValues, setInitialValues] = useState({ - ...correlationRuleStateDefaultValue, - }); - const [action, setAction] = useState('Create'); - const [logTypeOptions, setLogTypeOptions] = useState([]); + return undefined; + }, + [dataFilterEnabled, groupByEnabled] + ); useEffect(() => { if (props.history.location.state?.rule) { @@ -131,22 +191,38 @@ export const CreateCorrelationRule: React.FC = ( }, []); useEffect(() => { + setPeriod(parseTime(initialValues.time_window)); + setGroupByEnabled(initialValues.queries.some((q) => !!q.field)); + setDataFilterEnabled(initialValues.queries.some((q) => q.conditions.length > 0)); + initialValues.queries.forEach(({ index }) => { updateLogFieldsForIndex(index); }); }, [initialValues]); - const submit = async (values: any) => { + const submit = async (values: CorrelationRuleModel) => { let error; if ((error = validateCorrelationRule(values))) { errorNotificationToast(props.notifications, action, 'rule', error); return; } + if (!dataFilterEnabled) { + values.queries.forEach((query) => { + query.conditions = []; + }); + } + + if (!groupByEnabled) { + values.queries.forEach((query) => { + query.field = ''; + }); + } + if (action === 'Edit') { - await correlationStore.updateCorrelationRule(values); + await correlationStore.updateCorrelationRule(values as CorrelationRule); } else { - await correlationStore.createCorrelationRule(values); + await correlationStore.createCorrelationRule(values as CorrelationRule); } props.history.push(ROUTES.CORRELATION_RULES); @@ -222,9 +298,70 @@ export const CreateCorrelationRule: React.FC = ( ) => { return ( <> + +
Query type
+
+ + + + + +

Data filter

+
+ +

+ A correlation will be created for the matching findings narrowed down with + data filter. +

+
+ + } + checkableType="checkbox" + checked={dataFilterEnabled} + onChange={() => { + setDataFilterEnabled(!dataFilterEnabled); + }} + /> +
+ + + +

Group by field values

+
+ +

+ A correlation will be created when the values for the field values for each + data source match between the findings. +

+
+ + } + checkableType="checkbox" + checked={groupByEnabled} + onChange={() => { + setGroupByEnabled(!groupByEnabled); + }} + /> +
+
+ {!dataFilterEnabled && !groupByEnabled && ( + +

Select at least one query type

+
+ )} + + {correlationQueries.map((query, queryIdx) => { const fieldOptions = logFieldsByIndex[query.index] || []; - const isInvalidInputForQuery = (field: 'logType' | 'index'): boolean => { + const isInvalidInputForQuery = (field: 'logType' | 'index' | 'field'): boolean => { return ( !!touchedInputs.queries?.[queryIdx]?.[field] && !!(formikErrors.queries?.[queryIdx] as FormikErrors)?.[field] @@ -238,7 +375,7 @@ export const CreateCorrelationRule: React.FC = ( id={`query-${queryIdx}`} buttonContent={ -

Query {queryIdx + 1}

+

Data source {queryIdx + 1}

} extraAction={ @@ -334,107 +471,95 @@ export const CreateCorrelationRule: React.FC = ( }} /> - - -

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} - /> - ); + {!dataFilterEnabled && !groupByEnabled && ( + <> + + +

Select at least one query type

+
+ + )} + {dataFilterEnabled && ( + <> + + +

Data filter

+
+ + {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 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' }]; + const conditionButtonGroup = ( + { + props.handleChange( + `queries[${queryIdx}].conditions[${conditionIdx}].condition` + )(e); + }} + className={'correlation_rule_field_condition'} + /> + ); + + const firstFieldRow = ( + + + Field}> + {fieldNameInput} + + + + Field value}> + {fieldValueInput} + + + + _

}> = ( ); }} /> -
- ) : null + + + + ); + + const fieldRowWithCondition = ( + + + {conditionButtonGroup} + + {firstFieldRow} + + ); + + return ( + <> + + {conditionIdx === 0 ? firstFieldRow : fieldRowWithCondition} + + + ); + })} + { + props.setFieldValue(`queries[${queryIdx}].conditions`, [ + ...query.conditions, + ...correlationRuleStateDefaultValue.queries[0].conditions, + ]); + }} + iconType={'plusInCircle'} + > + Add field + + + )} + + {groupByEnabled && ( + <> + + +

Group by field

+
+ + Field} + isInvalid={isInvalidInputForQuery('field')} + error={ + (formikErrors.queries?.[queryIdx] as FormikErrors) + ?.field + } + > + { + props.handleChange(`queries[${queryIdx}].field`)( + e[0]?.value ? e[0].value : '' + ); + }} + onBlur={props.handleBlur(`queries[${queryIdx}].field`)} + selectedOptions={ + query.field ? [{ value: query.field, label: query.field }] : [] } - style={{ maxWidth: '500px' }} - > - - {conditionIdx === 0 ? firstFieldRow : fieldRowWithCondition} - -
- - - ); - })} - { - props.setFieldValue(`queries[${queryIdx}].conditions`, [ - ...query.conditions, - ...correlationRuleStateDefaultValue.queries[0].conditions, - ]); - }} - iconType={'plusInCircle'} - > - Add field - + onCreateOption={(e) => { + props.handleChange(`queries[${queryIdx}].field`)(e); + }} + isClearable={true} + /> + + + )}
@@ -527,6 +701,24 @@ export const CreateCorrelationRule: React.FC = ( } } + if ( + Number.isNaN(values.time_window) || + values.time_window > 86400000 || + values.time_window < 60000 + ) { + errors.time_window = 'Invalid time window.'; + } + + values.queries.forEach((query, idx) => { + if (!query.field) { + if (!errors.queries) { + errors.queries = Array(values.queries.length).fill(null); + } + + (errors.queries as Array<{ field: string }>)[idx] = { field: 'Field is required.' }; + } + }); + return errors; }} onSubmit={(values, { setSubmitting }) => { @@ -535,7 +727,7 @@ export const CreateCorrelationRule: React.FC = ( }} enableReinitialize={true} > - {({ values: { name, queries }, touched, errors, ...props }) => { + {({ values: { name, queries, time_window }, touched, errors, ...props }) => { return (
= ( /> + + + Time window + + +

The period during which the findings are considered correlated.

+
+ + } + isInvalid={!!errors?.time_window} + error={errors.time_window} + helpText={ + 'A valid time window is between 1 minute and 24 hours. Consider keeping time window to the minimum for more accurate correlations.' + } + > + + + { + const newInterval = e.target.valueAsNumber; + const newTimeWindow = + newInterval * (period.unit === 'HOURS' ? 3600000 : 60000); + props.setFieldValue('time_window', newTimeWindow); + setPeriod({ ...period, interval: newInterval }); + }} + data-test-subj={'detector-schedule-number-select'} + required={true} + /> + + + { + const newUnit = e.target.value; + const newTimeWindow = + period.interval * (newUnit === 'HOURS' ? 3600000 : 60000); + props.setFieldValue('time_window', newTimeWindow); + setPeriod({ ...period, unit: newUnit }); + }} + value={period.unit} + data-test-subj={'detector-schedule-unit-select'} + /> + + +
= ( 'Configure two or more queries to set the conditions for correlating findings.' } panelStyles={{ paddingLeft: 10, paddingRight: 10 }} + hideHeaderBorder > {createForm(queries, touched, errors, props)} diff --git a/public/store/CorrelationsStore.ts b/public/store/CorrelationsStore.ts index 86d9b3adf..1cc620a82 100644 --- a/public/store/CorrelationsStore.ts +++ b/public/store/CorrelationsStore.ts @@ -69,6 +69,7 @@ export class CorrelationsStore implements ICorrelationsStore { public async createCorrelationRule(correlationRule: CorrelationRule): Promise { const response = await this.invalidateCache().service.createCorrelationRule({ name: correlationRule.name, + time_window: correlationRule.time_window, correlate: correlationRule.queries?.map((query) => ({ index: query.index, category: query.logType, @@ -76,6 +77,7 @@ export class CorrelationsStore implements ICorrelationsStore { .map((condition) => `${condition.name}:${condition.value}`) // TODO: for the phase one only AND condition is supported, add condition once the correlation engine support is implemented .join(' AND '), + field: query.field, })), }); @@ -92,12 +94,14 @@ export class CorrelationsStore implements ICorrelationsStore { correlationRule.id, { name: correlationRule.name, + time_window: correlationRule.time_window, correlate: correlationRule.queries?.map((query) => ({ index: query.index, category: query.logType, query: query.conditions .map((condition) => `${condition.name}:${condition.value}`) .join(' AND '), + field: query.field, })), } ); @@ -124,6 +128,7 @@ export class CorrelationsStore implements ICorrelationsStore { return { index: queryData.index, logType: queryData.category, + field: queryData.field || '', conditions: this.parseRuleQueryString(queryData.query), }; }); @@ -131,6 +136,7 @@ export class CorrelationsStore implements ICorrelationsStore { return { id: hit._id, name: hit._source.name, + time_window: hit._source.time_window || 300000, queries, }; } @@ -153,6 +159,7 @@ export class CorrelationsStore implements ICorrelationsStore { return { index: queryData.index, logType: queryData.category, + field: queryData.field || '', conditions: this.parseRuleQueryString(queryData.query), }; }); @@ -160,6 +167,7 @@ export class CorrelationsStore implements ICorrelationsStore { return { id: hit._id, name: hit._source.name, + time_window: hit._source.time_window || 300000, queries, }; })); @@ -291,6 +299,9 @@ export class CorrelationsStore implements ICorrelationsStore { private parseRuleQueryString(queryString: string): CorrelationFieldCondition[] { const queries: CorrelationFieldCondition[] = []; + if (!queryString) { + return queries; + } const orConditions = queryString.trim().split(/ OR /gi); orConditions.forEach((cond, conditionIndex) => { diff --git a/types/Correlations.ts b/types/Correlations.ts index 61a94d921..9b93a44a5 100644 --- a/types/Correlations.ts +++ b/types/Correlations.ts @@ -37,6 +37,7 @@ export type CorrelationFinding = { export interface CorrelationRuleQuery { logType: string; index: string; + field: string; conditions: CorrelationFieldCondition[]; } @@ -48,6 +49,7 @@ export interface CorrelationFieldCondition { export interface CorrelationRuleModel { name: string; + time_window: number; // Time in milliseconds queries: CorrelationRuleQuery[]; } @@ -62,11 +64,13 @@ export interface CorrelationRuleTableItem extends CorrelationRule { export interface CorrelationRuleSourceQueries { index: string; query: string; + field: string; category: string; } export interface CorrelationRuleSource { name: string; + time_window: number; correlate: CorrelationRuleSourceQueries[]; }