From be11d9dc0f5d89ba6b705988cc2c6a7bc06fbb4a Mon Sep 17 00:00:00 2001 From: Davis Plumlee <56367316+dplumlee@users.noreply.github.com> Date: Mon, 7 Aug 2023 23:03:52 -0400 Subject: [PATCH] [Security Solution] Coverage Overview Dashboard (#161556) --- .../rule_management/api/api.ts | 23 +- .../use_perform_all_rules_install_mutation.ts | 3 + .../use_perform_all_rules_upgrade_mutation.ts | 3 + ...perform_specific_rules_install_mutation.ts | 3 + ...perform_specific_rules_upgrade_mutation.ts | 3 + .../api/hooks/use_bulk_action_mutation.ts | 6 + .../api/hooks/use_create_rule_mutation.ts | 3 + .../api/hooks/use_fetch_coverage_overview.ts | 61 ++++++ .../api/hooks/use_update_rule_mutation.ts | 3 + ...build_coverage_overview_dashboard_model.ts | 124 +++++++++++ ...uild_coverage_overview_mitre_graph.test.ts | 124 +++++++++++ .../build_coverage_overview_mitre_graph.ts | 85 ++++++++ .../rule_management/logic/types.ts | 10 +- .../coverage_overview/__mocks__/index.ts | 59 ++++++ .../coverage_overview/mitre_subtechnique.ts | 20 ++ .../model/coverage_overview/mitre_tactic.ts | 1 + .../coverage_overview/mitre_technique.ts | 11 +- .../pages/coverage_overview/constants.ts | 26 +++ .../coverage_overview_page.test.tsx | 42 ++++ .../coverage_overview_page.tsx | 73 +++++++ .../pages/coverage_overview/filters_panel.tsx | 53 +++++ .../pages/coverage_overview/helpers.test.ts | 51 +++++ .../pages/coverage_overview/helpers.ts | 24 +++ .../pages/coverage_overview/index.ts | 8 + .../pages/coverage_overview/index.tsx | 27 --- .../pages/coverage_overview/reducer.ts | 32 +++ .../shared_components/dashboard_legend.tsx | 73 +++++++ .../shared_components/panel_metadata.tsx | 57 +++++ .../shared_components/popover_list_header.tsx | 32 +++ .../coverage_overview/tactic_panel.test.tsx | 34 +++ .../pages/coverage_overview/tactic_panel.tsx | 79 +++++++ .../technique_panel.test.tsx | 47 +++++ .../coverage_overview/technique_panel.tsx | 96 +++++++++ .../technique_panel_popover.test.tsx | 82 ++++++++ .../technique_panel_popover.tsx | 199 ++++++++++++++++++ .../pages/coverage_overview/translations.ts | 92 ++++++++ .../pages/rule_management/index.tsx | 9 +- .../public/detections/mitre/types.ts | 21 ++ 38 files changed, 1660 insertions(+), 39 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/detection_engine/rule_management/api/hooks/use_fetch_coverage_overview.ts create mode 100644 x-pack/plugins/security_solution/public/detection_engine/rule_management/logic/coverage_overview/build_coverage_overview_dashboard_model.ts create mode 100644 x-pack/plugins/security_solution/public/detection_engine/rule_management/logic/coverage_overview/build_coverage_overview_mitre_graph.test.ts create mode 100644 x-pack/plugins/security_solution/public/detection_engine/rule_management/logic/coverage_overview/build_coverage_overview_mitre_graph.ts create mode 100644 x-pack/plugins/security_solution/public/detection_engine/rule_management/model/coverage_overview/__mocks__/index.ts create mode 100644 x-pack/plugins/security_solution/public/detection_engine/rule_management/model/coverage_overview/mitre_subtechnique.ts create mode 100644 x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/pages/coverage_overview/constants.ts create mode 100644 x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/pages/coverage_overview/coverage_overview_page.test.tsx create mode 100644 x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/pages/coverage_overview/coverage_overview_page.tsx create mode 100644 x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/pages/coverage_overview/filters_panel.tsx create mode 100644 x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/pages/coverage_overview/helpers.test.ts create mode 100644 x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/pages/coverage_overview/helpers.ts create mode 100644 x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/pages/coverage_overview/index.ts delete mode 100644 x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/pages/coverage_overview/index.tsx create mode 100644 x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/pages/coverage_overview/reducer.ts create mode 100644 x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/pages/coverage_overview/shared_components/dashboard_legend.tsx create mode 100644 x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/pages/coverage_overview/shared_components/panel_metadata.tsx create mode 100644 x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/pages/coverage_overview/shared_components/popover_list_header.tsx create mode 100644 x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/pages/coverage_overview/tactic_panel.test.tsx create mode 100644 x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/pages/coverage_overview/tactic_panel.tsx create mode 100644 x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/pages/coverage_overview/technique_panel.test.tsx create mode 100644 x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/pages/coverage_overview/technique_panel.tsx create mode 100644 x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/pages/coverage_overview/technique_panel_popover.test.tsx create mode 100644 x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/pages/coverage_overview/technique_panel_popover.tsx diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/api.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/api.ts index 296ef675b28df..d62114881adad 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/api.ts +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/api.ts @@ -26,8 +26,14 @@ import type { ReviewRuleUpgradeResponseBody, ReviewRuleInstallationResponseBody, } from '../../../../common/api/detection_engine/prebuilt_rules'; -import type { GetRuleManagementFiltersResponse } from '../../../../common/api/detection_engine/rule_management'; -import { RULE_MANAGEMENT_FILTERS_URL } from '../../../../common/api/detection_engine/rule_management'; +import type { + CoverageOverviewResponse, + GetRuleManagementFiltersResponse, +} from '../../../../common/api/detection_engine/rule_management'; +import { + RULE_MANAGEMENT_FILTERS_URL, + RULE_MANAGEMENT_COVERAGE_OVERVIEW_URL, +} from '../../../../common/api/detection_engine/rule_management'; import type { BulkActionsDryRunErrCode } from '../../../../common/constants'; import { DETECTION_ENGINE_RULES_BULK_ACTION, @@ -60,6 +66,7 @@ import * as i18n from '../../../detections/pages/detection_engine/rules/translat import type { CreateRulesProps, ExportDocumentsProps, + FetchCoverageOverviewProps, FetchRuleProps, FetchRuleSnoozingProps, FetchRulesProps, @@ -244,6 +251,18 @@ export const fetchConnectors = ( ): Promise>> => KibanaServices.get().http.fetch(`${BASE_ACTION_API_PATH}/connectors`, { method: 'GET', signal }); +export const fetchCoverageOverview = async ({ + filter, + signal, +}: FetchCoverageOverviewProps): Promise => + KibanaServices.get().http.fetch(RULE_MANAGEMENT_COVERAGE_OVERVIEW_URL, { + method: 'POST', + body: JSON.stringify({ + filter, + }), + signal, + }); + export const fetchConnectorTypes = (signal?: AbortSignal): Promise => KibanaServices.get().http.fetch(`${BASE_ACTION_API_PATH}/connector_types`, { method: 'GET', diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/hooks/prebuilt_rules/use_perform_all_rules_install_mutation.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/hooks/prebuilt_rules/use_perform_all_rules_install_mutation.ts index f5eb06037c75c..13e44682d95e8 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/hooks/prebuilt_rules/use_perform_all_rules_install_mutation.ts +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/hooks/prebuilt_rules/use_perform_all_rules_install_mutation.ts @@ -14,6 +14,7 @@ import { useInvalidateFetchRuleManagementFiltersQuery } from '../use_fetch_rule_ import { useInvalidateFetchRulesSnoozeSettingsQuery } from '../use_fetch_rules_snooze_settings'; import { useInvalidateFetchPrebuiltRulesInstallReviewQuery } from './use_fetch_prebuilt_rules_install_review_query'; import { performInstallAllRules } from '../../api'; +import { useInvalidateFetchCoverageOverviewQuery } from '../use_fetch_coverage_overview'; export const PERFORM_ALL_RULES_INSTALLATION_KEY = [ 'POST', @@ -30,6 +31,7 @@ export const usePerformAllRulesInstallMutation = ( const invalidateFetchPrebuiltRulesInstallReview = useInvalidateFetchPrebuiltRulesInstallReviewQuery(); const invalidateRuleStatus = useInvalidateFetchPrebuiltRulesStatusQuery(); + const invalidateFetchCoverageOverviewQuery = useInvalidateFetchCoverageOverviewQuery(); return useMutation(() => performInstallAllRules(), { ...options, @@ -41,6 +43,7 @@ export const usePerformAllRulesInstallMutation = ( invalidateFetchPrebuiltRulesInstallReview(); invalidateRuleStatus(); + invalidateFetchCoverageOverviewQuery(); if (options?.onSettled) { options.onSettled(...args); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/hooks/prebuilt_rules/use_perform_all_rules_upgrade_mutation.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/hooks/prebuilt_rules/use_perform_all_rules_upgrade_mutation.ts index 07555ed65f307..4a4bee4779f7e 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/hooks/prebuilt_rules/use_perform_all_rules_upgrade_mutation.ts +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/hooks/prebuilt_rules/use_perform_all_rules_upgrade_mutation.ts @@ -14,6 +14,7 @@ import { useInvalidateFetchRulesSnoozeSettingsQuery } from '../use_fetch_rules_s import { useInvalidateFetchPrebuiltRulesUpgradeReviewQuery } from './use_fetch_prebuilt_rules_upgrade_review_query'; import { useInvalidateFetchPrebuiltRulesStatusQuery } from './use_fetch_prebuilt_rules_status_query'; import { performUpgradeAllRules } from '../../api'; +import { useInvalidateFetchCoverageOverviewQuery } from '../use_fetch_coverage_overview'; export const PERFORM_ALL_RULES_UPGRADE_KEY = ['POST', 'ALL_RULES', PERFORM_RULE_UPGRADE_URL]; @@ -26,6 +27,7 @@ export const usePerformAllRulesUpgradeMutation = ( const invalidateFetchPrebuiltRulesUpgradeReview = useInvalidateFetchPrebuiltRulesUpgradeReviewQuery(); const invalidateRuleStatus = useInvalidateFetchPrebuiltRulesStatusQuery(); + const invalidateFetchCoverageOverviewQuery = useInvalidateFetchCoverageOverviewQuery(); return useMutation(() => performUpgradeAllRules(), { ...options, @@ -37,6 +39,7 @@ export const usePerformAllRulesUpgradeMutation = ( invalidateFetchPrebuiltRulesUpgradeReview(); invalidateRuleStatus(); + invalidateFetchCoverageOverviewQuery(); if (options?.onSettled) { options.onSettled(...args); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/hooks/prebuilt_rules/use_perform_specific_rules_install_mutation.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/hooks/prebuilt_rules/use_perform_specific_rules_install_mutation.ts index 1867b0c4e3405..9b12d4901e0fb 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/hooks/prebuilt_rules/use_perform_specific_rules_install_mutation.ts +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/hooks/prebuilt_rules/use_perform_specific_rules_install_mutation.ts @@ -17,6 +17,7 @@ import { useInvalidateFetchRuleManagementFiltersQuery } from '../use_fetch_rule_ import { useInvalidateFetchRulesSnoozeSettingsQuery } from '../use_fetch_rules_snooze_settings'; import { useInvalidateFetchPrebuiltRulesInstallReviewQuery } from './use_fetch_prebuilt_rules_install_review_query'; import { performInstallSpecificRules } from '../../api'; +import { useInvalidateFetchCoverageOverviewQuery } from '../use_fetch_coverage_overview'; export const PERFORM_SPECIFIC_RULES_INSTALLATION_KEY = [ 'POST', @@ -38,6 +39,7 @@ export const usePerformSpecificRulesInstallMutation = ( const invalidateFetchPrebuiltRulesInstallReview = useInvalidateFetchPrebuiltRulesInstallReviewQuery(); const invalidateRuleStatus = useInvalidateFetchPrebuiltRulesStatusQuery(); + const invalidateFetchCoverageOverviewQuery = useInvalidateFetchCoverageOverviewQuery(); return useMutation< PerformRuleInstallationResponseBody, @@ -58,6 +60,7 @@ export const usePerformSpecificRulesInstallMutation = ( invalidateFetchPrebuiltRulesInstallReview(); invalidateRuleStatus(); + invalidateFetchCoverageOverviewQuery(); if (options?.onSettled) { options.onSettled(...args); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/hooks/prebuilt_rules/use_perform_specific_rules_upgrade_mutation.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/hooks/prebuilt_rules/use_perform_specific_rules_upgrade_mutation.ts index 925009a3ca29a..0c67b349302af 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/hooks/prebuilt_rules/use_perform_specific_rules_upgrade_mutation.ts +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/hooks/prebuilt_rules/use_perform_specific_rules_upgrade_mutation.ts @@ -17,6 +17,7 @@ import { useInvalidateFetchRuleManagementFiltersQuery } from '../use_fetch_rule_ import { useInvalidateFetchRulesSnoozeSettingsQuery } from '../use_fetch_rules_snooze_settings'; import { performUpgradeSpecificRules } from '../../api'; import { useInvalidateFetchPrebuiltRulesUpgradeReviewQuery } from './use_fetch_prebuilt_rules_upgrade_review_query'; +import { useInvalidateFetchCoverageOverviewQuery } from '../use_fetch_coverage_overview'; export const PERFORM_SPECIFIC_RULES_UPGRADE_KEY = [ 'POST', @@ -38,6 +39,7 @@ export const usePerformSpecificRulesUpgradeMutation = ( const invalidateFetchPrebuiltRulesUpgradeReview = useInvalidateFetchPrebuiltRulesUpgradeReviewQuery(); const invalidateRuleStatus = useInvalidateFetchPrebuiltRulesStatusQuery(); + const invalidateFetchCoverageOverviewQuery = useInvalidateFetchCoverageOverviewQuery(); return useMutation( (rulesToUpgrade: UpgradeSpecificRulesRequest['rules']) => { @@ -54,6 +56,7 @@ export const usePerformSpecificRulesUpgradeMutation = ( invalidateFetchPrebuiltRulesUpgradeReview(); invalidateRuleStatus(); + invalidateFetchCoverageOverviewQuery(); if (options?.onSettled) { options.onSettled(...args); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/hooks/use_bulk_action_mutation.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/hooks/use_bulk_action_mutation.ts index aa8aec37a8bbe..bade054295965 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/hooks/use_bulk_action_mutation.ts +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/hooks/use_bulk_action_mutation.ts @@ -17,6 +17,7 @@ import { useInvalidateFetchRuleManagementFiltersQuery } from './use_fetch_rule_m import { useInvalidateFetchPrebuiltRulesStatusQuery } from './prebuilt_rules/use_fetch_prebuilt_rules_status_query'; import { useInvalidateFetchPrebuiltRulesUpgradeReviewQuery } from './prebuilt_rules/use_fetch_prebuilt_rules_upgrade_review_query'; import { useInvalidateFetchPrebuiltRulesInstallReviewQuery } from './prebuilt_rules/use_fetch_prebuilt_rules_install_review_query'; +import { useInvalidateFetchCoverageOverviewQuery } from './use_fetch_coverage_overview'; export const BULK_ACTION_MUTATION_KEY = ['POST', DETECTION_ENGINE_RULES_BULK_ACTION]; @@ -35,6 +36,7 @@ export const useBulkActionMutation = ( useInvalidateFetchPrebuiltRulesInstallReviewQuery(); const invalidateFetchPrebuiltRulesUpgradeReviewQuery = useInvalidateFetchPrebuiltRulesUpgradeReviewQuery(); + const invalidateFetchCoverageOverviewQuery = useInvalidateFetchCoverageOverviewQuery(); const updateRulesCache = useUpdateRulesCache(); return useMutation< @@ -60,6 +62,7 @@ export const useBulkActionMutation = ( case BulkActionType.enable: case BulkActionType.disable: { invalidateFetchRuleByIdQuery(); + invalidateFetchCoverageOverviewQuery(); if (updatedRules) { // We have a list of updated rules, no need to invalidate all updateRulesCache(updatedRules); @@ -76,10 +79,12 @@ export const useBulkActionMutation = ( invalidateFetchPrebuiltRulesStatusQuery(); invalidateFetchPrebuiltRulesInstallReviewQuery(); invalidateFetchPrebuiltRulesUpgradeReviewQuery(); + invalidateFetchCoverageOverviewQuery(); break; case BulkActionType.duplicate: invalidateFindRulesQuery(); invalidateFetchRuleManagementFilters(); + invalidateFetchCoverageOverviewQuery(); break; case BulkActionType.edit: if (updatedRules) { @@ -91,6 +96,7 @@ export const useBulkActionMutation = ( } invalidateFetchRuleByIdQuery(); invalidateFetchRuleManagementFilters(); + invalidateFetchCoverageOverviewQuery(); break; } diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/hooks/use_create_rule_mutation.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/hooks/use_create_rule_mutation.ts index 16effcc78cf8e..199cf558137cf 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/hooks/use_create_rule_mutation.ts +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/hooks/use_create_rule_mutation.ts @@ -15,6 +15,7 @@ import { transformOutput } from '../../../../detections/containers/detection_eng import { createRule } from '../api'; import { useInvalidateFetchRuleManagementFiltersQuery } from './use_fetch_rule_management_filters_query'; import { useInvalidateFindRulesQuery } from './use_find_rules_query'; +import { useInvalidateFetchCoverageOverviewQuery } from './use_fetch_coverage_overview'; export const CREATE_RULE_MUTATION_KEY = ['POST', DETECTION_ENGINE_RULES_URL]; @@ -23,6 +24,7 @@ export const useCreateRuleMutation = ( ) => { const invalidateFindRulesQuery = useInvalidateFindRulesQuery(); const invalidateFetchRuleManagementFilters = useInvalidateFetchRuleManagementFiltersQuery(); + const invalidateFetchCoverageOverviewQuery = useInvalidateFetchCoverageOverviewQuery(); return useMutation( (rule: RuleCreateProps) => createRule({ rule: transformOutput(rule) }), @@ -32,6 +34,7 @@ export const useCreateRuleMutation = ( onSettled: (...args) => { invalidateFindRulesQuery(); invalidateFetchRuleManagementFilters(); + invalidateFetchCoverageOverviewQuery(); if (options?.onSettled) { options.onSettled(...args); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/hooks/use_fetch_coverage_overview.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/hooks/use_fetch_coverage_overview.ts new file mode 100644 index 0000000000000..2866245bb4e87 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/hooks/use_fetch_coverage_overview.ts @@ -0,0 +1,61 @@ +/* + * 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 { UseQueryOptions } from '@tanstack/react-query'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { useCallback } from 'react'; +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'; + +const COVERAGE_OVERVIEW_QUERY_KEY = ['POST', RULE_MANAGEMENT_COVERAGE_OVERVIEW_URL]; + +/** + * A wrapper around useQuery provides default values to the underlying query, + * like query key, abortion signal, and error handler. + * + * @param filter - coverage overview filter, see CoverageOverviewFilter type + * @param options - react-query options + * @returns useQuery result + */ +export const useFetchCoverageOverviewQuery = ( + filter: CoverageOverviewFilter = {}, + options?: UseQueryOptions +) => { + return useQuery( + [...COVERAGE_OVERVIEW_QUERY_KEY, filter], + async ({ signal }) => { + const response = await fetchCoverageOverview({ signal, filter }); + + return buildCoverageOverviewDashboardModel(response); + }, + { + ...DEFAULT_QUERY_OPTIONS, + ...options, + } + ); +}; + +/** + * We should use this hook to invalidate the coverage overview cache. For example, rule + * mutations that affect rule set size, like creation or deletion, should lead + * to cache invalidation. + * + * @returns A coverage overview cache invalidation callback + */ +export const useInvalidateFetchCoverageOverviewQuery = () => { + const queryClient = useQueryClient(); + + return useCallback(() => { + queryClient.invalidateQueries(COVERAGE_OVERVIEW_QUERY_KEY, { + refetchType: 'active', + }); + }, [queryClient]); +}; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/hooks/use_update_rule_mutation.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/hooks/use_update_rule_mutation.ts index f6167c967bbe6..53103287046be 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/hooks/use_update_rule_mutation.ts +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/api/hooks/use_update_rule_mutation.ts @@ -16,6 +16,7 @@ import { updateRule } from '../api'; import { useInvalidateFindRulesQuery } from './use_find_rules_query'; import { useInvalidateFetchRuleByIdQuery } from './use_fetch_rule_by_id_query'; import { useInvalidateFetchRuleManagementFiltersQuery } from './use_fetch_rule_management_filters_query'; +import { useInvalidateFetchCoverageOverviewQuery } from './use_fetch_coverage_overview'; export const UPDATE_RULE_MUTATION_KEY = ['PUT', DETECTION_ENGINE_RULES_URL]; @@ -25,6 +26,7 @@ export const useUpdateRuleMutation = ( const invalidateFindRulesQuery = useInvalidateFindRulesQuery(); const invalidateFetchRuleManagementFilters = useInvalidateFetchRuleManagementFiltersQuery(); const invalidateFetchRuleByIdQuery = useInvalidateFetchRuleByIdQuery(); + const invalidateFetchCoverageOverviewQuery = useInvalidateFetchCoverageOverviewQuery(); return useMutation( (rule: RuleUpdateProps) => updateRule({ rule: transformOutput(rule) }), @@ -35,6 +37,7 @@ export const useUpdateRuleMutation = ( invalidateFindRulesQuery(); invalidateFetchRuleByIdQuery(); invalidateFetchRuleManagementFilters(); + invalidateFetchCoverageOverviewQuery(); if (options?.onSettled) { options.onSettled(...args); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/logic/coverage_overview/build_coverage_overview_dashboard_model.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_management/logic/coverage_overview/build_coverage_overview_dashboard_model.ts new file mode 100644 index 0000000000000..8f0275ba8d0c5 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/logic/coverage_overview/build_coverage_overview_dashboard_model.ts @@ -0,0 +1,124 @@ +/* + * 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 { + CoverageOverviewResponse, + CoverageOverviewRuleAttributes, +} from '../../../../../common/api/detection_engine'; +import { CoverageOverviewRuleActivity } from '../../../../../common/api/detection_engine'; + +import type { CoverageOverviewDashboard } from '../../model/coverage_overview/dashboard'; +import type { CoverageOverviewRule } from '../../model/coverage_overview/rule'; +import { buildCoverageOverviewMitreGraph } from './build_coverage_overview_mitre_graph'; + +const lazyMitreConfiguration = () => { + /** + * The specially formatted comment in the `import` expression causes the corresponding webpack chunk to be named. This aids us in debugging chunk size issues. + * See https://webpack.js.org/api/module-methods/#magic-comments + */ + return import( + /* webpackChunkName: "lazy_mitre_configuration" */ + '../../../../detections/mitre/mitre_tactics_techniques' + ); +}; + +export async function buildCoverageOverviewDashboardModel( + apiResponse: CoverageOverviewResponse +): Promise { + const mitreConfig = await lazyMitreConfiguration(); + const { tactics, technique: techniques, subtechniques } = mitreConfig; + const mitreTactics = buildCoverageOverviewMitreGraph(tactics, techniques, subtechniques); + + for (const tactic of mitreTactics) { + for (const ruleId of apiResponse.coverage[tactic.id] ?? []) { + addRule(tactic, ruleId, apiResponse.rules_data[ruleId]); + } + + for (const technique of tactic.techniques) { + for (const ruleId of apiResponse.coverage[technique.id] ?? []) { + addRule(technique, ruleId, apiResponse.rules_data[ruleId]); + } + + for (const subtechnique of technique.subtechniques) { + for (const ruleId of apiResponse.coverage[subtechnique.id] ?? []) { + addRule(subtechnique, ruleId, apiResponse.rules_data[ruleId]); + } + } + } + } + + return { + mitreTactics, + unmappedRules: buildUnmappedRules(apiResponse), + metrics: calcMetrics(apiResponse.rules_data), + }; +} + +function calcMetrics( + rulesData: Record +): CoverageOverviewDashboard['metrics'] { + const ruleIds = Object.keys(rulesData); + const metrics: CoverageOverviewDashboard['metrics'] = { + totalRulesCount: ruleIds.length, + totalEnabledRulesCount: 0, + }; + + for (const ruleId of Object.keys(rulesData)) { + if (rulesData[ruleId].activity === CoverageOverviewRuleActivity.Enabled) { + metrics.totalEnabledRulesCount++; + } + } + + return metrics; +} + +function buildUnmappedRules( + apiResponse: CoverageOverviewResponse +): CoverageOverviewDashboard['unmappedRules'] { + const unmappedRules: CoverageOverviewDashboard['unmappedRules'] = { + enabledRules: [], + disabledRules: [], + availableRules: [], + }; + + for (const ruleId of apiResponse.unmapped_rule_ids) { + addRule(unmappedRules, ruleId, apiResponse.rules_data[ruleId]); + } + + return unmappedRules; +} + +function addRule( + container: { + enabledRules: CoverageOverviewRule[]; + disabledRules: CoverageOverviewRule[]; + availableRules: CoverageOverviewRule[]; + }, + ruleId: string, + ruleData: CoverageOverviewRuleAttributes +): void { + if (!ruleData) { + return; + } + + if (ruleData.activity === CoverageOverviewRuleActivity.Enabled) { + container.enabledRules.push({ + id: ruleId, + name: ruleData.name, + }); + } else if (ruleData.activity === CoverageOverviewRuleActivity.Disabled) { + container.disabledRules.push({ + id: ruleId, + name: ruleData.name, + }); + } else if (ruleData.activity === CoverageOverviewRuleActivity.Available) { + container.availableRules.push({ + id: ruleId, + name: ruleData.name, + }); + } +} diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/logic/coverage_overview/build_coverage_overview_mitre_graph.test.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_management/logic/coverage_overview/build_coverage_overview_mitre_graph.test.ts new file mode 100644 index 0000000000000..8039da45b80a6 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/logic/coverage_overview/build_coverage_overview_mitre_graph.test.ts @@ -0,0 +1,124 @@ +/* + * 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 { buildCoverageOverviewMitreGraph } from './build_coverage_overview_mitre_graph'; + +describe('buildCoverageOverviewModel', () => { + it('builds domain model', () => { + const tactics = [ + { + name: 'Tactic 1', + id: 'TA001', + reference: 'https://some-link/TA001', + }, + { + name: 'Tactic 2', + id: 'TA002', + reference: 'https://some-link/TA002', + }, + ]; + const techniques = [ + { + name: 'Technique 1', + id: 'T001', + reference: 'https://some-link/T001', + tactics: ['tactic-1'], + }, + { + name: 'Technique 2', + id: 'T002', + reference: 'https://some-link/T002', + tactics: ['tactic-1', 'tactic-2'], + }, + ]; + const subtechniques = [ + { + name: 'Subtechnique 1', + id: 'T001.001', + reference: 'https://some-link/T001/001', + tactics: ['tactic-1'], + techniqueId: 'T001', + }, + { + name: 'Subtechnique 2', + id: 'T001.002', + reference: 'https://some-link/T001/002', + tactics: ['tactic-1'], + techniqueId: 'T001', + }, + ]; + + const model = buildCoverageOverviewMitreGraph(tactics, techniques, subtechniques); + + expect(model).toEqual([ + { + id: 'TA001', + name: 'Tactic 1', + reference: 'https://some-link/TA001', + techniques: [ + { + id: 'T001', + name: 'Technique 1', + reference: 'https://some-link/T001', + subtechniques: [ + { + id: 'T001.001', + name: 'Subtechnique 1', + reference: 'https://some-link/T001/001', + enabledRules: [], + disabledRules: [], + availableRules: [], + }, + { + id: 'T001.002', + name: 'Subtechnique 2', + reference: 'https://some-link/T001/002', + enabledRules: [], + disabledRules: [], + availableRules: [], + }, + ], + enabledRules: [], + disabledRules: [], + availableRules: [], + }, + { + id: 'T002', + name: 'Technique 2', + reference: 'https://some-link/T002', + subtechniques: [], + enabledRules: [], + disabledRules: [], + availableRules: [], + }, + ], + enabledRules: [], + disabledRules: [], + availableRules: [], + }, + { + id: 'TA002', + name: 'Tactic 2', + reference: 'https://some-link/TA002', + techniques: [ + { + id: 'T002', + name: 'Technique 2', + reference: 'https://some-link/T002', + subtechniques: [], + enabledRules: [], + disabledRules: [], + availableRules: [], + }, + ], + enabledRules: [], + disabledRules: [], + availableRules: [], + }, + ]); + }); +}); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/logic/coverage_overview/build_coverage_overview_mitre_graph.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_management/logic/coverage_overview/build_coverage_overview_mitre_graph.ts new file mode 100644 index 0000000000000..7a775b3292991 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/logic/coverage_overview/build_coverage_overview_mitre_graph.ts @@ -0,0 +1,85 @@ +/* + * 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 { kebabCase } from 'lodash'; +import type { + MitreTactic, + MitreTechnique, + MitreSubTechnique, +} from '../../../../detections/mitre/types'; +import type { CoverageOverviewMitreSubTechnique } from '../../model/coverage_overview/mitre_subtechnique'; +import type { CoverageOverviewMitreTactic } from '../../model/coverage_overview/mitre_tactic'; +import type { CoverageOverviewMitreTechnique } from '../../model/coverage_overview/mitre_technique'; + +export function buildCoverageOverviewMitreGraph( + tactics: MitreTactic[], + techniques: MitreTechnique[], + subtechniques: MitreSubTechnique[] +): CoverageOverviewMitreTactic[] { + const techniqueToSubtechniquesMap = new Map(); // Map(TechniqueId -> SubTechniqueId[]) + + for (const subtechnique of subtechniques) { + const coverageOverviewMitreSubTechnique = { + id: subtechnique.id, + name: subtechnique.name, + reference: subtechnique.reference, + enabledRules: [], + disabledRules: [], + availableRules: [], + }; + + const techniqueSubtechniques = techniqueToSubtechniquesMap.get(subtechnique.techniqueId); + + if (!techniqueSubtechniques) { + techniqueToSubtechniquesMap.set(subtechnique.techniqueId, [ + coverageOverviewMitreSubTechnique, + ]); + } else { + techniqueSubtechniques.push(coverageOverviewMitreSubTechnique); + } + } + + const tacticToTechniquesMap = new Map(); // Map(kebabCase(tactic name) -> CoverageOverviewMitreTechnique) + + for (const technique of techniques) { + const coverageOverviewMitreTechnique: CoverageOverviewMitreTechnique = { + id: technique.id, + name: technique.name, + reference: technique.reference, + subtechniques: techniqueToSubtechniquesMap.get(technique.id) ?? [], + enabledRules: [], + disabledRules: [], + availableRules: [], + }; + + for (const kebabCaseTacticName of technique.tactics) { + const tacticTechniques = tacticToTechniquesMap.get(kebabCaseTacticName); + + if (!tacticTechniques) { + tacticToTechniquesMap.set(kebabCaseTacticName, [coverageOverviewMitreTechnique]); + } else { + tacticTechniques.push(coverageOverviewMitreTechnique); + } + } + } + + const result: CoverageOverviewMitreTactic[] = []; + + for (const tactic of tactics) { + result.push({ + id: tactic.id, + name: tactic.name, + reference: tactic.reference, + techniques: tacticToTechniquesMap.get(kebabCase(tactic.name)) ?? [], + enabledRules: [], + disabledRules: [], + availableRules: [], + }); + } + + return result; +} diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/logic/types.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_management/logic/types.ts index ec19ad7e5511d..35441d926402b 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management/logic/types.ts +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/logic/types.ts @@ -75,7 +75,10 @@ import { TimestampOverrideFallbackDisabled, } from '../../../../common/api/detection_engine/model/rule_schema'; -import type { PatchRuleRequestBody } from '../../../../common/api/detection_engine/rule_management'; +import type { + CoverageOverviewFilter, + PatchRuleRequestBody, +} from '../../../../common/api/detection_engine/rule_management'; import { FindRulesSortField } from '../../../../common/api/detection_engine/rule_management'; import type { RuleCreateProps, @@ -271,6 +274,11 @@ export interface FetchRuleSnoozingProps { signal?: AbortSignal; } +export interface FetchCoverageOverviewProps { + filter: CoverageOverviewFilter; + signal?: AbortSignal; +} + export interface BasicFetchProps { signal: AbortSignal; } diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/model/coverage_overview/__mocks__/index.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_management/model/coverage_overview/__mocks__/index.ts new file mode 100644 index 0000000000000..21a32787aa5db --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/model/coverage_overview/__mocks__/index.ts @@ -0,0 +1,59 @@ +/* + * 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 { CoverageOverviewDashboard } from '../dashboard'; +import type { CoverageOverviewMitreSubTechnique } from '../mitre_subtechnique'; +import type { CoverageOverviewMitreTactic } from '../mitre_tactic'; +import type { CoverageOverviewMitreTechnique } from '../mitre_technique'; +import type { CoverageOverviewRule } from '../rule'; + +export const getMockCoverageOverviewRule = (): CoverageOverviewRule => ({ + id: 'rule-id', + name: 'test rule', +}); + +const mockCoverageOverviewRules = { + enabledRules: [getMockCoverageOverviewRule()], + disabledRules: [getMockCoverageOverviewRule()], + availableRules: [getMockCoverageOverviewRule()], +}; + +export const getMockCoverageOverviewMitreTactic = (): CoverageOverviewMitreTactic => ({ + id: 'tactic-id', + name: 'test tactic', + reference: 'http://test-link', + techniques: [], + ...mockCoverageOverviewRules, +}); + +export const getMockCoverageOverviewMitreTechnique = (): CoverageOverviewMitreTechnique => ({ + id: 'technique-id', + name: 'test technique', + reference: 'http://test-link', + subtechniques: [], + ...mockCoverageOverviewRules, +}); + +export const getMockCoverageOverviewMitreSubTechnique = (): CoverageOverviewMitreSubTechnique => ({ + id: 'sub-technique-id', + name: 'test sub-technique', + reference: 'http://test-link', + ...mockCoverageOverviewRules, +}); + +export const getMockCoverageOverviewDashboard = (): CoverageOverviewDashboard => ({ + mitreTactics: [getMockCoverageOverviewMitreTactic()], + unmappedRules: { + enabledRules: [], + disabledRules: [], + availableRules: [], + }, + metrics: { + totalRulesCount: 3, + totalEnabledRulesCount: 1, + }, +}); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/model/coverage_overview/mitre_subtechnique.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_management/model/coverage_overview/mitre_subtechnique.ts new file mode 100644 index 0000000000000..622213c7e7a6f --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/model/coverage_overview/mitre_subtechnique.ts @@ -0,0 +1,20 @@ +/* + * 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 { CoverageOverviewRule } from './rule'; + +export interface CoverageOverviewMitreSubTechnique { + id: string; + name: string; + /** + * An url leading to the subtechnique's page + */ + reference: string; + enabledRules: CoverageOverviewRule[]; + disabledRules: CoverageOverviewRule[]; + availableRules: CoverageOverviewRule[]; +} diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/model/coverage_overview/mitre_tactic.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_management/model/coverage_overview/mitre_tactic.ts index 4384407e52261..a9dd6ac807d31 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management/model/coverage_overview/mitre_tactic.ts +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/model/coverage_overview/mitre_tactic.ts @@ -9,6 +9,7 @@ import type { CoverageOverviewRule } from './rule'; import type { CoverageOverviewMitreTechnique } from './mitre_technique'; export interface CoverageOverviewMitreTactic { + id: string; name: string; /** * An url leading to the tactic's page diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management/model/coverage_overview/mitre_technique.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_management/model/coverage_overview/mitre_technique.ts index af4ee49396ea6..35587ca59fdda 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management/model/coverage_overview/mitre_technique.ts +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management/model/coverage_overview/mitre_technique.ts @@ -5,22 +5,17 @@ * 2.0. */ +import type { CoverageOverviewMitreSubTechnique } from './mitre_subtechnique'; import type { CoverageOverviewRule } from './rule'; export interface CoverageOverviewMitreTechnique { + id: string; name: string; /** * An url leading to the technique's page */ reference: string; - /** - * A number of covered subtechniques (having at least one enabled rule associated with it) - */ - numOfCoveredSubtechniques: number; - /** - * A total number of subtechniques associated with this technique - */ - numOfSubtechniques: number; + subtechniques: CoverageOverviewMitreSubTechnique[]; enabledRules: CoverageOverviewRule[]; disabledRules: CoverageOverviewRule[]; availableRules: CoverageOverviewRule[]; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/pages/coverage_overview/constants.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/pages/coverage_overview/constants.ts new file mode 100644 index 0000000000000..76322812ecf27 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/pages/coverage_overview/constants.ts @@ -0,0 +1,26 @@ +/* + * 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 { euiPalettePositive } from '@elastic/eui'; + +export const coverageOverviewPaletteColors = euiPalettePositive(5); + +export const coverageOverviewPanelWidth = 160; + +export const coverageOverviewLegendWidth = 380; + +/** + * Rules count -> color map + * + * A corresponding color is applied if rules count >= a specific threshold + */ +export const coverageOverviewCardColorThresholds = [ + { threshold: 10, color: coverageOverviewPaletteColors[3] }, + { threshold: 7, color: coverageOverviewPaletteColors[2] }, + { threshold: 3, color: coverageOverviewPaletteColors[1] }, + { threshold: 1, color: coverageOverviewPaletteColors[0] }, +]; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/pages/coverage_overview/coverage_overview_page.test.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/pages/coverage_overview/coverage_overview_page.test.tsx new file mode 100644 index 0000000000000..794a8ca09d1f5 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/pages/coverage_overview/coverage_overview_page.test.tsx @@ -0,0 +1,42 @@ +/* + * 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 { render } from '@testing-library/react'; +import React from 'react'; +import { useFetchCoverageOverviewQuery } from '../../../rule_management/api/hooks/use_fetch_coverage_overview'; + +import { getMockCoverageOverviewDashboard } from '../../../rule_management/model/coverage_overview/__mocks__'; +import { TestProviders } from '../../../../common/mock'; +import { CoverageOverviewPage } from './coverage_overview_page'; + +jest.mock('../../../../common/utils/route/spy_routes', () => ({ SpyRoute: () => null })); +jest.mock('../../../rule_management/api/hooks/use_fetch_coverage_overview'); + +(useFetchCoverageOverviewQuery as jest.Mock).mockReturnValue({ + data: getMockCoverageOverviewDashboard(), +}); + +const renderCoverageOverviewDashboard = () => { + return render( + + + + ); +}; + +describe('CoverageOverviewPage', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('it renders', () => { + const wrapper = renderCoverageOverviewDashboard(); + + expect(wrapper.getByTestId('coverageOverviewPage')).toBeInTheDocument(); + expect(useFetchCoverageOverviewQuery).toHaveBeenCalled(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/pages/coverage_overview/coverage_overview_page.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/pages/coverage_overview/coverage_overview_page.tsx new file mode 100644 index 0000000000000..ae2115d031e50 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/pages/coverage_overview/coverage_overview_page.tsx @@ -0,0 +1,73 @@ +/* + * 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 React, { useCallback, useReducer } from 'react'; +import { EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; +import { SecuritySolutionPageWrapper } from '../../../../common/components/page_wrapper'; +import { SpyRoute } from '../../../../common/utils/route/spy_routes'; +import { SecurityPageName } from '../../../../app/types'; +import { HeaderPage } from '../../../../common/components/header_page'; + +import * as i18n from './translations'; +import { useFetchCoverageOverviewQuery } from '../../../rule_management/api/hooks/use_fetch_coverage_overview'; +import { CoverageOverviewTacticPanel } from './tactic_panel'; +import { CoverageOverviewMitreTechniquePanelPopover } from './technique_panel_popover'; +import { CoverageOverviewFiltersPanel } from './filters_panel'; +import { createCoverageOverviewDashboardReducer, initialState } from './reducer'; + +const CoverageOverviewPageComponent = () => { + const { data } = useFetchCoverageOverviewQuery(); + + const [{ showExpandedCells }, dispatch] = useReducer( + createCoverageOverviewDashboardReducer(), + initialState + ); + + const setShowExpandedCells = useCallback( + (value: boolean): void => { + dispatch({ + type: 'setShowExpandedCells', + value, + }); + }, + [dispatch] + ); + + return ( + <> + + + + + + + + {data?.mitreTactics.map((tactic) => ( + + + + + + {tactic.techniques.map((technique, techniqueKey) => ( + + + + ))} + + ))} + + + + ); +}; + +export const CoverageOverviewPage = React.memo(CoverageOverviewPageComponent); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/pages/coverage_overview/filters_panel.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/pages/coverage_overview/filters_panel.tsx new file mode 100644 index 0000000000000..283f7a77d7036 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/pages/coverage_overview/filters_panel.tsx @@ -0,0 +1,53 @@ +/* + * 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 { EuiFilterButton, EuiFilterGroup, EuiFlexGroup, EuiFlexItem, EuiPanel } from '@elastic/eui'; +import React, { memo } from 'react'; +import { CoverageOverviewLegend } from './shared_components/dashboard_legend'; +import * as i18n from './translations'; + +export interface CoverageOverviewFiltersPanelProps { + setShowExpandedCells: (arg: boolean) => void; + showExpandedCells: boolean; +} + +const CoverageOverviewFiltersPanelComponent = ({ + setShowExpandedCells, + showExpandedCells, +}: CoverageOverviewFiltersPanelProps) => { + const handleExpandCellsFilterClick = () => setShowExpandedCells(true); + const handleCollapseCellsFilterClick = () => setShowExpandedCells(false); + + return ( + + + + + + {i18n.COLLAPSE_CELLS_FILTER_BUTTON} + + + {i18n.EXPAND_CELLS_FILTER_BUTTON} + + + + + + + + + ); +}; + +export const CoverageOverviewFiltersPanel = memo(CoverageOverviewFiltersPanelComponent); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/pages/coverage_overview/helpers.test.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/pages/coverage_overview/helpers.test.ts new file mode 100644 index 0000000000000..b6d6c48749a10 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/pages/coverage_overview/helpers.test.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 { + getMockCoverageOverviewMitreSubTechnique, + getMockCoverageOverviewMitreTactic, + getMockCoverageOverviewMitreTechnique, +} from '../../../rule_management/model/coverage_overview/__mocks__'; +import { getNumOfCoveredSubtechniques, getNumOfCoveredTechniques } from './helpers'; + +describe('helpers', () => { + describe('getCoveredTechniques', () => { + it('returns 0 when no techniques are present', () => { + const payload = getMockCoverageOverviewMitreTactic(); + expect(getNumOfCoveredTechniques(payload)).toEqual(0); + }); + + it('returns number of techniques when present', () => { + const payload = { + ...getMockCoverageOverviewMitreTactic(), + techniques: [ + getMockCoverageOverviewMitreTechnique(), + getMockCoverageOverviewMitreTechnique(), + ], + }; + expect(getNumOfCoveredTechniques(payload)).toEqual(2); + }); + }); + + describe('getCoveredSubtechniques', () => { + it('returns 0 when no subtechniques are present', () => { + const payload = getMockCoverageOverviewMitreTechnique(); + expect(getNumOfCoveredSubtechniques(payload)).toEqual(0); + }); + + it('returns number of subtechniques when present', () => { + const payload = { + ...getMockCoverageOverviewMitreTechnique(), + subtechniques: [ + getMockCoverageOverviewMitreSubTechnique(), + getMockCoverageOverviewMitreSubTechnique(), + ], + }; + expect(getNumOfCoveredSubtechniques(payload)).toEqual(2); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/pages/coverage_overview/helpers.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/pages/coverage_overview/helpers.ts new file mode 100644 index 0000000000000..9611759fad271 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/pages/coverage_overview/helpers.ts @@ -0,0 +1,24 @@ +/* + * 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 { CoverageOverviewMitreTactic } from '../../../rule_management/model/coverage_overview/mitre_tactic'; +import type { CoverageOverviewMitreTechnique } from '../../../rule_management/model/coverage_overview/mitre_technique'; +import { coverageOverviewCardColorThresholds } from './constants'; + +export const getNumOfCoveredTechniques = (tactic: CoverageOverviewMitreTactic): number => + tactic.techniques.filter((technique) => technique.enabledRules.length !== 0).length; + +export const getNumOfCoveredSubtechniques = (technique: CoverageOverviewMitreTechnique): number => + technique.subtechniques.filter((subtechnique) => subtechnique.enabledRules.length !== 0).length; + +export const getCardBackgroundColor = (value: number) => { + for (const { threshold, color } of coverageOverviewCardColorThresholds) { + if (value >= threshold) { + return color; + } + } +}; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/pages/coverage_overview/index.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/pages/coverage_overview/index.ts new file mode 100644 index 0000000000000..324ce06e2d418 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/pages/coverage_overview/index.ts @@ -0,0 +1,8 @@ +/* + * 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 { CoverageOverviewPage } from './coverage_overview_page'; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/pages/coverage_overview/index.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/pages/coverage_overview/index.tsx deleted file mode 100644 index 4d0900fae8ab9..0000000000000 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/pages/coverage_overview/index.tsx +++ /dev/null @@ -1,27 +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 React from 'react'; -import { SecuritySolutionPageWrapper } from '../../../../common/components/page_wrapper'; -import { SpyRoute } from '../../../../common/utils/route/spy_routes'; -import { SecurityPageName } from '../../../../app/types'; -import { HeaderPage } from '../../../../common/components/header_page'; - -import * as i18n from './translations'; - -const CoverageOverviewPageComponent = () => { - return ( - <> - - - - - - - ); -}; - -export const CoverageOverviewPage = React.memo(CoverageOverviewPageComponent); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/pages/coverage_overview/reducer.ts b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/pages/coverage_overview/reducer.ts new file mode 100644 index 0000000000000..cdafe0aa6b756 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/pages/coverage_overview/reducer.ts @@ -0,0 +1,32 @@ +/* + * 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 interface State { + showExpandedCells: boolean; +} + +export const initialState: State = { + showExpandedCells: false, +}; + +export interface Action { + type: 'setShowExpandedCells'; + value: boolean; +} + +export const createCoverageOverviewDashboardReducer = + () => + (state: State, action: Action): State => { + switch (action.type) { + case 'setShowExpandedCells': { + const { value } = action; + return { ...state, showExpandedCells: value }; + } + default: + return state; + } + }; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/pages/coverage_overview/shared_components/dashboard_legend.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/pages/coverage_overview/shared_components/dashboard_legend.tsx new file mode 100644 index 0000000000000..5a72efc13f1f3 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/pages/coverage_overview/shared_components/dashboard_legend.tsx @@ -0,0 +1,73 @@ +/* + * 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 { + EuiFacetButton, + EuiBetaBadge, + EuiPanel, + EuiFlexGroup, + EuiText, + EuiSpacer, +} from '@elastic/eui'; +import React, { useMemo } from 'react'; +import { coverageOverviewCardColorThresholds, coverageOverviewLegendWidth } from '../constants'; +import * as i18n from '../translations'; + +const LegendLabel = ({ label, color }: { label: string; color?: string }) => ( + + } + > + {label} + +); + +export const CoverageOverviewLegend = () => { + const thresholds = useMemo( + () => + coverageOverviewCardColorThresholds.map(({ threshold, color }, index, thresholdsMap) => ( + + )), + [] + ); + + return ( + + + +

{i18n.CoverageOverviewLegendTitle}

+
+ + {i18n.CoverageOverviewLegendSubtitle} + +
+ + + + {thresholds} + + + +
+ ); +}; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/pages/coverage_overview/shared_components/panel_metadata.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/pages/coverage_overview/shared_components/panel_metadata.tsx new file mode 100644 index 0000000000000..06b995c34ba83 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/pages/coverage_overview/shared_components/panel_metadata.tsx @@ -0,0 +1,57 @@ +/* + * 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 { EuiFlexGroup, EuiFlexItem, EuiText, EuiNotificationBadge } from '@elastic/eui'; +import { css, cx } from '@emotion/css'; +import React from 'react'; +import * as i18n from '../translations'; + +export interface CoverageOverviewPanelMetadataProps { + disabledRules: number; + enabledRules: number; +} + +const metadataLabelClass = css` + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +`; + +export const CoverageOverviewPanelMetadata = ({ + disabledRules, + enabledRules, +}: CoverageOverviewPanelMetadataProps) => { + return ( + + + + + {i18n.DISABLED_RULES_METADATA_LABEL} + + + + + {disabledRules} + + + + + + + + {i18n.ENABLED_RULES_METADATA_LABEL} + + + + + {enabledRules} + + + + + ); +}; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/pages/coverage_overview/shared_components/popover_list_header.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/pages/coverage_overview/shared_components/popover_list_header.tsx new file mode 100644 index 0000000000000..3baf97feca2ae --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/pages/coverage_overview/shared_components/popover_list_header.tsx @@ -0,0 +1,32 @@ +/* + * 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 { EuiFlexGroup, EuiFlexItem, EuiText, EuiNotificationBadge } from '@elastic/eui'; +import React from 'react'; + +export const CoverageOverviewRuleListHeader = ({ + listTitle, + listLength, +}: { + listTitle: string; + listLength: number; +}) => { + return ( + + + +

{listTitle}

+
+
+ + + {listLength} + + +
+ ); +}; diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/pages/coverage_overview/tactic_panel.test.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/pages/coverage_overview/tactic_panel.test.tsx new file mode 100644 index 0000000000000..cddd257c130fa --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/pages/coverage_overview/tactic_panel.test.tsx @@ -0,0 +1,34 @@ +/* + * 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 { render } from '@testing-library/react'; +import React from 'react'; + +import { getMockCoverageOverviewMitreTactic } from '../../../rule_management/model/coverage_overview/__mocks__'; +import { TestProviders } from '../../../../common/mock'; +import { CoverageOverviewTacticPanel } from './tactic_panel'; +import type { CoverageOverviewMitreTactic } from '../../../rule_management/model/coverage_overview/mitre_tactic'; + +const renderTacticPanel = ( + tactic: CoverageOverviewMitreTactic = getMockCoverageOverviewMitreTactic() +) => { + return render( + + + + ); +}; + +describe('CoverageOverviewTacticPanel', () => { + test('it renders information correctly', () => { + const wrapper = renderTacticPanel(); + + expect(wrapper.getByTestId('coverageOverviewTacticPanel')).toBeInTheDocument(); + expect(wrapper.getByTestId('metadataDisabledRulesCount')).toHaveTextContent('1'); + expect(wrapper.getByTestId('metadataEnabledRulesCount')).toHaveTextContent('1'); + }); +}); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/pages/coverage_overview/tactic_panel.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/pages/coverage_overview/tactic_panel.tsx new file mode 100644 index 0000000000000..12431fe237617 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/pages/coverage_overview/tactic_panel.tsx @@ -0,0 +1,79 @@ +/* + * 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 { EuiPanel, EuiProgress, EuiSpacer, EuiText, EuiToolTip } from '@elastic/eui'; +import { css } from '@emotion/css'; +import React, { memo, useMemo } from 'react'; +import { euiThemeVars } from '@kbn/ui-theme'; +import type { CoverageOverviewMitreTactic } from '../../../rule_management/model/coverage_overview/mitre_tactic'; +import { coverageOverviewPanelWidth } from './constants'; +import { getNumOfCoveredTechniques } from './helpers'; +import * as i18n from './translations'; +import { CoverageOverviewPanelMetadata } from './shared_components/panel_metadata'; + +export interface CoverageOverviewTacticPanelProps { + tactic: CoverageOverviewMitreTactic; +} + +const CoverageOverviewTacticPanelComponent = ({ tactic }: CoverageOverviewTacticPanelProps) => { + const coveredTechniques = useMemo(() => getNumOfCoveredTechniques(tactic), [tactic]); + + const ProgressLabel = useMemo( + () => ( + +
{i18n.COVERED_MITRE_TECHNIQUES(coveredTechniques, tactic.techniques.length)}
+
+ ), + [tactic.techniques, coveredTechniques] + ); + + return ( + + + +

{tactic.name}

+
+
+ + + + +
+ ); +}; + +export const CoverageOverviewTacticPanel = memo(CoverageOverviewTacticPanelComponent); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/pages/coverage_overview/technique_panel.test.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/pages/coverage_overview/technique_panel.test.tsx new file mode 100644 index 0000000000000..38e10e6299b8e --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/pages/coverage_overview/technique_panel.test.tsx @@ -0,0 +1,47 @@ +/* + * 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 { render } from '@testing-library/react'; +import React from 'react'; + +import { getMockCoverageOverviewMitreTechnique } from '../../../rule_management/model/coverage_overview/__mocks__'; +import { TestProviders } from '../../../../common/mock'; +import { CoverageOverviewMitreTechniquePanel } from './technique_panel'; +import type { CoverageOverviewMitreTechnique } from '../../../rule_management/model/coverage_overview/mitre_technique'; + +const renderTechniquePanel = ( + technique: CoverageOverviewMitreTechnique = getMockCoverageOverviewMitreTechnique(), + isExpanded: boolean = false +) => { + return render( + + {}} + isPopoverOpen={false} + isExpanded={isExpanded} + /> + + ); +}; + +describe('CoverageOverviewMitreTechniquePanel', () => { + test('it renders collapsed view', () => { + const wrapper = renderTechniquePanel(); + + expect(wrapper.getByTestId('coverageOverviewTechniquePanel')).toBeInTheDocument(); + expect(wrapper.queryByTestId('coverageOverviewPanelMetadata')).not.toBeInTheDocument(); + }); + + test('it renders expanded view', () => { + const wrapper = renderTechniquePanel(getMockCoverageOverviewMitreTechnique(), true); + + expect(wrapper.getByTestId('coverageOverviewTechniquePanel')).toBeInTheDocument(); + expect(wrapper.getByTestId('coverageOverviewPanelMetadata')).toBeInTheDocument(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/pages/coverage_overview/technique_panel.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/pages/coverage_overview/technique_panel.tsx new file mode 100644 index 0000000000000..d8af376d32bab --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/pages/coverage_overview/technique_panel.tsx @@ -0,0 +1,96 @@ +/* + * 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 { EuiFlexGroup, EuiFlexItem, EuiPanel, EuiText } from '@elastic/eui'; +import { css } from '@emotion/css'; +import React, { memo, useCallback, useMemo } from 'react'; +import type { CoverageOverviewMitreTechnique } from '../../../rule_management/model/coverage_overview/mitre_technique'; +import { coverageOverviewPanelWidth } from './constants'; +import { getCardBackgroundColor } from './helpers'; +import { CoverageOverviewPanelMetadata } from './shared_components/panel_metadata'; +import * as i18n from './translations'; + +export interface CoverageOverviewMitreTechniquePanelProps { + technique: CoverageOverviewMitreTechnique; + coveredSubtechniques: number; + setIsPopoverOpen: (isOpen: boolean) => void; + isPopoverOpen: boolean; + isExpanded: boolean; +} + +const CoverageOverviewMitreTechniquePanelComponent = ({ + technique, + coveredSubtechniques, + setIsPopoverOpen, + isPopoverOpen, + isExpanded, +}: CoverageOverviewMitreTechniquePanelProps) => { + const techniqueBackgroundColor = useMemo( + () => getCardBackgroundColor(technique.enabledRules.length), + [technique.enabledRules.length] + ); + + const handlePanelOnClick = useCallback( + () => setIsPopoverOpen(!isPopoverOpen), + [isPopoverOpen, setIsPopoverOpen] + ); + + const SubtechniqueInfo = useMemo( + () => ( + + + {i18n.SUBTECHNIQUES} + + + {`${coveredSubtechniques}/${technique.subtechniques.length}`} + + + ), + [technique.subtechniques, coveredSubtechniques] + ); + + return ( + + + + +

{technique.name}

+
+ {SubtechniqueInfo} +
+ {isExpanded && ( + + + + )} +
+
+ ); +}; + +export const CoverageOverviewMitreTechniquePanel = memo( + CoverageOverviewMitreTechniquePanelComponent +); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/pages/coverage_overview/technique_panel_popover.test.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/pages/coverage_overview/technique_panel_popover.test.tsx new file mode 100644 index 0000000000000..dba2b381deb88 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/pages/coverage_overview/technique_panel_popover.test.tsx @@ -0,0 +1,82 @@ +/* + * 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 { act, fireEvent, render, within } from '@testing-library/react'; +import React from 'react'; + +import { getMockCoverageOverviewMitreTechnique } from '../../../rule_management/model/coverage_overview/__mocks__'; +import { TestProviders } from '../../../../common/mock'; +import type { CoverageOverviewMitreTechnique } from '../../../rule_management/model/coverage_overview/mitre_technique'; +import { CoverageOverviewMitreTechniquePanelPopover } from './technique_panel_popover'; +import { useExecuteBulkAction } from '../../../rule_management/logic/bulk_actions/use_execute_bulk_action'; + +jest.mock('../../../rule_management/logic/bulk_actions/use_execute_bulk_action'); + +const mockExecuteBulkAction = jest.fn(); + +(useExecuteBulkAction as jest.Mock).mockReturnValue({ + executeBulkAction: mockExecuteBulkAction, +}); + +const renderTechniquePanelPopover = ( + technique: CoverageOverviewMitreTechnique = getMockCoverageOverviewMitreTechnique(), + isExpanded: boolean = false +) => { + return render( + + + + ); +}; + +describe('CoverageOverviewMitreTechniquePanelPopover', () => { + test('it renders all rules in correct areas', () => { + const wrapper = renderTechniquePanelPopover(); + + act(() => { + fireEvent.click(wrapper.getByTestId('coverageOverviewTechniquePanel')); + }); + + expect(wrapper.getByTestId('coverageOverviewPopover')).toBeInTheDocument(); + expect( + within(wrapper.getByTestId('coverageOverviewEnabledRulesList')).getByText( + getMockCoverageOverviewMitreTechnique().enabledRules[0].name + ) + ).toBeInTheDocument(); + expect( + within(wrapper.getByTestId('coverageOverviewDisabledRulesList')).getByText( + getMockCoverageOverviewMitreTechnique().disabledRules[0].name + ) + ).toBeInTheDocument(); + }); + + test('calls bulk action enable when "Enable all disabled" button is pressed', async () => { + const wrapper = renderTechniquePanelPopover(); + + act(() => { + fireEvent.click(wrapper.getByTestId('coverageOverviewTechniquePanel')); + }); + await act(async () => { + fireEvent.click(wrapper.getByTestId('enableAllDisabledButton')); + }); + + expect(mockExecuteBulkAction).toHaveBeenCalledWith({ ids: ['rule-id'], type: 'enable' }); + }); + + test('"Enable all disabled" button is disabled when there are no disabled rules', async () => { + const mockTechnique: CoverageOverviewMitreTechnique = { + ...getMockCoverageOverviewMitreTechnique(), + disabledRules: [], + }; + const wrapper = renderTechniquePanelPopover(mockTechnique); + + act(() => { + fireEvent.click(wrapper.getByTestId('coverageOverviewTechniquePanel')); + }); + expect(wrapper.getByTestId('enableAllDisabledButton')).toBeDisabled(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/pages/coverage_overview/technique_panel_popover.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/pages/coverage_overview/technique_panel_popover.tsx new file mode 100644 index 0000000000000..2a7ca6f6a22f3 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/pages/coverage_overview/technique_panel_popover.tsx @@ -0,0 +1,199 @@ +/* + * 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 { EuiListGroupItemProps } from '@elastic/eui'; +import { + EuiButton, + EuiButtonEmpty, + EuiFlexGroup, + EuiFlexItem, + EuiListGroup, + EuiPopover, + EuiPopoverFooter, + EuiPopoverTitle, + EuiText, + EuiSpacer, + EuiAccordion, +} from '@elastic/eui'; +import { css, cx } from '@emotion/css'; +import React, { memo, useCallback, useMemo, useState } from 'react'; +import { BulkActionType } from '../../../../../common/api/detection_engine'; +import { useExecuteBulkAction } from '../../../rule_management/logic/bulk_actions/use_execute_bulk_action'; +import type { CoverageOverviewMitreTechnique } from '../../../rule_management/model/coverage_overview/mitre_technique'; +import { getNumOfCoveredSubtechniques } from './helpers'; +import { CoverageOverviewRuleListHeader } from './shared_components/popover_list_header'; +import { CoverageOverviewMitreTechniquePanel } from './technique_panel'; +import * as i18n from './translations'; +import { RuleLink } from '../../components/rules_table/use_columns'; + +export interface CoverageOverviewMitreTechniquePanelPopoverProps { + technique: CoverageOverviewMitreTechnique; + isExpanded: boolean; +} + +const CoverageOverviewMitreTechniquePanelPopoverComponent = ({ + technique, + isExpanded, +}: CoverageOverviewMitreTechniquePanelPopoverProps) => { + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + const [isEnableButtonLoading, setIsDisableButtonLoading] = useState(false); + const closePopover = useCallback(() => setIsPopoverOpen(false), []); + const coveredSubtechniques = useMemo(() => getNumOfCoveredSubtechniques(technique), [technique]); + const { executeBulkAction } = useExecuteBulkAction(); + const isEnableButtonDisabled = useMemo( + () => technique.disabledRules.length === 0, + [technique.disabledRules.length] + ); + + const handleEnableAllDisabled = useCallback(async () => { + setIsDisableButtonLoading(true); + const ruleIds = technique.disabledRules.map((rule) => rule.id); + await executeBulkAction({ type: BulkActionType.enable, ids: ruleIds }); + setIsDisableButtonLoading(false); + closePopover(); + }, [closePopover, executeBulkAction, technique.disabledRules]); + + const TechniquePanel = ( + + ); + const CoveredSubtechniquesLabel = useMemo( + () => ( + +

+ {i18n.COVERED_MITRE_SUBTECHNIQUES(coveredSubtechniques, technique.subtechniques.length)} +

+
+ ), + [coveredSubtechniques, technique.subtechniques.length] + ); + + const enabledRuleListItems: EuiListGroupItemProps[] = useMemo( + () => + technique.enabledRules.map((rule) => ({ + label: , + color: 'primary', + })), + [technique.enabledRules] + ); + + const disabledRuleListItems: EuiListGroupItemProps[] = useMemo( + () => + technique.disabledRules.map((rule) => ({ + label: , + color: 'primary', + })), + [technique.disabledRules] + ); + + const EnabledRulesAccordionButton = useMemo( + () => ( + + ), + [technique.enabledRules.length] + ); + + const DisabledRulesAccordionButton = useMemo( + () => ( + + ), + [technique.disabledRules.length] + ); + + return ( + + + + + + +

{technique.name}

+
+
+
+ {CoveredSubtechniquesLabel} +
+
+
+ 0} + buttonContent={EnabledRulesAccordionButton} + > + + + + 0} + buttonContent={DisabledRulesAccordionButton} + > + + + +
+ + + + + {i18n.ENABLE_ALL_DISABLED} + + + + +
+ ); +}; + +export const CoverageOverviewMitreTechniquePanelPopover = memo( + CoverageOverviewMitreTechniquePanelPopoverComponent +); 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 df25731c71cb4..ce4587fb01aea 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 @@ -13,3 +13,95 @@ export const COVERAGE_OVERVIEW_DASHBOARD_TITLE = i18n.translate( defaultMessage: 'MITRE ATT&CK\u00AE Coverage', } ); + +export const COLLAPSE_CELLS_FILTER_BUTTON = i18n.translate( + 'xpack.securitySolution.coverageOverviewDashboard.collapseCellsButton', + { + defaultMessage: 'Collapse cells', + } +); + +export const EXPAND_CELLS_FILTER_BUTTON = i18n.translate( + 'xpack.securitySolution.coverageOverviewDashboard.expandCellsButton', + { + defaultMessage: 'Expand cells', + } +); + +export const DISABLED_RULES_METADATA_LABEL = i18n.translate( + 'xpack.securitySolution.coverageOverviewDashboard.disabledRulesMetadataLabel', + { + defaultMessage: 'Disabled Rules:', + } +); + +export const ENABLED_RULES_METADATA_LABEL = i18n.translate( + 'xpack.securitySolution.coverageOverviewDashboard.enabledRulesMetadataLabel', + { + defaultMessage: 'Enabled Rules:', + } +); + +export const SUBTECHNIQUES = i18n.translate( + 'xpack.securitySolution.coverageOverviewDashboard.subtechniques', + { + defaultMessage: 'Sub-techniques', + } +); + +export const ENABLE_ALL_DISABLED = i18n.translate( + 'xpack.securitySolution.coverageOverviewDashboard.enableAllDisabledButtonLabel', + { + defaultMessage: 'Enable all disabled', + } +); + +export const COVERED_MITRE_TECHNIQUES = (enabledTechniques: number, totalTechniques: number) => + i18n.translate('xpack.securitySolution.coverageOverviewDashboard.coveredMitreTechniques', { + values: { enabledTechniques, totalTechniques }, + defaultMessage: '{enabledTechniques}/{totalTechniques} techniques', + }); + +export const COVERED_MITRE_SUBTECHNIQUES = ( + enabledSubtechniques: number, + totalSubtechniques: number +) => + i18n.translate('xpack.securitySolution.coverageOverviewDashboard.coveredMitreSubtechniques', { + values: { enabledSubtechniques, totalSubtechniques }, + defaultMessage: 'Sub-techniques {enabledSubtechniques}/{totalSubtechniques}', + }); + +export const DISABLED_RULES_LIST_LABEL = i18n.translate( + 'xpack.securitySolution.coverageOverviewDashboard.disabledRulesListLabel', + { + defaultMessage: 'Disabled rules', + } +); + +export const ENABLED_RULES_LIST_LABEL = i18n.translate( + 'xpack.securitySolution.coverageOverviewDashboard.enabledRulesListLabel', + { + defaultMessage: 'Enabled rules', + } +); + +export const CoverageOverviewLegendTitle = i18n.translate( + 'xpack.securitySolution.coverageOverviewDashboard.legendTitle', + { + defaultMessage: 'Legend', + } +); + +export const CoverageOverviewLegendSubtitle = i18n.translate( + 'xpack.securitySolution.coverageOverviewDashboard.legendSubtitle', + { + defaultMessage: '(count will include all rules selected)', + } +); + +export const CoverageOverviewLegendRulesLabel = i18n.translate( + 'xpack.securitySolution.coverageOverviewDashboard.legendRulesLabel', + { + defaultMessage: 'rules', + } +); diff --git a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/pages/rule_management/index.tsx b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/pages/rule_management/index.tsx index 42de0cb40fd58..2f5a202459f79 100644 --- a/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/pages/rule_management/index.tsx +++ b/x-pack/plugins/security_solution/public/detection_engine/rule_management_ui/pages/rule_management/index.tsx @@ -33,6 +33,7 @@ import { importRules } from '../../../rule_management/logic'; import { AllRules } from '../../components/rules_table'; import { RulesTableContextProvider } from '../../components/rules_table/rules_table/rules_table_context'; import { SuperHeader } from './super_header'; +import { useInvalidateFetchCoverageOverviewQuery } from '../../../rule_management/api/hooks/use_fetch_coverage_overview'; const RulesPageComponent: React.FC = () => { const [isImportModalVisible, showImportModal, hideImportModal] = useBoolState(); @@ -40,11 +41,17 @@ const RulesPageComponent: React.FC = () => { const kibanaServices = useKibana().services; const { navigateToApp } = kibanaServices.application; const invalidateFindRulesQuery = useInvalidateFindRulesQuery(); + const invalidateFetchCoverageOverviewQuery = useInvalidateFetchCoverageOverviewQuery(); const invalidateFetchRuleManagementFilters = useInvalidateFetchRuleManagementFiltersQuery(); const invalidateRules = useCallback(() => { invalidateFindRulesQuery(); invalidateFetchRuleManagementFilters(); - }, [invalidateFindRulesQuery, invalidateFetchRuleManagementFilters]); + invalidateFetchCoverageOverviewQuery(); + }, [ + invalidateFindRulesQuery, + invalidateFetchRuleManagementFilters, + invalidateFetchCoverageOverviewQuery, + ]); const [ { diff --git a/x-pack/plugins/security_solution/public/detections/mitre/types.ts b/x-pack/plugins/security_solution/public/detections/mitre/types.ts index 031b42e097ac2..4f6de5ad1f6fb 100644 --- a/x-pack/plugins/security_solution/public/detections/mitre/types.ts +++ b/x-pack/plugins/security_solution/public/detections/mitre/types.ts @@ -24,3 +24,24 @@ export interface MitreTechniquesOptions extends MitreOptions { export interface MitreSubtechniquesOptions extends MitreTechniquesOptions { techniqueId: string; } + +export interface MitreTactic { + id: string; + name: string; + reference: string; // A link to the tactic's page +} + +export interface MitreTechnique { + id: string; + name: string; + reference: string; // A link to the technique's page + tactics: string[]; // Tactics this technique assigned to (lowercase dash separated) +} + +export interface MitreSubTechnique { + id: string; + name: string; + reference: string; // A link to the subtechnique's page + tactics: string[]; // Tactics this technique assigned to (lowercase dash separated) + techniqueId: string; // A technique id this subtechnique assigned to +}