From 3fb90214d679a8e7f50dbbdb170b95ce2fcbbf81 Mon Sep 17 00:00:00 2001 From: Amardeepsingh Siglani Date: Wed, 14 Feb 2024 20:47:49 +0530 Subject: [PATCH] Add missing backports 2.x (#887) * Make notifications optional (#796) * Make notifications optional 2.11 (#770) * Threat intel feed support for detector creation (#762) * added threat intel feed support for detector creation Signed-off-by: Amardeepsingh Siglani * updated cypress workflow file Signed-off-by: Amardeepsingh Siglani * updated alerts; findings UX Signed-off-by: Amardeepsingh Siglani * refactored alert condition panel; update detector for intel feeds Signed-off-by: Amardeepsingh Siglani * updated snapshots, mocks Signed-off-by: Amardeepsingh Siglani * updated workflow Signed-off-by: Amardeepsingh Siglani * updated tests Signed-off-by: Amardeepsingh Siglani * updated snapshot Signed-off-by: Amardeepsingh Siglani * updated UI; tests Signed-off-by: Amardeepsingh Siglani --------- Signed-off-by: Amardeepsingh Siglani * fixed tests; make notification optional Signed-off-by: Amardeepsingh Siglani --------- Signed-off-by: Amardeepsingh Siglani * revert notification change Signed-off-by: Amardeepsingh Siglani --------- Signed-off-by: Amardeepsingh Siglani * supporting array of key/value under selection (#803) Signed-off-by: Amardeepsingh Siglani * Correlation rules field matching and time window support (#804) * added ux for group by field and time window Signed-off-by: Amardeepsingh Siglani * improved validation Signed-off-by: Amardeepsingh Siglani * check for null values Signed-off-by: Amardeepsingh Siglani * addressed PR comments Signed-off-by: Amardeepsingh Siglani --------- Signed-off-by: Amardeepsingh Siglani * validate fields only when group by enabled; update default window to 5 min (#810) Signed-off-by: Amardeepsingh Siglani * do not send fields if empty (#817) Signed-off-by: Amardeepsingh Siglani * Allow empty field name for supporting timeframe and keywords when validating rule during creation/update (#823) * allow empty field name for supporting timeframe and keywords Signed-off-by: Amardeepsingh Siglani * fixed cypress test Signed-off-by: Amardeepsingh Siglani --------- Signed-off-by: Amardeepsingh Siglani * fixed update payload (#838) Signed-off-by: Amardeepsingh Siglani --------- Signed-off-by: Amardeepsingh Siglani --- cypress/integration/1_detectors.spec.js | 291 ++++----- cypress/integration/2_rules.spec.js | 12 +- .../components/ContentPanel/ContentPanel.tsx | 4 +- .../FormFieldHeader/FormFieldHeader.tsx | 4 +- .../containers/CorrelationRuleFormModel.ts | 3 + .../containers/CreateCorrelationRule.tsx | 571 +++++++++++++----- .../AlertCondition/AlertConditionPanel.tsx | 15 +- .../AlertConditionPanel.test.tsx.snap | 2 - .../containers/ConfigureAlerts.tsx | 4 +- .../RuleEditor/DetectionVisualEditor.tsx | 51 +- public/store/CorrelationsStore.ts | 59 +- types/Correlations.ts | 4 + 12 files changed, 650 insertions(+), 370 deletions(-) diff --git a/cypress/integration/1_detectors.spec.js b/cypress/integration/1_detectors.spec.js index 25a4a4c99..bbf1ecf8d 100644 --- a/cypress/integration/1_detectors.spec.js +++ b/cypress/integration/1_detectors.spec.js @@ -16,7 +16,6 @@ const cypressIndexDns = 'cypress-index-dns'; const cypressIndexWindows = 'cypress-index-windows'; const detectorName = 'test detector'; const cypressLogTypeDns = 'dns'; -const sampleNotificationChannel = 'sample_chime_channel'; const creationFailedMessage = 'Create detector failed.'; const cypressDNSRule = dns_name_rule_data.title; @@ -43,10 +42,6 @@ const logTypeLabel = 'Log type'; const getLogTypeField = () => cy.getFieldByLabel(logTypeLabel); -const notificationLabel = 'Notification channel'; - -const getNotificationField = () => cy.getFieldByLabel(notificationLabel); - const openDetectorDetails = (detectorName) => { cy.getInputByPlaceholder('Search threat detectors').type(`${detectorName}`).pressEnterKey(); cy.getElementByText('.euiTableCellContent button', detectorName).click(); @@ -162,8 +157,6 @@ const createDetector = (detectorName, dataSource, expectFailure) => { .focus() .blur(); - getNotificationField().selectComboboxItem(`[Channel] ${sampleNotificationChannel}`); - cy.intercept('POST', '/_plugins/_security_analytics/mappings').as('createMappingsRequest'); cy.intercept('POST', '/_plugins/_security_analytics/detectors').as('createDetectorRequest'); @@ -227,155 +220,141 @@ describe('Detectors', () => { cy.createRule(dns_name_rule_data); cy.createRule(dns_type_rule_data); - - cy.request('POST', 'http://localhost:9200/_plugins/_notifications/configs/', { - config_id: 'sa_notification-channel_id', - name: sampleNotificationChannel, - config: { - name: sampleNotificationChannel, - description: 'This is a sample chime channel', - config_type: 'chime', - is_enabled: true, - chime: { - url: 'https://sample-chime-webhook', - }, - }, - }).should('have.property', 'status', 200); }); - // describe('...should validate form fields', () => { - // beforeEach(() => { - // cy.intercept('/_plugins/_security_analytics/detectors/_search').as('detectorsSearch'); - - // // Visit Detectors page before any test - // cy.visit(`${OPENSEARCH_DASHBOARDS_URL}/detectors`); - // cy.wait('@detectorsSearch').should('have.property', 'state', 'Complete'); - - // openCreateForm(); - // }); - - // it('...should validate name field', () => { - // getNameField().should('be.empty'); - // getNameField().focus().blur(); - // getNameField().parentsUntil('.euiFormRow__fieldWrapper').siblings().contains('Enter a name.'); - - // getNameField().type('text').focus().blur(); - - // getNameField() - // .parents('.euiFormRow__fieldWrapper') - // .find('.euiFormErrorText') - // .contains( - // 'Name should only consist of upper and lowercase letters, numbers 0-9, hyphens, spaces, and underscores. Use between 5 and 50 characters.' - // ); - - // getNameField().type('{selectall}').type('{backspace}').type('tex&').focus().blur(); - - // getNameField() - // .parents('.euiFormRow__fieldWrapper') - // .find('.euiFormErrorText') - // .contains( - // 'Name should only consist of upper and lowercase letters, numbers 0-9, hyphens, spaces, and underscores. Use between 5 and 50 characters.' - // ); - - // getNameField() - // .type('{selectall}') - // .type('{backspace}') - // .type('Detector name') - // .focus() - // .blur() - // .parents('.euiFormRow__fieldWrapper') - // .find('.euiFormErrorText') - // .should('not.exist'); - // }); - - // it('...should validate description field', () => { - // const longDescriptionText = - // 'This is a long text. This is a long text. This is a long text. This is a long text. This is a long text. This is a long text. This is a long text. This is a long text. This is a long text. This is a long text. This is a long text. This is a long text. This is a long text. This is a long text. This is a long text. This is a long text. This is a long text. This is a long text. This is a long text. This is a long text. This is a long text. This is a long text. This is a long text. This is a long text. This is a long text.'; - - // getDescriptionField().should('be.empty'); - - // getDescriptionField().type(longDescriptionText).focus().blur(); - - // getDescriptionField() - // .parents('.euiFormRow__fieldWrapper') - // .find('.euiFormErrorText') - // .contains( - // 'Description should only consist of upper and lowercase letters, numbers 0-9, commas, hyphens, periods, spaces, and underscores. Max limit of 500 characters.' - // ); - - // getDescriptionField() - // .type('{selectall}') - // .type('{backspace}') - // .type('Detector description...') - // .focus() - // .blur(); - - // getDescriptionField() - // .type('{selectall}') - // .type('{backspace}') - // .type('Detector name') - // .focus() - // .blur() - // .parents('.euiFormRow__fieldWrapper') - // .find('.euiFormErrorText') - // .should('not.exist'); - // }); - - // it('...should validate data source field', () => { - // getDataSourceField() - // .focus() - // .blur() - // .parentsUntil('.euiFormRow__fieldWrapper') - // .siblings() - // .contains('Select an input source.'); - - // getDataSourceField().selectComboboxItem(cypressIndexDns); - // getDataSourceField() - // .focus() - // .blur() - // .parentsUntil('.euiFormRow__fieldWrapper') - // .find('.euiFormErrorText') - // .should('not.exist'); - // }); - - // it('...should validate next button', () => { - // getNextButton().should('be.disabled'); - - // fillDetailsForm(detectorName, cypressIndexDns); - // getNextButton().should('be.enabled'); - // }); - - // it('...should validate alerts page', () => { - // fillDetailsForm(detectorName, cypressIndexDns); - // getNextButton().click({ force: true }); - // // Open the trigger details accordion - // cy.get('[data-test-subj="trigger-details-btn"]').click({ force: true }); - // getTriggerNameField().should('have.value', 'Trigger 1'); - // getTriggerNameField() - // .parents('.euiFormRow__fieldWrapper') - // .find('.euiFormErrorText') - // .should('not.exist'); - - // getTriggerNameField().type('{selectall}').type('{backspace}').focus().blur(); - // getCreateDetectorButton().should('be.disabled'); - - // cy.getButtonByText('Remove').click({ force: true }); - // getCreateDetectorButton().should('be.enabled'); - // }); - - // it('...should show mappings warning', () => { - // fillDetailsForm(detectorName, cypressIndexDns); - - // getDataSourceField().selectComboboxItem(cypressIndexWindows); - // getDataSourceField().focus().blur(); - - // cy.get('[data-test-subj="define-detector-diff-log-types-warning"]') - // .should('be.visible') - // .contains( - // 'To avoid issues with field mappings, we recommend creating separate detectors for different log types.' - // ); - // }); - // }); + describe('...should validate form fields', () => { + beforeEach(() => { + cy.intercept('/_plugins/_security_analytics/detectors/_search').as('detectorsSearch'); + + // Visit Detectors page before any test + cy.visit(`${OPENSEARCH_DASHBOARDS_URL}/detectors`); + cy.wait('@detectorsSearch').should('have.property', 'state', 'Complete'); + + openCreateForm(); + }); + + it('...should validate name field', () => { + getNameField().should('be.empty'); + getNameField().focus().blur(); + getNameField().parentsUntil('.euiFormRow__fieldWrapper').siblings().contains('Enter a name.'); + + getNameField().type('text').focus().blur(); + + getNameField() + .parents('.euiFormRow__fieldWrapper') + .find('.euiFormErrorText') + .contains( + 'Name should only consist of upper and lowercase letters, numbers 0-9, hyphens, spaces, and underscores. Use between 5 and 50 characters.' + ); + + getNameField().type('{selectall}').type('{backspace}').type('tex&').focus().blur(); + + getNameField() + .parents('.euiFormRow__fieldWrapper') + .find('.euiFormErrorText') + .contains( + 'Name should only consist of upper and lowercase letters, numbers 0-9, hyphens, spaces, and underscores. Use between 5 and 50 characters.' + ); + + getNameField() + .type('{selectall}') + .type('{backspace}') + .type('Detector name') + .focus() + .blur() + .parents('.euiFormRow__fieldWrapper') + .find('.euiFormErrorText') + .should('not.exist'); + }); + + it('...should validate description field', () => { + const longDescriptionText = + 'This is a long text. This is a long text. This is a long text. This is a long text. This is a long text. This is a long text. This is a long text. This is a long text. This is a long text. This is a long text. This is a long text. This is a long text. This is a long text. This is a long text. This is a long text. This is a long text. This is a long text. This is a long text. This is a long text. This is a long text. This is a long text. This is a long text. This is a long text. This is a long text. This is a long text.'; + + getDescriptionField().should('be.empty'); + + getDescriptionField().type(longDescriptionText).focus().blur(); + + getDescriptionField() + .parents('.euiFormRow__fieldWrapper') + .find('.euiFormErrorText') + .contains( + 'Description should only consist of upper and lowercase letters, numbers 0-9, commas, hyphens, periods, spaces, and underscores. Max limit of 500 characters.' + ); + + getDescriptionField() + .type('{selectall}') + .type('{backspace}') + .type('Detector description...') + .focus() + .blur(); + + getDescriptionField() + .type('{selectall}') + .type('{backspace}') + .type('Detector name') + .focus() + .blur() + .parents('.euiFormRow__fieldWrapper') + .find('.euiFormErrorText') + .should('not.exist'); + }); + + it('...should validate data source field', () => { + getDataSourceField() + .focus() + .blur() + .parentsUntil('.euiFormRow__fieldWrapper') + .siblings() + .contains('Select an input source.'); + + getDataSourceField().selectComboboxItem(cypressIndexDns); + getDataSourceField() + .focus() + .blur() + .parentsUntil('.euiFormRow__fieldWrapper') + .find('.euiFormErrorText') + .should('not.exist'); + }); + + it('...should validate next button', () => { + getNextButton().should('be.disabled'); + + fillDetailsForm(detectorName, cypressIndexDns); + getNextButton().should('be.enabled'); + }); + + it('...should validate alerts page', () => { + fillDetailsForm(detectorName, cypressIndexDns); + getNextButton().click({ force: true }); + // Open the trigger details accordion + cy.get('[data-test-subj="trigger-details-btn"]').click({ force: true }); + getTriggerNameField().should('have.value', 'Trigger 1'); + getTriggerNameField() + .parents('.euiFormRow__fieldWrapper') + .find('.euiFormErrorText') + .should('not.exist'); + + getTriggerNameField().type('{selectall}').type('{backspace}').focus().blur(); + getCreateDetectorButton().should('be.disabled'); + + cy.getButtonByText('Remove').click({ force: true }); + getCreateDetectorButton().should('be.enabled'); + }); + + it('...should show mappings warning', () => { + fillDetailsForm(detectorName, cypressIndexDns); + + getDataSourceField().selectComboboxItem(cypressIndexWindows); + getDataSourceField().focus().blur(); + + cy.get('[data-test-subj="define-detector-diff-log-types-warning"]') + .should('be.visible') + .contains( + 'To avoid issues with field mappings, we recommend creating separate detectors for different log types.' + ); + }); + }); describe('...validate create detector flow', () => { beforeEach(() => { @@ -521,9 +500,5 @@ describe('Detectors', () => { after(() => { cy.cleanUpTests(); - cy.request( - 'DELETE', - 'http://localhost:9200/_plugins/_notifications/configs/sa_notification-channel_id' - ); }); }); diff --git a/cypress/integration/2_rules.spec.js b/cypress/integration/2_rules.spec.js index 18b18f6ca..3e90ed212 100644 --- a/cypress/integration/2_rules.spec.js +++ b/cypress/integration/2_rules.spec.js @@ -330,13 +330,20 @@ describe('Rules', () => { it('...should validate selection map key field', () => { getSelectionPanelByIndex(0).within(() => { getMapKeyField().should('be.empty'); - getMapKeyField().focus().blur(); + getMapKeyField() + .focus() + .blur() + .parents('.euiFormRow__fieldWrapper') + .find('.euiFormErrorText') + .should('not.exist'); + + getMapKeyField().type('hello@'); getMapKeyField() .parentsUntil('.euiFormRow__fieldWrapper') .siblings() .contains('Invalid key name'); - getMapKeyField().type('FieldKey'); + getMapKeyField().focus().type('{selectall}').type('FieldKey'); getMapKeyField() .focus() .blur() @@ -446,7 +453,6 @@ describe('Rules', () => { getSelectionPanelByIndex(0).within(() => getMapKeyField().type('{selectall}').type('{backspace}') ); - toastShouldExist(); getSelectionPanelByIndex(0).within(() => getMapKeyField().type('FieldKey')); // selection map value field 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..775b07523 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: 300000, 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 221619d13..f6823a7dc 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, @@ -58,6 +63,28 @@ export interface CorrelationOption { options?: CorrelationOption[]; } +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 ) => { @@ -66,48 +93,81 @@ export const CreateCorrelationRule: React.FC = ( const [logFieldsByIndex, setLogFieldsByIndex] = useState<{ [index: string]: CorrelationOption[]; }>({}); - 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) { @@ -133,22 +193,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); @@ -212,9 +288,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] @@ -228,7 +365,7 @@ export const CreateCorrelationRule: React.FC = ( id={`query-${queryIdx}`} buttonContent={ -

Query {queryIdx + 1}

+

Data source {queryIdx + 1}

} extraAction={ @@ -327,107 +464,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} + /> + + + )}
@@ -520,6 +694,26 @@ 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.'; + } + + if (groupByEnabled) { + 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 }) => { @@ -528,7 +722,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/pages/CreateDetector/components/ConfigureAlerts/components/AlertCondition/AlertConditionPanel.tsx b/public/pages/CreateDetector/components/ConfigureAlerts/components/AlertCondition/AlertConditionPanel.tsx index 6b79cc1ab..a14a8e8f1 100644 --- a/public/pages/CreateDetector/components/ConfigureAlerts/components/AlertCondition/AlertConditionPanel.tsx +++ b/public/pages/CreateDetector/components/ConfigureAlerts/components/AlertCondition/AlertConditionPanel.tsx @@ -56,7 +56,6 @@ interface AlertConditionPanelState { showNotificationDetails: boolean; detectionRulesTriggerEnabled: boolean; threatIntelTriggerEnabled: boolean; - notificationError: string; } export default class AlertConditionPanel extends Component< @@ -73,7 +72,6 @@ export default class AlertConditionPanel extends Component< showNotificationDetails: true, detectionRulesTriggerEnabled: props.alertCondition.detection_types.includes('rules'), threatIntelTriggerEnabled: props.alertCondition.detection_types.includes('threat_intel'), - notificationError: '', }; } @@ -214,7 +212,6 @@ export default class AlertConditionPanel extends Component< const actions = alertCondition.actions; if (selectedOptions.length > 0) { actions[0].destination_id = selectedOptions[0].value!; - this.setState({ notificationError: '' }); } else { actions[0].destination_id = ''; } @@ -294,7 +291,6 @@ export default class AlertConditionPanel extends Component< showNotificationDetails, detectionRulesTriggerEnabled, threatIntelTriggerEnabled, - notificationError, } = this.state; const { name, sev_levels: ruleSeverityLevels, tags, severity } = alertCondition; const uniqueTagsOptions = new Set( @@ -540,7 +536,7 @@ export default class AlertConditionPanel extends Component< - + Notification channel

} - isInvalid={!!notificationError} - error={notificationError} > { - this.setState({ - notificationError: selectedNotificationChannelOption.length - ? '' - : 'Notification channel is required', - }); - }} isDisabled={!hasNotificationPlugin} />
diff --git a/public/pages/CreateDetector/components/ConfigureAlerts/components/AlertCondition/__snapshots__/AlertConditionPanel.test.tsx.snap b/public/pages/CreateDetector/components/ConfigureAlerts/components/AlertCondition/__snapshots__/AlertConditionPanel.test.tsx.snap index 159047a20..03b992412 100644 --- a/public/pages/CreateDetector/components/ConfigureAlerts/components/AlertCondition/__snapshots__/AlertConditionPanel.test.tsx.snap +++ b/public/pages/CreateDetector/components/ConfigureAlerts/components/AlertCondition/__snapshots__/AlertConditionPanel.test.tsx.snap @@ -570,7 +570,6 @@ Object { class="euiFormRow__labelWrapper" >