From f7dafc84d5afccec78e9ece4e7e048d583153804 Mon Sep 17 00:00:00 2001 From: Amardeepsingh Siglani Date: Tue, 10 Jan 2023 03:06:05 +0530 Subject: [PATCH] Merge failed backports #201, #209, #248, #261 (#287) * resolved conflicts Signed-off-by: Amardeepsingh Siglani * resolved conflicts Signed-off-by: Amardeepsingh Siglani * resolved conflicts Signed-off-by: Amardeepsingh Siglani * resolved conflicts Signed-off-by: Amardeepsingh Siglani * updated latest code from the main branch Signed-off-by: Amardeepsingh Siglani * updated integration test to latest from main Signed-off-by: Amardeepsingh Siglani Signed-off-by: Amardeepsingh Siglani Co-authored-by: Aleksandar Djindjic Co-authored-by: Jovan Cvetkovic Signed-off-by: AWSHurneyt --- cypress.json | 3 +- cypress/integration/1_detectors.spec.js | 105 +++-- cypress/integration/2_rules.spec.js | 357 ++++++++------- cypress/integration/3_alerts.spec.js | 208 ++++----- cypress/integration/4_findings.spec.js | 137 +++--- cypress/integration/5_integrations.spec.js | 26 +- cypress/support/commands.js | 34 +- cypress/support/constants.js | 6 +- cypress/support/index.d.ts | 39 +- package.json | 3 + .../components/UpdateRules/UpdateRules.tsx | 1 - .../RuleContentViewer.test.tsx | 44 ++ .../RuleContentViewer/RuleContentViewer.tsx | 283 +++++++----- .../RuleContentYamlViewer.test.tsx | 51 +++ .../RuleContentYamlViewer.tsx | 24 + .../RuleContentViewer.test.tsx.snap | 341 ++++++++++++++ .../RuleContentYamlViewer.test.tsx.snap | 426 ++++++++++++++++++ .../components/RuleEditor/FieldTextArray.tsx | 6 +- .../FormSubmitionErrorToastNotification.tsx | 33 ++ .../RuleEditor/RuleEditorContainer.tsx | 92 ++++ .../components/RuleEditor/RuleEditorForm.tsx | 423 +++++++++++++++++ .../RuleEditor/RuleEditorFormModel.ts | 36 ++ .../RuleTagsComboBox.tsx | 67 +++ .../YamlRuleEditorComponent.test.tsx | 101 +++++ .../YamlRuleEditorComponent.tsx | 119 +++++ .../YamlRuleEditorComponent.test.tsx.snap | 36 ++ .../Rules/components/RuleEditor/mappers.ts | 48 ++ .../containers/CreateRule/CreateRule.tsx | 46 +- .../DuplicateRule/DuplicateRule.tsx | 10 +- .../Rules/containers/EditRule/EditRule.tsx | 42 +- .../containers/ImportRule/ImportRule.tsx | 10 +- public/pages/Rules/utils/constants.ts | 2 +- public/pages/Rules/utils/mappers.ts | 70 +++ yarn.lock | 44 +- 34 files changed, 2651 insertions(+), 622 deletions(-) create mode 100644 public/pages/Rules/components/RuleContentViewer/RuleContentViewer.test.tsx create mode 100644 public/pages/Rules/components/RuleContentViewer/RuleContentYamlViewer.test.tsx create mode 100644 public/pages/Rules/components/RuleContentViewer/RuleContentYamlViewer.tsx create mode 100644 public/pages/Rules/components/RuleContentViewer/__snapshots__/RuleContentViewer.test.tsx.snap create mode 100644 public/pages/Rules/components/RuleContentViewer/__snapshots__/RuleContentYamlViewer.test.tsx.snap create mode 100644 public/pages/Rules/components/RuleEditor/FormSubmitionErrorToastNotification.tsx create mode 100644 public/pages/Rules/components/RuleEditor/RuleEditorContainer.tsx create mode 100644 public/pages/Rules/components/RuleEditor/RuleEditorForm.tsx create mode 100644 public/pages/Rules/components/RuleEditor/RuleEditorFormModel.ts create mode 100644 public/pages/Rules/components/RuleEditor/components/YamlRuleEditorComponent/RuleTagsComboBox.tsx create mode 100644 public/pages/Rules/components/RuleEditor/components/YamlRuleEditorComponent/YamlRuleEditorComponent.test.tsx create mode 100644 public/pages/Rules/components/RuleEditor/components/YamlRuleEditorComponent/YamlRuleEditorComponent.tsx create mode 100644 public/pages/Rules/components/RuleEditor/components/YamlRuleEditorComponent/__snapshots__/YamlRuleEditorComponent.test.tsx.snap create mode 100644 public/pages/Rules/components/RuleEditor/mappers.ts create mode 100644 public/pages/Rules/utils/mappers.ts diff --git a/cypress.json b/cypress.json index 676ad4cb2..56235296e 100644 --- a/cypress.json +++ b/cypress.json @@ -1,7 +1,8 @@ { "viewportHeight": 900, "viewportWidth": 1440, - "defaultCommandTimeout": 10000, + "defaultCommandTimeout": 20000, + "retries": 1, "env": { "opensearch_url": "localhost:9200", "opensearch_dashboards": "http://localhost:5601", diff --git a/cypress/integration/1_detectors.spec.js b/cypress/integration/1_detectors.spec.js index 65d1027f2..75573f160 100644 --- a/cypress/integration/1_detectors.spec.js +++ b/cypress/integration/1_detectors.spec.js @@ -3,34 +3,31 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { PLUGIN_NAME } from '../support/constants'; +import { OPENSEARCH_DASHBOARDS_URL } from '../support/constants'; import sample_field_mappings from '../fixtures/sample_field_mappings.json'; import sample_index_settings from '../fixtures/sample_index_settings.json'; describe('Detectors', () => { const indexName = 'cypress-test-windows'; + const detectorName = 'test detector'; before(() => { - cy.deleteAllIndices(); + cy.cleanUpTests(); // Create test index cy.createIndex(indexName, sample_index_settings); - cy.contains('test detector').should('not.exist'); + cy.contains(detectorName).should('not.exist'); }); beforeEach(() => { // Visit Detectors page - cy.visit(`${Cypress.env('opensearch_dashboards')}/app/${PLUGIN_NAME}#/detectors`); - - //wait for page to load - cy.wait(7000); + cy.visit(`${OPENSEARCH_DASHBOARDS_URL}/detectors`); // Check that correct page is showing - cy.url().should( - 'eq', - 'http://localhost:5601/app/opensearch_security_analytics_dashboards#/detectors' - ); + cy.waitForPageLoad('detectors', { + contains: 'Threat detectors', + }); }); it('...can be created', () => { @@ -38,19 +35,15 @@ describe('Detectors', () => { cy.contains('Create detector').click({ force: true }); // Check to ensure process started - cy.contains('Define detector'); - cy.url().should( - 'eq', - 'http://localhost:5601/app/opensearch_security_analytics_dashboards#/create-detector' - ); + cy.waitForPageLoad('create-detector', { + contains: 'Define detector', + }); // Enter a name for the detector in the appropriate input cy.get(`input[placeholder="Enter a name for the detector."]`).type('test detector{enter}'); - // Select our pre-seeded data source (cypress-test-windows) - cy.get(`[data-test-subj="define-detector-select-data-source"]`).type( - 'cypress-test-windows{enter}' - ); + // Select our pre-seeded data source (check indexName) + cy.get(`[data-test-subj="define-detector-select-data-source"]`).type(`${indexName}{enter}`); // Select threat detector type (Windows logs) cy.get(`input[id="windows"]`).click({ force: true }); @@ -59,7 +52,7 @@ describe('Detectors', () => { cy.get('[data-test-subj="detection-rules-btn"]').click({ timeout: 5000 }); // find search, type USB - cy.get(`[placeholder="Search..."]`).type('USB Device Plugged').trigger('search'); + cy.triggerSearchField('Search...', 'USB Device Plugged'); // Disable all rules cy.contains('tr', 'USB Device Plugged', { timeout: 20000 }); @@ -121,21 +114,23 @@ describe('Detectors', () => { // Confirm entries user has made cy.contains('Detector details'); - cy.contains('test detector'); + cy.contains(detectorName); cy.contains('windows'); cy.contains(indexName); cy.contains('Alert on test_trigger'); // Create the detector cy.get('button').contains('Create').click({ force: true }); - - cy.wait(10000); + cy.waitForPageLoad('detector-details', { + contains: detectorName, + }); // Confirm detector active - cy.contains('There are no existing detectors.', { timeout: 20000 }).should('not.exist'); - cy.contains('test detector'); + cy.contains(detectorName); cy.contains('Active'); + cy.contains('View Alerts'); cy.contains('View Findings'); + cy.contains('Actions'); cy.contains('Detector configuration'); cy.contains('Field mappings'); cy.contains('Alert triggers'); @@ -146,21 +141,19 @@ describe('Detectors', () => { it('...basic details can be edited', () => { // Click on detector name - cy.contains('test detector').click({ force: true }); - - // Confirm on detector details page - cy.contains('test detector'); + cy.contains(detectorName).click({ force: true }); + cy.waitForPageLoad('detector-details', { + contains: detectorName, + }); // Click "Edit" button in detector details cy.get(`[data-test-subj="edit-detector-basic-details"]`).click({ force: true }); // Confirm arrival at "Edit detector details" page - cy.url().should( - 'include', - 'http://localhost:5601/app/opensearch_security_analytics_dashboards#/edit-detector-details' - ); + cy.waitForPageLoad('edit-detector-details', { + contains: 'Edit detector details', + }); - cy.wait(5000); // Change detector name cy.get(`[data-test-subj="define-detector-detector-name"]`).type('_edited'); @@ -173,18 +166,16 @@ describe('Detectors', () => { ); // Change detector scheduling - cy.get(`[data-test-subj="detector-schedule-number-select"]`).type('0'); + cy.get(`[data-test-subj="detector-schedule-number-select"]`).type('{selectall}10'); cy.get(`[data-test-subj="detector-schedule-unit-select"]`).select('Hours'); - cy.wait(7000); // Save changes to detector details cy.get(`[data-test-subj="save-basic-details-edits"]`).click({ force: true }); // Confirm taken to detector details page - cy.url({ timeout: 20000 }).should( - 'include', - 'http://localhost:5601/app/opensearch_security_analytics_dashboards#/detector-details' - ); + cy.waitForPageLoad('detector-details', { + contains: detectorName, + }); // Verify edits are applied cy.contains('test detector_edited'); @@ -195,13 +186,15 @@ describe('Detectors', () => { it('...rules can be edited', () => { // Ensure start on main detectors page - cy.url().should( - 'eq', - 'http://localhost:5601/app/opensearch_security_analytics_dashboards#/detectors' - ); + cy.waitForPageLoad('detectors', { + contains: 'Threat detectors', + }); // Click on detector name - cy.contains('test detector').click({ force: true }); + cy.contains(detectorName).click({ force: true }); + cy.waitForPageLoad('detector-details', { + contains: detectorName, + }); // Confirm number of rules before edit cy.contains('Active rules (1)'); @@ -216,7 +209,7 @@ describe('Detectors', () => { ); // Search for specific rule - cy.get(`[placeholder="Search..."]`).type('USB Device').trigger('search', { timeout: 5000 }); + cy.triggerSearchField('Search...', 'USB Device'); // Toggle single search result to unchecked cy.contains('tr', 'USB Device Plugged').within(() => { @@ -235,13 +228,12 @@ describe('Detectors', () => { cy.get(`[data-test-subj="edit-detector-rules"]`).click({ force: true }); // Confirm arrival on "Edit detector rules" page - cy.url().should( - 'include', - 'http://localhost:5601/app/opensearch_security_analytics_dashboards#/edit-detector-rules' - ); + cy.waitForPageLoad('edit-detector-rules', { + contains: 'Edit detector rules', + }); // Search for specific rule - cy.get(`[placeholder="Search..."]`).focus().type('USB').trigger('search', { timeout: 5000 }); + cy.triggerSearchField('Search...', 'USB'); // Toggle single search result to checked cy.contains('tr', 'USB Device Plugged').within(() => { @@ -251,6 +243,9 @@ describe('Detectors', () => { // Save changes cy.get(`[data-test-subj="save-detector-rules-edits"]`).click({ force: true }); + cy.waitForPageLoad('detector-details', { + contains: detectorName, + }); // Confirm 1 rule has been added to detector cy.contains('Active rules (1)'); @@ -261,7 +256,9 @@ describe('Detectors', () => { cy.contains('test detector_edited').click({ force: true }); // Confirm page - cy.contains('Detector details'); + cy.waitForPageLoad('detector-details', { + contains: 'Detector details', + }); // Click "Actions" button, the click "Delete" cy.contains('Actions').click({ force: true }); @@ -270,4 +267,6 @@ describe('Detectors', () => { // Confirm detector is deleted cy.contains('There are no existing detectors'); }); + + after(() => cy.cleanUpTests()); }); diff --git a/cypress/integration/2_rules.spec.js b/cypress/integration/2_rules.spec.js index bb97e09ca..eff0cff07 100644 --- a/cypress/integration/2_rules.spec.js +++ b/cypress/integration/2_rules.spec.js @@ -3,12 +3,13 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { PLUGIN_NAME, TWENTY_SECONDS_TIMEOUT } from '../support/constants'; +import { OPENSEARCH_DASHBOARDS_URL } from '../support/constants'; +const uniqueId = Cypress._.random(0, 1e6); const SAMPLE_RULE = { - name: 'Cypress test rule', + name: `Cypress test rule ${uniqueId}`, logType: 'windows', - description: 'This is a rule used to test the rule creation workflow. Not for production use.', + description: 'This is a rule used to test the rule creation workflow.', detection: 'selection:\n Provider_Name: Service Control Manager\nEventID: 7045\nServiceName: ZzNetSvc\n{backspace}{backspace}condition: selection', detectionLine: [ @@ -26,173 +27,211 @@ const SAMPLE_RULE = { status: 'experimental', }; +const YAML_RULE_LINES = [ + `id:`, + `logsource:`, + `product: ${SAMPLE_RULE.logType}`, + `title: ${SAMPLE_RULE.name}`, + `description: ${SAMPLE_RULE.description}`, + `tags:`, + `- ${SAMPLE_RULE.tags[0]}`, + `- ${SAMPLE_RULE.tags[1]}`, + `- ${SAMPLE_RULE.tags[2]}`, + `falsepositives:`, + `- ${SAMPLE_RULE.falsePositive}`, + `level: ${SAMPLE_RULE.severity}`, + `status: ${SAMPLE_RULE.status}`, + `references:`, + `- '${SAMPLE_RULE.references}'`, + `author: ${SAMPLE_RULE.author}`, + `detection:`, + ...SAMPLE_RULE.detection.replaceAll(' ', '').replaceAll('{backspace}', '').split('\n'), +]; + describe('Rules', () => { - before(() => { - // Deleting pre-existing test rules - cy.deleteRule(SAMPLE_RULE.name); - }); + before(() => cy.cleanUpTests()); beforeEach(() => { // Visit Rules page - cy.visit(`${Cypress.env('opensearch_dashboards')}/app/${PLUGIN_NAME}#/rules`); + cy.visit(`${OPENSEARCH_DASHBOARDS_URL}/rules`); + + // Check that correct page is showing + cy.waitForPageLoad('rules', { + contains: 'Rules', + }); }); - describe('Can be created', () => { - it('manually using UI', () => { - // Click "create new rule" button - cy.get('[data-test-subj="create_rule_button"]', TWENTY_SECONDS_TIMEOUT).click({ - force: true, - }); + it('...can be created', () => { + // Click "create new rule" button + cy.get('[data-test-subj="create_rule_button"]').click({ + force: true, + }); + + // Enter the name + cy.get('[data-test-subj="rule_name_field"]').type(SAMPLE_RULE.name); + + // Enter the log type + cy.get('[data-test-subj="rule_type_dropdown"]').type(SAMPLE_RULE.logType); + + // Enter the description + cy.get('[data-test-subj="rule_description_field"]').type(SAMPLE_RULE.description); + + // Enter the severity + cy.get('[data-test-subj="rule_severity_dropdown"]').type(SAMPLE_RULE.severity); + + // Enter the tags + SAMPLE_RULE.tags.forEach((tag) => + cy.get('[data-test-subj="rule_tags_dropdown"]').type(`${tag}{enter}{esc}`) + ); + + // Enter the reference + cy.get('[data-test-subj="rule_references_field_0"]').type(SAMPLE_RULE.references); + + // Enter the false positive cases + cy.get('[data-test-subj="rule_false_positives_field_0"]').type(SAMPLE_RULE.falsePositive); + + // Enter the author + cy.get('[data-test-subj="rule_author_field"]').type(SAMPLE_RULE.author); + + // Enter the log type + cy.get('[data-test-subj="rule_status_dropdown"]').type(SAMPLE_RULE.status); + + // Enter the detection + cy.get('[data-test-subj="rule_detection_field"]').type(SAMPLE_RULE.detection); + + // Switch to YAML editor + cy.get('[data-test-subj="change-editor-type"] label:nth-child(2)').click({ + force: true, + }); + + YAML_RULE_LINES.forEach((line) => cy.get('[data-test-subj="rule_yaml_editor"]').contains(line)); + + cy.intercept({ + url: '/rules', + }).as('getRules'); + + // Click "create" button + cy.get('[data-test-subj="submit_rule_form_button"]').click({ + force: true, + }); + + cy.waitForPageLoad('rules', { + contains: 'Rules', + }); + + cy.wait('@getRules'); - // Enter the name - cy.get('[data-test-subj="rule_name_field"]', TWENTY_SECONDS_TIMEOUT).type(SAMPLE_RULE.name); - - // Enter the log type - cy.get('[data-test-subj="rule_type_dropdown"]', TWENTY_SECONDS_TIMEOUT).select( - SAMPLE_RULE.logType - ); - - // Enter the description - cy.get('[data-test-subj="rule_description_field"]', TWENTY_SECONDS_TIMEOUT).type( - SAMPLE_RULE.description - ); - - // Enter the detection - cy.get('[data-test-subj="rule_detection_field"]', TWENTY_SECONDS_TIMEOUT).type( - SAMPLE_RULE.detection - ); - - // Enter the severity - cy.get('[data-test-subj="rule_severity_dropdown"]', TWENTY_SECONDS_TIMEOUT).select( - SAMPLE_RULE.severity - ); - - // Enter the tags - SAMPLE_RULE.tags.forEach((tag) => - cy - .get('[data-test-subj="rule_tags_dropdown"]', TWENTY_SECONDS_TIMEOUT) - .type(`${tag}{enter}{esc}`) - ); - - // Enter the reference - cy.get('[data-test-subj="rule_references_field_0"]', TWENTY_SECONDS_TIMEOUT).type( - SAMPLE_RULE.references - ); - - // Enter the false positive cases - cy.get('[data-test-subj="rule_false_positive_cases_field_0"]', TWENTY_SECONDS_TIMEOUT).type( - SAMPLE_RULE.falsePositive - ); - - // Enter the author - cy.get('[data-test-subj="rule_author_field"]', TWENTY_SECONDS_TIMEOUT).type( - SAMPLE_RULE.author - ); - - // Enter the log type - cy.get('[data-test-subj="rule_status_dropdown"]', TWENTY_SECONDS_TIMEOUT).select( - SAMPLE_RULE.status - ); - - // Click "create" button - cy.get('[data-test-subj="create_rule_button"]', TWENTY_SECONDS_TIMEOUT).click({ - force: true, + // Search for the rule + cy.triggerSearchField('Search rules', SAMPLE_RULE.name); + + // Click the rule link to open the details flyout + cy.get(`[data-test-subj="rule_link_${SAMPLE_RULE.name}"]`).click({ force: true }); + + // Confirm the flyout contains the expected values + cy.get(`[data-test-subj="rule_flyout_${SAMPLE_RULE.name}"]`) + .click({ force: true }) + .within(() => { + // Validate name + cy.get('[data-test-subj="rule_flyout_rule_name"]').contains(SAMPLE_RULE.name); + + // Validate log type + cy.get('[data-test-subj="rule_flyout_rule_log_type"]').contains(SAMPLE_RULE.logType); + + // Validate description + cy.get('[data-test-subj="rule_flyout_rule_description"]').contains(SAMPLE_RULE.description); + + // Validate author + cy.get('[data-test-subj="rule_flyout_rule_author"]').contains(SAMPLE_RULE.author); + + // Validate source is "custom" + cy.get('[data-test-subj="rule_flyout_rule_source"]').contains('Custom'); + + // Validate severity + cy.get('[data-test-subj="rule_flyout_rule_severity"]').contains(SAMPLE_RULE.severity); + + // Validate tags + SAMPLE_RULE.tags.forEach((tag) => + cy.get('[data-test-subj="rule_flyout_rule_tags"]').contains(tag) + ); + + // Validate references + cy.get('[data-test-subj="rule_flyout_rule_references"]').contains(SAMPLE_RULE.references); + + // Validate false positives + cy.get('[data-test-subj="rule_flyout_rule_false_positives"]').contains( + SAMPLE_RULE.falsePositive + ); + + // Validate status + cy.get('[data-test-subj="rule_flyout_rule_status"]').contains(SAMPLE_RULE.status); + + // Validate detection + SAMPLE_RULE.detectionLine.forEach((line) => + cy.get('[data-test-subj="rule_flyout_rule_detection"]').contains(line) + ); + + cy.get('[data-test-subj="change-editor-type"] label:nth-child(2)').click({ + force: true, + }); + + cy.get('[data-test-subj="rule_flyout_yaml_rule"]') + .get('[class="euiCodeBlock__line"]') + .each((lineElement, lineIndex) => { + if (lineIndex >= YAML_RULE_LINES.length) { + return; + } + let line = lineElement.text().replaceAll('\n', '').trim(); + let expectedLine = YAML_RULE_LINES[lineIndex]; + + // The document ID field is generated when the document is added to the index, + // so this test just checks that the line starts with the ID key. + if (expectedLine.startsWith('id:')) { + expectedLine = 'id:'; + expect(line, `Sigma rule line ${lineIndex}`).to.contain(expectedLine); + } else { + expect(line, `Sigma rule line ${lineIndex}`).to.equal(expectedLine); + } + }); + + // Close the flyout + cy.get('[data-test-subj="close-rule-details-flyout"]').click({ + force: true, + }); }); + }); - // Wait for the page to finish loading - cy.wait(5000); - cy.contains('No items found', TWENTY_SECONDS_TIMEOUT).should('not.exist'); - - // Search for the rule - cy.get(`input[type="search"]`, TWENTY_SECONDS_TIMEOUT).type(`${SAMPLE_RULE.name}{enter}`); - - // Click the rule link to open the details flyout - cy.get(`[data-test-subj="rule_link_${SAMPLE_RULE.name}"]`, TWENTY_SECONDS_TIMEOUT).click(); - - // Confirm the flyout contains the expected values - cy.get(`[data-test-subj="rule_flyout_${SAMPLE_RULE.name}"]`, TWENTY_SECONDS_TIMEOUT) - .click({ force: true }) - .within(() => { - // Validate name - cy.get('[data-test-subj="rule_flyout_rule_name"]', TWENTY_SECONDS_TIMEOUT).contains( - SAMPLE_RULE.name, - TWENTY_SECONDS_TIMEOUT - ); - - // Validate log type - cy.get('[data-test-subj="rule_flyout_rule_log_type"]', TWENTY_SECONDS_TIMEOUT).contains( - SAMPLE_RULE.logType, - TWENTY_SECONDS_TIMEOUT - ); - - // Validate description - cy.get( - '[data-test-subj="rule_flyout_rule_description"]', - TWENTY_SECONDS_TIMEOUT - ).contains(SAMPLE_RULE.description, TWENTY_SECONDS_TIMEOUT); - - // Validate author - cy.get('[data-test-subj="rule_flyout_rule_author"]', TWENTY_SECONDS_TIMEOUT).contains( - SAMPLE_RULE.author, - TWENTY_SECONDS_TIMEOUT - ); - - // Validate source is "custom" - cy.get('[data-test-subj="rule_flyout_rule_source"]', TWENTY_SECONDS_TIMEOUT).contains( - 'Custom', - TWENTY_SECONDS_TIMEOUT - ); - - // Validate severity - cy.get('[data-test-subj="rule_flyout_rule_severity"]', TWENTY_SECONDS_TIMEOUT).contains( - SAMPLE_RULE.severity, - TWENTY_SECONDS_TIMEOUT - ); - - // Validate tags - SAMPLE_RULE.tags.forEach((tag) => - cy - .get('[data-test-subj="rule_flyout_rule_tags"]', TWENTY_SECONDS_TIMEOUT) - .contains(tag, TWENTY_SECONDS_TIMEOUT) - ); - - // Validate references - cy.get('[data-test-subj="rule_flyout_rule_references"]', TWENTY_SECONDS_TIMEOUT).contains( - SAMPLE_RULE.references, - TWENTY_SECONDS_TIMEOUT - ); - - // Validate false positives - cy.get( - '[data-test-subj="rule_flyout_rule_false_positives"]', - TWENTY_SECONDS_TIMEOUT - ).contains(SAMPLE_RULE.falsePositive, TWENTY_SECONDS_TIMEOUT); - - // Validate status - cy.get('[data-test-subj="rule_flyout_rule_status"]', TWENTY_SECONDS_TIMEOUT).contains( - SAMPLE_RULE.status, - TWENTY_SECONDS_TIMEOUT - ); - - // Validate detection - SAMPLE_RULE.detectionLine.forEach((line) => - cy - .get('[data-test-subj="rule_flyout_rule_detection"]', TWENTY_SECONDS_TIMEOUT) - .contains(line, TWENTY_SECONDS_TIMEOUT) - ); - - // Close the flyout - cy.get('[data-test-subj="close-rule-details-flyout"]', TWENTY_SECONDS_TIMEOUT).click({ + it('...can be deleted', () => { + cy.intercept({ + url: '/rules', + }).as('deleteRule'); + + cy.triggerSearchField('Search rules', SAMPLE_RULE.name); + + // Click the rule link to open the details flyout + cy.get(`[data-test-subj="rule_link_${SAMPLE_RULE.name}"]`).click({ force: true }); + + cy.get('.euiButton') + .contains('Action') + .click({ force: true }) + .then(() => { + // Confirm arrival at detectors page + cy.get( + '.euiFlexGroup > :nth-child(3) > .euiButtonEmpty > .euiButtonContent > .euiButtonEmpty__text' + ) + .click({ force: true, + }) + .then(() => { + cy.get('.euiButton').contains('Delete').click(); }); - }); - // Confirm flyout closed - cy.contains(`[data-test-subj="rule_flyout_${SAMPLE_RULE.name}"]`).should('not.exist'); - }); - }); + cy.wait('@deleteRule'); - after(() => { - // Deleting test rules - cy.deleteRule(SAMPLE_RULE.name); + // Search for sample_detector, presumably deleted + cy.triggerSearchField('Search rules', SAMPLE_RULE.name); + // Click the rule link to open the details flyout + cy.get('tbody').contains(SAMPLE_RULE.name).should('not.exist'); + }); }); + + after(() => cy.cleanUpTests()); }); diff --git a/cypress/integration/3_alerts.spec.js b/cypress/integration/3_alerts.spec.js index 71ed9a628..332328c82 100644 --- a/cypress/integration/3_alerts.spec.js +++ b/cypress/integration/3_alerts.spec.js @@ -4,12 +4,7 @@ */ import moment from 'moment'; -import { - PLUGIN_NAME, - NINETY_SECONDS, - TWENTY_SECONDS_TIMEOUT, - FEATURE_SYSTEM_INDICES, -} from '../support/constants'; +import { DETECTOR_TRIGGER_TIMEOUT, OPENSEARCH_DASHBOARDS_URL } from '../support/constants'; import sample_index_settings from '../fixtures/sample_index_settings.json'; import sample_alias_mappings from '../fixtures/sample_alias_mappings.json'; import sample_detector from '../fixtures/sample_detector.json'; @@ -44,20 +39,11 @@ const testDetector = { // but all of the alert time fields should all contain the date in this format. const date = moment(moment.now()).format('MM/DD/YY'); +const docCount = 4; describe('Alerts', () => { before(() => { // Delete any pre-existing test detectors - cy.deleteDetector(testDetectorName) - - // Delete any pre-existing test indices - .then(() => cy.deleteIndex(testIndex)) - - // Delete any pre-existing windows alerts and findings - .then(() => { - cy.deleteIndex(FEATURE_SYSTEM_INDICES.WINDOWS_ALERTS_INDEX); - cy.deleteIndex(FEATURE_SYSTEM_INDICES.WINDOWS_FINDINGS_INDEX); - }) - + cy.cleanUpTests() // Create test index .then(() => cy.createIndex(testIndex, sample_index_settings)) @@ -71,25 +57,42 @@ describe('Alerts', () => { .then(() => { // Go to the detectors table page - cy.visit(`${Cypress.env('opensearch_dashboards')}/app/${PLUGIN_NAME}#/detectors`); + cy.visit(`${OPENSEARCH_DASHBOARDS_URL}/detectors`); + + // Check that correct page is showing + cy.waitForPageLoad('detectors', { + contains: 'Threat detectors', + }); // Filter table to only show the test detector - cy.get(`input[type="search"]`, TWENTY_SECONDS_TIMEOUT).type(`${testDetector.name}{enter}`); + cy.get(`input[type="search"]`).type(`${testDetector.name}{enter}`); // Confirm detector was created - cy.get('tbody > tr', TWENTY_SECONDS_TIMEOUT).should(($tr) => { - expect($tr, '1 row').to.have.length(1); + cy.get('tbody > tr').should(($tr) => { expect($tr, 'detector name').to.contain(testDetector.name); }); }); + + // Ingest documents to the test index + for (let i = 0; i < docCount; i++) { + cy.insertDocumentToIndex(testIndex, '', sample_document); + } + + // Wait for the detector to execute + cy.wait(DETECTOR_TRIGGER_TIMEOUT); }); beforeEach(() => { // Visit Alerts table page - cy.visit(`${Cypress.env('opensearch_dashboards')}/app/${PLUGIN_NAME}#/alerts`); + cy.visit(`${OPENSEARCH_DASHBOARDS_URL}/alerts`); + + // Wait for page to load + cy.waitForPageLoad('alerts', { + contains: 'Security alerts', + }); // Filter table to only show alerts for the test detector - cy.get(`input[type="search"]`, TWENTY_SECONDS_TIMEOUT).type(`${testDetector.name}{enter}`); + cy.get(`input[type="search"]`).type(`${testDetector.name}{enter}`); // Adjust the date range picker to display alerts from today cy.get('[class="euiButtonEmpty__text euiQuickSelectPopover__buttonText"]').click({ @@ -99,37 +102,28 @@ describe('Alerts', () => { }); it('are generated', () => { - // Ingest documents to the test index - const docCount = 4; - for (let i = 0; i < docCount; i++) { - cy.insertDocumentToIndex(testIndex, '', sample_document); - } - - // Wait for the detector to execute - cy.wait(NINETY_SECONDS); - // Refresh the table cy.get('[data-test-subj="superDatePickerApplyTimeButton"]').click({ force: true }); - // Confirm the table contains 1 row - cy.get('tbody > tr', TWENTY_SECONDS_TIMEOUT).should(($tr) => - expect($tr, `${docCount} rows`).to.have.length(docCount) - ); + // Confirm there are alerts created + cy.get('tbody > tr') + .filter(`:contains(${testDetectorAlertCondition})`) + .should('have.length', docCount); }); it('contain expected values in table', () => { - // Confirm each row contains the expected values - cy.get('tbody > tr', TWENTY_SECONDS_TIMEOUT).each(($el, $index) => { - expect($el, `row number ${$index} start time`).to.contain(date); - expect($el, `row number ${$index} trigger name`).to.contain(testDetector.triggers[0].name); - expect($el, `row number ${$index} detector name`).to.contain(testDetector.name); - expect($el, `row number ${$index} status`).to.contain('Active'); - expect($el, `row number ${$index} severity`).to.contain('4 (Low)'); + // Confirm there is a row containing the expected values + cy.get('tbody > tr').should(($tr) => { + expect($tr, 'start time').to.contain(date); + expect($tr, 'trigger name').to.contain(testDetector.triggers[0].name); + expect($tr, 'detector name').to.contain(testDetector.name); + expect($tr, 'status').to.contain('Active'); + expect($tr, 'severity').to.contain('4 (Low)'); }); }); it('contain expected values in alert details flyout', () => { - cy.get('tbody > tr', TWENTY_SECONDS_TIMEOUT) + cy.get('tbody > tr') .first() .within(() => { // Click the "View details" button for the first alert @@ -159,18 +153,16 @@ describe('Alerts', () => { cy.get('[data-test-subj="text-details-group-content-detector"]').contains(testDetector.name); // Wait for the findings table to finish loading - cy.contains('Findings (1)', TWENTY_SECONDS_TIMEOUT); - cy.contains('USB Device Plugged', TWENTY_SECONDS_TIMEOUT); + cy.contains('Findings (1)'); + cy.contains('USB Device Plugged'); // Confirm alert findings contain expected values - cy.get('tbody > tr', TWENTY_SECONDS_TIMEOUT) - .should(($tr) => expect($tr, '1 row').to.have.length(1)) - .each(($el, $index) => { - expect($el, `row number ${$index} timestamp`).to.contain(date); - expect($el, `row number ${$index} rule name`).to.contain('USB Device Plugged'); - expect($el, `row number ${$index} detector name`).to.contain(testDetector.name); - expect($el, `row number ${$index} log type`).to.contain('Windows'); - }); + cy.get('tbody > tr').should(($tr) => { + expect($tr, `timestamp`).to.contain(date); + expect($tr, `rule name`).to.contain('USB Device Plugged'); + expect($tr, `detector name`).to.contain(testDetector.name); + expect($tr, `log type`).to.contain('Windows'); + }); // Close the flyout cy.get('[data-test-subj="alert-details-flyout-close-button"]').click({ force: true }); @@ -182,22 +174,22 @@ describe('Alerts', () => { it('contain expected values in finding details flyout', () => { // Open first alert details flyout - cy.get('tbody > tr', TWENTY_SECONDS_TIMEOUT) + cy.get('tbody > tr') .first() .within(() => { // Click the "View details" button for the first alert cy.get('[aria-label="View details"]').click({ force: true }); }); - cy.get('[data-test-subj="alert-details-flyout"]', TWENTY_SECONDS_TIMEOUT).within(() => { + cy.get('[data-test-subj="alert-details-flyout"]').within(() => { // Wait for findings table to finish loading - cy.contains('USB Device Plugged', TWENTY_SECONDS_TIMEOUT); + cy.contains('USB Device Plugged'); // Click the details button for the first finding - cy.get('tbody > tr', TWENTY_SECONDS_TIMEOUT) + cy.get('tbody > tr') .first() .within(() => { - cy.get('[data-test-subj="finding-details-flyout-button"]', TWENTY_SECONDS_TIMEOUT).click({ + cy.get('[data-test-subj="finding-details-flyout-button"]').click({ force: true, }); }); @@ -289,17 +281,14 @@ describe('Alerts', () => { }); // Press the "back" button - cy.get( - '[data-test-subj="finding-details-flyout-back-button"]', - TWENTY_SECONDS_TIMEOUT - ).click({ force: true }); + cy.get('[data-test-subj="finding-details-flyout-back-button"]').click({ force: true }); }); // Confirm finding details flyout closed cy.get('[data-test-subj="finding-details-flyout"]').should('not.exist'); // Confirm the expected alert details flyout rendered - cy.get('[data-test-subj="alert-details-flyout"]', TWENTY_SECONDS_TIMEOUT).within(() => { + cy.get('[data-test-subj="alert-details-flyout"]').within(() => { cy.get('[data-test-subj="text-details-group-content-alert-trigger-name"]').contains( testDetector.triggers[0].name ); @@ -310,30 +299,23 @@ describe('Alerts', () => { // Confirm the "Acknowledge" button is disabled when no alerts are selected cy.get('[data-test-subj="acknowledge-button"]').should('be.disabled'); - // Confirm all 4 alerts are currently "Active" - cy.get('tbody > tr', TWENTY_SECONDS_TIMEOUT) - .should(($tr) => expect($tr, '4 rows').to.have.length(4)) - .each(($el, $index) => { - expect($el, `row number ${$index} status`).to.contain('Active'); - }); + // Confirm there is alert which is currently "Active" + cy.get('tbody > tr').should(($tr) => { + expect($tr, `status`).to.contain('Active'); + }); // Click the checkboxes for the first and last alerts. - cy.get('tbody > tr', TWENTY_SECONDS_TIMEOUT) + cy.get('tbody > tr') .first() .within(() => { cy.get('[class="euiCheckbox__input"]').click({ force: true }); }); - cy.get('tbody > tr', TWENTY_SECONDS_TIMEOUT) - .last() - .within(() => { - cy.get('[class="euiCheckbox__input"]').click({ force: true }); - }); // Press the "Acknowledge" button cy.get('[data-test-subj="acknowledge-button"]').click({ force: true }); // Wait for acknowledge API to finish executing - cy.contains('Acknowledged', TWENTY_SECONDS_TIMEOUT); + cy.contains('Acknowledged'); // Filter the table to show only "Acknowledged" alerts cy.get('[data-text="Status"]').click({ force: true }); @@ -341,26 +323,23 @@ describe('Alerts', () => { cy.contains('Acknowledged').click({ force: true }); }); - // Confirm there are now 2 "Acknowledged" alerts - cy.get('tbody > tr', TWENTY_SECONDS_TIMEOUT) - .should(($tr) => expect($tr, '2 rows').to.have.length(2)) - .each(($el, $index) => { - expect($el, `row number ${$index} status`).to.contain('Acknowledged'); - }); + // Confirm there is an "Acknowledged" alert + cy.get('tbody > tr').should(($tr) => { + expect($tr, `alert name`).to.contain(testDetectorAlertCondition); + expect($tr, `status`).to.contain('Acknowledged'); + }); // Filter the table to show only "Active" alerts cy.get('[data-text="Status"]'); cy.get('[class="euiFilterSelect__items"]').within(() => { cy.contains('Acknowledged').click({ force: true }); - cy.contains('Active').click({ force: true }); }); // Confirm there are now 2 "Acknowledged" alerts - cy.get('tbody > tr', TWENTY_SECONDS_TIMEOUT) - .should(($tr) => expect($tr, '2 rows').to.have.length(2)) - .each(($el, $index) => { - expect($el, `row number ${$index} status`).to.contain('Active'); - }); + cy.get('tbody > tr') + .filter(`:contains(${testDetectorAlertCondition})`) + .should('contain', 'Active') + .should('contain', 'Acknowledged'); }); it('can be acknowledged via row button', () => { @@ -370,19 +349,20 @@ describe('Alerts', () => { cy.contains('Active').click({ force: true }); }); - // Confirm there are 2 "Active" alerts - cy.get('tbody > tr', TWENTY_SECONDS_TIMEOUT) - .should(($tr) => expect($tr, '2 rows').to.have.length(2)) + cy.get('tbody > tr') + .filter(`:contains(${testDetectorAlertCondition})`) + .should('have.length', 3); + + cy.get('tbody > tr') // Click the "Acknowledge" icon button in the first row .first() .within(() => { cy.get('[aria-label="Acknowledge"]').click({ force: true }); }); - // Confirm there is 1 "Active" alert - cy.get('tbody > tr', TWENTY_SECONDS_TIMEOUT).should(($tr) => - expect($tr, '1 row').to.have.length(1) - ); + cy.get('tbody > tr') + .filter(`:contains(${testDetectorAlertCondition})`) + .should('have.length', 2); // Filter the table to show only "Acknowledged" alerts cy.get('[data-text="Status"]'); @@ -392,9 +372,9 @@ describe('Alerts', () => { }); // Confirm there are now 3 "Acknowledged" alerts - cy.get('tbody > tr', TWENTY_SECONDS_TIMEOUT).should(($tr) => - expect($tr, '3 rows').to.have.length(3) - ); + cy.get('tbody > tr') + .filter(`:contains(${testDetectorAlertCondition})`) + .should('have.length', 2); }); it('can be acknowledged via flyout button', () => { @@ -404,7 +384,7 @@ describe('Alerts', () => { cy.contains('Active').click({ force: true }); }); - cy.get('tbody > tr', TWENTY_SECONDS_TIMEOUT) + cy.get('tbody > tr') .first() .within(() => { // Click the "View details" button for the first alert @@ -419,10 +399,7 @@ describe('Alerts', () => { cy.get('[data-test-subj="alert-details-flyout-acknowledge-button"]').click({ force: true }); // Confirm the alert is now "Acknowledged" - cy.get( - '[data-test-subj="text-details-group-content-alert-status"]', - TWENTY_SECONDS_TIMEOUT - ).contains('Active'); + cy.get('[data-test-subj="text-details-group-content-alert-status"]').contains('Active'); // Confirm the "Acknowledge" button is disabled cy.get('[data-test-subj="alert-details-flyout-acknowledge-button"]').should('be.disabled'); @@ -431,22 +408,22 @@ describe('Alerts', () => { it('detector name hyperlink on finding details flyout redirects to the detector details page', () => { // Open first alert details flyout - cy.get('tbody > tr', TWENTY_SECONDS_TIMEOUT) + cy.get('tbody > tr') .first() .within(() => { // Click the "View details" button for the first alert cy.get('[aria-label="View details"]').click({ force: true }); }); - cy.get('[data-test-subj="alert-details-flyout"]', TWENTY_SECONDS_TIMEOUT).within(() => { + cy.get('[data-test-subj="alert-details-flyout"]').within(() => { // Wait for findings table to finish loading - cy.contains('USB Device Plugged', TWENTY_SECONDS_TIMEOUT); + cy.contains('USB Device Plugged'); // Click the details button for the first finding - cy.get('tbody > tr', TWENTY_SECONDS_TIMEOUT) + cy.get('tbody > tr') .first() .within(() => { - cy.get('[data-test-subj="finding-details-flyout-button"]', TWENTY_SECONDS_TIMEOUT).click({ + cy.get('[data-test-subj="finding-details-flyout-button"]').click({ force: true, }); }); @@ -461,17 +438,8 @@ describe('Alerts', () => { }); // Confirm the detector details page is for the expected detector - cy.get('[data-test-subj="detector-details-detector-name"]', TWENTY_SECONDS_TIMEOUT).contains( - testDetector.name, - TWENTY_SECONDS_TIMEOUT - ); + cy.get('[data-test-subj="detector-details-detector-name"]').contains(testDetector.name); }); - after(() => { - // Clean up test resources - cy.deleteDetector(testDetectorName); - cy.deleteIndex(FEATURE_SYSTEM_INDICES.WINDOWS_ALERTS_INDEX); - cy.deleteIndex(FEATURE_SYSTEM_INDICES.WINDOWS_FINDINGS_INDEX); - cy.deleteIndex(testIndex); - }); + after(() => cy.cleanUpTests()); }); diff --git a/cypress/integration/4_findings.spec.js b/cypress/integration/4_findings.spec.js index 6eedc94f9..cd8025433 100644 --- a/cypress/integration/4_findings.spec.js +++ b/cypress/integration/4_findings.spec.js @@ -3,7 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { PLUGIN_NAME, TWENTY_SECONDS_TIMEOUT } from '../support/constants'; +import { DETECTOR_TRIGGER_TIMEOUT, OPENSEARCH_DASHBOARDS_URL } from '../support/constants'; import sample_document from '../fixtures/sample_document.json'; import sample_index_settings from '../fixtures/sample_index_settings.json'; import sample_field_mappings from '../fixtures/sample_field_mappings.json'; @@ -14,30 +14,34 @@ describe('Findings', () => { const indexName = 'cypress-test-windows'; before(() => { - cy.deleteAllIndices(); + cy.cleanUpTests(); // Visit Findings page - cy.visit(`${Cypress.env('opensearch_dashboards')}/app/${PLUGIN_NAME}#/findings`); + cy.visit(`${OPENSEARCH_DASHBOARDS_URL}/findings`); // create test index, mappings, and detector cy.createIndex(indexName, sample_index_settings); cy.createAliasMappings(indexName, 'windows', sample_field_mappings, true); cy.createDetector(sample_detector); - }); - - it('displays findings based on recently ingested data', () => { - // Confirm arrival at Findings page - cy.url({ timeout: 2000 }).should( - 'include', - 'opensearch_security_analytics_dashboards#/findings' - ); // Ingest a new document cy.ingestDocument(indexName, sample_document); // wait for detector interval to pass - cy.wait(60000); + cy.wait(DETECTOR_TRIGGER_TIMEOUT); + }); + + beforeEach(() => { + // Visit Alerts table page + cy.visit(`${OPENSEARCH_DASHBOARDS_URL}/findings`); + // Wait for page to load + cy.waitForPageLoad('findings', { + contains: 'Findings', + }); + }); + + it('displays findings based on recently ingested data', () => { // Click refresh cy.get('button').contains('Refresh').click({ force: true }); @@ -50,41 +54,45 @@ describe('Findings', () => { cy.contains('Low'); }); - it('displays finding details flyout when user clicks on Finding ID or View details icon', () => { + it('displays finding details flyout when user clicks on View details icon', () => { // filter table to show only sample_detector findings - cy.get(`[placeholder="Search findings"]`).type('sample_detector').trigger('Search'); + cy.triggerSearchField('Search findings', 'sample_detector'); - // Click findingId to trigger Finding details flyout - cy.get(`[data-test-subj="finding-details-flyout-button"]`, { timeout: 2000 }).eq(0).click(); + // Click View details icon + cy.getTableFirstRow('[data-test-subj="view-details-icon"]').then(($el) => { + cy.get($el).click({ force: true }); + }); // Confirm flyout contents cy.contains('Finding details'); cy.contains('Rule details'); // Close Flyout - cy.get(`[data-test-subj="close-finding-details-flyout"]`).then(($el) => { - cy.get($el).click({ force: true }); - }); + cy.get('.euiFlexItem--flexGrowZero > .euiButtonIcon').click({ force: true }); + }); - // wait for icon to become clickable - in this case, timeout insufficient. - cy.wait(1000); + it('displays finding details flyout when user clicks on Finding ID', () => { + // filter table to show only sample_detector findings + cy.triggerSearchField('Search findings', 'sample_detector'); - // Click View details icon - cy.get(`[data-test-subj="view-details-icon"]`).eq(0).click({ force: true }); + // Click findingId to trigger Finding details flyout + cy.getTableFirstRow('[data-test-subj="finding-details-flyout-button"]').then(($el) => { + cy.get($el).click({ force: true }); + }); // Confirm flyout contents cy.contains('Finding details'); cy.contains('Rule details'); // Close Flyout - cy.get(`[data-test-subj="close-finding-details-flyout"]`).then(($el) => { - cy.get($el).click({ force: true }); - }); + cy.get('.euiFlexItem--flexGrowZero > .euiButtonIcon').click({ force: true }); }); it('allows user to view details about rules that were triggered', () => { + // filter table to show only sample_detector findings + cy.triggerSearchField('Search findings', 'sample_detector'); + // open Finding details flyout via finding id link. cy.wait essential, timeout insufficient. - cy.wait(1000); cy.get(`[data-test-subj="view-details-icon"]`).eq(0).click({ force: true }); // open rule details inside flyout @@ -107,49 +115,64 @@ describe('Findings', () => { // see github issue #124 at https://github.com/opensearch-project/security-analytics-dashboards-plugin/issues/124 it('opens rule details flyout when rule name inside accordion drop down is clicked', () => { + // filter table to show only sample_detector findings + cy.triggerSearchField('Search findings', 'sample_detector'); + + // open Finding details flyout via finding id link. cy.wait essential, timeout insufficient. + cy.getTableFirstRow('[data-test-subj="view-details-icon"]').then(($el) => { + cy.get($el).click({ force: true }); + }); + // Click rule link cy.get(`[data-test-subj="finding-details-flyout-USB Device Plugged-details"]`).click({ force: true, }); // Validate flyout appearance - cy.get('[data-test-subj="rule_flyout_USB Device Plugged"]', TWENTY_SECONDS_TIMEOUT).within( - () => { - cy.get('[data-test-subj="rule_flyout_rule_name"]', TWENTY_SECONDS_TIMEOUT).contains( - 'USB Device Plugged', - TWENTY_SECONDS_TIMEOUT - ); - } - ); + cy.get('[data-test-subj="rule_flyout_USB Device Plugged"]').within(() => { + cy.get('[data-test-subj="rule_flyout_rule_name"]').contains('USB Device Plugged'); + }); }); - after(() => { + it('...can delete detector', () => { // Visit Detectors page - cy.visit(`${Cypress.env('opensearch_dashboards')}/app/${PLUGIN_NAME}#/detectors`, { - timeout: 5000, + cy.visit(`${OPENSEARCH_DASHBOARDS_URL}/detectors`); + cy.waitForPageLoad('detectors', { + contains: 'Threat detectors', }); - // Confirm arrival at detectors page - cy.url().should('include', 'opensearch_security_analytics_dashboards#/detectors'); - - // Click on detector to be removed - cy.contains('sample_detector').click({ force: true }, { timeout: 2000 }); - - cy.url().should('include', 'opensearch_security_analytics_dashboards#/detector-details'); + // filter table to show only sample_detector findings + cy.triggerSearchField('Search threat detectors', 'sample_detector'); - // Click "Actions" button, the click "Delete" - cy.get('button') - .contains('Actions') - .click({ force: true }, { timeout: 2000 }) - .then(() => { - // Confirm arrival at detectors page - cy.contains('Delete').click({ force: true }); + // intercept detectors and rules requests + cy.intercept('detectors/_search').as('getDetector'); + cy.intercept('rules/_search?prePackaged=true').as('getPrePackagedRules'); + cy.intercept('rules/_search?prePackaged=false').as('getRules'); - // Search for sample_detector, presumably deleted - cy.get(`[placeholder="Search threat detectors"]`).type('sample_detector').trigger('search'); + // Click on detector to be removed + cy.contains('sample_detector').click({ force: true }); + cy.waitForPageLoad('detector-details', { + contains: sample_detector.name, + }); - // Confirm sample_detector no longer exists - cy.contains('There are no existing detectors.'); - }); + // wait for detector details to load before continuing + cy.wait(['@getDetector', '@getPrePackagedRules', '@getRules']).then(() => { + // Click "Actions" button, the click "Delete" + cy.get('button.euiButton') + .contains('Actions') + .click({ force: true }) + .then(() => { + // Confirm arrival at detectors page + cy.get('[data-test-subj="editButton"]').contains('Delete').click({ force: true }); + + // Search for sample_detector, presumably deleted + cy.triggerSearchField('Search threat detectors', 'sample_detector'); + + // Confirm sample_detector no longer exists + cy.contains('There are no existing detectors.'); + }); + }); }); + + after(() => cy.cleanUpTests()); }); diff --git a/cypress/integration/5_integrations.spec.js b/cypress/integration/5_integrations.spec.js index ee82d4ab9..a38ea3ecb 100644 --- a/cypress/integration/5_integrations.spec.js +++ b/cypress/integration/5_integrations.spec.js @@ -3,7 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { PLUGIN_NAME } from '../support/constants'; +import { DETECTOR_TRIGGER_TIMEOUT, OPENSEARCH_DASHBOARDS_URL } from '../support/constants'; import sample_index_settings from '../fixtures/sample_index_settings.json'; import sample_dns_settings from '../fixtures/integration_tests/index/create_dns_settings.json'; import windows_usb_rule_data from '../fixtures/integration_tests/rule/create_windows_usb_rule.json'; @@ -19,14 +19,8 @@ describe('Integration tests', () => { const indexName = 'cypress-index-windows'; const dnsName = 'cypress-index-dns'; - const cleanUpTests = () => { - cy.deleteAllCustomRules(); - cy.deleteAllDetectors(); - cy.deleteAllIndices(); - }; - before(() => { - cleanUpTests(); + cy.cleanUpTests(); // Create custom rules cy.createRule(windows_usb_rule_data).then((response) => { @@ -66,25 +60,17 @@ describe('Integration tests', () => { cy.request('POST', `${Cypress.env('opensearch')}/${dnsName}/_doc/101`, add_dns_index_data); // Wait for detector interval to pass - cy.wait(60000); + cy.wait(DETECTOR_TRIGGER_TIMEOUT); }); beforeEach(() => { // Visit Detectors page - cy.visit(`${Cypress.env('opensearch_dashboards')}/app/${PLUGIN_NAME}#/detectors`); + cy.visit(`${OPENSEARCH_DASHBOARDS_URL}/detectors`); // Wait for page to load - cy.wait(7000); - - // Check that correct page is showing - cy.url().should( - 'eq', - 'http://localhost:5601/app/opensearch_security_analytics_dashboards#/detectors' - ); + cy.waitForPageLoad('detectors', 'Threat detectors'); }); - after(() => cleanUpTests()); - it('...can navigate to findings page', () => { cy.intercept({ method: 'GET', @@ -151,4 +137,6 @@ describe('Integration tests', () => { }); }); }); + + after(() => cy.cleanUpTests()); }); diff --git a/cypress/support/commands.js b/cypress/support/commands.js index 011ce6aae..cb5c8824d 100644 --- a/cypress/support/commands.js +++ b/cypress/support/commands.js @@ -3,8 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -const { NODE_API } = require('./constants'); - +const { NODE_API, OPENSEARCH_DASHBOARDS, OPENSEARCH_DASHBOARDS_URL } = require('./constants'); // *********************************************** // This example commands.js shows you how to // create various custom commands and overwrite @@ -84,6 +83,33 @@ Cypress.Commands.overwrite('request', (originalFn, ...args) => { return originalFn(Object.assign({}, defaults, options)); }); +Cypress.Commands.add('cleanUpTests', () => { + cy.deleteAllCustomRules(); + cy.deleteAllDetectors(); + cy.deleteAllIndices(); +}); + +Cypress.Commands.add('getTableFirstRow', (selector) => { + if (!selector) return cy.get('tbody > tr').first(); + return cy.get('tbody > tr:first').find(selector); +}); + +Cypress.Commands.add('triggerSearchField', (placeholder, text) => { + cy.get(`[placeholder="${placeholder}"]`).type(`{selectall}${text}`).trigger('search'); +}); + +Cypress.Commands.add('waitForPageLoad', (url, { timeout = 10000, contains = null }) => { + const fullUrl = `${OPENSEARCH_DASHBOARDS_URL}/${url}`; + Cypress.log({ + message: `Wait for url: ${fullUrl} to be loaded.`, + }); + cy.url({ timeout: timeout }) + .should('include', fullUrl) + .then(() => { + contains && cy.contains(contains); + }); +}); + Cypress.Commands.add('deleteAllIndices', () => { cy.request({ method: 'DELETE', @@ -170,9 +196,7 @@ Cypress.Commands.add( Cypress.Commands.add('createRule', (ruleJSON) => { return cy.request({ method: 'POST', - url: `${Cypress.env('opensearch_dashboards')}${NODE_API.RULES_BASE}?category=${ - ruleJSON.category - }`, + url: `${OPENSEARCH_DASHBOARDS}${NODE_API.RULES_BASE}?category=${ruleJSON.category}`, body: JSON.stringify(ruleJSON), }); }); diff --git a/cypress/support/constants.js b/cypress/support/constants.js index dd8156067..a5ca5beb4 100644 --- a/cypress/support/constants.js +++ b/cypress/support/constants.js @@ -5,9 +5,10 @@ import { API } from '../../server/utils/constants'; -export const NINETY_SECONDS = 90000; export const TWENTY_SECONDS_TIMEOUT = { timeout: 20000 }; +export const DETECTOR_TRIGGER_TIMEOUT = 65000; + export const FEATURE_SYSTEM_INDICES = { DETECTORS_INDEX: '.opensearch-detectors-config', DETECTOR_QUERIES_INDEX: '.opensearch-sap-windows-detectors-queries', @@ -23,3 +24,6 @@ export const NODE_API = { ...API, INDEX_TEMPLATE_BASE: '/_index_template', }; + +export const { opensearch_dashboards: OPENSEARCH_DASHBOARDS } = Cypress.env(); +export const OPENSEARCH_DASHBOARDS_URL = `${OPENSEARCH_DASHBOARDS}/app/${PLUGIN_NAME}#`; diff --git a/cypress/support/index.d.ts b/cypress/support/index.d.ts index 5dc4c2a8d..2af6cf0b6 100644 --- a/cypress/support/index.d.ts +++ b/cypress/support/index.d.ts @@ -3,10 +3,20 @@ * SPDX-License-Identifier: Apache-2.0 */ -/// +/// declare namespace Cypress { interface Chainable { + /** + * Wait for page to be loaded + * @param {string} url + * @param {number} timeout + * @example + * cy.waitForPageLoad('detectors') + * cy.waitForPageLoad('detectors', 20000) + */ + waitForPageLoad(url: string, timeout?: number): Chainable; + /** * Deletes all indices in cluster * @example @@ -14,6 +24,33 @@ declare namespace Cypress { */ deleteAllIndices(): Chainable; + /** + * Removes custom rules, detectors and indices + * @example + * cy.cleanUpTests() + */ + cleanUpTests(): Chainable; + + /** + * Returns table first row + * Can find elements deeper in a row with selector + * @param {string} selector + * @example + * cy.getTableFirstRow() + * cy.getTableFirstRow('td') + */ + getTableFirstRow(selector: string): Chainable; + + /** + * Returns table first row + * Can find elements deeper in a row with selector + * @param {string} placeholder + * @param {string} text + * @example + * cy.triggerSearchField('Search rules', 'USB Detection Rule') + */ + triggerSearchField(placeholder: string, text: string): Chainable; + /** * Deletes all custom rules in cluster * @example diff --git a/package.json b/package.json index 9a31bd04f..585b85ce0 100644 --- a/package.json +++ b/package.json @@ -65,5 +65,8 @@ }, "engines": { "yarn": "^1.21.1" + }, + "dependencies": { + "formik": "^2.2.6" } } diff --git a/public/pages/Detectors/components/UpdateRules/UpdateRules.tsx b/public/pages/Detectors/components/UpdateRules/UpdateRules.tsx index bd1f2ecf3..63b7fbb4e 100644 --- a/public/pages/Detectors/components/UpdateRules/UpdateRules.tsx +++ b/public/pages/Detectors/components/UpdateRules/UpdateRules.tsx @@ -217,7 +217,6 @@ export const UpdateDetectorRules: React.FC = (props) = const ruleItems = prePackagedRuleItems.concat(customRuleItems); const onRuleDetails = (ruleItem: RuleItem) => { - console.log('onRuleDetails', ruleItem); setFlyoutData(() => ({ title: ruleItem.name, level: ruleItem.severity, diff --git a/public/pages/Rules/components/RuleContentViewer/RuleContentViewer.test.tsx b/public/pages/Rules/components/RuleContentViewer/RuleContentViewer.test.tsx new file mode 100644 index 000000000..aa5c20055 --- /dev/null +++ b/public/pages/Rules/components/RuleContentViewer/RuleContentViewer.test.tsx @@ -0,0 +1,44 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { render } from '@testing-library/react'; +import { RuleContentViewer } from './RuleContentViewer'; + +describe(' spec', () => { + it('renders the component', () => { + const { container } = render( + + ); + expect(container.firstChild).toMatchSnapshot(); + }); +}); diff --git a/public/pages/Rules/components/RuleContentViewer/RuleContentViewer.tsx b/public/pages/Rules/components/RuleContentViewer/RuleContentViewer.tsx index ca87404df..9c4e9e507 100644 --- a/public/pages/Rules/components/RuleContentViewer/RuleContentViewer.tsx +++ b/public/pages/Rules/components/RuleContentViewer/RuleContentViewer.tsx @@ -14,144 +14,183 @@ import { EuiModalBody, EuiSpacer, EuiText, + EuiButtonGroup, } from '@elastic/eui'; import { DEFAULT_EMPTY_DATA } from '../../../../utils/constants'; -import React from 'react'; +import React, { useState } from 'react'; import { RuleItemInfoBase } from '../../models/types'; +import { RuleContentYamlViewer } from './RuleContentYamlViewer'; export interface RuleContentViewerProps { rule: RuleItemInfoBase; } +const editorTypes = [ + { + id: 'visual', + label: 'Visual', + }, + { + id: 'yaml', + label: 'YAML', + }, +]; + export const RuleContentViewer: React.FC = ({ - rule: { prePackaged, _source: ruleData }, + rule: { prePackaged, _source: ruleData, _id: ruleId }, }) => { + if (!ruleData.id) { + ruleData.id = ruleId; + } + const [selectedEditorType, setSelectedEditorType] = useState('visual'); + + const onEditorTypeChange = (optionId: string) => { + setSelectedEditorType(optionId); + }; + return ( - - - Rule Name - {ruleData.title} - - - Log Type - {ruleData.category} - - - - - - Description - - {ruleData.description || DEFAULT_EMPTY_DATA} - - - - - - Last Updated - {ruleData.last_update_time} - - - Author - {ruleData.author} - - - - - - - - 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} + onEditorTypeChange(id)} + /> + + {selectedEditorType === 'visual' && ( + <> + + + Rule Name + {ruleData.title} - ))} - - ) : ( -
{DEFAULT_EMPTY_DATA}
- )} + + Log Type + {ruleData.category} + +
+ + + + Description + + {ruleData.description || DEFAULT_EMPTY_DATA} + + + + + + Last Updated + {ruleData.last_update_time} + + + Author + {ruleData.author} + + - - - - References - {ruleData.references.length > 0 ? ( - ruleData.references.map((reference: any, i: number) => ( -
- - {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`] = ` +
+
+
+ + This is editor type selector + +
+ + +
+
+
+
+
+ +
+ My Rule +
+
+
+ +
+ dns +
+
+
+
+ +
+ My Rule +
+
+
+
+ + 2022-11-22T23:00:00.000Z +
+
+ + aleksandar +
+
+
+
+
+ + Custom +
+
+
+
+
+ + high +
+
+
+ +
+ - +
+
+
+ +
+ - +
+
+ +
+
+ - +
+
+
+ +
+ stable +
+
+
+
+ +
+
+
+
+            
+              
+                
+                  selection
+                
+                
+                  :
+                
+                
+
+              
+              
+                  
+                
+                  EventID
+                
+                
+                  :
+                
+                 
+                
+                  4800
+                
+                
+
+              
+              
+                
+                
+                  condition
+                
+                
+                  :
+                
+                 selection
+
+              
+              
+                
+              
+            
+          
+
+
+
+
+
+`; 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) => ( +
+ + onEditorTypeChange(id)} + /> + + + {selectedEditorType === 'yaml' && ( + 0} + errors={Object.keys(props.errors).map( + (key) => props.errors[key as keyof RuleEditorFormModel] as string + )} + change={(e) => { + const formState = mapRuleToForm(e); + props.setValues(formState); + }} + > + )} + + {selectedEditorType === 'visual' && ( + <> + + + + Rule name + + } + isInvalid={props.touched.name && !!props.errors?.name} + error={props.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={props.values.name} + /> + + + + + Log type + + } + isInvalid={props.touched.logType && !!props.errors?.logType} + error={props.errors.logType} + > + ({ value: type, label: type }))} + singleSelection={{ asPlainText: true }} + onChange={(e) => { + props.handleChange('logType')(e[0]?.value ? e[0].value : ''); + }} + onBlur={props.handleBlur('logType')} + selectedOptions={ + props.values.logType + ? [{ value: props.values.logType, label: props.values.logType }] + : [] + } + /> + + + + + + + + Description + - optional + + } + helpText="Description must contain 5-500 characters. Valid characters are a-z, A-Z, 0-9, hyphens, spaces, dots, commas, and underscores." + isInvalid={!!props.errors?.description} + error={props.errors.description} + > + { + props.handleChange('description')(e.target.value); + }} + onBlur={props.handleBlur('description')} + value={props.values.description} + /> + + + + + + Detection + + } + isInvalid={props.touched.detection && !!props.errors?.detection} + error={props.errors.detection} + > + { + props.handleChange('detection')(value); + }} + onBlur={props.handleBlur('detection')} + data-test-subj={'rule_detection_field'} + /> + + + + + Rule level + + } + isInvalid={props.touched.level && !!props.errors?.level} + error={props.errors.level} + > + { + props.handleChange('level')(e[0]?.value ? e[0].value : ''); + }} + onBlur={props.handleBlur('level')} + selectedOptions={ + props.values.level + ? [{ value: props.values.level, label: props.values.level }] + : [] + } + /> + + + + + { + const tags = value.map((option) => ({ label: option.label })); + props.setFieldValue('tags', tags); + }} + onCreateOption={(newTag) => { + props.setFieldValue('tags', [...props.values.tags, { label: newTag }]); + }} + onBlur={props.handleBlur('tags')} + /> + + + + References + - optional + + } + addButtonName="Add another URL" + fields={props.values.references} + onFieldAdd={() => { + props.setFieldValue('references', [...props.values.references, '']); + }} + onFieldEdit={(value: string, index: number) => { + props.setFieldValue('references', [ + ...props.values.references.slice(0, index), + value, + ...props.values.references.slice(index + 1), + ]); + }} + onFieldRemove={(index: number) => { + const newRefs = [...props.values.references]; + newRefs.splice(index, 1); + + props.setFieldValue('references', newRefs); + }} + data-test-subj={'rule_references_field'} + /> + + + False positive cases + - optional + + } + addButtonName="Add another case" + fields={props.values.falsePositives} + onFieldAdd={() => { + props.setFieldValue('falsePositives', [...props.values.falsePositives, '']); + }} + onFieldEdit={(value: string, index: number) => { + props.setFieldValue('falsePositives', [ + ...props.values.falsePositives.slice(0, index), + value, + ...props.values.falsePositives.slice(index + 1), + ]); + }} + onFieldRemove={(index: number) => { + const newCases = [...props.values.falsePositives]; + newCases.splice(index, 1); + + props.setFieldValue('falsePositives', newCases); + }} + data-test-subj={'rule_falsePositives_field'} + /> + + + Author + + } + helpText="Author must contain 5-50 characters. Valid characters are a-z, A-Z, 0-9, hyphens, spaces, commas, and underscores." + isInvalid={props.touched.author && !!props.errors?.author} + error={props.errors.author} + > + { + props.handleChange('author')(e); + }} + onBlur={props.handleBlur('author')} + value={props.values.author} + /> + + + + + + Rule Status + + } + isInvalid={props.touched.status && !!props.errors?.status} + error={props.errors.status} + > + ({ value: type, label: type }))} + singleSelection={{ asPlainText: true }} + onChange={(e) => { + props.handleChange('status')(e[0]?.value ? e[0].value : ''); + }} + onBlur={props.handleBlur('status')} + selectedOptions={ + props.values.status + ? [{ value: props.values.status, label: props.values.status }] + : [] + } + /> + + + )} + + + + + + + Cancel + + + props.handleSubmit()} + data-test-subj={'submit_rule_form_button'} + fill + > + {mode === 'create' ? 'Create' : 'Save changes'} + + + + + )} +
+ ); +}; 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`] = ` +
+
+ + Please address the highlighted errors. + +
+
+
+
    +
  • + 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==