From 761000793e4bbabc9b1b7e2bf2d337890d3f8002 Mon Sep 17 00:00:00 2001 From: Davis Plumlee <56367316+dplumlee@users.noreply.github.com> Date: Tue, 9 Jan 2024 13:09:40 -0600 Subject: [PATCH] [Security Solution] Adds tests for coverage overview page (#168058) **Resolves: https://github.com/elastic/kibana/issues/162250** ## Summary Adds remaining unit, api integration, and e2e cypress tests for the coverage overview page in accordance to the [existing test plan](https://github.com/elastic/kibana/blob/main/x-pack/plugins/security_solution/docs/testing/test_plans/detection_response/rule_management/coverage_overview_dashboard.md) - [Flaky test runner build](https://buildkite.com/elastic/kibana-flaky-test-suite-runner/builds/4756) ### Checklist Delete any items that are not applicable to this PR. - [x] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md) - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios ### For maintainers - [ ] This was checked for breaking API changes and was [labeled appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process) --- .buildkite/ftr_configs.yml | 2 + .github/CODEOWNERS | 1 + .../coverage_overview_route.test.ts | 121 ++++ .../coverage_overview_dashboard.md | 4 +- .../use_fetch_coverage_overview_query.ts | 9 + .../coverage_overview_dashboard.tsx | 7 +- .../coverage_overview/rule_source_filter.tsx | 2 +- .../coverage_overview/technique_panel.tsx | 2 +- .../pages/coverage_overview/translations.ts | 7 + .../mitre/mitre_tactics_techniques.ts | 134 +++- .../detections/mitre/valid_threat_mock.ts | 2 +- .../extract_tactics_techniques_mitre.js | 64 +- .../basic/tests/coverage_overview.ts | 510 ------------- .../basic/tests/index.ts | 1 - .../utils/create_non_security_rule.ts | 42 -- .../utils/index.ts | 1 - .../package.json | 6 + .../rule_management/configs/ess.config.ts | 22 + .../configs/serverless.config.ts | 15 + .../rule_management/coverage_overview.ts | 670 ++++++++++++++++++ .../rule_management/index.ts | 13 + .../utils/rules/create_non_security_rule.ts | 1 + .../utils/rules/get_coverage_overview.ts | 28 + .../coverage_overview/coverage_overview.cy.ts | 439 ++++++++++++ .../cypress/objects/rule.ts | 42 +- .../screens/rules_coverage_overview.ts | 39 + .../cypress/tasks/rules_coverage_overview.ts | 51 ++ .../cypress/urls/rules_management.ts | 1 + 28 files changed, 1626 insertions(+), 610 deletions(-) create mode 100644 x-pack/plugins/security_solution/common/api/detection_engine/rule_management/coverage_overview/coverage_overview_route.test.ts delete mode 100644 x-pack/test/detection_engine_api_integration/basic/tests/coverage_overview.ts delete mode 100644 x-pack/test/detection_engine_api_integration/utils/create_non_security_rule.ts create mode 100644 x-pack/test/security_solution_api_integration/test_suites/detections_response/basic_essentials_license/rule_management/configs/ess.config.ts create mode 100644 x-pack/test/security_solution_api_integration/test_suites/detections_response/basic_essentials_license/rule_management/configs/serverless.config.ts create mode 100644 x-pack/test/security_solution_api_integration/test_suites/detections_response/basic_essentials_license/rule_management/coverage_overview.ts create mode 100644 x-pack/test/security_solution_api_integration/test_suites/detections_response/basic_essentials_license/rule_management/index.ts create mode 100644 x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/rules/get_coverage_overview.ts create mode 100644 x-pack/test/security_solution_cypress/cypress/e2e/detection_response/rule_management/coverage_overview/coverage_overview.cy.ts create mode 100644 x-pack/test/security_solution_cypress/cypress/screens/rules_coverage_overview.ts create mode 100644 x-pack/test/security_solution_cypress/cypress/tasks/rules_coverage_overview.ts diff --git a/.buildkite/ftr_configs.yml b/.buildkite/ftr_configs.yml index cd0c62dd4c5e6..17a357c71640b 100644 --- a/.buildkite/ftr_configs.yml +++ b/.buildkite/ftr_configs.yml @@ -496,6 +496,8 @@ enabled: - x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/telemetry/configs/ess.config.ts - x-pack/test/security_solution_api_integration/test_suites/detections_response/basic_essentials_license/detection_engine/configs/serverless.config.ts - x-pack/test/security_solution_api_integration/test_suites/detections_response/basic_essentials_license/detection_engine/configs/ess.config.ts + - x-pack/test/security_solution_api_integration/test_suites/detections_response/basic_essentials_license/rule_management/configs/serverless.config.ts + - x-pack/test/security_solution_api_integration/test_suites/detections_response/basic_essentials_license/rule_management/configs/ess.config.ts - x-pack/test/security_solution_api_integration/test_suites/lists_and_exception_lists/default_license/exception_lists_items/configs/serverless.config.ts - x-pack/test/security_solution_api_integration/test_suites/lists_and_exception_lists/default_license/exception_lists_items/configs/ess.config.ts - x-pack/test/security_solution_api_integration/test_suites/lists_and_exception_lists/default_license/lists_items/configs/serverless.config.ts diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 53c1c8ec207a5..5c2145cac690b 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1382,6 +1382,7 @@ x-pack/test/security_solution_cypress/cypress/tasks/expandable_flyout @elastic/ /x-pack/plugins/security_solution/docs/testing/test_plans/detection_response/prebuilt_rules @elastic/security-detection-rule-management /x-pack/plugins/security_solution/docs/testing/test_plans/detection_response/rule_management @elastic/security-detection-rule-management /x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/prebuilt_rules @elastic/security-detection-rule-management +/x-pack/test/security_solution_api_integration/test_suites/detections_response/basic_essentials_license/rule_management @elastic/security-detection-rule-management /x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_delete @elastic/security-detection-rule-management x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_update @elastic/security-detection-rule-management /x-pack/test/security_solution_api_integration/test_suites/detections_response/default_license/rule_patch @elastic/security-detection-rule-management diff --git a/x-pack/plugins/security_solution/common/api/detection_engine/rule_management/coverage_overview/coverage_overview_route.test.ts b/x-pack/plugins/security_solution/common/api/detection_engine/rule_management/coverage_overview/coverage_overview_route.test.ts new file mode 100644 index 0000000000000..dd8b2501b90a7 --- /dev/null +++ b/x-pack/plugins/security_solution/common/api/detection_engine/rule_management/coverage_overview/coverage_overview_route.test.ts @@ -0,0 +1,121 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { left } from 'fp-ts/lib/Either'; +import { exactCheck, foldLeftRight, getPaths } from '@kbn/securitysolution-io-ts-utils'; +import { + CoverageOverviewRequestBody, + CoverageOverviewRuleActivity, + CoverageOverviewRuleSource, +} from './coverage_overview_route'; + +describe('Coverage overview request schema', () => { + test('empty object validates', () => { + const payload: CoverageOverviewRequestBody = {}; + + const decoded = CoverageOverviewRequestBody.decode(payload); + const checked = exactCheck(payload, decoded); + const message = foldLeftRight(checked); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('validates with all fields populated', () => { + const payload: CoverageOverviewRequestBody = { + filter: { + activity: [CoverageOverviewRuleActivity.Enabled, CoverageOverviewRuleActivity.Disabled], + source: [CoverageOverviewRuleSource.Custom, CoverageOverviewRuleSource.Prebuilt], + search_term: 'search term', + }, + }; + + const decoded = CoverageOverviewRequestBody.decode(payload); + const checked = exactCheck(payload, decoded); + const message = foldLeftRight(checked); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('does NOT validate with extra fields', () => { + const payload: CoverageOverviewRequestBody & { invalid_field: string } = { + filter: { + activity: [CoverageOverviewRuleActivity.Enabled, CoverageOverviewRuleActivity.Disabled], + source: [CoverageOverviewRuleSource.Custom, CoverageOverviewRuleSource.Prebuilt], + search_term: 'search term', + }, + invalid_field: 'invalid field', + }; + + const decoded = CoverageOverviewRequestBody.decode(payload); + const checked = exactCheck(payload, decoded); + const message = foldLeftRight(checked); + + expect(getPaths(left(message.errors))).toEqual(['invalid keys "invalid_field"']); + expect(message.schema).toEqual({}); + }); + + test('does NOT validate with invalid filter values', () => { + const payload: CoverageOverviewRequestBody = { + filter: { + // @ts-expect-error + activity: ['Wrong activity field'], + // @ts-expect-error + source: ['Wrong source field'], + search_term: 'search term', + }, + }; + + const decoded = CoverageOverviewRequestBody.decode(payload); + const checked = exactCheck(payload, decoded); + const message = foldLeftRight(checked); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "Wrong activity field" supplied to "filter,activity"', + 'Invalid value "Wrong source field" supplied to "filter,source"', + ]); + expect(message.schema).toEqual({}); + }); + + test('does NOT validate with empty filter arrays', () => { + const payload: CoverageOverviewRequestBody = { + filter: { + activity: [], + source: [], + search_term: 'search term', + }, + }; + + const decoded = CoverageOverviewRequestBody.decode(payload); + const checked = exactCheck(payload, decoded); + const message = foldLeftRight(checked); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "[]" supplied to "filter,activity"', + 'Invalid value "[]" supplied to "filter,source"', + ]); + expect(message.schema).toEqual({}); + }); + + test('does NOT validate with empty search_term', () => { + const payload: CoverageOverviewRequestBody = { + filter: { + search_term: '', + }, + }; + + const decoded = CoverageOverviewRequestBody.decode(payload); + const checked = exactCheck(payload, decoded); + const message = foldLeftRight(checked); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "" supplied to "filter,search_term"', + ]); + expect(message.schema).toEqual({}); + }); +}); diff --git a/x-pack/plugins/security_solution/docs/testing/test_plans/detection_response/rule_management/coverage_overview_dashboard.md b/x-pack/plugins/security_solution/docs/testing/test_plans/detection_response/rule_management/coverage_overview_dashboard.md index 08f23aceda9b9..86170a9e4c405 100644 --- a/x-pack/plugins/security_solution/docs/testing/test_plans/detection_response/rule_management/coverage_overview_dashboard.md +++ b/x-pack/plugins/security_solution/docs/testing/test_plans/detection_response/rule_management/coverage_overview_dashboard.md @@ -24,9 +24,9 @@ Status: `in progress`. The current test plan matches `Milestone 1 - MVP` of the - **Rule Source**: The filter type defining rule type, current options are `prebuilt`(from elastic prebuilt rules package) and `custom`(created by user) --**Initial filter state**: The filters present on initial page load. Rule activity will be set to `enabled`, rule source will be set to `prebuilt` and `custom` simultaneously. +- **Initial filter state**: The filters present on initial page load. Rule activity will be set to `enabled`, rule source will be set to `prebuilt` and `custom` simultaneously. --**Dashboard containing the rule data**: The normal render of the coverage overview dashboard. Any returned rule data mapped correctly to the tile layout of all the MITRE data in a colored grid +- **Dashboard containing the rule data**: The normal render of the coverage overview dashboard. Any returned rule data mapped correctly to the tile layout of all the MITRE data in a colored grid ### Assumptions diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/hooks/use_fetch_coverage_overview_query.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/hooks/use_fetch_coverage_overview_query.ts index 2866245bb4e87..7f27e08164fe3 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/hooks/use_fetch_coverage_overview_query.ts +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/hooks/use_fetch_coverage_overview_query.ts @@ -8,12 +8,14 @@ import type { UseQueryOptions } from '@tanstack/react-query'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useCallback } from 'react'; +import * as i18n from '../../../rule_management_ui/pages/coverage_overview/translations'; import type { CoverageOverviewFilter } from '../../../../../common/api/detection_engine'; import { RULE_MANAGEMENT_COVERAGE_OVERVIEW_URL } from '../../../../../common/api/detection_engine'; import { fetchCoverageOverview } from '../api'; import { buildCoverageOverviewDashboardModel } from '../../logic/coverage_overview/build_coverage_overview_dashboard_model'; import type { CoverageOverviewDashboard } from '../../model/coverage_overview/dashboard'; import { DEFAULT_QUERY_OPTIONS } from './constants'; +import { useAppToasts } from '../../../../common/hooks/use_app_toasts'; const COVERAGE_OVERVIEW_QUERY_KEY = ['POST', RULE_MANAGEMENT_COVERAGE_OVERVIEW_URL]; @@ -29,6 +31,8 @@ export const useFetchCoverageOverviewQuery = ( filter: CoverageOverviewFilter = {}, options?: UseQueryOptions ) => { + const { addError } = useAppToasts(); + return useQuery( [...COVERAGE_OVERVIEW_QUERY_KEY, filter], async ({ signal }) => { @@ -39,6 +43,11 @@ export const useFetchCoverageOverviewQuery = ( { ...DEFAULT_QUERY_OPTIONS, ...options, + onError: (error) => { + addError(error, { + title: i18n.COVERAGE_OVERVIEW_FETCH_ERROR_TITLE, + }); + }, } ); }; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/pages/coverage_overview/coverage_overview_dashboard.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/pages/coverage_overview/coverage_overview_dashboard.tsx index 11ee8f0d70bbc..fb14abd42ee61 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/pages/coverage_overview/coverage_overview_dashboard.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/pages/coverage_overview/coverage_overview_dashboard.tsx @@ -40,7 +40,12 @@ const CoverageOverviewDashboardComponent = () => { {data?.mitreTactics.map((tactic) => ( - + diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/pages/coverage_overview/rule_source_filter.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/pages/coverage_overview/rule_source_filter.tsx index fd1995beb68d7..e55450f0a069a 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/pages/coverage_overview/rule_source_filter.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/pages/coverage_overview/rule_source_filter.tsx @@ -87,7 +87,7 @@ const RuleSourceFilterComponent = ({ `} > - +

{technique.name}

{SubtechniqueInfo} diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/pages/coverage_overview/translations.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/pages/coverage_overview/translations.ts index eb9f06c350421..f61fa72e7f322 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/pages/coverage_overview/translations.ts +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/pages/coverage_overview/translations.ts @@ -177,3 +177,10 @@ export const CoverageOverviewDashboardInformation = i18n.translate( "Your current coverage of MITRE ATT&CK\u00AE tactics and techniques, based on installed rules. Click a cell to view and enable a technique's rules. Rules must be mapped to the MITRE ATT&CK\u00AE framework to be displayed.", } ); + +export const COVERAGE_OVERVIEW_FETCH_ERROR_TITLE = i18n.translate( + 'xpack.securitySolution.coverageOverviewDashboard.fetchErrorTitle', + { + defaultMessage: 'Failed to fetch coverage overview data', + } +); diff --git a/x-pack/plugins/security_solution/public/detections/mitre/mitre_tactics_techniques.ts b/x-pack/plugins/security_solution/public/detections/mitre/mitre_tactics_techniques.ts index 6846462b94c63..abfd92ca074ac 100644 --- a/x-pack/plugins/security_solution/public/detections/mitre/mitre_tactics_techniques.ts +++ b/x-pack/plugins/security_solution/public/detections/mitre/mitre_tactics_techniques.ts @@ -7251,27 +7251,123 @@ export const subtechniques: MitreSubTechnique[] = [ ]; /** - * A full object of Mitre Attack Threat data that is taken directly from the `mitre_tactics_techniques.ts` file + * An array of full Mitre Attack Threat objects that are taken directly from the `mitre_tactics_techniques.ts` file * * Is built alongside and sampled from the data in the file so to always be valid with the most up to date MITRE ATT&CK data */ -export const getMockThreatData = () => ({ - tactic: { - name: 'Credential Access', - id: 'TA0006', - reference: 'https://attack.mitre.org/tactics/TA0006', - }, - technique: { - name: 'OS Credential Dumping', - id: 'T1003', - reference: 'https://attack.mitre.org/techniques/T1003', - tactics: ['credential-access'], +export const getMockThreatData = () => [ + { + tactic: { + name: 'Credential Access', + id: 'TA0006', + reference: 'https://attack.mitre.org/tactics/TA0006', + }, + technique: { + name: 'OS Credential Dumping', + id: 'T1003', + reference: 'https://attack.mitre.org/techniques/T1003', + tactics: ['credential-access'], + }, + subtechnique: { + name: '/etc/passwd and /etc/shadow', + id: 'T1003.008', + reference: 'https://attack.mitre.org/techniques/T1003/008', + tactics: ['credential-access'], + techniqueId: 'T1003', + }, + }, + { + tactic: { + name: 'Credential Access', + id: 'TA0006', + reference: 'https://attack.mitre.org/tactics/TA0006', + }, + technique: { + name: 'Steal or Forge Kerberos Tickets', + id: 'T1558', + reference: 'https://attack.mitre.org/techniques/T1558', + tactics: ['credential-access'], + }, + subtechnique: { + name: 'AS-REP Roasting', + id: 'T1558.004', + reference: 'https://attack.mitre.org/techniques/T1558/004', + tactics: ['credential-access'], + techniqueId: 'T1558', + }, + }, + { + tactic: { + name: 'Persistence', + id: 'TA0003', + reference: 'https://attack.mitre.org/tactics/TA0003', + }, + technique: { + name: 'Boot or Logon Autostart Execution', + id: 'T1547', + reference: 'https://attack.mitre.org/techniques/T1547', + tactics: ['persistence', 'privilege-escalation'], + }, + subtechnique: { + name: 'Active Setup', + id: 'T1547.014', + reference: 'https://attack.mitre.org/techniques/T1547/014', + tactics: ['persistence', 'privilege-escalation'], + techniqueId: 'T1547', + }, + }, + { + tactic: { + name: 'Persistence', + id: 'TA0003', + reference: 'https://attack.mitre.org/tactics/TA0003', + }, + technique: { + name: 'Account Manipulation', + id: 'T1098', + reference: 'https://attack.mitre.org/techniques/T1098', + tactics: ['persistence'], + }, + subtechnique: { + name: 'Additional Cloud Credentials', + id: 'T1098.001', + reference: 'https://attack.mitre.org/techniques/T1098/001', + tactics: ['persistence'], + techniqueId: 'T1098', + }, }, - subtechnique: { - name: '/etc/passwd and /etc/shadow', - id: 'T1003.008', - reference: 'https://attack.mitre.org/techniques/T1003/008', - tactics: ['credential-access'], - techniqueId: 'T1003', +]; + +/** + * An array of specifically chosen Mitre Attack Threat objects that is taken directly from the `mitre_tactics_techniques.ts` file + * + * These objects have identical technique fields but are assigned to different tactics + */ +export const getDuplicateTechniqueThreatData = () => [ + { + tactic: { + name: 'Privilege Escalation', + id: 'TA0004', + reference: 'https://attack.mitre.org/tactics/TA0004', + }, + technique: { + name: 'Event Triggered Execution', + id: 'T1546', + reference: 'https://attack.mitre.org/techniques/T1546', + tactics: ['privilege-escalation', 'persistence'], + }, + }, + { + tactic: { + name: 'Persistence', + id: 'TA0003', + reference: 'https://attack.mitre.org/tactics/TA0003', + }, + technique: { + name: 'Event Triggered Execution', + id: 'T1546', + reference: 'https://attack.mitre.org/techniques/T1546', + tactics: ['privilege-escalation', 'persistence'], + }, }, -}); +]; diff --git a/x-pack/plugins/security_solution/public/detections/mitre/valid_threat_mock.ts b/x-pack/plugins/security_solution/public/detections/mitre/valid_threat_mock.ts index c084a8a01d58e..f7885a26d119f 100644 --- a/x-pack/plugins/security_solution/public/detections/mitre/valid_threat_mock.ts +++ b/x-pack/plugins/security_solution/public/detections/mitre/valid_threat_mock.ts @@ -8,7 +8,7 @@ import type { Threats } from '@kbn/securitysolution-io-ts-alerting-types'; import { getMockThreatData } from './mitre_tactics_techniques'; -const { tactic, technique, subtechnique } = getMockThreatData(); +const { tactic, technique, subtechnique } = getMockThreatData()[0]; const { tactics, ...mockTechnique } = technique; const { tactics: subtechniqueTactics, ...mockSubtechnique } = subtechnique; diff --git a/x-pack/plugins/security_solution/scripts/extract_tactics_techniques_mitre.js b/x-pack/plugins/security_solution/scripts/extract_tactics_techniques_mitre.js index ee037cae293b2..1f8526538e8c9 100644 --- a/x-pack/plugins/security_solution/scripts/extract_tactics_techniques_mitre.js +++ b/x-pack/plugins/security_solution/scripts/extract_tactics_techniques_mitre.js @@ -22,6 +22,15 @@ const OUTPUT_DIRECTORY = resolve('public', 'detections', 'mitre'); const MITRE_CONTENT_VERSION = 'ATT&CK-v13.1'; // last updated when preparing for 8.10.3 release const MITRE_CONTENT_URL = `https://raw.githubusercontent.com/mitre/cti/${MITRE_CONTENT_VERSION}/enterprise-attack/enterprise-attack.json`; +/** + * An ID for a technique that exists in multiple tactics. This may change in further updates and on MITRE + * version upgrade, this ID should be double-checked to make sure it still represents these parameters. + * + * We have this in order to cover edge cases with our mock data that can't be achieved by simply generating + * data from the MITRE api. + */ +const MOCK_DUPLICATE_TECHNIQUE_ID = 'T1546'; + const getTacticsOptions = (tactics) => tactics.map((t) => `{ @@ -172,15 +181,37 @@ const extractSubtechniques = (mitreData) => { }; const buildMockThreatData = (tacticsData, techniques, subtechniques) => { - const subtechnique = subtechniques[0]; - const technique = techniques.find((technique) => technique.id === subtechnique.techniqueId); - const tactic = tacticsData.find((tactic) => tactic.shortName === technique.tactics[0]); - - return { - tactic: normalizeTacticsData([tactic])[0], - technique, - subtechnique, - }; + const numberOfThreatsToGenerate = 4; + const mockThreatData = []; + for (let i = 0; i < numberOfThreatsToGenerate; i++) { + const subtechnique = subtechniques[i * 2]; // Double our interval to broaden the subtechnique types we're pulling data from a bit + const technique = techniques.find((technique) => technique.id === subtechnique.techniqueId); + const tactic = tacticsData.find((tactic) => tactic.shortName === technique.tactics[0]); + + mockThreatData.push({ + tactic: normalizeTacticsData([tactic])[0], + technique, + subtechnique, + }); + } + return mockThreatData; +}; + +const buildDuplicateTechniqueMockThreatData = (tacticsData, techniques) => { + const technique = techniques.find((technique) => technique.id === MOCK_DUPLICATE_TECHNIQUE_ID); + const tacticOne = tacticsData.find((tactic) => tactic.shortName === technique.tactics[0]); + const tacticTwo = tacticsData.find((tactic) => tactic.shortName === technique.tactics[1]); + + return [ + { + tactic: normalizeTacticsData([tacticOne])[0], + technique, + }, + { + tactic: normalizeTacticsData([tacticTwo])[0], + technique, + }, + ]; }; async function main() { @@ -224,7 +255,7 @@ async function main() { .replace(/"{/g, '{')}; /** - * A full object of Mitre Attack Threat data that is taken directly from the \`mitre_tactics_techniques.ts\` file + * An array of full Mitre Attack Threat objects that are taken directly from the \`mitre_tactics_techniques.ts\` file * * Is built alongside and sampled from the data in the file so to always be valid with the most up to date MITRE ATT&CK data */ @@ -235,6 +266,19 @@ async function main() { ) .replace(/}"/g, '}') .replace(/"{/g, '{')}); + + /** + * An array of specifically chosen Mitre Attack Threat objects that is taken directly from the \`mitre_tactics_techniques.ts\` file + * + * These objects have identical technique fields but are assigned to different tactics + */ + export const getDuplicateTechniqueThreatData = () => (${JSON.stringify( + buildDuplicateTechniqueMockThreatData(tacticsData, techniques), + null, + 2 + ) + .replace(/}"/g, '}') + .replace(/"{/g, '{')}); `; fs.writeFileSync(`${OUTPUT_DIRECTORY}/mitre_tactics_techniques.ts`, body, 'utf-8'); diff --git a/x-pack/test/detection_engine_api_integration/basic/tests/coverage_overview.ts b/x-pack/test/detection_engine_api_integration/basic/tests/coverage_overview.ts deleted file mode 100644 index b40033b5eb17e..0000000000000 --- a/x-pack/test/detection_engine_api_integration/basic/tests/coverage_overview.ts +++ /dev/null @@ -1,510 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import expect from '@kbn/expect'; - -import { - RULE_MANAGEMENT_COVERAGE_OVERVIEW_URL, - ThreatArray, -} from '@kbn/security-solution-plugin/common/api/detection_engine'; -import { FtrProviderContext } from '../../common/ftr_provider_context'; -import { - createPrebuiltRuleAssetSavedObjects, - createRuleAssetSavedObject, - createRule, - deleteAllRules, - getSimpleRule, - installPrebuiltRulesAndTimelines, - createNonSecurityRule, -} from '../../utils'; - -// eslint-disable-next-line import/no-default-export -export default ({ getService }: FtrProviderContext): void => { - const supertest = getService('supertest'); - const log = getService('log'); - const es = getService('es'); - - describe('coverage_overview', () => { - beforeEach(async () => { - await deleteAllRules(supertest, log); - }); - - it('does NOT error when there are no security rules', async () => { - await createNonSecurityRule(supertest); - const rule1 = await createRule(supertest, log, { - ...getSimpleRule(), - threat: generateThreatArray(1), - }); - - const { body } = await supertest - .post(RULE_MANAGEMENT_COVERAGE_OVERVIEW_URL) - .set('kbn-xsrf', 'true') - .set('elastic-api-version', '1') - .send({}) - .expect(200); - - expect(body).to.eql({ - coverage: { - T001: [rule1.id], - TA001: [rule1.id], - 'T001.001': [rule1.id], - }, - unmapped_rule_ids: [], - rules_data: { - [rule1.id]: { - activity: 'disabled', - name: 'Simple Rule Query', - }, - }, - }); - }); - - describe('without filters', () => { - it('returns an empty response if there are no rules', async () => { - const { body } = await supertest - .post(RULE_MANAGEMENT_COVERAGE_OVERVIEW_URL) - .set('kbn-xsrf', 'true') - .set('elastic-api-version', '1') - .send({}) - .expect(200); - - expect(body).to.eql({ - coverage: {}, - unmapped_rule_ids: [], - rules_data: {}, - }); - }); - - it('returns response with a single rule mapped to MITRE categories', async () => { - const rule1 = await createRule(supertest, log, { - ...getSimpleRule(), - threat: generateThreatArray(1), - }); - - const { body } = await supertest - .post(RULE_MANAGEMENT_COVERAGE_OVERVIEW_URL) - .set('kbn-xsrf', 'true') - .set('elastic-api-version', '1') - .send({}) - .expect(200); - - expect(body).to.eql({ - coverage: { - T001: [rule1.id], - TA001: [rule1.id], - 'T001.001': [rule1.id], - }, - unmapped_rule_ids: [], - rules_data: { - [rule1.id]: { - activity: 'disabled', - name: 'Simple Rule Query', - }, - }, - }); - }); - - it('returns response with an unmapped rule', async () => { - const rule1 = await createRule(supertest, log, { ...getSimpleRule(), threat: undefined }); - - const { body } = await supertest - .post(RULE_MANAGEMENT_COVERAGE_OVERVIEW_URL) - .set('kbn-xsrf', 'true') - .set('elastic-api-version', '1') - .send({}) - .expect(200); - - expect(body).to.eql({ - coverage: {}, - unmapped_rule_ids: [rule1.id], - rules_data: { - [rule1.id]: { - activity: 'disabled', - name: 'Simple Rule Query', - }, - }, - }); - }); - }); - - describe('with filters', () => { - describe('search_term', () => { - it('returns response filtered by tactic', async () => { - await createRule(supertest, log, { - ...getSimpleRule('rule-1'), - threat: generateThreatArray(1), - }); - const expectedRule = await createRule(supertest, log, { - ...getSimpleRule('rule-2'), - threat: generateThreatArray(2), - }); - - const { body } = await supertest - .post(RULE_MANAGEMENT_COVERAGE_OVERVIEW_URL) - .set('kbn-xsrf', 'true') - .set('elastic-api-version', '1') - .send({ - filter: { - search_term: 'TA002', - }, - }) - .expect(200); - - expect(body).to.eql({ - coverage: { - T002: [expectedRule.id], - TA002: [expectedRule.id], - 'T002.002': [expectedRule.id], - }, - unmapped_rule_ids: [], - rules_data: { - [expectedRule.id]: { - activity: 'disabled', - name: 'Simple Rule Query', - }, - }, - }); - }); - - it('returns response filtered by technique', async () => { - await createRule(supertest, log, { - ...getSimpleRule('rule-1'), - threat: generateThreatArray(1), - }); - const expectedRule = await createRule(supertest, log, { - ...getSimpleRule('rule-2'), - threat: generateThreatArray(2), - }); - - const { body } = await supertest - .post(RULE_MANAGEMENT_COVERAGE_OVERVIEW_URL) - .set('kbn-xsrf', 'true') - .set('elastic-api-version', '1') - .send({ - filter: { - search_term: 'T002', - }, - }) - .expect(200); - - expect(body).to.eql({ - coverage: { - T002: [expectedRule.id], - TA002: [expectedRule.id], - 'T002.002': [expectedRule.id], - }, - unmapped_rule_ids: [], - rules_data: { - [expectedRule.id]: { - activity: 'disabled', - name: 'Simple Rule Query', - }, - }, - }); - }); - - it('returns response filtered by subtechnique', async () => { - await createRule(supertest, log, { - ...getSimpleRule('rule-1'), - threat: generateThreatArray(1), - }); - const expectedRule = await createRule(supertest, log, { - ...getSimpleRule('rule-2'), - threat: generateThreatArray(2), - }); - - const { body } = await supertest - .post(RULE_MANAGEMENT_COVERAGE_OVERVIEW_URL) - .set('kbn-xsrf', 'true') - .set('elastic-api-version', '1') - .send({ - filter: { - search_term: 'T002.002', - }, - }) - .expect(200); - - expect(body).to.eql({ - coverage: { - T002: [expectedRule.id], - TA002: [expectedRule.id], - 'T002.002': [expectedRule.id], - }, - unmapped_rule_ids: [], - rules_data: { - [expectedRule.id]: { - activity: 'disabled', - name: 'Simple Rule Query', - }, - }, - }); - }); - - it('returns response filtered by rule name', async () => { - await createRule(supertest, log, getSimpleRule('rule-1')); - const expectedRule = await createRule(supertest, log, { - ...getSimpleRule('rule-2'), - name: 'rule-2', - }); - - const { body } = await supertest - .post(RULE_MANAGEMENT_COVERAGE_OVERVIEW_URL) - .set('kbn-xsrf', 'true') - .set('elastic-api-version', '1') - .send({ - filter: { - search_term: 'rule-2', - }, - }) - .expect(200); - - expect(body).to.eql({ - coverage: {}, - unmapped_rule_ids: [expectedRule.id], - rules_data: { - [expectedRule.id]: { - activity: 'disabled', - name: 'rule-2', - }, - }, - }); - }); - - it('returns response filtered by index pattern', async () => { - await createRule(supertest, log, { - ...getSimpleRule('rule-1'), - index: ['index-pattern-1'], - }); - const expectedRule = await createRule(supertest, log, { - ...getSimpleRule('rule-2'), - index: ['index-pattern-2'], - }); - - const { body } = await supertest - .post(RULE_MANAGEMENT_COVERAGE_OVERVIEW_URL) - .set('kbn-xsrf', 'true') - .set('elastic-api-version', '1') - .send({ - filter: { - search_term: 'index-pattern-2', - }, - }) - .expect(200); - - expect(body).to.eql({ - coverage: {}, - unmapped_rule_ids: [expectedRule.id], - rules_data: { - [expectedRule.id]: { - activity: 'disabled', - name: 'Simple Rule Query', - }, - }, - }); - }); - }); - - describe('activity', () => { - it('returns response filtered by disabled rules', async () => { - const expectedRule = await createRule(supertest, log, { - ...getSimpleRule('rule-1'), - threat: generateThreatArray(1), - }); - await createRule(supertest, log, { - ...getSimpleRule('rule-2', true), - threat: generateThreatArray(2), - }); - - const { body } = await supertest - .post(RULE_MANAGEMENT_COVERAGE_OVERVIEW_URL) - .set('kbn-xsrf', 'true') - .set('elastic-api-version', '1') - .send({ - filter: { - activity: ['disabled'], - }, - }) - .expect(200); - - expect(body).to.eql({ - coverage: { - T001: [expectedRule.id], - TA001: [expectedRule.id], - 'T001.001': [expectedRule.id], - }, - unmapped_rule_ids: [], - rules_data: { - [expectedRule.id]: { - activity: 'disabled', - name: 'Simple Rule Query', - }, - }, - }); - }); - - it('returns response filtered by enabled rules', async () => { - await createRule(supertest, log, { - ...getSimpleRule('rule-1'), - threat: generateThreatArray(1), - }); - const expectedRule = await createRule(supertest, log, { - ...getSimpleRule('rule-2', true), - threat: generateThreatArray(2), - }); - - const { body } = await supertest - .post(RULE_MANAGEMENT_COVERAGE_OVERVIEW_URL) - .set('kbn-xsrf', 'true') - .set('elastic-api-version', '1') - .send({ - filter: { - activity: ['enabled'], - }, - }) - .expect(200); - - expect(body).to.eql({ - coverage: { - T002: [expectedRule.id], - TA002: [expectedRule.id], - 'T002.002': [expectedRule.id], - }, - unmapped_rule_ids: [], - rules_data: { - [expectedRule.id]: { - activity: 'enabled', - name: 'Simple Rule Query', - }, - }, - }); - }); - - it('returns all rules if both enabled and disabled filters are specified in the request', async () => { - const expectedRule1 = await createRule(supertest, log, { - ...getSimpleRule('rule-1', false), - name: 'Disabled rule', - threat: generateThreatArray(1), - }); - const expectedRule2 = await createRule(supertest, log, { - ...getSimpleRule('rule-2', true), - name: 'Enabled rule', - threat: generateThreatArray(2), - }); - - const { body } = await supertest - .post(RULE_MANAGEMENT_COVERAGE_OVERVIEW_URL) - .set('kbn-xsrf', 'true') - .set('elastic-api-version', '1') - .send({ - filter: { - activity: ['enabled', 'disabled'], - }, - }) - .expect(200); - - expect(body).to.eql({ - coverage: { - T001: [expectedRule1.id], - TA001: [expectedRule1.id], - 'T001.001': [expectedRule1.id], - T002: [expectedRule2.id], - TA002: [expectedRule2.id], - 'T002.002': [expectedRule2.id], - }, - unmapped_rule_ids: [], - rules_data: { - [expectedRule1.id]: { - activity: 'disabled', - name: 'Disabled rule', - }, - [expectedRule2.id]: { - activity: 'enabled', - name: 'Enabled rule', - }, - }, - }); - }); - }); - - describe('source', () => { - it('returns response filtered by custom rules', async () => { - await createPrebuiltRuleAssetSavedObjects(es, [ - createRuleAssetSavedObject({ - rule_id: 'prebuilt-rule-1', - threat: generateThreatArray(1), - }), - ]); - await installPrebuiltRulesAndTimelines(es, supertest); - - const expectedRule = await createRule(supertest, log, { - ...getSimpleRule('rule-1'), - threat: generateThreatArray(2), - }); - - const { body } = await supertest - .post(RULE_MANAGEMENT_COVERAGE_OVERVIEW_URL) - .set('kbn-xsrf', 'true') - .set('elastic-api-version', '1') - .send({ - filter: { - source: ['custom'], - }, - }) - .expect(200); - - expect(body).to.eql({ - coverage: { - T002: [expectedRule.id], - TA002: [expectedRule.id], - 'T002.002': [expectedRule.id], - }, - unmapped_rule_ids: [], - rules_data: { - [expectedRule.id]: { - activity: 'disabled', - name: 'Simple Rule Query', - }, - }, - }); - }); - }); - }); - }); -}; - -function generateThreatArray(startIndex: number, count = 1): ThreatArray { - const result: ThreatArray = []; - - for (let i = 0; i < count; ++i) { - const indexName = (i + startIndex).toString().padStart(3, '0'); - - result.push({ - framework: 'MITRE ATT&CK', - tactic: { - id: `TA${indexName}`, - name: `Tactic ${indexName}`, - reference: `http://some-link-${indexName}`, - }, - technique: [ - { - id: `T${indexName}`, - name: `Technique ${indexName}`, - reference: `http://some-technique-link-${indexName}`, - subtechnique: [ - { - id: `T${indexName}.${indexName}`, - name: `Subtechnique ${indexName}`, - reference: `http://some-sub-technique-link-${indexName}`, - }, - ], - }, - ], - }); - } - - return result; -} diff --git a/x-pack/test/detection_engine_api_integration/basic/tests/index.ts b/x-pack/test/detection_engine_api_integration/basic/tests/index.ts index 5b3449707d38f..3ef462f7add2a 100644 --- a/x-pack/test/detection_engine_api_integration/basic/tests/index.ts +++ b/x-pack/test/detection_engine_api_integration/basic/tests/index.ts @@ -22,6 +22,5 @@ export default ({ loadTestFile }: FtrProviderContext): void => { loadTestFile(require.resolve('./patch_rules_bulk')); loadTestFile(require.resolve('./patch_rules')); loadTestFile(require.resolve('./import_timelines')); - loadTestFile(require.resolve('./coverage_overview')); }); }; diff --git a/x-pack/test/detection_engine_api_integration/utils/create_non_security_rule.ts b/x-pack/test/detection_engine_api_integration/utils/create_non_security_rule.ts deleted file mode 100644 index 09bc0f9b81a6d..0000000000000 --- a/x-pack/test/detection_engine_api_integration/utils/create_non_security_rule.ts +++ /dev/null @@ -1,42 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import type SuperTest from 'supertest'; - -const SIMPLE_APM_RULE_DATA = { - name: 'Test rule', - rule_type_id: 'apm.anomaly', - enabled: false, - consumer: 'alerts', - tags: [], - actions: [], - params: { - windowSize: 30, - windowUnit: 'm', - anomalySeverityType: 'critical', - anomalyDetectorTypes: ['txLatency'], - environment: 'ENVIRONMENT_ALL', - }, - schedule: { - interval: '10m', - }, -}; - -/** - * Created a non security rule. Helpful in tests to verify functionality works with presence of non security rules. - * @param supertest The supertest deps - */ -export async function createNonSecurityRule( - supertest: SuperTest.SuperTest -): Promise { - await supertest - .post('/api/alerting/rule') - .set('kbn-xsrf', 'true') - .set('elastic-api-version', '2023-10-31') - .send(SIMPLE_APM_RULE_DATA) - .expect(200); -} diff --git a/x-pack/test/detection_engine_api_integration/utils/index.ts b/x-pack/test/detection_engine_api_integration/utils/index.ts index 1938a069a2f53..baa4be0491625 100644 --- a/x-pack/test/detection_engine_api_integration/utils/index.ts +++ b/x-pack/test/detection_engine_api_integration/utils/index.ts @@ -31,7 +31,6 @@ export * from './prebuilt_rules/create_prebuilt_rule_saved_objects'; export * from './prebuilt_rules/install_prebuilt_rules_and_timelines'; export * from './get_simple_rule_update'; export * from './get_simple_ml_rule_update'; -export * from './create_non_security_rule'; export * from './get_simple_rule_as_ndjson'; export * from './rule_to_ndjson'; export * from './delete_rule'; diff --git a/x-pack/test/security_solution_api_integration/package.json b/x-pack/test/security_solution_api_integration/package.json index 3c083278eedce..53acd95fe2515 100644 --- a/x-pack/test/security_solution_api_integration/package.json +++ b/x-pack/test/security_solution_api_integration/package.json @@ -156,6 +156,12 @@ "detection_engine_basicessentionals:qa:serverless": "npm run run-tests:dr:basicEssentials detection_engine serverless qaEnv", "detection_engine_basicessentionals:server:ess": "npm run initialize-server:dr:basicEssentials detection_engine ess", "detection_engine_basicessentionals:runner:ess": "npm run run-tests:dr:basicEssentials detection_engine ess essEnv", + + "rule_management_basicessentionals:server:serverless": "npm run initialize-server:dr:basicEssentials rule_management serverless", + "rule_management_basicessentionals:runner:serverless": "npm run run-tests:dr:basicEssentials rule_management serverless serverlessEnv", + "rule_management_basicessentionals:qa:serverless": "npm run run-tests:dr:basicEssentials rule_management serverless qaEnv", + "rule_management_basicessentionals:server:ess": "npm run initialize-server:dr:basicEssentials rule_management ess", + "rule_management_basicessentionals:runner:ess": "npm run run-tests:dr:basicEssentials rule_management ess essEnv", "exception_lists_items:server:serverless": "npm run initialize-server:lists:default exception_lists_items serverless", "exception_lists_items:runner:serverless": "npm run run-tests:lists:default exception_lists_items serverless serverlessEnv", diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/basic_essentials_license/rule_management/configs/ess.config.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/basic_essentials_license/rule_management/configs/ess.config.ts new file mode 100644 index 0000000000000..eaae21719c720 --- /dev/null +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/basic_essentials_license/rule_management/configs/ess.config.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { FtrConfigProviderContext } from '@kbn/test'; + +export default async function ({ readConfigFile }: FtrConfigProviderContext) { + const functionalConfig = await readConfigFile( + require.resolve('../../../../../config/ess/config.base.basic') + ); + + return { + ...functionalConfig.getAll(), + testFiles: [require.resolve('..')], + junit: { + reportName: 'Detection Engine ESS/ Rule management API Integration Tests', + }, + }; +} diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/basic_essentials_license/rule_management/configs/serverless.config.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/basic_essentials_license/rule_management/configs/serverless.config.ts new file mode 100644 index 0000000000000..2e5b2f2b6ac69 --- /dev/null +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/basic_essentials_license/rule_management/configs/serverless.config.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { createTestConfig } from '../../../../../config/serverless/config.base.essentials'; + +export default createTestConfig({ + testFiles: [require.resolve('..')], + junit: { + reportName: 'Detection Engine Serverless/ Rule management API Integration Tests', + }, +}); diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/basic_essentials_license/rule_management/coverage_overview.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/basic_essentials_license/rule_management/coverage_overview.ts new file mode 100644 index 0000000000000..b22b76e4b8670 --- /dev/null +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/basic_essentials_license/rule_management/coverage_overview.ts @@ -0,0 +1,670 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; + +import { + CoverageOverviewRuleActivity, + CoverageOverviewRuleSource, + RULE_MANAGEMENT_COVERAGE_OVERVIEW_URL, + ThreatArray, +} from '@kbn/security-solution-plugin/common/api/detection_engine'; +import { FtrProviderContext } from '../../../../ftr_provider_context'; +import { + createPrebuiltRuleAssetSavedObjects, + createRuleAssetSavedObject, + createRule, + deleteAllRules, + installPrebuiltRulesAndTimelines, + installPrebuiltRules, + getCustomQueryRuleParams, + createNonSecurityRule, +} from '../../utils'; +import { getCoverageOverview } from '../../utils/rules/get_coverage_overview'; + +export default ({ getService }: FtrProviderContext): void => { + const supertest = getService('supertest'); + const log = getService('log'); + const es = getService('es'); + + describe('coverage_overview', () => { + beforeEach(async () => { + await deleteAllRules(supertest, log); + }); + + // ESS only + describe('@ess specific tests', () => { + it('does NOT error when there exist some stack rules in addition to security detection rules', async () => { + await createNonSecurityRule(supertest); + + const rule1 = await createRule( + supertest, + log, + getCustomQueryRuleParams({ threat: generateThreatArray(1) }) + ); + + const body = await getCoverageOverview(supertest); + + expect(body).to.eql({ + coverage: { + T001: [rule1.id], + TA001: [rule1.id], + 'T001.001': [rule1.id], + }, + unmapped_rule_ids: [], + rules_data: { + [rule1.id]: { + activity: 'disabled', + name: 'Custom query rule', + }, + }, + }); + }); + }); + + // Both serverless and ESS + describe('@serverless @ess tests', () => { + describe('base cases', () => { + it('returns an empty response if there are no rules', async () => { + const body = await getCoverageOverview(supertest); + + expect(body).to.eql({ + coverage: {}, + unmapped_rule_ids: [], + rules_data: {}, + }); + }); + + it('returns response with a single rule mapped to MITRE categories', async () => { + const rule1 = await createRule( + supertest, + log, + getCustomQueryRuleParams({ threat: generateThreatArray(1) }) + ); + + const body = await getCoverageOverview(supertest); + + expect(body).to.eql({ + coverage: { + T001: [rule1.id], + TA001: [rule1.id], + 'T001.001': [rule1.id], + }, + unmapped_rule_ids: [], + rules_data: { + [rule1.id]: { + activity: 'disabled', + name: 'Custom query rule', + }, + }, + }); + }); + + it('returns response with an unmapped rule', async () => { + const rule1 = await createRule( + supertest, + log, + getCustomQueryRuleParams({ threat: undefined }) + ); + + const body = await getCoverageOverview(supertest); + + expect(body).to.eql({ + coverage: {}, + unmapped_rule_ids: [rule1.id], + rules_data: { + [rule1.id]: { + activity: 'disabled', + name: 'Custom query rule', + }, + }, + }); + }); + }); + + describe('with filters', () => { + describe('search_term', () => { + it('returns response filtered by tactic', async () => { + await createRule( + supertest, + log, + getCustomQueryRuleParams({ rule_id: 'rule-1', threat: generateThreatArray(1) }) + ); + const expectedRule = await createRule( + supertest, + log, + getCustomQueryRuleParams({ rule_id: 'rule-2', threat: generateThreatArray(2) }) + ); + + const body = await getCoverageOverview(supertest, { + search_term: 'TA002', + }); + + expect(body).to.eql({ + coverage: { + T002: [expectedRule.id], + TA002: [expectedRule.id], + 'T002.002': [expectedRule.id], + }, + unmapped_rule_ids: [], + rules_data: { + [expectedRule.id]: { + activity: 'disabled', + name: 'Custom query rule', + }, + }, + }); + }); + + it('returns response filtered by technique', async () => { + await createRule( + supertest, + log, + getCustomQueryRuleParams({ rule_id: 'rule-1', threat: generateThreatArray(1) }) + ); + const expectedRule = await createRule( + supertest, + log, + getCustomQueryRuleParams({ rule_id: 'rule-2', threat: generateThreatArray(2) }) + ); + + const body = await getCoverageOverview(supertest, { + search_term: 'T002', + }); + + expect(body).to.eql({ + coverage: { + T002: [expectedRule.id], + TA002: [expectedRule.id], + 'T002.002': [expectedRule.id], + }, + unmapped_rule_ids: [], + rules_data: { + [expectedRule.id]: { + activity: 'disabled', + name: 'Custom query rule', + }, + }, + }); + }); + + it('returns response filtered by subtechnique', async () => { + await createRule( + supertest, + log, + getCustomQueryRuleParams({ rule_id: 'rule-1', threat: generateThreatArray(1) }) + ); + const expectedRule = await createRule( + supertest, + log, + getCustomQueryRuleParams({ rule_id: 'rule-2', threat: generateThreatArray(2) }) + ); + + const body = await getCoverageOverview(supertest, { + search_term: 'T002.002', + }); + + expect(body).to.eql({ + coverage: { + T002: [expectedRule.id], + TA002: [expectedRule.id], + 'T002.002': [expectedRule.id], + }, + unmapped_rule_ids: [], + rules_data: { + [expectedRule.id]: { + activity: 'disabled', + name: 'Custom query rule', + }, + }, + }); + }); + + it('returns response filtered by rule name', async () => { + await createRule(supertest, log, getCustomQueryRuleParams({ rule_id: 'rule-1' })); + const expectedRule = await createRule( + supertest, + log, + getCustomQueryRuleParams({ rule_id: 'rule-2', name: 'rule-2' }) + ); + + const body = await getCoverageOverview(supertest, { + search_term: 'rule-2', + }); + + expect(body).to.eql({ + coverage: {}, + unmapped_rule_ids: [expectedRule.id], + rules_data: { + [expectedRule.id]: { + activity: 'disabled', + name: 'rule-2', + }, + }, + }); + }); + + it('returns response filtered by index pattern', async () => { + await createRule( + supertest, + log, + getCustomQueryRuleParams({ rule_id: 'rule-1', index: ['index-pattern-1'] }) + ); + const expectedRule = await createRule( + supertest, + log, + getCustomQueryRuleParams({ rule_id: 'rule-2', index: ['index-pattern-2'] }) + ); + + const body = await getCoverageOverview(supertest, { + search_term: 'index-pattern-2', + }); + + expect(body).to.eql({ + coverage: {}, + unmapped_rule_ids: [expectedRule.id], + rules_data: { + [expectedRule.id]: { + activity: 'disabled', + name: 'Custom query rule', + }, + }, + }); + }); + }); + + describe('activity', () => { + it('returns response filtered by disabled rules', async () => { + const expectedRule = await createRule( + supertest, + log, + getCustomQueryRuleParams({ rule_id: 'rule-1', threat: generateThreatArray(1) }) + ); + await createRule( + supertest, + log, + getCustomQueryRuleParams({ + rule_id: 'rule-2', + enabled: true, + threat: generateThreatArray(2), + }) + ); + + const body = await getCoverageOverview(supertest, { + activity: [CoverageOverviewRuleActivity.Disabled], + }); + + expect(body).to.eql({ + coverage: { + T001: [expectedRule.id], + TA001: [expectedRule.id], + 'T001.001': [expectedRule.id], + }, + unmapped_rule_ids: [], + rules_data: { + [expectedRule.id]: { + activity: 'disabled', + name: 'Custom query rule', + }, + }, + }); + }); + + it('returns response filtered by enabled rules', async () => { + await createRule( + supertest, + log, + getCustomQueryRuleParams({ rule_id: 'rule-1', threat: generateThreatArray(1) }) + ); + const expectedRule = await createRule( + supertest, + log, + getCustomQueryRuleParams({ + rule_id: 'rule-2', + enabled: true, + threat: generateThreatArray(2), + }) + ); + + const body = await getCoverageOverview(supertest, { + activity: [CoverageOverviewRuleActivity.Enabled], + }); + + expect(body).to.eql({ + coverage: { + T002: [expectedRule.id], + TA002: [expectedRule.id], + 'T002.002': [expectedRule.id], + }, + unmapped_rule_ids: [], + rules_data: { + [expectedRule.id]: { + activity: 'enabled', + name: 'Custom query rule', + }, + }, + }); + }); + + it('returns all rules if both enabled and disabled filters are specified in the request', async () => { + const expectedRule1 = await createRule( + supertest, + log, + getCustomQueryRuleParams({ + rule_id: 'rule-1', + enabled: false, + name: 'Disabled rule', + threat: generateThreatArray(1), + }) + ); + const expectedRule2 = await createRule( + supertest, + log, + getCustomQueryRuleParams({ + rule_id: 'rule-2', + enabled: true, + name: 'Enabled rule', + threat: generateThreatArray(2), + }) + ); + + const body = await getCoverageOverview(supertest, { + activity: [ + CoverageOverviewRuleActivity.Enabled, + CoverageOverviewRuleActivity.Disabled, + ], + }); + + expect(body).to.eql({ + coverage: { + T001: [expectedRule1.id], + TA001: [expectedRule1.id], + 'T001.001': [expectedRule1.id], + T002: [expectedRule2.id], + TA002: [expectedRule2.id], + 'T002.002': [expectedRule2.id], + }, + unmapped_rule_ids: [], + rules_data: { + [expectedRule1.id]: { + activity: 'disabled', + name: 'Disabled rule', + }, + [expectedRule2.id]: { + activity: 'enabled', + name: 'Enabled rule', + }, + }, + }); + }); + + it('returns all rules if neither enabled and disabled filters are specified in the request', async () => { + const expectedRule1 = await createRule( + supertest, + log, + getCustomQueryRuleParams({ + rule_id: 'rule-1', + enabled: false, + name: 'Disabled rule', + threat: generateThreatArray(1), + }) + ); + const expectedRule2 = await createRule( + supertest, + log, + getCustomQueryRuleParams({ + rule_id: 'rule-2', + enabled: true, + name: 'Enabled rule', + threat: generateThreatArray(2), + }) + ); + + const body = await getCoverageOverview(supertest); + + expect(body).to.eql({ + coverage: { + T001: [expectedRule1.id], + TA001: [expectedRule1.id], + 'T001.001': [expectedRule1.id], + T002: [expectedRule2.id], + TA002: [expectedRule2.id], + 'T002.002': [expectedRule2.id], + }, + unmapped_rule_ids: [], + rules_data: { + [expectedRule1.id]: { + activity: 'disabled', + name: 'Disabled rule', + }, + [expectedRule2.id]: { + activity: 'enabled', + name: 'Enabled rule', + }, + }, + }); + }); + }); + + describe('source', () => { + it('returns response filtered by custom rules', async () => { + await createPrebuiltRuleAssetSavedObjects(es, [ + createRuleAssetSavedObject({ + rule_id: 'prebuilt-rule-1', + threat: generateThreatArray(1), + }), + ]); + await installPrebuiltRulesAndTimelines(es, supertest); + + const expectedRule = await createRule( + supertest, + log, + getCustomQueryRuleParams({ rule_id: 'rule-1', threat: generateThreatArray(2) }) + ); + + const body = await getCoverageOverview(supertest, { + source: [CoverageOverviewRuleSource.Custom], + }); + + expect(body).to.eql({ + coverage: { + T002: [expectedRule.id], + TA002: [expectedRule.id], + 'T002.002': [expectedRule.id], + }, + unmapped_rule_ids: [], + rules_data: { + [expectedRule.id]: { + activity: 'disabled', + name: 'Custom query rule', + }, + }, + }); + }); + + it('returns response filtered by prebuilt rules', async () => { + await createPrebuiltRuleAssetSavedObjects(es, [ + createRuleAssetSavedObject({ + rule_id: 'prebuilt-rule-1', + threat: generateThreatArray(1), + }), + ]); + const { + results: { created }, + } = await installPrebuiltRules(es, supertest); + const expectedRule = created[0]; + + await createRule( + supertest, + log, + getCustomQueryRuleParams({ rule_id: 'rule-1', threat: generateThreatArray(2) }) + ); + + const body = await getCoverageOverview(supertest, { + source: [CoverageOverviewRuleSource.Prebuilt], + }); + + expect(body).to.eql({ + coverage: { + T001: [expectedRule.id], + TA001: [expectedRule.id], + 'T001.001': [expectedRule.id], + }, + unmapped_rule_ids: [], + rules_data: { + [expectedRule.id]: { + activity: 'disabled', + name: 'Query with a rule id', + }, + }, + }); + }); + + it('returns all rules if both custom and prebuilt filters are specified in the request', async () => { + await createPrebuiltRuleAssetSavedObjects(es, [ + createRuleAssetSavedObject({ + rule_id: 'prebuilt-rule-1', + threat: generateThreatArray(1), + }), + ]); + const { + results: { created }, + } = await installPrebuiltRules(es, supertest); + const expectedPrebuiltRule = created[0]; + + const expectedCustomRule = await createRule( + supertest, + log, + getCustomQueryRuleParams({ rule_id: 'rule-1', threat: generateThreatArray(2) }) + ); + + const body = await getCoverageOverview(supertest, { + source: [CoverageOverviewRuleSource.Prebuilt, CoverageOverviewRuleSource.Custom], + }); + + expect(body).to.eql({ + coverage: { + T001: [expectedPrebuiltRule.id], + TA001: [expectedPrebuiltRule.id], + 'T001.001': [expectedPrebuiltRule.id], + T002: [expectedCustomRule.id], + TA002: [expectedCustomRule.id], + 'T002.002': [expectedCustomRule.id], + }, + unmapped_rule_ids: [], + rules_data: { + [expectedPrebuiltRule.id]: { + activity: 'disabled', + name: 'Query with a rule id', + }, + [expectedCustomRule.id]: { + activity: 'disabled', + name: 'Custom query rule', + }, + }, + }); + }); + + it('returns all rules if neither custom and prebuilt filters are specified in the request', async () => { + await createPrebuiltRuleAssetSavedObjects(es, [ + createRuleAssetSavedObject({ + rule_id: 'prebuilt-rule-1', + threat: generateThreatArray(1), + }), + ]); + const { + results: { created }, + } = await installPrebuiltRules(es, supertest); + const expectedPrebuiltRule = created[0]; + + const expectedCustomRule = await createRule( + supertest, + log, + getCustomQueryRuleParams({ rule_id: 'rule-1', threat: generateThreatArray(2) }) + ); + + const body = await getCoverageOverview(supertest); + + expect(body).to.eql({ + coverage: { + T001: [expectedPrebuiltRule.id], + TA001: [expectedPrebuiltRule.id], + 'T001.001': [expectedPrebuiltRule.id], + T002: [expectedCustomRule.id], + TA002: [expectedCustomRule.id], + 'T002.002': [expectedCustomRule.id], + }, + unmapped_rule_ids: [], + rules_data: { + [expectedPrebuiltRule.id]: { + activity: 'disabled', + name: 'Query with a rule id', + }, + [expectedCustomRule.id]: { + activity: 'disabled', + name: 'Custom query rule', + }, + }, + }); + }); + }); + }); + + describe('error cases', async () => { + it('throws error when request body is not valid', async () => { + const { body } = await supertest + .post(RULE_MANAGEMENT_COVERAGE_OVERVIEW_URL) + .set('kbn-xsrf', 'true') + .set('elastic-api-version', '1') + .set('x-elastic-internal-origin', 'foo') + .send({ filter: { source: ['give me all the rules'] } }) + .expect(400); + + expect(body).to.eql({ + statusCode: 400, + error: 'Bad Request', + message: + '[request body]: Invalid value "give me all the rules" supplied to "filter,source"', + }); + }); + }); + }); + }); +}; + +function generateThreatArray(startIndex: number, count = 1): ThreatArray { + const result: ThreatArray = []; + + for (let i = 0; i < count; ++i) { + const indexName = (i + startIndex).toString().padStart(3, '0'); + + result.push({ + framework: 'MITRE ATT&CK', + tactic: { + id: `TA${indexName}`, + name: `Tactic ${indexName}`, + reference: `http://some-link-${indexName}`, + }, + technique: [ + { + id: `T${indexName}`, + name: `Technique ${indexName}`, + reference: `http://some-technique-link-${indexName}`, + subtechnique: [ + { + id: `T${indexName}.${indexName}`, + name: `Subtechnique ${indexName}`, + reference: `http://some-sub-technique-link-${indexName}`, + }, + ], + }, + ], + }); + } + + return result; +} diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/basic_essentials_license/rule_management/index.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/basic_essentials_license/rule_management/index.ts new file mode 100644 index 0000000000000..84d79ee1dd675 --- /dev/null +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/basic_essentials_license/rule_management/index.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { FtrProviderContext } from '../../../../ftr_provider_context'; + +export default function ({ loadTestFile }: FtrProviderContext) { + describe('Rule management API', function () { + loadTestFile(require.resolve('./coverage_overview')); + }); +} diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/rules/create_non_security_rule.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/rules/create_non_security_rule.ts index 89bb2bbea5725..09bc0f9b81a6d 100644 --- a/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/rules/create_non_security_rule.ts +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/rules/create_non_security_rule.ts @@ -18,6 +18,7 @@ const SIMPLE_APM_RULE_DATA = { windowSize: 30, windowUnit: 'm', anomalySeverityType: 'critical', + anomalyDetectorTypes: ['txLatency'], environment: 'ENVIRONMENT_ALL', }, schedule: { diff --git a/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/rules/get_coverage_overview.ts b/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/rules/get_coverage_overview.ts new file mode 100644 index 0000000000000..f93a29b0ec149 --- /dev/null +++ b/x-pack/test/security_solution_api_integration/test_suites/detections_response/utils/rules/get_coverage_overview.ts @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import type SuperTest from 'supertest'; + +import { + CoverageOverviewFilter, + CoverageOverviewResponse, + RULE_MANAGEMENT_COVERAGE_OVERVIEW_URL, +} from '@kbn/security-solution-plugin/common/api/detection_engine'; + +export const getCoverageOverview = async ( + supertest: SuperTest.SuperTest, + filter?: CoverageOverviewFilter +): Promise => { + const response = await supertest + .post(RULE_MANAGEMENT_COVERAGE_OVERVIEW_URL) + .set('kbn-xsrf', 'true') + .set('elastic-api-version', '1') + .set('x-elastic-internal-origin', 'foo') + .send({ filter }) + .expect(200); + + return response.body; +}; diff --git a/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/rule_management/coverage_overview/coverage_overview.cy.ts b/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/rule_management/coverage_overview/coverage_overview.cy.ts new file mode 100644 index 0000000000000..6a52134d4d738 --- /dev/null +++ b/x-pack/test/security_solution_cypress/cypress/e2e/detection_response/rule_management/coverage_overview/coverage_overview.cy.ts @@ -0,0 +1,439 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + getDuplicateTechniqueThreatData, + getMockThreatData, +} from '@kbn/security-solution-plugin/public/detections/mitre/mitre_tactics_techniques'; +import { Threat } from '@kbn/securitysolution-io-ts-alerting-types'; +import { + COVERAGE_OVERVIEW_ENABLE_ALL_DISABLED_BUTTON, + COVERAGE_OVERVIEW_POPOVER_DISABLED_RULES, + COVERAGE_OVERVIEW_POPOVER_ENABLED_RULES, + COVERAGE_OVERVIEW_TACTIC_DISABLED_STATS, + COVERAGE_OVERVIEW_TACTIC_ENABLED_STATS, + COVERAGE_OVERVIEW_TACTIC_PANEL, +} from '../../../../screens/rules_coverage_overview'; +import { createRule } from '../../../../tasks/api_calls/rules'; +import { visit } from '../../../../tasks/navigation'; +import { RULES_COVERAGE_OVERVIEW_URL } from '../../../../urls/rules_management'; +import { createRuleAssetSavedObject } from '../../../../helpers/rules'; +import { getNewRule } from '../../../../objects/rule'; +import { + createAndInstallMockedPrebuiltRules, + preventPrebuiltRulesPackageInstallation, +} from '../../../../tasks/api_calls/prebuilt_rules'; +import { + deleteAlertsAndRules, + deletePrebuiltRulesAssets, +} from '../../../../tasks/api_calls/common'; +import { login } from '../../../../tasks/login'; +import { + enableAllDisabledRules, + filterCoverageOverviewBySearchBar, + openTechniquePanelByName, + openTechniquePanelByNameAndTacticId, + selectCoverageOverviewActivityFilterOption, + selectCoverageOverviewSourceFilterOption, +} from '../../../../tasks/rules_coverage_overview'; + +// Mitre data used in base case tests +const EnabledPrebuiltRuleMitreData = getMockThreatData()[0]; +const DisabledPrebuiltRuleMitreData = getMockThreatData()[1]; +const EnabledCustomRuleMitreData = getMockThreatData()[2]; +const DisabledCustomRuleMitreData = getMockThreatData()[3]; + +// Mitre data used for duplicate technique tests +const DuplicateTechniqueMitreData1 = getDuplicateTechniqueThreatData()[1]; +const DuplicateTechniqueMitreData2 = getDuplicateTechniqueThreatData()[0]; + +const MockEnabledPrebuiltRuleThreat: Threat = { + framework: 'MITRE ATT&CK', + tactic: { + name: EnabledPrebuiltRuleMitreData.tactic.name, + id: EnabledPrebuiltRuleMitreData.tactic.id, + reference: EnabledPrebuiltRuleMitreData.tactic.reference, + }, + technique: [ + { + id: EnabledPrebuiltRuleMitreData.technique.id, + reference: EnabledPrebuiltRuleMitreData.technique.reference, + name: EnabledPrebuiltRuleMitreData.technique.name, + subtechnique: [ + { + id: EnabledPrebuiltRuleMitreData.subtechnique.id, + name: EnabledPrebuiltRuleMitreData.subtechnique.name, + reference: EnabledPrebuiltRuleMitreData.subtechnique.reference, + }, + ], + }, + { + name: EnabledPrebuiltRuleMitreData.technique.name, + id: EnabledPrebuiltRuleMitreData.technique.id, + reference: EnabledPrebuiltRuleMitreData.technique.reference, + subtechnique: [], + }, + ], +}; + +const MockDisabledPrebuiltRuleThreat: Threat = { + framework: 'MITRE ATT&CK', + tactic: { + name: DisabledPrebuiltRuleMitreData.tactic.name, + id: DisabledPrebuiltRuleMitreData.tactic.id, + reference: DisabledPrebuiltRuleMitreData.tactic.reference, + }, + technique: [ + { + id: DisabledPrebuiltRuleMitreData.technique.id, + reference: DisabledPrebuiltRuleMitreData.technique.reference, + name: DisabledPrebuiltRuleMitreData.technique.name, + subtechnique: [ + { + id: DisabledPrebuiltRuleMitreData.subtechnique.id, + name: DisabledPrebuiltRuleMitreData.subtechnique.name, + reference: DisabledPrebuiltRuleMitreData.subtechnique.reference, + }, + ], + }, + ], +}; + +const MockEnabledCustomRuleThreat: Threat = { + framework: 'MITRE ATT&CK', + tactic: { + name: EnabledCustomRuleMitreData.tactic.name, + id: EnabledCustomRuleMitreData.tactic.id, + reference: EnabledCustomRuleMitreData.tactic.reference, + }, + technique: [ + { + id: EnabledCustomRuleMitreData.technique.id, + reference: EnabledCustomRuleMitreData.technique.reference, + name: EnabledCustomRuleMitreData.technique.name, + subtechnique: [ + { + id: EnabledCustomRuleMitreData.subtechnique.id, + name: EnabledCustomRuleMitreData.subtechnique.name, + reference: EnabledCustomRuleMitreData.subtechnique.reference, + }, + ], + }, + ], +}; + +const MockDisabledCustomRuleThreat: Threat = { + framework: 'MITRE ATT&CK', + tactic: { + name: DisabledCustomRuleMitreData.tactic.name, + id: DisabledCustomRuleMitreData.tactic.id, + reference: DisabledCustomRuleMitreData.tactic.reference, + }, + technique: [ + { + id: DisabledCustomRuleMitreData.technique.id, + reference: DisabledCustomRuleMitreData.technique.reference, + name: DisabledCustomRuleMitreData.technique.name, + }, + ], +}; + +const MockCustomRuleDuplicateTechniqueThreat1: Threat = { + framework: 'MITRE ATT&CK', + tactic: { + name: DuplicateTechniqueMitreData1.tactic.name, + id: DuplicateTechniqueMitreData1.tactic.id, + reference: DuplicateTechniqueMitreData1.tactic.reference, + }, + technique: [ + { + id: DuplicateTechniqueMitreData1.technique.id, + reference: DuplicateTechniqueMitreData1.technique.reference, + name: DuplicateTechniqueMitreData1.technique.name, + }, + ], +}; + +const MockCustomRuleDuplicateTechniqueThreat2: Threat = { + framework: 'MITRE ATT&CK', + tactic: { + name: DuplicateTechniqueMitreData2.tactic.name, + id: DuplicateTechniqueMitreData2.tactic.id, + reference: DuplicateTechniqueMitreData2.tactic.reference, + }, + technique: [ + { + id: DuplicateTechniqueMitreData2.technique.id, + reference: DuplicateTechniqueMitreData2.technique.reference, + name: DuplicateTechniqueMitreData2.technique.name, + }, + ], +}; + +const prebuiltRules = [ + createRuleAssetSavedObject({ + name: `Enabled prebuilt rule`, + rule_id: `enabled_prebuilt_rule`, + enabled: true, + threat: [MockEnabledPrebuiltRuleThreat], + }), + createRuleAssetSavedObject({ + name: `Disabled prebuilt rule`, + rule_id: `disabled_prebuilt_rule`, + enabled: false, + threat: [MockDisabledPrebuiltRuleThreat], + }), +]; + +describe('Coverage overview', { tags: ['@ess', '@serverless'] }, () => { + describe('base cases', () => { + beforeEach(() => { + login(); + deleteAlertsAndRules(); + deletePrebuiltRulesAssets(); + preventPrebuiltRulesPackageInstallation(); + createAndInstallMockedPrebuiltRules(prebuiltRules); + createRule( + getNewRule({ + rule_id: 'enabled_custom_rule', + enabled: true, + name: 'Enabled custom rule', + threat: [MockEnabledCustomRuleThreat], + }) + ); + createRule( + getNewRule({ + rule_id: 'disabled_custom_rule', + name: 'Disabled custom rule', + enabled: false, + threat: [MockDisabledCustomRuleThreat], + }) + ); + visit(RULES_COVERAGE_OVERVIEW_URL); + }); + + it('technique panel renders custom and prebuilt rule data on page load', () => { + openTechniquePanelByName(EnabledPrebuiltRuleMitreData.technique.name); + cy.get(COVERAGE_OVERVIEW_POPOVER_ENABLED_RULES).contains('Enabled prebuilt rule'); + cy.get(COVERAGE_OVERVIEW_POPOVER_ENABLED_RULES) + .contains('Enabled custom rule') + .should('not.exist'); + cy.get(COVERAGE_OVERVIEW_POPOVER_DISABLED_RULES) + .contains('Disabled prebuilt rule') + .should('not.exist'); + cy.get(COVERAGE_OVERVIEW_POPOVER_DISABLED_RULES) + .contains('Disabled custom rule') + .should('not.exist'); + cy.get(COVERAGE_OVERVIEW_ENABLE_ALL_DISABLED_BUTTON).should('be.disabled'); + }); + + describe('filtering tests', () => { + it('filters for all data', () => { + selectCoverageOverviewActivityFilterOption('Disabled rules'); // Activates disabled rules filter as it's off by default on page load + + openTechniquePanelByName(EnabledPrebuiltRuleMitreData.technique.name); + cy.get(COVERAGE_OVERVIEW_POPOVER_ENABLED_RULES).contains('Enabled prebuilt rule'); + + openTechniquePanelByName(DisabledPrebuiltRuleMitreData.technique.name); + cy.get(COVERAGE_OVERVIEW_POPOVER_DISABLED_RULES).contains('Disabled prebuilt rule'); + + openTechniquePanelByName(EnabledCustomRuleMitreData.technique.name); + cy.get(COVERAGE_OVERVIEW_POPOVER_ENABLED_RULES).contains('Enabled custom rule'); + + openTechniquePanelByName(DisabledCustomRuleMitreData.technique.name); + cy.get(COVERAGE_OVERVIEW_POPOVER_DISABLED_RULES).contains('Disabled custom rule'); + }); + + it('filters for disabled and prebuilt rules', () => { + selectCoverageOverviewActivityFilterOption('Enabled rules'); // Disables default filter + selectCoverageOverviewActivityFilterOption('Disabled rules'); // Activates disabled rules filter as it's off by default on page load + selectCoverageOverviewSourceFilterOption('Custom rules'); // Disables default filter + + openTechniquePanelByName(EnabledPrebuiltRuleMitreData.technique.name); + cy.get(COVERAGE_OVERVIEW_POPOVER_ENABLED_RULES) + .contains('Enabled prebuilt rule') + .should('not.exist'); + + openTechniquePanelByName(DisabledPrebuiltRuleMitreData.technique.name); + cy.get(COVERAGE_OVERVIEW_POPOVER_DISABLED_RULES).contains('Disabled prebuilt rule'); + + openTechniquePanelByName(EnabledCustomRuleMitreData.technique.name); + cy.get(COVERAGE_OVERVIEW_POPOVER_ENABLED_RULES) + .contains('Enabled custom rule') + .should('not.exist'); + + openTechniquePanelByName(DisabledCustomRuleMitreData.technique.name); + cy.get(COVERAGE_OVERVIEW_POPOVER_DISABLED_RULES) + .contains('Disabled custom rule') + .should('not.exist'); + }); + + it('filters for only prebuilt rules', () => { + selectCoverageOverviewActivityFilterOption('Disabled rules'); // Activates disabled rules filter as it's off by default on page load + selectCoverageOverviewSourceFilterOption('Custom rules'); // Disables default filter + + openTechniquePanelByName(EnabledPrebuiltRuleMitreData.technique.name); + cy.get(COVERAGE_OVERVIEW_POPOVER_ENABLED_RULES).contains('Enabled prebuilt rule'); + + openTechniquePanelByName(DisabledPrebuiltRuleMitreData.technique.name); + cy.get(COVERAGE_OVERVIEW_POPOVER_DISABLED_RULES).contains('Disabled prebuilt rule'); + + openTechniquePanelByName(EnabledCustomRuleMitreData.technique.name); + cy.get(COVERAGE_OVERVIEW_POPOVER_ENABLED_RULES) + .contains('Enabled custom rule') + .should('not.exist'); + + openTechniquePanelByName(DisabledCustomRuleMitreData.technique.name); + cy.get(COVERAGE_OVERVIEW_POPOVER_DISABLED_RULES) + .contains('Disabled custom rule') + .should('not.exist'); + }); + + it('filters for only custom rules', () => { + selectCoverageOverviewActivityFilterOption('Disabled rules'); // Activates disabled rules filter as it's off by default on page load + selectCoverageOverviewSourceFilterOption('Elastic rules'); // Disables default filter + + openTechniquePanelByName(EnabledPrebuiltRuleMitreData.technique.name); + cy.get(COVERAGE_OVERVIEW_POPOVER_ENABLED_RULES) + .contains('Enabled prebuilt rule') + .should('not.exist'); + + openTechniquePanelByName(DisabledPrebuiltRuleMitreData.technique.name); + cy.get(COVERAGE_OVERVIEW_POPOVER_DISABLED_RULES) + .contains('Disabled prebuilt rule') + .should('not.exist'); + + openTechniquePanelByName(EnabledCustomRuleMitreData.technique.name); + cy.get(COVERAGE_OVERVIEW_POPOVER_ENABLED_RULES).contains('Enabled custom rule'); + + openTechniquePanelByName(DisabledCustomRuleMitreData.technique.name); + cy.get(COVERAGE_OVERVIEW_POPOVER_DISABLED_RULES).contains('Disabled custom rule'); + }); + + it('filters for search term', () => { + filterCoverageOverviewBySearchBar('Enabled custom rule'); // Disables default filter + + openTechniquePanelByName(EnabledPrebuiltRuleMitreData.technique.name); + cy.get(COVERAGE_OVERVIEW_POPOVER_ENABLED_RULES) + .contains('Enabled prebuilt rule') + .should('not.exist'); + + openTechniquePanelByName(DisabledPrebuiltRuleMitreData.technique.name); + cy.get(COVERAGE_OVERVIEW_POPOVER_DISABLED_RULES) + .contains('Disabled prebuilt rule') + .should('not.exist'); + + openTechniquePanelByName(EnabledCustomRuleMitreData.technique.name); + cy.get(COVERAGE_OVERVIEW_POPOVER_ENABLED_RULES).contains('Enabled custom rule'); + + openTechniquePanelByName(DisabledCustomRuleMitreData.technique.name); + cy.get(COVERAGE_OVERVIEW_POPOVER_DISABLED_RULES) + .contains('Disabled custom rule') + .should('not.exist'); + }); + }); + + it('enables all disabled rules', () => { + selectCoverageOverviewActivityFilterOption('Disabled rules'); // Activates disabled rules filter as it's off by default on page load + openTechniquePanelByName(DisabledPrebuiltRuleMitreData.technique.name); + enableAllDisabledRules(); + + // Should now render all rules in "enabled" section + openTechniquePanelByName(DisabledPrebuiltRuleMitreData.technique.name); + cy.get(COVERAGE_OVERVIEW_POPOVER_ENABLED_RULES).contains('Disabled prebuilt rule'); + + // Shouldn't render the rules in "disabled" section + cy.get(COVERAGE_OVERVIEW_POPOVER_DISABLED_RULES) + .contains('Disabled prebuilt rule') + .should('not.exist'); + cy.get(COVERAGE_OVERVIEW_ENABLE_ALL_DISABLED_BUTTON).should('be.disabled'); + }); + }); + + describe('with rules that have identical mitre techniques that belong to multiple tactics', () => { + const SharedTechniqueName = DuplicateTechniqueMitreData1.technique.name; + const TacticOfRule1 = DuplicateTechniqueMitreData1.tactic; + const TacticOfRule2 = DuplicateTechniqueMitreData2.tactic; + + beforeEach(() => { + login(); + deleteAlertsAndRules(); + deletePrebuiltRulesAssets(); + createRule( + getNewRule({ + rule_id: 'duplicate_technique_rule_1', + enabled: true, + name: 'Rule under Persistence tactic', + threat: [MockCustomRuleDuplicateTechniqueThreat1], + }) + ); + createRule( + getNewRule({ + rule_id: 'duplicate_technique_rule_2', + name: 'Rule under Privilege Escalation tactic', + enabled: false, + threat: [MockCustomRuleDuplicateTechniqueThreat2], + }) + ); + visit(RULES_COVERAGE_OVERVIEW_URL); + }); + + it('technique panels render unique rule data', () => { + // Tests to make sure each rule only exists in the specific technique and tactic that's assigned to it + + selectCoverageOverviewActivityFilterOption('Disabled rules'); // Activates disabled rules filter as it's off by default on page load + + // Open duplicated technique panel under Persistence tactic + openTechniquePanelByNameAndTacticId(SharedTechniqueName, TacticOfRule1.id); + + // Only rule 1 data is present + cy.get(COVERAGE_OVERVIEW_POPOVER_ENABLED_RULES).contains('Rule under Persistence tactic'); + cy.get(COVERAGE_OVERVIEW_POPOVER_DISABLED_RULES) + .contains('Rule under Privilege Escalation tactic') + .should('not.exist'); + + // Open duplicated technique panel under Privilege Escalation tactic + openTechniquePanelByNameAndTacticId(SharedTechniqueName, TacticOfRule2.id); + + // Only rule 2 data is present + cy.get(COVERAGE_OVERVIEW_POPOVER_ENABLED_RULES) + .contains('Rule under Persistence tactic') + .should('not.exist'); + cy.get(COVERAGE_OVERVIEW_POPOVER_DISABLED_RULES).contains( + 'Rule under Privilege Escalation tactic' + ); + }); + + it('tactic panels render correct rule stats', () => { + selectCoverageOverviewActivityFilterOption('Disabled rules'); // Activates disabled rules filter as it's off by default on page load + + // Validate rule count stats for the Persistence tactic only show stats based on its own technique + // Enabled rule count + cy.get(COVERAGE_OVERVIEW_TACTIC_PANEL) + .contains(TacticOfRule1.name) + .get(COVERAGE_OVERVIEW_TACTIC_ENABLED_STATS) + .contains('0'); + // Disabled rule count + cy.get(COVERAGE_OVERVIEW_TACTIC_PANEL) + .contains(TacticOfRule1.name) + .get(COVERAGE_OVERVIEW_TACTIC_DISABLED_STATS) + .contains('1'); + + // Validate rule count stats for the Privilege Escalation tactic only show stats based on its own technique + // Enabled rule count + cy.get(COVERAGE_OVERVIEW_TACTIC_PANEL) + .contains(TacticOfRule2.name) + .get(COVERAGE_OVERVIEW_TACTIC_ENABLED_STATS) + .contains('1'); + // Disabled rule count + cy.get(COVERAGE_OVERVIEW_TACTIC_PANEL) + .contains(TacticOfRule2.name) + .get(COVERAGE_OVERVIEW_TACTIC_DISABLED_STATS) + .contains('0'); + }); + }); +}); diff --git a/x-pack/test/security_solution_cypress/cypress/objects/rule.ts b/x-pack/test/security_solution_cypress/cypress/objects/rule.ts index 681eff67d071e..5b20fe24b6c7a 100644 --- a/x-pack/test/security_solution_cypress/cypress/objects/rule.ts +++ b/x-pack/test/security_solution_cypress/cypress/objects/rule.ts @@ -39,27 +39,27 @@ export const getThreatIndexPatterns = (): string[] => ['logs-ti_*']; const getMitre1 = (): Threat => ({ framework: 'MITRE ATT&CK', tactic: { - name: getMockThreatData().tactic.name, - id: getMockThreatData().tactic.id, - reference: getMockThreatData().tactic.reference, + name: getMockThreatData()[0].tactic.name, + id: getMockThreatData()[0].tactic.id, + reference: getMockThreatData()[0].tactic.reference, }, technique: [ { - id: getMockThreatData().technique.id, - reference: getMockThreatData().technique.reference, - name: getMockThreatData().technique.name, + id: getMockThreatData()[0].technique.id, + reference: getMockThreatData()[0].technique.reference, + name: getMockThreatData()[0].technique.name, subtechnique: [ { - id: getMockThreatData().subtechnique.id, - name: getMockThreatData().subtechnique.name, - reference: getMockThreatData().subtechnique.reference, + id: getMockThreatData()[0].subtechnique.id, + name: getMockThreatData()[0].subtechnique.name, + reference: getMockThreatData()[0].subtechnique.reference, }, ], }, { - name: getMockThreatData().technique.name, - id: getMockThreatData().technique.id, - reference: getMockThreatData().technique.reference, + name: getMockThreatData()[0].technique.name, + id: getMockThreatData()[0].technique.id, + reference: getMockThreatData()[0].technique.reference, subtechnique: [], }, ], @@ -68,20 +68,20 @@ const getMitre1 = (): Threat => ({ const getMitre2 = (): Threat => ({ framework: 'MITRE ATT&CK', tactic: { - name: getMockThreatData().tactic.name, - id: getMockThreatData().tactic.id, - reference: getMockThreatData().tactic.reference, + name: getMockThreatData()[1].tactic.name, + id: getMockThreatData()[1].tactic.id, + reference: getMockThreatData()[1].tactic.reference, }, technique: [ { - id: getMockThreatData().technique.id, - reference: getMockThreatData().technique.reference, - name: getMockThreatData().technique.name, + id: getMockThreatData()[1].technique.id, + reference: getMockThreatData()[1].technique.reference, + name: getMockThreatData()[1].technique.name, subtechnique: [ { - id: getMockThreatData().subtechnique.id, - name: getMockThreatData().subtechnique.name, - reference: getMockThreatData().subtechnique.reference, + id: getMockThreatData()[1].subtechnique.id, + name: getMockThreatData()[1].subtechnique.name, + reference: getMockThreatData()[1].subtechnique.reference, }, ], }, diff --git a/x-pack/test/security_solution_cypress/cypress/screens/rules_coverage_overview.ts b/x-pack/test/security_solution_cypress/cypress/screens/rules_coverage_overview.ts new file mode 100644 index 0000000000000..bf696867e61cf --- /dev/null +++ b/x-pack/test/security_solution_cypress/cypress/screens/rules_coverage_overview.ts @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const COVERAGE_OVERVIEW_TECHNIQUE_PANEL = + '[data-test-subj="coverageOverviewTechniquePanel"]'; + +export const COVERAGE_OVERVIEW_TECHNIQUE_PANEL_IN_TACTIC_GROUP = (id: string) => + `[data-test-subj="coverageOverviewTacticGroup-${id}"] [data-test-subj="coverageOverviewTechniquePanel"]`; + +export const COVERAGE_OVERVIEW_POPOVER_ENABLED_RULES = + '[data-test-subj="coverageOverviewEnabledRulesList"]'; + +export const COVERAGE_OVERVIEW_POPOVER_DISABLED_RULES = + '[data-test-subj="coverageOverviewDisabledRulesList"]'; + +export const COVERAGE_OVERVIEW_ENABLE_ALL_DISABLED_BUTTON = + '[data-test-subj="enableAllDisabledButton"]'; + +export const COVERAGE_OVERVIEW_ACTIVITY_FILTER_BUTTON = + '[data-test-subj="coverageOverviewRuleActivityFilterButton"]'; + +export const COVERAGE_OVERVIEW_SOURCE_FILTER_BUTTON = + '[data-test-subj="coverageOverviewRuleSourceFilterButton"]'; + +export const COVERAGE_OVERVIEW_FILTER_LIST = '[data-test-subj="coverageOverviewFilterList"]'; + +export const COVERAGE_OVERVIEW_SEARCH_BAR = '[data-test-subj="coverageOverviewFilterSearchBar"]'; + +export const COVERAGE_OVERVIEW_TACTIC_PANEL = '[data-test-subj="coverageOverviewTacticPanel"]'; + +export const COVERAGE_OVERVIEW_TACTIC_ENABLED_STATS = + '[data-test-subj="ruleStatsEnabledRulesCount"]'; + +export const COVERAGE_OVERVIEW_TACTIC_DISABLED_STATS = + '[data-test-subj="ruleStatsDisabledRulesCount"]'; diff --git a/x-pack/test/security_solution_cypress/cypress/tasks/rules_coverage_overview.ts b/x-pack/test/security_solution_cypress/cypress/tasks/rules_coverage_overview.ts new file mode 100644 index 0000000000000..5f8f18bb8a36b --- /dev/null +++ b/x-pack/test/security_solution_cypress/cypress/tasks/rules_coverage_overview.ts @@ -0,0 +1,51 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + COVERAGE_OVERVIEW_ACTIVITY_FILTER_BUTTON, + COVERAGE_OVERVIEW_ENABLE_ALL_DISABLED_BUTTON, + COVERAGE_OVERVIEW_FILTER_LIST, + COVERAGE_OVERVIEW_SEARCH_BAR, + COVERAGE_OVERVIEW_SOURCE_FILTER_BUTTON, + COVERAGE_OVERVIEW_TECHNIQUE_PANEL_IN_TACTIC_GROUP, + COVERAGE_OVERVIEW_TECHNIQUE_PANEL, +} from '../screens/rules_coverage_overview'; +import { LOADING_INDICATOR } from '../screens/security_header'; + +export const openTechniquePanelByName = (label: string) => { + cy.get(COVERAGE_OVERVIEW_TECHNIQUE_PANEL).contains(label).click(); +}; + +export const openTechniquePanelByNameAndTacticId = (label: string, tacticId: string) => { + cy.get(COVERAGE_OVERVIEW_TECHNIQUE_PANEL_IN_TACTIC_GROUP(tacticId)).contains(label).click(); +}; + +export const selectCoverageOverviewActivityFilterOption = (option: string) => { + cy.get(COVERAGE_OVERVIEW_ACTIVITY_FILTER_BUTTON).click(); // open filter popover + cy.get(COVERAGE_OVERVIEW_FILTER_LIST).contains(option).click(); + cy.get(LOADING_INDICATOR).should('not.exist'); + cy.get(COVERAGE_OVERVIEW_ACTIVITY_FILTER_BUTTON).click(); // close filter popover +}; + +export const selectCoverageOverviewSourceFilterOption = (option: string) => { + cy.get(COVERAGE_OVERVIEW_SOURCE_FILTER_BUTTON).click(); // open filter popover + cy.get(COVERAGE_OVERVIEW_FILTER_LIST).contains(option).click(); + cy.get(LOADING_INDICATOR).should('not.exist'); + cy.get(COVERAGE_OVERVIEW_SOURCE_FILTER_BUTTON).click(); // close filter popover +}; + +export const filterCoverageOverviewBySearchBar = (searchTerm: string) => { + cy.get(COVERAGE_OVERVIEW_SEARCH_BAR).type(`${searchTerm}`); + cy.get(COVERAGE_OVERVIEW_SEARCH_BAR).focus(); + cy.get(COVERAGE_OVERVIEW_SEARCH_BAR).realType('{enter}'); +}; + +export const enableAllDisabledRules = () => { + cy.get(COVERAGE_OVERVIEW_ENABLE_ALL_DISABLED_BUTTON).click(); + cy.get(COVERAGE_OVERVIEW_ENABLE_ALL_DISABLED_BUTTON).should('not.exist'); + cy.get(LOADING_INDICATOR).should('not.exist'); +}; diff --git a/x-pack/test/security_solution_cypress/cypress/urls/rules_management.ts b/x-pack/test/security_solution_cypress/cypress/urls/rules_management.ts index 6b0f4bc231247..03af67cfe79db 100644 --- a/x-pack/test/security_solution_cypress/cypress/urls/rules_management.ts +++ b/x-pack/test/security_solution_cypress/cypress/urls/rules_management.ts @@ -7,3 +7,4 @@ export const RULES_MANAGEMENT_URL = '/app/security/rules/management'; export const RULES_MONITORING_URL = '/app/security/rules/monitoring'; +export const RULES_COVERAGE_OVERVIEW_URL = '/app/security/rules_coverage_overview';