[Security Solution] Coverage Overview follow-up (#164613)

This commit is contained in:
Davis Plumlee 2023-08-25 13:56:55 -04:00 committed by GitHub
parent b5b2c36d95
commit 168e3dc5e9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 181 additions and 78 deletions

View file

@ -15,7 +15,6 @@ import {
detectionResponseLinks,
entityAnalyticsLinks,
overviewLinks,
coverageOverviewDashboardLinks,
} from '../overview/links';
import { IconDashboards } from '../common/icons/dashboards';
@ -27,7 +26,6 @@ const subLinks: LinkItem[] = [
vulnerabilityDashboardLink,
entityAnalyticsLinks,
ecsDataQualityDashboardLinks,
coverageOverviewDashboardLinks,
].map((link) => ({ ...link, sideNavIcon: IconDashboards }));
export const dashboardsLinks: LinkItem = {

View file

@ -5,14 +5,13 @@
* 2.0.
*/
import { euiPalettePositive } from '@elastic/eui';
import {
CoverageOverviewRuleActivity,
CoverageOverviewRuleSource,
} from '../../../../../common/api/detection_engine';
import * as i18n from './translations';
export const coverageOverviewPaletteColors = euiPalettePositive(5);
export const coverageOverviewPaletteColors = ['#00BFB326', '#00BFB34D', '#00BFB399', '#00BFB3'];
export const coverageOverviewPanelWidth = 160;

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import React from 'react';
import { EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui';
import { EuiFlexGroup, EuiFlexItem, EuiLink, EuiSpacer, EuiText } from '@elastic/eui';
import { HeaderPage } from '../../../../common/components/header_page';
import * as i18n from './translations';
@ -18,9 +18,22 @@ const CoverageOverviewDashboardComponent = () => {
const {
state: { data },
} = useCoverageOverviewDashboardContext();
const subtitle = (
<EuiText color="subdued" size="s">
<span>{i18n.CoverageOverviewDashboardInformation}</span>{' '}
<EuiLink
external={true}
href={'https://www.elastic.co/'} // TODO: change to actual docs link before release
rel="noopener noreferrer"
target="_blank"
>
{i18n.CoverageOverviewDashboardInformationLink}
</EuiLink>
</EuiText>
);
return (
<>
<HeaderPage title={i18n.COVERAGE_OVERVIEW_DASHBOARD_TITLE} />
<HeaderPage title={i18n.COVERAGE_OVERVIEW_DASHBOARD_TITLE} subtitle={subtitle} />
<CoverageOverviewFiltersPanel />
<EuiSpacer />
<EuiFlexGroup gutterSize="m" className="eui-xScroll">

View file

@ -14,11 +14,11 @@ import React, {
useReducer,
} from 'react';
import { invariant } from '../../../../../common/utils/invariant';
import type {
import {
BulkActionType,
CoverageOverviewRuleActivity,
CoverageOverviewRuleSource,
} from '../../../../../common/api/detection_engine';
import { BulkActionType } from '../../../../../common/api/detection_engine';
import type { CoverageOverviewDashboardState } from './coverage_overview_dashboard_reducer';
import {
SET_SHOW_EXPANDED_CELLS,
@ -53,7 +53,10 @@ interface CoverageOverviewDashboardContextProviderProps {
export const initialState: CoverageOverviewDashboardState = {
showExpandedCells: false,
filter: {},
filter: {
activity: [CoverageOverviewRuleActivity.Enabled],
source: [CoverageOverviewRuleSource.Prebuilt, CoverageOverviewRuleSource.Custom],
},
data: undefined,
isLoading: false,
};

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import type { CoverageOverviewRuleActivity } from '../../../../../common/api/detection_engine';
import { CoverageOverviewRuleActivity } from '../../../../../common/api/detection_engine';
import { getCoverageOverviewFilterMock } from '../../../../../common/api/detection_engine/rule_management/coverage_overview/coverage_overview_route.mock';
import {
getMockCoverageOverviewMitreSubTechnique,
@ -17,6 +17,7 @@ import {
extractSelected,
getNumOfCoveredSubtechniques,
getNumOfCoveredTechniques,
getTotalRuleCount,
populateSelected,
} from './helpers';
@ -88,4 +89,26 @@ describe('helpers', () => {
]);
});
});
describe('getTotalRuleCount', () => {
it('returns count of all rules when no activity filter is present', () => {
const payload = getMockCoverageOverviewMitreTechnique();
expect(getTotalRuleCount(payload)).toEqual(2);
});
it('returns count of one rule type when an activity filter is present', () => {
const payload = getMockCoverageOverviewMitreTechnique();
expect(getTotalRuleCount(payload, [CoverageOverviewRuleActivity.Disabled])).toEqual(1);
});
it('returns count of multiple rule type when multiple activity filter is present', () => {
const payload = getMockCoverageOverviewMitreTechnique();
expect(
getTotalRuleCount(payload, [
CoverageOverviewRuleActivity.Enabled,
CoverageOverviewRuleActivity.Disabled,
])
).toEqual(2);
});
});
});

View file

@ -6,10 +6,8 @@
*/
import type { EuiSelectableOption } from '@elastic/eui';
import type {
CoverageOverviewRuleActivity,
CoverageOverviewRuleSource,
} from '../../../../../common/api/detection_engine';
import type { CoverageOverviewRuleSource } from '../../../../../common/api/detection_engine';
import { CoverageOverviewRuleActivity } from '../../../../../common/api/detection_engine';
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';
@ -43,3 +41,20 @@ export const populateSelected = (
allOptions.map((option) =>
selected.includes(option.label) ? { ...option, checked: 'on' } : option
);
export const getTotalRuleCount = (
technique: CoverageOverviewMitreTechnique,
activity?: CoverageOverviewRuleActivity[]
): number => {
if (!activity) {
return technique.enabledRules.length + technique.disabledRules.length;
}
let totalRuleCount = 0;
if (activity.includes(CoverageOverviewRuleActivity.Enabled)) {
totalRuleCount += technique.enabledRules.length;
}
if (activity.includes(CoverageOverviewRuleActivity.Disabled)) {
totalRuleCount += technique.disabledRules.length;
}
return totalRuleCount;
};

View file

@ -97,7 +97,6 @@ const RuleActivityFilterComponent = ({
<EuiPopoverTitle paddingSize="s">{i18n.CoverageOverviewFilterPopoverTitle}</EuiPopoverTitle>
<EuiSelectable
data-test-subj="coverageOverviewFilterList"
isLoading={isLoading}
options={options}
onChange={handleSelectableOnChange}
renderOption={renderOptionLabel}
@ -120,7 +119,7 @@ const RuleActivityFilterComponent = ({
iconType="cross"
color="danger"
size="xs"
isDisabled={numActiveFilters === 0 || isLoading}
isDisabled={numActiveFilters === 0}
onClick={handleOnClear}
>
{i18n.CoverageOverviewFilterPopoverClearAll}

View file

@ -96,7 +96,6 @@ const RuleSourceFilterComponent = ({
<EuiPopoverTitle paddingSize="s">{i18n.CoverageOverviewFilterPopoverTitle}</EuiPopoverTitle>
<EuiSelectable
data-test-subj="coverageOverviewFilterList"
isLoading={isLoading}
options={options}
onChange={handleSelectableOnChange}
renderOption={renderOptionLabel}
@ -119,7 +118,7 @@ const RuleSourceFilterComponent = ({
iconType="cross"
color="danger"
size="xs"
isDisabled={numActiveFilters === 0 || isLoading}
isDisabled={numActiveFilters === 0}
onClick={handleOnClear}
>
{i18n.CoverageOverviewFilterPopoverClearAll}

View file

@ -10,7 +10,8 @@ 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 { useCoverageOverviewDashboardContext } from './coverage_overview_dashboard_context';
import { getCardBackgroundColor, getTotalRuleCount } from './helpers';
import { CoverageOverviewPanelRuleStats } from './shared_components/panel_rule_stats';
import * as i18n from './translations';
@ -29,9 +30,13 @@ const CoverageOverviewMitreTechniquePanelComponent = ({
isPopoverOpen,
isExpanded,
}: CoverageOverviewMitreTechniquePanelProps) => {
const {
state: { filter },
} = useCoverageOverviewDashboardContext();
const totalRuleCount = getTotalRuleCount(technique, filter.activity);
const techniqueBackgroundColor = useMemo(
() => getCardBackgroundColor(technique.enabledRules.length),
[technique.enabledRules.length]
() => getCardBackgroundColor(totalRuleCount),
[totalRuleCount]
);
const handlePanelOnClick = useCallback(

View file

@ -13,8 +13,10 @@ import { TestProviders } from '../../../../common/mock';
import type { CoverageOverviewMitreTechnique } from '../../../rule_management/model/coverage_overview/mitre_technique';
import { CoverageOverviewMitreTechniquePanelPopover } from './technique_panel_popover';
import { useCoverageOverviewDashboardContext } from './coverage_overview_dashboard_context';
import { useUserData } from '../../../../detections/components/user_info';
jest.mock('./coverage_overview_dashboard_context');
jest.mock('../../../../detections/components/user_info');
const mockEnableAllDisabled = jest.fn();
@ -31,9 +33,10 @@ const renderTechniquePanelPopover = (
describe('CoverageOverviewMitreTechniquePanelPopover', () => {
beforeEach(() => {
(useCoverageOverviewDashboardContext as jest.Mock).mockReturnValue({
state: { showExpandedCells: false },
state: { showExpandedCells: false, filter: {} },
actions: { enableAllDisabled: mockEnableAllDisabled },
});
(useUserData as jest.Mock).mockReturnValue([{ loading: false, canUserCRUD: true }]);
});
afterEach(() => {
@ -49,7 +52,7 @@ describe('CoverageOverviewMitreTechniquePanelPopover', () => {
test('it renders panel with expanded view', () => {
(useCoverageOverviewDashboardContext as jest.Mock).mockReturnValue({
state: { showExpandedCells: true },
state: { showExpandedCells: true, filter: {} },
actions: { enableAllDisabled: mockEnableAllDisabled },
});
const wrapper = renderTechniquePanelPopover();
@ -103,4 +106,14 @@ describe('CoverageOverviewMitreTechniquePanelPopover', () => {
});
expect(wrapper.getByTestId('enableAllDisabledButton')).toBeDisabled();
});
test('"Enable all disabled" button is disabled when user does not have CRUD permissions', async () => {
(useUserData as jest.Mock).mockReturnValue([{ loading: false, canUserCRUD: false }]);
const wrapper = renderTechniquePanelPopover();
act(() => {
fireEvent.click(wrapper.getByTestId('coverageOverviewTechniquePanel'));
});
expect(wrapper.getByTestId('enableAllDisabledButton')).toBeDisabled();
});
});

View file

@ -21,6 +21,7 @@ import {
} from '@elastic/eui';
import { css, cx } from '@emotion/css';
import React, { memo, useCallback, useMemo, useState } from 'react';
import { useUserData } from '../../../../detections/components/user_info';
import type { CoverageOverviewMitreTechnique } from '../../../rule_management/model/coverage_overview/mitre_technique';
import { getNumOfCoveredSubtechniques } from './helpers';
import { CoverageOverviewRuleListHeader } from './shared_components/popover_list_header';
@ -36,13 +37,19 @@ export interface CoverageOverviewMitreTechniquePanelPopoverProps {
const CoverageOverviewMitreTechniquePanelPopoverComponent = ({
technique,
}: CoverageOverviewMitreTechniquePanelPopoverProps) => {
const [{ loading: userInfoLoading, canUserCRUD }] = useUserData();
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const [isEnableButtonLoading, setIsDisableButtonLoading] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const closePopover = useCallback(() => setIsPopoverOpen(false), []);
const coveredSubtechniques = useMemo(() => getNumOfCoveredSubtechniques(technique), [technique]);
const isEnableButtonDisabled = useMemo(
() => technique.disabledRules.length === 0,
[technique.disabledRules.length]
() => !canUserCRUD || technique.disabledRules.length === 0,
[canUserCRUD, technique.disabledRules.length]
);
const isEnableButtonLoading = useMemo(
() => isLoading || userInfoLoading,
[isLoading, userInfoLoading]
);
const {
@ -51,10 +58,10 @@ const CoverageOverviewMitreTechniquePanelPopoverComponent = ({
} = useCoverageOverviewDashboardContext();
const handleEnableAllDisabled = useCallback(async () => {
setIsDisableButtonLoading(true);
setIsLoading(true);
const ruleIds = technique.disabledRules.map((rule) => rule.id);
await enableAllDisabled(ruleIds);
setIsDisableButtonLoading(false);
setIsLoading(false);
closePopover();
}, [closePopover, enableAllDisabled, technique.disabledRules]);

View file

@ -152,7 +152,7 @@ export const CoverageOverviewSearchBarPlaceholder = i18n.translate(
'xpack.securitySolution.coverageOverviewDashboard.searchBarPlaceholder',
{
defaultMessage:
'Search for the tactic, technique (e.g.,"defence evasion" or "TA0005") or rule name, index pattern (e.g.,"filebeat-*")',
'Search for the tactic, technique (e.g.,"defense evasion" or "TA0005") or rule name',
}
);
@ -169,3 +169,18 @@ export const CoverageOverviewFilterPopoverClearAll = i18n.translate(
defaultMessage: 'Clear all',
}
);
export const CoverageOverviewDashboardInformation = i18n.translate(
'xpack.securitySolution.coverageOverviewDashboard.dashboardInformation',
{
defaultMessage:
'The interactive MITRE ATT&CK coverage below shows the current state of your coverage from installed rules, click on a cell to view further details. Unmapped rules will not be displayed. View further information from our',
}
);
export const CoverageOverviewDashboardInformationLink = i18n.translate(
'xpack.securitySolution.coverageOverviewDashboard.dashboardInformationLink',
{
defaultMessage: 'docs.',
}
);

View file

@ -7,7 +7,6 @@
import { i18n } from '@kbn/i18n';
import {
COVERAGE_OVERVIEW_PATH,
DATA_QUALITY_PATH,
DETECTION_RESPONSE_PATH,
ENTITY_ANALYTICS_PATH,
@ -22,7 +21,6 @@ import {
GETTING_STARTED,
OVERVIEW,
ENTITY_ANALYTICS,
COVERAGE_OVERVIEW,
} from '../app/translations';
import type { LinkItem } from '../common/links/types';
import overviewPageImg from '../common/images/overview_page.png';
@ -113,24 +111,3 @@ export const ecsDataQualityDashboardLinks: LinkItem = {
}),
],
};
export const coverageOverviewDashboardLinks: LinkItem = {
id: SecurityPageName.coverageOverview,
title: COVERAGE_OVERVIEW,
landingImage: overviewPageImg, // TODO: change with updated image before removing feature flag https://github.com/elastic/security-team/issues/2905
description: i18n.translate(
'xpack.securitySolution.appLinks.coverageOverviewDashboardDescription',
{
defaultMessage:
'An overview of rule coverage according to the MITRE ATT&CK\u00AE specifications',
}
),
path: COVERAGE_OVERVIEW_PATH,
capabilities: [`${SERVER_APP_ID}.show`],
globalSearchKeywords: [
i18n.translate('xpack.securitySolution.appLinks.coverageOverviewDashboard', {
defaultMessage: 'MITRE ATT&CK Coverage',
}),
],
experimentalKey: 'detectionsCoverageOverview',
};

View file

@ -7,7 +7,6 @@
import React from 'react';
import { TrackApplicationView } from '@kbn/usage-collection-plugin/public';
import { Redirect } from 'react-router-dom';
import {
LANDING_PATH,
OVERVIEW_PATH,
@ -15,7 +14,6 @@ import {
DETECTION_RESPONSE_PATH,
SecurityPageName,
ENTITY_ANALYTICS_PATH,
COVERAGE_OVERVIEW_PATH,
} from '../../common/constants';
import type { SecuritySubPluginRoutes } from '../app/types';
@ -25,9 +23,7 @@ import { DetectionResponse } from './pages/detection_response';
import { PluginTemplateWrapper } from '../common/components/plugin_template_wrapper';
import { EntityAnalyticsPage } from './pages/entity_analytics';
import { SecurityRoutePageWrapper } from '../common/components/security_route_page_wrapper';
import { CoverageOverviewPage } from '../detection_engine/rule_management_ui/pages/coverage_overview';
import { LandingPage } from './pages/landing';
import { useIsExperimentalFeatureEnabled } from '../common/hooks/use_experimental_features';
const OverviewRoutes = () => (
<PluginTemplateWrapper>
@ -69,22 +65,6 @@ const DataQualityRoutes = () => (
</PluginTemplateWrapper>
);
const CoverageOverviewRoutes = () => {
const isDetectionsCoverageOverviewEnabled = useIsExperimentalFeatureEnabled(
'detectionsCoverageOverview'
);
return isDetectionsCoverageOverviewEnabled ? (
<PluginTemplateWrapper>
<TrackApplicationView viewId={SecurityPageName.coverageOverview}>
<CoverageOverviewPage />
</TrackApplicationView>
</PluginTemplateWrapper>
) : (
<Redirect to={SecurityPageName.landing} />
);
};
export const routes: SecuritySubPluginRoutes = [
{
path: OVERVIEW_PATH,
@ -106,8 +86,4 @@ export const routes: SecuritySubPluginRoutes = [
path: DATA_QUALITY_PATH,
component: DataQualityRoutes,
},
{
path: COVERAGE_OVERVIEW_PATH,
component: CoverageOverviewRoutes,
},
];

View file

@ -13,13 +13,22 @@ import {
RULES_LANDING_PATH,
RULES_ADD_PATH,
SERVER_APP_ID,
COVERAGE_OVERVIEW_PATH,
} from '../../common/constants';
import { ADD_RULES, CREATE_NEW_RULE, EXCEPTIONS, RULES, SIEM_RULES } from '../app/translations';
import {
ADD_RULES,
COVERAGE_OVERVIEW,
CREATE_NEW_RULE,
EXCEPTIONS,
RULES,
SIEM_RULES,
} from '../app/translations';
import { SecurityPageName } from '../app/types';
import { benchmarksLink } from '../cloud_security_posture/links';
import type { LinkItem } from '../common/links';
import { IconConsoleCloud } from '../common/icons/console_cloud';
import { IconRollup } from '../common/icons/rollup';
import { IconDashboards } from '../common/icons/dashboards';
export const links: LinkItem = {
id: SecurityPageName.rulesLanding,
@ -78,6 +87,25 @@ export const links: LinkItem = {
],
},
benchmarksLink,
{
id: SecurityPageName.coverageOverview,
title: COVERAGE_OVERVIEW,
landingIcon: IconDashboards,
description: i18n.translate(
'xpack.securitySolution.appLinks.coverageOverviewDashboardDescription',
{
defaultMessage: 'Review and maintain your protections MITRE ATT&CK® coverage',
}
),
path: COVERAGE_OVERVIEW_PATH,
capabilities: [`${SERVER_APP_ID}.show`],
globalSearchKeywords: [
i18n.translate('xpack.securitySolution.appLinks.coverageOverviewDashboard', {
defaultMessage: 'MITRE ATT&CK Coverage',
}),
],
experimentalKey: 'detectionsCoverageOverview',
},
],
categories: [
{
@ -90,5 +118,11 @@ export const links: LinkItem = {
SecurityPageName.exceptions,
],
},
{
label: i18n.translate('xpack.securitySolution.appLinks.category.discover', {
defaultMessage: 'Discover',
}),
linkIds: [SecurityPageName.coverageOverview],
},
],
};

View file

@ -10,7 +10,12 @@ import { Routes, Route } from '@kbn/shared-ux-router';
import { TrackApplicationView } from '@kbn/usage-collection-plugin/public';
import * as i18n from './translations';
import { RULES_LANDING_PATH, RULES_PATH, SecurityPageName } from '../../common/constants';
import {
COVERAGE_OVERVIEW_PATH,
RULES_LANDING_PATH,
RULES_PATH,
SecurityPageName,
} from '../../common/constants';
import { NotFoundPage } from '../app/404';
import { RulesPage } from '../detection_engine/rule_management_ui/pages/rule_management';
import { CreateRulePage } from '../detection_engine/rule_creation_ui/pages/rule_creation';
@ -26,6 +31,8 @@ import { AllRulesTabs } from '../detection_engine/rule_management_ui/components/
import { AddRulesPage } from '../detection_engine/rule_management_ui/pages/add_rules';
import type { SecuritySubPluginRoutes } from '../app/types';
import { RulesLandingPage } from './landing';
import { useIsExperimentalFeatureEnabled } from '../common/hooks/use_experimental_features';
import { CoverageOverviewPage } from '../detection_engine/rule_management_ui/pages/coverage_overview';
const RulesSubRoutes = [
{
@ -102,6 +109,22 @@ const RulesContainerComponent: React.FC = () => {
const Rules = React.memo(RulesContainerComponent);
const CoverageOverviewRoutes = () => {
const isDetectionsCoverageOverviewEnabled = useIsExperimentalFeatureEnabled(
'detectionsCoverageOverview'
);
return isDetectionsCoverageOverviewEnabled ? (
<PluginTemplateWrapper>
<TrackApplicationView viewId={SecurityPageName.coverageOverview}>
<CoverageOverviewPage />
</TrackApplicationView>
</PluginTemplateWrapper>
) : (
<Redirect to={SecurityPageName.landing} />
);
};
export const routes: SecuritySubPluginRoutes = [
{
path: RULES_LANDING_PATH,
@ -111,4 +134,8 @@ export const routes: SecuritySubPluginRoutes = [
path: RULES_PATH,
component: Rules,
},
{
path: COVERAGE_OVERVIEW_PATH,
component: CoverageOverviewRoutes,
},
];