diff --git a/src/platform/plugins/shared/discover/public/context_awareness/profile_providers/security/constants.ts b/src/platform/plugins/shared/discover/public/context_awareness/profile_providers/security/constants.ts index 09b2a3e1a5bb8..38e5f1ed1974e 100644 --- a/src/platform/plugins/shared/discover/public/context_awareness/profile_providers/security/constants.ts +++ b/src/platform/plugins/shared/discover/public/context_awareness/profile_providers/security/constants.ts @@ -19,9 +19,14 @@ export const SIGNAL_RULE_NAME_FIELD_NAME = 'kibana.alert.rule.name'; export const LEGACY_SIGNAL_RULE_NAME_FIELD_NAME = 'signal.rule.name'; export const ALERT_WORKFLOW_STATUS_FIELD_NAME = 'kibana.alert.workflow_status'; +export const HOST_NAME_FIELD = 'host.name'; +export const HOST_HOSTNAME_FIELD = 'host.hostname'; + // Also see: x-pack/solutions/security/plugins/security_solution/public/one_discover/cell_renderers/cell_renderers.tsx export const ALLOWED_CELL_RENDER_FIELDS = [ ALERT_WORKFLOW_STATUS_FIELD_NAME, SIGNAL_RULE_NAME_FIELD_NAME, LEGACY_SIGNAL_RULE_NAME_FIELD_NAME, + HOST_NAME_FIELD, + HOST_HOSTNAME_FIELD, ]; diff --git a/x-pack/solutions/security/plugins/security_solution/public/agent_builder/components/entity_card_flyout_overview_canvas.tsx b/x-pack/solutions/security/plugins/security_solution/public/agent_builder/components/entity_card_flyout_overview_canvas.tsx index 4c61a7b5165a0..1d0a7ce794b67 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/agent_builder/components/entity_card_flyout_overview_canvas.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/agent_builder/components/entity_card_flyout_overview_canvas.tsx @@ -35,9 +35,9 @@ import { type RiskStats, } from '../../../common/search_strategy'; import { useUiSetting, useKibana } from '../../common/lib/kibana'; -import { HostPanelContent } from '../../flyout/entity_details/host_right/content'; -import { HostPanelHeader } from '../../flyout/entity_details/host_right/header'; -import { useObservedHost } from '../../flyout/entity_details/host_right/hooks/use_observed_host'; +import { Content as HostPanelContent } from '../../flyout_v2/entity/host/main/content'; +import { Header as HostPanelHeader } from '../../flyout_v2/entity/host/main/header'; +import { useObservedHost } from '../../flyout_v2/entity/host/main/hooks/use_observed_host'; import { EntityType } from '../../../common/entity_analytics/types'; import { buildRiskScoreStateFromEntityRecord, @@ -49,8 +49,9 @@ import { mergeLegacyIdentityWhenStoreEntityMissing, type IdentityFields, } from '../../flyout/document_details/shared/utils'; -import { HOST_PANEL_RISK_SCORE_QUERY_ID } from '../../flyout/entity_details/host_right/constants'; +import { HOST_PANEL_RISK_SCORE_QUERY_ID } from '../../flyout_v2/entity/host/main/constants'; import { FlyoutBody } from '../../flyout/shared/components/flyout_body'; +import { FlyoutHeader } from '../../flyout/shared/components/flyout_header'; import { useEntityPanelTabs, TABLE_TAB_ID, @@ -464,19 +465,21 @@ const HostEntityFlyoutOverviewCanvas: React.FC<{ return ( <> - + + + {observedHost.entityRecord && ( { + const mockOpenDetailsPanel = jest.fn(); + + beforeEach(() => { + (useVulnerabilitiesPreview as jest.Mock).mockReturnValue({ + data: { count: { CRITICAL: 0, HIGH: 1, MEDIUM: 1, LOW: 0, UNKNOWN: 0 } }, + }); + (useMisconfigurationPreview as jest.Mock).mockReturnValue({ + data: { count: { passed: 1, failed: 1 } }, + }); + }); + afterEach(() => { + jest.clearAllMocks(); + }); + + it('renders', () => { + const { getByTestId } = render( + + + + ); + + expect(getByTestId('securitySolutionFlyoutInsightsAlertsTitleLink')).toBeInTheDocument(); + }); + + it('renders correct alerts number', () => { + const { getByTestId } = render( + + + + ); + + expect(getByTestId('securitySolutionFlyoutInsightsAlertsCount').textContent).toEqual('5'); + }); + + it('should render the correct number of distribution bar section based on the number of severities', () => { + const { queryAllByTestId } = render( + + + + ); + + expect(queryAllByTestId('AlertsPreviewDistributionBarTestId__part').length).toEqual(3); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/public/cloud_security_posture/components/flyout_v2/alerts/alerts_preview.tsx b/x-pack/solutions/security/plugins/security_solution/public/cloud_security_posture/components/flyout_v2/alerts/alerts_preview.tsx new file mode 100644 index 0000000000000..faa6f498f4a13 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/cloud_security_posture/components/flyout_v2/alerts/alerts_preview.tsx @@ -0,0 +1,157 @@ +/* + * 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, useMemo } from 'react'; +import { css } from '@emotion/react'; +import { capitalize } from 'lodash'; +import type { EuiThemeComputed } from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiText, EuiTitle, useEuiTheme } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { DistributionBar } from '@kbn/security-solution-distribution-bar'; +import { getAbbreviatedNumber } from '@kbn/cloud-security-posture-common'; +import type { + AlertsByStatus, + ParsedAlertsData, +} from '../../../../overview/components/detection_response/alerts_by_status/types'; +import { ExpandablePanel } from '../../../../flyout_v2/shared/components/expandable_panel'; +import { getSeverityColor } from '../../../../detections/components/alerts_kpis/severity_level_panel/helpers'; +import type { EntityDetailsPath } from '../../../../flyout/entity_details/shared/components/left_panel/left_panel_header'; +import { + CspInsightLeftPanelSubTab, + EntityDetailsLeftPanelTab, +} from '../../../../flyout/entity_details/shared/components/left_panel/left_panel_header'; + +const AlertsCount = ({ + alertsTotal, + euiTheme, +}: { + alertsTotal: number; + euiTheme: EuiThemeComputed<{}>; +}) => { + return ( + + + + +

+ {getAbbreviatedNumber(alertsTotal)} +

+
+
+ + + + + +
+
+ ); +}; + +export const AlertsPreview = ({ + alertsData, + openDetailsPanel, +}: { + alertsData: ParsedAlertsData; + openDetailsPanel: (path: EntityDetailsPath) => void; +}) => { + const { euiTheme } = useEuiTheme(); + + const severityMap = new Map(); + const severityRank: Record = { + critical: 4, + high: 3, + medium: 2, + low: 1, + }; + + (Object.keys(alertsData || {}) as AlertsByStatus[]).forEach((status) => { + if (alertsData?.[status]?.severities) { + alertsData?.[status]?.severities.forEach((severity) => { + const currentSeverity = severityMap.get(severity.key) || 0; + severityMap.set(severity.key, currentSeverity + severity.value); + }); + } + }); + + const alertStats = Array.from(severityMap, ([key, count]: [string, number]) => ({ + key: capitalize(key), + count, + color: getSeverityColor(key, euiTheme), + sort: severityRank[key.toLowerCase()] || 0, + })).sort((a, b) => b.sort - a.sort); + + const totalAlertsCount = alertStats.reduce((total, item) => total + item.count, 0); + + const goToEntityInsightTab = useCallback( + () => + openDetailsPanel({ + tab: EntityDetailsLeftPanelTab.CSP_INSIGHTS, + subTab: CspInsightLeftPanelSubTab.ALERTS, + }), + [openDetailsPanel] + ); + + const link = useMemo( + () => ({ + callback: goToEntityInsightTab, + tooltip: ( + + ), + }), + [goToEntityInsightTab] + ); + return ( + + + + ), + link: totalAlertsCount > 0 ? link : undefined, + }} + data-test-subj={'securitySolutionFlyoutInsightsAlerts'} + > + + + + + + + + + + + + + + ); +}; diff --git a/x-pack/solutions/security/plugins/security_solution/public/cloud_security_posture/components/flyout_v2/csp_details/alerts_findings_details_table.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/cloud_security_posture/components/flyout_v2/csp_details/alerts_findings_details_table.test.tsx new file mode 100644 index 0000000000000..068e107113a3e --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/cloud_security_posture/components/flyout_v2/csp_details/alerts_findings_details_table.test.tsx @@ -0,0 +1,97 @@ +/* + * 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 { AlertsDetailsTable } from './alerts_findings_details_table'; +import { TestProviders } from '../../../../common/mock/test_providers'; +import { EntityIdentifierFields } from '../../../../../common/entity_analytics/types'; + +jest.mock('@kbn/entity-store/public', () => ({ + ...jest.requireActual('@kbn/entity-store/public'), + useEntityStoreEuidApi: jest.fn().mockReturnValue({ euid: null }), +})); + +jest.mock('../../../../common/lib/kibana', () => ({ + useUiSetting: jest.fn().mockReturnValue(false), + useKibana: jest.fn().mockReturnValue({ services: {} }), +})); + +jest.mock('../../../../common/containers/use_global_time', () => ({ + useGlobalTime: jest.fn().mockReturnValue({ to: '2023-01-01', from: '2022-01-01' }), +})); + +jest.mock('../../../../detections/containers/detection_engine/alerts/use_query', () => ({ + useQueryAlerts: jest.fn().mockReturnValue({ + loading: false, + data: null, + setQuery: jest.fn(), + response: '', + request: '', + refetch: jest.fn(), + }), +})); + +jest.mock('../../../../detections/containers/detection_engine/alerts/use_signal_index', () => ({ + useSignalIndex: jest.fn().mockReturnValue({ + loading: false, + signalIndexName: '.alerts-security', + }), +})); + +jest.mock('../../../../flyout/entity_details/shared/hooks/use_entity_from_store', () => ({ + useEntityFromStore: jest.fn().mockReturnValue({ entityRecord: null, isLoading: false }), +})); + +jest.mock('../../../hooks/use_non_closed_alerts', () => ({ + useNonClosedAlerts: jest.fn().mockReturnValue({ + hasNonClosedAlerts: false, + filteredAlertsData: null, + }), +})); + +jest.mock('../../../../common/hooks/use_navigate_to_alerts_page_with_filters', () => ({ + useNavigateToAlertsPageWithFilters: jest.fn().mockReturnValue(jest.fn()), +})); + +describe('AlertsDetailsTable (v2)', () => { + const mockOnShowAlert = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders without crashing', () => { + const { getByTestId } = render( + + + + ); + + expect(getByTestId('securitySolutionFlyoutMisconfigurationFindingsTable')).toBeInTheDocument(); + }); + + it('accepts required onShowAlert callback without throwing', () => { + expect(() => + render( + + + + ) + ).not.toThrow(); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/public/cloud_security_posture/components/flyout_v2/csp_details/alerts_findings_details_table.tsx b/x-pack/solutions/security/plugins/security_solution/public/cloud_security_posture/components/flyout_v2/csp_details/alerts_findings_details_table.tsx new file mode 100644 index 0000000000000..9c6d4fe22327d --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/cloud_security_posture/components/flyout_v2/csp_details/alerts_findings_details_table.tsx @@ -0,0 +1,444 @@ +/* + * 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, { memo, useCallback, useEffect, useMemo, useState } from 'react'; +import { encode } from '@kbn/rison'; +import { capitalize } from 'lodash'; +import type { Criteria, EuiBasicTableColumn, EuiTableSortingType } from '@elastic/eui'; +import { + EuiSpacer, + EuiPanel, + EuiText, + EuiBasicTable, + EuiIcon, + EuiLink, + useEuiTheme, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { DistributionBar } from '@kbn/security-solution-distribution-bar'; +import { + ENTITY_FLYOUT_EXPAND_MISCONFIGURATION_VIEW_VISITS, + uiMetricService, +} from '@kbn/cloud-security-posture-common/utils/ui_metrics'; +import { METRIC_TYPE } from '@kbn/analytics'; +import { buildEntityAlertsQuery } from '@kbn/cloud-security-posture-common/utils/helpers'; +import type { QueryDslQueryContainer } from '@kbn/data-views-plugin/common/types'; +import { FF_ENABLE_ENTITY_STORE_V2, useEntityStoreEuidApi } from '@kbn/entity-store/public'; +import type { AlertsByStatus } from '../../../../overview/components/detection_response/alerts_by_status/types'; +import { DETECTION_RESPONSE_ALERTS_BY_STATUS_ID } from '../../../../overview/components/detection_response/alerts_by_status/types'; +import { + OPEN_IN_ALERTS_TITLE_HOSTNAME, + OPEN_IN_ALERTS_TITLE_STATUS, + OPEN_IN_ALERTS_TITLE_USERNAME, +} from '../../../../overview/components/detection_response/translations'; +import { URL_PARAM_KEY } from '../../../../common/hooks/use_url_state'; +import { useNavigateToAlertsPageWithFilters } from '../../../../common/hooks/use_navigate_to_alerts_page_with_filters'; +import type { ESBoolQuery } from '../../../../../common/typed_json'; +import { useGlobalTime } from '../../../../common/containers/use_global_time'; +import { useUiSetting } from '../../../../common/lib/kibana'; +import { useQueryAlerts } from '../../../../detections/containers/detection_engine/alerts/use_query'; +import { ALERTS_QUERY_NAMES } from '../../../../detections/containers/detection_engine/alerts/constants'; +import { useSignalIndex } from '../../../../detections/containers/detection_engine/alerts/use_signal_index'; +import { getSeverityColor } from '../../../../detections/components/alerts_kpis/severity_level_panel/helpers'; +import { SeverityBadge } from '../../../../common/components/severity_badge'; +import { FILTER_OPEN, FILTER_ACKNOWLEDGED } from '../../../../../common/types'; +import { useNonClosedAlerts } from '../../../hooks/use_non_closed_alerts'; +import { useEntityFromStore } from '../../../../flyout/entity_details/shared/hooks/use_entity_from_store'; +import type { CloudPostureEntityIdentifier } from '../../entity_insight'; + +enum KIBANA_ALERTS { + SEVERITY = 'kibana.alert.severity', + RULE_NAME = 'kibana.alert.rule.name', + WORKFLOW_STATUS = 'kibana.alert.workflow_status', +} + +type AlertSeverity = 'low' | 'medium' | 'high' | 'critical'; + +type AlertsSortFieldType = + | 'id' + | 'index' + | KIBANA_ALERTS.SEVERITY + | KIBANA_ALERTS.WORKFLOW_STATUS + | KIBANA_ALERTS.RULE_NAME; + +interface ResultAlertsField { + _id: string[]; + _index: string[]; + [KIBANA_ALERTS.SEVERITY]: AlertSeverity[]; + [KIBANA_ALERTS.RULE_NAME]: string[]; + [KIBANA_ALERTS.WORKFLOW_STATUS]: string[]; +} + +interface ContextualFlyoutAlertsField { + id: string; + index: string; + [KIBANA_ALERTS.SEVERITY]: AlertSeverity; + [KIBANA_ALERTS.RULE_NAME]: string; + [KIBANA_ALERTS.WORKFLOW_STATUS]: string; +} + +interface AlertsDetailsFields { + fields: ResultAlertsField; +} + +export const AlertsDetailsTable = memo( + ({ + field, + value, + entityId, + entityType, + onShowAlert, + }: { + field: CloudPostureEntityIdentifier; + value: string; + /** Canonical entity store id (`host.entity.id` / `user.entity.id`); when set with Entity Store v2, identity is loaded from the store for EUID DSL. */ + entityId?: string; + entityType?: 'host' | 'user'; + onShowAlert: (eventId: string, indexName: string) => void; + }) => { + const { euiTheme } = useEuiTheme(); + + useEffect(() => { + uiMetricService.trackUiMetric( + METRIC_TYPE.COUNT, + ENTITY_FLYOUT_EXPAND_MISCONFIGURATION_VIEW_VISITS + ); + }, []); + + const [currentFilter, setCurrentFilter] = useState(''); + + const [pageIndex, setPageIndex] = useState(0); + const [pageSize, setPageSize] = useState(10); + + const [sortField, setSortField] = useState(KIBANA_ALERTS.SEVERITY); + const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc'); + + const sorting: EuiTableSortingType = { + sort: { + field: sortField, + direction: sortDirection, + }, + }; + + const alertsPagination = (alerts: ContextualFlyoutAlertsField[]) => { + let pageOfItems; + + if (!pageIndex && !pageSize) { + pageOfItems = alerts; + } else { + const startIndex = pageIndex * pageSize; + pageOfItems = alerts?.slice(startIndex, Math.min(startIndex + pageSize, alerts?.length)); + } + + return { + pageOfItems, + totalItemCount: alerts?.length, + }; + }; + + const { to, from } = useGlobalTime(); + const timerange = encode({ + global: { + [URL_PARAM_KEY.timerange]: { + kind: 'absolute', + from, + to, + }, + }, + }); + + const entityStoreV2Enabled = useUiSetting(FF_ENABLE_ENTITY_STORE_V2); + const entityTypeResolved: 'host' | 'user' = + entityType ?? (field === 'user.name' ? 'user' : 'host'); + + const { entityRecord, isLoading: entityFromStoreLoading } = useEntityFromStore({ + entityId, + entityType: entityTypeResolved, + skip: !entityStoreV2Enabled || !entityId, + }); + + const euidApi = useEntityStoreEuidApi(); + const euidEntityFilter = useMemo((): QueryDslQueryContainer | undefined => { + if (!euidApi?.euid) { + return undefined; + } + return euidApi.euid.dsl.getEuidFilterBasedOnDocument(entityTypeResolved, entityRecord); + }, [euidApi?.euid, entityTypeResolved, entityRecord]); + + const filterAlertsByEuid = Boolean(euidApi?.euid && euidEntityFilter); + /** Wait for entity-store lookup when `entityId` is set; after it finishes with no record, fall back to field/value filters. */ + const skipEntityResolution = + entityStoreV2Enabled && Boolean(entityId) && entityFromStoreLoading; + + const entityFilterForQuery: QueryDslQueryContainer | undefined = filterAlertsByEuid + ? euidEntityFilter + : undefined; + + const buildAlertsListQuery = useCallback( + (opts: { + severity?: string; + sortField?: AlertsSortFieldType; + sortDirection?: 'asc' | 'desc'; + }) => + buildEntityAlertsQuery({ + field, + to, + from, + queryValue: value, + size: 500, + severity: opts.severity ?? '', + sortField: opts.sortField ?? sortField, + sortDirection: opts.sortDirection ?? sortDirection, + entityFilter: entityFilterForQuery, + }), + [entityFilterForQuery, field, from, sortDirection, sortField, to, value] + ); + + const alertsListQuery = useMemo( + () => + buildEntityAlertsQuery({ + field, + to, + from, + queryValue: value, + size: 500, + severity: currentFilter, + sortField, + sortDirection, + entityFilter: entityFilterForQuery, + }), + [currentFilter, entityFilterForQuery, field, from, sortDirection, sortField, to, value] + ); + + const { signalIndexName } = useSignalIndex(); + const { data, setQuery } = useQueryAlerts({ + query: alertsListQuery, + queryName: ALERTS_QUERY_NAMES.BY_RULE_BY_STATUS, + indexName: signalIndexName, + skip: skipEntityResolution, + }); + + useEffect(() => { + setQuery(alertsListQuery); + }, [alertsListQuery, setQuery]); + + const alertsByStatusIdentityFields = useMemo((): Record => { + if (filterAlertsByEuid) { + return {}; + } + return { [field]: value }; + }, [field, filterAlertsByEuid, value]); + + const alertsByStatusAdditionalFilters = useMemo((): ESBoolQuery[] | undefined => { + if (!filterAlertsByEuid || !euidEntityFilter) { + return undefined; + } + return [euidEntityFilter] as ESBoolQuery[]; + }, [euidEntityFilter, filterAlertsByEuid]); + + const { filteredAlertsData: alertsData } = useNonClosedAlerts({ + identityFields: alertsByStatusIdentityFields, + additionalFilters: alertsByStatusAdditionalFilters, + skip: skipEntityResolution, + to, + from, + queryId: `${DETECTION_RESPONSE_ALERTS_BY_STATUS_ID}`, + }); + + const severityMap = new Map(); + (Object.keys(alertsData || {}) as AlertsByStatus[]).forEach((status) => { + if (alertsData?.[status]?.severities) { + alertsData?.[status]?.severities.forEach((severity) => { + const currentSeverity = severityMap.get(severity.key) || 0; + severityMap.set(severity.key, currentSeverity + severity.value); + }); + } + }); + + const alertStats = Array.from(severityMap, ([key, count]) => ({ + key: capitalize(key), + count, + color: getSeverityColor(key, euiTheme), + filter: () => { + setCurrentFilter(key); + setQuery( + buildAlertsListQuery({ + severity: key, + sortField, + sortDirection, + }) + ); + }, + isCurrentFilter: currentFilter === key, + reset: (event: React.MouseEvent) => { + setCurrentFilter(''); + setQuery(buildAlertsListQuery({ severity: '' })); + event?.stopPropagation(); + }, + })); + + const alertDataResults = (data?.hits?.hits as AlertsDetailsFields[])?.map( + (item: AlertsDetailsFields) => { + return { + id: item.fields?._id?.[0], + index: item.fields?._index?.[0], + [KIBANA_ALERTS.RULE_NAME]: item.fields?.[KIBANA_ALERTS.RULE_NAME]?.[0], + [KIBANA_ALERTS.SEVERITY]: item.fields?.[KIBANA_ALERTS.SEVERITY]?.[0], + [KIBANA_ALERTS.WORKFLOW_STATUS]: item.fields?.[KIBANA_ALERTS.WORKFLOW_STATUS]?.[0], + }; + } + ); + + const { pageOfItems, totalItemCount } = alertsPagination(alertDataResults || []); + + const pagination = { + pageIndex, + pageSize, + totalItemCount, + pageSizeOptions: [10, 25, 100], + }; + + const onTableChange = useCallback( + ({ page, sort }: Criteria) => { + if (page) { + const { index, size } = page; + setPageIndex(index); + setPageSize(size); + } + + if (sort) { + const { field: fieldSort, direction } = sort; + setSortField(fieldSort); + setSortDirection(direction); + setQuery( + buildAlertsListQuery({ + severity: currentFilter, + sortField: fieldSort, + sortDirection: direction, + }) + ); + } + }, + [buildAlertsListQuery, currentFilter, setQuery] + ); + + const columns: Array> = [ + { + field: 'id', + name: '', + width: '5%', + render: (id: string, alert: ContextualFlyoutAlertsField) => ( + onShowAlert(id, alert.index)}> + + + ), + }, + { + field: KIBANA_ALERTS.RULE_NAME, + render: (ruleName: string) => {ruleName}, + name: i18n.translate( + 'xpack.securitySolution.flyout.left.insights.alerts.table.ruleNameColumnName', + { + defaultMessage: 'Rule', + } + ), + width: '55%', + sortable: true, + }, + { + field: KIBANA_ALERTS.SEVERITY, + render: (severity: AlertSeverity) => ( + + + + ), + name: i18n.translate( + 'xpack.securitySolution.flyout.left.insights.alerts.table.severityColumnName', + { + defaultMessage: 'Severity', + } + ), + width: '20%', + sortable: true, + }, + { + field: KIBANA_ALERTS.WORKFLOW_STATUS, + render: (status: string) => {capitalize(status)}, + name: i18n.translate( + 'xpack.securitySolution.flyout.left.insights.alerts.table.statusColumnName', + { + defaultMessage: 'Status', + } + ), + width: '20%', + sortable: true, + }, + ]; + + const openAlertsPageWithFilters = useNavigateToAlertsPageWithFilters(); + + const openAlertsInAlertsPage = useCallback( + () => + openAlertsPageWithFilters( + [ + { + title: + field === 'host.name' + ? OPEN_IN_ALERTS_TITLE_HOSTNAME + : OPEN_IN_ALERTS_TITLE_USERNAME, + selected_options: [value], + field_name: field, + }, + { + title: OPEN_IN_ALERTS_TITLE_STATUS, + selected_options: [FILTER_OPEN, FILTER_ACKNOWLEDGED], + field_name: 'kibana.alert.workflow_status', + }, + ], + true, + timerange + ), + [field, openAlertsPageWithFilters, timerange, value] + ); + + return ( + <> + + openAlertsInAlertsPage()}> +

+ {i18n.translate('xpack.securitySolution.flyout.left.insights.alerts.tableTitle', { + defaultMessage: 'Alerts ', + })} + +

+
+ + + + + +
+ + ); + } +); + +AlertsDetailsTable.displayName = 'AlertsDetailsTable'; diff --git a/x-pack/solutions/security/plugins/security_solution/public/cloud_security_posture/components/flyout_v2/csp_details/misconfiguration_findings_details_table.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/cloud_security_posture/components/flyout_v2/csp_details/misconfiguration_findings_details_table.test.tsx new file mode 100644 index 0000000000000..fa3e3e9646743 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/cloud_security_posture/components/flyout_v2/csp_details/misconfiguration_findings_details_table.test.tsx @@ -0,0 +1,87 @@ +/* + * 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 { MisconfigurationFindingsDetailsTable } from './misconfiguration_findings_details_table'; +import { TestProviders } from '../../../../common/mock/test_providers'; +import { EntityIdentifierFields } from '../../../../../common/entity_analytics/types'; + +jest.mock('@kbn/cloud-security-posture', () => ({ + useMisconfigurationFindings: jest.fn().mockReturnValue({ data: { page: [] }, isLoading: false }), + useHasMisconfigurations: jest.fn().mockReturnValue({ + hasMisconfigurationFindings: false, + passedFindings: 0, + failedFindings: 0, + }), + useGetMisconfigurationStatusColor: jest.fn().mockReturnValue({ + getMisconfigurationStatusColor: jest.fn().mockReturnValue('#000'), + }), + useGetNavigationUrlParams: jest.fn().mockReturnValue(jest.fn().mockReturnValue('')), + CspEvaluationBadge: ({ type }: { type: string }) => {type}, + MISCONFIGURATION: { RESULT_EVALUATION: 'result.evaluation', RULE_NAME: 'rule.name' }, +})); + +jest.mock('@kbn/entity-store/public', () => ({ + ...jest.requireActual('@kbn/entity-store/public'), + useEntityStoreEuidApi: jest.fn().mockReturnValue({ euid: null }), +})); + +jest.mock('../../../../common/lib/kibana', () => ({ + useUiSetting: jest.fn().mockReturnValue(false), + useKibana: jest.fn().mockReturnValue({ services: {} }), +})); + +jest.mock('../../../../flyout/entity_details/shared/hooks/use_entity_from_store', () => ({ + useEntityFromStore: jest.fn().mockReturnValue({ entityRecord: null, isLoading: false }), +})); + +jest.mock('../../../../common/components/links', () => ({ + SecuritySolutionLinkAnchor: ({ children }: { children: React.ReactNode }) => ( + {children} + ), +})); + +describe('MisconfigurationFindingsDetailsTable (v2)', () => { + const mockOnShowFinding = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders without crashing', () => { + const { getByTestId } = render( + + + + ); + + expect(getByTestId('securitySolutionFlyoutMisconfigurationFindingsTable')).toBeInTheDocument(); + }); + + it('accepts required onShowFinding callback without throwing', () => { + expect(() => + render( + + + + ) + ).not.toThrow(); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/public/cloud_security_posture/components/flyout_v2/csp_details/misconfiguration_findings_details_table.tsx b/x-pack/solutions/security/plugins/security_solution/public/cloud_security_posture/components/flyout_v2/csp_details/misconfiguration_findings_details_table.tsx new file mode 100644 index 0000000000000..bdfea3d823a8c --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/cloud_security_posture/components/flyout_v2/csp_details/misconfiguration_findings_details_table.tsx @@ -0,0 +1,440 @@ +/* + * 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, { memo, useCallback, useEffect, useMemo, useState } from 'react'; +import type { Criteria, EuiBasicTableColumn, EuiTableSortingType } from '@elastic/eui'; +import { + EuiSpacer, + EuiPanel, + EuiText, + EuiBasicTable, + EuiIcon, + EuiButtonIcon, + EuiToolTip, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import type { CspFindingResult } from '@kbn/cloud-security-posture-common'; +import { MISCONFIGURATION_STATUS } from '@kbn/cloud-security-posture-common'; +import { DistributionBar } from '@kbn/security-solution-distribution-bar'; +import type { CspBenchmarkRuleMetadata } from '@kbn/cloud-security-posture-common/schema/rules/latest'; +import type { MisconfigurationFindingDetailFields } from '@kbn/cloud-security-posture'; +import { + CspEvaluationBadge, + MISCONFIGURATION, + useGetMisconfigurationStatusColor, + useGetNavigationUrlParams, + useHasMisconfigurations, + useMisconfigurationFindings, +} from '@kbn/cloud-security-posture'; + +import { + ENTITY_FLYOUT_EXPAND_MISCONFIGURATION_VIEW_VISITS, + NAV_TO_FINDINGS_BY_HOST_NAME_FROM_ENTITY_FLYOUT, + NAV_TO_FINDINGS_BY_RULE_NAME_FROM_ENTITY_FLYOUT, + uiMetricService, +} from '@kbn/cloud-security-posture-common/utils/ui_metrics'; +import { METRIC_TYPE } from '@kbn/analytics'; +import { SecurityPageName } from '@kbn/deeplinks-security'; +import type { QueryDslQueryContainer } from '@kbn/data-views-plugin/common/types'; +import type { EntityType } from '@kbn/entity-store/public'; +import { FF_ENABLE_ENTITY_STORE_V2, useEntityStoreEuidApi } from '@kbn/entity-store/public'; +import type { UseCspOptions } from '@kbn/cloud-security-posture-common/types/findings'; +import { SecuritySolutionLinkAnchor } from '../../../../common/components/links'; +import { useUiSetting } from '../../../../common/lib/kibana'; +import { useEntityFromStore } from '../../../../flyout/entity_details/shared/hooks/use_entity_from_store'; +import type { CloudPostureEntityIdentifier } from '../../entity_insight'; + +type MisconfigurationSortFieldType = + | MISCONFIGURATION.RESULT_EVALUATION + | MISCONFIGURATION.RULE_NAME + | 'resource' + | 'rule'; + +interface MisconfigurationDetailsDistributionBarProps { + key: string; + count: number; + color: string; + filter: () => void; + isCurrentFilter: boolean; + reset: (event: React.MouseEvent) => void; +} + +const useGetFindingsStats = () => { + const { getMisconfigurationStatusColor } = useGetMisconfigurationStatusColor(); + + const getFindingsStats = ( + passedFindingsStats: number, + failedFindingsStats: number, + filterFunction: (filter: string) => void, + currentFilter: string + ) => { + const misconfigurationStats: MisconfigurationDetailsDistributionBarProps[] = []; + if (passedFindingsStats === 0 && failedFindingsStats === 0) return []; + if (passedFindingsStats > 0) { + misconfigurationStats.push({ + key: i18n.translate( + 'xpack.securitySolution.flyout.right.insights.misconfigurations.passedFindingsText', + { + defaultMessage: '{count, plural, one {Passed finding} other {Passed findings}}', + values: { count: passedFindingsStats }, + } + ), + count: passedFindingsStats, + color: getMisconfigurationStatusColor(MISCONFIGURATION_STATUS.PASSED), + filter: () => { + filterFunction(MISCONFIGURATION_STATUS.PASSED); + }, + isCurrentFilter: currentFilter === MISCONFIGURATION_STATUS.PASSED, + reset: (event: React.MouseEvent) => { + filterFunction(''); + event?.stopPropagation(); + }, + }); + } + if (failedFindingsStats > 0) { + misconfigurationStats.push({ + key: i18n.translate( + 'xpack.securitySolution.flyout.right.insights.misconfigurations.failedFindingsText', + { + defaultMessage: '{count, plural, one {Failed finding} other {Failed findings}}', + values: { count: failedFindingsStats }, + } + ), + count: failedFindingsStats, + color: getMisconfigurationStatusColor(MISCONFIGURATION_STATUS.FAILED), + filter: () => { + filterFunction(MISCONFIGURATION_STATUS.FAILED); + }, + isCurrentFilter: currentFilter === MISCONFIGURATION_STATUS.FAILED, + reset: (event: React.MouseEvent) => { + filterFunction(''); + event?.stopPropagation(); + }, + }); + } + return misconfigurationStats; + }; + + return { getFindingsStats }; +}; + +const buildMisconfigurationCspOptions = ({ + euidEntityFilter, + sort, + enabled, + pageSize, + currentFilter, + includeStatusFilter, +}: { + euidEntityFilter: QueryDslQueryContainer | undefined; + sort: UseCspOptions['sort']; + enabled: boolean; + pageSize: number; + currentFilter: string; + includeStatusFilter: boolean; +}): UseCspOptions => { + const filters: QueryDslQueryContainer[] = []; + if (euidEntityFilter) { + filters.push(euidEntityFilter); + } + if (includeStatusFilter && currentFilter) { + filters.push({ + bool: { + should: [ + { + term: { + 'result.evaluation': { + value: currentFilter, + case_insensitive: true, + }, + }, + }, + ], + minimum_should_match: 1, + }, + }); + } + return { + query: { bool: { filter: filters } }, + sort: sort ?? [], + enabled, + pageSize, + }; +}; + +/** + * Insights view displayed in the host entity v2 flyout — misconfiguration findings tab. + */ +export const MisconfigurationFindingsDetailsTable = memo( + ({ + field, + value, + scopeId, + entityId, + entityType, + onShowFinding, + }: { + field: CloudPostureEntityIdentifier; + value: string; + scopeId: string; + /** Canonical entity store id (`host.entity.id` / `user.entity.id`); when set with v2 FF, identity fields are loaded from the store for EUID DSL. */ + entityId?: string; + entityType?: string; + onShowFinding: (resourceId: string, ruleId: string) => void; + }) => { + useEffect(() => { + uiMetricService.trackUiMetric( + METRIC_TYPE.COUNT, + ENTITY_FLYOUT_EXPAND_MISCONFIGURATION_VIEW_VISITS + ); + }, []); + + const [currentFilter, setCurrentFilter] = useState(''); + + const [sortField, setSortField] = useState( + MISCONFIGURATION.RESULT_EVALUATION + ); + const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc'); + + const sortFieldDirection: { [key: string]: string } = {}; + sortFieldDirection[sortField] = sortDirection; + + const entityStoreV2Enabled = useUiSetting(FF_ENABLE_ENTITY_STORE_V2); + + const { entityRecord, isLoading: isEntityRecordLoading } = useEntityFromStore({ + entityId, + entityType, + skip: !entityStoreV2Enabled || !entityId, + }); + + const euidApi = useEntityStoreEuidApi(); + const euidEntityFilter = useMemo(() => { + if (!euidApi?.euid || entityRecord == null || entityType == null) { + return undefined; + } + return euidApi.euid.dsl.getEuidFilterBasedOnDocument(entityType as EntityType, entityRecord); + }, [euidApi?.euid, entityType, entityRecord]); + + const cspQueriesEnabled = + entityRecord !== null && Boolean(euidEntityFilter) && Boolean(euidApi?.euid); + + const { data, isLoading } = useMisconfigurationFindings( + buildMisconfigurationCspOptions({ + euidEntityFilter, + sort: [sortFieldDirection], + enabled: cspQueriesEnabled, + pageSize: 1, + currentFilter, + includeStatusFilter: true, + }) + ); + + const { passedFindings, failedFindings } = useHasMisconfigurations( + buildMisconfigurationCspOptions({ + euidEntityFilter, + sort: [], + enabled: cspQueriesEnabled, + pageSize: 1, + currentFilter: '', + includeStatusFilter: false, + }) + ); + + const [pageIndex, setPageIndex] = useState(0); + const [pageSize, setPageSize] = useState(10); + + const sorting: EuiTableSortingType = { + sort: { + field: sortField, + direction: sortDirection, + }, + }; + + const findingsPagination = (findings: MisconfigurationFindingDetailFields[]) => { + let pageOfItems; + + if (!pageIndex && !pageSize) { + pageOfItems = findings; + } else { + const startIndex = pageIndex * pageSize; + pageOfItems = findings?.slice( + startIndex, + Math.min(startIndex + pageSize, findings?.length) + ); + } + + return { + pageOfItems, + totalItemCount: findings?.length, + }; + }; + + const { pageOfItems, totalItemCount } = findingsPagination( + (data?.rows as MisconfigurationFindingDetailFields[]) || [] + ); + + const pagination = { + pageIndex, + pageSize, + totalItemCount, + pageSizeOptions: [10, 25, 100], + }; + const onTableChange = useCallback( + ({ page, sort }: Criteria) => { + if (page) { + const { index, size } = page; + setPageIndex(index); + setPageSize(size); + } + if (sort) { + const { field: fieldSort, direction } = sort; + if ( + fieldSort === MISCONFIGURATION.RESULT_EVALUATION || + fieldSort === MISCONFIGURATION.RULE_NAME || + fieldSort === 'resource' || + fieldSort === 'rule' + ) { + setSortField(fieldSort); + setSortDirection(direction); + } + } + }, + [] + ); + + const getNavUrlParams = useGetNavigationUrlParams(); + + const getFindingsPageUrl = (name: string, queryField: CloudPostureEntityIdentifier) => { + return getNavUrlParams({ [queryField]: name }, 'configurations', ['rule.name']); + }; + + const linkWidth = 40; + const resultWidth = 74; + + const { getFindingsStats } = useGetFindingsStats(); + const misconfigurationStats = getFindingsStats( + passedFindings, + failedFindings, + setCurrentFilter, + currentFilter + ); + + const columns: Array> = [ + { + field: 'rule', + name: '', + width: `${linkWidth}`, + render: (rule: CspBenchmarkRuleMetadata, finding: MisconfigurationFindingDetailFields) => ( + + { + uiMetricService.trackUiMetric( + METRIC_TYPE.CLICK, + NAV_TO_FINDINGS_BY_RULE_NAME_FROM_ENTITY_FLYOUT + ); + onShowFinding(finding.resource.id, finding.rule.id); + }} + /> + + ), + }, + { + field: MISCONFIGURATION.RESULT_EVALUATION, + render: (result: CspFindingResult['evaluation'] | undefined) => ( + + ), + name: i18n.translate( + 'xpack.securitySolution.flyout.left.insights.misconfigurations.table.resultColumnName', + { + defaultMessage: 'Result', + } + ), + width: `${resultWidth}px`, + sortable: true, + }, + { + field: MISCONFIGURATION.RULE_NAME, + render: (ruleName: CspBenchmarkRuleMetadata['name']) => ( + {ruleName} + ), + name: i18n.translate( + 'xpack.securitySolution.flyout.left.insights.misconfigurations.table.ruleColumnName', + { + defaultMessage: 'Rule', + } + ), + width: `calc(100% - ${linkWidth + resultWidth}px)`, + sortable: true, + }, + ]; + + return ( + <> + + { + uiMetricService.trackUiMetric( + METRIC_TYPE.CLICK, + NAV_TO_FINDINGS_BY_HOST_NAME_FROM_ENTITY_FLYOUT + ); + }} + > + {i18n.translate( + 'xpack.securitySolution.flyout.left.insights.misconfigurations.tableTitle', + { + defaultMessage: 'Misconfigurations ', + } + )} + + + + + + + + + ); + } +); + +MisconfigurationFindingsDetailsTable.displayName = 'MisconfigurationFindingsDetailsTable'; diff --git a/x-pack/solutions/security/plugins/security_solution/public/cloud_security_posture/components/flyout_v2/csp_details/vulnerabilities_findings_details_table.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/cloud_security_posture/components/flyout_v2/csp_details/vulnerabilities_findings_details_table.test.tsx new file mode 100644 index 0000000000000..39a7c7bd62583 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/cloud_security_posture/components/flyout_v2/csp_details/vulnerabilities_findings_details_table.test.tsx @@ -0,0 +1,95 @@ +/* + * 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 { VulnerabilitiesFindingsDetailsTable } from './vulnerabilities_findings_details_table'; +import { TestProviders } from '../../../../common/mock/test_providers'; +import { EntityIdentifierFields } from '../../../../../common/entity_analytics/types'; + +jest.mock('@kbn/cloud-security-posture', () => ({ + useVulnerabilitiesFindings: jest.fn().mockReturnValue({ data: { page: [] }, isLoading: false }), + getVulnerabilityStats: jest.fn().mockReturnValue([]), + hasVulnerabilitiesData: jest.fn().mockReturnValue(false), + SecuritySolutionLinkAnchor: ({ children }: { children: React.ReactNode }) => ( + {children} + ), +})); + +jest.mock('@kbn/cloud-security-posture/src/hooks/use_has_vulnerabilities', () => ({ + useHasVulnerabilities: jest.fn().mockReturnValue({ hasVulnerabilitiesFindings: false }), +})); + +jest.mock('@kbn/cloud-security-posture/src/hooks/use_get_navigation_url_params', () => ({ + useGetNavigationUrlParams: jest.fn().mockReturnValue(jest.fn().mockReturnValue('')), +})); + +jest.mock('@kbn/cloud-security-posture/src/hooks/use_get_severity_status_color', () => ({ + useGetSeverityStatusColor: jest + .fn() + .mockReturnValue({ getSeverityStatusColor: jest.fn().mockReturnValue('#000') }), +})); + +jest.mock('@kbn/entity-store/public', () => ({ + ...jest.requireActual('@kbn/entity-store/public'), + useEntityStoreEuidApi: jest.fn().mockReturnValue({ euid: null }), +})); + +jest.mock('../../../../common/lib/kibana', () => ({ + useUiSetting: jest.fn().mockReturnValue(false), + useKibana: jest.fn().mockReturnValue({ services: {} }), +})); + +jest.mock('../../../../flyout/entity_details/shared/hooks/use_entity_from_store', () => ({ + useEntityFromStore: jest.fn().mockReturnValue({ entityRecord: null, isLoading: false }), +})); + +jest.mock('../../../../common/components/links', () => ({ + SecuritySolutionLinkAnchor: ({ children }: { children: React.ReactNode }) => ( + {children} + ), +})); + +describe('VulnerabilitiesFindingsDetailsTable (v2)', () => { + const mockOnShowVulnerability = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders without crashing', () => { + const { getByTestId } = render( + + + + ); + + expect(getByTestId('securitySolutionFlyoutVulnerabilitiesFindingsTable')).toBeInTheDocument(); + }); + + it('accepts required onShowVulnerability callback without throwing', () => { + expect(() => + render( + + + + ) + ).not.toThrow(); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/public/cloud_security_posture/components/flyout_v2/csp_details/vulnerabilities_findings_details_table.tsx b/x-pack/solutions/security/plugins/security_solution/public/cloud_security_posture/components/flyout_v2/csp_details/vulnerabilities_findings_details_table.tsx new file mode 100644 index 0000000000000..c8508204534b4 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/cloud_security_posture/components/flyout_v2/csp_details/vulnerabilities_findings_details_table.tsx @@ -0,0 +1,487 @@ +/* + * 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, { memo, useCallback, useEffect, useMemo, useState } from 'react'; +import type { Criteria, EuiBasicTableColumn, EuiTableSortingType } from '@elastic/eui'; +import { + EuiSpacer, + EuiPanel, + EuiText, + EuiBasicTable, + EuiIcon, + EuiButtonIcon, + EuiToolTip, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { DistributionBar } from '@kbn/security-solution-distribution-bar'; +import type { + VulnerabilitiesFindingDetailFields, + VulnerabilitiesPackage, +} from '@kbn/cloud-security-posture/src/hooks/use_vulnerabilities_findings'; +import { + useVulnerabilitiesFindings, + VULNERABILITY_FINDING, +} from '@kbn/cloud-security-posture/src/hooks/use_vulnerabilities_findings'; +import type { MultiValueCellAction } from '@kbn/cloud-security-posture'; +import { + getVulnerabilityStats, + CVSScoreBadge, + SeverityStatusBadge, + getNormalizedSeverity, + ActionableBadge, + MultiValueCellPopover, + findReferenceLink, +} from '@kbn/cloud-security-posture'; +import { + ENTITY_FLYOUT_EXPAND_VULNERABILITY_VIEW_VISITS, + NAV_TO_FINDINGS_BY_HOST_NAME_FROM_ENTITY_FLYOUT, + uiMetricService, +} from '@kbn/cloud-security-posture-common/utils/ui_metrics'; +import { METRIC_TYPE } from '@kbn/analytics'; +import { SecurityPageName } from '@kbn/deeplinks-security'; +import { useGetNavigationUrlParams } from '@kbn/cloud-security-posture/src/hooks/use_get_navigation_url_params'; +import { useGetSeverityStatusColor } from '@kbn/cloud-security-posture/src/hooks/use_get_severity_status_color'; +import { useHasVulnerabilities } from '@kbn/cloud-security-posture/src/hooks/use_has_vulnerabilities'; +import { get } from 'lodash/fp'; +import type { QueryDslQueryContainer } from '@kbn/data-views-plugin/common/types'; + +import type { EntityType } from '@kbn/entity-store/public'; +import { FF_ENABLE_ENTITY_STORE_V2, useEntityStoreEuidApi } from '@kbn/entity-store/public'; +import type { UseCspOptions } from '@kbn/cloud-security-posture-common/types/findings'; +import { SecuritySolutionLinkAnchor } from '../../../../common/components/links'; +import { useUiSetting } from '../../../../common/lib/kibana'; +import { useEntityFromStore } from '../../../../flyout/entity_details/shared/hooks/use_entity_from_store'; +import type { CloudPostureEntityIdentifier } from '../../entity_insight'; + +type VulnerabilitySortFieldType = + | 'score' + | 'vulnerability' + | 'resource' + | VULNERABILITY_FINDING.SEVERITY + | VULNERABILITY_FINDING.ID + | VULNERABILITY_FINDING.PACKAGE_NAME + | VULNERABILITY_FINDING.TITLE + | 'event' + | VULNERABILITY_FINDING.PACKAGE_VERSION; + +const EMPTY_VALUE = '-'; + +const buildVulnerabilityCspOptions = ({ + euidEntityFilter, + sort, + enabled, + pageSize, + currentFilter, + includeSeverityFilter, +}: { + euidEntityFilter: QueryDslQueryContainer | undefined; + sort: UseCspOptions['sort']; + enabled: boolean; + pageSize: number; + currentFilter: string; + includeSeverityFilter: boolean; +}): UseCspOptions => { + const filters: QueryDslQueryContainer[] = []; + if (euidEntityFilter) { + filters.push(euidEntityFilter); + } + if (includeSeverityFilter && currentFilter) { + filters.push({ + bool: { + should: [ + { + term: { + 'vulnerability.severity': { + value: currentFilter, + case_insensitive: true, + }, + }, + }, + ], + minimum_should_match: 1, + }, + }); + } + return { + query: { bool: { filter: filters } }, + sort: sort ?? [], + enabled, + pageSize, + }; +}; + +export const VulnerabilitiesFindingsDetailsTable = memo( + ({ + identityField, + value, + scopeId, + entityId, + entityType, + onShowVulnerability, + }: { + identityField: CloudPostureEntityIdentifier; + value: string; + scopeId: string; + entityId?: string; + entityType?: string; + onShowVulnerability: (params: { + vulnerabilityId: string; + resourceId: string; + packageName: string; + packageVersion: string; + eventId: string; + }) => void; + }) => { + const { getSeverityStatusColor } = useGetSeverityStatusColor(); + + useEffect(() => { + uiMetricService.trackUiMetric( + METRIC_TYPE.COUNT, + ENTITY_FLYOUT_EXPAND_VULNERABILITY_VIEW_VISITS + ); + }, []); + + const [currentFilter, setCurrentFilter] = useState(''); + const [sortField, setSortField] = useState( + VULNERABILITY_FINDING.SEVERITY + ); + const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc'); + + const sortFieldDirection: { [key: string]: string } = {}; + sortFieldDirection[sortField === 'score' ? 'vulnerability.score.base' : sortField] = + sortDirection; + + const sorting: EuiTableSortingType = { + sort: { + field: sortField, + direction: sortDirection, + }, + }; + + const entityStoreV2Enabled = useUiSetting(FF_ENABLE_ENTITY_STORE_V2); + + const { entityRecord, isLoading: isEntityRecordLoading } = useEntityFromStore({ + entityId, + entityType, + skip: !entityStoreV2Enabled || !entityId, + }); + + const euidApi = useEntityStoreEuidApi(); + const euidEntityFilter = useMemo(() => { + if (!euidApi?.euid || entityRecord == null || entityType == null) { + return undefined; + } + return euidApi.euid.dsl.getEuidFilterBasedOnDocument(entityType as EntityType, entityRecord); + }, [euidApi?.euid, entityType, entityRecord]); + + const cspQueriesEnabled = + entityRecord !== null && Boolean(euidEntityFilter) && Boolean(euidApi?.euid); + + const { data, isLoading } = useVulnerabilitiesFindings( + buildVulnerabilityCspOptions({ + euidEntityFilter, + sort: [sortFieldDirection], + enabled: cspQueriesEnabled, + pageSize: 1, + currentFilter, + includeSeverityFilter: true, + }) + ); + + const { counts } = useHasVulnerabilities( + buildVulnerabilityCspOptions({ + euidEntityFilter, + sort: [], + enabled: cspQueriesEnabled, + pageSize: 1, + currentFilter: '', + includeSeverityFilter: false, + }) + ); + + const { critical = 0, high = 0, medium = 0, low = 0, none = 0 } = counts || {}; + + const [pageIndex, setPageIndex] = useState(0); + const [pageSize, setPageSize] = useState(10); + + const findingsPagination = (findings: VulnerabilitiesFindingDetailFields[]) => { + let pageOfItems; + + if (!pageIndex && !pageSize) { + pageOfItems = findings; + } else { + const startIndex = pageIndex * pageSize; + pageOfItems = findings?.slice( + startIndex, + Math.min(startIndex + pageSize, findings?.length) + ); + } + + return { + pageOfItems, + totalItemCount: findings?.length, + }; + }; + + const { pageOfItems, totalItemCount } = findingsPagination(data?.rows || []); + + const pagination = { + pageIndex, + pageSize, + totalItemCount, + pageSizeOptions: [10, 25, 100], + }; + + const onTableChange = ({ page, sort }: Criteria) => { + if (page) { + const { index, size } = page; + setPageIndex(index); + setPageSize(size); + } + if (sort) { + const { field: fieldSort, direction } = sort; + setSortField(fieldSort); + setSortDirection(direction); + } + }; + + const getNavUrlParams: ReturnType = + useGetNavigationUrlParams(); + + const getVulnerabilityUrl = (name: string, queryField: CloudPostureEntityIdentifier) => { + return getNavUrlParams({ [queryField]: name }, 'vulnerabilities'); + }; + + const vulnerabilityStats = getVulnerabilityStats( + { + critical, + high, + medium, + low, + none, + }, + getSeverityStatusColor, + setCurrentFilter, + currentFilter + ); + + const renderItem = useCallback( + (item: string, i: number, field: string, object: VulnerabilitiesFindingDetailFields) => { + const references = Array.isArray(object.vulnerability.reference) + ? object.vulnerability.reference + : [object.vulnerability.reference]; + + const url = findReferenceLink(references, item); + + const actions: MultiValueCellAction[] = [ + ...(field === 'vulnerability.id' && url + ? [ + { + onClick: () => window.open(url, '_blank'), + iconType: 'external', + ariaLabel: i18n.translate( + 'xpack.securitySolution.vulnerabilities.findingsDetailsTable.openUrlInWindow', + { + defaultMessage: 'Open URL in window', + } + ), + title: i18n.translate( + 'xpack.securitySolution.vulnerabilities.findingsDetailsTable.openUrlInWindow', + { + defaultMessage: 'Open URL in window', + } + ), + }, + ] + : []), + ]; + + return ; + }, + [] + ); + + const renderMultiValueCell = (field: string, finding: VulnerabilitiesFindingDetailFields) => { + const cellValue = get(field, finding); + if (!Array.isArray(cellValue)) { + return {cellValue || EMPTY_VALUE}; + } + + return ( + + items={cellValue} + field={field} + object={finding} + renderItem={renderItem} + firstItemRenderer={(item) => {item}} + /> + ); + }; + + const columns: Array> = [ + { + field: 'vulnerability', + name: '', + width: '5%', + render: ( + vulnerability: VulnerabilitiesPackage, + finding: VulnerabilitiesFindingDetailFields + ) => ( + + { + const vulnerabilityId = Array.isArray(vulnerability?.id) + ? vulnerability.id[0] + : vulnerability?.id; + onShowVulnerability({ + vulnerabilityId: vulnerabilityId ?? '', + resourceId: finding?.resource?.id ?? '', + packageName: finding?.[VULNERABILITY_FINDING.PACKAGE_NAME], + packageVersion: finding?.[VULNERABILITY_FINDING.PACKAGE_VERSION], + eventId: finding?.event?.id ?? '', + }); + }} + aria-label={i18n.translate( + 'xpack.securitySolution.flyout.left.insights.vulnerability.table.previewDetailsButtonAriaLabel', + { + defaultMessage: 'Preview vulnerability details', + } + )} + /> + + ), + }, + { + field: 'score', + render: (score: { version?: string; base?: number }) => ( + + + + ), + name: i18n.translate( + 'xpack.securitySolution.flyout.left.insights.vulnerability.table.ruleColumnName', + { defaultMessage: 'CVSS' } + ), + width: '10%', + sortable: true, + }, + { + field: VULNERABILITY_FINDING.TITLE, + render: (title: string) => { + if (Array.isArray(title)) { + return {title.join(', ')}; + } + + return {title || EMPTY_VALUE}; + }, + name: i18n.translate( + 'xpack.securitySolution.flyout.left.insights.vulnerability.table.vulnerabilityTitleColumnName', + { defaultMessage: 'Vulnerability Title' } + ), + width: '25%', + sortable: true, + }, + { + field: VULNERABILITY_FINDING.ID, + render: (id: string, finding: VulnerabilitiesFindingDetailFields) => + renderMultiValueCell(VULNERABILITY_FINDING.ID, finding), + name: i18n.translate( + 'xpack.securitySolution.flyout.left.insights.vulnerability.table.vulnerabilityIdColumnName', + { defaultMessage: 'CVE ID' } + ), + width: '20%', + sortable: true, + }, + { + field: VULNERABILITY_FINDING.SEVERITY, + render: (severity: string) => ( + <> + + + + + ), + name: i18n.translate( + 'xpack.securitySolution.flyout.left.insights.vulnerability.table.ruleColumnName', + { defaultMessage: 'Severity' } + ), + width: '10%', + sortable: true, + }, + { + field: VULNERABILITY_FINDING.PACKAGE_NAME, + render: (packageName: string, finding: VulnerabilitiesFindingDetailFields) => + renderMultiValueCell(VULNERABILITY_FINDING.PACKAGE_NAME, finding), + name: i18n.translate( + 'xpack.securitySolution.flyout.left.insights.vulnerability.table.ruleColumnName', + { defaultMessage: 'Package' } + ), + width: '30%', + sortable: true, + }, + ]; + + return ( + <> + + { + uiMetricService.trackUiMetric( + METRIC_TYPE.CLICK, + NAV_TO_FINDINGS_BY_HOST_NAME_FROM_ENTITY_FLYOUT + ); + }} + > + {i18n.translate( + 'xpack.securitySolution.flyout.left.insights.vulnerability.tableTitle', + { + defaultMessage: 'Vulnerability ', + } + )} + + + + + + + + + ); + } +); + +VulnerabilitiesFindingsDetailsTable.displayName = 'VulnerabilitiesFindingsDetailsTable'; diff --git a/x-pack/solutions/security/plugins/security_solution/public/cloud_security_posture/components/flyout_v2/entity_insight.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/cloud_security_posture/components/flyout_v2/entity_insight.test.tsx new file mode 100644 index 0000000000000..28355e8b9ef25 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/cloud_security_posture/components/flyout_v2/entity_insight.test.tsx @@ -0,0 +1,117 @@ +/* + * 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 { EntityInsight } from './entity_insight'; +import { TestProviders } from '../../../common/mock/test_providers'; + +jest.mock('@kbn/cloud-security-posture/src/hooks/use_has_misconfigurations', () => ({ + useHasMisconfigurations: jest.fn().mockReturnValue({ + hasMisconfigurationFindings: false, + passedFindings: 0, + failedFindings: 0, + }), +})); + +jest.mock('@kbn/cloud-security-posture/src/hooks/use_has_vulnerabilities', () => ({ + useHasVulnerabilities: jest.fn().mockReturnValue({ hasVulnerabilitiesFindings: false }), +})); + +jest.mock('@kbn/entity-store/public', () => ({ + ...jest.requireActual('@kbn/entity-store/public'), + useEntityStoreEuidApi: jest.fn().mockReturnValue({ euid: null }), +})); + +jest.mock('../../../common/containers/use_global_time', () => ({ + useGlobalTime: jest.fn().mockReturnValue({ to: '2023-01-01', from: '2022-01-01' }), +})); + +jest.mock('../../hooks/use_non_closed_alerts', () => ({ + useNonClosedAlerts: jest.fn().mockReturnValue({ + hasNonClosedAlerts: false, + filteredAlertsData: null, + }), +})); + +jest.mock('../../../common/lib/kibana', () => ({ + useUiSetting: jest.fn().mockReturnValue(false), + useKibana: jest.fn().mockReturnValue({ services: {} }), +})); + +import { useHasMisconfigurations } from '@kbn/cloud-security-posture/src/hooks/use_has_misconfigurations'; +import { useNonClosedAlerts } from '../../hooks/use_non_closed_alerts'; + +describe('EntityInsight (v2)', () => { + const mockOpenDetailsPanel = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + (useHasMisconfigurations as jest.Mock).mockReturnValue({ + hasMisconfigurationFindings: false, + passedFindings: 0, + failedFindings: 0, + }); + (useNonClosedAlerts as jest.Mock).mockReturnValue({ + hasNonClosedAlerts: false, + filteredAlertsData: null, + }); + }); + + it('does not render the accordion when there are no insights', () => { + const { queryByTestId } = render( + + + + ); + + expect(queryByTestId('entityInsightTestSubj')).not.toBeInTheDocument(); + }); + + it('renders the accordion when there are misconfiguration insights', () => { + (useHasMisconfigurations as jest.Mock).mockReturnValue({ + hasMisconfigurationFindings: true, + passedFindings: 2, + failedFindings: 1, + }); + + const { getByTestId } = render( + + + + ); + + expect(getByTestId('entityInsightTestSubj')).toBeInTheDocument(); + }); + + it('renders the accordion when there are alert insights', () => { + (useNonClosedAlerts as jest.Mock).mockReturnValue({ + hasNonClosedAlerts: true, + filteredAlertsData: { open: { total: 3, severities: [] } }, + }); + + const { getByTestId } = render( + + + + ); + + expect(getByTestId('entityInsightTestSubj')).toBeInTheDocument(); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/public/cloud_security_posture/components/flyout_v2/entity_insight.tsx b/x-pack/solutions/security/plugins/security_solution/public/cloud_security_posture/components/flyout_v2/entity_insight.tsx new file mode 100644 index 0000000000000..372cd5149d4d7 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/cloud_security_posture/components/flyout_v2/entity_insight.tsx @@ -0,0 +1,153 @@ +/* + * 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 { EuiAccordion, EuiHorizontalRule, EuiSpacer, EuiTitle, useEuiTheme } from '@elastic/eui'; +import React from 'react'; +import { css } from '@emotion/react'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { useHasVulnerabilities } from '@kbn/cloud-security-posture/src/hooks/use_has_vulnerabilities'; +import { useHasMisconfigurations } from '@kbn/cloud-security-posture/src/hooks/use_has_misconfigurations'; +import { FF_ENABLE_ENTITY_STORE_V2, useEntityStoreEuidApi } from '@kbn/entity-store/public'; +import { + buildEuidCspPreviewOptions, + inferEntityTypeFromIdentityFields, +} from '../../utils/build_euid_csp_preview_options'; +import type { EntityIdentifierFields } from '../../../../common/entity_analytics/types'; +import type { IdentityFields } from '../../../flyout/document_details/shared/utils'; +import { MisconfigurationsPreview } from './misconfiguration/misconfiguration_preview'; +import { VulnerabilitiesPreview } from './vulnerabilities/vulnerabilities_preview'; +import { AlertsPreview } from './alerts/alerts_preview'; +import { useGlobalTime } from '../../../common/containers/use_global_time'; +import { DETECTION_RESPONSE_ALERTS_BY_STATUS_ID } from '../../../overview/components/detection_response/alerts_by_status/types'; +import { useNonClosedAlerts } from '../../hooks/use_non_closed_alerts'; +import type { EntityDetailsPath } from '../../../flyout/entity_details/shared/components/left_panel/left_panel_header'; +import { useUiSetting } from '../../../common/lib/kibana'; +import type { EntityStoreRecord } from '../../../flyout/entity_details/shared/hooks/use_entity_from_store'; + +export type CloudPostureEntityIdentifier = + | Extract< + EntityIdentifierFields, + | EntityIdentifierFields.hostName + | EntityIdentifierFields.userName + | EntityIdentifierFields.generic + > + | 'related.entity'; // related.entity is not an entity identifier field, but it includes entity ids which we use to filter for related entities + +export const EntityInsight = ({ + identityFields, + openDetailsPanel, + entityType, + entityRecord, +}: { + identityFields: IdentityFields; + openDetailsPanel: (path: EntityDetailsPath) => void; + /** Host or user when the flyout represents that entity; enables v2 alerts resolution by `entity.id`. */ + entityType?: string; + entityRecord?: EntityStoreRecord | null; +}) => { + const { euiTheme } = useEuiTheme(); + const euidApi = useEntityStoreEuidApi(); + const entityStoreV2Enabled = useUiSetting(FF_ENABLE_ENTITY_STORE_V2); + const insightContent: React.ReactElement[] = []; + + const cspPreviewEntityType = inferEntityTypeFromIdentityFields(identityFields); + const { + hasMisconfigurationFindings: showMisconfigurationsPreview, + passedFindings, + failedFindings, + } = useHasMisconfigurations( + buildEuidCspPreviewOptions(cspPreviewEntityType, entityRecord, euidApi, { + entityStoreV2Enabled, + legacyIdentityFields: identityFields, + }) + ); + + const { hasVulnerabilitiesFindings } = useHasVulnerabilities( + buildEuidCspPreviewOptions(cspPreviewEntityType, entityRecord, euidApi, { + entityStoreV2Enabled, + legacyIdentityFields: identityFields, + }) + ); + + const showVulnerabilitiesPreview = + hasVulnerabilitiesFindings && Object.keys(identityFields).length > 0; + + const { to, from } = useGlobalTime(); + + const { hasNonClosedAlerts: showAlertsPreview, filteredAlertsData } = useNonClosedAlerts({ + identityFields, + entityRecord, + entityType, + to, + from, + queryId: DETECTION_RESPONSE_ALERTS_BY_STATUS_ID, + }); + + if (showAlertsPreview) { + insightContent.push( + <> + + + + ); + } + if (showMisconfigurationsPreview) + insightContent.push( + <> + + + + ); + if (showVulnerabilitiesPreview) + insightContent.push( + <> + + + + ); + return ( + <> + {insightContent.length > 0 && ( + <> + +

+ +

+ + } + > + + {insightContent} +
+ + + )} + + ); +}; diff --git a/x-pack/solutions/security/plugins/security_solution/public/cloud_security_posture/components/flyout_v2/misconfiguration/misconfiguration_preview.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/cloud_security_posture/components/flyout_v2/misconfiguration/misconfiguration_preview.test.tsx new file mode 100644 index 0000000000000..e196c07fb7a7e --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/cloud_security_posture/components/flyout_v2/misconfiguration/misconfiguration_preview.test.tsx @@ -0,0 +1,31 @@ +/* + * 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 { MisconfigurationsPreview } from './misconfiguration_preview'; +import { TestProviders } from '../../../../common/mock/test_providers'; + +describe('MisconfigurationsPreview (v2)', () => { + const mockOpenDetailsPanel = jest.fn(); + + it('renders', () => { + const { getByTestId } = render( + + + + ); + + expect( + getByTestId('securitySolutionFlyoutInsightsMisconfigurationsTitleLink') + ).toBeInTheDocument(); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/public/cloud_security_posture/components/flyout_v2/misconfiguration/misconfiguration_preview.tsx b/x-pack/solutions/security/plugins/security_solution/public/cloud_security_posture/components/flyout_v2/misconfiguration/misconfiguration_preview.tsx new file mode 100644 index 0000000000000..a19a908202322 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/cloud_security_posture/components/flyout_v2/misconfiguration/misconfiguration_preview.tsx @@ -0,0 +1,181 @@ +/* + * 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, useEffect, useMemo } from 'react'; +import { css } from '@emotion/react'; +import { EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiText, EuiTitle, useEuiTheme } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { DistributionBar } from '@kbn/security-solution-distribution-bar'; +import { i18n } from '@kbn/i18n'; +import { useGetMisconfigurationStatusColor } from '@kbn/cloud-security-posture'; +import { MISCONFIGURATION_STATUS } from '@kbn/cloud-security-posture-common'; +import { METRIC_TYPE } from '@kbn/analytics'; +import { + ENTITY_FLYOUT_WITH_MISCONFIGURATION_VISIT, + uiMetricService, +} from '@kbn/cloud-security-posture-common/utils/ui_metrics'; +import { ExpandablePanel } from '../../../../flyout_v2/shared/components/expandable_panel'; +import type { EntityDetailsPath } from '../../../../flyout/entity_details/shared/components/left_panel/left_panel_header'; +import { + CspInsightLeftPanelSubTab, + EntityDetailsLeftPanelTab, +} from '../../../../flyout/entity_details/shared/components/left_panel/left_panel_header'; + +interface MisconfigurationPreviewDistributionBarProps { + key: string; + count: number; + color: string; +} + +export const useGetFindingsStats = (passedFindingsStats: number, failedFindingsStats: number) => { + const { getMisconfigurationStatusColor } = useGetMisconfigurationStatusColor(); + + return useMemo(() => { + const misconfigurationStats: MisconfigurationPreviewDistributionBarProps[] = []; + if (passedFindingsStats === 0 && failedFindingsStats === 0) return []; + if (passedFindingsStats > 0) { + misconfigurationStats.push({ + key: i18n.translate( + 'xpack.securitySolution.flyout.right.insights.misconfigurations.passedFindingsText', + { + defaultMessage: '{count, plural, one {Passed finding} other {Passed findings}}', + values: { count: passedFindingsStats }, + } + ), + count: passedFindingsStats, + color: getMisconfigurationStatusColor(MISCONFIGURATION_STATUS.PASSED), + }); + } + if (failedFindingsStats > 0) { + misconfigurationStats.push({ + key: i18n.translate( + 'xpack.securitySolution.flyout.right.insights.misconfigurations.failedFindingsText', + { + defaultMessage: '{count, plural, one {Failed finding} other {Failed findings}}', + values: { count: failedFindingsStats }, + } + ), + count: failedFindingsStats, + color: getMisconfigurationStatusColor(MISCONFIGURATION_STATUS.FAILED), + }); + } + return misconfigurationStats; + }, [passedFindingsStats, failedFindingsStats, getMisconfigurationStatusColor]); +}; + +const MisconfigurationPreviewScore = ({ + passedFindings, + failedFindings, +}: { + passedFindings: number; + failedFindings: number; +}) => { + const { euiTheme } = useEuiTheme(); + + return ( + + + + +

{`${Math.round((passedFindings / (passedFindings + failedFindings)) * 100)}%`}

+
+
+ + + + + +
+
+ ); +}; + +export const MisconfigurationsPreview = ({ + openDetailsPanel, + passedFindings, + failedFindings, +}: { + passedFindings: number; + failedFindings: number; + openDetailsPanel: (path: EntityDetailsPath) => void; +}) => { + const findingsStats = useGetFindingsStats(passedFindings, failedFindings); + + useEffect(() => { + uiMetricService.trackUiMetric(METRIC_TYPE.CLICK, ENTITY_FLYOUT_WITH_MISCONFIGURATION_VISIT); + }, []); + const { euiTheme } = useEuiTheme(); + + const goToEntityInsightTab = useCallback( + () => + openDetailsPanel({ + tab: EntityDetailsLeftPanelTab.CSP_INSIGHTS, + subTab: CspInsightLeftPanelSubTab.MISCONFIGURATIONS, + }), + [openDetailsPanel] + ); + + const link = useMemo( + () => ({ + callback: goToEntityInsightTab, + tooltip: ( + + ), + }), + [goToEntityInsightTab] + ); + return ( + + + + ), + link, + }} + data-test-subj={'securitySolutionFlyoutInsightsMisconfigurations'} + > + + + + + + + + + + + + + + + ); +}; diff --git a/x-pack/solutions/security/plugins/security_solution/public/cloud_security_posture/components/flyout_v2/vulnerabilities/vulnerabilities_preview.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/cloud_security_posture/components/flyout_v2/vulnerabilities/vulnerabilities_preview.test.tsx new file mode 100644 index 0000000000000..4109042b6600e --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/cloud_security_posture/components/flyout_v2/vulnerabilities/vulnerabilities_preview.test.tsx @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { render } from '@testing-library/react'; +import { VulnerabilitiesPreview } from './vulnerabilities_preview'; +import { useVulnerabilitiesPreview } from '@kbn/cloud-security-posture/src/hooks/use_vulnerabilities_preview'; +import { TestProviders } from '../../../../common/mock/test_providers'; + +jest.mock('@kbn/cloud-security-posture/src/hooks/use_vulnerabilities_preview'); + +describe('VulnerabilitiesPreview (v2)', () => { + const mockOpenDetailsPanel = jest.fn(); + + beforeEach(() => { + (useVulnerabilitiesPreview as jest.Mock).mockReturnValue({ + data: { count: { CRITICAL: 0, HIGH: 1, MEDIUM: 1, LOW: 0, UNKNOWN: 0 } }, + }); + }); + + it('renders', () => { + const { getByTestId } = render( + + + + ); + + expect( + getByTestId('securitySolutionFlyoutInsightsVulnerabilitiesTitleLink') + ).toBeInTheDocument(); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/public/cloud_security_posture/components/flyout_v2/vulnerabilities/vulnerabilities_preview.tsx b/x-pack/solutions/security/plugins/security_solution/public/cloud_security_posture/components/flyout_v2/vulnerabilities/vulnerabilities_preview.tsx new file mode 100644 index 0000000000000..f07fdd5f655ce --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/cloud_security_posture/components/flyout_v2/vulnerabilities/vulnerabilities_preview.tsx @@ -0,0 +1,178 @@ +/* + * 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, useEffect, useMemo } from 'react'; +import { css } from '@emotion/react'; +import { EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiText, EuiTitle, useEuiTheme } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { DistributionBar } from '@kbn/security-solution-distribution-bar'; +import { useVulnerabilitiesPreview } from '@kbn/cloud-security-posture/src/hooks/use_vulnerabilities_preview'; +import { useGetSeverityStatusColor } from '@kbn/cloud-security-posture/src/hooks/use_get_severity_status_color'; +import { getAbbreviatedNumber } from '@kbn/cloud-security-posture-common'; +import { getVulnerabilityStats, hasVulnerabilitiesData } from '@kbn/cloud-security-posture'; +import { + ENTITY_FLYOUT_WITH_VULNERABILITY_PREVIEW, + uiMetricService, +} from '@kbn/cloud-security-posture-common/utils/ui_metrics'; +import { METRIC_TYPE } from '@kbn/analytics'; +import { FF_ENABLE_ENTITY_STORE_V2, useEntityStoreEuidApi } from '@kbn/entity-store/public'; +import type { EntityStoreRecord } from '../../../../flyout/entity_details/shared/hooks/use_entity_from_store'; +import { + buildEuidCspPreviewOptions, + inferEntityTypeFromIdentityFields, +} from '../../../utils/build_euid_csp_preview_options'; +import { ExpandablePanel } from '../../../../flyout_v2/shared/components/expandable_panel'; +import type { EntityDetailsPath } from '../../../../flyout/entity_details/shared/components/left_panel/left_panel_header'; +import { + CspInsightLeftPanelSubTab, + EntityDetailsLeftPanelTab, +} from '../../../../flyout/entity_details/shared/components/left_panel/left_panel_header'; +import type { IdentityFields } from '../../../../flyout/document_details/shared/utils'; +import { useUiSetting } from '../../../../common/lib/kibana'; + +const VulnerabilitiesCount = ({ + vulnerabilitiesTotal, +}: { + vulnerabilitiesTotal: string | number; +}) => { + const { euiTheme } = useEuiTheme(); + + return ( + + + + +

{vulnerabilitiesTotal}

+
+
+ + + + + +
+
+ ); +}; + +export const VulnerabilitiesPreview = ({ + identityFields, + entityRecord, + openDetailsPanel, +}: { + identityFields: IdentityFields; + entityRecord?: EntityStoreRecord | null; + openDetailsPanel: (path: EntityDetailsPath) => void; +}) => { + useEffect(() => { + uiMetricService.trackUiMetric(METRIC_TYPE.CLICK, ENTITY_FLYOUT_WITH_VULNERABILITY_PREVIEW); + }, []); + + const euidApi = useEntityStoreEuidApi(); + const entityStoreV2Enabled = useUiSetting(FF_ENABLE_ENTITY_STORE_V2); + const entityType = inferEntityTypeFromIdentityFields(identityFields); + const cspPreviewOptions = useMemo( + () => + buildEuidCspPreviewOptions(entityType, entityRecord, euidApi, { + entityStoreV2Enabled, + legacyIdentityFields: identityFields, + }), + [entityType, entityRecord, euidApi, entityStoreV2Enabled, identityFields] + ); + const { data } = useVulnerabilitiesPreview(cspPreviewOptions); + + const { CRITICAL = 0, HIGH = 0, MEDIUM = 0, LOW = 0, NONE = 0 } = data?.count || {}; + + const totalVulnerabilities = CRITICAL + HIGH + MEDIUM + LOW + NONE; + + const hasVulnerabilitiesFindings = hasVulnerabilitiesData({ + critical: CRITICAL, + high: HIGH, + medium: MEDIUM, + low: LOW, + none: NONE, + }); + + const { euiTheme } = useEuiTheme(); + const { getSeverityStatusColor } = useGetSeverityStatusColor(); + + const goToEntityInsightTab = useCallback( + () => + openDetailsPanel({ + tab: EntityDetailsLeftPanelTab.CSP_INSIGHTS, + subTab: CspInsightLeftPanelSubTab.VULNERABILITIES, + }), + [openDetailsPanel] + ); + + const link = useMemo( + () => ({ + callback: goToEntityInsightTab, + tooltip: ( + + ), + }), + [goToEntityInsightTab] + ); + + const vulnerabilityStats = getVulnerabilityStats( + { + critical: CRITICAL, + high: HIGH, + medium: MEDIUM, + low: LOW, + none: NONE, + }, + getSeverityStatusColor + ); + + return ( + + + + ), + link: hasVulnerabilitiesFindings ? link : undefined, + }} + data-test-subj={'securitySolutionFlyoutInsightsVulnerabilities'} + > + + + + + + + + + + + + + + ); +}; diff --git a/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/components/entity_details_flyout/hooks/use_risk_input_actions_panels.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/components/entity_details_flyout/hooks/use_risk_input_actions_panels.test.tsx index 59d88072efe1d..95e2cb84f23fc 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/components/entity_details_flyout/hooks/use_risk_input_actions_panels.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/components/entity_details_flyout/hooks/use_risk_input_actions_panels.test.tsx @@ -16,6 +16,7 @@ import { useRiskInputActionsPanels } from './use_risk_input_actions_panels'; import { useSendBulkToTimeline } from '../../../../detections/components/alerts_table/timeline_actions/use_send_bulk_to_timeline'; import { useUserPrivileges } from '../../../../common/components/user_privileges'; import { EntityEventTypes } from '../../../../common/lib/telemetry'; +import { useIsInSecurityApp } from '../../../../common/hooks/is_in_security_app'; const casesServiceMock = casesPluginMock.createStartContract(); const mockCanUseCases = jest.fn(); @@ -50,9 +51,11 @@ jest.mock( '../../../../detections/components/alerts_table/timeline_actions/use_send_bulk_to_timeline' ); jest.mock('../../../../common/components/user_privileges'); +jest.mock('../../../../common/hooks/is_in_security_app'); const mockUseSendBulkToTimeline = useSendBulkToTimeline as jest.Mock; const mockUseUserPrivileges = useUserPrivileges as jest.Mock; +const mockUseIsInSecurityApp = useIsInSecurityApp as jest.Mock; const TestMenu = ({ panels }: { panels: EuiContextMenuPanelDescriptor[] }) => ( @@ -83,6 +86,7 @@ describe('useRiskInputActionsPanels', () => { mockUseUserPrivileges.mockReturnValue({ timelinePrivileges: { read: false }, }); + mockUseIsInSecurityApp.mockReturnValue(true); }); it('displays the rule name when only one alert is selected', () => { diff --git a/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/components/entity_details_flyout/hooks/use_risk_input_actions_panels.tsx b/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/components/entity_details_flyout/hooks/use_risk_input_actions_panels.tsx index bddf26b8bf52f..25da74d022e6e 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/components/entity_details_flyout/hooks/use_risk_input_actions_panels.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/components/entity_details_flyout/hooks/use_risk_input_actions_panels.tsx @@ -20,6 +20,7 @@ import { useSendBulkToTimeline } from '../../../../detections/components/alerts_ import { useUserPrivileges } from '../../../../common/components/user_privileges'; import { EntityEventTypes } from '../../../../common/lib/telemetry'; import { useKibana } from '../../../../common/lib/kibana/kibana_react'; +import { useIsInSecurityApp } from '../../../../common/hooks/is_in_security_app'; export const useRiskInputActionsPanels = (inputs: InputAlert[], closePopover: () => void) => { const { cases: casesService, telemetry } = useKibana().services; @@ -28,6 +29,7 @@ export const useRiskInputActionsPanels = (inputs: InputAlert[], closePopover: () const { timelinePrivileges: { read: canReadTimelines }, } = useUserPrivileges(); + const isInSecurityApp = useIsInSecurityApp(); const userCasesPermissions = casesService?.helpers.canUseCases([SECURITY_SOLUTION_OWNER]); const hasCasesPermissions = userCasesPermissions?.create && userCasesPermissions?.read; @@ -37,7 +39,7 @@ export const useRiskInputActionsPanels = (inputs: InputAlert[], closePopover: () tableId: TableId.riskInputs, }); const timelineActions = useMemo(() => { - if (!canReadTimelines) { + if (!canReadTimelines || !isInSecurityApp) { return []; } @@ -71,7 +73,14 @@ export const useRiskInputActionsPanels = (inputs: InputAlert[], closePopover: () }, }, ]; - }, [canReadTimelines, inputs, sendBulkEventsToTimelineHandler, closePopover, telemetry]); + }, [ + canReadTimelines, + isInSecurityApp, + inputs, + sendBulkEventsToTimelineHandler, + closePopover, + telemetry, + ]); return useMemo(() => { const ruleName = get(['alert', ALERT_RULE_NAME], inputs[0]) ?? ''; diff --git a/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/components/flyout_v2/constants.ts b/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/components/flyout_v2/constants.ts new file mode 100644 index 0000000000000..74b20561ba130 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/components/flyout_v2/constants.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 const RISK_INPUTS_TAB_QUERY_ID = 'RiskInputsTabQuery'; diff --git a/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/components/flyout_v2/risk_inputs_tab.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/components/flyout_v2/risk_inputs_tab.test.tsx new file mode 100644 index 0000000000000..20633bdd62cb2 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/components/flyout_v2/risk_inputs_tab.test.tsx @@ -0,0 +1,946 @@ +/* + * 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 { fireEvent, render } from '@testing-library/react'; +import React from 'react'; +import { TestProviders } from '../../../common/mock'; +import { times } from 'lodash/fp'; +import { EXPAND_ALERT_TEST_ID, RiskInputsTab } from './risk_inputs_tab'; +import { alertInputDataMock } from '../entity_details_flyout/mocks'; +import { RiskSeverity } from '../../../../common/search_strategy'; +import { EntityType } from '../../../../common/entity_analytics/types'; +import { + EntityDetailsLeftPanelTab, + RiskScoreLeftPanelSubTab, +} from '../../../flyout/entity_details/shared/components/left_panel/left_panel_header'; + +const mockUseRiskContributingAlerts = jest.fn().mockReturnValue({ loading: false, data: [] }); +const mockGetEuidFromObject = jest.fn().mockReturnValue('user:entity-1'); + +jest.mock('../../hooks/use_risk_contributing_alerts', () => ({ + useRiskContributingAlerts: () => mockUseRiskContributingAlerts(), +})); + +jest.mock('@kbn/entity-store/public', () => ({ + useEntityStoreEuidApi: () => ({ + euid: { + getEuidFromObject: (...args: unknown[]) => mockGetEuidFromObject(...args), + }, + }), +})); + +const mockUseUiSetting = jest.fn().mockReturnValue([false]); + +jest.mock('@kbn/kibana-react-plugin/public', () => { + const original = jest.requireActual('@kbn/kibana-react-plugin/public'); + return { + ...original, + useUiSetting$: () => mockUseUiSetting(), + }; +}); + +const mockUseRiskScore = jest.fn().mockReturnValue({ loading: false, data: [] }); + +jest.mock('../../api/hooks/use_risk_score', () => ({ + useRiskScore: (params: unknown) => mockUseRiskScore(params), +})); + +const mockUseGetWatchlists = jest.fn().mockReturnValue({ data: [] }); + +jest.mock('../../api/hooks/use_get_watchlists', () => ({ + useGetWatchlists: () => mockUseGetWatchlists(), +})); + +const mockUseResolutionGroup = jest.fn().mockReturnValue({ data: undefined }); + +jest.mock('../entity_resolution/hooks/use_resolution_group', () => ({ + useResolutionGroup: (entityId: string) => mockUseResolutionGroup(entityId), +})); + +const mockUseStableExpandableFlyoutState = jest.fn().mockReturnValue({}); + +jest.mock('../../../flyout/shared/hooks/use_stable_expandable_flyout_state', () => ({ + useStableExpandableFlyoutState: () => mockUseStableExpandableFlyoutState(), +})); + +const riskScore = { + '@timestamp': '2021-08-19T16:00:00.000Z', + user: { + name: 'elastic', + risk: { + rule_risks: [], + calculated_score_norm: 100, + multipliers: [], + calculated_level: RiskSeverity.Critical, + }, + }, +}; + +const riskScoreWithAssetCriticalityContribution = (contribution: number) => { + const score = JSON.parse(JSON.stringify(riskScore)); + score.user.risk.modifiers = [ + { + type: 'asset_criticality', + contribution, + metadata: { + criticality_level: 'high_impact', + }, + }, + ]; + score.user.risk.category_2_score = contribution; + return score; +}; + +describe('RiskInputsTab (v2)', () => { + const isResolutionFilter = (params?: { filterQuery?: unknown }): boolean => { + const filters = ( + params?.filterQuery as + | { + bool?: { filter?: Array<{ term?: Record }> }; + } + | undefined + )?.bool?.filter; + + if (!Array.isArray(filters)) { + return false; + } + + return filters.some((clause) => Object.values(clause.term ?? {}).includes('resolution')); + }; + + beforeEach(() => { + jest.clearAllMocks(); + mockGetEuidFromObject.mockReturnValue('user:entity-1'); + mockUseResolutionGroup.mockReturnValue({ data: undefined }); + mockUseGetWatchlists.mockReturnValue({ data: [] }); + mockUseStableExpandableFlyoutState.mockReturnValue({}); + mockUseRiskScore.mockImplementation((params?: { filterQuery?: unknown; skip?: boolean }) => + params?.skip + ? { + loading: false, + error: false, + data: [], + } + : isResolutionFilter(params) + ? { + loading: false, + error: false, + data: [], + } + : { + loading: false, + error: false, + data: [riskScore], + } + ); + }); + + it('renders', () => { + mockUseRiskContributingAlerts.mockReturnValue({ + loading: false, + error: false, + data: [alertInputDataMock], + }); + mockUseRiskScore.mockReturnValue({ + loading: false, + error: false, + data: [riskScore], + }); + + const { getByTestId, queryByTestId } = render( + + + + ); + + expect(queryByTestId('risk-input-asset-criticality-title')).not.toBeInTheDocument(); + expect(getByTestId('risk-input-table-description-cell')).toHaveTextContent('Rule Name'); + }); + + it('Does not render the context section if enabled but no asset criticality', () => { + mockUseUiSetting.mockReturnValue([true]); + + const { queryByTestId } = render( + + + + ); + + expect(queryByTestId('risk-input-asset-criticality-title')).not.toBeInTheDocument(); + }); + + it('Renders the context section if enabled and risks contains asset criticality', () => { + mockUseUiSetting.mockReturnValue([true]); + + const riskScoreWithAssetCriticality = { + '@timestamp': '2021-08-19T16:00:00.000Z', + user: { + name: 'elastic', + risk: { + ...riskScore.user.risk, + criticality_level: 'extreme_impact', + modifiers: [ + { + type: 'asset_criticality', + contribution: 5, + metadata: { + criticality_level: 'extreme_impact', + }, + }, + ], + }, + }, + }; + + mockUseRiskScore.mockReturnValue({ + loading: false, + error: false, + data: [riskScoreWithAssetCriticality], + }); + + const { queryByTestId } = render( + + + + ); + + expect(queryByTestId('risk-input-contexts-title')).toBeInTheDocument(); + }); + + it('it renders alert preview button', () => { + mockUseRiskScore.mockReturnValue({ + loading: false, + error: false, + data: [riskScore], + }); + mockUseRiskContributingAlerts.mockReturnValue({ + loading: false, + error: false, + data: [alertInputDataMock], + }); + + const { getByTestId } = render( + + + + ); + + expect(getByTestId(EXPAND_ALERT_TEST_ID)).toBeInTheDocument(); + }); + + it('Displays 0.00 for the asset criticality contribution if the contribution value is less than -0.01', () => { + mockUseUiSetting.mockReturnValue([true]); + + mockUseRiskScore.mockReturnValue({ + loading: false, + error: false, + data: [riskScoreWithAssetCriticalityContribution(-0.0000001)], + }); + + const { getByTestId } = render( + + + + ); + const contextsTable = getByTestId('risk-input-contexts-table'); + expect(contextsTable).not.toHaveTextContent('-0.00'); + expect(contextsTable).toHaveTextContent('0.00'); + }); + + it('Displays 0.00 for the asset criticality contribution if the contribution value is less than 0.01', () => { + mockUseUiSetting.mockReturnValue([true]); + + mockUseRiskScore.mockReturnValue({ + loading: false, + error: false, + data: [riskScoreWithAssetCriticalityContribution(0.0000001)], + }); + + const { getByTestId } = render( + + + + ); + const contextsTable = getByTestId('risk-input-contexts-table'); + expect(contextsTable).not.toHaveTextContent('+0.00'); + expect(contextsTable).toHaveTextContent('0.00'); + }); + + it('Adds a plus to positive asset criticality contribution scores', () => { + mockUseUiSetting.mockReturnValue([true]); + + mockUseRiskScore.mockReturnValue({ + loading: false, + error: false, + data: [riskScoreWithAssetCriticalityContribution(2.22)], + }); + + const { getByTestId } = render( + + + + ); + + expect(getByTestId('risk-input-contexts-table')).toHaveTextContent('+2.22'); + }); + + it('shows extra alerts contribution message', () => { + const alerts = times( + (number) => ({ + ...alertInputDataMock, + _id: number.toString(), + }), + 11 + ); + + mockUseRiskContributingAlerts.mockReturnValue({ + loading: false, + error: false, + data: alerts, + }); + mockUseRiskScore.mockReturnValue({ + loading: false, + error: false, + data: [riskScore], + }); + + const { queryByTestId } = render( + + + + ); + + expect(queryByTestId('risk-input-extra-alerts-message')).toBeInTheDocument(); + }); + + it('does not show score view toggle without resolution score', () => { + const { queryByTestId } = render( + + + + ); + + expect(queryByTestId('risk-input-score-view-toggle')).not.toBeInTheDocument(); + }); + + it('does not show score view toggle when resolution group has a single member', () => { + const resolutionRiskScore = { + '@timestamp': '2021-08-19T16:00:00.000Z', + user: { + name: 'elastic', + risk: { + ...riskScore.user.risk, + }, + }, + }; + + mockUseResolutionGroup.mockReturnValue({ + data: { + target: { + entity: { id: 'user:elastic', name: 'elastic', attributes: { watchlists: [] } }, + }, + aliases: [], + group_size: 1, + }, + }); + mockUseRiskScore.mockImplementation((params?: { filterQuery?: unknown }) => + isResolutionFilter(params) + ? { + loading: false, + error: false, + data: [resolutionRiskScore], + } + : { + loading: false, + error: false, + data: [riskScore], + } + ); + + const { queryByTestId } = render( + + + + ); + + expect(queryByTestId('risk-input-score-view-toggle')).not.toBeInTheDocument(); + }); + + it('shows score view toggle and switches to resolution contributions', () => { + const resolutionRiskScore = { + '@timestamp': '2021-08-19T16:00:00.000Z', + user: { + name: 'elastic', + risk: { + ...riskScore.user.risk, + category_1_count: 2, + category_1_score: 11, + modifiers: [ + { + type: 'watchlist', + contribution: 1.25, + metadata: { watchlist_id: 'wl-123' }, + }, + ], + inputs: [ + { + ...alertInputDataMock.input, + id: 'resolution-alert-id', + }, + ], + }, + }, + }; + + mockUseResolutionGroup.mockReturnValue({ + data: { + target: { + entity: { id: 'user:elastic', name: 'elastic', attributes: { watchlists: [] } }, + asset: { criticality: 'high_impact' }, + }, + aliases: [ + { + entity: { + id: 'user:entity-1', + name: 'entity-1', + attributes: { watchlists: ['wl-123'] }, + }, + asset: { criticality: 'extreme_impact' }, + }, + ], + group_size: 2, + }, + }); + mockUseRiskScore.mockImplementation((params?: { filterQuery?: unknown }) => + isResolutionFilter(params) + ? { + loading: false, + error: false, + data: [resolutionRiskScore], + } + : { + loading: false, + error: false, + data: [riskScore], + } + ); + + mockUseRiskContributingAlerts.mockReturnValue({ + loading: false, + error: false, + data: [alertInputDataMock], + }); + + const { getByTestId, getByText } = render( + + + + ); + + expect(getByTestId('risk-input-score-view-toggle')).toBeInTheDocument(); + + fireEvent.click(getByText('Resolution group risk score')); + + expect(getByTestId('risk-input-contexts-table')).toHaveTextContent('Entity'); + expect(getByTestId('risk-input-contexts-table')).toHaveTextContent('entity-1'); + expect(getByTestId('risk-input-alert-title').parentElement).toHaveTextContent('Entity'); + expect(getByTestId('risk-input-table-description-cell')).toHaveTextContent('Rule Name'); + }); + + it('shows entity attribution for alerts in resolution view', () => { + const resolutionRiskScore = { + '@timestamp': '2021-08-19T16:00:00.000Z', + user: { + name: 'elastic', + risk: { + ...riskScore.user.risk, + category_1_count: 1, + category_1_score: 10, + inputs: [{ ...alertInputDataMock.input, id: 'resolution-alert-id' }], + }, + }, + }; + + mockUseResolutionGroup.mockReturnValue({ + data: { + target: { + entity: { id: 'user:elastic', name: 'elastic', attributes: { watchlists: [] } }, + }, + aliases: [ + { + entity: { id: 'user:entity-1', name: 'entity-1', attributes: { watchlists: [] } }, + }, + ], + group_size: 2, + }, + }); + mockUseRiskScore.mockImplementation((params?: { filterQuery?: unknown }) => + isResolutionFilter(params) + ? { + loading: false, + error: false, + data: [resolutionRiskScore], + } + : { + loading: false, + error: false, + data: [riskScore], + } + ); + mockUseRiskContributingAlerts.mockReturnValue({ + loading: false, + error: false, + data: [{ ...alertInputDataMock, _id: 'resolution-alert-id' }], + }); + + const { getByText, getByTestId } = render( + + + + ); + + fireEvent.click(getByText('Resolution group risk score')); + + expect(getByTestId('risk-input-alert-title').parentElement).toHaveTextContent('entity-1'); + }); + + it('shows entity attribution for context rows in resolution view', () => { + const resolutionRiskScore = { + '@timestamp': '2021-08-19T16:00:00.000Z', + user: { + name: 'elastic', + risk: { + ...riskScore.user.risk, + modifiers: [ + { + type: 'asset_criticality', + contribution: 4.5, + metadata: { criticality_level: 'high_impact' }, + }, + { + type: 'watchlist', + contribution: 2.5, + metadata: { watchlist_id: 'wl-123' }, + }, + ], + category_1_count: 1, + category_1_score: 10, + inputs: [{ ...alertInputDataMock.input, id: 'resolution-alert-id' }], + }, + }, + }; + + mockUseResolutionGroup.mockReturnValue({ + data: { + target: { + entity: { + id: 'user:elastic', + name: 'elastic', + attributes: { watchlists: ['wl-123'] }, + }, + asset: { criticality: 'high_impact' }, + }, + aliases: [ + { + entity: { + id: 'user:entity-1', + name: 'entity-1', + attributes: { watchlists: ['wl-123'] }, + }, + asset: { criticality: 'medium_impact' }, + }, + ], + group_size: 2, + }, + }); + mockUseRiskScore.mockImplementation((params?: { filterQuery?: unknown }) => + isResolutionFilter(params) + ? { + loading: false, + error: false, + data: [resolutionRiskScore], + } + : { + loading: false, + error: false, + data: [riskScore], + } + ); + + const { getByText, getByTestId } = render( + + + + ); + + fireEvent.click(getByText('Resolution group risk score')); + + expect(getByTestId('risk-input-contexts-table')).toHaveTextContent('Entity'); + expect(getByTestId('risk-input-contexts-table')).toHaveTextContent('elastic'); + expect(getByTestId('risk-input-contexts-table')).toHaveTextContent('entity-1'); + }); + + it('initializes to resolution view when flyout state subTab is "resolution"', () => { + mockUseStableExpandableFlyoutState.mockReturnValue({ + left: { + params: { + path: { + tab: EntityDetailsLeftPanelTab.RISK_INPUTS, + subTab: RiskScoreLeftPanelSubTab.RESOLUTION, + }, + }, + }, + }); + mockUseResolutionGroup.mockReturnValue({ + data: { + target: { + entity: { id: 'user:elastic', name: 'elastic', attributes: { watchlists: [] } }, + }, + aliases: [ + { + entity: { id: 'user:entity-1', name: 'entity-1', attributes: { watchlists: [] } }, + }, + ], + group_size: 2, + }, + }); + mockUseRiskScore.mockImplementation((params?: { filterQuery?: unknown; skip?: boolean }) => + params?.skip + ? { loading: false, error: false, data: [] } + : { loading: false, error: false, data: [riskScore] } + ); + + const { getByRole } = render( + + + + ); + + expect(getByRole('button', { name: 'Resolution group risk score' })).toHaveAttribute( + 'aria-pressed', + 'true' + ); + }); + + it('auto-switches to resolution view when there is no entity risk score but a resolution score exists', () => { + mockUseResolutionGroup.mockReturnValue({ + data: { + target: { + entity: { id: 'user:elastic', name: 'elastic', attributes: { watchlists: [] } }, + }, + aliases: [ + { + entity: { id: 'user:entity-1', name: 'entity-1', attributes: { watchlists: [] } }, + }, + ], + group_size: 2, + }, + }); + mockUseRiskScore.mockImplementation((params?: { filterQuery?: unknown; skip?: boolean }) => + params?.skip + ? { loading: false, error: false, data: [] } + : isResolutionFilter(params) + ? { loading: false, error: false, data: [riskScore] } + : { loading: false, error: false, data: [] } + ); + + const { getByRole } = render( + + + + ); + + expect(getByRole('button', { name: 'Resolution group risk score' })).toHaveAttribute( + 'aria-pressed', + 'true' + ); + }); + + it('switches from entity view to resolution view and back', () => { + mockUseResolutionGroup.mockReturnValue({ + data: { + target: { + entity: { id: 'user:elastic', name: 'elastic', attributes: { watchlists: [] } }, + }, + aliases: [ + { + entity: { id: 'user:entity-1', name: 'entity-1', attributes: { watchlists: [] } }, + }, + ], + group_size: 2, + }, + }); + mockUseRiskScore.mockImplementation((params?: { filterQuery?: unknown; skip?: boolean }) => + params?.skip + ? { loading: false, error: false, data: [] } + : isResolutionFilter(params) + ? { loading: false, error: false, data: [riskScore] } + : { loading: false, error: false, data: [riskScore] } + ); + + const { getByText, getByRole, getByTestId } = render( + + + + ); + + expect(getByTestId('risk-input-score-view-toggle')).toBeInTheDocument(); + expect(getByRole('button', { name: 'Entity risk score' })).toHaveAttribute( + 'aria-pressed', + 'true' + ); + + fireEvent.click(getByRole('button', { name: 'Resolution group risk score' })); + expect(getByRole('button', { name: 'Resolution group risk score' })).toHaveAttribute( + 'aria-pressed', + 'true' + ); + + fireEvent.click(getByText('Entity risk score')); + expect(getByRole('button', { name: 'Entity risk score' })).toHaveAttribute( + 'aria-pressed', + 'true' + ); + }); + + it('content changes when switching between entity and resolution views', () => { + const entityScore = { + '@timestamp': '2021-08-19T16:00:00.000Z', + user: { + name: 'elastic', + risk: { + ...riskScore.user.risk, + modifiers: [ + { type: 'watchlist', contribution: 5, metadata: { watchlist_id: 'wl-entity' } }, + ], + }, + }, + }; + const resolutionScore = { + '@timestamp': '2021-08-19T16:00:00.000Z', + user: { + name: 'elastic', + risk: { + ...riskScore.user.risk, + modifiers: [ + { type: 'watchlist', contribution: 3, metadata: { watchlist_id: 'wl-resolution' } }, + ], + }, + }, + }; + + mockUseResolutionGroup.mockReturnValue({ + data: { + target: { + entity: { id: 'user:elastic', name: 'elastic', attributes: { watchlists: [] } }, + }, + aliases: [ + { + entity: { id: 'user:entity-1', name: 'entity-1', attributes: { watchlists: [] } }, + }, + ], + group_size: 2, + }, + }); + mockUseRiskScore.mockImplementation((params?: { filterQuery?: unknown; skip?: boolean }) => + params?.skip + ? { loading: false, error: false, data: [] } + : isResolutionFilter(params) + ? { loading: false, error: false, data: [resolutionScore] } + : { loading: false, error: false, data: [entityScore] } + ); + + const { getByText, getByTestId } = render( + + + + ); + + expect(getByTestId('risk-input-contexts-table')).toHaveTextContent('wl-entity'); + expect(getByTestId('risk-input-contexts-table')).not.toHaveTextContent('wl-resolution'); + + fireEvent.click(getByText('Resolution group risk score')); + + expect(getByTestId('risk-input-contexts-table')).not.toHaveTextContent('wl-entity'); + expect(getByTestId('risk-input-contexts-table')).toHaveTextContent('wl-resolution'); + }); + + it('user tab selection overrides initial subTab from URL', () => { + mockUseStableExpandableFlyoutState.mockReturnValue({ + left: { + params: { + path: { + tab: EntityDetailsLeftPanelTab.RISK_INPUTS, + subTab: RiskScoreLeftPanelSubTab.RESOLUTION, + }, + }, + }, + }); + mockUseResolutionGroup.mockReturnValue({ + data: { + target: { + entity: { id: 'user:elastic', name: 'elastic', attributes: { watchlists: [] } }, + }, + aliases: [ + { + entity: { id: 'user:entity-1', name: 'entity-1', attributes: { watchlists: [] } }, + }, + ], + group_size: 2, + }, + }); + mockUseRiskScore.mockImplementation((params?: { filterQuery?: unknown; skip?: boolean }) => + params?.skip + ? { loading: false, error: false, data: [] } + : { loading: false, error: false, data: [riskScore] } + ); + + const { getByRole } = render( + + + + ); + + expect(getByRole('button', { name: 'Resolution group risk score' })).toHaveAttribute( + 'aria-pressed', + 'true' + ); + + fireEvent.click(getByRole('button', { name: 'Entity risk score' })); + + expect(getByRole('button', { name: 'Entity risk score' })).toHaveAttribute( + 'aria-pressed', + 'true' + ); + }); + + it('renders watchlist modifiers in context section', () => { + const riskScoreWithWatchlistModifier = { + '@timestamp': '2021-08-19T16:00:00.000Z', + user: { + name: 'elastic', + risk: { + ...riskScore.user.risk, + modifiers: [ + { + type: 'watchlist', + contribution: 8.75, + metadata: { + watchlist_id: 'wl-123', + }, + }, + ], + }, + }, + }; + + mockUseRiskScore.mockReturnValue({ + loading: false, + error: false, + data: [riskScoreWithWatchlistModifier], + }); + + const { getByTestId } = render( + + + + ); + + expect(getByTestId('risk-input-contexts-table')).toHaveTextContent('Watchlist'); + expect(getByTestId('risk-input-contexts-table')).toHaveTextContent('wl-123'); + expect(getByTestId('risk-input-contexts-table')).toHaveTextContent('+8.75'); + }); + + it('renders custom watchlist name from watchlists API data', () => { + mockUseGetWatchlists.mockReturnValue({ + data: [{ id: 'wl-123', name: 'High Risk Vendors', managed: false, riskModifier: 1.5 }], + }); + + const riskScoreWithWatchlistModifier = { + '@timestamp': '2021-08-19T16:00:00.000Z', + user: { + name: 'elastic', + risk: { + ...riskScore.user.risk, + modifiers: [ + { + type: 'watchlist', + contribution: 2.5, + metadata: { + watchlist_id: 'wl-123', + }, + }, + ], + }, + }, + }; + + mockUseRiskScore.mockReturnValue({ + loading: false, + error: false, + data: [riskScoreWithWatchlistModifier], + }); + + const { getByTestId } = render( + + + + ); + + expect(getByTestId('risk-input-contexts-table')).toHaveTextContent('High Risk Vendors'); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/components/flyout_v2/risk_inputs_tab.tsx b/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/components/flyout_v2/risk_inputs_tab.tsx new file mode 100644 index 0000000000000..278600dac4612 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/components/flyout_v2/risk_inputs_tab.tsx @@ -0,0 +1,914 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { useEntityStoreEuidApi } from '@kbn/entity-store/public'; +import type { EuiBasicTableColumn } from '@elastic/eui'; +import { + EuiButtonGroup, + EuiButtonIcon, + EuiCallOut, + EuiFlexGroup, + EuiFlexItem, + EuiInMemoryTable, + EuiSpacer, + EuiTitle, + EuiToolTip, +} from '@elastic/eui'; +import type { FlyoutPanelProps } from '@kbn/expandable-flyout'; +import type { ReactNode } from 'react'; +import React, { useCallback, useMemo, useState } from 'react'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { ALERT_RULE_NAME } from '@kbn/rule-data-utils'; +import { get, noop } from 'lodash/fp'; +import { DOC_VIEWER_FLYOUT_HISTORY_KEY } from '@kbn/unified-doc-viewer'; +import { useHistory } from 'react-router-dom'; +import { useStore } from 'react-redux'; +import { + EntityDetailsLeftPanelTab, + RiskScoreLeftPanelSubTab, +} from '../../../flyout/entity_details/shared/components/left_panel/left_panel_header'; +import type { CriticalityLevel } from '../../../../common/entity_analytics/asset_criticality/types'; +import { getWatchlistName } from '../../../../common/entity_analytics/watchlists/constants'; +import { useGlobalTime } from '../../../common/containers/use_global_time'; +import { useQueryInspector } from '../../../common/components/page/manage_query'; +import { formatRiskScore } from '../../common'; +import type { + InputAlert, + UseRiskContributingAlertsResult, +} from '../../hooks/use_risk_contributing_alerts'; +import { useRiskContributingAlerts } from '../../hooks/use_risk_contributing_alerts'; +import { PreferenceFormattedDate } from '../../../common/components/formatted_date'; + +import { useRiskScore } from '../../api/hooks/use_risk_score'; +import type { RiskScoreState } from '../../api/hooks/use_risk_score'; +import { useGetWatchlists } from '../../api/hooks/use_get_watchlists'; +import type { EntityRiskScore, EntityType } from '../../../../common/search_strategy'; +import type { ESQuery } from '../../../../common/typed_json'; +import { buildEntityNameFilter } from '../../../../common/search_strategy'; +import { AssetCriticalityBadge } from '../asset_criticality'; +import { RiskInputsUtilityBar } from '../entity_details_flyout/components/utility_bar'; +import { ActionColumn } from '../entity_details_flyout/components/action_column'; +import { AiAssistantButton } from '../ai_assistant_button/ai_assistant_button'; +import { useIsExperimentalFeatureEnabled } from '../../../common/hooks/use_experimental_features'; +import { useAgentBuilderAvailability } from '../../../agent_builder/hooks/use_agent_builder_availability'; +import { useResolutionGroup } from '../entity_resolution/hooks/use_resolution_group'; +import { getEntityId, getEntityField, getEntityName } from '../entity_resolution/helpers'; +import { useStableExpandableFlyoutState } from '../../../flyout/shared/hooks/use_stable_expandable_flyout_state'; +import { useKibana } from '../../../common/lib/kibana'; +import { useIsInSecurityApp } from '../../../common/hooks/is_in_security_app'; +import { flyoutProviders } from '../../../flyout_v2/shared/components/flyout_provider'; +import { useDefaultDocumentFlyoutProperties } from '../../../flyout_v2/shared/hooks/use_default_flyout_properties'; +import { documentFlyoutHistoryKey } from '../../../flyout_v2/shared/constants/flyout_history'; +import { DocumentFlyoutWrapper } from '../../../flyout_v2/document/main/document_flyout_wrapper'; +import { cellActionRenderer } from '../../../flyout_v2/shared/components/cell_actions'; +import { RISK_INPUTS_TAB_QUERY_ID } from './constants'; + +export interface RiskInputsTabProps { + entityType: T; + entityName: string; + scopeId: string; + entityId?: string; +} + +const FIRST_RECORD_PAGINATION = { + cursorStart: 0, + querySize: 1, +}; + +export const EXPAND_ALERT_TEST_ID = 'risk-input-alert-preview-button'; + +interface RiskScorePanelProps extends FlyoutPanelProps { + params: { + path: { + tab: EntityDetailsLeftPanelTab.RISK_INPUTS; + subTab?: RiskScoreLeftPanelSubTab; + }; + }; +} + +function isRiskScoreFlyoutPanelProps( + panelLeft: FlyoutPanelProps | undefined +): panelLeft is RiskScorePanelProps { + const params = panelLeft?.params; + if (!params || typeof params !== 'object' || !('path' in params)) { + return false; + } + + const path = params.path as { tab?: unknown; subTab?: unknown }; + if (path?.tab !== EntityDetailsLeftPanelTab.RISK_INPUTS) { + return false; + } + + return ( + path.subTab === undefined || + (typeof path.subTab === 'string' && + Object.values(RiskScoreLeftPanelSubTab).includes(path.subTab as RiskScoreLeftPanelSubTab)) + ); +} + +export const RiskInputsTab = ({ + entityType, + entityName, + scopeId, + entityId, +}: RiskInputsTabProps) => { + const panels = useStableExpandableFlyoutState(); + const subTab = isRiskScoreFlyoutPanelProps(panels.left) + ? panels.left.params.path.subTab + : undefined; + + const { data: watchlists } = useGetWatchlists(); + + const entityFilterQuery = useMemo( + () => + entityId + ? ({ + bool: { + filter: [{ term: { [`${entityType}.risk.id_value`]: entityId } }], + must_not: [{ term: { [`${entityType}.risk.score_type`]: 'resolution' } }], + }, + } as ESQuery) + : buildEntityNameFilter(entityType, [entityName]), + [entityId, entityName, entityType] + ); + + const { + data: riskScoreData, + error: riskScoreError, + loading: loadingRiskScore, + inspect: inspectRiskScore, + refetch, + } = useRiskScore({ + riskEntity: entityType, + filterQuery: entityFilterQuery, + onlyLatest: false, + pagination: FIRST_RECORD_PAGINATION, + skip: entityFilterQuery === undefined, + }); + + const { data: resolutionGroup } = useResolutionGroup(entityId ?? '', { + enabled: Boolean(entityId), + }); + const hasRealResolutionGroup = (resolutionGroup?.group_size ?? 0) > 1; + const resolutionTargetEntityId = useMemo( + () => (resolutionGroup?.target ? getEntityId(resolutionGroup.target) : undefined), + [resolutionGroup?.target] + ); + const shouldFetchResolutionRiskScore = + hasRealResolutionGroup && Boolean(resolutionTargetEntityId); + const resolutionFilterQuery = useMemo( + () => + shouldFetchResolutionRiskScore && resolutionTargetEntityId + ? ({ + bool: { + filter: [ + { term: { [`${entityType}.risk.id_value`]: resolutionTargetEntityId } }, + { term: { [`${entityType}.risk.score_type`]: 'resolution' } }, + ], + }, + } as ESQuery) + : undefined, + [entityType, resolutionTargetEntityId, shouldFetchResolutionRiskScore] + ); + const { + data: resolutionRiskScoreData, + loading: loadingResolutionRiskScore, + inspect: inspectResolutionRiskScore, + refetch: refetchResolutionRiskScore, + } = useRiskScore({ + riskEntity: entityType, + filterQuery: resolutionFilterQuery, + onlyLatest: false, + pagination: FIRST_RECORD_PAGINATION, + skip: !shouldFetchResolutionRiskScore, + }); + + const entityRiskScore = riskScoreData && riskScoreData.length > 0 ? riskScoreData[0] : undefined; + const resolutionRiskScore = + resolutionRiskScoreData && resolutionRiskScoreData.length > 0 + ? resolutionRiskScoreData[0] + : undefined; + const hasResolutionScore = hasRealResolutionGroup && Boolean(resolutionRiskScore); + + const watchlistNamesById = useMemo(() => { + const map = new Map(); + (watchlists ?? []).forEach((watchlist) => { + if (watchlist.id) { + map.set(watchlist.id, watchlist.name); + } + }); + return map; + }, [watchlists]); + + if (riskScoreError) { + return ( + + } + color="danger" + iconType="error" + > +

+ +

+
+ ); + } + + return ( + + ); +}; + +RiskInputsTab.displayName = 'RiskInputsTab'; + +interface RiskInputsTabContentProps { + subTab?: RiskScoreLeftPanelSubTab; + entityType: T; + entityName: string; + scopeId: string; + entityRiskScore: EntityRiskScore | undefined; + resolutionRiskScore: EntityRiskScore | undefined; + hasResolutionScore: boolean; + loadingRiskScore: boolean; + loadingResolutionRiskScore: boolean; + inspectRiskScore: RiskScoreState['inspect']; + inspectResolutionRiskScore: RiskScoreState['inspect']; + refetch: RiskScoreState['refetch']; + refetchResolutionRiskScore: RiskScoreState['refetch']; + resolutionGroup: ReturnType['data']; + watchlistNamesById: Map; +} + +const RiskInputsTabContent = ({ + subTab, + entityType, + entityName, + scopeId, + entityRiskScore, + resolutionRiskScore, + hasResolutionScore, + loadingRiskScore, + loadingResolutionRiskScore, + inspectRiskScore, + inspectResolutionRiskScore, + refetch, + refetchResolutionRiskScore, + resolutionGroup, + watchlistNamesById, +}: RiskInputsTabContentProps) => { + const { setQuery, deleteQuery } = useGlobalTime(); + const euidApi = useEntityStoreEuidApi(); + const [selectedItems, setSelectedItems] = useState([]); + const [userSelectedView, setUserSelectedView] = useState(subTab); + const isAssistantToolDisabled = useIsExperimentalFeatureEnabled('riskScoreAssistantToolDisabled'); + const { isAgentBuilderEnabled } = useAgentBuilderAvailability(); + const showAiAssistantButton = !isAssistantToolDisabled || isAgentBuilderEnabled; + + const { services } = useKibana(); + const store = useStore(); + const history = useHistory(); + const defaultDocumentFlyoutProperties = useDefaultDocumentFlyoutProperties(); + const isInSecurityApp = useIsInSecurityApp(); + const historyKey = isInSecurityApp ? documentFlyoutHistoryKey : DOC_VIEWER_FLYOUT_HISTORY_KEY; + + const defaultView = + !loadingRiskScore && !entityRiskScore && hasResolutionScore + ? RiskScoreLeftPanelSubTab.RESOLUTION + : RiskScoreLeftPanelSubTab.ENTITY; + const selectedView = userSelectedView ?? defaultView; + + const openAlertPreview = useCallback( + (id: string, indexName: string) => { + services.overlays.openSystemFlyout( + flyoutProviders({ + services, + store, + history, + children: ( + + ), + }), + { + ...defaultDocumentFlyoutProperties, + historyKey, + session: 'inherit', + } + ); + }, + [services, store, history, defaultDocumentFlyoutProperties, historyKey] + ); + + const isResolutionView = + selectedView === RiskScoreLeftPanelSubTab.RESOLUTION && hasResolutionScore; + const activeRiskScore = isResolutionView ? resolutionRiskScore : entityRiskScore; + const activeInspectRiskScore = isResolutionView ? inspectResolutionRiskScore : inspectRiskScore; + const activeRiskScoreLoading = isResolutionView ? loadingResolutionRiskScore : loadingRiskScore; + const activeRiskScoreRefetch = isResolutionView ? refetchResolutionRiskScore : refetch; + + useQueryInspector({ + deleteQuery, + inspect: activeInspectRiskScore, + loading: activeRiskScoreLoading, + queryId: RISK_INPUTS_TAB_QUERY_ID, + refetch: activeRiskScoreRefetch, + setQuery, + }); + + const alerts = useRiskContributingAlerts({ riskScore: activeRiskScore, entityType }); + + const entityNameByEuid = useMemo(() => { + const map = new Map(); + if (!resolutionGroup) return map; + [resolutionGroup.target, ...resolutionGroup.aliases].forEach((entity) => { + const entityIdValue = getEntityId(entity); + if (entityIdValue) { + map.set(entityIdValue, getEntityName(entity) || entityIdValue); + } + }); + return map; + }, [resolutionGroup]); + + const alertEntityById = useMemo(() => { + const map = new Map(); + if (!isResolutionView || !euidApi || !alerts.data) return map; + alerts.data.forEach((alert) => { + const sourceEntityId = + alert.input.entity_id ?? euidApi.euid.getEuidFromObject(entityType, alert.rawSource); + if (sourceEntityId) { + map.set(alert._id, entityNameByEuid.get(sourceEntityId) ?? sourceEntityId); + } + }); + return map; + }, [alerts.data, entityNameByEuid, entityType, euidApi, isResolutionView]); + + const euiTableSelectionProps = useMemo( + () => ({ + initialSelected: [], + selectable: () => true, + onSelectionChange: setSelectedItems, + }), + [] + ); + + const inputColumns: Array> = useMemo(() => { + const columns: Array> = [ + { + render: (data: InputAlert) => ( + + openAlertPreview(data._id, data.input.index)} + aria-label={i18n.translate( + 'xpack.securitySolution.flyout.right.alertPreview.ariaLabel', + { + defaultMessage: 'Preview alert with id {id}', + values: { id: data._id }, + } + )} + /> + + ), + width: '5%', + }, + { + name: ( + + ), + width: '80px', + render: (data: InputAlert) => , + }, + { + field: 'input.timestamp', + name: ( + + ), + truncateText: false, + mobileOptions: { show: true }, + sortable: true, + width: isResolutionView ? '20%' : '30%', + render: (timestamp: string) => , + }, + { + field: 'alert', + 'data-test-subj': 'risk-input-table-description-cell', + name: ( + + ), + truncateText: true, + mobileOptions: { show: true }, + sortable: true, + render: (alert: InputAlert['alert']) => get(ALERT_RULE_NAME, alert), + }, + { + field: 'input.contribution_score', + 'data-test-subj': 'risk-input-table-contribution-cell', + name: ( + + ), + truncateText: false, + mobileOptions: { show: true }, + sortable: true, + align: 'right', + render: formatContribution, + }, + ]; + + if (isResolutionView) { + columns.splice(4, 0, { + name: ( + + ), + width: '25%', + render: (data: InputAlert) => alertEntityById.get(data._id) ?? '-', + }); + } + + return columns; + }, [alertEntityById, isResolutionView, openAlertPreview]); + + const riskInputsAlertSection = ( + <> + +

+ +

+
+ + + + + riskScore={activeRiskScore} alerts={alerts} entityType={entityType} /> + + ); + + return ( + <> + {hasResolutionScore && ( + <> + setUserSelectedView(id as RiskScoreLeftPanelSubTab)} + data-test-subj="risk-input-score-view-toggle" + /> + + + )} + + loading={activeRiskScoreLoading} + riskScore={activeRiskScore} + entityType={entityType} + isResolutionView={isResolutionView} + resolutionGroup={resolutionGroup} + watchlistNamesById={watchlistNamesById} + /> + + {riskInputsAlertSection} + {showAiAssistantButton && ( + <> + + + + + + + + )} + + ); +}; + +interface ContextsSectionProps { + riskScore?: EntityRiskScore; + entityType: T; + loading: boolean; + isResolutionView: boolean; + resolutionGroup?: { + target: Record; + aliases: Array>; + }; + watchlistNamesById: Map; +} + +const ContextsSection = ({ + riskScore, + loading, + entityType, + isResolutionView, + resolutionGroup, + watchlistNamesById, +}: ContextsSectionProps) => { + const memberEntities = useMemo( + () => (resolutionGroup ? [resolutionGroup.target, ...resolutionGroup.aliases] : []), + [resolutionGroup] + ); + const watchlistEntityNames = useMemo(() => { + const map = new Map(); + + if (!isResolutionView) { + return map; + } + + memberEntities.forEach((member) => { + const entityName = getEntityName(member) || getEntityId(member) || '-'; + const watchlistsValue = getEntityField(member, 'entity.attributes.watchlists'); + if (!Array.isArray(watchlistsValue)) { + return; + } + + watchlistsValue.forEach((watchlistId) => { + if (typeof watchlistId !== 'string') { + return; + } + + const matchingEntities = map.get(watchlistId) ?? []; + if (!matchingEntities.includes(entityName)) { + matchingEntities.push(entityName); + } + map.set(watchlistId, matchingEntities); + }); + }); + + return map; + }, [isResolutionView, memberEntities]); + const criticalityEntityNames = useMemo(() => { + const map = new Map(); + + if (!isResolutionView) { + return map; + } + + memberEntities.forEach((member) => { + const entityName = getEntityName(member) || getEntityId(member) || '-'; + const criticalityLevel = getEntityField(member, 'asset.criticality'); + + if (typeof criticalityLevel !== 'string') { + return; + } + + const matchingEntities = map.get(criticalityLevel) ?? []; + if (!matchingEntities.includes(entityName)) { + matchingEntities.push(entityName); + } + map.set(criticalityLevel, matchingEntities); + }); + + return map; + }, [isResolutionView, memberEntities]); + const contributions = useMemo(() => { + if (!riskScore) { + return undefined; + } + + const modifiers = riskScore[entityType].risk.modifiers ?? []; + const criticality = riskScore[entityType].risk.modifiers?.find( + (mod) => mod.type === 'asset_criticality' + ); + const watchlists = modifiers.filter((mod) => mod.type === 'watchlist'); + + if (!criticality && watchlists.length === 0) { + return undefined; + } + + return { + criticality: { + level: (criticality?.metadata?.criticality_level as CriticalityLevel) ?? null, + contribution: criticality?.contribution, + }, + watchlists, + }; + }, [entityType, riskScore]); + + if (contributions === undefined) { + return null; + } + const { criticality, watchlists } = contributions; + + const items: ContextRow[] = []; + + if (criticality.level != null && criticality.contribution != null) { + const relatedEntities = isResolutionView + ? criticalityEntityNames.get(criticality.level)?.join(', ') ?? '-' + : ''; + items.push({ + field: ( + + ), + value: ( + + ), + contribution: formatContribution(criticality.contribution), + entities: relatedEntities, + }); + } + + watchlists.forEach((watchlist) => { + const watchlistMetadata = watchlist.metadata as + | { + watchlist_id?: string; + is_privileged_user?: boolean; + } + | undefined; + const watchlistId = + typeof watchlistMetadata?.watchlist_id === 'string' ? watchlistMetadata.watchlist_id : ''; + const watchlistLabel = watchlistId + ? watchlistNamesById.get(watchlistId) ?? getWatchlistName(watchlistId) + : i18n.translate( + 'xpack.securitySolution.flyout.entityDetails.riskInputs.unknownWatchlistLabel', + { + defaultMessage: 'Unknown watchlist', + } + ); + + items.push({ + field: ( + + ), + value: ( + + ), + contribution: formatContribution(watchlist.contribution), + entities: isResolutionView ? watchlistEntityNames.get(watchlistId)?.join(', ') ?? '-' : '', + }); + }); + + if (items.length === 0) { + return null; + } + + return ( + <> + +

+ +

+
+ + + + ); +}; + +interface ContextRow { + field: ReactNode; + value: ReactNode; + contribution: string; + entities: string; +} + +const getContextColumns = (isResolutionView: boolean): Array> => { + const columns: Array> = [ + { + field: 'field', + name: ( + + ), + width: '25%', + render: (field: ContextRow['field']) => field, + }, + { + field: 'value', + name: ( + + ), + width: isResolutionView ? '30%' : '35%', + render: (val: ContextRow['value']) => val, + }, + ]; + + if (isResolutionView) { + columns.push({ + field: 'entities', + name: ( + + ), + width: '25%', + render: (entities: ContextRow['entities']) => entities || '-', + }); + } + + columns.push({ + field: 'contribution', + width: isResolutionView ? '20%' : '40%', + align: 'right', + name: ( + + ), + render: (score: ContextRow['contribution']) => score, + }); + + return columns; +}; + +interface ExtraAlertsMessageProps { + riskScore?: EntityRiskScore; + alerts: UseRiskContributingAlertsResult; + entityType: T; +} + +const ExtraAlertsMessage = ({ + riskScore, + alerts, + entityType, +}: ExtraAlertsMessageProps) => { + const totals = !riskScore + ? { count: 0, score: 0 } + : { + count: riskScore[entityType].risk.category_1_count, + score: riskScore[entityType].risk.category_1_score, + }; + + const displayed = { + count: alerts.data?.length || 0, + score: alerts.data?.reduce((sum, { input }) => sum + (input.contribution_score || 0), 0) || 0, + }; + + if (displayed.count >= totals.count) { + return null; + } + return ( + + } + iconType="annotation" + /> + ); +}; + +const formatContribution = (value: number): string => { + const fixedValue = formatRiskScore(value); + + // prevent +0.00 for values like 0.0001 + if (fixedValue === '0.00') { + return fixedValue; + } + + if (value > 0) { + return `+${fixedValue}`; + } + + return fixedValue; +}; diff --git a/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/components/flyout_v2/risk_summary.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/components/flyout_v2/risk_summary.test.tsx new file mode 100644 index 0000000000000..b5383fe7c34cb --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/components/flyout_v2/risk_summary.test.tsx @@ -0,0 +1,518 @@ +/* + * 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 { + mockHostRiskScoreState, + mockUserRiskScoreState, +} from '../../../flyout/entity_details/mocks'; +import { fireEvent, render } from '@testing-library/react'; +import React from 'react'; +import { TestProviders } from '../../../common/mock'; +import { FlyoutRiskSummary } from './risk_summary'; +import type { + LensAttributes, + VisualizationEmbeddableProps, +} from '../../../common/components/visualization_actions/types'; +import type { Query } from '@kbn/es-query'; +import { EntityType } from '../../../../common/search_strategy'; +import type { RiskScoreState } from '../../api/hooks/use_risk_score'; +import { + EntityDetailsLeftPanelTab, + RiskScoreLeftPanelSubTab, +} from '../../../flyout/entity_details/shared/components/left_panel/left_panel_header'; + +const mockVisualizationEmbeddable = jest + .fn() + .mockReturnValue(
); +const mockUseRiskScore = jest.fn(); +const mockUseResolutionGroup = jest.fn(); + +jest.mock('../../../common/components/visualization_actions/visualization_embeddable', () => ({ + VisualizationEmbeddable: (props: VisualizationEmbeddableProps) => + mockVisualizationEmbeddable(props), +})); + +jest.mock('../../api/hooks/use_risk_score', () => { + const actual = jest.requireActual('../../api/hooks/use_risk_score'); + return { + ...actual, + useRiskScore: (params: unknown) => mockUseRiskScore(params), + }; +}); + +jest.mock('../entity_resolution/hooks/use_resolution_group', () => ({ + useResolutionGroup: (entityId: string) => mockUseResolutionGroup(entityId), +})); + +describe('FlyoutRiskSummary (v2)', () => { + beforeEach(() => { + mockVisualizationEmbeddable.mockClear(); + mockUseResolutionGroup.mockReturnValue({ + data: undefined, + isLoading: false, + isFetching: false, + isError: false, + }); + mockUseRiskScore.mockReturnValue({ + ...(mockHostRiskScoreState as RiskScoreState), + data: undefined, + }); + }); + + it('renders risk summary table with context and totals', () => { + const { getByTestId, queryByTestId } = render( + + {}} + recalculatingScore={false} + entityType={EntityType.host} + /> + + ); + + expect(getByTestId('risk-summary-table')).toBeInTheDocument(); + + // Alerts + expect(getByTestId('risk-summary-table')).toHaveTextContent( + `${mockHostRiskScoreState.data?.[0].host.risk.category_1_count}` + ); + + // Result + expect(getByTestId('risk-summary-result-count')).toHaveTextContent( + `${mockHostRiskScoreState.data?.[0].host.risk.category_1_count}` + ); + + expect(getByTestId('risk-summary-result-score')).toHaveTextContent( + `${ + (mockHostRiskScoreState.data?.[0].host.risk.category_1_score ?? 0) + + (mockHostRiskScoreState.data?.[0].host.risk.category_2_score ?? 0) + }` + ); + + expect(getByTestId('entityRiskInputsTitleLink')).toBeInTheDocument(); + // v2: icon is always hidden (no isPreviewMode prop) + expect(queryByTestId('entityRiskInputsTitleIcon')).not.toBeInTheDocument(); + }); + + it('renders risk summary table when riskScoreData is empty', () => { + const { getByTestId } = render( + + {}} + recalculatingScore={false} + entityType={EntityType.host} + /> + + ); + expect(getByTestId('risk-summary-table')).toBeInTheDocument(); + }); + + it('risk summary header does not render link when riskScoreData is loading', () => { + const { queryByTestId } = render( + + {}} + recalculatingScore={false} + entityType={EntityType.host} + /> + + ); + + expect(queryByTestId('entityRiskInputsTitleLink')).not.toBeInTheDocument(); + }); + + it('renders visualization embeddable', () => { + const { getByTestId } = render( + + {}} + recalculatingScore={false} + entityType={EntityType.host} + /> + + ); + + expect(getByTestId('visualization-embeddable')).toBeInTheDocument(); + }); + + it('renders updated at', () => { + const { getByTestId } = render( + + {}} + recalculatingScore={false} + entityType={EntityType.host} + /> + + ); + + expect(getByTestId('risk-summary-updatedAt')).toHaveTextContent('Updated Nov 8, 1989'); + }); + + it('builds lens attributes for host risk score', () => { + render( + + {}} + recalculatingScore={false} + entityType={EntityType.host} + /> + + ); + + const lensAttributes: LensAttributes = + mockVisualizationEmbeddable.mock.calls[0][0].lensAttributes; + const datasourceLayers = Object.values( + lensAttributes.state.datasourceStates.formBased?.layers ?? {} + ); + const firstColumn = Object.values(datasourceLayers[0].columns)[0]; + + expect((lensAttributes.state.query as Query).query).toEqual( + 'host.name: "test" AND NOT host.risk.score_type: "resolution"' + ); + expect(firstColumn).toEqual( + expect.objectContaining({ + sourceField: 'host.risk.calculated_score_norm', + }) + ); + }); + + it('builds lens cases attachment metadata for host risk score', () => { + render( + + {}} + recalculatingScore={false} + entityType={EntityType.host} + /> + + ); + + const lensMetadata: LensAttributes = + mockVisualizationEmbeddable.mock.calls[0][0].casesAttachmentMetadata; + + expect(lensMetadata).toMatchInlineSnapshot(` + Object { + "description": "Risk score for host test", + } + `); + }); + + it('builds lens cases attachment metadata for user risk score', () => { + render( + + {}} + recalculatingScore={false} + entityType={EntityType.user} + /> + + ); + + const lensMetadata: LensAttributes = + mockVisualizationEmbeddable.mock.calls[0][0].casesAttachmentMetadata; + + expect(lensMetadata).toMatchInlineSnapshot(` + Object { + "description": "Risk score for user test", + } + `); + }); + + it('builds lens attributes for user risk score', () => { + render( + + {}} + recalculatingScore={false} + entityType={EntityType.user} + /> + + ); + + const lensAttributes: LensAttributes = + mockVisualizationEmbeddable.mock.calls[0][0].lensAttributes; + const datasourceLayers = Object.values( + lensAttributes.state.datasourceStates.formBased?.layers ?? {} + ); + const firstColumn = Object.values(datasourceLayers[0].columns)[0]; + + expect((lensAttributes.state.query as Query).query).toEqual( + 'user.name: "test" AND NOT user.risk.score_type: "resolution"' + ); + expect(firstColumn).toEqual( + expect.objectContaining({ + sourceField: 'user.risk.calculated_score_norm', + }) + ); + }); + + it('entity risk inputs link calls openDetailsPanel with entity sub-tab', () => { + const openDetailsPanel = jest.fn(); + const { getByTestId } = render( + + + + ); + + fireEvent.click(getByTestId('entityRiskInputsTitleLink')); + + expect(openDetailsPanel).toHaveBeenCalledWith({ + tab: EntityDetailsLeftPanelTab.RISK_INPUTS, + subTab: RiskScoreLeftPanelSubTab.ENTITY, + }); + }); + + it('resolution risk inputs link calls openDetailsPanel with resolution sub-tab', () => { + const openDetailsPanel = jest.fn(); + mockUseResolutionGroup.mockReturnValue({ + data: { + target: { entity: { id: 'host:target-entity' } }, + aliases: [], + group_size: 2, + }, + isLoading: false, + isFetching: false, + isError: false, + }); + mockUseRiskScore.mockReturnValue({ + ...(mockHostRiskScoreState as RiskScoreState), + data: mockHostRiskScoreState.data, + loading: false, + }); + + const { getByTestId } = render( + + + + ); + + fireEvent.click(getByTestId('resolutionRiskInputsTitleLink')); + + expect(openDetailsPanel).toHaveBeenCalledWith({ + tab: EntityDetailsLeftPanelTab.RISK_INPUTS, + subTab: RiskScoreLeftPanelSubTab.RESOLUTION, + }); + }); + + it('does not render resolution risk inputs link when resolution score is loading', () => { + mockUseResolutionGroup.mockReturnValue({ + data: { + target: { entity: { id: 'host:target-entity' } }, + aliases: [], + group_size: 2, + }, + isLoading: false, + isFetching: false, + isError: false, + }); + mockUseRiskScore.mockReturnValue({ + ...(mockHostRiskScoreState as RiskScoreState), + data: mockHostRiskScoreState.data, + loading: true, + }); + + const { queryByTestId } = render( + + {}} + recalculatingScore={false} + entityType={EntityType.host} + entityId="host:alias-entity" + /> + + ); + + expect(queryByTestId('resolutionRiskInputsTitleLink')).not.toBeInTheDocument(); + }); + + it('renders resolution risk score block when resolution score exists', () => { + mockUseResolutionGroup.mockReturnValue({ + data: { + target: { + entity: { + id: 'host:target-entity', + }, + }, + aliases: [], + group_size: 2, + }, + isLoading: false, + isFetching: false, + isError: false, + }); + mockUseRiskScore.mockReturnValue({ + ...(mockHostRiskScoreState as RiskScoreState), + data: mockHostRiskScoreState.data, + loading: false, + }); + + const { getByTestId, getAllByTestId } = render( + + {}} + recalculatingScore={false} + entityType={EntityType.host} + entityId="host:alias-entity" + /> + + ); + + expect(getByTestId('resolution-risk-summary-table')).toBeInTheDocument(); + expect(getAllByTestId('visualization-embeddable')).toHaveLength(2); + expect(mockUseRiskScore).toHaveBeenCalledWith( + expect.objectContaining({ + filterQuery: expect.objectContaining({ + bool: expect.objectContaining({ + filter: expect.arrayContaining([ + expect.objectContaining({ + term: expect.objectContaining({ + 'host.risk.id_value': 'host:target-entity', + }), + }), + expect.objectContaining({ + term: expect.objectContaining({ + 'host.risk.score_type': 'resolution', + }), + }), + ]), + }), + }), + }) + ); + }); + + it('falls back to prefetchedResolutionRisk when the inner risk-index lookup returns no data', () => { + mockUseResolutionGroup.mockReturnValue({ + data: { + target: { entity: { id: 'host:target-entity' } }, + aliases: [], + group_size: 2, + }, + isLoading: false, + isFetching: false, + isError: false, + }); + const prefetchedResolutionRisk = mockHostRiskScoreState.data?.[0]; + + const { getByTestId } = render( + + {}} + recalculatingScore={false} + entityType={EntityType.host} + entityId="host:alias-entity" + prefetchedResolutionRisk={prefetchedResolutionRisk} + /> + + ); + + expect(getByTestId('resolution-risk-summary-table')).toBeInTheDocument(); + }); + + it('does not render resolution risk block when neither the lookup nor prefetchedResolutionRisk has data', () => { + mockUseResolutionGroup.mockReturnValue({ + data: { + target: { entity: { id: 'host:target-entity' } }, + aliases: [], + group_size: 2, + }, + isLoading: false, + isFetching: false, + isError: false, + }); + + const { queryByTestId } = render( + + {}} + recalculatingScore={false} + entityType={EntityType.host} + entityId="host:alias-entity" + /> + + ); + + expect(queryByTestId('resolution-risk-summary-table')).not.toBeInTheDocument(); + }); + + it('does not render resolution risk score block for standalone entities', () => { + mockUseResolutionGroup.mockReturnValue({ + data: { + target: { + entity: { + id: 'host:target-entity', + }, + }, + aliases: [], + group_size: 1, + }, + isLoading: false, + isFetching: false, + isError: false, + }); + mockUseRiskScore.mockReturnValue({ + ...(mockHostRiskScoreState as RiskScoreState), + data: mockHostRiskScoreState.data, + loading: false, + }); + + const { queryByTestId } = render( + + {}} + recalculatingScore={false} + entityType={EntityType.host} + entityId="host:alias-entity" + /> + + ); + + expect(queryByTestId('resolution-risk-summary-table')).not.toBeInTheDocument(); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/components/flyout_v2/risk_summary.tsx b/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/components/flyout_v2/risk_summary.tsx new file mode 100644 index 0000000000000..a5da26311c30b --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/components/flyout_v2/risk_summary.tsx @@ -0,0 +1,530 @@ +/* + * 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, useMemo } from 'react'; +import { + EuiAccordion, + EuiBasicTable, + EuiFlexGroup, + EuiFlexItem, + EuiSpacer, + EuiTitle, + useEuiFontSize, + useEuiTheme, +} from '@elastic/eui'; +import { css } from '@emotion/react'; +import { FormattedMessage } from '@kbn/i18n-react'; +import dateMath from '@kbn/datemath'; +import { i18n } from '@kbn/i18n'; +import { capitalize } from 'lodash/fp'; +import { useIsExperimentalFeatureEnabled } from '../../../common/hooks/use_experimental_features'; +import type { EntityType } from '../../../../common/entity_analytics/types'; +import { EntityTypeToIdentifierField } from '../../../../common/entity_analytics/types'; +import { useKibana } from '../../../common/lib/kibana/kibana_react'; +import type { EntityDetailsPath } from '../../../flyout/entity_details/shared/components/left_panel/left_panel_header'; +import { + EntityDetailsLeftPanelTab, + RiskScoreLeftPanelSubTab, +} from '../../../flyout/entity_details/shared/components/left_panel/left_panel_header'; +import { InspectButton, InspectButtonContainer } from '../../../common/components/inspect'; +import { ONE_WEEK_IN_HOURS } from '../../../flyout/entity_details/shared/constants'; +import { FormattedRelativePreferenceDate } from '../../../common/components/formatted_date'; +import { VisualizationEmbeddable } from '../../../common/components/visualization_actions/visualization_embeddable'; +import { ExpandablePanel } from '../../../flyout_v2/shared/components/expandable_panel'; +import type { RiskScoreState } from '../../api/hooks/use_risk_score'; +import { useRiskScore } from '../../api/hooks/use_risk_score'; +import type { EntityRiskScore } from '../../../../common/search_strategy'; +import { getRiskScoreSummaryAttributes } from '../../lens_attributes/risk_score_summary'; +import { useSpaceId } from '../../../common/hooks/use_space_id'; +import { useResolutionGroup } from '../entity_resolution/hooks/use_resolution_group'; +import { getEntityId } from '../entity_resolution/helpers'; + +import { + columnsArray, + getEntityData, + getItems, + LAST_30_DAYS, + LENS_VISUALIZATION_HEIGHT, + LENS_VISUALIZATION_MIN_WIDTH, + SUMMARY_TABLE_MIN_WIDTH, +} from '../risk_summary_flyout/common'; +import { EntityEventTypes } from '../../../common/lib/telemetry'; + +const FIRST_RECORD_PAGINATION = { + cursorStart: 0, + querySize: 1, +}; + +export interface RiskSummaryProps { + riskScoreData: RiskScoreState; + entityType: T; + recalculatingScore: boolean; + queryId: string; + openDetailsPanel: (path: EntityDetailsPath) => void; + entityId?: string; + /** Optional prefetched resolution-group risk; used when the internal risk-index lookup returns no doc. */ + prefetchedResolutionRisk?: EntityRiskScore; +} + +const FlyoutRiskSummaryComponent = ({ + riskScoreData, + entityType, + entityId, + recalculatingScore, + queryId, + openDetailsPanel, + prefetchedResolutionRisk, +}: RiskSummaryProps) => { + const { telemetry } = useKibana().services; + const { data } = riskScoreData; + const fallbackRiskData = data && data.length > 0 ? data[0] : undefined; + const { euiTheme } = useEuiTheme(); + const spaceId = useSpaceId(); + const entityRiskFilterQueryDsl = useMemo( + () => + entityId + ? { + bool: { + filter: [{ term: { [`${entityType}.risk.id_value`]: entityId } }], + must_not: [{ term: { [`${entityType}.risk.score_type`]: 'resolution' } }], + }, + } + : undefined, + [entityId, entityType] + ); + const nonResolutionRiskScoreData = useRiskScore({ + riskEntity: entityType, + filterQuery: entityRiskFilterQueryDsl, + onlyLatest: false, + pagination: FIRST_RECORD_PAGINATION, + skip: !entityId, + }); + const nonResolutionRiskData = + nonResolutionRiskScoreData.data && nonResolutionRiskScoreData.data.length > 0 + ? nonResolutionRiskScoreData.data[0] + : undefined; + const riskData = nonResolutionRiskData ?? fallbackRiskData; + const entityData = getEntityData(entityType, riskData); + const lensAttributes = useMemo(() => { + const entityName = entityData?.name ?? ''; + const query = entityId + ? `${entityType}.risk.id_value: "${entityId}" AND NOT ${entityType}.risk.score_type: "resolution"` + : `${EntityTypeToIdentifierField[entityType]}: "${entityName}" AND NOT ${entityType}.risk.score_type: "resolution"`; + + return getRiskScoreSummaryAttributes({ + severity: entityData?.risk?.calculated_level, + query, + spaceId, + riskEntity: entityType, + dataSource: 'risk_index', + metricLabel: i18n.translate( + 'xpack.securitySolution.flyout.entityDetails.riskSummary.entityRiskScoreLabel', + { + defaultMessage: 'Entity risk score', + } + ), + }); + }, [entityData?.name, entityData?.risk?.calculated_level, entityType, entityId, spaceId]); + + const xsFontSize = useEuiFontSize('xxs').fontSize; + const isPrivmonModifierEnabled = useIsExperimentalFeatureEnabled( + 'enableRiskScorePrivmonModifier' + ); + const isWatchlistEnabled = useIsExperimentalFeatureEnabled('entityAnalyticsWatchlistEnabled'); + const rows = useMemo( + () => getItems(entityData, isPrivmonModifierEnabled, isWatchlistEnabled), + [entityData, isPrivmonModifierEnabled, isWatchlistEnabled] + ); + + const onToggle = useCallback( + (isOpen: boolean) => { + telemetry.reportEvent(EntityEventTypes.ToggleRiskSummaryClicked, { + entity: entityType, + action: isOpen ? 'show' : 'hide', + }); + }, + [entityType, telemetry] + ); + + const casesAttachmentMetadata = useMemo( + () => ({ + description: i18n.translate( + 'xpack.securitySolution.flyout.entityDetails.riskSummary.casesAttachmentLabel', + { + defaultMessage: + 'Risk score for {entityType, select, user {user} other {host}} {entityName}', + values: { + entityName: entityData?.name, + entityType, + }, + } + ), + }), + [entityData?.name, entityType] + ); + + const riskDataTimestamp = riskData?.['@timestamp']; + const timerange = useMemo(() => { + const from = dateMath.parse(LAST_30_DAYS.from)?.toISOString() ?? LAST_30_DAYS.from; + const to = dateMath.parse(LAST_30_DAYS.to)?.toISOString() ?? LAST_30_DAYS.to; + return { from, to }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [riskDataTimestamp]); // Update the timerange whenever the risk score timestamp changes to include new entries + + const goToEntityInsightsTab = useCallback( + (subTab?: RiskScoreLeftPanelSubTab) => + openDetailsPanel({ tab: EntityDetailsLeftPanelTab.RISK_INPUTS, subTab }), + [openDetailsPanel] + ); + + const entityTabLink = useMemo( + () => ({ + callback: () => goToEntityInsightsTab(RiskScoreLeftPanelSubTab.ENTITY), + tooltip: ( + + ), + }), + [goToEntityInsightsTab] + ); + + const resolutionTabLink = useMemo( + () => ({ + callback: () => goToEntityInsightsTab(RiskScoreLeftPanelSubTab.RESOLUTION), + tooltip: ( + + ), + }), + [goToEntityInsightsTab] + ); + + const { data: resolutionGroup } = useResolutionGroup(entityId ?? '', { + enabled: Boolean(entityId), + }); + const hasRealResolutionGroup = (resolutionGroup?.group_size ?? 0) > 1; + const resolutionTargetEntityId = useMemo( + () => (resolutionGroup?.target ? getEntityId(resolutionGroup.target) : undefined), + [resolutionGroup?.target] + ); + const shouldFetchResolutionRiskScore = + hasRealResolutionGroup && Boolean(resolutionTargetEntityId); + const resolutionRiskFilterQueryDsl = useMemo( + () => + shouldFetchResolutionRiskScore && resolutionTargetEntityId + ? { + bool: { + filter: [ + { term: { [`${entityType}.risk.id_value`]: resolutionTargetEntityId } }, + { term: { [`${entityType}.risk.score_type`]: 'resolution' } }, + ], + }, + } + : undefined, + [entityType, resolutionTargetEntityId, shouldFetchResolutionRiskScore] + ); + const resolutionRiskScoreData = useRiskScore({ + riskEntity: entityType, + filterQuery: resolutionRiskFilterQueryDsl, + onlyLatest: false, + pagination: FIRST_RECORD_PAGINATION, + skip: !shouldFetchResolutionRiskScore, + }); + const resolutionRiskData = + (resolutionRiskScoreData.data && resolutionRiskScoreData.data.length > 0 + ? resolutionRiskScoreData.data[0] + : undefined) ?? prefetchedResolutionRisk; + const resolutionEntityData = getEntityData(entityType, resolutionRiskData); + const resolutionRows = useMemo( + () => getItems(resolutionEntityData, isPrivmonModifierEnabled, isWatchlistEnabled), + [resolutionEntityData, isPrivmonModifierEnabled, isWatchlistEnabled] + ); + const showResolutionRiskSummary = hasRealResolutionGroup && Boolean(resolutionEntityData?.risk); + const resolutionLensAttributes = useMemo(() => { + if (!resolutionTargetEntityId) { + return undefined; + } + + return getRiskScoreSummaryAttributes({ + severity: resolutionEntityData?.risk?.calculated_level, + query: `${entityType}.risk.id_value: "${resolutionTargetEntityId}" AND ${entityType}.risk.score_type: "resolution"`, + spaceId, + riskEntity: entityType, + dataSource: 'risk_index', + metricLabel: i18n.translate( + 'xpack.securitySolution.flyout.entityDetails.riskSummary.resolutionGroupRiskScoreLabel', + { + defaultMessage: 'Resolution group risk score', + } + ), + }); + }, [entityType, resolutionEntityData?.risk?.calculated_level, resolutionTargetEntityId, spaceId]); + const resolutionCasesAttachmentMetadata = useMemo( + () => ({ + description: i18n.translate( + 'xpack.securitySolution.flyout.entityDetails.resolutionRiskSummary.casesAttachmentLabel', + { + defaultMessage: + 'Resolution group risk score for {entityType, select, user {user} other {host}} {entityName}', + values: { + entityName: resolutionEntityData?.name, + entityType, + }, + } + ), + }), + [entityType, resolutionEntityData?.name] + ); + const resolutionTimerange = useMemo(() => { + const from = dateMath.parse(LAST_30_DAYS.from)?.toISOString() ?? LAST_30_DAYS.from; + const to = dateMath.parse(LAST_30_DAYS.to)?.toISOString() ?? LAST_30_DAYS.to; + return { from, to }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [resolutionRiskData?.['@timestamp']]); + + return ( + +

+ +

+ + } + extraAction={ + + {riskData && ( + + ), + }} + /> + )} + + } + > + + + + ), + link: riskScoreData.loading ? undefined : entityTabLink, + iconType: undefined, + }} + expand={{ + expandable: false, + }} + > + + +
+ {riskData && ( + + } + casesAttachmentMetadata={casesAttachmentMetadata} + /> + )} +
+
+ + +
+
+ + } + /> +
+ +
+
+
+
+
+ {showResolutionRiskSummary && ( + <> + + + ), + link: resolutionRiskScoreData.loading ? undefined : resolutionTabLink, + iconType: undefined, + }} + expand={{ + expandable: false, + }} + > + + +
+ {resolutionRiskData && resolutionLensAttributes && ( + + } + casesAttachmentMetadata={resolutionCasesAttachmentMetadata} + /> + )} +
+
+ + + +
+
+ + )} + +
+ ); +}; + +export const FlyoutRiskSummary = React.memo( + FlyoutRiskSummaryComponent +) as typeof FlyoutRiskSummaryComponent & { displayName: string }; // This is needed to make React.memo work with generic +FlyoutRiskSummary.displayName = 'RiskSummary'; diff --git a/x-pack/solutions/security/plugins/security_solution/public/explore/hosts/pages/details/index.tsx b/x-pack/solutions/security/plugins/security_solution/public/explore/hosts/pages/details/index.tsx index 14046f3c29b75..208c53c1e588e 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/explore/hosts/pages/details/index.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/explore/hosts/pages/details/index.tsx @@ -80,9 +80,9 @@ import { useEntityFromStore, type EntityStoreRecord, } from '../../../../flyout/entity_details/shared/hooks/use_entity_from_store'; -import { ObservedDataSection as HostObservedDataSection } from '../../../../flyout/entity_details/host_right/components/observed_data_section'; +import { ObservedDataSection as HostObservedDataSection } from '../../../../flyout_v2/entity/host/main/components/observed_data_section'; import { HOST_PANEL_OBSERVED_HOST_QUERY_ID } from '../../../../flyout/entity_details/host_right'; -import { useObservedHost } from '../../../../flyout/entity_details/host_right/hooks/use_observed_host'; +import { useObservedHost } from '../../../../flyout_v2/entity/host/main/hooks/use_observed_host'; import { buildRiskScoreStateFromEntityRecord } from '../../../../flyout/entity_details/shared/entity_store_risk_utils'; import { NO_CORRESPONDING_ENTITY_EXISTS } from '../../../../flyout/entity_details/shared/translations'; import { useSecurityDefaultPatterns } from '../../../../data_view_manager/hooks/use_security_default_patterns'; diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/left/components/host_details.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/left/components/host_details.test.tsx index 8d189bfb2ed19..b115aa629ac41 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/left/components/host_details.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/left/components/host_details.test.tsx @@ -15,7 +15,7 @@ import { TestProviders } from '../../../../common/mock'; import { HostDetails } from './host_details'; import { useMlCapabilities } from '../../../../common/components/ml/hooks/use_ml_capabilities'; import { mockAnomalies } from '../../../../common/components/ml/mock'; -import { useObservedHost } from '../../../entity_details/host_right/hooks/use_observed_host'; +import { useObservedHost } from '../../../../flyout_v2/entity/host/main/hooks/use_observed_host'; import { useHostRelatedUsers } from '../../../../common/containers/related_entities/related_users'; import { RiskSeverity } from '../../../../../common/search_strategy'; import { @@ -97,7 +97,7 @@ jest.mock('../../../../common/components/ml/anomaly/anomaly_table_provider', () }) => children({ anomaliesData: mockAnomalies, isLoadingAnomaliesData: false, jobNameById: {} }), })); -jest.mock('../../../entity_details/host_right/hooks/use_observed_host'); +jest.mock('../../../../flyout_v2/entity/host/main/hooks/use_observed_host'); const mockUseObservedHost = useObservedHost as jest.Mock; jest.mock('../../../../common/containers/related_entities/related_users'); diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/left/components/host_details.tsx b/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/left/components/host_details.tsx index 9204e3920c21a..d2c85c409251c 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/left/components/host_details.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/left/components/host_details.tsx @@ -90,7 +90,7 @@ import { useSelectedPatterns } from '../../../../data_view_manager/hooks/use_sel import { useSecurityDefaultPatterns } from '../../../../data_view_manager/hooks/use_security_default_patterns'; import { useSpaceId } from '../../../../common/hooks/use_space_id'; import type { EntityFromStoreResult } from '../../../entity_details/shared/hooks/use_entity_from_store'; -import { useObservedHost } from '../../../entity_details/host_right/hooks/use_observed_host'; +import { useObservedHost } from '../../../../flyout_v2/entity/host/main/hooks/use_observed_host'; import { buildRiskScoreStateFromEntityRecord, getRiskFromEntityRecord, diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/host_right/footer.tsx b/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/host_right/footer.tsx deleted file mode 100644 index f93a513a44c95..0000000000000 --- a/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/host_right/footer.tsx +++ /dev/null @@ -1,59 +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, { useMemo } from 'react'; -import { EuiFlyoutFooter, EuiPanel, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; -import { useEntityStoreEuidApi } from '@kbn/entity-store/public'; -import { TakeAction } from '../shared/components/take_action'; -import { EntityIdentifierFields, EntityType } from '../../../../common/entity_analytics/types'; -import type { IdentityFields } from '../../document_details/shared/utils'; -import type { EntityStoreRecord } from '../shared/hooks/use_entity_from_store'; -import { AiAssistantButton } from '../../../entity_analytics/components/ai_assistant_button/ai_assistant_button'; - -export const HostPanelFooter = ({ - identityFields, - entity, -}: { - identityFields: IdentityFields; - /** When entity store v2 is enabled: entity record from the store. */ - entity?: EntityStoreRecord; -}) => { - const hostName = useMemo( - () => identityFields[EntityIdentifierFields.hostName] || Object.values(identityFields)[0] || '', - [identityFields] - ); - - const euidApi = useEntityStoreEuidApi(); - const euidEntityFilter = useMemo((): string | undefined => { - if (!euidApi?.euid || !entity) { - return undefined; - } - return euidApi.euid.kql.getEuidFilterBasedOnDocument('host', entity); - }, [euidApi?.euid, entity]); - - return ( - - - - - - - - - - - - - ); -}; diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/host_right/index.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/host_right/index.test.tsx index c527a56146561..373e539f2fd96 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/host_right/index.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/host_right/index.test.tsx @@ -38,7 +38,7 @@ jest.mock('../../../entity_analytics/api/hooks/use_risk_score', () => ({ const mockedUseObservedHost = jest.fn().mockReturnValue(mockObservedHostData); -jest.mock('./hooks/use_observed_host', () => ({ +jest.mock('../../../flyout_v2/entity/host/main/hooks/use_observed_host', () => ({ useObservedHost: () => mockedUseObservedHost(), })); diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/host_right/index.tsx b/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/host_right/index.tsx index d79837e0554d5..62f54c61de4dc 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/host_right/index.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/host_right/index.tsx @@ -11,7 +11,7 @@ import { useHasMisconfigurations } from '@kbn/cloud-security-posture/src/hooks/u import { useHasVulnerabilities } from '@kbn/cloud-security-posture/src/hooks/use_has_vulnerabilities'; import { TableId } from '@kbn/securitysolution-data-table'; import { FF_ENABLE_ENTITY_STORE_V2, useEntityStoreEuidApi } from '@kbn/entity-store/public'; -import { EuiSpacer } from '@elastic/eui'; +import { EuiFlyoutFooter, EuiSpacer } from '@elastic/eui'; import { useAssetCriticalityPrivileges } from '../../../entity_analytics/components/asset_criticality/use_asset_criticality'; import { useUpdateAssetCriticality } from '../../../entity_analytics/api/hooks/use_update_asset_criticality'; import { buildEuidCspPreviewOptions } from '../../../cloud_security_posture/utils/build_euid_csp_preview_options'; @@ -27,14 +27,14 @@ import { useGlobalTime } from '../../../common/containers/use_global_time'; import { buildHostNamesFilter, type RiskSeverity } from '../../../../common/search_strategy'; import { useUiSetting, useKibana } from '../../../common/lib/kibana'; import { FlyoutNavigation } from '../../shared/components/flyout_navigation'; -import { HostPanelFooter } from './footer'; -import { HostPanelContent } from './content'; -import { HostPanelHeader } from './header'; +import { Footer } from '../../../flyout_v2/entity/host/main/footer'; +import { Content } from '../../../flyout_v2/entity/host/main/content'; +import { Header } from '../../../flyout_v2/entity/host/main/header'; import { EntityDetailsLeftPanelTab } from '../shared/components/left_panel/left_panel_header'; import { HostPreviewPanelFooter } from '../host_preview/footer'; import { useNavigateToHostDetails } from './hooks/use_navigate_to_host_details'; import { EntityType } from '../../../../common/entity_analytics/types'; -import { useObservedHost } from './hooks/use_observed_host'; +import { useObservedHost } from '../../../flyout_v2/entity/host/main/hooks/use_observed_host'; import { buildRiskScoreStateFromEntityRecord, getRiskFromEntityRecord, @@ -46,7 +46,11 @@ import { mergeLegacyIdentityWhenStoreEntityMissing, type IdentityFields, } from '../../document_details/shared/utils'; -import { HOST_PANEL_RISK_SCORE_QUERY_ID, HOST_PANEL_OBSERVED_HOST_QUERY_ID } from './constants'; +import { + HOST_PANEL_RISK_SCORE_QUERY_ID, + HOST_PANEL_OBSERVED_HOST_QUERY_ID, +} from '../../../flyout_v2/entity/host/main/constants'; +import { FlyoutHeader } from '../../shared/components/flyout_header'; import { FlyoutBody } from '../../shared/components/flyout_body'; import { useEntityPanelTabs, TABLE_TAB_ID } from '../shared/hooks/use_entity_panel_tabs'; import { EntityPanelHeaderTabs } from '../shared/components/entity_panel_tabs'; @@ -297,19 +301,21 @@ export const HostPanel = memo(function HostPanel({ isPreviewMode={isPreviewMode} isRulePreview={scopeId === TableId.rulePreview} /> - + +
+ {observedHost.entityRecord && ( ) : ( - )} {!isPreviewMode && assetInventoryEnabled && ( - + +