From cd217c072fc786cb76ee47d885501688507c2dde Mon Sep 17 00:00:00 2001 From: christineweng <18648970+christineweng@users.noreply.github.com> Date: Thu, 10 Oct 2024 15:46:51 -0500 Subject: [PATCH] [Security Solution] Add alert and cloud insights to document flyout (#195509) ## Summary This PR adds alert count, misconfiguration and vulnerabilities insights to alert/event flyout. If data is not available, the insights are hidden. [Mocks](https://www.figma.com/design/ubvhBGHee58diJNvSiy0GZ/%5B8.%2B%5D-%5BAlerts%5D-Expandable-Event-Flyout?node-id=8017-179782&node-type=canvas&t=0YjHfPi9zOUFUScc-0) ![image](https://github.com/user-attachments/assets/ba706ab8-448a-4286-8229-c4c398136638) ### Checklist - [x] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md) - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios --- .../src/distribution_bar.stories.tsx | 8 ++ .../src/distribution_bar.test.tsx | 62 ++++++++++++ .../distribution_bar/src/distribution_bar.tsx | 9 +- .../misconfiguration_preview.tsx | 2 +- .../left/components/host_details.test.tsx | 52 ++++++++++ .../left/components/host_details.tsx | 30 ++++++ .../left/components/test_ids.ts | 8 ++ .../left/components/user_details.test.tsx | 38 +++++++ .../left/components/user_details.tsx | 22 +++++ .../components/host_entity_overview.test.tsx | 65 ++++++++++++ .../right/components/host_entity_overview.tsx | 24 ++++- .../right/components/test_ids.ts | 10 ++ .../components/user_entity_overview.test.tsx | 52 ++++++++++ .../right/components/user_entity_overview.tsx | 18 +++- .../components/alert_count_insight.test.tsx | 64 ++++++++++++ .../shared/components/alert_count_insight.tsx | 99 +++++++++++++++++++ .../insight_distribution_bar.test.tsx | 41 ++++++++ .../components/insight_distribution_bar.tsx | 88 +++++++++++++++++ .../misconfiguration_insight.test.tsx | 43 ++++++++ .../components/misconfiguration_insight.tsx | 80 +++++++++++++++ .../shared/components/test_ids.ts | 3 + .../vulnerabilities_insight.test.tsx | 44 +++++++++ .../components/vulnerabilities_insight.tsx | 91 +++++++++++++++++ 23 files changed, 946 insertions(+), 7 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/flyout/document_details/shared/components/alert_count_insight.test.tsx create mode 100644 x-pack/plugins/security_solution/public/flyout/document_details/shared/components/alert_count_insight.tsx create mode 100644 x-pack/plugins/security_solution/public/flyout/document_details/shared/components/insight_distribution_bar.test.tsx create mode 100644 x-pack/plugins/security_solution/public/flyout/document_details/shared/components/insight_distribution_bar.tsx create mode 100644 x-pack/plugins/security_solution/public/flyout/document_details/shared/components/misconfiguration_insight.test.tsx create mode 100644 x-pack/plugins/security_solution/public/flyout/document_details/shared/components/misconfiguration_insight.tsx create mode 100644 x-pack/plugins/security_solution/public/flyout/document_details/shared/components/vulnerabilities_insight.test.tsx create mode 100644 x-pack/plugins/security_solution/public/flyout/document_details/shared/components/vulnerabilities_insight.tsx diff --git a/x-pack/packages/security-solution/distribution_bar/src/distribution_bar.stories.tsx b/x-pack/packages/security-solution/distribution_bar/src/distribution_bar.stories.tsx index 90b6887636c8a..c1b292c3f08cc 100644 --- a/x-pack/packages/security-solution/distribution_bar/src/distribution_bar.stories.tsx +++ b/x-pack/packages/security-solution/distribution_bar/src/distribution_bar.stories.tsx @@ -70,6 +70,14 @@ export const DistributionBar = () => { , + + +

{'Hide last tooltip'}

+
+ + + +
,

{'Empty state'}

diff --git a/x-pack/packages/security-solution/distribution_bar/src/distribution_bar.test.tsx b/x-pack/packages/security-solution/distribution_bar/src/distribution_bar.test.tsx index d4bdf4c20f133..e83b66e5e01e7 100644 --- a/x-pack/packages/security-solution/distribution_bar/src/distribution_bar.test.tsx +++ b/x-pack/packages/security-solution/distribution_bar/src/distribution_bar.test.tsx @@ -79,5 +79,67 @@ describe('DistributionBar', () => { }); }); + it('should render last tooltip by default', () => { + const stats = [ + { + key: 'low', + count: 9, + color: 'green', + }, + { + key: 'medium', + count: 90, + color: 'red', + }, + { + key: 'high', + count: 900, + color: 'red', + }, + ]; + + const { container } = render( + + ); + expect(container).toBeInTheDocument(); + const parts = container.querySelectorAll(`[classname*="distribution_bar--tooltip"]`); + parts.forEach((part, index) => { + if (index < parts.length - 1) { + expect(part).toHaveStyle({ opacity: 0 }); + } else { + expect(part).toHaveStyle({ opacity: 1 }); + } + }); + }); + + it('should not render last tooltip when hideLastTooltip is true', () => { + const stats = [ + { + key: 'low', + count: 9, + color: 'green', + }, + { + key: 'medium', + count: 90, + color: 'red', + }, + { + key: 'high', + count: 900, + color: 'red', + }, + ]; + + const { container } = render( + + ); + expect(container).toBeInTheDocument(); + const parts = container.querySelectorAll(`[classname*="distribution_bar--tooltip"]`); + parts.forEach((part) => { + expect(part).toHaveStyle({ opacity: 0 }); + }); + }); + // todo: test tooltip visibility logic }); diff --git a/x-pack/packages/security-solution/distribution_bar/src/distribution_bar.tsx b/x-pack/packages/security-solution/distribution_bar/src/distribution_bar.tsx index 28d8ca4a8a148..5b06292813ccd 100644 --- a/x-pack/packages/security-solution/distribution_bar/src/distribution_bar.tsx +++ b/x-pack/packages/security-solution/distribution_bar/src/distribution_bar.tsx @@ -13,6 +13,8 @@ import { css } from '@emotion/react'; export interface DistributionBarProps { /** distribution data points */ stats: Array<{ key: string; count: number; color: string; label?: React.ReactNode }>; + /** hide the label above the bar at first render */ + hideLastTooltip?: boolean; /** data-test-subj used for querying the component in tests */ ['data-test-subj']?: string; } @@ -136,18 +138,21 @@ export const DistributionBar: React.FC = React.memo(functi props ) { const styles = useStyles(); - const { stats, 'data-test-subj': dataTestSubj } = props; + const { stats, 'data-test-subj': dataTestSubj, hideLastTooltip } = props; const parts = stats.map((stat) => { const partStyle = [ styles.part.base, styles.part.tick, styles.part.hover, - styles.part.lastTooltip, css` background-color: ${stat.color}; flex: ${stat.count}; `, ]; + if (!hideLastTooltip) { + partStyle.push(styles.part.lastTooltip); + } + const prettyNumber = numeral(stat.count).format('0,0a'); return ( diff --git a/x-pack/plugins/security_solution/public/cloud_security_posture/components/misconfiguration/misconfiguration_preview.tsx b/x-pack/plugins/security_solution/public/cloud_security_posture/components/misconfiguration/misconfiguration_preview.tsx index a13a77a3562ff..a372ca4755fd8 100644 --- a/x-pack/plugins/security_solution/public/cloud_security_posture/components/misconfiguration/misconfiguration_preview.tsx +++ b/x-pack/plugins/security_solution/public/cloud_security_posture/components/misconfiguration/misconfiguration_preview.tsx @@ -35,7 +35,7 @@ const FIRST_RECORD_PAGINATION = { querySize: 1, }; -const getFindingsStats = (passedFindingsStats: number, failedFindingsStats: number) => { +export const getFindingsStats = (passedFindingsStats: number, failedFindingsStats: number) => { if (passedFindingsStats === 0 && failedFindingsStats === 0) return []; return [ { diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/left/components/host_details.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/left/components/host_details.test.tsx index 46288434f48bb..23f6969c36778 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/left/components/host_details.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/left/components/host_details.test.tsx @@ -7,6 +7,8 @@ import React from 'react'; import { render } from '@testing-library/react'; +import { useMisconfigurationPreview } from '@kbn/cloud-security-posture/src/hooks/use_misconfiguration_preview'; +import { useVulnerabilitiesPreview } from '@kbn/cloud-security-posture/src/hooks/use_vulnerabilities_preview'; import type { Anomalies } from '../../../../common/components/ml/types'; import { DocumentDetailsContext } from '../../shared/context'; import { TestProviders } from '../../../../common/mock'; @@ -24,6 +26,9 @@ import { HOST_DETAILS_LINK_TEST_ID, HOST_DETAILS_RELATED_USERS_LINK_TEST_ID, HOST_DETAILS_RELATED_USERS_IP_LINK_TEST_ID, + HOST_DETAILS_MISCONFIGURATIONS_TEST_ID, + HOST_DETAILS_VULNERABILITIES_TEST_ID, + HOST_DETAILS_ALERT_COUNT_TEST_ID, } from './test_ids'; import { EXPANDABLE_PANEL_CONTENT_TEST_ID } from '@kbn/security-solution-common'; import { useRiskScore } from '../../../../entity_analytics/api/hooks/use_risk_score'; @@ -35,8 +40,11 @@ import { HOST_PREVIEW_BANNER } from '../../right/components/host_entity_overview import { UserPreviewPanelKey } from '../../../entity_details/user_right'; import { USER_PREVIEW_BANNER } from '../../right/components/user_entity_overview'; import { NetworkPanelKey, NETWORK_PREVIEW_BANNER } from '../../../network_details'; +import { useSummaryChartData } from '../../../../detections/components/alerts_kpis/alerts_summary_charts_panel/use_summary_chart_data'; jest.mock('@kbn/expandable-flyout'); +jest.mock('@kbn/cloud-security-posture/src/hooks/use_misconfiguration_preview'); +jest.mock('@kbn/cloud-security-posture/src/hooks/use_vulnerabilities_preview'); jest.mock('../../../../common/hooks/use_experimental_features'); const mockUseIsExperimentalFeatureEnabled = useIsExperimentalFeatureEnabled as jest.Mock; @@ -104,6 +112,10 @@ const mockUseHostsRelatedUsers = useHostRelatedUsers as jest.Mock; jest.mock('../../../../entity_analytics/api/hooks/use_risk_score'); const mockUseRiskScore = useRiskScore as jest.Mock; +jest.mock( + '../../../../detections/components/alerts_kpis/alerts_summary_charts_panel/use_summary_chart_data' +); + const timestamp = '2022-07-25T08:20:18.966Z'; const defaultProps = { @@ -158,6 +170,9 @@ describe('', () => { mockUseRiskScore.mockReturnValue(mockRiskScoreResponse); mockUseHostsRelatedUsers.mockReturnValue(mockRelatedUsersResponse); mockUseIsExperimentalFeatureEnabled.mockReturnValue(true); + (useMisconfigurationPreview as jest.Mock).mockReturnValue({}); + (useVulnerabilitiesPreview as jest.Mock).mockReturnValue({}); + (useSummaryChartData as jest.Mock).mockReturnValue({ isLoading: false, items: [] }); }); it('should render host details correctly', () => { @@ -296,4 +311,41 @@ describe('', () => { }); }); }); + + describe('distribution bar insights', () => { + it('should not render if no data is available', () => { + const { queryByTestId } = renderHostDetails(mockContextValue); + expect(queryByTestId(HOST_DETAILS_MISCONFIGURATIONS_TEST_ID)).not.toBeInTheDocument(); + expect(queryByTestId(HOST_DETAILS_VULNERABILITIES_TEST_ID)).not.toBeInTheDocument(); + expect(queryByTestId(HOST_DETAILS_ALERT_COUNT_TEST_ID)).not.toBeInTheDocument(); + }); + + it('should render alert count when data is available', () => { + (useSummaryChartData as jest.Mock).mockReturnValue({ + isLoading: false, + items: [{ key: 'high', value: 78, label: 'High' }], + }); + + const { getByTestId } = renderHostDetails(mockContextValue); + expect(getByTestId(HOST_DETAILS_ALERT_COUNT_TEST_ID)).toBeInTheDocument(); + }); + + it('should render misconfiguration when data is available', () => { + (useMisconfigurationPreview as jest.Mock).mockReturnValue({ + data: { count: { passed: 1, failed: 2 } }, + }); + + const { getByTestId } = renderHostDetails(mockContextValue); + expect(getByTestId(HOST_DETAILS_MISCONFIGURATIONS_TEST_ID)).toBeInTheDocument(); + }); + + it('should render vulnerabilities when data is available', () => { + (useVulnerabilitiesPreview as jest.Mock).mockReturnValue({ + data: { count: { CRITICAL: 0, HIGH: 1, MEDIUM: 1, LOW: 0, UNKNOWN: 0 } }, + }); + + const { getByTestId } = renderHostDetails(mockContextValue); + expect(getByTestId(HOST_DETAILS_VULNERABILITIES_TEST_ID)).toBeInTheDocument(); + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/left/components/host_details.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/left/components/host_details.tsx index 33b8bb22fce53..122caa657b039 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/left/components/host_details.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/left/components/host_details.tsx @@ -18,6 +18,8 @@ import { EuiToolTip, EuiIcon, EuiPanel, + EuiHorizontalRule, + EuiFlexGrid, } from '@elastic/eui'; import type { EuiBasicTableColumn } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; @@ -51,6 +53,9 @@ import { HOST_DETAILS_RELATED_USERS_TABLE_TEST_ID, HOST_DETAILS_RELATED_USERS_LINK_TEST_ID, HOST_DETAILS_RELATED_USERS_IP_LINK_TEST_ID, + HOST_DETAILS_ALERT_COUNT_TEST_ID, + HOST_DETAILS_MISCONFIGURATIONS_TEST_ID, + HOST_DETAILS_VULNERABILITIES_TEST_ID, } from './test_ids'; import { USER_NAME_FIELD_NAME, @@ -63,6 +68,9 @@ import { PreviewLink } from '../../../shared/components/preview_link'; import { HostPreviewPanelKey } from '../../../entity_details/host_right'; import { HOST_PREVIEW_BANNER } from '../../right/components/host_entity_overview'; import type { NarrowDateRange } from '../../../../common/components/ml/types'; +import { MisconfigurationsInsight } from '../../shared/components/misconfiguration_insight'; +import { VulnerabilitiesInsight } from '../../shared/components/vulnerabilities_insight'; +import { AlertCountInsight } from '../../shared/components/alert_count_insight'; const HOST_DETAILS_ID = 'entities-hosts-details'; const RELATED_USERS_ID = 'entities-hosts-related-users'; @@ -337,6 +345,28 @@ export const HostDetails: React.FC = ({ hostName, timestamp, s )} + + + + + + + + diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/left/components/test_ids.ts b/x-pack/plugins/security_solution/public/flyout/document_details/left/components/test_ids.ts index 0779f3c135b2d..8669b504f6861 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/left/components/test_ids.ts +++ b/x-pack/plugins/security_solution/public/flyout/document_details/left/components/test_ids.ts @@ -43,6 +43,9 @@ export const PREVALENCE_DETAILS_TABLE_UPSELL_CELL_TEST_ID = export const ENTITIES_DETAILS_TEST_ID = `${PREFIX}EntitiesDetails` as const; export const USER_DETAILS_TEST_ID = `${PREFIX}UsersDetails` as const; export const USER_DETAILS_LINK_TEST_ID = `${USER_DETAILS_TEST_ID}TitleLink` as const; +export const USER_DETAILS_ALERT_COUNT_TEST_ID = `${USER_DETAILS_TEST_ID}AlertCount` as const; +export const USER_DETAILS_MISCONFIGURATIONS_TEST_ID = + `${USER_DETAILS_TEST_ID}Misconfigurations` as const; export const USER_DETAILS_RELATED_HOSTS_TABLE_TEST_ID = `${USER_DETAILS_TEST_ID}RelatedHostsTable` as const; export const USER_DETAILS_RELATED_HOSTS_LINK_TEST_ID = @@ -53,6 +56,11 @@ export const USER_DETAILS_INFO_TEST_ID = 'user-overview' as const; export const HOST_DETAILS_TEST_ID = `${PREFIX}HostsDetails` as const; export const HOST_DETAILS_LINK_TEST_ID = `${HOST_DETAILS_TEST_ID}TitleLink` as const; +export const HOST_DETAILS_ALERT_COUNT_TEST_ID = `${HOST_DETAILS_TEST_ID}AlertCount` as const; +export const HOST_DETAILS_MISCONFIGURATIONS_TEST_ID = + `${HOST_DETAILS_TEST_ID}Misconfigurations` as const; +export const HOST_DETAILS_VULNERABILITIES_TEST_ID = + `${HOST_DETAILS_TEST_ID}Vulnerabilities` as const; export const HOST_DETAILS_RELATED_USERS_TABLE_TEST_ID = `${HOST_DETAILS_TEST_ID}RelatedUsersTable` as const; export const HOST_DETAILS_RELATED_USERS_LINK_TEST_ID = diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/left/components/user_details.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/left/components/user_details.test.tsx index c1ed881e80a95..a2c53afb8c3f3 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/left/components/user_details.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/left/components/user_details.test.tsx @@ -7,6 +7,7 @@ import React from 'react'; import { render } from '@testing-library/react'; +import { useMisconfigurationPreview } from '@kbn/cloud-security-posture/src/hooks/use_misconfiguration_preview'; import type { Anomalies } from '../../../../common/components/ml/types'; import { TestProviders } from '../../../../common/mock'; import { DocumentDetailsContext } from '../../shared/context'; @@ -24,6 +25,8 @@ import { USER_DETAILS_RELATED_HOSTS_TABLE_TEST_ID, USER_DETAILS_RELATED_HOSTS_LINK_TEST_ID, USER_DETAILS_RELATED_HOSTS_IP_LINK_TEST_ID, + USER_DETAILS_MISCONFIGURATIONS_TEST_ID, + USER_DETAILS_ALERT_COUNT_TEST_ID, } from './test_ids'; import { EXPANDABLE_PANEL_CONTENT_TEST_ID } from '@kbn/security-solution-common'; import { useRiskScore } from '../../../../entity_analytics/api/hooks/use_risk_score'; @@ -35,8 +38,10 @@ import { HOST_PREVIEW_BANNER } from '../../right/components/host_entity_overview import { UserPreviewPanelKey } from '../../../entity_details/user_right'; import { USER_PREVIEW_BANNER } from '../../right/components/user_entity_overview'; import { NetworkPanelKey, NETWORK_PREVIEW_BANNER } from '../../../network_details'; +import { useSummaryChartData } from '../../../../detections/components/alerts_kpis/alerts_summary_charts_panel/use_summary_chart_data'; jest.mock('@kbn/expandable-flyout'); +jest.mock('@kbn/cloud-security-posture/src/hooks/use_misconfiguration_preview'); jest.mock('../../../../common/hooks/use_experimental_features'); const mockUseIsExperimentalFeatureEnabled = useIsExperimentalFeatureEnabled as jest.Mock; @@ -101,6 +106,10 @@ const mockUseUsersRelatedHosts = useUserRelatedHosts as jest.Mock; jest.mock('../../../../entity_analytics/api/hooks/use_risk_score'); const mockUseRiskScore = useRiskScore as jest.Mock; +jest.mock( + '../../../../detections/components/alerts_kpis/alerts_summary_charts_panel/use_summary_chart_data' +); + const timestamp = '2022-07-25T08:20:18.966Z'; const defaultProps = { @@ -155,6 +164,8 @@ describe('', () => { mockUseRiskScore.mockReturnValue(mockRiskScoreResponse); mockUseUsersRelatedHosts.mockReturnValue(mockRelatedHostsResponse); mockUseIsExperimentalFeatureEnabled.mockReturnValue(true); + (useMisconfigurationPreview as jest.Mock).mockReturnValue({}); + (useSummaryChartData as jest.Mock).mockReturnValue({ isLoading: false, items: [] }); }); it('should render user details correctly', () => { @@ -278,4 +289,31 @@ describe('', () => { }); }); }); + + describe('distribution bar insights', () => { + it('should not render if no data is available', () => { + const { queryByTestId } = renderUserDetails(mockContextValue); + expect(queryByTestId(USER_DETAILS_MISCONFIGURATIONS_TEST_ID)).not.toBeInTheDocument(); + expect(queryByTestId(USER_DETAILS_ALERT_COUNT_TEST_ID)).not.toBeInTheDocument(); + }); + + it('should render alert count when data is available', () => { + (useSummaryChartData as jest.Mock).mockReturnValue({ + isLoading: false, + items: [{ key: 'high', value: 78, label: 'High' }], + }); + + const { getByTestId } = renderUserDetails(mockContextValue); + expect(getByTestId(USER_DETAILS_ALERT_COUNT_TEST_ID)).toBeInTheDocument(); + }); + + it('should render misconfiguration when data is available', () => { + (useMisconfigurationPreview as jest.Mock).mockReturnValue({ + data: { count: { passed: 1, failed: 2 } }, + }); + + const { getByTestId } = renderUserDetails(mockContextValue); + expect(getByTestId(USER_DETAILS_MISCONFIGURATIONS_TEST_ID)).toBeInTheDocument(); + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/left/components/user_details.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/left/components/user_details.tsx index 13d3e825053ba..c90d11f4b8bc2 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/left/components/user_details.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/left/components/user_details.tsx @@ -18,6 +18,8 @@ import { EuiFlexItem, EuiToolTip, EuiPanel, + EuiHorizontalRule, + EuiFlexGrid, } from '@elastic/eui'; import type { EuiBasicTableColumn } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; @@ -51,6 +53,8 @@ import { USER_DETAILS_TEST_ID, USER_DETAILS_RELATED_HOSTS_LINK_TEST_ID, USER_DETAILS_RELATED_HOSTS_IP_LINK_TEST_ID, + USER_DETAILS_MISCONFIGURATIONS_TEST_ID, + USER_DETAILS_ALERT_COUNT_TEST_ID, } from './test_ids'; import { HOST_NAME_FIELD_NAME, @@ -63,6 +67,8 @@ import { UserPreviewPanelKey } from '../../../entity_details/user_right'; import { USER_PREVIEW_BANNER } from '../../right/components/user_entity_overview'; import { PreviewLink } from '../../../shared/components/preview_link'; import type { NarrowDateRange } from '../../../../common/components/ml/types'; +import { MisconfigurationsInsight } from '../../shared/components/misconfiguration_insight'; +import { AlertCountInsight } from '../../shared/components/alert_count_insight'; const USER_DETAILS_ID = 'entities-users-details'; const RELATED_HOSTS_ID = 'entities-users-related-hosts'; @@ -340,6 +346,22 @@ export const UserDetails: React.FC = ({ userName, timestamp, s )} + + + + + + diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/host_entity_overview.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/host_entity_overview.test.tsx index b710df84e1a13..6ad90adb28997 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/host_entity_overview.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/host_entity_overview.test.tsx @@ -6,6 +6,8 @@ */ import React from 'react'; import { render } from '@testing-library/react'; +import { useMisconfigurationPreview } from '@kbn/cloud-security-posture/src/hooks/use_misconfiguration_preview'; +import { useVulnerabilitiesPreview } from '@kbn/cloud-security-posture/src/hooks/use_vulnerabilities_preview'; import { TestProviders } from '../../../../common/mock'; import { HostEntityOverview, HOST_PREVIEW_BANNER } from './host_entity_overview'; import { useHostDetails } from '../../../../explore/hosts/containers/hosts/details'; @@ -16,6 +18,9 @@ import { ENTITIES_HOST_OVERVIEW_LINK_TEST_ID, ENTITIES_HOST_OVERVIEW_RISK_LEVEL_TEST_ID, ENTITIES_HOST_OVERVIEW_LOADING_TEST_ID, + ENTITIES_HOST_OVERVIEW_MISCONFIGURATIONS_TEST_ID, + ENTITIES_HOST_OVERVIEW_VULNERABILITIES_TEST_ID, + ENTITIES_HOST_OVERVIEW_ALERT_COUNT_TEST_ID, } from './test_ids'; import { DocumentDetailsContext } from '../../shared/context'; import { mockContextValue } from '../../shared/mocks/mock_context'; @@ -29,6 +34,7 @@ import { ENTITIES_TAB_ID } from '../../left/components/entities_details'; import { useRiskScore } from '../../../../entity_analytics/api/hooks/use_risk_score'; import { mockFlyoutApi } from '../../shared/mocks/mock_flyout_context'; import { createTelemetryServiceMock } from '../../../../common/lib/telemetry/telemetry_service.mock'; +import { useSummaryChartData } from '../../../../detections/components/alerts_kpis/alerts_summary_charts_panel/use_summary_chart_data'; const hostName = 'host'; const osFamily = 'Windows'; @@ -46,6 +52,17 @@ const panelContextValue = { }; jest.mock('@kbn/expandable-flyout'); +jest.mock('@kbn/cloud-security-posture/src/hooks/use_misconfiguration_preview'); +jest.mock('@kbn/cloud-security-posture/src/hooks/use_vulnerabilities_preview'); + +jest.mock('react-router-dom', () => { + const actual = jest.requireActual('react-router-dom'); + return { ...actual, useLocation: jest.fn().mockReturnValue({ pathname: '' }) }; +}); + +jest.mock( + '../../../../detections/components/alerts_kpis/alerts_summary_charts_panel/use_summary_chart_data' +); const mockedTelemetry = createTelemetryServiceMock(); jest.mock('../../../../common/lib/kibana', () => { @@ -99,6 +116,9 @@ describe('', () => { beforeAll(() => { jest.mocked(useExpandableFlyoutApi).mockReturnValue(mockFlyoutApi); mockUseIsExperimentalFeatureEnabled.mockReturnValue(true); + (useMisconfigurationPreview as jest.Mock).mockReturnValue({}); + (useVulnerabilitiesPreview as jest.Mock).mockReturnValue({}); + (useSummaryChartData as jest.Mock).mockReturnValue({ isLoading: false, items: [] }); }); describe('license is valid', () => { @@ -150,6 +170,7 @@ describe('', () => { ); expect(getByTestId(ENTITIES_HOST_OVERVIEW_LOADING_TEST_ID)).toBeInTheDocument(); }); + describe('license is not valid', () => { it('should render os family and last seen', () => { mockUseHostDetails.mockReturnValue([false, { hostDetails: hostData }]); @@ -210,4 +231,48 @@ describe('', () => { }); }); }); + + describe('distribution bar insights', () => { + beforeEach(() => { + mockUseHostDetails.mockReturnValue([false, { hostDetails: hostData }]); + mockUseRiskScore.mockReturnValue({ data: riskLevel, isAuthorized: true }); + }); + + it('should not render if no data is available', () => { + const { queryByTestId } = renderHostEntityContent(); + expect( + queryByTestId(ENTITIES_HOST_OVERVIEW_MISCONFIGURATIONS_TEST_ID) + ).not.toBeInTheDocument(); + expect(queryByTestId(ENTITIES_HOST_OVERVIEW_VULNERABILITIES_TEST_ID)).not.toBeInTheDocument(); + expect(queryByTestId(ENTITIES_HOST_OVERVIEW_ALERT_COUNT_TEST_ID)).not.toBeInTheDocument(); + }); + + it('should render alert count when data is available', () => { + (useSummaryChartData as jest.Mock).mockReturnValue({ + isLoading: false, + items: [{ key: 'high', value: 78, label: 'High' }], + }); + + const { getByTestId } = renderHostEntityContent(); + expect(getByTestId(ENTITIES_HOST_OVERVIEW_ALERT_COUNT_TEST_ID)).toBeInTheDocument(); + }); + + it('should render misconfiguration when data is available', () => { + (useMisconfigurationPreview as jest.Mock).mockReturnValue({ + data: { count: { passed: 1, failed: 2 } }, + }); + + const { getByTestId } = renderHostEntityContent(); + expect(getByTestId(ENTITIES_HOST_OVERVIEW_MISCONFIGURATIONS_TEST_ID)).toBeInTheDocument(); + }); + + it('should render vulnerabilities when data is available', () => { + (useVulnerabilitiesPreview as jest.Mock).mockReturnValue({ + data: { count: { CRITICAL: 0, HIGH: 1, MEDIUM: 1, LOW: 0, UNKNOWN: 0 } }, + }); + + const { getByTestId } = renderHostEntityContent(); + expect(getByTestId(ENTITIES_HOST_OVERVIEW_VULNERABILITIES_TEST_ID)).toBeInTheDocument(); + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/host_entity_overview.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/host_entity_overview.tsx index ca6a68eb23be8..90405286b004c 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/host_entity_overview.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/host_entity_overview.tsx @@ -52,11 +52,17 @@ import { ENTITIES_HOST_OVERVIEW_RISK_LEVEL_TEST_ID, ENTITIES_HOST_OVERVIEW_LINK_TEST_ID, ENTITIES_HOST_OVERVIEW_LOADING_TEST_ID, + ENTITIES_HOST_OVERVIEW_ALERT_COUNT_TEST_ID, + ENTITIES_HOST_OVERVIEW_MISCONFIGURATIONS_TEST_ID, + ENTITIES_HOST_OVERVIEW_VULNERABILITIES_TEST_ID, } from './test_ids'; import { DocumentDetailsLeftPanelKey } from '../../shared/constants/panel_keys'; import { LeftPanelInsightsTab } from '../../left'; import { RiskScoreDocTooltip } from '../../../../overview/components/common'; import { PreviewLink } from '../../../shared/components/preview_link'; +import { MisconfigurationsInsight } from '../../shared/components/misconfiguration_insight'; +import { VulnerabilitiesInsight } from '../../shared/components/vulnerabilities_insight'; +import { AlertCountInsight } from '../../shared/components/alert_count_insight'; const HOST_ICON = 'storage'; @@ -196,12 +202,12 @@ export const HostEntityOverview: React.FC = ({ hostName return ( - + @@ -270,6 +276,20 @@ export const HostEntityOverview: React.FC = ({ hostName )} + + + ); }; diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/test_ids.ts b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/test_ids.ts index 40670ddc7110a..e0d8bc6db0f5c 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/test_ids.ts +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/test_ids.ts @@ -121,6 +121,10 @@ export const ENTITIES_USER_OVERVIEW_LAST_SEEN_TEST_ID = `${ENTITIES_USER_OVERVIEW_TEST_ID}LastSeen` as const; export const ENTITIES_USER_OVERVIEW_RISK_LEVEL_TEST_ID = `${ENTITIES_USER_OVERVIEW_TEST_ID}RiskLevel` as const; +export const ENTITIES_USER_OVERVIEW_ALERT_COUNT_TEST_ID = + `${ENTITIES_USER_OVERVIEW_TEST_ID}AlertCount` as const; +export const ENTITIES_USER_OVERVIEW_MISCONFIGURATIONS_TEST_ID = + `${ENTITIES_USER_OVERVIEW_TEST_ID}Misconfigurations` as const; export const ENTITIES_HOST_OVERVIEW_TEST_ID = `${INSIGHTS_ENTITIES_TEST_ID}HostOverview` as const; export const ENTITIES_HOST_OVERVIEW_LOADING_TEST_ID = @@ -132,6 +136,12 @@ export const ENTITIES_HOST_OVERVIEW_LAST_SEEN_TEST_ID = `${ENTITIES_HOST_OVERVIEW_TEST_ID}LastSeen` as const; export const ENTITIES_HOST_OVERVIEW_RISK_LEVEL_TEST_ID = `${ENTITIES_HOST_OVERVIEW_TEST_ID}RiskLevel` as const; +export const ENTITIES_HOST_OVERVIEW_ALERT_COUNT_TEST_ID = + `${ENTITIES_HOST_OVERVIEW_TEST_ID}AlertCount` as const; +export const ENTITIES_HOST_OVERVIEW_MISCONFIGURATIONS_TEST_ID = + `${ENTITIES_HOST_OVERVIEW_TEST_ID}Misconfigurations` as const; +export const ENTITIES_HOST_OVERVIEW_VULNERABILITIES_TEST_ID = + `${ENTITIES_HOST_OVERVIEW_TEST_ID}Vulnerabilities` as const; /* Threat intelligence */ diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/user_entity_overview.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/user_entity_overview.test.tsx index 000da8946ff61..95c399ca4362e 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/user_entity_overview.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/user_entity_overview.test.tsx @@ -7,6 +7,7 @@ import React from 'react'; import { render } from '@testing-library/react'; import { TestProviders } from '../../../../common/mock'; +import { useMisconfigurationPreview } from '@kbn/cloud-security-posture/src/hooks/use_misconfiguration_preview'; import { UserEntityOverview, USER_PREVIEW_BANNER } from './user_entity_overview'; import { useFirstLastSeen } from '../../../../common/containers/use_first_last_seen'; import { @@ -15,6 +16,8 @@ import { ENTITIES_USER_OVERVIEW_LINK_TEST_ID, ENTITIES_USER_OVERVIEW_RISK_LEVEL_TEST_ID, ENTITIES_USER_OVERVIEW_LOADING_TEST_ID, + ENTITIES_USER_OVERVIEW_MISCONFIGURATIONS_TEST_ID, + ENTITIES_USER_OVERVIEW_ALERT_COUNT_TEST_ID, } from './test_ids'; import { useObservedUserDetails } from '../../../../explore/users/containers/users/observed_details'; import { mockContextValue } from '../../shared/mocks/mock_context'; @@ -28,6 +31,7 @@ import { useExpandableFlyoutApi } from '@kbn/expandable-flyout'; import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features'; import { mockFlyoutApi } from '../../shared/mocks/mock_flyout_context'; import { UserPreviewPanelKey } from '../../../entity_details/user_right'; +import { useSummaryChartData } from '../../../../detections/components/alerts_kpis/alerts_summary_charts_panel/use_summary_chart_data'; const userName = 'user'; const domain = 'n54bg2lfc7'; @@ -45,6 +49,18 @@ const panelContextValue = { }; jest.mock('@kbn/expandable-flyout'); +jest.mock('@kbn/cloud-security-posture/src/hooks/use_misconfiguration_preview'); + +jest.mock('../../../../common/lib/kibana'); + +jest.mock('react-router-dom', () => { + const actual = jest.requireActual('react-router-dom'); + return { ...actual, useLocation: jest.fn().mockReturnValue({ pathname: '' }) }; +}); + +jest.mock( + '../../../../detections/components/alerts_kpis/alerts_summary_charts_panel/use_summary_chart_data' +); jest.mock('../../../../common/hooks/use_experimental_features'); const mockUseIsExperimentalFeatureEnabled = useIsExperimentalFeatureEnabled as jest.Mock; @@ -85,6 +101,8 @@ describe('', () => { beforeAll(() => { jest.mocked(useExpandableFlyoutApi).mockReturnValue(mockFlyoutApi); mockUseIsExperimentalFeatureEnabled.mockReturnValue(true); + (useMisconfigurationPreview as jest.Mock).mockReturnValue({}); + (useSummaryChartData as jest.Mock).mockReturnValue({ isLoading: false, items: [] }); }); describe('license is valid', () => { @@ -211,4 +229,38 @@ describe('', () => { }); }); }); + + describe('distribution bar insights', () => { + beforeEach(() => { + mockUseUserDetails.mockReturnValue([false, { userDetails: userData }]); + mockUseRiskScore.mockReturnValue({ data: riskLevel, isAuthorized: true }); + }); + + it('should not render if no data is available', () => { + const { queryByTestId } = renderUserEntityOverview(); + expect( + queryByTestId(ENTITIES_USER_OVERVIEW_MISCONFIGURATIONS_TEST_ID) + ).not.toBeInTheDocument(); + expect(queryByTestId(ENTITIES_USER_OVERVIEW_ALERT_COUNT_TEST_ID)).not.toBeInTheDocument(); + }); + + it('should render alert count when data is available', () => { + (useSummaryChartData as jest.Mock).mockReturnValue({ + isLoading: false, + items: [{ key: 'high', value: 78, label: 'High' }], + }); + + const { getByTestId } = renderUserEntityOverview(); + expect(getByTestId(ENTITIES_USER_OVERVIEW_ALERT_COUNT_TEST_ID)).toBeInTheDocument(); + }); + + it('should render misconfiguration when data is available', () => { + (useMisconfigurationPreview as jest.Mock).mockReturnValue({ + data: { count: { passed: 1, failed: 2 } }, + }); + + const { getByTestId } = renderUserEntityOverview(); + expect(getByTestId(ENTITIES_USER_OVERVIEW_MISCONFIGURATIONS_TEST_ID)).toBeInTheDocument(); + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/user_entity_overview.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/user_entity_overview.tsx index 624b9e816c9e5..0019228d656cd 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/user_entity_overview.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/user_entity_overview.tsx @@ -53,10 +53,14 @@ import { ENTITIES_USER_OVERVIEW_RISK_LEVEL_TEST_ID, ENTITIES_USER_OVERVIEW_LINK_TEST_ID, ENTITIES_USER_OVERVIEW_LOADING_TEST_ID, + ENTITIES_USER_OVERVIEW_MISCONFIGURATIONS_TEST_ID, + ENTITIES_USER_OVERVIEW_ALERT_COUNT_TEST_ID, } from './test_ids'; import { useObservedUserDetails } from '../../../../explore/users/containers/users/observed_details'; import { RiskScoreDocTooltip } from '../../../../overview/components/common'; import { PreviewLink } from '../../../shared/components/preview_link'; +import { MisconfigurationsInsight } from '../../shared/components/misconfiguration_insight'; +import { AlertCountInsight } from '../../shared/components/alert_count_insight'; const USER_ICON = 'user'; @@ -196,12 +200,12 @@ export const UserEntityOverview: React.FC = ({ userName return ( - + @@ -270,6 +274,16 @@ export const UserEntityOverview: React.FC = ({ userName )} + + ); }; diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/shared/components/alert_count_insight.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/shared/components/alert_count_insight.test.tsx new file mode 100644 index 0000000000000..f0d16a418f2b2 --- /dev/null +++ b/x-pack/plugins/security_solution/public/flyout/document_details/shared/components/alert_count_insight.test.tsx @@ -0,0 +1,64 @@ +/* + * 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 { render } from '@testing-library/react'; +import { TestProviders } from '../../../../common/mock'; +import { AlertCountInsight } from './alert_count_insight'; +import { useSummaryChartData } from '../../../../detections/components/alerts_kpis/alerts_summary_charts_panel/use_summary_chart_data'; + +jest.mock('../../../../common/lib/kibana'); + +jest.mock('react-router-dom', () => { + const actual = jest.requireActual('react-router-dom'); + return { ...actual, useLocation: jest.fn().mockReturnValue({ pathname: '' }) }; +}); +jest.mock('@kbn/cloud-security-posture/src/hooks/use_misconfiguration_preview'); +jest.mock( + '../../../../detections/components/alerts_kpis/alerts_summary_charts_panel/use_summary_chart_data' +); + +const fieldName = 'host.name'; +const name = 'test host'; +const testId = 'test'; + +const renderAlertCountInsight = () => { + return render( + + + + ); +}; + +describe('AlertCountInsight', () => { + it('renders', () => { + (useSummaryChartData as jest.Mock).mockReturnValue({ + isLoading: false, + items: [ + { key: 'high', value: 78, label: 'High' }, + { key: 'low', value: 46, label: 'Low' }, + { key: 'medium', value: 32, label: 'Medium' }, + { key: 'critical', value: 21, label: 'Critical' }, + ], + }); + const { getByTestId } = renderAlertCountInsight(); + expect(getByTestId(testId)).toBeInTheDocument(); + expect(getByTestId(`${testId}-distribution-bar`)).toBeInTheDocument(); + }); + + it('renders loading spinner if data is being fetched', () => { + (useSummaryChartData as jest.Mock).mockReturnValue({ isLoading: true, items: [] }); + const { getByTestId } = renderAlertCountInsight(); + expect(getByTestId(`${testId}-loading-spinner`)).toBeInTheDocument(); + }); + + it('renders null if no misconfiguration data found', () => { + (useSummaryChartData as jest.Mock).mockReturnValue({ isLoading: false, items: [] }); + const { container } = renderAlertCountInsight(); + expect(container).toBeEmptyDOMElement(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/shared/components/alert_count_insight.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/shared/components/alert_count_insight.tsx new file mode 100644 index 0000000000000..566b77b5739a9 --- /dev/null +++ b/x-pack/plugins/security_solution/public/flyout/document_details/shared/components/alert_count_insight.tsx @@ -0,0 +1,99 @@ +/* + * 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, { useMemo } from 'react'; +import { v4 as uuid } from 'uuid'; +import { EuiLoadingSpinner, EuiFlexItem, type EuiFlexGroupProps } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { InsightDistributionBar } from './insight_distribution_bar'; +import { severityAggregations } from '../../../../detections/components/alerts_kpis/alerts_summary_charts_panel/aggregations'; +import { useSummaryChartData } from '../../../../detections/components/alerts_kpis/alerts_summary_charts_panel/use_summary_chart_data'; +import { + getIsAlertsBySeverityData, + getSeverityColor, +} from '../../../../detections/components/alerts_kpis/severity_level_panel/helpers'; + +const ENTITY_ALERT_COUNT_ID = 'entity-alert-count'; + +interface AlertCountInsightProps { + /** + * The name of the entity to filter the alerts by. + */ + name: string; + /** + * The field name to filter the alerts by. + */ + fieldName: 'host.name' | 'user.name'; + /** + * The direction of the flex group. + */ + direction?: EuiFlexGroupProps['direction']; + /** + * The data-test-subj to use for the component. + */ + ['data-test-subj']?: string; +} + +/* + * Displays a distribution bar with the count of critical alerts for a given entity + */ +export const AlertCountInsight: React.FC = ({ + name, + fieldName, + direction, + 'data-test-subj': dataTestSubj, +}) => { + const uniqueQueryId = useMemo(() => `${ENTITY_ALERT_COUNT_ID}-${uuid()}`, []); + const entityFilter = useMemo(() => ({ field: fieldName, value: name }), [fieldName, name]); + + const { items, isLoading } = useSummaryChartData({ + aggregations: severityAggregations, + entityFilter, + uniqueQueryId, + signalIndexName: null, + }); + + const data = useMemo(() => (getIsAlertsBySeverityData(items) ? items : []), [items]); + + const alertStats = useMemo(() => { + return data.map((item) => ({ + key: item.key, + count: item.value, + color: getSeverityColor(item.key), + })); + }, [data]); + + const count = useMemo( + () => data.filter((item) => item.key === 'critical')[0]?.value ?? 0, + [data] + ); + + if (!isLoading && items.length === 0) return null; + + return ( + + {isLoading ? ( + + ) : ( + + } + stats={alertStats} + count={count} + direction={direction} + data-test-subj={`${dataTestSubj}-distribution-bar`} + /> + )} + + ); +}; + +AlertCountInsight.displayName = 'AlertCountInsight'; diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/shared/components/insight_distribution_bar.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/shared/components/insight_distribution_bar.test.tsx new file mode 100644 index 0000000000000..a775da8a7f73a --- /dev/null +++ b/x-pack/plugins/security_solution/public/flyout/document_details/shared/components/insight_distribution_bar.test.tsx @@ -0,0 +1,41 @@ +/* + * 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 { render } from '@testing-library/react'; +import { InsightDistributionBar } from './insight_distribution_bar'; +import { TestProviders } from '../../../../common/mock'; + +const title = 'test title'; +const count = 10; +const testId = 'test-id'; +const stats = [ + { + key: 'passed', + count: 90, + color: 'green', + }, + { + key: 'failed', + count: 10, + color: 'red', + }, +]; + +describe('', () => { + it('should render', () => { + const { getByTestId, getByText } = render( + + + + ); + expect(getByTestId(testId)).toBeInTheDocument(); + expect(getByText(title)).toBeInTheDocument(); + expect(getByTestId(`${testId}-badge`)).toHaveTextContent(`${count}`); + expect(getByTestId(`${testId}-distribution-bar`)).toBeInTheDocument(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/shared/components/insight_distribution_bar.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/shared/components/insight_distribution_bar.tsx new file mode 100644 index 0000000000000..006ec8c5dad4f --- /dev/null +++ b/x-pack/plugins/security_solution/public/flyout/document_details/shared/components/insight_distribution_bar.tsx @@ -0,0 +1,88 @@ +/* + * 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 { css } from '@emotion/css'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiText, + EuiBadge, + useEuiTheme, + useEuiFontSize, + type EuiFlexGroupProps, +} from '@elastic/eui'; +import { DistributionBar } from '@kbn/security-solution-distribution-bar'; +import { FormattedCount } from '../../../../common/components/formatted_number'; + +export interface InsightDistributionBarProps { + /** + * Title of the insight + */ + title: string | React.ReactNode; + /** + * Distribution stats to display + */ + stats: Array<{ key: string; count: number; color: string; label?: React.ReactNode }>; + /** + * Count to be displayed in the badge + */ + count: number; + /** + * Flex direction of the component + */ + direction?: EuiFlexGroupProps['direction']; + /** + * Optional test id + */ + ['data-test-subj']?: string; +} + +// Displays a distribution bar with a count badge +export const InsightDistributionBar: React.FC = ({ + title, + stats, + count, + direction = 'row', + 'data-test-subj': dataTestSubj, +}) => { + const { euiTheme } = useEuiTheme(); + const xsFontSize = useEuiFontSize('xs').fontSize; + + return ( + + + + {title} + + + + + + + + + + + + + + + + ); +}; + +InsightDistributionBar.displayName = 'InsightDistributionBar'; diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/shared/components/misconfiguration_insight.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/shared/components/misconfiguration_insight.test.tsx new file mode 100644 index 0000000000000..296a61f444a17 --- /dev/null +++ b/x-pack/plugins/security_solution/public/flyout/document_details/shared/components/misconfiguration_insight.test.tsx @@ -0,0 +1,43 @@ +/* + * 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 { render } from '@testing-library/react'; +import { TestProviders } from '../../../../common/mock'; +import { MisconfigurationsInsight } from './misconfiguration_insight'; +import { useMisconfigurationPreview } from '@kbn/cloud-security-posture/src/hooks/use_misconfiguration_preview'; + +jest.mock('@kbn/cloud-security-posture/src/hooks/use_misconfiguration_preview'); + +const fieldName = 'host.name'; +const name = 'test host'; +const testId = 'test'; + +const renderMisconfigurationsInsight = () => { + return render( + + + + ); +}; + +describe('MisconfigurationsInsight', () => { + it('renders', () => { + (useMisconfigurationPreview as jest.Mock).mockReturnValue({ + data: { count: { passed: 1, failed: 2 } }, + }); + const { getByTestId } = renderMisconfigurationsInsight(); + expect(getByTestId(testId)).toBeInTheDocument(); + expect(getByTestId(`${testId}-distribution-bar`)).toBeInTheDocument(); + }); + + it('renders null if no misconfiguration data found', () => { + (useMisconfigurationPreview as jest.Mock).mockReturnValue({}); + const { container } = renderMisconfigurationsInsight(); + expect(container).toBeEmptyDOMElement(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/shared/components/misconfiguration_insight.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/shared/components/misconfiguration_insight.tsx new file mode 100644 index 0000000000000..552a242c84893 --- /dev/null +++ b/x-pack/plugins/security_solution/public/flyout/document_details/shared/components/misconfiguration_insight.tsx @@ -0,0 +1,80 @@ +/* + * 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, { useMemo } from 'react'; +import { EuiFlexItem, type EuiFlexGroupProps } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { useMisconfigurationPreview } from '@kbn/cloud-security-posture/src/hooks/use_misconfiguration_preview'; +import { buildEntityFlyoutPreviewQuery } from '@kbn/cloud-security-posture-common'; +import { InsightDistributionBar } from './insight_distribution_bar'; +import { getFindingsStats } from '../../../../cloud_security_posture/components/misconfiguration/misconfiguration_preview'; + +interface MisconfigurationsInsightProps { + /** + * Entity name to retrieve misconfigurations for + */ + name: string; + /** + * Indicator whether the entity is host or user + */ + fieldName: 'host.name' | 'user.name'; + /** + * The direction of the flex group + */ + direction?: EuiFlexGroupProps['direction']; + /** + * The data-test-subj to use for the component + */ + ['data-test-subj']?: string; +} + +/* + * Displays a distribution bar with the count of failed misconfigurations for a given entity + */ +export const MisconfigurationsInsight: React.FC = ({ + name, + fieldName, + direction, + 'data-test-subj': dataTestSubj, +}) => { + const { data } = useMisconfigurationPreview({ + query: buildEntityFlyoutPreviewQuery(fieldName, name), + sort: [], + enabled: true, + pageSize: 1, + }); + + const passedFindings = data?.count.passed || 0; + const failedFindings = data?.count.failed || 0; + const hasMisconfigurationFindings = passedFindings > 0 || failedFindings > 0; + + const misconfigurationsStats = useMemo( + () => getFindingsStats(passedFindings, failedFindings), + [passedFindings, failedFindings] + ); + + if (!hasMisconfigurationFindings) return null; + + return ( + + + } + stats={misconfigurationsStats} + count={failedFindings} + direction={direction} + data-test-subj={`${dataTestSubj}-distribution-bar`} + /> + + ); +}; + +MisconfigurationsInsight.displayName = 'MisconfigurationsInsight'; diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/shared/components/test_ids.ts b/x-pack/plugins/security_solution/public/flyout/document_details/shared/components/test_ids.ts index 8561df63d7199..7c2ce2ff5870b 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/shared/components/test_ids.ts +++ b/x-pack/plugins/security_solution/public/flyout/document_details/shared/components/test_ids.ts @@ -12,3 +12,6 @@ export const FLYOUT_PREVIEW_LINK_TEST_ID = `${PREFIX}PreviewLink` as const; export const SESSION_VIEW_UPSELL_TEST_ID = `${PREFIX}SessionViewUpsell` as const; export const SESSION_VIEW_NO_DATA_TEST_ID = `${PREFIX}SessionViewNoData` as const; + +export const MISCONFIGURATIONS_TEST_ID = `${PREFIX}Misconfigurations` as const; +export const VULNERABILITIES_TEST_ID = `${PREFIX}Vulnerabilities` as const; diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/shared/components/vulnerabilities_insight.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/shared/components/vulnerabilities_insight.test.tsx new file mode 100644 index 0000000000000..77c6737266b89 --- /dev/null +++ b/x-pack/plugins/security_solution/public/flyout/document_details/shared/components/vulnerabilities_insight.test.tsx @@ -0,0 +1,44 @@ +/* + * 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 { TestProviders } from '../../../../common/mock'; +import { render } from '@testing-library/react'; +import React from 'react'; +import { VulnerabilitiesInsight } from './vulnerabilities_insight'; +import { useVulnerabilitiesPreview } from '@kbn/cloud-security-posture/src/hooks/use_vulnerabilities_preview'; + +jest.mock('@kbn/cloud-security-posture/src/hooks/use_vulnerabilities_preview'); + +const hostName = 'test host'; +const testId = 'test'; + +const renderVulnerabilitiesInsight = () => { + return render( + + + + ); +}; + +describe('VulnerabilitiesInsight', () => { + it('renders', () => { + (useVulnerabilitiesPreview as jest.Mock).mockReturnValue({ + data: { count: { CRITICAL: 0, HIGH: 1, MEDIUM: 1, LOW: 0, UNKNOWN: 0 } }, + }); + + const { getByTestId } = renderVulnerabilitiesInsight(); + expect(getByTestId(testId)).toBeInTheDocument(); + expect(getByTestId(`${testId}-distribution-bar`)).toBeInTheDocument(); + }); + + it('renders null when data is not available', () => { + (useVulnerabilitiesPreview as jest.Mock).mockReturnValue({}); + + const { container } = renderVulnerabilitiesInsight(); + expect(container).toBeEmptyDOMElement(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/shared/components/vulnerabilities_insight.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/shared/components/vulnerabilities_insight.tsx new file mode 100644 index 0000000000000..4c581b6db57d0 --- /dev/null +++ b/x-pack/plugins/security_solution/public/flyout/document_details/shared/components/vulnerabilities_insight.tsx @@ -0,0 +1,91 @@ +/* + * 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, { useMemo } from 'react'; +import { EuiFlexItem, type EuiFlexGroupProps } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { useVulnerabilitiesPreview } from '@kbn/cloud-security-posture/src/hooks/use_vulnerabilities_preview'; +import { buildEntityFlyoutPreviewQuery } from '@kbn/cloud-security-posture-common'; +import { getVulnerabilityStats, hasVulnerabilitiesData } from '@kbn/cloud-security-posture'; +import { InsightDistributionBar } from './insight_distribution_bar'; + +interface VulnerabilitiesInsightProps { + /** + * Host name to retrieve vulnerabilities for + */ + hostName: string; + /** + * The direction of the flex group + */ + direction?: EuiFlexGroupProps['direction']; + /** + * The data-test-subj to use for the component + */ + ['data-test-subj']?: string; +} + +/* + * Displays a distribution bar with the count of critical vulnerabilities for a given host + */ +export const VulnerabilitiesInsight: React.FC = ({ + hostName, + direction, + 'data-test-subj': dataTestSubj, +}) => { + const { data } = useVulnerabilitiesPreview({ + query: buildEntityFlyoutPreviewQuery('host.name', hostName), + sort: [], + enabled: true, + pageSize: 1, + }); + + const { CRITICAL = 0, HIGH = 0, MEDIUM = 0, LOW = 0, NONE = 0 } = data?.count || {}; + const hasVulnerabilitiesFindings = useMemo( + () => + hasVulnerabilitiesData({ + critical: CRITICAL, + high: HIGH, + medium: MEDIUM, + low: LOW, + none: NONE, + }), + [CRITICAL, HIGH, MEDIUM, LOW, NONE] + ); + + const vulnerabilitiesStats = useMemo( + () => + getVulnerabilityStats({ + critical: CRITICAL, + high: HIGH, + medium: MEDIUM, + low: LOW, + none: NONE, + }), + [CRITICAL, HIGH, MEDIUM, LOW, NONE] + ); + + if (!hasVulnerabilitiesFindings) return null; + + return ( + + + } + stats={vulnerabilitiesStats} + count={CRITICAL} + direction={direction} + data-test-subj={`${dataTestSubj}-distribution-bar`} + /> + + ); +}; + +VulnerabilitiesInsight.displayName = 'VulnerabilitiesInsight';