diff --git a/x-pack/solutions/security/packages/kbn-cloud-security-posture/common/schema/graph/v1.ts b/x-pack/solutions/security/packages/kbn-cloud-security-posture/common/schema/graph/v1.ts index b8788db4467f9..b8e867c9c7492 100644 --- a/x-pack/solutions/security/packages/kbn-cloud-security-posture/common/schema/graph/v1.ts +++ b/x-pack/solutions/security/packages/kbn-cloud-security-posture/common/schema/graph/v1.ts @@ -69,6 +69,7 @@ export const entitySchema = schema.object({ name: schema.maybe(schema.string()), type: schema.maybe(schema.string()), sub_type: schema.maybe(schema.string()), + engine_type: schema.maybe(schema.string()), host: schema.maybe( schema.object({ ip: schema.maybe(schema.string()), diff --git a/x-pack/solutions/security/packages/kbn-cloud-security-posture/common/types/graph/v1.ts b/x-pack/solutions/security/packages/kbn-cloud-security-posture/common/types/graph/v1.ts index 25532157296c2..627d77c4220bd 100644 --- a/x-pack/solutions/security/packages/kbn-cloud-security-posture/common/types/graph/v1.ts +++ b/x-pack/solutions/security/packages/kbn-cloud-security-posture/common/types/graph/v1.ts @@ -23,7 +23,11 @@ import type { } from '../../schema/graph/v1'; import { REACHED_NODES_LIMIT } from '../../schema/graph/v1'; -export { DOCUMENT_TYPE_ALERT, DOCUMENT_TYPE_EVENT } from '../../schema/graph/v1'; +export { + DOCUMENT_TYPE_ALERT, + DOCUMENT_TYPE_ENTITY, + DOCUMENT_TYPE_EVENT, +} from '../../schema/graph/v1'; export type GraphRequest = Omit, 'query.esQuery'> & { query: { esQuery?: { bool: Partial } }; diff --git a/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/filters/filter_store.ts b/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/filters/filter_store.ts index f94dae6557ba1..4279c1e52bfee 100644 --- a/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/filters/filter_store.ts +++ b/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/filters/filter_store.ts @@ -104,6 +104,11 @@ export const isEntityRelationshipExpandedForScope = ( return store?.isEntityRelationshipExpanded(entityId) ?? false; }; +export const isInitialEntityForScope = (scopeId: string, entityId: string): boolean => { + const store = stores.get(scopeId); + return store?.isInitialEntity(entityId) ?? false; +}; + /** * Check if a filter is active for the given scope, field, and value. * Returns false gracefully if no store exists (no warning logged). @@ -142,6 +147,7 @@ const stores = new Map(); export class FilterStore { readonly scopeId: string; private dataViewId?: string; + private initialEntityIds: Array<{ id: string; isOrigin: boolean }> = []; private readonly filters$ = new BehaviorSubject([]); private readonly expandedEntityIds$ = new BehaviorSubject>(new Set()); private readonly filterEventSubscription: Subscription; @@ -174,6 +180,10 @@ export class FilterStore { } } + setInitialEntityIds(initialEntityIds: Array<{ id: string; isOrigin: boolean }>): void { + this.initialEntityIds = initialEntityIds; + } + /** * Get the current filters from the store. */ @@ -244,7 +254,14 @@ export class FilterStore { * Check if an entity's relationships are currently expanded. */ isEntityRelationshipExpanded(entityId: string): boolean { - return this.expandedEntityIds$.value.has(entityId); + return this.expandedEntityIds$.value.has(entityId) || this.isInitialEntity(entityId); + } + + /** + * Check if an entity ID is part of the initial set of entities (e.g. from the original graph request). + */ + isInitialEntity(entityId: string): boolean { + return this.initialEntityIds.find((entity) => entity.id === entityId)?.isOrigin ?? false; } /** diff --git a/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/filters/use_graph_filters.ts b/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/filters/use_graph_filters.ts index e96d0233567ef..a8b9fe24291ee 100644 --- a/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/filters/use_graph_filters.ts +++ b/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/filters/use_graph_filters.ts @@ -27,6 +27,17 @@ import { getOrCreateFilterStore, destroyFilterStore } from './filter_store'; */ export const useGraphFilters = ( scopeId: string, + initialEntityIds: Array<{ + /** + * The ID of the entity. + */ + id: string; + + /** + * Whether this entity is the origin of the graph (for centering). + */ + isOrigin: boolean; + }>, dataViewId: string ): { searchFilters: Filter[]; @@ -39,7 +50,8 @@ export const useGraphFilters = ( // Update dataViewId when it changes useEffect(() => { store.setDataViewId(dataViewId); - }, [store, dataViewId]); + store.setInitialEntityIds(initialEntityIds); + }, [store, dataViewId, initialEntityIds]); // Clean up store on unmount or when scopeId changes useEffect(() => { @@ -91,13 +103,17 @@ export const useGraphFilters = ( // Convert expandedEntityIds Set to API format const entityIdsForApi = useMemo(() => { - if (expandedEntityIds.size === 0) return undefined; + if (expandedEntityIds.size === 0) return initialEntityIds; - return Array.from(expandedEntityIds).map((id) => ({ - id, - isOrigin: false, // User-expanded entities are not the graph origin - })); - }, [expandedEntityIds]); + return initialEntityIds.concat( + Array.from(expandedEntityIds) + .filter((id) => !initialEntityIds.some((entity) => entity.id === id)) + .map((id) => ({ + id, + isOrigin: false, // User-expanded entities are not the graph origin + })) + ); + }, [expandedEntityIds, initialEntityIds]); return { searchFilters, diff --git a/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/graph_grouped_node_preview_panel/components/grouped_item/grouped_item.stories.tsx b/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/graph_grouped_node_preview_panel/components/grouped_item/grouped_item.stories.tsx index b3ce9f4113461..576edde192515 100644 --- a/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/graph_grouped_node_preview_panel/components/grouped_item/grouped_item.stories.tsx +++ b/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/graph_grouped_node_preview_panel/components/grouped_item/grouped_item.stories.tsx @@ -57,6 +57,7 @@ export const EntityItem: StoryFn = ({ }: EntityStoryProps) => { const item: EntityItemType = { itemType: DOCUMENT_TYPE_ENTITY, + entity: {}, ...itemArgs, }; diff --git a/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/graph_grouped_node_preview_panel/components/grouped_item/grouped_item.test.tsx b/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/graph_grouped_node_preview_panel/components/grouped_item/grouped_item.test.tsx index 41d0f0abdd802..be830ab36bbcb 100644 --- a/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/graph_grouped_node_preview_panel/components/grouped_item/grouped_item.test.tsx +++ b/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/graph_grouped_node_preview_panel/components/grouped_item/grouped_item.test.tsx @@ -61,6 +61,7 @@ describe('', () => { risk: 55, ips: ['5.5.5.5'], countryCodes: ['US'], + entity: {}, }} /> ); @@ -138,7 +139,10 @@ describe('', () => { it('falls back to entity id when entity label is missing', () => { const entityId = 'entity-id'; const { getByTestId } = render( - + ); expect(getByTestId(GROUPED_ITEM_TITLE_TEST_ID_TEXT).textContent).toBe(entityId); }); @@ -175,6 +179,7 @@ describe('', () => { itemType: 'entity', id: 'e1', label: 'entity-1', + entity: {}, actor: { id: 'a1', label: 'actor' }, target: { id: 't1', label: 'target' }, } as any // eslint-disable-line @typescript-eslint/no-explicit-any @@ -413,6 +418,7 @@ describe('', () => { itemType: 'entity', id: 'entity-1', label: 'test_entity', + entity: {}, }} /> ); @@ -432,6 +438,7 @@ describe('', () => { id: 'e1', label: 'entity-1', countryCodes: ['il'], + entity: {}, }} /> ); @@ -448,6 +455,7 @@ describe('', () => { id: 'e1', label: 'entity-1', countryCodes: undefined, + entity: {}, }} /> ); @@ -464,6 +472,7 @@ describe('', () => { id: 'e1', label: 'entity-1', countryCodes: [''], + entity: {}, }} /> ); @@ -480,6 +489,7 @@ describe('', () => { id: 'e1', label: 'entity-1', countryCodes: ['INVALID'], + entity: {}, }} /> ); @@ -493,6 +503,7 @@ describe('', () => { itemType: 'entity', id: 'e1', label: 'entity-1', + entity: {}, }; const { getByTestId, rerender } = render( @@ -531,6 +542,7 @@ describe('', () => { id: 'e1', label: 'entity-1', ips: undefined, + entity: {}, }} /> ); @@ -547,6 +559,7 @@ describe('', () => { id: 'e1', label: 'entity-1', ips: [''], + entity: {}, }} /> ); @@ -564,6 +577,7 @@ describe('', () => { id: 'e1', label: 'entity-1', ips: [ipv4], + entity: {}, }} /> ); @@ -581,6 +595,7 @@ describe('', () => { id: 'e1', label: 'entity-1', ips: ipArray, + entity: {}, }} /> ); @@ -597,6 +612,7 @@ describe('', () => { id: 'e1', label: 'entity-1', ips: [], + entity: {}, }} /> ); @@ -613,6 +629,7 @@ describe('', () => { id: 'e1', label: 'entity-1', ips: ['', ''], + entity: {}, }} /> ); @@ -632,6 +649,7 @@ describe('', () => { id: 'e1', label: 'entity-1', countryCodes: countryArray, + entity: {}, }} /> ); @@ -650,6 +668,7 @@ describe('', () => { id: 'e1', label: 'entity-1', countryCodes: [], + entity: {}, }} /> ); @@ -666,6 +685,7 @@ describe('', () => { id: 'e1', label: 'entity-1', countryCodes: ['', ''], + entity: {}, }} /> ); @@ -692,6 +712,7 @@ describe('', () => { itemType: 'entity', id: 'e1', label: 'entity-1', + entity: {}, }} /> ); @@ -710,6 +731,7 @@ describe('', () => { itemType: 'entity', id: 'e1', label: 'entity-1', + entity: {}, }} /> ); @@ -727,6 +749,7 @@ describe('', () => { itemType: 'entity', id: 'e1', label: 'entity-1', + entity: {}, }} /> ); diff --git a/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/graph_grouped_node_preview_panel/components/grouped_item/parts/entity_actions_button.test.tsx b/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/graph_grouped_node_preview_panel/components/grouped_item/parts/entity_actions_button.test.tsx index 799805fee13c5..a1c7399f78388 100644 --- a/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/graph_grouped_node_preview_panel/components/grouped_item/parts/entity_actions_button.test.tsx +++ b/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/graph_grouped_node_preview_panel/components/grouped_item/parts/entity_actions_button.test.tsx @@ -26,7 +26,7 @@ describe('EntityActionsButton', () => { itemType: 'entity', icon: 'user', label: 'Test Entity', - availableInEntityStore: true, + entity: { availableInEntityStore: true }, }; const scopeId = 'test-scope-id'; @@ -81,7 +81,7 @@ describe('EntityActionsButton', () => { describe('when entity is not available in entity store', () => { const notInStoreItem: EntityItem = { ...mockEntityItem, - availableInEntityStore: false, + entity: { availableInEntityStore: false }, }; it('should render entity details item as disabled', () => { diff --git a/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/graph_grouped_node_preview_panel/components/grouped_item/parts/entity_actions_button.tsx b/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/graph_grouped_node_preview_panel/components/grouped_item/parts/entity_actions_button.tsx index 80a19c01ecc38..0c59260ba4b38 100644 --- a/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/graph_grouped_node_preview_panel/components/grouped_item/parts/entity_actions_button.tsx +++ b/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/graph_grouped_node_preview_panel/components/grouped_item/parts/entity_actions_button.tsx @@ -8,7 +8,6 @@ import React, { useCallback, useState } from 'react'; import { EuiButtonIcon, EuiPopover, EuiListGroup, EuiHorizontalRule } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { useExpandableFlyoutApi } from '@kbn/expandable-flyout'; import { PopoverListItem } from '../../../../popovers/primitives/popover_list_item'; import { GROUPED_ITEM_ACTIONS_BUTTON_TEST_ID, @@ -26,8 +25,8 @@ import { emitEntityRelationshipToggle, isEntityRelationshipExpandedForScope, } from '../../../../filters/filter_store'; -import { GenericEntityPanelKey, GENERIC_ENTITY_PREVIEW_BANNER } from '../../../constants'; import { RELATED_ENTITY } from '../../../../../common/constants'; +import { useOpenEntityPreviewPanel } from '../../../hooks/use_open_entity_preview_panel'; const actionsButtonAriaLabel = i18n.translate( 'securitySolutionPackages.csp.graph.groupedItem.actionsButton.ariaLabel', @@ -52,25 +51,11 @@ export interface EntityActionsButtonProps { */ export const EntityActionsButton = ({ item, scopeId }: EntityActionsButtonProps) => { const [isPopoverOpen, setIsPopoverOpen] = useState(false); - const { openPreviewPanel } = useExpandableFlyoutApi(); - const closePopover = useCallback(() => setIsPopoverOpen(false), []); const togglePopover = useCallback(() => setIsPopoverOpen((prev) => !prev), []); - const handleShowEntityDetails = useCallback(() => { - openPreviewPanel({ - id: GenericEntityPanelKey, - params: { - entityId: item.id, - scopeId, - isPreviewMode: true, - banner: GENERIC_ENTITY_PREVIEW_BANNER, - isEngineMetadataExist: !!item.availableInEntityStore, - }, - }); - }, [item.id, item.availableInEntityStore, openPreviewPanel, scopeId]); - - const sourceFields = item.sourceFields ?? {}; + const openEntityPreviewPanel = useOpenEntityPreviewPanel(); + const sourceFields = item.entity.sourceFields ?? {}; const entityFilterActions: EntityFilterActions = { toggleEntityFilter: (role, action) => { @@ -95,7 +80,7 @@ export const EntityActionsButton = ({ item, scopeId }: EntityActionsButtonProps) const items = getEntityExpandItems({ nodeId: item.id, entityFilterActions, - onShowEntityDetails: handleShowEntityDetails, + onShowEntityDetails: () => openEntityPreviewPanel(item.id, scopeId, item.entity), onClose: closePopover, shouldRender: { showEntityRelationships: true, @@ -104,10 +89,10 @@ export const EntityActionsButton = ({ item, scopeId }: EntityActionsButtonProps) showRelatedEvents: true, showEntityDetails: true, }, - showEntityDetailsDisabled: !item.availableInEntityStore, + showEntityDetailsDisabled: !item.entity.availableInEntityStore, isEntityRelationshipsExpanded: isEntityRelationshipExpandedForScope(scopeId, item.id), toggleEntityRelationships: (action) => emitEntityRelationshipToggle(scopeId, item.id, action), - showEntityRelationshipsDisabled: !item.availableInEntityStore, + showEntityRelationshipsDisabled: !item.entity.availableInEntityStore, }); return ( diff --git a/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/graph_grouped_node_preview_panel/components/grouped_item/parts/header_row.test.tsx b/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/graph_grouped_node_preview_panel/components/grouped_item/parts/header_row.test.tsx index 09b38cdb09be2..358ee20966f35 100644 --- a/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/graph_grouped_node_preview_panel/components/grouped_item/parts/header_row.test.tsx +++ b/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/graph_grouped_node_preview_panel/components/grouped_item/parts/header_row.test.tsx @@ -48,7 +48,7 @@ describe('', () => { itemType: DOCUMENT_TYPE_ENTITY, id: 'entity-1', label: 'Entity One', - availableInEntityStore: true, + entity: { availableInEntityStore: true }, }; const { getByTestId } = render(); @@ -61,7 +61,7 @@ describe('', () => { itemType: DOCUMENT_TYPE_ENTITY, id: 'entity-1', label: 'Entity One', - availableInEntityStore: true, + entity: { availableInEntityStore: true }, }; const { getByTestId } = render(); @@ -82,7 +82,7 @@ describe('', () => { itemType: DOCUMENT_TYPE_ENTITY, id: 'entity-dup', label: 'Dup', - availableInEntityStore: true, + entity: { availableInEntityStore: true }, }; const { getByTestId } = render(); @@ -101,7 +101,7 @@ describe('', () => { itemType: DOCUMENT_TYPE_ENTITY, id: 'entity-2', label: 'Entity Two', - availableInEntityStore: false, + entity: { availableInEntityStore: false }, }; const { getByTestId, queryByTestId } = render( @@ -126,7 +126,7 @@ describe('', () => { itemType: DOCUMENT_TYPE_ENTITY, id: 'entity-2', label: 'Entity Two', - availableInEntityStore: false, + entity: { availableInEntityStore: false }, }; const { getByTestId } = render(); @@ -142,6 +142,7 @@ describe('', () => { itemType: DOCUMENT_TYPE_ENTITY, id: 'entity-3', label: 'Entity Three', + entity: {}, }; const { getByTestId } = render(); diff --git a/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/graph_grouped_node_preview_panel/components/grouped_item/parts/header_row.tsx b/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/graph_grouped_node_preview_panel/components/grouped_item/parts/header_row.tsx index 9f61537933a9e..f3c85c0e3bd90 100644 --- a/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/graph_grouped_node_preview_panel/components/grouped_item/parts/header_row.tsx +++ b/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/graph_grouped_node_preview_panel/components/grouped_item/parts/header_row.tsx @@ -34,12 +34,11 @@ import { displayEntityName, displayEventName } from '../utils'; import { EntityActionsButton } from './entity_actions_button'; import { EventActionsButton } from './event_actions_button'; import { - GENERIC_ENTITY_PREVIEW_BANNER, DocumentDetailsPreviewPanelKey, - GenericEntityPanelKey, ALERT_PREVIEW_BANNER, EVENT_PREVIEW_BANNER, } from '../../../constants'; +import { useOpenEntityPreviewPanel } from '../../../hooks/use_open_entity_preview_panel'; const entityUnavailableTooltip = i18n.translate( 'securitySolutionPackages.csp.graph.groupedItem.entityUnavailable.tooltip', @@ -59,6 +58,7 @@ export interface HeaderRowProps { export const HeaderRow = ({ item, scopeId }: HeaderRowProps) => { const { euiTheme } = useEuiTheme(); const { openPreviewPanel } = useExpandableFlyoutApi(); + const openEntityPreviewPanel = useOpenEntityPreviewPanel(); const title = useMemo(() => { switch (item.itemType) { @@ -77,23 +77,16 @@ export const HeaderRow = ({ item, scopeId }: HeaderRowProps) => { e.preventDefault(); if (item.itemType === DOCUMENT_TYPE_ENTITY) { - openPreviewPanel({ - id: GenericEntityPanelKey, - params: { - entityId: item.id, - scopeId, - isPreviewMode: true, - banner: GENERIC_ENTITY_PREVIEW_BANNER, - isEngineMetadataExist: !!item.availableInEntityStore, - }, - }); + const entityItem = item as EntityItem; + openEntityPreviewPanel(entityItem.id, scopeId, entityItem.entity); } else { // event or alert + const eventOrAlertItem = item as EventItem | AlertItem; openPreviewPanel({ id: DocumentDetailsPreviewPanelKey, params: { - id: item.docId, - indexName: item.index, + id: eventOrAlertItem.docId, + indexName: eventOrAlertItem.index, scopeId, banner: item.itemType === DOCUMENT_TYPE_ALERT ? ALERT_PREVIEW_BANNER : EVENT_PREVIEW_BANNER, @@ -102,13 +95,13 @@ export const HeaderRow = ({ item, scopeId }: HeaderRowProps) => { }); } }, - [item, openPreviewPanel, scopeId] + [item, openPreviewPanel, openEntityPreviewPanel, scopeId] ); const isClickable = item.itemType === DOCUMENT_TYPE_EVENT || item.itemType === DOCUMENT_TYPE_ALERT || - (item.itemType === DOCUMENT_TYPE_ENTITY && item.availableInEntityStore); + (item.itemType === DOCUMENT_TYPE_ENTITY && (item as EntityItem).entity?.availableInEntityStore); return ( @@ -117,10 +110,10 @@ export const HeaderRow = ({ item, scopeId }: HeaderRowProps) => { )} - {item.itemType === DOCUMENT_TYPE_ENTITY && item.icon && ( + {item.itemType === DOCUMENT_TYPE_ENTITY && (item as EntityItem).icon && ( ; + entity: EntityDocumentDataModel; } export type EntityOrEventItem = EventItem | AlertItem | EntityItem; diff --git a/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/graph_grouped_node_preview_panel/constants.ts b/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/graph_grouped_node_preview_panel/constants.ts index ab9f24217a5f0..46b232076a402 100644 --- a/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/graph_grouped_node_preview_panel/constants.ts +++ b/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/graph_grouped_node_preview_panel/constants.ts @@ -23,9 +23,24 @@ export const GraphGroupedNodePreviewPanelKey = 'graphGroupedNodePreviewPanel' as * Panel keys for preview panels used by the graph grouped node preview panel. * These must match the keys registered in the security solution flyout. */ -export const GenericEntityPanelKey = 'generic-entity-panel' as const; + +// TODO: START - COPIED FROM SECURITY_SOLUTION_FLYOUT, should not be copied here. Will be removed in the future. export const DocumentDetailsPreviewPanelKey = 'document-details-preview' as const; +export const HostPanelKey = 'host-panel' as const; +export const UserPanelKey = 'user-panel' as const; +export const ServicePanelKey = 'service-panel' as const; +export const GenericEntityPanelKey = 'generic-entity-panel' as const; + +export const EntityPanelKeyByType: Record = { + host: HostPanelKey, + user: UserPanelKey, + service: ServicePanelKey, + generic: undefined, // TODO create generic flyout? +}; + +// TODO: END - COPIED FROM SECURITY_SOLUTION_FLYOUT, should not be copied here. Will be removed in the future. + /** * Banner configurations for preview panels. */ diff --git a/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/graph_grouped_node_preview_panel/graph_grouped_node_preview_panel.stories.tsx b/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/graph_grouped_node_preview_panel/graph_grouped_node_preview_panel.stories.tsx index 066b68d4f27b7..85a229dacf1e6 100644 --- a/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/graph_grouped_node_preview_panel/graph_grouped_node_preview_panel.stories.tsx +++ b/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/graph_grouped_node_preview_panel/graph_grouped_node_preview_panel.stories.tsx @@ -50,13 +50,13 @@ export default meta; const createEntityItem = (overrides: Partial = {}): EntityItem => ({ itemType: DOCUMENT_TYPE_ENTITY, id: 'entity-1', - type: 'host', label: 'host-01.acme.com', icon: 'storage', risk: 75, timestamp: new Date('2023-12-01T10:30:00Z'), ips: ['10.200.0.101'], countryCodes: ['US'], + entity: { type: 'host' }, ...overrides, }); @@ -97,7 +97,7 @@ const ContentTemplate: StoryFn = (args) => { if (firstItem && firstItem.itemType === DOCUMENT_TYPE_ENTITY) { icon = firstItem.icon ?? icon; - groupedItemsType = capitalize(`${firstItem.type}s`) || 'Entities'; + groupedItemsType = capitalize(`${firstItem.entity?.type}s`) || 'Entities'; } // Create mock pagination controls @@ -139,7 +139,7 @@ EntitiesGroup.args = { createEntityItem({ id: 'host-1', label: 'web-server-01.prod', - type: 'host', + entity: { type: 'host' }, icon: 'storage', risk: 85, ips: ['10.0.1.10'], @@ -148,7 +148,7 @@ EntitiesGroup.args = { createEntityItem({ id: 'host-2', label: 'db-server-02.prod', - type: 'host', + entity: { type: 'host' }, icon: 'storage', risk: 45, ips: ['10.0.1.11'], @@ -157,7 +157,7 @@ EntitiesGroup.args = { createEntityItem({ id: 'host-3', label: 'api-server-03.staging', - type: 'host', + entity: { type: 'host' }, icon: 'storage', risk: 65, ips: ['10.0.2.15'], @@ -344,7 +344,7 @@ export const LargeGroup: StoryFn = () => { !str ? '' : str[0].toUpperCase() + str.slice(1).toLowerCase(); if (firstItem && firstItem.itemType === DOCUMENT_TYPE_ENTITY) { icon = firstItem.icon ?? icon; - groupedItemsType = capitalize(`${firstItem.type}s`) || 'Entities'; + groupedItemsType = capitalize(`${firstItem.entity?.type}s`) || 'Entities'; } // Slice items for current page diff --git a/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/graph_grouped_node_preview_panel/graph_grouped_node_preview_panel.test.tsx b/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/graph_grouped_node_preview_panel/graph_grouped_node_preview_panel.test.tsx index 1cd84b898e281..b74068e29d9e5 100644 --- a/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/graph_grouped_node_preview_panel/graph_grouped_node_preview_panel.test.tsx +++ b/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/graph_grouped_node_preview_panel/graph_grouped_node_preview_panel.test.tsx @@ -70,11 +70,12 @@ describe('GraphGroupedNodePreviewPanel', () => { entityIdCounter += 1; return { id: `entity-${entityIdCounter}`, - type: 'host', + itemType: 'entity', icon: 'storage', label: 'Test Host', + entity: { type: 'host' }, ...overrides, - } as EntityItem; + }; }; beforeEach(() => { @@ -177,7 +178,7 @@ describe('GraphGroupedNodePreviewPanel', () => { }); it('should render icon, title, and grouped type in ContentBody', () => { - const entityItems = [createEntityItem({ icon: 'test-icon', type: 'host' })]; + const entityItems = [createEntityItem({ icon: 'test-icon', entity: { type: 'host' } })]; render(); expect(screen.getByTestId(TOTAL_HITS_TEST_ID)).toBeInTheDocument(); @@ -199,7 +200,7 @@ describe('GraphGroupedNodePreviewPanel', () => { }); it('should display correct groupedItemsType label', () => { - const entityItems = [createEntityItem({ type: 'user' })]; + const entityItems = [createEntityItem({ entity: { type: 'user' } })]; render(); expect(screen.getByTestId(GROUPED_ITEMS_TYPE_TEST_ID)).toHaveTextContent('Users'); @@ -448,8 +449,8 @@ describe('GraphGroupedNodePreviewPanel', () => { it('should derive groupedItemsType from first entity type - this should never happened', () => { const entityItems = [ - createEntityItem({ type: 'host' }), - createEntityItem({ type: 'user' }), + createEntityItem({ entity: { type: 'host' } }), + createEntityItem({ entity: { type: 'user' } }), ]; render(); @@ -457,30 +458,28 @@ describe('GraphGroupedNodePreviewPanel', () => { }); it('should display "Hosts" label for host type', () => { - const entityItems = [createEntityItem({ type: 'host' })]; + const entityItems = [createEntityItem({ entity: { type: 'host' } })]; render(); expect(screen.getByTestId(GROUPED_ITEMS_TYPE_TEST_ID)).toHaveTextContent('Hosts'); }); it('should display "Users" label for user type', () => { - const entityItems = [createEntityItem({ type: 'user' })]; + const entityItems = [createEntityItem({ entity: { type: 'user' } })]; render(); expect(screen.getByTestId(GROUPED_ITEMS_TYPE_TEST_ID)).toHaveTextContent('Users'); }); it('should display "Entities" label for unknown type', () => { - const entityItems = [ - createEntityItem({ type: 'unknown' as unknown as EntityItem['type'] }), - ]; + const entityItems = [createEntityItem({ entity: { type: 'unknown' } })]; render(); expect(screen.getByTestId(GROUPED_ITEMS_TYPE_TEST_ID)).toHaveTextContent('Entities'); }); it('should display "Entities" label when type is undefined', () => { - const entityItems = [createEntityItem({ type: undefined })]; + const entityItems = [createEntityItem({ entity: { type: undefined } })]; render(); expect(screen.getByTestId(GROUPED_ITEMS_TYPE_TEST_ID)).toHaveTextContent('Entities'); diff --git a/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/graph_grouped_node_preview_panel/graph_grouped_node_preview_panel.tsx b/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/graph_grouped_node_preview_panel/graph_grouped_node_preview_panel.tsx index 8544e511dab02..ebe79b7ba235b 100644 --- a/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/graph_grouped_node_preview_panel/graph_grouped_node_preview_panel.tsx +++ b/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/graph_grouped_node_preview_panel/graph_grouped_node_preview_panel.tsx @@ -112,7 +112,7 @@ const useContentMetadata = ( const entityItem = firstItem as EntityItem; return { icon: entityItem.icon || 'index', - groupedItemsType: translateEntityType(entityItem.type), + groupedItemsType: translateEntityType(entityItem.entity?.type), }; } diff --git a/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/graph_grouped_node_preview_panel/hooks/use_open_entity_preview_panel.ts b/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/graph_grouped_node_preview_panel/hooks/use_open_entity_preview_panel.ts new file mode 100644 index 0000000000000..c478e04cfb41c --- /dev/null +++ b/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/graph_grouped_node_preview_panel/hooks/use_open_entity_preview_panel.ts @@ -0,0 +1,62 @@ +/* + * 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 { useExpandableFlyoutApi } from '@kbn/expandable-flyout'; +import type { EntityDocumentDataModel } from '@kbn/cloud-security-posture-common/types/graph/latest'; +import { + GenericEntityPanelKey, + GENERIC_ENTITY_PREVIEW_BANNER, + EntityPanelKeyByType, +} from '../constants'; + +// TODO: this is a repeated call of graph_visaulization controller. Should be refactored. +// Graph visualization should listen to open preview panel and handle it instead of having this repetitive code. + +export const useOpenEntityPreviewPanel = () => { + const { openPreviewPanel } = useExpandableFlyoutApi(); + + return (entityId: string, scopeId: string, entity: EntityDocumentDataModel) => { + const engineType = entity.engine_type; + const panelId = + engineType && engineType in EntityPanelKeyByType + ? EntityPanelKeyByType[engineType as keyof typeof EntityPanelKeyByType] + : GenericEntityPanelKey; + + if (!panelId) { + // toasts.addDanger({ + // title: i18n.translate( + // 'xpack.securitySolution.flyout.shared.components.graphVisualization.errorInvalidEntityPanel', + // { + // defaultMessage: 'Unable to open entity preview', + // } + // ), + // }); + return; + } + + const params = + engineType === 'host' + ? { hostName: entity.name } + : engineType === 'user' + ? { userName: entity.name } + : engineType === 'service' + ? { serviceName: entity.name } + : {}; + + openPreviewPanel({ + id: panelId, + params: { + entityId, + scopeId, + isPreviewMode: true, + banner: GENERIC_ENTITY_PREVIEW_BANNER, + isEngineMetadataExist: !!entity, + ...params, + }, + }); + }; +}; diff --git a/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/graph_investigation/graph_investigation.tsx b/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/graph_investigation/graph_investigation.tsx index 1612369c2ccc7..4fa8823651ef1 100644 --- a/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/graph_investigation/graph_investigation.tsx +++ b/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/graph_investigation/graph_investigation.tsx @@ -259,8 +259,10 @@ export const GraphInvestigation = memo( onOpenEventPreview, onOpenNetworkPreview, }: GraphInvestigationProps) => { + const emptyEntityIds = useMemo(() => [], []); const { searchFilters, setSearchFilters, entityIdsForApi } = useGraphFilters( scopeId, + entityIds ?? emptyEntityIds, dataView?.id ?? '' ); const [timeRange, setTimeRange] = useState(initialTimeRange); @@ -271,26 +273,6 @@ export const GraphInvestigation = memo( const lastValidEsQuery = useRef(); const [kquery, setKQuery] = useState(EMPTY_QUERY); - // Merge user-expanded entity IDs with initial entity IDs for the API - const mergedEntityIdsForApi = useMemo(() => { - const initial = entityIds ?? []; - const expanded = entityIdsForApi ?? []; - - if (initial.length === 0 && expanded.length === 0) return undefined; - - // Merge: initial entityIds keep their isOrigin flag, expanded ones are not origin - const mergedMap = new Map(); - for (const entry of initial) { - mergedMap.set(entry.id, entry); - } - for (const entry of expanded) { - if (!mergedMap.has(entry.id)) { - mergedMap.set(entry.id, entry); - } - } - return Array.from(mergedMap.values()); - }, [entityIds, entityIdsForApi]); - const onInvestigateInTimelineCallback = useCallback(() => { const query = { ...kquery }; @@ -355,7 +337,7 @@ export const GraphInvestigation = memo( esQuery, start: timeRange.from, end: timeRange.to, - entityIds: mergedEntityIdsForApi, + entityIds: entityIdsForApi, pinnedIds, }, nodesLimit: GRAPH_NODES_LIMIT, diff --git a/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/popovers/node_expand/get_entity_expand_items.ts b/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/popovers/node_expand/get_entity_expand_items.ts index d4e833ab17229..660fd2fb0fa88 100644 --- a/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/popovers/node_expand/get_entity_expand_items.ts +++ b/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/popovers/node_expand/get_entity_expand_items.ts @@ -113,6 +113,8 @@ export interface GetEntityExpandItemsOptions { showEntityDetailsDisabled?: boolean; /** Whether entity relationships is currently expanded (controls show/hide label) */ isEntityRelationshipsExpanded?: boolean; + /** Whether the entity is part of the initial set of entities (e.g., from the original graph request) */ + isInitialEntity?: boolean; /** Callback to toggle entity relationships on/off */ toggleEntityRelationships?: (action: 'show' | 'hide') => void; /** Whether entity relationships should be disabled. Defaults to false. */ @@ -140,6 +142,7 @@ export const getEntityExpandItems = ( shouldRender, showEntityDetailsDisabled = false, isEntityRelationshipsExpanded = false, + isInitialEntity = false, toggleEntityRelationships, showEntityRelationshipsDisabled = false, } = options; @@ -168,10 +171,15 @@ export const getEntityExpandItems = ( }, showToolTip: showEntityRelationshipsDisabled, toolTipText: showEntityRelationshipsDisabled - ? i18n.translate( - 'securitySolutionPackages.csp.graph.graphNodeExpandPopover.entityRelationshipsNotAvailable', - { defaultMessage: 'Entity relationships not available' } - ) + ? isInitialEntity + ? i18n.translate( + 'securitySolutionPackages.csp.graph.graphNodeExpandPopover.initialEntityRelationshipsNotAvailable', + { defaultMessage: 'Cannot hide entity relationships of investigation entity' } + ) + : i18n.translate( + 'securitySolutionPackages.csp.graph.graphNodeExpandPopover.entityRelationshipsNotAvailable', + { defaultMessage: 'Entity relationships not available' } + ) : undefined, toolTipProps: showEntityRelationshipsDisabled ? { diff --git a/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/popovers/node_expand/use_entity_node_expand_popover.ts b/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/popovers/node_expand/use_entity_node_expand_popover.ts index 068b8007360c7..0d11dacd48c33 100644 --- a/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/popovers/node_expand/use_entity_node_expand_popover.ts +++ b/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/popovers/node_expand/use_entity_node_expand_popover.ts @@ -21,6 +21,7 @@ import { isFilterActiveForScope, emitEntityRelationshipToggle, isEntityRelationshipExpandedForScope, + isInitialEntityForScope, } from '../../filters/filter_store'; import { RELATED_ENTITY } from '../../../common/constants'; @@ -48,6 +49,7 @@ export const useEntityNodeExpandPopover = ( const isSingleEntity = docMode === 'single-entity'; const isGroupedEntities = docMode === 'grouped-entities'; const isEnriched = isEntityNodeEnriched(node.data); + const isInitialEntity = isInitialEntityForScope(scopeId, node.id); const sourceFields = getSourceFieldsFromNode(node.data); @@ -86,9 +88,10 @@ export const useEntityNodeExpandPopover = ( (isSingleEntity || isGroupedEntities) && onOpenEventPreview !== undefined, }, isEntityRelationshipsExpanded: isEntityRelationshipExpandedForScope(scopeId, node.id), + isInitialEntity, toggleEntityRelationships: (action) => emitEntityRelationshipToggle(scopeId, node.id, action), - showEntityRelationshipsDisabled: !isEnriched, + showEntityRelationshipsDisabled: !isEnriched || isInitialEntity, showEntityDetailsDisabled: isSingleEntity && !isEnriched, }); }, diff --git a/x-pack/solutions/security/plugins/cloud_security_posture/server/routes/graph/fetch_entity_relationships_graph.ts b/x-pack/solutions/security/plugins/cloud_security_posture/server/routes/graph/fetch_entity_relationships_graph.ts index 40e65ecc2e15f..e06e1cdaef5ec 100644 --- a/x-pack/solutions/security/plugins/cloud_security_posture/server/routes/graph/fetch_entity_relationships_graph.ts +++ b/x-pack/solutions/security/plugins/cloud_security_posture/server/routes/graph/fetch_entity_relationships_graph.ts @@ -9,8 +9,18 @@ import type { Logger, IScopedClusterClient } from '@kbn/core/server'; import type { EsqlToRecords } from '@elastic/elasticsearch/lib/helpers'; import { getEntitiesLatestIndexName } from '@kbn/cloud-security-posture-common/utils/helpers'; import { ENTITY_RELATIONSHIP_FIELDS } from '@kbn/cloud-security-posture-common/constants'; -import { checkIfEntitiesIndexLookupMode, formatJsonProperty } from './utils'; -import type { EntityId, RelationshipEdge } from './types'; +import { + checkIfEntitiesIndexLookupMode, + concatJsonObjectPropertyBool, + concatJsonObjectPropertyEsqlExpr, + concatJsonObjectPropertyString, + concatJsonObjectPropertyEsqlExprSafe, + JSON_OBJECT_END, + JSON_OBJECT_SEPARATOR, + JSON_OBJECT_START, + concatJsonObjectPropertyEsqlExprAsString, +} from './utils'; +import type { EntityId, EntityRecord, RelationshipEdge } from './types'; interface BuildRelationshipsEsqlQueryParams { indexName: string; @@ -51,6 +61,7 @@ const buildRelationshipsEsqlQuery = ({ | RENAME _source_type = entity.type | RENAME _source_sub_type = entity.sub_type | RENAME _source_host_ip = host.ip +| RENAME _source_engine_metadata_type = entity.EngineMetadata.Type // Lookup target entity metadata | EVAL entity.id = _target_id | LOOKUP JOIN ${indexName} ON entity.id @@ -58,12 +69,14 @@ const buildRelationshipsEsqlQuery = ({ | RENAME _target_type = entity.type | RENAME _target_sub_type = entity.sub_type | RENAME _target_host_ip = host.ip +| RENAME _target_engine_metadata_type = entity.EngineMetadata.Type // Restore source entity fields | RENAME entity.id = _source_id | RENAME entity.name = _source_name | RENAME entity.type = _source_type | RENAME entity.sub_type = _source_sub_type -| RENAME host.ip = _source_host_ip`; +| RENAME host.ip = _source_host_ip +| RENAME entity.EngineMetadata.Type = _source_engine_metadata_type`; return `FROM ${indexName} ${coalesceStatements} @@ -72,31 +85,66 @@ ${forkBranches} | WHERE _target_id != "" ${enrichmentSection} // Build enriched actors doc data with entity metadata (from the queried entity) -| EVAL actorDocData = CONCAT("{\\"id\\":\\"", entity.id, "\\",\\"type\\":\\"entity\\",\\"entity\\":{", - "\\"availableInEntityStore\\":true", - ${formatJsonProperty('name', 'entity.name')}, - ${formatJsonProperty('type', 'entity.type')}, - ${formatJsonProperty('sub_type', 'entity.sub_type')}, - CASE( - host.ip IS NOT NULL, - CONCAT(",\\"host\\":", "{", "\\"ip\\":\\"", TO_STRING(host.ip), "\\"", "}"), - "" - ), - ",\\"sourceFields\\":{\\"entity.id\\":\\"", entity.id, "\\"}", - "}}") +| EVAL actorDocData = CONCAT(${JSON_OBJECT_START}, + ${concatJsonObjectPropertyEsqlExprSafe('id', 'entity.id')}, + ${JSON_OBJECT_SEPARATOR}, ${concatJsonObjectPropertyString('type', 'entity')}, + ${JSON_OBJECT_SEPARATOR}, "\\"entity\\":", ${JSON_OBJECT_START}, + ${concatJsonObjectPropertyBool('availableInEntityStore', true)}, + CASE(entity.name IS NOT NULL, CONCAT(${JSON_OBJECT_SEPARATOR}, + ${concatJsonObjectPropertyEsqlExprAsString('name', 'entity.name')}), ""), + CASE(entity.type IS NOT NULL, CONCAT(${JSON_OBJECT_SEPARATOR}, + ${concatJsonObjectPropertyEsqlExprAsString('type', 'entity.type')}), ""), + CASE(entity.sub_type IS NOT NULL, CONCAT(${JSON_OBJECT_SEPARATOR}, + ${concatJsonObjectPropertyEsqlExprAsString('sub_type', 'entity.sub_type')}), ""), + CASE(entity.EngineMetadata.Type IS NOT NULL, CONCAT(${JSON_OBJECT_SEPARATOR}, + ${concatJsonObjectPropertyEsqlExprAsString( + 'engine_type', + 'entity.EngineMetadata.Type' + )}), ""), + CASE( + host.ip IS NOT NULL, + CONCAT(${JSON_OBJECT_SEPARATOR}, "\\"host\\":", ${JSON_OBJECT_START}, + "\\"ip\\":\\"", TO_STRING(host.ip), "\\"", + ${JSON_OBJECT_END}), + "" + ), + ${JSON_OBJECT_SEPARATOR}, "\\"sourceFields\\":", ${JSON_OBJECT_START}, + ${concatJsonObjectPropertyEsqlExprAsString('entity.id', 'entity.id')}, + ${JSON_OBJECT_END}, + ${JSON_OBJECT_END}, + ${JSON_OBJECT_END}) // Build enriched targets doc data with entity metadata -| EVAL targetDocData = CONCAT("{\\"id\\":\\"", _target_id, "\\",\\"type\\":\\"entity\\",\\"entity\\":{", - "\\"availableInEntityStore\\":", CASE(_target_name IS NOT NULL OR _target_type IS NOT NULL, "true", "false"), - ${formatJsonProperty('name', '_target_name')}, - ${formatJsonProperty('type', '_target_type')}, - ${formatJsonProperty('sub_type', '_target_sub_type')}, +| EVAL targetDocData = CONCAT(${JSON_OBJECT_START}, + ${concatJsonObjectPropertyEsqlExprSafe('id', '_target_id')}, + ${JSON_OBJECT_SEPARATOR}, ${concatJsonObjectPropertyString('type', 'entity')}, + ${JSON_OBJECT_SEPARATOR}, "\\"entity\\":", ${JSON_OBJECT_START}, + ${concatJsonObjectPropertyEsqlExpr( + 'availableInEntityStore', + 'CASE(_target_name IS NOT NULL OR _target_type IS NOT NULL, "true", "false")' + )}, + CASE(_target_name IS NOT NULL, CONCAT(${JSON_OBJECT_SEPARATOR}, + ${concatJsonObjectPropertyEsqlExprAsString('name', '_target_name')}), ""), + CASE(_target_type IS NOT NULL, CONCAT(${JSON_OBJECT_SEPARATOR}, + ${concatJsonObjectPropertyEsqlExprAsString('type', '_target_type')}), ""), + CASE(_target_sub_type IS NOT NULL, CONCAT(${JSON_OBJECT_SEPARATOR}, + ${concatJsonObjectPropertyEsqlExprAsString('sub_type', '_target_sub_type')}), ""), CASE( _target_host_ip IS NOT NULL, - CONCAT(",\\"host\\":", "{", "\\"ip\\":\\"", TO_STRING(_target_host_ip), "\\"", "}"), + CONCAT(${JSON_OBJECT_SEPARATOR}, "\\"host\\":", ${JSON_OBJECT_START}, + "\\"ip\\":\\"", TO_STRING(_target_host_ip), "\\"", + ${JSON_OBJECT_END}), "" ), - ",\\"sourceFields\\":{\\"entity.id\\":\\"", _target_id, "\\"}", - "}}") + CASE(_target_engine_metadata_type IS NOT NULL, CONCAT(${JSON_OBJECT_SEPARATOR}, + ${concatJsonObjectPropertyEsqlExprAsString( + 'engine_type', + '_target_engine_metadata_type' + )}), ""), + ${JSON_OBJECT_SEPARATOR}, "\\"sourceFields\\":", ${JSON_OBJECT_START}, + ${concatJsonObjectPropertyEsqlExprAsString('entity.id', '_target_id')}, + ${JSON_OBJECT_END}, + ${JSON_OBJECT_END}, +${JSON_OBJECT_END}) // Group by actor entity, relationship, and target type/subtype (for target grouping) // This ensures targets with the same type are grouped together | STATS badge = COUNT(*), @@ -228,3 +276,76 @@ export const fetchEntityRelationships = async ({ throw error; } }; + +export const fetchEntities = async ({ + esClient, + logger, + entityIds, + spaceId, +}: { + esClient: IScopedClusterClient; + logger: Logger; + entityIds: EntityId[]; + spaceId: string; +}): Promise> => { + const indexName = getEntitiesLatestIndexName(spaceId); + + logger.trace(`Fetching entities from index [${indexName}] for ${entityIds.length} entities`); + const esqlQuery = `FROM ${indexName} + | WHERE entity.id IN (${entityIds.map((_, idx) => `?entityId${idx}`).join(',')}) + | EVAL id = entity.id + | EVAL name = entity.name + | EVAL type = entity.type + | EVAL sub_type = entity.sub_type + | EVAL docData = CONCAT(${JSON_OBJECT_START}, + ${concatJsonObjectPropertyEsqlExprAsString('id', 'entity.id')}, + ${JSON_OBJECT_SEPARATOR}, ${concatJsonObjectPropertyString('type', 'entity')}, + ${JSON_OBJECT_SEPARATOR}, "\\"entity\\":", ${JSON_OBJECT_START}, + ${concatJsonObjectPropertyBool('availableInEntityStore', true)}, + CASE(entity.name IS NOT NULL, CONCAT(${JSON_OBJECT_SEPARATOR}, + ${concatJsonObjectPropertyEsqlExprAsString('name', 'entity.name')}), ""), + CASE(entity.type IS NOT NULL, CONCAT(${JSON_OBJECT_SEPARATOR}, + ${concatJsonObjectPropertyEsqlExprAsString('type', 'entity.type')}), ""), + CASE(entity.sub_type IS NOT NULL, CONCAT(${JSON_OBJECT_SEPARATOR}, + ${concatJsonObjectPropertyEsqlExprAsString('sub_type', 'entity.sub_type')}), ""), + CASE(entity.EngineMetadata.Type IS NOT NULL, CONCAT(${JSON_OBJECT_SEPARATOR}, + ${concatJsonObjectPropertyEsqlExprAsString( + 'engine_type', + 'entity.EngineMetadata.Type' + )}), ""), + CASE( + host.ip IS NOT NULL, + CONCAT(${JSON_OBJECT_SEPARATOR}, "\\"host\\":", ${JSON_OBJECT_START}, + "\\"ip\\":\\"", TO_STRING(host.ip), "\\"", + ${JSON_OBJECT_END}), + "" + ), + ${JSON_OBJECT_SEPARATOR}, "\\"sourceFields\\":", ${JSON_OBJECT_START}, + ${concatJsonObjectPropertyEsqlExprAsString('entity.id', 'entity.id')}, + ${JSON_OBJECT_END}, + ${JSON_OBJECT_END}, + ${JSON_OBJECT_END}) + | KEEP id, name, type, sub_type, docData`; + logger.trace(`Entities ES|QL query: ${esqlQuery}`); + + try { + const response = await esClient.asCurrentUser.helpers + .esql({ + columnar: false, + query: esqlQuery, + // @ts-ignore - types are not up to date + params: [...entityIds.map((entity, idx) => ({ [`entityId${idx}`]: entity.id }))], + }) + .toRecords(); + + logger.trace(`Fetched [${response.records.length}] entity records`); + return response; + } catch (error) { + // If the index doesn't exist, return empty result + if (error.statusCode === 404) { + logger.debug(`Entities index ${indexName} does not exist, skipping entities fetch`); + return { columns: [], records: [] }; + } + throw error; + } +}; diff --git a/x-pack/solutions/security/plugins/cloud_security_posture/server/routes/graph/fetch_events_graph.ts b/x-pack/solutions/security/plugins/cloud_security_posture/server/routes/graph/fetch_events_graph.ts index 03d17694bf9fd..5526fb42e754f 100644 --- a/x-pack/solutions/security/plugins/cloud_security_posture/server/routes/graph/fetch_events_graph.ts +++ b/x-pack/solutions/security/plugins/cloud_security_posture/server/routes/graph/fetch_events_graph.ts @@ -24,7 +24,17 @@ import { getEuidEsqlEvaluation, getFieldEvaluationsEsql, } from '@kbn/entity-store/common/domain/euid'; -import { formatJsonProperty, buildEntityEnrichment, checkIfEntitiesIndexLookupMode } from './utils'; +import { + concatJsonObjectPropertyEsqlExprSafe, + buildEntityEnrichment, + checkIfEntitiesIndexLookupMode, + concatJsonObjectPropertyBool, + JSON_OBJECT_START, + JSON_OBJECT_END, + JSON_OBJECT_SEPARATOR, + concatJsonObjectPropertyEsqlExprAsString, + concatJsonObjectPropertyString, +} from './utils'; import { getTargetEuidEsqlEvaluation } from './target_euid'; import { SECURITY_ALERTS_PARTIAL_IDENTIFIER } from '../../../common/constants'; import type { EsQuery, OriginEventId, EventEdge } from './types'; @@ -341,7 +351,7 @@ const TYPED_ENTITY_PREFIXES = ['user', 'host', 'service'] as const; * For typed entities, values come from saved _sf_* variables (pre-LOOKUP JOIN). * For generic entities, the value is the EUID column itself (which IS the raw * entity.id value post MV_EXPAND). - * Uses REPLACE to fix the "{," pattern that occurs when the first property is null. + * Uses REPLACE to fix null properties. */ const buildSourceFieldsJson = (fields: readonly string[], euidColumn: string): string => { const properties = fields @@ -351,10 +361,8 @@ const buildSourceFieldsJson = (fields: readonly string[], euidColumn: string): s if (typePrefix) { // Typed field: only include when EUID matches the entity type const column = ENTITY_FIELD_COLUMN_MAP[field] ?? `\`${field}\``; - return `CASE(STARTS_WITH(${euidColumn}, "${typePrefix}:"), ${formatJsonProperty( - field, - `TO_STRING(${column})` - )}, "")`; + return `CASE(STARTS_WITH(${euidColumn}, "${typePrefix}:"), + ${concatJsonObjectPropertyEsqlExprSafe(field, `TO_STRING(${column})`)}, "")`; } // Generic field: include when EUID doesn't match any typed prefix @@ -362,13 +370,16 @@ const buildSourceFieldsJson = (fields: readonly string[], euidColumn: string): s const notTypedCondition = TYPED_ENTITY_PREFIXES.map( (p) => `NOT STARTS_WITH(${euidColumn}, "${p}:")` ).join(' AND '); - return `CASE(${notTypedCondition}, ${formatJsonProperty( - field, - `TO_STRING(${euidColumn})` - )}, "")`; + return `CASE(${notTypedCondition}, + ${concatJsonObjectPropertyEsqlExprSafe(field, `TO_STRING(${euidColumn})`)}, "")`; }) - .join(',\n '); - return `REPLACE(CONCAT(",\\"sourceFields\\":{", ${properties}, "}"), "\\\\{,", "{")`; + .join(`, ${JSON_OBJECT_SEPARATOR},\n `); + return ` + REPLACE( + REPLACE( + REPLACE(CONCAT("\\"sourceFields\\":", ${JSON_OBJECT_START}, ${properties}, ${JSON_OBJECT_END}), "[,]+", ","), + "\\\\{,", ${JSON_OBJECT_START}), + ",}", ${JSON_OBJECT_END})`; }; const buildActorSourceFieldsEsql = (): string => @@ -388,41 +399,56 @@ const buildEnrichedEntityFieldsEsql = (): string => { // Put required fields first (no comma prefix), optional fields use comma prefix | EVAL actorEntityField = CASE( actorEntityName IS NOT NULL OR actorEntityType IS NOT NULL OR actorEntitySubType IS NOT NULL, - CONCAT(",\\"entity\\":", "{", - "\\"availableInEntityStore\\":true", - ${formatJsonProperty('name', 'actorEntityName')}, - ${formatJsonProperty('type', 'actorEntityType')}, - ${formatJsonProperty('sub_type', 'actorEntitySubType')}, + CONCAT("\\"entity\\":", + ${JSON_OBJECT_START}, + ${concatJsonObjectPropertyBool('availableInEntityStore', true)}, + CASE(actorEntityName IS NOT NULL, CONCAT(${JSON_OBJECT_SEPARATOR}, + ${concatJsonObjectPropertyEsqlExprAsString('name', 'actorEntityName')}), ""), + CASE(actorEntityType IS NOT NULL, CONCAT(${JSON_OBJECT_SEPARATOR}, + ${concatJsonObjectPropertyEsqlExprAsString('type', 'actorEntityType')}), ""), + CASE(actorEntitySubType IS NOT NULL, CONCAT(${JSON_OBJECT_SEPARATOR}, + ${concatJsonObjectPropertyEsqlExprAsString('sub_type', 'actorEntitySubType')}), ""), + CASE(actorEntityEngineType IS NOT NULL, CONCAT(${JSON_OBJECT_SEPARATOR}, + ${concatJsonObjectPropertyEsqlExprAsString('engine_type', 'actorEntityEngineType')}), ""), CASE( actorHostIp IS NOT NULL, - CONCAT(",\\"host\\":", "{", "\\"ip\\":\\"", TO_STRING(actorHostIp), "\\"", "}"), - "" - ), - ${buildActorSourceFieldsEsql()}, - "}"), - CONCAT(",\\"entity\\":", "{", - "\\"availableInEntityStore\\":false", - ${buildActorSourceFieldsEsql()}, - "}") + CONCAT(${JSON_OBJECT_SEPARATOR}, "\\"host\\":", + ${JSON_OBJECT_START}, + "\\"ip\\":\\"", TO_STRING(actorHostIp), "\\"", + ${JSON_OBJECT_END}), ""), + ${JSON_OBJECT_SEPARATOR}, ${buildActorSourceFieldsEsql()}, + ${JSON_OBJECT_END}), + CONCAT("\\"entity\\":", ${JSON_OBJECT_START}, + ${concatJsonObjectPropertyBool('availableInEntityStore', false)}, + ${JSON_OBJECT_SEPARATOR}, ${buildActorSourceFieldsEsql()}, + ${JSON_OBJECT_END}) ) | EVAL targetEntityField = CASE( targetEntityName IS NOT NULL OR targetEntityType IS NOT NULL OR targetEntitySubType IS NOT NULL, - CONCAT(",\\"entity\\":", "{", - "\\"availableInEntityStore\\":true", - ${formatJsonProperty('name', 'targetEntityName')}, - ${formatJsonProperty('type', 'targetEntityType')}, - ${formatJsonProperty('sub_type', 'targetEntitySubType')}, + CONCAT("\\"entity\\":", + ${JSON_OBJECT_START}, + ${concatJsonObjectPropertyBool('availableInEntityStore', true)}, + CASE(targetEntityName IS NOT NULL, CONCAT(${JSON_OBJECT_SEPARATOR}, + ${concatJsonObjectPropertyEsqlExprAsString('name', 'targetEntityName')}), ""), + CASE(targetEntityType IS NOT NULL, CONCAT(${JSON_OBJECT_SEPARATOR}, + ${concatJsonObjectPropertyEsqlExprAsString('type', 'targetEntityType')}), ""), + CASE(targetEntitySubType IS NOT NULL, CONCAT(${JSON_OBJECT_SEPARATOR}, + ${concatJsonObjectPropertyEsqlExprAsString('sub_type', 'targetEntitySubType')}), ""), + CASE(targetEntityEngineType IS NOT NULL, CONCAT(${JSON_OBJECT_SEPARATOR}, + ${concatJsonObjectPropertyEsqlExprAsString('engine_type', 'targetEntityEngineType')}), ""), CASE( targetHostIp IS NOT NULL, - CONCAT(",\\"host\\":", "{", "\\"ip\\":\\"", TO_STRING(targetHostIp), "\\"", "}"), - "" - ), - ${buildTargetSourceFieldsEsql()}, - "}"), - CONCAT(",\\"entity\\":", "{", - "\\"availableInEntityStore\\":false", - ${buildTargetSourceFieldsEsql()}, - "}") + CONCAT(${JSON_OBJECT_SEPARATOR}, "\\"host\\":", + ${JSON_OBJECT_START}, + "\\"ip\\":\\"", TO_STRING(targetHostIp), "\\"", + ${JSON_OBJECT_END}), ""), + ${JSON_OBJECT_SEPARATOR}, ${buildTargetSourceFieldsEsql()}, + ${JSON_OBJECT_END}), + CONCAT("\\"entity\\":", + ${JSON_OBJECT_START}, + ${concatJsonObjectPropertyBool('availableInEntityStore', false)}, + ${JSON_OBJECT_SEPARATOR}, ${buildTargetSourceFieldsEsql()}, + ${JSON_OBJECT_END}) )`; }; @@ -455,37 +481,41 @@ ${buildEntityEnrichment(isLookupIndexAvailable, spaceId)} ${buildEnrichedEntityFieldsEsql()} ` : ` -| EVAL actorEntityField = CONCAT(",\\"entity\\":", "{", - "\\"availableInEntityStore\\":false", - ${buildActorSourceFieldsEsql()}, - "}") -| EVAL targetEntityField = CONCAT(",\\"entity\\":", "{", - "\\"availableInEntityStore\\":false", - ${buildTargetSourceFieldsEsql()}, - "}") +| EVAL actorEntityField = CONCAT("\\"entity\\":", + ${JSON_OBJECT_START}, + ${concatJsonObjectPropertyBool('availableInEntityStore', false)}, + ${JSON_OBJECT_SEPARATOR}, ${buildActorSourceFieldsEsql()}, + ${JSON_OBJECT_END}) +| EVAL targetEntityField = CONCAT("\\"entity\\":", + ${JSON_OBJECT_START}, + ${concatJsonObjectPropertyBool('availableInEntityStore', false)}, + ${JSON_OBJECT_SEPARATOR}, ${buildTargetSourceFieldsEsql()}, + ${JSON_OBJECT_END}) // Fallback to null string with non-enriched entity metadata | EVAL actorEntityName = TO_STRING(null) | EVAL actorEntityType = TO_STRING(null) | EVAL actorEntitySubType = TO_STRING(null) | EVAL actorHostIp = TO_STRING(null) +| EVAL actorEntityEngineType = TO_STRING(null) | EVAL targetEntityName = TO_STRING(null) | EVAL targetEntityType = TO_STRING(null) | EVAL targetEntitySubType = TO_STRING(null) | EVAL targetHostIp = TO_STRING(null) +| EVAL targetEntityEngineType = TO_STRING(null) ` } // Create actor and target data with entity data -| EVAL actorDocData = CONCAT("{", - "\\"id\\":\\"", actorEntityId, "\\"", - ",\\"type\\":\\"", "${DOCUMENT_TYPE_ENTITY}", "\\"", - actorEntityField, - "}") -| EVAL targetDocData = CONCAT("{", - "\\"id\\":\\"", COALESCE(targetEntityId, ""), "\\"", - ",\\"type\\":\\"", "${DOCUMENT_TYPE_ENTITY}", "\\"", - targetEntityField, - "}") +| EVAL actorDocData = CONCAT(${JSON_OBJECT_START}, + ${concatJsonObjectPropertyEsqlExprAsString('id', 'actorEntityId')}, + ${JSON_OBJECT_SEPARATOR}, ${concatJsonObjectPropertyString('type', DOCUMENT_TYPE_ENTITY)}, + ${JSON_OBJECT_SEPARATOR}, actorEntityField, + ${JSON_OBJECT_END}) +| EVAL targetDocData = CONCAT(${JSON_OBJECT_START}, + ${concatJsonObjectPropertyEsqlExprAsString('id', 'COALESCE(targetEntityId, "")')}, + ${JSON_OBJECT_SEPARATOR}, ${concatJsonObjectPropertyString('type', DOCUMENT_TYPE_ENTITY)}, + ${JSON_OBJECT_SEPARATOR}, targetEntityField, + ${JSON_OBJECT_END}) // Map host and source values to enriched contextual data | EVAL sourceIps = source.ip diff --git a/x-pack/solutions/security/plugins/cloud_security_posture/server/routes/graph/fetch_graph.test.ts b/x-pack/solutions/security/plugins/cloud_security_posture/server/routes/graph/fetch_graph.test.ts index c111c311e3549..ab309aca09b54 100644 --- a/x-pack/solutions/security/plugins/cloud_security_posture/server/routes/graph/fetch_graph.test.ts +++ b/x-pack/solutions/security/plugins/cloud_security_posture/server/routes/graph/fetch_graph.test.ts @@ -9,7 +9,7 @@ import { loggingSystemMock } from '@kbn/core-logging-server-mocks'; import { elasticsearchServiceMock } from '@kbn/core/server/mocks'; import { fetchGraph } from './fetch_graph'; import { fetchEvents } from './fetch_events_graph'; -import { fetchEntityRelationships } from './fetch_entity_relationships_graph'; +import { fetchEntityRelationships, fetchEntities } from './fetch_entity_relationships_graph'; import type { EventEdge, RelationshipEdge } from './types'; jest.mock('./fetch_events_graph'); @@ -19,6 +19,7 @@ const mockedFetchEvents = fetchEvents as jest.MockedFunction const mockedFetchEntityRelationships = fetchEntityRelationships as jest.MockedFunction< typeof fetchEntityRelationships >; +const mockedFetchEntities = fetchEntities as jest.MockedFunction; describe('fetchGraph', () => { const esClient = elasticsearchServiceMock.createScopedClusterClient(); @@ -75,6 +76,10 @@ describe('fetchGraph', () => { columns: [], records: mockRelationshipRecords, } as any); + mockedFetchEntities.mockResolvedValue({ + columns: [], + records: [], + } as any); }); afterEach(() => { diff --git a/x-pack/solutions/security/plugins/cloud_security_posture/server/routes/graph/fetch_graph.ts b/x-pack/solutions/security/plugins/cloud_security_posture/server/routes/graph/fetch_graph.ts index c47860af2fe9c..3373e03b8ad96 100644 --- a/x-pack/solutions/security/plugins/cloud_security_posture/server/routes/graph/fetch_graph.ts +++ b/x-pack/solutions/security/plugins/cloud_security_posture/server/routes/graph/fetch_graph.ts @@ -8,8 +8,15 @@ import type { Logger, IScopedClusterClient } from '@kbn/core/server'; import type { EsqlToRecords } from '@elastic/elasticsearch/lib/helpers'; import { fetchEvents } from './fetch_events_graph'; -import { fetchEntityRelationships } from './fetch_entity_relationships_graph'; -import type { EsQuery, EntityId, OriginEventId, EventEdge, RelationshipEdge } from './types'; +import { fetchEntities, fetchEntityRelationships } from './fetch_entity_relationships_graph'; +import type { + EsQuery, + EntityId, + OriginEventId, + EventEdge, + RelationshipEdge, + EntityRecord, +} from './types'; export interface FetchGraphParams { esClient: IScopedClusterClient; @@ -28,10 +35,12 @@ export interface FetchGraphParams { export interface FetchGraphResult { events: EventEdge[]; relationships: RelationshipEdge[]; + entities: EntityRecord[]; } const emptyEventsResult: EsqlToRecords = { columns: [], records: [] }; const emptyRelationshipsResult: EsqlToRecords = { columns: [], records: [] }; +const emptyEntitiesResult: EsqlToRecords = { columns: [], records: [] }; /** * Fetches graph data including both events and entity relationships. @@ -92,10 +101,25 @@ export const fetchGraph = async ({ }) : Promise.resolve(emptyRelationshipsResult); - // Wait for both in parallel - const [eventsResult, relationshipsResult] = await Promise.all([ + // We fetch the entities just in case they don't have any relationships. We would still like to see them in the graph. + // These entities suppose to be pinned anyway. So there's no worry that they might be part of a group. + const entitiesPromise = hasEntityIds + ? fetchEntities({ + esClient, + logger, + entityIds, + spaceId, + }).catch((error) => { + logger.error(`Failed to fetch entities: ${error.message}`); + throw error; + }) + : Promise.resolve(emptyEntitiesResult); + + // Wait for all in parallel + const [eventsResult, relationshipsResult, entitiesResult] = await Promise.all([ eventsPromise, relationshipsPromise, + entitiesPromise, ]); logger.trace( @@ -105,5 +129,6 @@ export const fetchGraph = async ({ return { events: eventsResult.records, relationships: relationshipsResult.records, + entities: entitiesResult.records, }; }; diff --git a/x-pack/solutions/security/plugins/cloud_security_posture/server/routes/graph/parse_records.test.ts b/x-pack/solutions/security/plugins/cloud_security_posture/server/routes/graph/parse_records.test.ts index 25808f346adc1..74e5ce3d5ae1c 100644 --- a/x-pack/solutions/security/plugins/cloud_security_posture/server/routes/graph/parse_records.test.ts +++ b/x-pack/solutions/security/plugins/cloud_security_posture/server/routes/graph/parse_records.test.ts @@ -446,7 +446,7 @@ describe('parseRecords', () => { ]; // nodesLimit = 2, so only first record should be processed // First record creates 3 nodes (actor group, target group, label) - const result = parseRecords(mockLogger, records, [], 2); + const result = parseRecords(mockLogger, records, [], [], 2); expect(result.nodes.length).toBeLessThanOrEqual(3); expect(result.messages).toContain(ApiMessageCode.ReachedNodesLimit); }); @@ -538,7 +538,7 @@ describe('parseRecords', () => { // - First relationship record would add 3 more nodes (rel-actor-1, rel-target-1, rel(Owns)) // reaching 6 nodes total, but the limit check before the 2nd relationship stops further processing // - Second relationship record should NOT be processed - const result = parseRecords(mockLogger, eventRecords, relationshipRecords, 5); + const result = parseRecords(mockLogger, eventRecords, relationshipRecords, [], 5); // Verify that the second relationship record was skipped due to the shared limit const nodeIds = result.nodes.map((n) => n.id); diff --git a/x-pack/solutions/security/plugins/cloud_security_posture/server/routes/graph/parse_records.ts b/x-pack/solutions/security/plugins/cloud_security_posture/server/routes/graph/parse_records.ts index 247bf6f2a601d..4b6d341e8f0ba 100644 --- a/x-pack/solutions/security/plugins/cloud_security_posture/server/routes/graph/parse_records.ts +++ b/x-pack/solutions/security/plugins/cloud_security_posture/server/routes/graph/parse_records.ts @@ -27,6 +27,7 @@ import { ENTITY_RELATIONSHIP_LABELS } from '@kbn/cloud-security-posture-common/c import { type EventEdge, type RelationshipEdge, + type EntityRecord, NON_ENRICHED_ENTITY_TYPE_PLURAL, NON_ENRICHED_ENTITY_TYPE_SINGULAR, } from './types'; @@ -104,6 +105,7 @@ export const parseRecords = ( logger: Logger, eventRecords: EventEdge[] = [], relationshipRecords: RelationshipEdge[] = [], + entityRecords: EntityRecord[] = [], nodesLimit?: number ): Pick => { const ctx: ParseContext = { @@ -118,7 +120,7 @@ export const parseRecords = ( logger.trace( `Parsing records [events: ${eventRecords.length}] [relationships: ${ relationshipRecords.length - }] [nodesLimit: ${nodesLimit ?? 'none'}]` + }] [entities: ${entityRecords.length}] [nodesLimit: ${nodesLimit ?? 'none'}]` ); // Process event records @@ -142,6 +144,23 @@ export const parseRecords = ( // Create edges and groups for both createEdgesAndGroups(ctx); + for (const entity of entityRecords) { + if (ctx.nodesMap[entity.id] === undefined) { + createEntityNode( + ctx.nodesMap, + { + nodeId: entity.id, + idsCount: 1, + entityType: entity.type, + entitySubType: entity.sub_type, + entityName: entity.name, + docData: entity.docData ? castArray(entity.docData) : [], + }, + ctx.logger + ); + } + } + logger.trace( `Parsed [nodes: ${Object.keys(ctx.nodesMap).length}, edges: ${ Object.keys(ctx.edgesMap).length @@ -230,7 +249,8 @@ const createEntityNode = ( entityName?: string | string[] | null; docData?: Array | string; hostIps?: string[]; - } + }, + logger?: Logger ): void => { const { nodeId, idsCount, entityType, entitySubType, entityName, docData, hostIps } = params; @@ -243,7 +263,15 @@ const createEntityNode = ( docData ? castArray(docData) .filter((d): d is string => d != null) - .map((d) => JSON.parse(d)) + .map((d) => { + try { + return JSON.parse(d); + } catch (e) { + logger?.error(`Failed to parse document data for node [${nodeId}]: ${e}`); + logger?.trace(d); + throw e; + } + }) : [] ); @@ -284,29 +312,37 @@ const createGroupedActorAndTargetNodes = ( } = record; // Create actor entity node - createEntityNode(nodesMap, { - nodeId: actorNodeId, - idsCount: actorIdsCount, - entityType: actorEntityType, - entitySubType: actorEntitySubType, - entityName: actorEntityName, - docData: actorsDocData, - hostIps: actorHostIps ? castArray(actorHostIps) : [], - }); + createEntityNode( + nodesMap, + { + nodeId: actorNodeId, + idsCount: actorIdsCount, + entityType: actorEntityType, + entitySubType: actorEntitySubType, + entityName: actorEntityName, + docData: actorsDocData, + hostIps: actorHostIps ? castArray(actorHostIps) : [], + }, + context.logger + ); // Create target entity node (or unknown target) const targetId = targetIdsCount > 0 && targetNodeId ? targetNodeId : `unknown-${uuidv4()}`; if (targetIdsCount > 0 && targetNodeId) { - createEntityNode(nodesMap, { - nodeId: targetNodeId, - idsCount: targetIdsCount, - entityType: targetEntityType, - entitySubType: targetEntitySubType, - entityName: targetEntityName, - docData: targetsDocData, - hostIps: targetHostIps ? castArray(targetHostIps) : [], - }); + createEntityNode( + nodesMap, + { + nodeId: targetNodeId, + idsCount: targetIdsCount, + entityType: targetEntityType, + entitySubType: targetEntitySubType, + entityName: targetEntityName, + docData: targetsDocData, + hostIps: targetHostIps ? castArray(targetHostIps) : [], + }, + context.logger + ); } else if (nodesMap[targetId] === undefined) { // Unknown target nodesMap[targetId] = { @@ -445,25 +481,33 @@ const processRelationshipRecord = (record: RelationshipEdge, context: ParseConte const targetNodeId = record.targetNodeId; // Create actor and target entity nodes using shared helper - createEntityNode(context.nodesMap, { - nodeId: actorNodeId, - idsCount: record.actorIdsCount, - entityType: record.actorEntityType, - entitySubType: record.actorEntitySubType, - entityName: record.actorEntityName, - docData: record.actorsDocData, - hostIps: record.actorHostIps ? castArray(record.actorHostIps) : [], - }); + createEntityNode( + context.nodesMap, + { + nodeId: actorNodeId, + idsCount: record.actorIdsCount, + entityType: record.actorEntityType, + entitySubType: record.actorEntitySubType, + entityName: record.actorEntityName, + docData: record.actorsDocData, + hostIps: record.actorHostIps ? castArray(record.actorHostIps) : [], + }, + context.logger + ); - createEntityNode(context.nodesMap, { - nodeId: targetNodeId, - idsCount: record.targetIdsCount, - entityType: record.targetEntityType, - entitySubType: record.targetEntitySubType, - entityName: record.targetEntityName, - docData: record.targetsDocData, - hostIps: record.targetHostIps ? castArray(record.targetHostIps) : [], - }); + createEntityNode( + context.nodesMap, + { + nodeId: targetNodeId, + idsCount: record.targetIdsCount, + entityType: record.targetEntityType, + entitySubType: record.targetEntitySubType, + entityName: record.targetEntityName, + docData: record.targetsDocData, + hostIps: record.targetHostIps ? castArray(record.targetHostIps) : [], + }, + context.logger + ); // Create relationship node - ID is based on actor + relationship (relationshipNodeId) // so each actor+relationship combination gets one node that connects to all target groups diff --git a/x-pack/solutions/security/plugins/cloud_security_posture/server/routes/graph/types.ts b/x-pack/solutions/security/plugins/cloud_security_posture/server/routes/graph/types.ts index 61b7b7cfeb944..daa1761c93f28 100644 --- a/x-pack/solutions/security/plugins/cloud_security_posture/server/routes/graph/types.ts +++ b/x-pack/solutions/security/plugins/cloud_security_posture/server/routes/graph/types.ts @@ -92,6 +92,14 @@ export interface RelationshipEdge extends GraphEdge { targetIds: string[]; // All target entity IDs in this group } +export interface EntityRecord { + id: string; + name: string; + type: string; + sub_type: string; + docData: string; +} + /** * Entity ID with type for relationship queries. */ diff --git a/x-pack/solutions/security/plugins/cloud_security_posture/server/routes/graph/utils/esql_utils.test.ts b/x-pack/solutions/security/plugins/cloud_security_posture/server/routes/graph/utils/esql_utils.test.ts index 205c4dc15d0f5..e66dbdbf093f7 100644 --- a/x-pack/solutions/security/plugins/cloud_security_posture/server/routes/graph/utils/esql_utils.test.ts +++ b/x-pack/solutions/security/plugins/cloud_security_posture/server/routes/graph/utils/esql_utils.test.ts @@ -8,7 +8,7 @@ import { getFieldNamespace, generateFieldHintCases, - formatJsonProperty, + concatJsonObjectPropertyEsqlExprSafe, buildLookupJoinEsql, } from './esql_utils'; @@ -84,14 +84,14 @@ describe('ESQL utils', () => { }); describe('formatJsonProperty', () => { - it('should generate ESQL that outputs JSON property with comma prefix, or empty string if null', () => { - const result = formatJsonProperty('name', 'entityName'); + it('should generate ESQL that outputs JSON property, or empty string if null', () => { + const result = concatJsonObjectPropertyEsqlExprSafe('name', 'entityName'); - expect(result).toBe('COALESCE(CONCAT(",\\"name\\":\\"", entityName, "\\""), "")'); + expect(result).toBe('COALESCE(CONCAT("\\"name\\":\\"", entityName, "\\""), "")'); }); it('should include the property name and variable in the output', () => { - const result = formatJsonProperty('customProp', 'customVar'); + const result = concatJsonObjectPropertyEsqlExprSafe('customProp', 'customVar'); expect(result).toContain('customProp'); expect(result).toContain('customVar'); diff --git a/x-pack/solutions/security/plugins/cloud_security_posture/server/routes/graph/utils/esql_utils.ts b/x-pack/solutions/security/plugins/cloud_security_posture/server/routes/graph/utils/esql_utils.ts index 0a1112f55b966..68499c7c94de8 100644 --- a/x-pack/solutions/security/plugins/cloud_security_posture/server/routes/graph/utils/esql_utils.ts +++ b/x-pack/solutions/security/plugins/cloud_security_posture/server/routes/graph/utils/esql_utils.ts @@ -54,13 +54,10 @@ export const generateFieldHintCases = (fields: readonly string[], entityIdVar: s }; /** - * Generates an ESQL expression that formats a JSON property with comma prefix. + * Generates an ESQL expression that formats a JSON property. * If the value is NOT NULL, it returns the full property with quoted value. * If the value is NULL, it returns an empty string (property is omitted entirely). * - * Always includes comma prefix - place required properties first in the JSON - * object so optional properties using this function come after. - * * @param propertyName - The JSON property name (e.g., "name", "type", "sub_type") * @param valueVar - The ESQL variable name containing the value * @returns ESQL expression that outputs a JSON property or empty string @@ -75,12 +72,44 @@ export const generateFieldHintCases = (fields: readonly string[], entityIdVar: s * CONCAT("{", "\"required\":true", formatJsonProperty('optional', 'val'), "}") * ``` */ -export const formatJsonProperty = (propertyName: string, valueVar: string): string => { +export const concatJsonObjectPropertyEsqlExprSafe = ( + propertyName: string, + esqlVariable: string +): string => { // CONCAT returns null if any argument is null, so if valueVar is null, // the entire CONCAT returns null, and COALESCE returns empty string - return `COALESCE(CONCAT(",\\"${propertyName}\\":\\"", ${valueVar}, "\\""), "")`; + return `COALESCE(CONCAT("\\"${propertyName}\\":\\"", ${esqlVariable}, "\\""), "")`; +}; + +export const concatJsonObjectPropertyString = ( + propertyName: string, + stringValue: string +): string => { + return `CONCAT("\\"${propertyName}\\":\\"", "${stringValue}", "\\"")`; +}; + +export const concatJsonObjectPropertyBool = (propertyName: string, boolValue: boolean): string => { + return `CONCAT("\\"${propertyName}\\":", "${boolValue}")`; }; +export const concatJsonObjectPropertyEsqlExpr = ( + propertyName: string, + esqlExpr: string +): string => { + return `CONCAT("\\"${propertyName}\\":", ${esqlExpr})`; +}; + +export const concatJsonObjectPropertyEsqlExprAsString = ( + propertyName: string, + esqlExpr: string +): string => { + return `CONCAT("\\"${propertyName}\\":\\"", ${esqlExpr}, "\\"")`; +}; + +export const JSON_OBJECT_SEPARATOR = '","'; +export const JSON_OBJECT_START = '"{"'; +export const JSON_OBJECT_END = '"}"'; + /** * Generates ESQL statements for entity enrichment using LOOKUP JOIN. * This is the preferred method for enriching actor and target entities with entity store data. @@ -105,6 +134,7 @@ export const buildLookupJoinEsql = (lookupIndexName: string): string => { | RENAME actorEntitySubType = entity.sub_type | RENAME actorHostIp = host.ip | RENAME actorLookupEntityId = entity.id +| RENAME actorEntityEngineType = entity.EngineMetadata.Type | EVAL entity.id = targetEntityId | LOOKUP JOIN ${lookupIndexName} ON entity.id @@ -112,7 +142,8 @@ export const buildLookupJoinEsql = (lookupIndexName: string): string => { | RENAME targetEntityType = entity.type | RENAME targetEntitySubType = entity.sub_type | RENAME targetHostIp = host.ip -| RENAME targetLookupEntityId = entity.id`; +| RENAME targetLookupEntityId = entity.id +| RENAME targetEntityEngineType = entity.EngineMetadata.Type`; }; /** @@ -259,8 +290,10 @@ export const buildEntityEnrichment = (isLookupIndexAvailable: boolean, spaceId: | EVAL actorEntityType = TO_STRING(null) | EVAL actorEntitySubType = TO_STRING(null) | EVAL actorHostIp = TO_STRING(null) +| EVAL actorEntityEngineType = TO_STRING(null) | EVAL targetEntityName = TO_STRING(null) | EVAL targetEntityType = TO_STRING(null) | EVAL targetEntitySubType = TO_STRING(null) -| EVAL targetHostIp = TO_STRING(null)`; +| EVAL targetHostIp = TO_STRING(null) +| EVAL targetEntityEngineType = TO_STRING(null)`; }; diff --git a/x-pack/solutions/security/plugins/cloud_security_posture/server/routes/graph/v1.test.ts b/x-pack/solutions/security/plugins/cloud_security_posture/server/routes/graph/v1.test.ts index ea4cf652526c2..ffe4f0e4bc28a 100644 --- a/x-pack/solutions/security/plugins/cloud_security_posture/server/routes/graph/v1.test.ts +++ b/x-pack/solutions/security/plugins/cloud_security_posture/server/routes/graph/v1.test.ts @@ -25,7 +25,7 @@ describe('getGraph', () => { }); it('should call fetchGraph and parseRecords with correct parameters', async () => { - const fakeFetchResult = { events: ['event1', 'event2'], relationships: [] }; + const fakeFetchResult = { events: ['event1', 'event2'], relationships: [], entities: [] }; (fetchGraph as jest.Mock).mockResolvedValue(fakeFetchResult); const parsedResult = { nodes: ['node1'], edges: ['edge1'], messages: ['msg1'] }; @@ -69,6 +69,7 @@ describe('getGraph', () => { mockLogger, fakeFetchResult.events, fakeFetchResult.relationships, + fakeFetchResult.entities, 10 ); expect(result).toEqual(parsedResult); diff --git a/x-pack/solutions/security/plugins/cloud_security_posture/server/routes/graph/v1.ts b/x-pack/solutions/security/plugins/cloud_security_posture/server/routes/graph/v1.ts index 3b40e8393e403..b354d93192543 100644 --- a/x-pack/solutions/security/plugins/cloud_security_posture/server/routes/graph/v1.ts +++ b/x-pack/solutions/security/plugins/cloud_security_posture/server/routes/graph/v1.ts @@ -55,7 +55,7 @@ export const getGraph = async ({ }] in [spaceId: ${spaceId}] [indexPatterns: ${indexPatterns.join(',')}]` ); - const { events, relationships } = await fetchGraph({ + const { events, relationships, entities } = await fetchGraph({ esClient, logger, start, @@ -69,5 +69,5 @@ export const getGraph = async ({ entityIds, }); - return parseRecords(logger, events, relationships, nodesLimit); + return parseRecords(logger, events, relationships, entities, nodesLimit); }; diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/lib/telemetry/events/entity_analytics/types.ts b/x-pack/solutions/security/plugins/security_solution/public/common/lib/telemetry/events/entity_analytics/types.ts index 5fca6129f0322..6e58fd1a82dfa 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/common/lib/telemetry/events/entity_analytics/types.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/common/lib/telemetry/events/entity_analytics/types.ts @@ -14,6 +14,7 @@ export enum EntityEventTypes { EntityRiskFiltered = 'Entity Risk Filtered', EntityStoreEnablementToggleClicked = 'Entity Store Enablement Toggle Clicked', EntityStoreDashboardInitButtonClicked = 'Entity Store Initialization Button Clicked', + EntityGraphClicked = 'Entity Graph Clicked', ToggleRiskSummaryClicked = 'Toggle Risk Summary Clicked', AddRiskInputToTimelineClicked = 'Add Risk Input To Timeline Clicked', RiskInputsExpandedFlyoutOpened = 'Risk Inputs Expanded Flyout Opened', @@ -51,6 +52,8 @@ interface ReportToggleRiskSummaryClickedParams extends EntityParam { type ReportRiskInputsExpandedFlyoutOpenedParams = EntityParam; +type ReportEntityGraphViewClickedParams = EntityParam; + interface ReportAddRiskInputToTimelineClickedParams { quantity: number; } @@ -113,6 +116,7 @@ export interface EntityAnalyticsTelemetryEventsMap { [EntityEventTypes.EntityRiskFiltered]: ReportEntityRiskFilteredParams; [EntityEventTypes.EntityStoreEnablementToggleClicked]: ReportEntityStoreEnablementParams; [EntityEventTypes.EntityStoreDashboardInitButtonClicked]: ReportEntityStoreInitParams; + [EntityEventTypes.EntityGraphClicked]: ReportEntityGraphViewClickedParams; [EntityEventTypes.ToggleRiskSummaryClicked]: ReportToggleRiskSummaryClickedParams; [EntityEventTypes.AddRiskInputToTimelineClicked]: ReportAddRiskInputToTimelineClickedParams; [EntityEventTypes.RiskInputsExpandedFlyoutOpened]: ReportRiskInputsExpandedFlyoutOpenedParams; diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/mock/storybook_providers.tsx b/x-pack/solutions/security/plugins/security_solution/public/common/mock/storybook_providers.tsx index 7a1a97e9aa557..8a8eb0c80eaac 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/common/mock/storybook_providers.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/common/mock/storybook_providers.tsx @@ -9,16 +9,29 @@ import { euiLightVars } from '@kbn/ui-theme'; import type { FC, PropsWithChildren } from 'react'; import React from 'react'; import { Provider as ReduxStoreProvider } from 'react-redux'; -import { Subject } from 'rxjs'; +import { BehaviorSubject, Subject, of } from 'rxjs'; import { ThemeProvider } from 'styled-components'; import type { CoreStart } from '@kbn/core/public'; import { createKibanaReactContext } from '@kbn/kibana-react-plugin/public'; import { I18nProvider } from '@kbn/i18n-react'; import { CellActionsProvider } from '@kbn/cell-actions'; import { NavigationProvider } from '@kbn/security-solution-navigation'; -import { CASES_FEATURE_ID } from '../../../common'; +import { + AssistantProvider, + useAssistantContextValue, +} from '@kbn/elastic-assistant/impl/assistant_context'; +import { Storage } from '@kbn/kibana-utils-plugin/public'; +import { UpsellingService } from '@kbn/security-solution-upselling/service'; +import { ProductFeatureSecurityKey } from '@kbn/security-solution-features/keys'; +import { CASES_FEATURE_ID, SECURITY_FEATURE_ID } from '../../../common'; import { ReactQueryClientProvider } from '../containers/query_client/query_client_provider'; -import { createMockStore } from './create_store'; +import { createStore } from '../store'; +import { mockGlobalState } from './global_state'; +import { createSecuritySolutionStorageMock, localStorageMock } from './mock_local_storage'; +import { SUB_PLUGINS_REDUCER } from './utils'; +import { UpsellingProvider } from '../components/upselling_provider'; +import { useKibana } from '../lib/kibana'; +import { licenseService } from '../hooks/use_license'; const uiSettings = { get: (setting: string) => { @@ -32,10 +45,79 @@ const uiSettings = { get$: () => new Subject(), }; +const noopLogger = { + trace: () => {}, + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, + fatal: () => {}, + log: () => {}, + isLevelEnabled: () => false, + get: (..._childContextPaths: string[]) => noopLogger, +}; + +const mockGraphResponse = { + nodes: [ + { + id: 'user-test-user-name', + label: 'test-user-name', + color: 'primary', + shape: 'ellipse', + icon: 'user', + }, + { + id: 'host-test-host', + label: 'test-host', + color: 'primary', + shape: 'hexagon', + icon: 'storage', + }, + { + id: 'process-bash', + label: '/bin/bash', + color: 'warning', + shape: 'rectangle', + icon: 'console', + }, + ], + edges: [ + { id: 'edge-1', source: 'user-test-user-name', target: 'host-test-host', color: 'primary' }, + { id: 'edge-2', source: 'host-test-host', target: 'process-bash', color: 'subdued' }, + ], +}; + +const noopHttp = { + basePath: { serverBasePath: '', prepend: (p: string) => p, get: () => '' }, + fetch: (path: string) => { + if (typeof path === 'string' && path.includes('entity_store') && path.includes('status')) { + return Promise.resolve({ status: 'running', engines: [] }); + } + return Promise.resolve({}); + }, + post: (path: string) => { + if (typeof path === 'string' && path.includes('cloud_security_posture/graph')) { + return Promise.resolve(mockGraphResponse); + } + return Promise.resolve({}); + }, +} as unknown as CoreStart['http']; + const coreMock = { application: { getUrlForApp: () => {}, - capabilities: { [CASES_FEATURE_ID]: {} }, + navigateToUrl: () => {}, + currentAppId$: new BehaviorSubject(undefined), + capabilities: { + [CASES_FEATURE_ID]: {}, + [SECURITY_FEATURE_ID]: { + crud: true, + read: true, + 'entity-analytics': true, + writeGlobalArtifacts: true, + socManagement: true, + }, + }, }, lens: { EmbeddableComponent: () => , @@ -70,10 +152,14 @@ const coreMock = { uiSettings, notifications: { toasts: { - addError: () => {}, - addSuccess: () => {}, - addWarning: () => {}, + get$: () => new Subject(), + add: () => ({ id: '' }), remove: () => {}, + addInfo: () => ({ id: '' }), + addSuccess: () => ({ id: '' }), + addWarning: () => ({ id: '' }), + addDanger: () => ({ id: '' }), + addError: () => ({ id: '' }), }, }, timelines: { @@ -84,16 +170,130 @@ const coreMock = { getFilterOutValueButton: () => {}, }), }, + logger: noopLogger, + http: noopHttp, + dataViews: { + get: () => Promise.resolve({}), + getDefaultDataView: () => Promise.resolve(null), + find: () => Promise.resolve([]), + }, + triggersActionsUi: { + actionTypeRegistry: { + has: () => false, + register: () => {}, + get: () => ({}), + list: () => [], + }, + }, + storage: new Storage(localStorageMock()), + upselling: new UpsellingService(), + productFeatureKeys$: new BehaviorSubject | null>( + new Set([ProductFeatureSecurityKey.graphVisualization]) + ), + getComponents$: () => of({}), } as unknown as CoreStart; + +// Bootstrap the singleton license service with a platinum license so +// license-gated features (e.g. graph visualization) render in Storybook. +licenseService.start( + of({ + isAvailable: true, + isActive: true, + type: 'platinum', + signature: '', + hasAtLeast: (level: string) => ['basic', 'standard', 'gold', 'platinum'].includes(level), + getUnavailableReason: () => undefined, + getFeature: (_name: string) => ({ isAvailable: true, isEnabled: true }), + toJSON: () => ({ signature: '' }), + check: () => ({ state: 'valid' as const }), + } as import('@kbn/licensing-types').ILicense) +); const KibanaReactContext = createKibanaReactContext(coreMock); +const assistantAvailability = { + hasSearchAILakeConfigurations: false, + hasAssistantPrivilege: false, + hasAgentBuilderPrivilege: false, + hasConnectorsAllPrivilege: false, + hasConnectorsReadPrivilege: false, + hasUpdateAIAssistantAnonymization: false, + hasManageGlobalKnowledgeBase: false, + isAssistantEnabled: false, + isAssistantVisible: false, + isAssistantManagementEnabled: false, +}; + +const noopDocLinks = { + links: {}, +} as unknown as CoreStart['docLinks']; + +const noopChrome = { + getChromeStyle$: () => new Subject(), +} as unknown as CoreStart['chrome']; + +const noopUserProfileService = { + getCurrent: () => Promise.resolve({ uid: '', user: { username: '' } }), +} as unknown as CoreStart['userProfile']; + +const noopProductDocBase = { + installation: { + getStatus: () => Promise.resolve({}), + install: () => Promise.resolve(), + uninstall: () => Promise.resolve(), + }, +}; + +const noopActionTypeRegistry = { + has: () => false, + register: () => {}, + get: () => ({}), + list: () => [], +} as unknown as Parameters[0]['actionTypeRegistry']; + +const noopSettings = { + client: { get: () => undefined }, +} as unknown as Parameters[0]['settings']; + +/** + * Inner component that calls the useAssistantContextValue hook (must be inside ReactQueryClientProvider). + */ +const StorybookAssistantProvider: FC> = ({ children }) => { + const assistantContextValue = useAssistantContextValue({ + actionTypeRegistry: noopActionTypeRegistry, + assistantAvailability, + augmentMessageCodeBlocks: { mount: () => () => {} }, + basePath: '', + docLinks: noopDocLinks, + getComments: () => [], + http: noopHttp, + navigateToApp: () => Promise.resolve(), + currentAppId: 'securitySolution', + productDocBase: noopProductDocBase as unknown as Parameters< + typeof useAssistantContextValue + >[0]['productDocBase'], + userProfileService: noopUserProfileService, + chrome: noopChrome, + getUrlForApp: () => '', + settings: noopSettings, + }); + + return {children}; +}; + /** * A utility for wrapping components in Storybook that provides access to the most common React contexts used by security components. * It is a simplified version of TestProvidersComponent. * To reuse TestProvidersComponent here, we need to remove all references to jest from mocks. */ +const { storage } = createSecuritySolutionStorageMock(); + +const StorybookUpsellingProvider: FC> = ({ children }) => { + const upsellingService = useKibana().services.upselling as UpsellingService; + return {children}; +}; + export const StorybookProviders: FC> = ({ children }) => { - const store = createMockStore(); + const store = createStore(mockGlobalState, SUB_PLUGINS_REDUCER, coreMock, storage); return ( @@ -103,7 +303,9 @@ export const StorybookProviders: FC> = ({ children }) Promise.resolve([])}> ({ eui: euiLightVars, darkMode: false })}> - {children} + + {children} + diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/left/components/graph_visualization.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/left/components/graph_visualization.test.tsx index 51e44287cc4f9..e77ab3f673fd7 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/left/components/graph_visualization.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/left/components/graph_visualization.test.tsx @@ -14,6 +14,16 @@ import { GraphVisualization } from './graph_visualization'; import { mockFlyoutApi } from '../../shared/mocks/mock_flyout_context'; import { GRAPH_VISUALIZATION_TEST_ID } from './test_ids'; +/** + * Unit tests for the document_details GraphVisualization wrapper. + * + * This wrapper reads event context from useDocumentDetailsContext + useGraphPreview + * and passes the resolved values to the shared GraphVisualization component in 'event' mode. + * + * The full callback behaviour (onInvestigateInTimeline, onOpenEventPreview, etc.) is tested in: + * flyout/shared/components/graph_visualization.test.tsx + */ + const mockToasts = { addDanger: jest.fn(), addError: jest.fn(), @@ -23,10 +33,6 @@ const mockToasts = { remove: jest.fn(), }; -const mockInvestigateInTimeline = { - investigateInTimeline: jest.fn(), -}; - const GRAPH_INVESTIGATION_TEST_ID = 'cloudSecurityPostureGraphGraphInvestigation'; jest.mock('@kbn/expandable-flyout', () => ({ @@ -34,7 +40,6 @@ jest.mock('@kbn/expandable-flyout', () => ({ })); jest.mock('@kbn/cloud-security-posture-graph', () => { - // Import actual utility functions directly from the source const { isEntityNode, getNodeDocumentMode, hasNodeDocumentsData, getSingleDocumentData } = jest.requireActual('@kbn/cloud-security-posture-graph/src/components/utils'); const { GraphGroupedNodePreviewPanelKey, GROUP_PREVIEW_BANNER } = jest.requireActual( @@ -45,35 +50,30 @@ jest.mock('@kbn/cloud-security-posture-graph', () => { ); return { - // Mocked GraphInvestigation component GraphInvestigation: jest.fn(), - // Use actual utility functions isEntityNode, isEntityItem, getNodeDocumentMode, hasNodeDocumentsData, getSingleDocumentData, - // Use actual constants GraphGroupedNodePreviewPanelKey, GROUP_PREVIEW_BANNER, }; }); -jest.mock('../../../../common/lib/kibana', () => { - return { - useToasts: () => mockToasts, - KibanaServices: { - get: () => ({ - uiSettings: { - get: jest.fn().mockReturnValue(true), - }, - }), - }, - }; -}); +jest.mock('../../../../common/lib/kibana', () => ({ + useToasts: () => mockToasts, + KibanaServices: { + get: () => ({ + uiSettings: { + get: jest.fn().mockReturnValue(true), + }, + }), + }, +})); jest.mock('../../../../common/hooks/timeline/use_investigate_in_timeline', () => ({ - useInvestigateInTimeline: () => mockInvestigateInTimeline, + useInvestigateInTimeline: () => ({ investigateInTimeline: jest.fn() }), })); jest.mock('../../../../sourcerer/components/use_get_sourcerer_data_view', () => ({ @@ -107,15 +107,18 @@ jest.mock('../../shared/context', () => ({ }), })); +const MOCK_EVENT_IDS = ['event-1', 'event-2']; +const MOCK_TIMESTAMP = new Date().toISOString(); + jest.mock('../../shared/hooks/use_graph_preview', () => ({ useGraphPreview: () => ({ - eventIds: ['event-1', 'event-2'], - timestamp: new Date().toISOString(), + eventIds: MOCK_EVENT_IDS, + timestamp: MOCK_TIMESTAMP, isAlert: false, }), })); -describe('GraphVisualization', () => { +describe('GraphVisualization (document_details wrapper)', () => { beforeEach(() => { jest.clearAllMocks(); jest.mocked(useExpandableFlyoutApi).mockReturnValue(mockFlyoutApi); @@ -128,80 +131,31 @@ describe('GraphVisualization', () => { jest.resetAllMocks(); }); - describe('rendering', () => { - it('renders GraphInvestigation component', async () => { - const { getByTestId } = render(); - expect(getByTestId(GRAPH_VISUALIZATION_TEST_ID)).toBeInTheDocument(); + it('renders the graph visualization wrapper', async () => { + const { getByTestId } = render(); + expect(getByTestId(GRAPH_VISUALIZATION_TEST_ID)).toBeInTheDocument(); - // Wait for lazy-loaded GraphInvestigation to appear - await waitFor(() => { - expect(getByTestId(GRAPH_INVESTIGATION_TEST_ID)).toBeInTheDocument(); - }); - }); - - it('GraphInvestigation receives onOpenEventPreview callback', async () => { - const { getByTestId } = render(); - - await waitFor(() => { - expect(getByTestId(GRAPH_INVESTIGATION_TEST_ID)).toBeInTheDocument(); - }); - - expect(GraphInvestigation).toHaveBeenCalledTimes(1); - // Verify onOpenEventPreview IS passed as a callback - expect(jest.mocked(GraphInvestigation).mock.calls[0][0]).toHaveProperty('onOpenEventPreview'); - expect(typeof jest.mocked(GraphInvestigation).mock.calls[0][0].onOpenEventPreview).toBe( - 'function' - ); + await waitFor(() => { + expect(getByTestId(GRAPH_INVESTIGATION_TEST_ID)).toBeInTheDocument(); }); }); - describe('onInvestigateInTimeline', () => { - it('shows danger toast when cannot investigate in timeline - missing time range', async () => { - const { getByTestId } = render(); - expect(getByTestId(GRAPH_VISUALIZATION_TEST_ID)).toBeInTheDocument(); - - // Wait for lazy-loaded GraphInvestigation to appear - await waitFor(() => { - expect(getByTestId(GRAPH_INVESTIGATION_TEST_ID)).toBeInTheDocument(); - }); + it('passes event context from useDocumentDetailsContext and useGraphPreview as originEventIds', async () => { + render(); + await waitFor(() => { expect(GraphInvestigation).toHaveBeenCalledTimes(1); - expect(jest.mocked(GraphInvestigation).mock.calls[0][0]).toHaveProperty( - 'onInvestigateInTimeline' - ); - const onInvestigateInTimeline = - jest.mocked(GraphInvestigation).mock.calls[0][0].onInvestigateInTimeline; - - // Act - onInvestigateInTimeline?.(undefined, [], { from: '', to: '' }); - - // Assert - expect(mockInvestigateInTimeline.investigateInTimeline).not.toHaveBeenCalled(); - expect(mockToasts.addDanger).toHaveBeenCalled(); }); - it('calls investigate in time', async () => { - const { getByTestId } = render(); - expect(getByTestId(GRAPH_VISUALIZATION_TEST_ID)).toBeInTheDocument(); - - // Wait for lazy-loaded GraphInvestigation to appear - await waitFor(() => { - expect(getByTestId(GRAPH_INVESTIGATION_TEST_ID)).toBeInTheDocument(); - }); - - expect(GraphInvestigation).toHaveBeenCalledTimes(1); - expect(jest.mocked(GraphInvestigation).mock.calls[0][0]).toHaveProperty( - 'onInvestigateInTimeline' - ); - const onInvestigateInTimeline = - jest.mocked(GraphInvestigation).mock.calls[0][0].onInvestigateInTimeline; - - // Act - onInvestigateInTimeline?.(undefined, [], { from: 'now-15m', to: 'now' }); - - // Assert - expect(mockInvestigateInTimeline.investigateInTimeline).toHaveBeenCalled(); - expect(mockToasts.addDanger).not.toHaveBeenCalled(); + const { initialState, scopeId } = jest.mocked(GraphInvestigation).mock.calls[0][0]; + expect(scopeId).toBe('test-scope'); + expect(initialState.originEventIds).toEqual([ + { id: 'event-1', isAlert: false }, + { id: 'event-2', isAlert: false }, + ]); + expect(initialState.timeRange).toEqual({ + from: `${MOCK_TIMESTAMP}||-30m`, + to: `${MOCK_TIMESTAMP}||+30m`, }); }); }); diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/left/components/graph_visualization.tsx b/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/left/components/graph_visualization.tsx index 1456dae962bb3..7862db314bcdc 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/left/components/graph_visualization.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/left/components/graph_visualization.tsx @@ -5,271 +5,34 @@ * 2.0. */ -import React, { memo, useCallback } from 'react'; -import { css } from '@emotion/react'; -import { EuiLoadingSpinner } from '@elastic/eui'; -import type { Filter, Query, TimeRange } from '@kbn/es-query'; -import dateMath from '@kbn/datemath'; -import { i18n } from '@kbn/i18n'; -import { useExpandableFlyoutApi } from '@kbn/expandable-flyout'; -import { - getNodeDocumentMode, - getSingleDocumentData, - GraphGroupedNodePreviewPanelKey, - GROUP_PREVIEW_BANNER, - NETWORK_PREVIEW_BANNER, - type NodeViewModel, -} from '@kbn/cloud-security-posture-graph'; -import { type NodeDocumentDataModel } from '@kbn/cloud-security-posture-common/types/graph/v1'; -import { DOCUMENT_TYPE_ENTITY } from '@kbn/cloud-security-posture-common/schema/graph/v1'; -import { isEntityNodeEnriched } from '@kbn/cloud-security-posture-graph/src/components/utils'; -import { PageScope } from '../../../../data_view_manager/constants'; -import { useDataView } from '../../../../data_view_manager/hooks/use_data_view'; -import { useGetScopedSourcererDataView } from '../../../../sourcerer/components/use_get_sourcerer_data_view'; +import React, { memo } from 'react'; import { useDocumentDetailsContext } from '../../shared/context'; -import { GRAPH_VISUALIZATION_TEST_ID } from './test_ids'; import { useGraphPreview } from '../../shared/hooks/use_graph_preview'; -import { useInvestigateInTimeline } from '../../../../common/hooks/timeline/use_investigate_in_timeline'; -import { normalizeTimeRange } from '../../../../common/utils/normalize_time_range'; -import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features'; -import { DocumentDetailsPreviewPanelKey } from '../../shared/constants/panel_keys'; -import { - ALERT_PREVIEW_BANNER, - EVENT_PREVIEW_BANNER, - GENERIC_ENTITY_PREVIEW_BANNER, -} from '../../preview/constants'; -import { useToasts } from '../../../../common/lib/kibana'; -import { GenericEntityPanelKey } from '../../../entity_details/shared/constants'; -import { FlowTargetSourceDest } from '../../../../../common/search_strategy'; +import { GraphVisualization as SharedGraphVisualization } from '../../../shared/components/graph_visualization'; -const GraphInvestigationLazy = React.lazy(() => - import('@kbn/cloud-security-posture-graph').then((module) => ({ - default: module.GraphInvestigation, - })) -); - -export const GRAPH_ID = 'graph-visualization' as const; - -const MAX_DOCUMENTS_TO_LOAD = 50; +export { GRAPH_ID } from '../../../shared/components/graph_visualization'; /** - * Graph visualization view displayed in the document details expandable flyout left section under the Visualize tab + * Graph visualization view displayed in the document details expandable flyout left section under the Visualize tab. + * Reads event context from {@link useDocumentDetailsContext} and delegates rendering to the shared {@link SharedGraphVisualization}. */ export const GraphVisualization: React.FC = memo(() => { - const toasts = useToasts(); - const oldDataView = useGetScopedSourcererDataView({ - sourcererScope: PageScope.default, - }); - - const { dataView: experimentalDataView } = useDataView(PageScope.default); - const newDataViewPickerEnabled = useIsExperimentalFeatureEnabled('newDataViewPickerEnabled'); - - const dataView = newDataViewPickerEnabled ? experimentalDataView : oldDataView; - const dataViewIndexPattern = dataView ? dataView.getIndexPattern() : undefined; - const { getFieldsData, dataAsNestedObject, dataFormattedForFieldBrowser, scopeId } = useDocumentDetailsContext(); - const { - eventIds, - timestamp = new Date().toISOString(), - isAlert, - } = useGraphPreview({ + const { eventIds, timestamp, isAlert } = useGraphPreview({ getFieldsData, ecsData: dataAsNestedObject, dataFormattedForFieldBrowser, }); - const { openPreviewPanel } = useExpandableFlyoutApi(); - - const onOpenNetworkPreview = useCallback( - (ip: string, previewScopeId: string) => { - openPreviewPanel({ - id: 'network-preview', - params: { - ip, - scopeId: previewScopeId, - flowTarget: FlowTargetSourceDest.source, - banner: NETWORK_PREVIEW_BANNER, - isPreviewMode: true, - }, - }); - }, - [openPreviewPanel] - ); - - const onOpenEventPreview = useCallback( - (node: NodeViewModel) => { - const singleDocumentData = getSingleDocumentData(node); - const docMode = getNodeDocumentMode(node); - const documentsData = (node.documentsData ?? []) as NodeDocumentDataModel[]; - - const showEntityPreview = (item: { id: string; entity?: unknown }) => { - openPreviewPanel({ - id: GenericEntityPanelKey, - params: { - entityId: item.id, - scopeId, - isPreviewMode: true, - banner: GENERIC_ENTITY_PREVIEW_BANNER, - isEngineMetadataExist: !!item.entity, - }, - }); - }; - - const showEventOrAlertPreview = ( - item: { id: string }, - banner: { - title: string; - backgroundColor: string; - textColor: string; - }, - index?: string - ) => { - openPreviewPanel({ - id: DocumentDetailsPreviewPanelKey, - params: { - id: item.id, - indexName: index, - scopeId, - banner, - isPreviewMode: true, - }, - }); - }; - if ((docMode === 'single-event' || docMode === 'single-alert') && singleDocumentData) { - showEventOrAlertPreview( - singleDocumentData, - docMode === 'single-alert' ? ALERT_PREVIEW_BANNER : EVENT_PREVIEW_BANNER, - singleDocumentData.index - ); - } else if (docMode === 'single-entity' && singleDocumentData && isEntityNodeEnriched(node)) { - showEntityPreview(singleDocumentData); - } else if (docMode === 'grouped-entities' && documentsData.length > 0) { - openPreviewPanel({ - id: GraphGroupedNodePreviewPanelKey, - params: { - id: node.id, - scopeId, - isPreviewMode: true, - banner: GROUP_PREVIEW_BANNER, - docMode, - entityItems: (node.documentsData as NodeDocumentDataModel[]) - .slice(0, MAX_DOCUMENTS_TO_LOAD) - .map((doc) => ({ - itemType: DOCUMENT_TYPE_ENTITY, - id: doc.id, - type: doc.entity?.type, - subType: doc.entity?.sub_type, - icon: node.icon, - availableInEntityStore: !!doc.entity?.availableInEntityStore, - sourceFields: doc.entity?.sourceFields, - })), - }, - }); - } else if (docMode === 'grouped-events' && documentsData.length > 0) { - openPreviewPanel({ - id: GraphGroupedNodePreviewPanelKey, - params: { - id: node.id, - scopeId, - isPreviewMode: true, - banner: GROUP_PREVIEW_BANNER, - docMode, - dataViewId: dataViewIndexPattern, - documentIds: (node.documentsData as NodeDocumentDataModel[]) - .slice(0, MAX_DOCUMENTS_TO_LOAD) - .map((doc) => doc.event?.id), - }, - }); - } else { - toasts.addDanger({ - title: i18n.translate( - 'xpack.securitySolution.flyout.document_details.left.components.graphVisualization.errorOpenNodePreview', - { - defaultMessage: 'Failed showing preview', - } - ), - }); - } - }, - [toasts, openPreviewPanel, scopeId, dataViewIndexPattern] - ); - - const originEventIds = eventIds.map((id) => ({ id, isAlert })); - const { investigateInTimeline } = useInvestigateInTimeline(); - const openTimelineCallback = useCallback( - (query: Query | undefined, filters: Filter[], timeRange: TimeRange) => { - const from = dateMath.parse(timeRange.from); - const to = dateMath.parse(timeRange.to); - - if (!from || !to) { - toasts.addDanger({ - title: i18n.translate( - 'xpack.securitySolution.flyout.document_details.left.components.graphVisualization.errorInvalidTimeRange', - { - defaultMessage: 'Invalid time range', - } - ), - text: i18n.translate( - 'xpack.securitySolution.flyout.document_details.left.components.graphVisualization.errorInvalidTimeRangeDescription', - { - defaultMessage: 'Please select a valid time range.', - } - ), - }); - return; - } - - const normalizedTimeRange = normalizeTimeRange({ - ...timeRange, - from: from.toISOString(), - to: to.toISOString(), - }); - - investigateInTimeline({ - keepDataView: true, - query, - filters, - timeRange: { - from: normalizedTimeRange.from, - to: normalizedTimeRange.to, - kind: 'absolute', - }, - }); - }, - [investigateInTimeline, toasts] - ); - return ( -
- {dataView && ( - }> - - - )} -
+ ); }); diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/right/components/graph_preview_container.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/right/components/graph_preview_container.test.tsx index 09c187d3be07c..ca6f5a4ca768a 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/right/components/graph_preview_container.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/right/components/graph_preview_container.test.tsx @@ -55,6 +55,10 @@ jest.mock('../../../../common/hooks/use_experimental_features', () => ({ const useIsExperimentalFeatureEnabledMock = useIsExperimentalFeatureEnabled as jest.Mock; +jest.mock('../../../shared/hooks/use_should_show_graph', () => ({ + useShouldShowGraph: jest.fn().mockReturnValue(false), +})); + jest.mock('../../shared/hooks/use_graph_preview'); jest.mock('@kbn/cloud-security-posture-graph/src/hooks', () => ({ useFetchGraphData: jest.fn(), diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/right/components/graph_preview_container.tsx b/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/right/components/graph_preview_container.tsx index e60ec9ce2309c..9667d2d568e4b 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/right/components/graph_preview_container.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/right/components/graph_preview_container.tsx @@ -5,30 +5,13 @@ * 2.0. */ -import React, { useEffect, useMemo } from 'react'; -import { FormattedMessage } from '@kbn/i18n-react'; -import { EuiBetaBadge, useGeneratedHtmlId } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import { useFetchGraphData } from '@kbn/cloud-security-posture-graph/src/hooks'; -import { - GRAPH_PREVIEW, - uiMetricService, -} from '@kbn/cloud-security-posture-common/utils/ui_metrics'; -import { METRIC_TYPE } from '@kbn/analytics'; +import React from 'react'; import { useDocumentDetailsContext } from '../../shared/context'; -import { GRAPH_PREVIEW_TEST_ID } from './test_ids'; -import { GraphPreview } from './graph_preview'; import { useGraphPreview } from '../../shared/hooks/use_graph_preview'; import { useNavigateToGraphVisualization } from '../../shared/hooks/use_navigate_to_graph_visualization'; -import { ExpandablePanel } from '../../../../flyout_v2/shared/components/expandable_panel'; -import { useUpsellingComponent } from '../../../../common/hooks/use_upselling'; +import { GraphPreviewContainer as SharedGraphPreviewContainer } from '../../../shared/components/graph_preview_container'; -/** - * Graph preview under Overview, Visualizations. It shows a graph representation of entities, - * or an upsell message when the required license is not met. - */ export const GraphPreviewContainer: React.FC = () => { - const renderingId = useGeneratedHtmlId(); const { dataAsNestedObject, getFieldsData, @@ -40,11 +23,6 @@ export const GraphPreviewContainer: React.FC = () => { dataFormattedForFieldBrowser, } = useDocumentDetailsContext(); - const allowFlyoutExpansion = useMemo( - () => !isPreviewMode && !isRulePreview, - [isRulePreview, isPreviewMode] - ); - const { navigateToGraphVisualization } = useNavigateToGraphVisualization({ eventId, indexName, @@ -52,100 +30,26 @@ export const GraphPreviewContainer: React.FC = () => { scopeId, }); - const { - eventIds, - timestamp = new Date().toISOString(), - shouldShowGraph, - isAlert, - } = useGraphPreview({ + const allowFlyoutExpansion = !isPreviewMode && !isRulePreview; + + const { eventIds, timestamp, shouldShowGraph, isAlert } = useGraphPreview({ getFieldsData, ecsData: dataAsNestedObject, dataFormattedForFieldBrowser, }); - // Show upsell when event has graph data but license is insufficient (ESS only) - const GraphVisualizationUpsell = useUpsellingComponent('graph_visualization'); - - // TODO: default start and end might not capture the original event - const { isLoading, isError, data } = useFetchGraphData({ - req: { - query: { - originEventIds: eventIds.map((id) => ({ id, isAlert })), - start: `${timestamp}||-30m`, - end: `${timestamp}||+30m`, - }, - }, - options: { - enabled: shouldShowGraph, - refetchOnWindowFocus: false, - }, - }); - - useEffect(() => { - if (shouldShowGraph) { - uiMetricService.trackUiMetric(METRIC_TYPE.LOADED, GRAPH_PREVIEW); - } - }, [shouldShowGraph, renderingId]); - - // Nothing to render when graph is not available and there is no upsell - if (!shouldShowGraph && !GraphVisualizationUpsell) { - return null; - } - return ( - - ), - headerContent: ( - - ), - iconType: allowFlyoutExpansion ? 'chevronLimitLeft' : undefined, - ...(allowFlyoutExpansion && - shouldShowGraph && { - link: { - callback: navigateToGraphVisualization, - tooltip: ( - - ), - }, - }), - }} - data-test-subj={GRAPH_PREVIEW_TEST_ID} - content={{ - paddingSize: 'none', - }} - > - {shouldShowGraph ? ( - - ) : ( - GraphVisualizationUpsell && - )} - + ); }; diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/shared/hooks/use_graph_preview.ts b/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/shared/hooks/use_graph_preview.ts index fe5cd3b96e4c2..ee9143bef3814 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/shared/hooks/use_graph_preview.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/shared/hooks/use_graph_preview.ts @@ -15,8 +15,7 @@ import { import type { GetFieldsData } from './use_get_fields_data'; import { getField, getFieldArray } from '../utils'; import { useBasicDataFromDetailsData } from './use_basic_data_from_details_data'; -import { useHasGraphVisualizationLicense } from '../../../../common/hooks/use_has_graph_visualization_license'; -import { useEntityStoreStatus } from '../../../../entity_analytics/components/entity_store/hooks/use_entity_store'; +import { useShouldShowGraph } from '../../../shared/hooks/use_should_show_graph'; export interface UseGraphPreviewParams { /** @@ -34,6 +33,7 @@ export interface UseGraphPreviewParams { */ dataFormattedForFieldBrowser: TimelineEventsDetailsItem[]; } + /** * Interface for the result of the useGraphPreview hook */ @@ -109,13 +109,6 @@ export const useGraphPreview = ({ const action: string[] | undefined = get(['event', 'action'], ecsData); - // Check if user license is high enough to access graph visualization - const hasRequiredLicense = useHasGraphVisualizationLicense(); - - // Check if entity store is running - const { data: entityStoreStatus } = useEntityStoreStatus(); - const isEntityStoreRunning = entityStoreStatus?.status === 'running'; - // Check if graph has all required data fields for graph visualization const hasGraphData = Boolean(timestamp) && @@ -125,7 +118,7 @@ export const useGraphPreview = ({ targetIds.length > 0; // Combine all conditions: data availability + license + entity store running - const shouldShowGraph = hasGraphData && hasRequiredLicense && isEntityStoreRunning; + const shouldShowGraph = useShouldShowGraph() && hasGraphData; const { isAlert } = useBasicDataFromDetailsData(dataFormattedForFieldBrowser); diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/host_details_left/hooks.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/host_details_left/hooks.test.tsx index 6cb64ca38977b..60b51ed636ec8 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/host_details_left/hooks.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/host_details_left/hooks.test.tsx @@ -129,7 +129,7 @@ describe('hooks', () => { ]); }); - it('should return an empty array when no tabs are available', () => { + it('should return only the graph view tab when no other tabs are available', () => { const { result } = renderHook( () => useTabs({ @@ -140,6 +140,28 @@ describe('hooks', () => { hasMisconfigurationFindings: false, hasVulnerabilitiesFindings: false, hasNonClosedAlerts: false, + entityStoreEntityId: 'testEntityStoreId', + }), + { wrapper: TestProviders } + ); + + expect(result.current).toEqual([ + expect.objectContaining({ id: EntityDetailsLeftPanelTab.GRAPH_VIEW }), + expect.objectContaining({ id: EntityDetailsLeftPanelTab.RESOLUTION_GROUP }), + ]); + }); + + it('should return an empty array when no tabs are available and entityId is not provided', () => { + const { result } = renderHook( + () => + useTabs({ + isRiskScoreExist: false, + hostName: 'testHost', + entityId: '', + scopeId: 'scope1', + hasMisconfigurationFindings: false, + hasVulnerabilitiesFindings: false, + hasNonClosedAlerts: false, }), { wrapper: TestProviders } ); diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/host_details_left/hooks.ts b/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/host_details_left/hooks.ts index f6aa5113268d6..ad23bbd6f29a1 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/host_details_left/hooks.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/host_details_left/hooks.ts @@ -17,6 +17,7 @@ import type { LeftPanelTabsType, EntityDetailsLeftPanelTab, } from '../shared/components/left_panel/left_panel_header'; +import { getGraphViewTab } from '../shared/components/left'; import type { HostDetailsPanelProps } from '.'; import { HostDetailsPanelKey } from '.'; @@ -77,11 +78,15 @@ export const useTabs = ({ ] : []; + const graphViewTab = entityStoreEntityId + ? [getGraphViewTab({ entityId: entityStoreEntityId, scopeId })] + : []; + const resolutionTab = entityStoreEntityId - ? [getResolutionGroupTab({ entityId: entityStoreEntityId, entityType: 'host' })] + ? [getResolutionGroupTab({ entityId: entityStoreEntityId, entityType: EntityType.host })] : []; - return [...riskScoreTab, ...insightsTab, ...resolutionTab]; + return [...riskScoreTab, ...insightsTab, ...graphViewTab, ...resolutionTab]; }, [ isRiskScoreExist, hostName, diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/host_right/constants.ts b/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/host_right/constants.ts new file mode 100644 index 0000000000000..24489d521845a --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/host_right/constants.ts @@ -0,0 +1,9 @@ +/* + * 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 HOST_PANEL_RISK_SCORE_QUERY_ID = 'HostPanelRiskScoreQuery'; +export const HOST_PANEL_OBSERVED_HOST_QUERY_ID = 'HostPanelObservedHostQuery'; diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/host_right/content.stories.tsx b/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/host_right/content.stories.tsx index 8529fe9e46c03..a67921b24d5a1 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/host_right/content.stories.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/host_right/content.stories.tsx @@ -12,7 +12,7 @@ import { TestProvider } from '@kbn/expandable-flyout/src/test/provider'; import { StorybookProviders } from '../../../common/mock/storybook_providers'; import { mockRiskScoreState } from '../../shared/mocks'; import { HostPanelContent } from './content'; -import { mockObservedHostData } from '../mocks'; +import { mockObservedHostData, mockEntityRecord } from '../mocks'; const riskScoreData = { ...mockRiskScoreState, data: [] }; @@ -50,6 +50,26 @@ export const Default = { name: 'default', }; +export const WithGraphVisualization = { + render: () => ( + {}} + identityFields={{ 'host.name': 'test-host-name' }} + onAssetCriticalityChange={() => {}} + recalculatingScore={false} + isPreviewMode={false} + entityRecord={mockEntityRecord} + entityStoreEntityId={mockEntityRecord.entity.id} + /> + ), + + name: 'with graph visualization', +}; + export const NoObservedData = { render: () => ( , 'anomalies'>; @@ -115,6 +116,17 @@ export const HostPanelContent = ({ openDetailsPanel={openDetailsPanel} entityType={EntityType.host} /> + {entityStoreEntityId && ( + <> + + + + )} { contextID: string; @@ -75,8 +78,6 @@ export interface HostPanelExpandableFlyoutProps extends FlyoutPanelProps { } export const HostPreviewPanelKey: HostPanelExpandableFlyoutProps['key'] = 'host-preview-panel'; -export const HOST_PANEL_RISK_SCORE_QUERY_ID = 'HostPanelRiskScoreQuery'; -export const HOST_PANEL_OBSERVED_HOST_QUERY_ID = 'HostPanelObservedHostQuery'; const FIRST_RECORD_PAGINATION = { cursorStart: 0, diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/mocks/index.ts b/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/mocks/index.ts index 90cadf61ea53c..ac7ac4fc410af 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/mocks/index.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/mocks/index.ts @@ -18,6 +18,7 @@ import type { import { HostPolicyResponseActionStatus, RiskSeverity } from '../../../../common/search_strategy'; import { RiskCategories } from '../../../../common/entity_analytics/risk_engine'; import type { ObservedEntityData } from '../shared/components/observed_entity/types'; +import type { HostEntity } from '../../../../common/api/entity_analytics'; const userRiskScore: UserRiskScore = { '@timestamp': '1989-11-08T23:00:00.000Z', @@ -268,3 +269,32 @@ export const mockObservedHostData: ObservedEntityData = { }, anomalies: { isLoading: false, anomalies: null, jobNameById: {} }, }; + +export const mockEntityRecord: HostEntity = { + '@timestamp': '2024-01-15T10:00:00.000Z', + entity: { + id: 'test-entity-id-host-abc123', + name: 'test-host-name', + type: 'host', + lifecycle: { + first_seen: '2023-01-01T00:00:00.000Z', + last_activity: '2024-01-15T10:00:00.000Z', + }, + }, + host: { + name: 'test-host-name', + id: ['host-id'], + ip: ['10.0.0.1', '127.0.0.1'], + mac: ['aa:bb:cc:dd:ee:ff'], + architecture: ['x86_64'], + type: ['server'], + os: { + name: 'Ubuntu', + family: 'debian', + version: '22.04', + }, + }, + asset: { + criticality: 'high_impact', + }, +}; diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/service_details_left/index.tsx b/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/service_details_left/index.tsx index ebd786b9e6460..b38ce04d81117 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/service_details_left/index.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/service_details_left/index.tsx @@ -51,7 +51,8 @@ export const ServiceDetailsPanel = ({ identityFields ?? {}, scopeId, tabs, - path + path, + entityStoreEntityId ); if (!selectedTabId) { @@ -75,7 +76,8 @@ const useSelectedTab = ( identityFields: IdentityFields, scopeId: string, tabs: LeftPanelTabsType, - path: PanelPath | undefined + path: PanelPath | undefined, + entityStoreEntityId?: string ) => { const { openLeftPanel } = useExpandableFlyoutApi(); @@ -93,6 +95,7 @@ const useSelectedTab = ( identityFields, isRiskScoreExist, scopeId, + entityStoreEntityId, path: { tab: tabId, }, diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/service_details_left/tabs.tsx b/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/service_details_left/tabs.tsx index f79db9578706b..a2346453e2430 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/service_details_left/tabs.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/service_details_left/tabs.tsx @@ -12,6 +12,7 @@ import { } from '../../../entity_analytics/components/entity_details_flyout'; import { EntityType } from '../../../../common/entity_analytics/types'; import type { LeftPanelTabsType } from '../shared/components/left_panel/left_panel_header'; +import { getGraphViewTab } from '../shared/components/left'; export const useTabs = ( name: string, @@ -27,9 +28,13 @@ export const useTabs = ( }), ]; + const graphTab = entityStoreEntityId + ? [getGraphViewTab({ entityId: entityStoreEntityId, scopeId })] + : []; + const resolutionTab = entityStoreEntityId - ? [getResolutionGroupTab({ entityId: entityStoreEntityId, entityType: 'service' })] + ? [getResolutionGroupTab({ entityId: entityStoreEntityId, entityType: EntityType.service })] : []; - return [...riskTab, ...resolutionTab]; + return [...riskTab, ...graphTab, ...resolutionTab]; }, [name, scopeId, entityStoreEntityId]); diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/service_right/content.tsx b/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/service_right/content.tsx index b0f6083ff3a86..dee80439942e0 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/service_right/content.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/service_right/content.tsx @@ -19,6 +19,7 @@ import { ObservedEntity } from '../shared/components/observed_entity'; import type { ObservedEntityData } from '../shared/components/observed_entity/types'; import { useObservedServiceItems } from './hooks/use_observed_service_items'; import type { EntityDetailsPath } from '../shared/components/left_panel/left_panel_header'; +import { VisualizationsSection } from '../shared/components/right/visualizations_section'; import { ResolutionSection } from '../../../entity_analytics/components/entity_resolution/resolution_section'; export const OBSERVED_SERVICE_QUERY_ID = 'observedServiceDetailsQuery'; @@ -30,6 +31,7 @@ interface ServicePanelContentProps { recalculatingScore: boolean; contextID: string; scopeId: string; + isPreviewMode: boolean; onAssetCriticalityChange: () => void; openDetailsPanel: (path: EntityDetailsPath) => void; entityRecord?: Entity; @@ -44,6 +46,7 @@ export const ServicePanelContent = ({ recalculatingScore, contextID, scopeId, + isPreviewMode, openDetailsPanel, onAssetCriticalityChange, entityStoreEntityId, @@ -59,7 +62,7 @@ export const ServicePanelContent = ({ recalculatingScore={recalculatingScore} queryId={SERVICE_PANEL_RISK_SCORE_QUERY_ID} openDetailsPanel={openDetailsPanel} - isPreviewMode={false} + isPreviewMode={isPreviewMode} entityType={EntityType.service} entityId={entityRecord?.entity.id} /> @@ -76,6 +79,17 @@ export const ServicePanelContent = ({ entity={{ name: serviceName, type: EntityType.service }} onChange={onAssetCriticalityChange} /> + {entityStoreEntityId && ( + <> + + + + )} { serviceName: mockProps.serviceName, scopeId: mockProps.scopeId, isRiskScoreExist: mockProps.isRiskScoreExist, + identityFields: mockProps.identityFields, + entityId: mockProps.entityId, path: { tab, subTab }, }, }); diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/service_right/hooks/use_navigate_to_service_details.ts b/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/service_right/hooks/use_navigate_to_service_details.ts index d6c689a0060cf..7f324fa374f6c 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/service_right/hooks/use_navigate_to_service_details.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/service_right/hooks/use_navigate_to_service_details.ts @@ -12,12 +12,17 @@ import type { EntityDetailsPath } from '../../shared/components/left_panel/left_ import { useKibana } from '../../../../common/lib/kibana'; import { EntityEventTypes } from '../../../../common/lib/telemetry'; import { ServiceDetailsPanelKey } from '../../service_details_left'; +import { ServicePanelKey } from '../../shared/constants'; +import type { IdentityFields } from '../../../document_details/shared/utils'; interface UseNavigateToServiceDetailsParams { entityId?: string; serviceName: string; scopeId: string; + contextID: string; isRiskScoreExist: boolean; + identityFields: IdentityFields; + isPreviewMode: boolean; entityStoreEntityId?: string; } @@ -25,11 +30,14 @@ export const useNavigateToServiceDetails = ({ entityId, serviceName, scopeId, + contextID, isRiskScoreExist, + identityFields, + isPreviewMode, entityStoreEntityId, }: UseNavigateToServiceDetailsParams): ((path: EntityDetailsPath) => void) => { const { telemetry } = useKibana().services; - const { openLeftPanel } = useExpandableFlyoutApi(); + const { openLeftPanel, openFlyout } = useExpandableFlyoutApi(); return useCallback( (path: EntityDetailsPath) => { @@ -37,24 +45,46 @@ export const useNavigateToServiceDetails = ({ entity: EntityType.service, }); - openLeftPanel({ + const left = { id: ServiceDetailsPanelKey, params: { isRiskScoreExist, + identityFields, scopeId, entityId, serviceName, entityStoreEntityId, path, }, - }); + }; + + if (isPreviewMode) { + openFlyout({ + right: { + id: ServicePanelKey, + params: { + contextID, + scopeId, + entityId, + serviceName, + }, + }, + left, + }); + } else { + openLeftPanel(left); + } }, [ isRiskScoreExist, + identityFields, openLeftPanel, + openFlyout, scopeId, entityId, serviceName, + contextID, + isPreviewMode, entityStoreEntityId, telemetry, ] diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/service_right/index.tsx b/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/service_right/index.tsx index a43361e053f8f..c27092d14ebe0 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/service_right/index.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/service_right/index.tsx @@ -36,6 +36,7 @@ export interface ServicePanelProps extends Record { scopeId: string; entityId: string; serviceName: string; + isPreviewMode?: boolean; } export interface ServicePanelExpandableFlyoutProps extends FlyoutPanelProps { @@ -49,7 +50,14 @@ const FIRST_RECORD_PAGINATION = { querySize: 1, }; -export const ServicePanel = ({ contextID, scopeId, entityId, serviceName }: ServicePanelProps) => { +export const ServicePanel = ({ + contextID, + scopeId, + entityId, + serviceName, + isPreviewMode = false, +}: ServicePanelProps) => { + const safeContextID = contextID ?? scopeId ?? 'service-panel'; const entityStoreV2Enabled = useUiSetting(FF_ENABLE_ENTITY_STORE_V2, false); const serviceStoreIdentityFields = useMemo( () => (!entityId && serviceName ? { 'service.name': serviceName } : undefined), @@ -120,7 +128,10 @@ export const ServicePanel = ({ contextID, scopeId, entityId, serviceName }: Serv serviceName, entityId, scopeId, + contextID: safeContextID, isRiskScoreExist, + identityFields: documentEntityIdentifiers, + isPreviewMode, entityStoreEntityId, }); @@ -147,6 +158,7 @@ export const ServicePanel = ({ contextID, scopeId, entityId, serviceName }: Serv @@ -157,9 +169,10 @@ export const ServicePanel = ({ contextID, scopeId, entityId, serviceName }: Serv riskScoreState={riskScoreState} recalculatingScore={recalculatingScore} onAssetCriticalityChange={calculateEntityRiskScore} - contextID={contextID} + contextID={safeContextID} scopeId={scopeId} openDetailsPanel={openDetailsPanel} + isPreviewMode={isPreviewMode} entityStoreEntityId={entityStoreEntityId} /> diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/shared/components/left/get_graph_view_tab.tsx b/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/shared/components/left/get_graph_view_tab.tsx new file mode 100644 index 0000000000000..bfd728c91a812 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/shared/components/left/get_graph_view_tab.tsx @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { EntityDetailsLeftPanelTab } from '../left_panel/left_panel_header'; +import { GRAPH_VIEW_TAB_TEST_ID } from './test_ids'; +import { GraphViewTab } from './graph_view_tab'; + +export interface GetGraphViewTabParams { + /** Entity Store v2 entity ID (`entity.id`) to center the graph on */ + entityId: string; + /** Scope ID for the flyout panel */ + scopeId: string; +} + +export const getGraphViewTab = ({ entityId, scopeId }: GetGraphViewTabParams) => { + return { + id: EntityDetailsLeftPanelTab.GRAPH_VIEW, + 'data-test-subj': GRAPH_VIEW_TAB_TEST_ID, + name: ( + + ), + content: , + isTechnicalPreview: true, + }; +}; diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/shared/components/left/graph_view_tab.tsx b/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/shared/components/left/graph_view_tab.tsx new file mode 100644 index 0000000000000..112a610b30951 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/shared/components/left/graph_view_tab.tsx @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { FC } from 'react'; +import React, { memo } from 'react'; +import { GraphVisualization } from '../../../../shared/components/graph_visualization'; + +const EXPANDABLE_FLYOUT_LEFT_SECTION_HEADER_HEIGHT = 72; +const VISUALIZE_WRAPPER_PADDING = 16; +const VISUALIZE_BUTTON_GROUP_HEIGHT = 32; +const EUI_SPACER_HEIGHT = 16; + +export interface GraphViewTabProps { + /** Entity Store v2 entity ID (`entity.id`) to center the graph on */ + entityId: string; + /** Scope ID for the flyout panel */ + scopeId: string; +} + +/** + * Graph view tab content for entity detail left panels. + * Renders the full graph investigation view centered on the given entity. + */ +export const GraphViewTab: FC = memo(({ entityId, scopeId }) => { + const height = + window.innerHeight - + EXPANDABLE_FLYOUT_LEFT_SECTION_HEADER_HEIGHT - + 2 * VISUALIZE_WRAPPER_PADDING - + VISUALIZE_BUTTON_GROUP_HEIGHT - + EUI_SPACER_HEIGHT; + return ( + + ); +}); + +GraphViewTab.displayName = 'GraphViewTab'; diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/shared/components/left/index.ts b/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/shared/components/left/index.ts new file mode 100644 index 0000000000000..cefb6530282e9 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/shared/components/left/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export * from './get_graph_view_tab'; diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/shared/components/left/test_ids.ts b/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/shared/components/left/test_ids.ts new file mode 100644 index 0000000000000..0573b0318f5f3 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/shared/components/left/test_ids.ts @@ -0,0 +1,10 @@ +/* + * 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 { PREFIX } from '../../../../shared/test_ids'; + +export const GRAPH_VIEW_TAB_TEST_ID = `${PREFIX}GraphViewTab` as const; diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/shared/components/left_panel/left_panel_header.tsx b/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/shared/components/left_panel/left_panel_header.tsx index c7b3e8dc3da00..6ec6772eed2b4 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/shared/components/left_panel/left_panel_header.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/shared/components/left_panel/left_panel_header.tsx @@ -5,8 +5,9 @@ * 2.0. */ -import { EuiTab, EuiTabs, useEuiTheme } from '@elastic/eui'; +import { EuiIconTip, EuiTab, EuiTabs, useEuiTheme } from '@elastic/eui'; import type { ReactElement, VFC } from 'react'; +import { FormattedMessage } from '@kbn/i18n-react'; import React, { memo } from 'react'; import { css } from '@emotion/react'; import { FlyoutHeader } from '../../../../shared/components/flyout_header'; @@ -16,6 +17,7 @@ export type LeftPanelTabsType = Array<{ 'data-test-subj': string; name: ReactElement; content: React.ReactElement; + isTechnicalPreview?: boolean; }>; export enum EntityDetailsLeftPanelTab { @@ -24,6 +26,7 @@ export enum EntityDetailsLeftPanelTab { ENTRA = 'entra_document', CSP_INSIGHTS = 'csp_insights', FIELDS_TABLE = 'fields_table', + GRAPH_VIEW = 'graph_view', RESOLUTION_GROUP = 'resolution_group', } @@ -38,6 +41,25 @@ export interface EntityDetailsPath { subTab?: CspInsightLeftPanelSubTab; } +const TechnicalPreviewBadge = () => ( + + } + content={ + + } + /> +); + export interface PanelHeaderProps { /** * Id of the tab selected in the parent component to display its content @@ -67,6 +89,7 @@ export const LeftPanelHeader: VFC = memo( isSelected={tab.id === selectedTabId} key={index} data-test-subj={tab['data-test-subj']} + append={tab.isTechnicalPreview && } > {tab.name} diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/shared/components/right/test_ids.ts b/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/shared/components/right/test_ids.ts new file mode 100644 index 0000000000000..3a4ea28432412 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/shared/components/right/test_ids.ts @@ -0,0 +1,18 @@ +/* + * 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 { PREFIX } from '../../../../shared/test_ids'; + +const HEADER_TEST_ID = 'Header'; +const CONTENT_TEST_ID = 'Content'; + +export const VISUALIZATIONS_TEST_ID = `${PREFIX}Visualizations` as const; +export const VISUALIZATIONS_SECTION_HEADER_TEST_ID = VISUALIZATIONS_TEST_ID + HEADER_TEST_ID; +export const VISUALIZATIONS_SECTION_CONTENT_TEST_ID = VISUALIZATIONS_TEST_ID + CONTENT_TEST_ID; + +export const GRAPH_PREVIEW_TEST_ID = `${PREFIX}GraphPreview` as const; +export const GRAPH_PREVIEW_LOADING_TEST_ID = `${GRAPH_PREVIEW_TEST_ID}Loading` as const; diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/shared/components/right/visualizations_section.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/shared/components/right/visualizations_section.test.tsx new file mode 100644 index 0000000000000..463d0903e0785 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/shared/components/right/visualizations_section.test.tsx @@ -0,0 +1,141 @@ +/* + * 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 { __IntlProvider as IntlProvider } from '@kbn/i18n-react'; +import { render } from '@testing-library/react'; +import { useFetchGraphData } from '@kbn/cloud-security-posture-graph/src/hooks'; +import { + GRAPH_PREVIEW, + uiMetricService, +} from '@kbn/cloud-security-posture-common/utils/ui_metrics'; +import { METRIC_TYPE } from '@kbn/analytics'; +import { + GRAPH_PREVIEW_TEST_ID, + VISUALIZATIONS_SECTION_CONTENT_TEST_ID, + VISUALIZATIONS_SECTION_HEADER_TEST_ID, +} from './test_ids'; +import { VisualizationsSection } from './visualizations_section'; +import { mockContextValue } from '../../../../document_details/shared/mocks/mock_context'; +import { mockDataFormattedForFieldBrowser } from '../../../../document_details/shared/mocks/mock_data_formatted_for_field_browser'; +import { DocumentDetailsContext } from '../../../../document_details/shared/context'; +import { TestProviders } from '../../../../../common/mock'; +import { useExpandSection } from '../../../../../flyout_v2/shared/hooks/use_expand_section'; +import { useShouldShowGraph } from '../../../../shared/hooks/use_should_show_graph'; + +jest.mock('../../../../../flyout_v2/shared/hooks/use_expand_section', () => ({ + useExpandSection: jest.fn(), +})); + +jest.mock('react-redux', () => { + const original = jest.requireActual('react-redux'); + + return { + ...original, + useDispatch: () => jest.fn(), + }; +}); + +jest.mock('../../../../shared/hooks/use_should_show_graph'); + +const mockUseShouldShowGraph = useShouldShowGraph as jest.Mock; + +jest.mock('@kbn/cloud-security-posture-graph/src/hooks', () => ({ + useFetchGraphData: jest.fn(), +})); + +const mockUseFetchGraphData = useFetchGraphData as jest.Mock; + +jest.mock('@kbn/cloud-security-posture-common/utils/ui_metrics', () => ({ + uiMetricService: { + trackUiMetric: jest.fn(), + }, +})); + +const uiMetricServiceMock = uiMetricService as jest.Mocked; + +const panelContextValue = { + ...mockContextValue, + dataFormattedForFieldBrowser: mockDataFormattedForFieldBrowser, +}; + +const renderVisualizationsSection = (contextValue = panelContextValue) => + render( + + + + + + + + ); + +describe('', () => { + const mockUseExpandSection = jest.mocked(useExpandSection); + + beforeEach(() => { + // Default mock: graph visualization available + mockUseShouldShowGraph.mockReturnValue(true); + mockUseFetchGraphData.mockReturnValue({ + isLoading: false, + isError: false, + data: { + nodes: [], + edges: [], + }, + }); + }); + + it('should render visualizations component', () => { + const { getByTestId } = renderVisualizationsSection(); + + expect(getByTestId(VISUALIZATIONS_SECTION_HEADER_TEST_ID)).toHaveTextContent('Visualizations'); + expect(getByTestId(VISUALIZATIONS_SECTION_CONTENT_TEST_ID)).toBeInTheDocument(); + }); + + it('should render the component collapsed if value is false in local storage', () => { + mockUseExpandSection.mockReturnValue(false); + + const { getByTestId } = renderVisualizationsSection(); + expect(getByTestId(VISUALIZATIONS_SECTION_CONTENT_TEST_ID)).not.toBeVisible(); + }); + + it('should render the component expanded if value is true in local storage', () => { + mockUseExpandSection.mockReturnValue(true); + + const { getByTestId } = renderVisualizationsSection(); + expect(getByTestId(VISUALIZATIONS_SECTION_CONTENT_TEST_ID)).toBeVisible(); + + expect(getByTestId(`${GRAPH_PREVIEW_TEST_ID}LeftSection`)).toBeInTheDocument(); + }); + + it('should render the graph preview component if the feature is enabled', () => { + mockUseExpandSection.mockReturnValue(true); + mockUseShouldShowGraph.mockReturnValue(true); + + const { getByTestId } = renderVisualizationsSection(); + + expect(getByTestId(`${GRAPH_PREVIEW_TEST_ID}LeftSection`)).toBeInTheDocument(); + expect(uiMetricServiceMock.trackUiMetric).toHaveBeenCalledWith( + METRIC_TYPE.LOADED, + GRAPH_PREVIEW + ); + }); + + it('should not render the graph preview component if the graph feature is disabled', () => { + mockUseExpandSection.mockReturnValue(true); + mockUseShouldShowGraph.mockReturnValue(false); + + const { queryByTestId } = renderVisualizationsSection(); + + expect(queryByTestId(`${GRAPH_PREVIEW_TEST_ID}LeftSection`)).not.toBeInTheDocument(); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/shared/components/right/visualizations_section.tsx b/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/shared/components/right/visualizations_section.tsx new file mode 100644 index 0000000000000..92d4ce24c4010 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/shared/components/right/visualizations_section.tsx @@ -0,0 +1,73 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo, useCallback } from 'react'; +import { TableId } from '@kbn/securitysolution-data-table'; +import { FLYOUT_STORAGE_KEYS } from '../../../../../flyout_v2/document/constants/local_storage'; +import { useExpandSection } from '../../../../../flyout_v2/shared/hooks/use_expand_section'; +import { ExpandableSection } from '../../../../../flyout_v2/shared/components/expandable_section'; +import { GraphPreviewContainer } from '../../../../shared/components/graph_preview_container'; +import { + VISUALIZATION_SECTION_TEST_ID, + VISUALIZATION_SECTION_TITLE, +} from '../../../../../flyout_v2/document/components/visualizations_section'; +import { useShouldShowGraph } from '../../../../shared/hooks/use_should_show_graph'; +import { EntityDetailsLeftPanelTab, type EntityDetailsPath } from '../left_panel/left_panel_header'; + +const KEY = 'visualizations'; + +/** + * Visualizations section in overview. It contains analyzer preview and session view preview. + */ +export const VisualizationsSection = memo( + ({ + entityId, + isPreviewMode, + scopeId, + openDetailsPanel, + }: { + entityId: string; + isPreviewMode: boolean; + scopeId: string; + openDetailsPanel?: (path: EntityDetailsPath) => void; + }) => { + const expanded = useExpandSection({ + storageKey: FLYOUT_STORAGE_KEYS.OVERVIEW_TAB_EXPANDED_SECTIONS, + title: KEY, + defaultValue: false, + }); + // Decide whether to show the graph preview or not + const shouldShowGraph = useShouldShowGraph(); + const handleOpenGraphViewTab = useCallback(() => { + openDetailsPanel?.({ tab: EntityDetailsLeftPanelTab.GRAPH_VIEW }); + }, [openDetailsPanel]); + + return ( + <> + {shouldShowGraph && ( + + + + )} + + ); + } +); + +VisualizationsSection.displayName = 'VisualizationsSection'; diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/user_details_left/tabs.tsx b/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/user_details_left/tabs.tsx index ecaf2c21bee45..9034a34efca12 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/user_details_left/tabs.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/user_details_left/tabs.tsx @@ -26,6 +26,7 @@ import { EntityType } from '../../../../common/entity_analytics/types'; import type { LeftPanelTabsType } from '../shared/components/left_panel/left_panel_header'; import { EntityDetailsLeftPanelTab } from '../shared/components/left_panel/left_panel_header'; import type { IdentityFields } from '../../document_details/shared/utils'; +import { getGraphViewTab } from '../shared/components/left'; export const useTabs = ( managedUser: ManagedUserHits, @@ -40,6 +41,7 @@ export const useTabs = ( ): LeftPanelTabsType => useMemo(() => { const tabs: LeftPanelTabsType = []; + const entraManagedUser = managedUser[ManagedUserDatasetKey.ENTRA]; const oktaManagedUser = managedUser[ManagedUserDatasetKey.OKTA]; @@ -74,7 +76,10 @@ export const useTabs = ( } if (entityStoreEntityId) { - tabs.push(getResolutionGroupTab({ entityId: entityStoreEntityId, entityType: 'user' })); + tabs.push(getGraphViewTab({ entityId: entityStoreEntityId, scopeId })); + tabs.push( + getResolutionGroupTab({ entityId: entityStoreEntityId, entityType: EntityType.user }) + ); } return tabs; diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/user_right/constants.ts b/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/user_right/constants.ts new file mode 100644 index 0000000000000..c116fc3d906cd --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/user_right/constants.ts @@ -0,0 +1,9 @@ +/* + * 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 USER_PANEL_RISK_SCORE_QUERY_ID = 'userPanelRiskScoreQuery'; +export const USER_PANEL_OBSERVED_USER_QUERY_ID = 'UserPanelObservedUserQuery'; diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/user_right/content.stories.tsx b/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/user_right/content.stories.tsx index dbb44a781386c..68a14ec6fcad4 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/user_right/content.stories.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/user_right/content.stories.tsx @@ -11,7 +11,7 @@ import { EuiFlyout } from '@elastic/eui'; import { TestProvider } from '@kbn/expandable-flyout/src/test/provider'; import { StorybookProviders } from '../../../common/mock/storybook_providers'; import { mockRiskScoreState } from '../../shared/mocks'; -import { mockObservedUser } from './mocks'; +import { mockObservedUser, mockEntityRecord } from './mocks'; import { UserPanelContent } from './content'; const riskScoreData = { ...mockRiskScoreState, data: [] }; @@ -68,6 +68,26 @@ export const IntegrationDisabled = { name: 'integration disabled', }; +export const WithGraphVisualization = { + render: () => ( + {}} + identityFields={{ 'user.name': 'test-user-name' }} + onAssetCriticalityChange={() => {}} + recalculatingScore={false} + isPreviewMode={false} + entityRecord={mockEntityRecord} + entityStoreEntityId={mockEntityRecord.entity.id} + /> + ), + + name: 'with graph visualization', +}; + export const NoManagedData = { render: () => ( , 'anomalies'> & { @@ -116,6 +117,17 @@ export const UserPanelContent = ({ openDetailsPanel={openDetailsPanel} entityType={EntityType.user} /> + {entityStoreEntityId && ( + <> + + + + )} { contextID: string; @@ -76,8 +79,7 @@ export interface UserPanelExpandableFlyoutProps extends FlyoutPanelProps { } export const UserPreviewPanelKey: UserPanelExpandableFlyoutProps['key'] = 'user-preview-panel'; -export const USER_PANEL_RISK_SCORE_QUERY_ID = 'userPanelRiskScoreQuery'; -export const USER_PANEL_OBSERVED_USER_QUERY_ID = 'UserPanelObservedUserQuery'; + const FIRST_RECORD_PAGINATION = { cursorStart: 0, querySize: 1, diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/user_right/mocks/index.ts b/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/user_right/mocks/index.ts index 3db700f3fef82..8ef09b641fb4c 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/user_right/mocks/index.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/user_right/mocks/index.ts @@ -14,6 +14,7 @@ import { import { mockAnomalies } from '../../../../common/components/ml/mock'; import type { UserItem } from '../../../../../common/search_strategy'; import type { ObservedEntityData } from '../../shared/components/observed_entity/types'; +import type { UserEntity } from '../../../../../common/api/entity_analytics'; const anomaly = mockAnomalies.anomalies[0]; @@ -75,3 +76,26 @@ export const managedUserDetails: ManagedUserHits = { export const mockManagedUserData: ManagedUserData = { data: managedUserDetails, }; + +export const mockEntityRecord: UserEntity = { + '@timestamp': '2024-01-15T10:00:00.000Z', + entity: { + id: 'test-entity-id-abc123', + name: 'test-user-name', + type: 'user', + lifecycle: { + first_seen: '2023-01-01T00:00:00.000Z', + last_activity: '2024-01-15T10:00:00.000Z', + }, + }, + user: { + name: 'test-user-name', + id: ['1234', '321'], + domain: ['test domain'], + full_name: ['Test User'], + email: ['test-user@example.com'], + }, + asset: { + criticality: 'medium_impact', + }, +}; diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/right/components/graph_preview.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/flyout/shared/components/graph_preview.test.tsx similarity index 92% rename from x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/right/components/graph_preview.test.tsx rename to x-pack/solutions/security/plugins/security_solution/public/flyout/shared/components/graph_preview.test.tsx index 2142d19c82870..cf53d9afd2234 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/right/components/graph_preview.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/shared/components/graph_preview.test.tsx @@ -7,9 +7,9 @@ import { render } from '@testing-library/react'; import React from 'react'; -import { TestProviders } from '../../../../common/mock'; -import { mockContextValue } from '../../shared/mocks/mock_context'; -import { DocumentDetailsContext } from '../../shared/context'; +import { TestProviders } from '../../../common/mock'; +import { mockContextValue } from '../../document_details/shared/mocks/mock_context'; +import { DocumentDetailsContext } from '../../document_details/shared/context'; import { GraphPreview, type GraphPreviewProps } from './graph_preview'; import { GRAPH_PREVIEW_TEST_ID, GRAPH_PREVIEW_LOADING_TEST_ID } from './test_ids'; diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/right/components/graph_preview.tsx b/x-pack/solutions/security/plugins/security_solution/public/flyout/shared/components/graph_preview.tsx similarity index 89% rename from x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/right/components/graph_preview.tsx rename to x-pack/solutions/security/plugins/security_solution/public/flyout/shared/components/graph_preview.tsx index 23de79c778d33..dec3c40790ad8 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/right/components/graph_preview.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/shared/components/graph_preview.tsx @@ -70,16 +70,12 @@ export const GraphPreview: React.FC = memo( const memoizedEdges = useMemo(() => data?.edges ?? [], [data?.edges]); return isLoading ? ( - - - + ) : isError || memoizedNodes.length === 0 ? ( - - - + ) : ( ({ + uiMetricService: { + trackUiMetric: jest.fn(), + }, +})); + +jest.mock('../../../common/hooks/use_experimental_features', () => ({ + useIsExperimentalFeatureEnabled: jest.fn(), +})); + +const useIsExperimentalFeatureEnabledMock = useIsExperimentalFeatureEnabled as jest.Mock; + +jest.mock('../hooks/use_graph_preview'); +jest.mock('@kbn/cloud-security-posture-graph/src/hooks', () => ({ + useFetchGraphData: jest.fn(), +})); +const mockUseFetchGraphData = useFetchGraphData as jest.Mock; + +const mockGraph = () =>
; + +jest.mock('@kbn/cloud-security-posture-graph', () => { + return { Graph: mockGraph }; +}); + +const uiMetricServiceMock = uiMetricService as jest.Mocked; + +const mockOnExpandGraph = jest.fn(); + +const defaultProps = { + mode: 'event' as const, + timestamp: new Date().toISOString(), + shouldShowGraph: true, + isAlert: false, + eventIds: [mockContextValue.eventId], + indexName: mockContextValue.indexName, + isPreviewMode: mockContextValue.isPreviewMode, + isRulePreview: mockContextValue.isRulePreview, + onExpandGraph: mockOnExpandGraph, +}; + +const renderGraphPreview = (override: Partial = {}) => + render( + + + + ); + +const DEFAULT_NODES = [ + { + id: '1', + color: 'primary', + shape: 'ellipse', + }, +]; + +describe(' (shared)', () => { + beforeEach(() => { + jest.clearAllMocks(); + useIsExperimentalFeatureEnabledMock.mockReturnValue(true); + }); + + it('should not track ui metric when graph should not show', async () => { + mockUseFetchGraphData.mockReturnValue({ + isLoading: false, + isError: false, + data: undefined, + }); + + const timestamp = new Date().toISOString(); + + const { queryByTestId } = renderGraphPreview({ + timestamp, + eventIds: [], + shouldShowGraph: false, + isAlert: true, + }); + + expect( + queryByTestId(EXPANDABLE_PANEL_CONTENT_TEST_ID(GRAPH_PREVIEW_TEST_ID)) + ).not.toBeInTheDocument(); + expect(mockUseFetchGraphData.mock.calls[0][0].options.enabled).toBe(false); + expect(uiMetricServiceMock.trackUiMetric).not.toHaveBeenCalled(); + }); + + it('should render graph when shouldShowGraph is true and data loads', async () => { + mockUseFetchGraphData.mockReturnValue({ + isLoading: false, + isError: false, + data: { nodes: DEFAULT_NODES, edges: [] }, + }); + + const timestamp = new Date().toISOString(); + const { findByTestId } = renderGraphPreview({ + timestamp, + eventIds: [], + shouldShowGraph: true, + isAlert: true, + }); + + expect(await findByTestId(GRAPH_PREVIEW_TEST_ID)).toBeInTheDocument(); + expect(uiMetricServiceMock.trackUiMetric).toHaveBeenCalledWith( + METRIC_TYPE.LOADED, + GRAPH_PREVIEW + ); + }); + + it('should show expand link when onExpandGraph is provided', async () => { + mockUseFetchGraphData.mockReturnValue({ + isLoading: false, + isError: false, + data: { nodes: DEFAULT_NODES, edges: [] }, + }); + + const timestamp = new Date().toISOString(); + const { getByTestId, findByTestId } = renderGraphPreview({ + timestamp, + eventIds: [], + shouldShowGraph: true, + isAlert: true, + onExpandGraph: mockOnExpandGraph, + }); + + expect(await findByTestId(GRAPH_PREVIEW_TEST_ID)).toBeInTheDocument(); + expect( + getByTestId(EXPANDABLE_PANEL_HEADER_TITLE_LINK_TEST_ID(GRAPH_PREVIEW_TEST_ID)) + ).toBeInTheDocument(); + getByTestId(EXPANDABLE_PANEL_HEADER_TITLE_LINK_TEST_ID(GRAPH_PREVIEW_TEST_ID)).click(); + expect(mockOnExpandGraph).toHaveBeenCalled(); + }); + + it('should not show expand link when onExpandGraph is undefined', async () => { + mockUseFetchGraphData.mockReturnValue({ + isLoading: false, + isError: false, + data: { nodes: DEFAULT_NODES, edges: [] }, + }); + + const timestamp = new Date().toISOString(); + + const { queryByTestId, findByTestId, getByTestId } = renderGraphPreview({ + timestamp, + eventIds: [], + shouldShowGraph: true, + isAlert: true, + onExpandGraph: undefined, + }); + + expect(await findByTestId(GRAPH_PREVIEW_TEST_ID)).toBeInTheDocument(); + expect( + queryByTestId(EXPANDABLE_PANEL_HEADER_TITLE_LINK_TEST_ID(GRAPH_PREVIEW_TEST_ID)) + ).not.toBeInTheDocument(); + expect( + queryByTestId(EXPANDABLE_PANEL_HEADER_TITLE_ICON_TEST_ID(GRAPH_PREVIEW_TEST_ID)) + ).not.toBeInTheDocument(); + expect( + getByTestId(EXPANDABLE_PANEL_HEADER_TITLE_TEXT_TEST_ID(GRAPH_PREVIEW_TEST_ID)) + ).toBeInTheDocument(); + }); + + it('should not show expand link in preview mode even if onExpandGraph is provided', async () => { + mockUseFetchGraphData.mockReturnValue({ + isLoading: false, + isError: false, + data: { nodes: DEFAULT_NODES, edges: [] }, + }); + + const timestamp = new Date().toISOString(); + + const { queryByTestId, findByTestId, getByTestId } = renderGraphPreview({ + timestamp, + eventIds: [], + shouldShowGraph: true, + isAlert: true, + isPreviewMode: true, + onExpandGraph: mockOnExpandGraph, + }); + + expect(await findByTestId(GRAPH_PREVIEW_TEST_ID)).toBeInTheDocument(); + expect( + queryByTestId(EXPANDABLE_PANEL_HEADER_TITLE_LINK_TEST_ID(GRAPH_PREVIEW_TEST_ID)) + ).not.toBeInTheDocument(); + expect( + queryByTestId(EXPANDABLE_PANEL_TOGGLE_ICON_TEST_ID(GRAPH_PREVIEW_TEST_ID)) + ).not.toBeInTheDocument(); + expect( + queryByTestId(EXPANDABLE_PANEL_HEADER_TITLE_ICON_TEST_ID(GRAPH_PREVIEW_TEST_ID)) + ).not.toBeInTheDocument(); + expect( + getByTestId(EXPANDABLE_PANEL_HEADER_TITLE_TEXT_TEST_ID(GRAPH_PREVIEW_TEST_ID)) + ).toBeInTheDocument(); + expect(mockOnExpandGraph).not.toHaveBeenCalled(); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/shared/components/graph_preview_container.tsx b/x-pack/solutions/security/plugins/security_solution/public/flyout/shared/components/graph_preview_container.tsx new file mode 100644 index 0000000000000..ac341fbec91fc --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/shared/components/graph_preview_container.tsx @@ -0,0 +1,168 @@ +/* + * 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, { useEffect } from 'react'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { EuiBetaBadge, useGeneratedHtmlId } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { useFetchGraphData } from '@kbn/cloud-security-posture-graph/src/hooks'; +import { + GRAPH_PREVIEW, + uiMetricService, +} from '@kbn/cloud-security-posture-common/utils/ui_metrics'; +import { METRIC_TYPE } from '@kbn/analytics'; +import type { Maybe } from '@kbn/timelines-plugin/common/search_strategy/common'; +import { GRAPH_PREVIEW_TEST_ID } from './test_ids'; +import { GraphPreview } from './graph_preview'; +import { ExpandablePanel } from '../../../flyout_v2/shared/components/expandable_panel'; +import { useUpsellingComponent } from '../../../common/hooks/use_upselling'; +import { useShouldShowGraph } from '../hooks/use_should_show_graph'; + +/** Props for event/alert mode — derives graph data from the event document */ +interface EventGraphPreviewContainerProps { + mode: 'event'; + /** Whether the graph should be displayed */ + shouldShowGraph: boolean; + /** Whether the source document is an alert or a regular event */ + isAlert: boolean; + /** Timestamp of the source document */ + timestamp: string; + /** Event/alert document ID */ + eventIds: string[]; + /** Index of the source document */ + indexName: Maybe | undefined; + /** Whether the flyout is in preview mode */ + isPreviewMode: boolean; + /** Whether the flyout is in rule preview mode */ + isRulePreview: boolean; + /** Optional callback to expand graph. When undefined, the expand arrow is hidden. */ + onExpandGraph?: () => void; +} + +/** Props for entity mode — uses the entity ID directly as the graph actor */ +interface EntityGraphPreviewContainerProps { + mode: 'entity'; + /** Entity Store v2 entity ID (entity.id) */ + entityId: string; + /** Whether the flyout is in preview mode */ + isPreviewMode: boolean; + /** Whether the flyout is in rule preview mode */ + isRulePreview: boolean; + /** Optional callback to expand graph. When undefined, the expand arrow is hidden. */ + onExpandGraph?: () => void; +} + +export type SharedGraphPreviewContainerProps = + | EventGraphPreviewContainerProps + | EntityGraphPreviewContainerProps; + +/** + * Graph preview under Overview, Visualizations. It shows a graph representation of entities. + * Supports two modes: 'event' (driven by alert/event document data) and 'entity' (driven by entity ID). + */ +export const GraphPreviewContainer: React.FC = (props) => { + const renderingId = useGeneratedHtmlId(); + const { isPreviewMode, isRulePreview, onExpandGraph } = props; + const showExpandControl = !isPreviewMode && !isRulePreview && onExpandGraph != null; + + const eventMode = props.mode === 'event' ? props : null; + const entityMode = props.mode === 'entity' ? props : null; + const shouldShowEntityGraph = useShouldShowGraph() && props.mode === 'entity'; + + const shouldShowGraph = shouldShowEntityGraph || eventMode?.shouldShowGraph; + + const { isLoading, isError, data } = useFetchGraphData({ + req: { + query: { + entityIds: entityMode?.entityId ? [{ id: entityMode.entityId, isOrigin: true }] : undefined, + originEventIds: eventMode?.eventIds.map((id) => ({ id, isAlert: eventMode.isAlert })), + start: props.mode === 'event' ? `${eventMode?.timestamp}||-30m` : 'now-30d', + end: props.mode === 'event' ? `${eventMode?.timestamp}||+30m` : 'now', + }, + }, + options: { + enabled: shouldShowGraph, + refetchOnWindowFocus: false, + }, + }); + + useEffect(() => { + if (shouldShowGraph) { + uiMetricService.trackUiMetric(METRIC_TYPE.LOADED, GRAPH_PREVIEW); + } + }, [shouldShowGraph, renderingId]); + + // Show upsell when event has graph data but license is insufficient (ESS only) + const GraphVisualizationUpsell = useUpsellingComponent('graph_visualization'); + + // Nothing to render when graph is not available and there is no upsell + if (!shouldShowGraph && !GraphVisualizationUpsell) { + return null; + } + + return ( + + ), + headerContent: ( + + ), + iconType: showExpandControl ? 'arrowStart' : undefined, + ...(showExpandControl && + onExpandGraph != null && { + link: { + callback: onExpandGraph, + tooltip: ( + + ), + }, + }), + }} + data-test-subj={GRAPH_PREVIEW_TEST_ID} + content={ + !isLoading && !isError + ? { + paddingSize: 'none', + } + : undefined + } + > + {shouldShowGraph ? ( + + ) : ( + GraphVisualizationUpsell && + )} + + ); +}; + +GraphPreviewContainer.displayName = 'GraphPreviewContainer'; diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/shared/components/graph_visualization.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/flyout/shared/components/graph_visualization.test.tsx new file mode 100644 index 0000000000000..52740039d754d --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/shared/components/graph_visualization.test.tsx @@ -0,0 +1,238 @@ +/* + * 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 '@testing-library/jest-dom'; +import { render, waitFor } from '@testing-library/react'; +import { GraphInvestigation } from '@kbn/cloud-security-posture-graph'; +import { useExpandableFlyoutApi } from '@kbn/expandable-flyout'; +import { GraphVisualization } from './graph_visualization'; +import { mockFlyoutApi } from '../../document_details/shared/mocks/mock_flyout_context'; +import { GRAPH_VISUALIZATION_TEST_ID } from './test_ids'; + +const mockToasts = { + addDanger: jest.fn(), + addError: jest.fn(), + addSuccess: jest.fn(), + addWarning: jest.fn(), + addInfo: jest.fn(), + remove: jest.fn(), +}; + +const mockInvestigateInTimeline = { + investigateInTimeline: jest.fn(), +}; + +const GRAPH_INVESTIGATION_TEST_ID = 'cloudSecurityPostureGraphGraphInvestigation'; + +jest.mock('@kbn/expandable-flyout', () => ({ + useExpandableFlyoutApi: jest.fn(), +})); + +jest.mock('@kbn/cloud-security-posture-graph', () => { + const { isEntityNode, getNodeDocumentMode, hasNodeDocumentsData, getSingleDocumentData } = + jest.requireActual('@kbn/cloud-security-posture-graph/src/components/utils'); + const { GraphGroupedNodePreviewPanelKey, GROUP_PREVIEW_BANNER } = jest.requireActual( + '@kbn/cloud-security-posture-graph/src/components/graph_grouped_node_preview_panel/constants' + ); + const { isEntityItem } = jest.requireActual( + '@kbn/cloud-security-posture-graph/src/components/graph_grouped_node_preview_panel/components/grouped_item/types' + ); + + return { + GraphInvestigation: jest.fn(), + isEntityNode, + isEntityItem, + getNodeDocumentMode, + hasNodeDocumentsData, + getSingleDocumentData, + GraphGroupedNodePreviewPanelKey, + GROUP_PREVIEW_BANNER, + }; +}); + +jest.mock('../../../common/lib/kibana', () => ({ + useToasts: () => mockToasts, + KibanaServices: { + get: () => ({ + uiSettings: { + get: jest.fn().mockReturnValue(true), + }, + }), + }, +})); + +jest.mock('../../../common/hooks/timeline/use_investigate_in_timeline', () => ({ + useInvestigateInTimeline: () => mockInvestigateInTimeline, +})); + +jest.mock('../../../sourcerer/components/use_get_sourcerer_data_view', () => ({ + useGetScopedSourcererDataView: () => ({ + dataView: { + id: 'old-data-view', + getIndexPattern: jest.fn().mockReturnValue('old-data-view-pattern'), + }, + }), +})); + +jest.mock('../../../data_view_manager/hooks/use_data_view', () => ({ + useDataView: () => ({ + dataView: { + id: 'experimental-data-view', + getIndexPattern: jest.fn().mockReturnValue('experimental-data-view-pattern'), + }, + }), +})); + +jest.mock('../../../common/hooks/use_experimental_features', () => ({ + useIsExperimentalFeatureEnabled: () => true, +})); + +const EVENT_PROPS = { + mode: 'event' as const, + scopeId: 'test-scope', + eventIds: ['event-1', 'event-2'], + timestamp: new Date().toISOString(), + isAlert: false, +}; + +const ENTITY_PROPS = { + mode: 'entity' as const, + scopeId: 'test-scope', + entityId: 'entity-1', +}; + +describe('GraphVisualization', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.mocked(useExpandableFlyoutApi).mockReturnValue(mockFlyoutApi); + (GraphInvestigation as unknown as jest.Mock).mockReturnValue( +
+ ); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('event mode', () => { + it('renders the wrapper and lazy-loads GraphInvestigation', async () => { + const { getByTestId } = render(); + expect(getByTestId(GRAPH_VISUALIZATION_TEST_ID)).toBeInTheDocument(); + + await waitFor(() => { + expect(getByTestId(GRAPH_INVESTIGATION_TEST_ID)).toBeInTheDocument(); + }); + }); + + it('passes originEventIds derived from eventIds and isAlert', async () => { + render(); + + await waitFor(() => { + expect(GraphInvestigation).toHaveBeenCalledTimes(1); + }); + + const { initialState } = jest.mocked(GraphInvestigation).mock.calls[0][0]; + expect(initialState.originEventIds).toEqual([ + { id: 'event-1', isAlert: false }, + { id: 'event-2', isAlert: false }, + ]); + expect(initialState.entityIds).toBeUndefined(); + }); + + it('passes a timestamp-anchored timeRange', async () => { + const timestamp = '2024-01-15T10:00:00.000Z'; + render(); + + await waitFor(() => { + expect(GraphInvestigation).toHaveBeenCalledTimes(1); + }); + + const { initialState } = jest.mocked(GraphInvestigation).mock.calls[0][0]; + expect(initialState.timeRange).toEqual({ + from: `${timestamp}||-30m`, + to: `${timestamp}||+30m`, + }); + }); + + it('passes onOpenEventPreview callback', async () => { + render(); + + await waitFor(() => { + expect(GraphInvestigation).toHaveBeenCalledTimes(1); + }); + + expect(typeof jest.mocked(GraphInvestigation).mock.calls[0][0].onOpenEventPreview).toBe( + 'function' + ); + }); + }); + + describe('entity mode', () => { + it('renders the wrapper and lazy-loads GraphInvestigation', async () => { + const { getByTestId } = render(); + expect(getByTestId(GRAPH_VISUALIZATION_TEST_ID)).toBeInTheDocument(); + + await waitFor(() => { + expect(getByTestId(GRAPH_INVESTIGATION_TEST_ID)).toBeInTheDocument(); + }); + }); + + it('passes entityIds with isOrigin: true', async () => { + render(); + + await waitFor(() => { + expect(GraphInvestigation).toHaveBeenCalledTimes(1); + }); + + const { initialState } = jest.mocked(GraphInvestigation).mock.calls[0][0]; + expect(initialState.entityIds).toEqual([{ id: 'entity-1', isOrigin: true }]); + expect(initialState.originEventIds).toBeUndefined(); + }); + + it('passes a rolling 30-day timeRange', async () => { + render(); + + await waitFor(() => { + expect(GraphInvestigation).toHaveBeenCalledTimes(1); + }); + + const { initialState } = jest.mocked(GraphInvestigation).mock.calls[0][0]; + expect(initialState.timeRange).toEqual({ from: 'now-30d', to: 'now' }); + }); + }); + + describe('onInvestigateInTimeline', () => { + it('shows a danger toast when time range cannot be parsed', async () => { + render(); + + await waitFor(() => { + expect(GraphInvestigation).toHaveBeenCalledTimes(1); + }); + + const { onInvestigateInTimeline } = jest.mocked(GraphInvestigation).mock.calls[0][0]; + onInvestigateInTimeline?.(undefined, [], { from: '', to: '' }); + + expect(mockInvestigateInTimeline.investigateInTimeline).not.toHaveBeenCalled(); + expect(mockToasts.addDanger).toHaveBeenCalled(); + }); + + it('calls investigateInTimeline with a valid time range', async () => { + render(); + + await waitFor(() => { + expect(GraphInvestigation).toHaveBeenCalledTimes(1); + }); + + const { onInvestigateInTimeline } = jest.mocked(GraphInvestigation).mock.calls[0][0]; + onInvestigateInTimeline?.(undefined, [], { from: 'now-15m', to: 'now' }); + + expect(mockInvestigateInTimeline.investigateInTimeline).toHaveBeenCalled(); + expect(mockToasts.addDanger).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/shared/components/graph_visualization.tsx b/x-pack/solutions/security/plugins/security_solution/public/flyout/shared/components/graph_visualization.tsx new file mode 100644 index 0000000000000..6dc2d86661281 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/shared/components/graph_visualization.tsx @@ -0,0 +1,336 @@ +/* + * 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 } from 'react'; +import { css } from '@emotion/react'; +import { EuiLoadingSpinner } from '@elastic/eui'; +import type { Filter, Query, TimeRange } from '@kbn/es-query'; +import dateMath from '@kbn/datemath'; +import { i18n } from '@kbn/i18n'; +import { useExpandableFlyoutApi } from '@kbn/expandable-flyout'; +import { + getNodeDocumentMode, + getSingleDocumentData, + GraphGroupedNodePreviewPanelKey, + GROUP_PREVIEW_BANNER, + NETWORK_PREVIEW_BANNER, + type NodeViewModel, +} from '@kbn/cloud-security-posture-graph'; +import { type NodeDocumentDataModel } from '@kbn/cloud-security-posture-common/types/graph/v1'; +import { DOCUMENT_TYPE_ENTITY } from '@kbn/cloud-security-posture-common/schema/graph/v1'; +import { isEntityNodeEnriched } from '@kbn/cloud-security-posture-graph/src/components/utils'; +import { PageScope } from '../../../data_view_manager/constants'; +import { useDataView } from '../../../data_view_manager/hooks/use_data_view'; +import { useGetScopedSourcererDataView } from '../../../sourcerer/components/use_get_sourcerer_data_view'; +import { GRAPH_VISUALIZATION_TEST_ID } from './test_ids'; +import { useInvestigateInTimeline } from '../../../common/hooks/timeline/use_investigate_in_timeline'; +import { normalizeTimeRange } from '../../../common/utils/normalize_time_range'; +import { useIsExperimentalFeatureEnabled } from '../../../common/hooks/use_experimental_features'; +import { DocumentDetailsPreviewPanelKey } from '../../document_details/shared/constants/panel_keys'; +import { + ALERT_PREVIEW_BANNER, + EVENT_PREVIEW_BANNER, + GENERIC_ENTITY_PREVIEW_BANNER, +} from '../../document_details/preview/constants'; +import { useToasts } from '../../../common/lib/kibana'; +import { GenericEntityPanelKey, EntityPanelKeyByType } from '../../entity_details/shared/constants'; +import { FlowTargetSourceDest } from '../../../../common/search_strategy'; + +const GraphInvestigationLazy = React.lazy(() => + import('@kbn/cloud-security-posture-graph').then((module) => ({ + default: module.GraphInvestigation, + })) +); + +export const GRAPH_ID = 'graph-visualization' as const; + +const MAX_DOCUMENTS_TO_LOAD = 50; + +/** Props for event/alert mode — drives the graph from an alert/event document */ +interface EventGraphVisualizationProps { + mode: 'event'; + /** Scope ID for the flyout panel */ + scopeId: string; + /** Event/alert document IDs that are the origin of the graph */ + eventIds: string[]; + /** Timestamp of the source event/alert */ + timestamp: string; + /** Whether the source document is an alert */ + isAlert: boolean; +} + +/** Props for entity mode — drives the graph from an Entity Store entity ID */ +interface EntityGraphVisualizationProps { + mode: 'entity'; + /** Scope ID for the flyout panel */ + scopeId: string; + /** Entity Store v2 entity ID (`entity.id`) to center the graph on */ + entityId: string; +} + +export type GraphVisualizationProps = ( + | EventGraphVisualizationProps + | EntityGraphVisualizationProps +) & { + height?: number | string; +}; + +/** + * Full-screen graph investigation view for use in left-panel flyout tabs. + * Supports two modes: + * - 'event': driven by alert/event document IDs and timestamp (used in document details). + * - 'entity': driven by an Entity Store entity ID (used in entity detail panels). + */ +export const GraphVisualization: React.FC = memo((props) => { + const { scopeId } = props; + + const toasts = useToasts(); + const oldDataView = useGetScopedSourcererDataView({ + sourcererScope: PageScope.default, + }); + + const { dataView: experimentalDataView } = useDataView(PageScope.default); + const newDataViewPickerEnabled = useIsExperimentalFeatureEnabled('newDataViewPickerEnabled'); + + const dataView = newDataViewPickerEnabled ? experimentalDataView : oldDataView; + const dataViewIndexPattern = dataView ? dataView.getIndexPattern() : undefined; + + const { openPreviewPanel } = useExpandableFlyoutApi(); + + const onOpenNetworkPreview = useCallback( + (ip: string, previewScopeId: string) => { + openPreviewPanel({ + id: 'network-preview', + params: { + ip, + scopeId: previewScopeId, + flowTarget: FlowTargetSourceDest.source, + banner: NETWORK_PREVIEW_BANNER, + isPreviewMode: true, + }, + }); + }, + [openPreviewPanel] + ); + + const onOpenEventPreview = useCallback( + (node: NodeViewModel) => { + const singleDocumentData = getSingleDocumentData(node); + const docMode = getNodeDocumentMode(node); + const documentsData = (node.documentsData ?? []) as NodeDocumentDataModel[]; + + const showEntityPreview = (item: { + id: string; + entity?: NodeDocumentDataModel['entity']; + }) => { + const engineType = item.entity?.engine_type; + const panelId = + engineType && engineType in EntityPanelKeyByType + ? EntityPanelKeyByType[engineType as keyof typeof EntityPanelKeyByType] ?? + GenericEntityPanelKey + : GenericEntityPanelKey; + + if (!panelId) { + toasts.addDanger({ + title: i18n.translate( + 'xpack.securitySolution.flyout.shared.components.graphVisualization.errorInvalidEntityPanel', + { + defaultMessage: 'Unable to open entity preview', + } + ), + }); + return; + } + + const params = + engineType === 'host' + ? { hostName: item.entity?.name } + : engineType === 'user' + ? { userName: item.entity?.name } + : engineType === 'service' + ? { serviceName: item.entity?.name } + : {}; + + openPreviewPanel({ + id: panelId, + params: { + entityId: item.id, + scopeId, + isPreviewMode: true, + banner: GENERIC_ENTITY_PREVIEW_BANNER, + isEngineMetadataExist: !!item.entity, + ...params, + }, + }); + }; + + const showEventOrAlertPreview = ( + item: { id: string }, + banner: { + title: string; + backgroundColor: string; + textColor: string; + }, + index?: string + ) => { + openPreviewPanel({ + id: DocumentDetailsPreviewPanelKey, + params: { + id: item.id, + indexName: index, + scopeId, + banner, + isPreviewMode: true, + }, + }); + }; + + if ((docMode === 'single-event' || docMode === 'single-alert') && singleDocumentData) { + showEventOrAlertPreview( + singleDocumentData, + docMode === 'single-alert' ? ALERT_PREVIEW_BANNER : EVENT_PREVIEW_BANNER, + singleDocumentData.index + ); + } else if (docMode === 'single-entity' && singleDocumentData && isEntityNodeEnriched(node)) { + showEntityPreview(singleDocumentData); + } else if (docMode === 'grouped-entities' && documentsData.length > 0) { + openPreviewPanel({ + id: GraphGroupedNodePreviewPanelKey, + params: { + id: node.id, + scopeId, + isPreviewMode: true, + banner: GROUP_PREVIEW_BANNER, + docMode, + entityItems: (node.documentsData as NodeDocumentDataModel[]) + .slice(0, MAX_DOCUMENTS_TO_LOAD) + .map((doc) => ({ + itemType: DOCUMENT_TYPE_ENTITY, + entity: doc.entity, + id: doc.id, + icon: node.icon, + })), + }, + }); + } else if (docMode === 'grouped-events' && documentsData.length > 0) { + openPreviewPanel({ + id: GraphGroupedNodePreviewPanelKey, + params: { + id: node.id, + scopeId, + isPreviewMode: true, + banner: GROUP_PREVIEW_BANNER, + docMode, + dataViewId: dataViewIndexPattern, + documentIds: (node.documentsData as NodeDocumentDataModel[]) + .slice(0, MAX_DOCUMENTS_TO_LOAD) + .map((doc) => doc.event?.id), + }, + }); + } else { + toasts.addDanger({ + title: i18n.translate( + 'xpack.securitySolution.flyout.shared.components.graphVisualization.errorOpenNodePreview', + { + defaultMessage: 'Failed showing preview', + } + ), + }); + } + }, + [toasts, openPreviewPanel, scopeId, dataViewIndexPattern] + ); + + const { investigateInTimeline } = useInvestigateInTimeline(); + const openTimelineCallback = useCallback( + (query: Query | undefined, filters: Filter[], timeRange: TimeRange) => { + const from = dateMath.parse(timeRange.from); + const to = dateMath.parse(timeRange.to); + + if (!from || !to) { + toasts.addDanger({ + title: i18n.translate( + 'xpack.securitySolution.flyout.shared.components.graphVisualization.errorInvalidTimeRange', + { + defaultMessage: 'Invalid time range', + } + ), + text: i18n.translate( + 'xpack.securitySolution.flyout.shared.components.graphVisualization.errorInvalidTimeRangeDescription', + { + defaultMessage: 'Please select a valid time range.', + } + ), + }); + return; + } + + const normalizedTimeRange = normalizeTimeRange({ + ...timeRange, + from: from.toISOString(), + to: to.toISOString(), + }); + + investigateInTimeline({ + keepDataView: true, + query, + filters, + timeRange: { + from: normalizedTimeRange.from, + to: normalizedTimeRange.to, + kind: 'absolute', + }, + }); + }, + [investigateInTimeline, toasts] + ); + + return ( +
+ {dataView && ( + }> + ({ id, isAlert: props.isAlert })), + timeRange: { + from: `${props.timestamp}||-30m`, + to: `${props.timestamp}||+30m`, + }, + } + : { + dataView, + entityIds: [{ id: props.entityId, isOrigin: true }], + timeRange: { + from: 'now-30d', + to: 'now', + }, + } + } + showInvestigateInTimeline={true} + showToggleSearch={true} + onInvestigateInTimeline={openTimelineCallback} + onOpenEventPreview={onOpenEventPreview} + onOpenNetworkPreview={onOpenNetworkPreview} + /> + + )} +
+ ); +}); + +GraphVisualization.displayName = 'GraphVisualization'; diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/shared/components/test_ids.ts b/x-pack/solutions/security/plugins/security_solution/public/flyout/shared/components/test_ids.ts index 94e5bd6f8cc1a..c990f272211ac 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/flyout/shared/components/test_ids.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/shared/components/test_ids.ts @@ -13,6 +13,10 @@ export const FLYOUT_LINK_TEST_ID = `${PREFIX}Link` as const; export const FLYOUT_ERROR_TEST_ID = `${PREFIX}Error` as const; export const FLYOUT_LOADING_TEST_ID = `${PREFIX}Loading` as const; +export const GRAPH_PREVIEW_TEST_ID = `${PREFIX}GraphPreview` as const; +export const GRAPH_PREVIEW_LOADING_TEST_ID = `${GRAPH_PREVIEW_TEST_ID}Loading` as const; +export const GRAPH_VISUALIZATION_TEST_ID = `${PREFIX}GraphVisualization` as const; + /* Header Navigation */ const FLYOUT_NAVIGATION_TEST_ID = `${PREFIX}Navigation` as const; diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/shared/hooks/use_graph_preview.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/flyout/shared/hooks/use_graph_preview.test.tsx new file mode 100644 index 0000000000000..23087ee2f3af7 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/shared/hooks/use_graph_preview.test.tsx @@ -0,0 +1,667 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { TimelineEventsDetailsItem } from '@kbn/timelines-plugin/common'; +import { renderHook } from '@testing-library/react'; +import type { UseGraphPreviewParams } from './use_graph_preview'; +import { useGraphPreview } from './use_graph_preview'; +import type { GetFieldsData } from '../../document_details/shared/hooks/use_get_fields_data'; +import { mockFieldData } from '../../document_details/shared/mocks/mock_get_fields_data'; +import { mockDataFormattedForFieldBrowser } from '../../document_details/shared/mocks/mock_data_formatted_for_field_browser'; +import { useHasGraphVisualizationLicense } from '../../../common/hooks/use_has_graph_visualization_license'; +import { + GRAPH_ACTOR_EUID_SOURCE_FIELDS, + GRAPH_TARGET_EUID_SOURCE_FIELDS, +} from '@kbn/cloud-security-posture-common'; + +jest.mock('../../../common/hooks/use_has_graph_visualization_license'); +const mockUseHasGraphVisualizationLicense = useHasGraphVisualizationLicense as jest.Mock; + +jest.mock('../../../entity_analytics/components/entity_store/hooks/use_entity_store'); +import { useEntityStoreStatus } from '../../../entity_analytics/components/entity_store/hooks/use_entity_store'; +const mockUseEntityStoreStatus = useEntityStoreStatus as jest.Mock; + +// All EUID source fields (must explicitly handle to avoid mockFieldData bleed-through) +const ALL_EUID_SOURCE_FIELDS: readonly string[] = [ + ...GRAPH_ACTOR_EUID_SOURCE_FIELDS, + ...GRAPH_TARGET_EUID_SOURCE_FIELDS, +]; + +// Mock uses EUID source fields (user.id as actor, entity.target.id as target) +// Explicitly returns undefined for all other EUID source fields to prevent mockFieldData bleed-through +const alertMockGetFieldsData: GetFieldsData = (field: string) => { + if (field === 'kibana.alert.uuid') { + return 'alertId'; + } else if (field === 'kibana.alert.original_event.id') { + return 'eventId'; + } else if (field === 'user.id') { + return 'userActorId'; + } else if (field === 'entity.target.id') { + return 'entityTargetId'; + } else if (ALL_EUID_SOURCE_FIELDS.includes(field)) { + // Explicitly return undefined for all other EUID source fields + return undefined; + } + + return mockFieldData[field]; +}; + +const alertMockDataFormattedForFieldBrowser = mockDataFormattedForFieldBrowser; + +// Mock uses EUID source fields (user.id as actor, entity.target.id as target) +// Explicitly returns undefined for all other EUID source fields to prevent mockFieldData bleed-through +const eventMockGetFieldsData: GetFieldsData = (field: string) => { + if (field === 'kibana.alert.uuid') { + return; + } else if (field === 'kibana.alert.original_event.id') { + return; + } else if (field === 'event.id') { + return 'eventId'; + } else if (field === 'user.id') { + return 'userActorId'; + } else if (field === 'entity.target.id') { + return 'entityTargetId'; + } else if (ALL_EUID_SOURCE_FIELDS.includes(field)) { + return undefined; + } + + return mockFieldData[field]; +}; + +const eventMockDataFormattedForFieldBrowser: TimelineEventsDetailsItem[] = []; + +describe('useGraphPreview', () => { + beforeEach(() => { + jest.clearAllMocks(); + // Default mock: graph visualization feature is available + mockUseHasGraphVisualizationLicense.mockReturnValue(true); + // Default mock: entity store is running + mockUseEntityStoreStatus.mockReturnValue({ data: { status: 'running' } }); + }); + + it(`should return false when missing actor and target`, () => { + const getFieldsData: GetFieldsData = (field: string) => { + // Return undefined for all EUID source fields (actor and target) + if ( + field === 'user.email' || + field === 'user.id' || + field === 'user.name' || + field === 'host.id' || + field === 'host.name' || + field === 'host.hostname' || + field === 'service.name' || + field === 'entity.id' || + field === 'user.target.email' || + field === 'user.target.id' || + field === 'user.target.name' || + field === 'host.target.id' || + field === 'host.target.name' || + field === 'host.target.hostname' || + field === 'service.target.name' || + field === 'entity.target.id' + ) { + return; + } + return alertMockGetFieldsData(field); + }; + + const hookResult = renderHook((props: UseGraphPreviewParams) => useGraphPreview(props), { + initialProps: { + getFieldsData, + ecsData: { + _id: 'id', + event: { + action: ['action'], + }, + }, + dataFormattedForFieldBrowser: alertMockDataFormattedForFieldBrowser, + }, + }); + + expect(hookResult.result.current).toStrictEqual({ + shouldShowGraph: false, + hasGraphData: false, + timestamp: mockFieldData['@timestamp'][0], + eventIds: ['eventId'], + actorIds: [], + action: ['action'], + targetIds: [], + isAlert: true, + }); + }); + + it(`should return false when missing event.action`, () => { + const hookResult = renderHook((props: UseGraphPreviewParams) => useGraphPreview(props), { + initialProps: { + getFieldsData: alertMockGetFieldsData, + ecsData: { + _id: 'id', + }, + dataFormattedForFieldBrowser: alertMockDataFormattedForFieldBrowser, + }, + }); + + expect(hookResult.result.current).toStrictEqual({ + shouldShowGraph: false, + hasGraphData: false, + timestamp: mockFieldData['@timestamp'][0], + eventIds: ['eventId'], + actorIds: ['userActorId'], + action: undefined, + targetIds: ['entityTargetId'], + isAlert: true, + }); + }); + + it(`should return false when missing actor (target exists)`, () => { + const getFieldsData: GetFieldsData = (field: string) => { + // Return undefined for all actor EUID source fields + if ( + field === 'user.email' || + field === 'user.id' || + field === 'user.name' || + field === 'host.id' || + field === 'host.name' || + field === 'host.hostname' || + field === 'service.name' || + field === 'entity.id' + ) { + return; + } + return alertMockGetFieldsData(field); + }; + + const hookResult = renderHook((props: UseGraphPreviewParams) => useGraphPreview(props), { + initialProps: { + getFieldsData, + ecsData: { + _id: 'id', + event: { + action: ['action'], + }, + }, + dataFormattedForFieldBrowser: alertMockDataFormattedForFieldBrowser, + }, + }); + + expect(hookResult.result.current).toStrictEqual({ + shouldShowGraph: false, + hasGraphData: false, + timestamp: mockFieldData['@timestamp'][0], + eventIds: ['eventId'], + actorIds: [], + action: ['action'], + targetIds: ['entityTargetId'], + isAlert: true, + }); + }); + + it(`should return false when missing target (actor exists)`, () => { + const getFieldsData: GetFieldsData = (field: string) => { + // Return undefined for all target EUID source fields + if ( + field === 'user.target.email' || + field === 'user.target.id' || + field === 'user.target.name' || + field === 'host.target.id' || + field === 'host.target.name' || + field === 'host.target.hostname' || + field === 'service.target.name' || + field === 'entity.target.id' + ) { + return; + } + return alertMockGetFieldsData(field); + }; + + const hookResult = renderHook((props: UseGraphPreviewParams) => useGraphPreview(props), { + initialProps: { + getFieldsData, + ecsData: { + _id: 'id', + event: { + action: ['action'], + }, + }, + dataFormattedForFieldBrowser: alertMockDataFormattedForFieldBrowser, + }, + }); + + expect(hookResult.result.current).toStrictEqual({ + shouldShowGraph: false, + hasGraphData: false, + timestamp: mockFieldData['@timestamp'][0], + eventIds: ['eventId'], + actorIds: ['userActorId'], + action: ['action'], + targetIds: [], + isAlert: true, + }); + }); + + it(`should return false when missing original_event.id`, () => { + const getFieldsData: GetFieldsData = (field: string) => { + if (field === 'kibana.alert.original_event.id') { + return; + } + + return alertMockGetFieldsData(field); + }; + + const hookResult = renderHook((props: UseGraphPreviewParams) => useGraphPreview(props), { + initialProps: { + getFieldsData, + ecsData: { + _id: 'id', + event: { + action: ['action'], + }, + }, + dataFormattedForFieldBrowser: alertMockDataFormattedForFieldBrowser, + }, + }); + + expect(hookResult.result.current).toStrictEqual({ + shouldShowGraph: false, + hasGraphData: false, + timestamp: mockFieldData['@timestamp'][0], + eventIds: [], + actorIds: ['userActorId'], + action: ['action'], + targetIds: ['entityTargetId'], + isAlert: true, + }); + }); + + it(`should return false when timestamp is missing`, () => { + const getFieldsData: GetFieldsData = (field: string) => { + if (field === '@timestamp') { + return; + } + + return alertMockGetFieldsData(field); + }; + + const hookResult = renderHook((props: UseGraphPreviewParams) => useGraphPreview(props), { + initialProps: { + getFieldsData, + ecsData: { + _id: 'id', + event: { + action: ['action'], + }, + }, + dataFormattedForFieldBrowser: alertMockDataFormattedForFieldBrowser, + }, + }); + + expect(hookResult.result.current).toStrictEqual({ + shouldShowGraph: false, + hasGraphData: false, + timestamp: null, + eventIds: ['eventId'], + actorIds: ['userActorId'], + action: ['action'], + targetIds: ['entityTargetId'], + isAlert: true, + }); + }); + + it(`should return true when event has graph graph preview`, () => { + const hookResult = renderHook((props: UseGraphPreviewParams) => useGraphPreview(props), { + initialProps: { + getFieldsData: eventMockGetFieldsData, + ecsData: { + _id: 'id', + event: { + action: ['action'], + }, + }, + dataFormattedForFieldBrowser: eventMockDataFormattedForFieldBrowser, + }, + }); + + expect(hookResult.result.current).toStrictEqual({ + shouldShowGraph: true, + hasGraphData: true, + timestamp: mockFieldData['@timestamp'][0], + eventIds: ['eventId'], + actorIds: ['userActorId'], + action: ['action'], + targetIds: ['entityTargetId'], + isAlert: false, + }); + }); + + it(`should return true when event has graph preview with multiple values`, () => { + const getFieldsData: GetFieldsData = (field: string) => { + if (field === 'kibana.alert.uuid') { + return; + } else if (field === 'kibana.alert.original_event.id') { + return; + } else if (field === 'event.id') { + return ['id1', 'id2']; + } else if (field === 'user.id') { + return ['userActorId1', 'userActorId2']; + } else if (field === 'entity.target.id') { + return ['entityTargetId1', 'entityTargetId2']; + } else if (ALL_EUID_SOURCE_FIELDS.includes(field)) { + return undefined; + } + + return mockFieldData[field]; + }; + + const hookResult = renderHook((props: UseGraphPreviewParams) => useGraphPreview(props), { + initialProps: { + getFieldsData, + ecsData: { + _id: 'id', + event: { + action: ['action1', 'action2'], + }, + }, + dataFormattedForFieldBrowser: eventMockDataFormattedForFieldBrowser, + }, + }); + + expect(hookResult.result.current).toStrictEqual({ + shouldShowGraph: true, + hasGraphData: true, + timestamp: mockFieldData['@timestamp'][0], + eventIds: ['id1', 'id2'], + actorIds: ['userActorId1', 'userActorId2'], + action: ['action1', 'action2'], + targetIds: ['entityTargetId1', 'entityTargetId2'], + isAlert: false, + }); + }); + + it(`should return true when alert has graph preview`, () => { + const hookResult = renderHook((props: UseGraphPreviewParams) => useGraphPreview(props), { + initialProps: { + getFieldsData: alertMockGetFieldsData, + ecsData: { + _id: 'id', + event: { + action: ['action'], + }, + }, + dataFormattedForFieldBrowser: alertMockDataFormattedForFieldBrowser, + }, + }); + + expect(hookResult.result.current).toStrictEqual({ + shouldShowGraph: true, + hasGraphData: true, + timestamp: mockFieldData['@timestamp'][0], + eventIds: ['eventId'], + actorIds: ['userActorId'], + action: ['action'], + targetIds: ['entityTargetId'], + isAlert: true, + }); + }); + + it(`should return true when alert has graph preview with multiple values`, () => { + const getFieldsData: GetFieldsData = (field: string) => { + if (field === 'kibana.alert.uuid') { + return 'alertId'; + } else if (field === 'kibana.alert.original_event.id') { + return ['id1', 'id2']; + } else if (field === 'user.id') { + return ['userActorId1', 'userActorId2']; + } else if (field === 'entity.target.id') { + return ['entityTargetId1', 'entityTargetId2']; + } else if (ALL_EUID_SOURCE_FIELDS.includes(field)) { + return undefined; + } + + return mockFieldData[field]; + }; + + const hookResult = renderHook((props: UseGraphPreviewParams) => useGraphPreview(props), { + initialProps: { + getFieldsData, + ecsData: { + _id: 'id', + event: { + action: ['action1', 'action2'], + }, + }, + dataFormattedForFieldBrowser: alertMockDataFormattedForFieldBrowser, + }, + }); + + expect(hookResult.result.current).toStrictEqual({ + shouldShowGraph: true, + hasGraphData: true, + timestamp: mockFieldData['@timestamp'][0], + eventIds: ['id1', 'id2'], + actorIds: ['userActorId1', 'userActorId2'], + action: ['action1', 'action2'], + targetIds: ['entityTargetId1', 'entityTargetId2'], + isAlert: true, + }); + }); + + it(`should return true when alert has graph preview with user EUID source fields (user.name)`, () => { + const getFieldsData: GetFieldsData = (field: string) => { + if (field === 'kibana.alert.uuid') { + return 'alertId'; + } else if (field === 'kibana.alert.original_event.id') { + return 'eventId'; + } else if (field === 'user.name') { + return 'userActorId'; + } else if (field === 'service.target.name') { + return 'serviceTargetId'; + } else if (ALL_EUID_SOURCE_FIELDS.includes(field)) { + return undefined; + } + + return mockFieldData[field]; + }; + + const hookResult = renderHook((props: UseGraphPreviewParams) => useGraphPreview(props), { + initialProps: { + getFieldsData, + ecsData: { + _id: 'id', + event: { + action: ['action'], + }, + }, + dataFormattedForFieldBrowser: alertMockDataFormattedForFieldBrowser, + }, + }); + + expect(hookResult.result.current).toStrictEqual({ + shouldShowGraph: true, + hasGraphData: true, + timestamp: mockFieldData['@timestamp'][0], + eventIds: ['eventId'], + actorIds: ['userActorId'], + action: ['action'], + targetIds: ['serviceTargetId'], + isAlert: true, + }); + }); + + it(`should return true when alert has graph preview with host EUID source fields (host.id)`, () => { + const getFieldsData: GetFieldsData = (field: string) => { + if (field === 'kibana.alert.uuid') { + return 'alertId'; + } else if (field === 'kibana.alert.original_event.id') { + return 'eventId'; + } else if (field === 'host.id') { + return 'hostActorId'; + } else if (field === 'entity.target.id') { + return 'entityTargetId'; + } else if (ALL_EUID_SOURCE_FIELDS.includes(field)) { + return undefined; + } + + return mockFieldData[field]; + }; + + const hookResult = renderHook((props: UseGraphPreviewParams) => useGraphPreview(props), { + initialProps: { + getFieldsData, + ecsData: { + _id: 'id', + event: { + action: ['action'], + }, + }, + dataFormattedForFieldBrowser: alertMockDataFormattedForFieldBrowser, + }, + }); + + expect(hookResult.result.current).toStrictEqual({ + shouldShowGraph: true, + hasGraphData: true, + timestamp: mockFieldData['@timestamp'][0], + eventIds: ['eventId'], + actorIds: ['hostActorId'], + action: ['action'], + targetIds: ['entityTargetId'], + isAlert: true, + }); + }); + + it(`should return true when alert has graph preview with service EUID source fields (service.name)`, () => { + const getFieldsData: GetFieldsData = (field: string) => { + if (field === 'kibana.alert.uuid') { + return 'alertId'; + } else if (field === 'kibana.alert.original_event.id') { + return 'eventId'; + } else if (field === 'service.name') { + return 'serviceActorId'; + } else if (field === 'user.target.id') { + return 'userTargetId'; + } else if (ALL_EUID_SOURCE_FIELDS.includes(field)) { + return undefined; + } + + return mockFieldData[field]; + }; + + const hookResult = renderHook((props: UseGraphPreviewParams) => useGraphPreview(props), { + initialProps: { + getFieldsData, + ecsData: { + _id: 'id', + event: { + action: ['action'], + }, + }, + dataFormattedForFieldBrowser: alertMockDataFormattedForFieldBrowser, + }, + }); + + expect(hookResult.result.current).toStrictEqual({ + shouldShowGraph: true, + hasGraphData: true, + timestamp: mockFieldData['@timestamp'][0], + eventIds: ['eventId'], + actorIds: ['serviceActorId'], + action: ['action'], + targetIds: ['userTargetId'], + isAlert: true, + }); + }); + + it(`should return true when alert has graph preview with generic EUID source fields (entity.id)`, () => { + const getFieldsData: GetFieldsData = (field: string) => { + if (field === 'kibana.alert.uuid') { + return 'alertId'; + } else if (field === 'kibana.alert.original_event.id') { + return 'eventId'; + } else if (field === 'entity.id') { + return 'entityActorId'; + } else if (field === 'host.target.id') { + return 'hostTargetId'; + } else if (ALL_EUID_SOURCE_FIELDS.includes(field)) { + return undefined; + } + + return mockFieldData[field]; + }; + + const hookResult = renderHook((props: UseGraphPreviewParams) => useGraphPreview(props), { + initialProps: { + getFieldsData, + ecsData: { + _id: 'id', + event: { + action: ['action'], + }, + }, + dataFormattedForFieldBrowser: alertMockDataFormattedForFieldBrowser, + }, + }); + + expect(hookResult.result.current).toStrictEqual({ + shouldShowGraph: true, + hasGraphData: true, + timestamp: mockFieldData['@timestamp'][0], + eventIds: ['eventId'], + actorIds: ['entityActorId'], + action: ['action'], + targetIds: ['hostTargetId'], + isAlert: true, + }); + }); + + it('should return false when all conditions are met but env does not have required license', () => { + mockUseHasGraphVisualizationLicense.mockReturnValue(false); + + const hookResult = renderHook((props: UseGraphPreviewParams) => useGraphPreview(props), { + initialProps: { + getFieldsData: alertMockGetFieldsData, + ecsData: { + _id: 'id', + event: { + action: ['action'], + }, + }, + dataFormattedForFieldBrowser: alertMockDataFormattedForFieldBrowser, + }, + }); + + expect(hookResult.result.current.shouldShowGraph).toBe(false); + expect(hookResult.result.current).toStrictEqual({ + shouldShowGraph: false, + hasGraphData: true, + timestamp: mockFieldData['@timestamp'][0], + eventIds: ['eventId'], + actorIds: ['userActorId'], + action: ['action'], + targetIds: ['entityTargetId'], + isAlert: true, + }); + }); + + it('should return false for shouldShowGraph when entity store is not running', () => { + mockUseEntityStoreStatus.mockReturnValue({ data: { status: 'not_installed' } }); + + const hookResult = renderHook((props: UseGraphPreviewParams) => useGraphPreview(props), { + initialProps: { + getFieldsData: alertMockGetFieldsData, + ecsData: { + _id: 'id', + event: { + action: ['action'], + }, + }, + dataFormattedForFieldBrowser: alertMockDataFormattedForFieldBrowser, + }, + }); + + expect(hookResult.result.current.shouldShowGraph).toBe(false); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/shared/hooks/use_graph_preview.ts b/x-pack/solutions/security/plugins/security_solution/public/flyout/shared/hooks/use_graph_preview.ts new file mode 100644 index 0000000000000..c1974423a0ffd --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/shared/hooks/use_graph_preview.ts @@ -0,0 +1,135 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { TimelineEventsDetailsItem } from '@kbn/timelines-plugin/common'; +import type { EcsSecurityExtension as Ecs } from '@kbn/securitysolution-ecs'; +import { get } from 'lodash/fp'; +import { + GRAPH_ACTOR_EUID_SOURCE_FIELDS, + GRAPH_TARGET_EUID_SOURCE_FIELDS, +} from '@kbn/cloud-security-posture-common'; +import type { GetFieldsData } from '../../document_details/shared/hooks/use_get_fields_data'; +import { getField, getFieldArray } from '../../document_details/shared/utils'; +import { useBasicDataFromDetailsData } from '../../document_details/shared/hooks/use_basic_data_from_details_data'; +import { useShouldShowGraph } from './use_should_show_graph'; + +export interface UseGraphPreviewParams { + /** + * Retrieves searchHit values for the provided field + */ + getFieldsData: GetFieldsData; + + /** + * An object with top level fields from the ECS object + */ + ecsData: Ecs; + + /** + * An array of field objects with category and value + */ + dataFormattedForFieldBrowser: TimelineEventsDetailsItem[]; +} + +/** + * Interface for the result of the useGraphPreview hook + */ +export interface UseGraphPreviewResult { + /** + * The timestamp of the event + */ + timestamp: string | null; + + /** + * Array of event IDs associated with the alert + */ + eventIds: string[]; + + /** + * Array of actor entity IDs associated with the alert + */ + actorIds: string[]; + + /** + * Array of target entity IDs associated with the alert + */ + targetIds: string[]; + + /** + * Action associated with the event + */ + action?: string[]; + + /** + * Boolean indicating if the event has all required data fields for graph visualization + */ + hasGraphData: boolean; + + /** + * Boolean indicating if graph visualization is fully available + * Combines: valid license + feature enabled in settings + */ + shouldShowGraph: boolean; + + /** + * Boolean indicating if the event is an alert or not + */ + isAlert: boolean; +} + +/** + * Hook that returns the graph view configuration if the graph view is available for the alert + */ +export const useGraphPreview = ({ + getFieldsData, + ecsData, + dataFormattedForFieldBrowser, +}: UseGraphPreviewParams): UseGraphPreviewResult => { + const timestamp = getField(getFieldsData('@timestamp')); + const originalEventId = getFieldsData('kibana.alert.original_event.id'); + const eventId = getFieldsData('event.id'); + const eventIds = originalEventId ? getFieldArray(originalEventId) : getFieldArray(eventId); + + // Get actor IDs from EUID source fields (raw ECS fields used to compute actor EUIDs) + const actorIds: string[] = []; + GRAPH_ACTOR_EUID_SOURCE_FIELDS.forEach((field) => { + const fieldValues = getFieldArray(getFieldsData(field)); + actorIds.push(...fieldValues); + }); + + // Get target IDs from EUID source fields (raw ECS fields used to compute target EUIDs) + const targetIds: string[] = []; + GRAPH_TARGET_EUID_SOURCE_FIELDS.forEach((field) => { + const fieldValues = getFieldArray(getFieldsData(field)); + targetIds.push(...fieldValues); + }); + + const action: string[] | undefined = get(['event', 'action'], ecsData); + + // Check if graph has all required data fields for graph visualization + const hasGraphRepresentation = + Boolean(timestamp) && + Boolean(action?.length) && + eventIds.length > 0 && + actorIds.length > 0 && + targetIds.length > 0; + + // Combine all conditions: data availability + license + const shouldShowGraph = useShouldShowGraph() && hasGraphRepresentation; + + const { isAlert } = useBasicDataFromDetailsData(dataFormattedForFieldBrowser); + + return { + timestamp, + eventIds, + actorIds, + action, + targetIds, + hasGraphData: hasGraphRepresentation, + shouldShowGraph, + isAlert, + }; +}; diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/shared/hooks/use_should_show_graph.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/flyout/shared/hooks/use_should_show_graph.test.tsx new file mode 100644 index 0000000000000..1f1cdd751b31c --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/shared/hooks/use_should_show_graph.test.tsx @@ -0,0 +1,49 @@ +/* + * 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 { renderHook } from '@testing-library/react'; +import { useHasGraphVisualizationLicense } from '../../../common/hooks/use_has_graph_visualization_license'; +import { useShouldShowGraph } from './use_should_show_graph'; + +jest.mock('../../../common/hooks/use_has_graph_visualization_license'); +const mockUseHasGraphVisualizationLicense = useHasGraphVisualizationLicense as jest.Mock; + +jest.mock('../../../entity_analytics/components/entity_store/hooks/use_entity_store'); +import { useEntityStoreStatus } from '../../../entity_analytics/components/entity_store/hooks/use_entity_store'; +const mockUseEntityStoreStatus = useEntityStoreStatus as jest.Mock; + +describe('useShouldShowGraph', () => { + beforeEach(() => { + jest.clearAllMocks(); + // Default mock: graph visualization feature is available + mockUseHasGraphVisualizationLicense.mockReturnValue(true); + // Default mock: entity store is running + mockUseEntityStoreStatus.mockReturnValue({ data: { status: 'running' } }); + }); + + it('should return true when user has required license and entity store is running', () => { + const hookResult = renderHook(() => useShouldShowGraph()); + + expect(hookResult.result.current).toBe(true); + }); + + it('should return false when all conditions are met but env does not have required license', () => { + mockUseHasGraphVisualizationLicense.mockReturnValue(false); + + const hookResult = renderHook(() => useShouldShowGraph()); + + expect(hookResult.result.current).toBe(false); + }); + + it('should return false for shouldShowGraph when entity store is not running', () => { + mockUseEntityStoreStatus.mockReturnValue({ data: { status: 'stopped' } }); + + const hookResult = renderHook(() => useShouldShowGraph()); + + expect(hookResult.result.current).toBe(false); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/shared/hooks/use_should_show_graph.ts b/x-pack/solutions/security/plugins/security_solution/public/flyout/shared/hooks/use_should_show_graph.ts new file mode 100644 index 0000000000000..868858c2fa80e --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/shared/hooks/use_should_show_graph.ts @@ -0,0 +1,23 @@ +/* + * 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 { useHasGraphVisualizationLicense } from '../../../common/hooks/use_has_graph_visualization_license'; +import { useEntityStoreStatus } from '../../../entity_analytics/components/entity_store/hooks/use_entity_store'; + +/** + * Hook to determine if the graph visualization should be shown in the alert, event or entity flyout. + */ +export const useShouldShowGraph = (): boolean => { + // Check if user license is high enough to access graph visualization + const hasRequiredLicense = useHasGraphVisualizationLicense(); + + // Check if entity store is running + const { data: entityStoreStatus } = useEntityStoreStatus(); + const isEntityStoreRunning = entityStoreStatus?.status === 'running'; + + return hasRequiredLicense && isEntityStoreRunning; +}; diff --git a/x-pack/solutions/security/test/cloud_security_posture_functional/constants/test_subject_ids.ts b/x-pack/solutions/security/test/cloud_security_posture_functional/constants/test_subject_ids.ts index 5ddaa364c4ace..62596af00e37e 100644 --- a/x-pack/solutions/security/test/cloud_security_posture_functional/constants/test_subject_ids.ts +++ b/x-pack/solutions/security/test/cloud_security_posture_functional/constants/test_subject_ids.ts @@ -45,6 +45,9 @@ export const testSubjectIds = { GRAPH_IPS_POPOVER_IP_ID: 'cloudSecurityGraphGraphInvestigationIpsPopoverId', PREVIEW_SECTION_BANNER_PANEL: 'previewSectionBannerPanel', GENERIC_ENTITY_PANEL_HEADER_TEST_ID: 'generic-panel-header', + HOST_PANEL_HEADER_TEST_ID: 'host-panel-header', + USER_PANEL_HEADER_TEST_ID: 'user-panel-header', + SERVICE_PANEL_HEADER_TEST_ID: 'service-panel-header', GROUPED_ITEM_TEST_ID: 'GraphGroupedNodePreviewPanelGroupedItem', GRAPH_CALLOUT_TEST_ID: 'cloudSecurityGraphGraphInvestigationCallout', GRAPH_NODE_ENTITY_DETAILS_ID: 'cloudSecurityGraphGraphInvestigationEntityNodeDetails', diff --git a/x-pack/solutions/security/test/cloud_security_posture_functional/group5/pages/entity_preview_flyout.ts b/x-pack/solutions/security/test/cloud_security_posture_functional/group5/pages/entity_preview_flyout.ts index 753229063e9e9..1041d49eae569 100644 --- a/x-pack/solutions/security/test/cloud_security_posture_functional/group5/pages/entity_preview_flyout.ts +++ b/x-pack/solutions/security/test/cloud_security_posture_functional/group5/pages/entity_preview_flyout.ts @@ -29,13 +29,13 @@ export default function ({ getPageObjects, getService }: SecurityTelemetryFtrPro 'header', 'networkEvents', 'expandedFlyoutGraph', - 'genericEntityFlyout', + 'entityFlyout', ]); const networkEventsPage = pageObjects.networkEvents; const expandedFlyoutGraph = pageObjects.expandedFlyoutGraph; - const genericEntityFlyout = pageObjects.genericEntityFlyout; + const entityFlyout = pageObjects.entityFlyout; - describe('Security Network Page - Generic Entity Preview flyout', function () { + describe('Security Network Page - Entity Preview flyout', function () { this.tags(['cloud_security_posture_graph_viz']); before(async () => { @@ -98,35 +98,6 @@ export default function ({ getPageObjects, getService }: SecurityTelemetryFtrPro ); }); - // Shared test suite that registers all test cases - called from both v1 and v2 describe blocks - const runEnrichmentTests = () => { - it('expanded flyout - show generic entity details', async () => { - // Setting the timerange to fit the data and open the flyout for a specific event - await networkEventsPage.navigateToNetworkEventsPage( - `${networkEventsPage.getAbsoluteTimerangeFilter( - '2024-09-01T00:00:00.000Z', - '2024-09-02T00:00:00.000Z' - )}&${networkEventsPage.getFlyoutFilter('MultiTargetEventDoc789')}` - ); - await networkEventsPage.waitForListToHaveEvents(); - - await networkEventsPage.flyout.expandVisualizations(); - await networkEventsPage.flyout.assertGraphPreviewVisible(); - await networkEventsPage.flyout.assertGraphNodesNumber(3); - - await expandedFlyoutGraph.expandGraph(); - await expandedFlyoutGraph.waitGraphIsLoaded(); - await expandedFlyoutGraph.assertGraphNodesNumber(3); - - // Click on the entity node to show entity details - await expandedFlyoutGraph.showEntityDetails('service:ApiServiceAccount'); - - // Verify the generic entity preview panel is open - await genericEntityFlyout.assertGenericEntityPanelIsOpen(); - await genericEntityFlyout.assertGenericEntityPanelHeader('ApiServiceAccount'); - }); - }; - describe('via LOOKUP JOIN (v2)', () => { before(async () => { // Delete v2 manually since its not being deleted by the cleanupEntityStore function @@ -160,7 +131,112 @@ export default function ({ getPageObjects, getService }: SecurityTelemetryFtrPro ); }); - runEnrichmentTests(); + it('expanded flyout - show generic entity details', async () => { + // Setting the timerange to fit the data and open the flyout for a specific event + await networkEventsPage.navigateToNetworkEventsPage( + `${networkEventsPage.getAbsoluteTimerangeFilter( + '2024-09-01T00:00:00.000Z', + '2024-09-02T00:00:00.000Z' + )}&${networkEventsPage.getFlyoutFilter('MvExpandBugTest123')}` + ); + await networkEventsPage.waitForListToHaveEvents(); + + await networkEventsPage.flyout.expandVisualizations(); + await networkEventsPage.flyout.assertGraphPreviewVisible(); + await networkEventsPage.flyout.assertGraphNodesNumber(4); + + await expandedFlyoutGraph.expandGraph(); + await expandedFlyoutGraph.waitGraphIsLoaded(); + await expandedFlyoutGraph.assertGraphNodesNumber(4); + + // Click on the entity node to show entity details + await expandedFlyoutGraph.showEntityDetails('mv-expand-target-storage'); + + // Verify entity preview panel is open + await entityFlyout.assertEntityPanelIsOpen('generic'); + await entityFlyout.assertEntityPanelHeader('generic', 'MvExpandTargetStorage'); + }); + + it('expanded flyout - show user entity details', async () => { + // Setting the timerange to fit the data and open the flyout for a specific event + await networkEventsPage.navigateToNetworkEventsPage( + `${networkEventsPage.getAbsoluteTimerangeFilter( + '2024-09-01T00:00:00.000Z', + '2024-09-02T00:00:00.000Z' + )}&${networkEventsPage.getFlyoutFilter('MvExpandBugTest123')}` + ); + await networkEventsPage.waitForListToHaveEvents(); + + await networkEventsPage.flyout.expandVisualizations(); + await networkEventsPage.flyout.assertGraphPreviewVisible(); + await networkEventsPage.flyout.assertGraphNodesNumber(4); + + await expandedFlyoutGraph.expandGraph(); + await expandedFlyoutGraph.waitGraphIsLoaded(); + await expandedFlyoutGraph.assertGraphNodesNumber(4); + + // Click on the entity node to show entity details + await expandedFlyoutGraph.showEntityDetails('user:mv-expand-test-actor@example.com@gcp'); + + // Verify entity preview panel is open + await entityFlyout.assertEntityPanelIsOpen('user'); + await entityFlyout.assertEntityPanelHeader('user', 'MvExpandTestActor'); + }); + + it('expanded flyout - show service entity details', async () => { + // Setting the timerange to fit the data and open the flyout for a specific event + await networkEventsPage.navigateToNetworkEventsPage( + `${networkEventsPage.getAbsoluteTimerangeFilter( + '2024-09-01T00:00:00.000Z', + '2024-09-02T00:00:00.000Z' + )}&${networkEventsPage.getFlyoutFilter('MultiTargetEventDoc789')}` + ); + await networkEventsPage.waitForListToHaveEvents(); + + await networkEventsPage.flyout.expandVisualizations(); + await networkEventsPage.flyout.assertGraphPreviewVisible(); + await networkEventsPage.flyout.assertGraphNodesNumber(3); + + await expandedFlyoutGraph.expandGraph(); + await expandedFlyoutGraph.waitGraphIsLoaded(); + await expandedFlyoutGraph.assertGraphNodesNumber(3); + + // Click on the entity node to show entity details + await expandedFlyoutGraph.showEntityDetails('service:ApiServiceAccount'); + + // Verify entity preview panel is open + await entityFlyout.assertEntityPanelIsOpen('service'); + await entityFlyout.assertEntityPanelHeader('service', 'ApiServiceAccount'); + }); + + it('expanded flyout - grouped entities - show host entity details', async () => { + // Setting the timerange to fit the data and open the flyout for a specific event + await networkEventsPage.navigateToNetworkEventsPage( + `${networkEventsPage.getAbsoluteTimerangeFilter( + '2024-09-01T00:00:00.000Z', + '2024-09-02T00:00:00.000Z' + )}&${networkEventsPage.getFlyoutFilter('MultiTargetEventDoc789')}` + ); + await networkEventsPage.waitForListToHaveEvents(); + + await networkEventsPage.flyout.expandVisualizations(); + await networkEventsPage.flyout.assertGraphPreviewVisible(); + await networkEventsPage.flyout.assertGraphNodesNumber(3); + + await expandedFlyoutGraph.expandGraph(); + await expandedFlyoutGraph.waitGraphIsLoaded(); + await expandedFlyoutGraph.assertGraphNodesNumber(3); + + // Click on the entity node to show grouped entities + await expandedFlyoutGraph.showEntityDetails('9da97a47da11862817d60dcc1cfbaaef'); + + // Verify grouped entities preview panel is open + await entityFlyout.clickOnEntity('host:host-instance-1'); + + // Verify entity preview panel is open + await entityFlyout.assertEntityPanelIsOpen('host'); + await entityFlyout.assertEntityPanelHeader('host', 'host-instance-1'); + }); }); }); } diff --git a/x-pack/solutions/security/test/cloud_security_posture_functional/page_objects/entity_flyout.ts b/x-pack/solutions/security/test/cloud_security_posture_functional/page_objects/entity_flyout.ts new file mode 100644 index 0000000000000..a6a6a9f35c19e --- /dev/null +++ b/x-pack/solutions/security/test/cloud_security_posture_functional/page_objects/entity_flyout.ts @@ -0,0 +1,84 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import { FtrService } from '@kbn/test-suites-xpack-platform/functional/ftr_provider_context'; +import { testSubjectIds } from '../constants/test_subject_ids'; + +const { + GENERIC_ENTITY_PANEL_HEADER_TEST_ID, + HOST_PANEL_HEADER_TEST_ID, + USER_PANEL_HEADER_TEST_ID, + SERVICE_PANEL_HEADER_TEST_ID, + GROUPED_ITEM_TITLE_TEST_ID_LINK, +} = testSubjectIds; + +export class EntityFlyoutPageObject extends FtrService { + private readonly testSubjects = this.ctx.getService('testSubjects'); + + async assertEntityPanelIsOpen( + entityType: 'generic' | 'host' | 'user' | 'service' + ): Promise { + let testId; + switch (entityType) { + case 'generic': + testId = GENERIC_ENTITY_PANEL_HEADER_TEST_ID; + break; + case 'host': + testId = HOST_PANEL_HEADER_TEST_ID; + break; + case 'user': + testId = USER_PANEL_HEADER_TEST_ID; + break; + case 'service': + testId = SERVICE_PANEL_HEADER_TEST_ID; + break; + } + await this.testSubjects.existOrFail(testId, { timeout: 10000 }); + } + + async assertEntityPanelHeader( + entityType: 'generic' | 'host' | 'user' | 'service', + expectedName?: string + ): Promise { + let testId; + switch (entityType) { + case 'generic': + testId = GENERIC_ENTITY_PANEL_HEADER_TEST_ID; + break; + case 'host': + testId = HOST_PANEL_HEADER_TEST_ID; + break; + case 'user': + testId = USER_PANEL_HEADER_TEST_ID; + break; + case 'service': + testId = SERVICE_PANEL_HEADER_TEST_ID; + break; + } + await this.testSubjects.existOrFail(testId, { timeout: 10000 }); + + if (expectedName) { + const headerText = await this.testSubjects.getVisibleText(testId); + expect(headerText).to.contain(expectedName); + } + } + + async clickOnEntity(entityName: string): Promise { + const entities = await this.testSubjects.findAll(GROUPED_ITEM_TITLE_TEST_ID_LINK); + + for (const entityElement of entities) { + const text = await entityElement.getVisibleText(); + if (text === entityName) { + await entityElement.click(); + return; + } + } + + throw new Error(`Entity "${entityName}" not found`); + } +} diff --git a/x-pack/solutions/security/test/cloud_security_posture_functional/page_objects/generic_entity_flyout.ts b/x-pack/solutions/security/test/cloud_security_posture_functional/page_objects/generic_entity_flyout.ts deleted file mode 100644 index abce02c78be18..0000000000000 --- a/x-pack/solutions/security/test/cloud_security_posture_functional/page_objects/generic_entity_flyout.ts +++ /dev/null @@ -1,27 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import expect from '@kbn/expect'; -import { FtrService } from '@kbn/test-suites-xpack-platform/functional/ftr_provider_context'; -import { testSubjectIds } from '../constants/test_subject_ids'; - -const { GENERIC_ENTITY_PANEL_HEADER_TEST_ID } = testSubjectIds; -export class GenericEntityFlyoutPageObject extends FtrService { - private readonly testSubjects = this.ctx.getService('testSubjects'); - - async assertGenericEntityPanelIsOpen(): Promise { - await this.testSubjects.existOrFail(GENERIC_ENTITY_PANEL_HEADER_TEST_ID, { timeout: 10000 }); - } - - async assertGenericEntityPanelHeader(expectedName?: string): Promise { - await this.testSubjects.existOrFail(GENERIC_ENTITY_PANEL_HEADER_TEST_ID, { timeout: 10000 }); - if (expectedName) { - const headerText = await this.testSubjects.getVisibleText('generic-panel-header'); - expect(headerText).to.contain(expectedName); - } - } -} diff --git a/x-pack/solutions/security/test/cloud_security_posture_functional/page_objects/index.ts b/x-pack/solutions/security/test/cloud_security_posture_functional/page_objects/index.ts index 492eacb250be1..123e55d2bb7b8 100644 --- a/x-pack/solutions/security/test/cloud_security_posture_functional/page_objects/index.ts +++ b/x-pack/solutions/security/test/cloud_security_posture_functional/page_objects/index.ts @@ -17,13 +17,13 @@ import { AlertsPageObject } from './alerts_page'; import { NetworkEventsPageObject } from './network_events_page'; import { ExpandedFlyoutGraph } from './expanded_flyout_graph'; import { TimelinePageObject } from './timeline_page'; -import { GenericEntityFlyoutPageObject } from './generic_entity_flyout'; +import { EntityFlyoutPageObject } from './entity_flyout'; export const cloudSecurityPosturePageObjects = { alerts: AlertsPageObject, networkEvents: NetworkEventsPageObject, expandedFlyoutGraph: ExpandedFlyoutGraph, - genericEntityFlyout: GenericEntityFlyoutPageObject, + entityFlyout: EntityFlyoutPageObject, timeline: TimelinePageObject, findings: FindingsPageProvider, cloudPostureDashboard: CspDashboardPageProvider, diff --git a/x-pack/solutions/security/test/serverless/functional/test_suites/ftr/cloud_security_posture/graph_alerts_flyout.ts b/x-pack/solutions/security/test/serverless/functional/test_suites/ftr/cloud_security_posture/graph_alerts_flyout.ts index 7e3409236d89a..56b1dab90a8af 100644 --- a/x-pack/solutions/security/test/serverless/functional/test_suites/ftr/cloud_security_posture/graph_alerts_flyout.ts +++ b/x-pack/solutions/security/test/serverless/functional/test_suites/ftr/cloud_security_posture/graph_alerts_flyout.ts @@ -87,6 +87,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { 'entity.name': { type: 'keyword' }, 'entity.type': { type: 'keyword' }, 'entity.sub_type': { type: 'keyword' }, + 'entity.EngineMetadata.Type': { type: 'keyword' }, 'host.ip': { type: 'ip' }, }, }, diff --git a/x-pack/solutions/security/test/serverless/functional/test_suites/ftr/cloud_security_posture/graph_events_flyout.ts b/x-pack/solutions/security/test/serverless/functional/test_suites/ftr/cloud_security_posture/graph_events_flyout.ts index a7494980a8b48..4d86d9d06106a 100644 --- a/x-pack/solutions/security/test/serverless/functional/test_suites/ftr/cloud_security_posture/graph_events_flyout.ts +++ b/x-pack/solutions/security/test/serverless/functional/test_suites/ftr/cloud_security_posture/graph_events_flyout.ts @@ -87,6 +87,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { 'entity.name': { type: 'keyword' }, 'entity.type': { type: 'keyword' }, 'entity.sub_type': { type: 'keyword' }, + 'entity.EngineMetadata.Type': { type: 'keyword' }, 'host.ip': { type: 'ip' }, }, },