diff --git a/x-pack/solutions/security/packages/kbn-cloud-security-posture/common/constants.ts b/x-pack/solutions/security/packages/kbn-cloud-security-posture/common/constants.ts index 6626377ca44bf..b931376faafba 100644 --- a/x-pack/solutions/security/packages/kbn-cloud-security-posture/common/constants.ts +++ b/x-pack/solutions/security/packages/kbn-cloud-security-posture/common/constants.ts @@ -4,6 +4,7 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ +import type { EntityStoreEuid } from '@kbn/entity-store/common/euid_helpers'; import type { VulnSeverity } from './types/vulnerabilities'; import type { MisconfigurationEvaluationStatus } from './types/misconfigurations'; @@ -166,36 +167,32 @@ export const GRAPH_TARGET_ENTITY_FIELDS = [ * These mirror the identity fields from Entity Store definitions. * Server-side code derives these dynamically via euid.getEuidSourceFields(). */ -export const GRAPH_ACTOR_EUID_SOURCE_FIELDS = [ - // user EUID source fields - 'user.email', - 'user.id', - 'user.name', - // host EUID source fields - 'host.id', - 'host.name', - 'host.hostname', - // service EUID source field - 'service.name', - // generic entity id - 'entity.id', -] as const; +export const getGraphActorEuidSourceFields = (euid: EntityStoreEuid) => { + return { + user: [...euid.getEuidSourceFields('user').identitySourceFields], + host: [...euid.getEuidSourceFields('host').identitySourceFields], + service: [...euid.getEuidSourceFields('service').identitySourceFields], + generic: [...euid.getEuidSourceFields('generic').identitySourceFields], + all: ['event.dataset', 'event.module', 'data_stream.dataset'], + }; +}; + +function toTargetField(field: string): string { + return field.replace('.', '.target.'); +} /** * Raw source fields used to compute target EUIDs in entity store v2. * Target-namespace equivalents of GRAPH_ACTOR_EUID_SOURCE_FIELDS. */ -export const GRAPH_TARGET_EUID_SOURCE_FIELDS = [ - // user target EUID source fields - 'user.target.email', - 'user.target.id', - 'user.target.name', - // host target EUID source fields - 'host.target.id', - 'host.target.name', - 'host.target.hostname', - // service target EUID source field - 'service.target.name', - // generic target entity id - 'entity.target.id', -] as const; +export const getGraphTargetEuidSourceFields = (euid: EntityStoreEuid) => { + return { + user: [...euid.getEuidSourceFields('user').identitySourceFields.map(toTargetField)], + host: [...euid.getEuidSourceFields('host').identitySourceFields.map(toTargetField)], + service: [...euid.getEuidSourceFields('service').identitySourceFields.map(toTargetField)], + generic: [...euid.getEuidSourceFields('generic').identitySourceFields.map(toTargetField)], + all: ['event.dataset', 'event.module', 'data_stream.dataset'], + }; +}; + +export type EuidSourceFields = ReturnType; 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 b8e867c9c7492..09f3a61c73786 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,19 +69,21 @@ 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()), + engine_type: schema.maybe( + schema.oneOf([ + schema.literal('host'), + schema.literal('user'), + schema.literal('service'), + schema.literal('generic'), + ]) + ), host: schema.maybe( schema.object({ ip: schema.maybe(schema.string()), }) ), availableInEntityStore: schema.maybe(schema.boolean()), - sourceFields: schema.maybe( - schema.recordOf( - schema.string(), - schema.oneOf([schema.string(), schema.arrayOf(schema.string())]) - ) - ), + sourceFields: schema.maybe(schema.object({}, { unknowns: 'allow' })), }); export const nodeDocumentDataSchema = schema.object({ diff --git a/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/filters/filter_store.test.ts b/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/filters/filter_store.test.ts index 58a4f0e70c211..e65047f3c068f 100644 --- a/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/filters/filter_store.test.ts +++ b/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/filters/filter_store.test.ts @@ -13,8 +13,10 @@ import { destroyFilterStore, emitFilterToggle, emitEntityRelationshipToggle, + emitPinnedEuidToggle, isFilterActiveForScope, isEntityRelationshipExpandedForScope, + isPinnedForScope, } from './filter_store'; // Simple phrase filter builder for tests @@ -187,17 +189,67 @@ describe('FilterStore', () => { }); }); + describe('togglePinnedEuid', () => { + it('should pin an entity with action "show"', () => { + const store = new FilterStore(uniqueScopeId()); + store.togglePinnedEuid('user:alice@okta', 'show'); + expect(store.isPinned('user:alice@okta')).toBe(true); + store.destroy(); + }); + + it('should unpin an entity with action "hide"', () => { + const store = new FilterStore(uniqueScopeId()); + store.togglePinnedEuid('user:alice@okta', 'show'); + store.togglePinnedEuid('user:alice@okta', 'hide'); + expect(store.isPinned('user:alice@okta')).toBe(false); + store.destroy(); + }); + + it('should handle multiple pinned entities independently', () => { + const store = new FilterStore(uniqueScopeId()); + store.togglePinnedEuid('user:alice@okta', 'show'); + store.togglePinnedEuid('host:server-1', 'show'); + + expect(store.isPinned('user:alice@okta')).toBe(true); + expect(store.isPinned('host:server-1')).toBe(true); + + store.togglePinnedEuid('user:alice@okta', 'hide'); + expect(store.isPinned('user:alice@okta')).toBe(false); + expect(store.isPinned('host:server-1')).toBe(true); + store.destroy(); + }); + }); + + describe('subscribeToPinnedEuids', () => { + it('should notify subscribers when pinned EUIDs change', () => { + const store = new FilterStore(uniqueScopeId()); + const callback = jest.fn(); + const subscription = store.subscribeToPinnedEuids(callback); + + // BehaviorSubject emits current value on subscribe + expect(callback).toHaveBeenCalledWith(new Set()); + + store.togglePinnedEuid('user:alice@okta', 'show'); + expect(callback).toHaveBeenCalledWith(new Set(['user:alice@okta'])); + + subscription.unsubscribe(); + store.destroy(); + }); + }); + describe('reset', () => { - it('should clear filters and expanded entity IDs', () => { + it('should clear filters, expanded entity IDs, and pinned EUIDs', () => { const store = new FilterStore(uniqueScopeId()); store.setDataViewId('data-view-1'); store.toggleFilter('user.name', 'alice', 'show'); store.toggleEntityRelationship('entity-1', 'show'); + store.togglePinnedEuid('user:alice@okta', 'show'); store.reset(); expect(store.getFilters()).toEqual([]); expect(store.getExpandedEntityIds().size).toBe(0); + expect(store.getPinnedEuids().size).toBe(0); store.destroy(); }); }); @@ -275,6 +327,38 @@ describe('event bus', () => { destroyFilterStore(idB); }); }); + + describe('emitPinnedEuidToggle', () => { + it('should deliver pinned EUID events to the matching store', () => { + const id = uniqueScopeId(); + const store = getOrCreateFilterStore(id); + + emitPinnedEuidToggle(id, 'user:alice@okta', 'show'); + + expect(store.isPinned('user:alice@okta')).toBe(true); + destroyFilterStore(id); + }); + + it('should not deliver pinned EUID events to a different scope', () => { + const idA = uniqueScopeId(); + const idB = uniqueScopeId(); + getOrCreateFilterStore(idA); + getOrCreateFilterStore(idB); + + emitPinnedEuidToggle(idA, 'user:alice@okta', 'show'); + + expect(isPinnedForScope(idA, 'user:alice@okta')).toBe(true); + expect(isPinnedForScope(idB, 'user:alice@okta')).toBe(false); + destroyFilterStore(idA); + destroyFilterStore(idB); + }); + + it('should not throw when no store exists for the scopeId', () => { + expect(() => { + emitPinnedEuidToggle('non-existent', 'user:alice@okta', 'show'); + }).not.toThrow(); + }); + }); }); describe('registry functions', () => { @@ -376,4 +460,26 @@ describe('scope helper functions', () => { expect(isEntityRelationshipExpandedForScope('non-existent', 'entity-1')).toBe(false); }); }); + + describe('isPinnedForScope', () => { + it('should return true when entity is pinned', () => { + const id = uniqueScopeId(); + const store = getOrCreateFilterStore(id); + store.togglePinnedEuid('user:alice@okta', 'show'); + + expect(isPinnedForScope(id, 'user:alice@okta')).toBe(true); + destroyFilterStore(id); + }); + + it('should return false when entity is not pinned', () => { + const id = uniqueScopeId(); + getOrCreateFilterStore(id); + expect(isPinnedForScope(id, 'user:alice@okta')).toBe(false); + destroyFilterStore(id); + }); + + it('should return false when store does not exist', () => { + expect(isPinnedForScope('non-existent', 'user:alice@okta')).toBe(false); + }); + }); }); 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 4279c1e52bfee..5066fbc2b0f5e 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 @@ -42,6 +42,24 @@ const filterToggleEvents$ = new Subject(); // Global event bus for entity relationship toggle actions const entityRelationshipEvents$ = new Subject(); +// ============================================================================= +// Pinned EUID Event Bus +// ============================================================================= + +/** + * Event emitted when a pinned EUID toggle action is requested. + * When an entity filter is toggled, the entity's EUID is also pinned/unpinned + * so the server can prioritize it in ES|QL query results. + */ +export interface PinnedEuidEvent { + scopeId: string; + entityId: string; + action: 'show' | 'hide'; +} + +// Global event bus for pinned EUID toggle actions +const pinnedEuidEvents$ = new Subject(); + /** * Emit a filter toggle event. Any FilterStore listening for this scopeId * will receive the event and update its filter state. @@ -88,6 +106,23 @@ export const emitEntityRelationshipToggle = ( entityRelationshipEvents$.next(event); }; +/** + * Emit a pinned EUID toggle event. Any FilterStore listening for this scopeId + * will receive the event and update its pinned EUIDs state. + * + * @param scopeId - Unique identifier for the graph instance + * @param entityId - The entity EUID to pin/unpin + * @param action - 'show' to pin, 'hide' to unpin + */ +export const emitPinnedEuidToggle = ( + scopeId: string, + entityId: string, + action: 'show' | 'hide' +): void => { + const event: PinnedEuidEvent = { scopeId, entityId, action }; + pinnedEuidEvents$.next(event); +}; + /** * Check if an entity's relationships are expanded for the given scope. * Returns false gracefully if no store exists. @@ -109,6 +144,15 @@ export const isInitialEntityForScope = (scopeId: string, entityId: string): bool return store?.isInitialEntity(entityId) ?? false; }; +/** + * Check if an entity EUID is pinned for the given scope. + * Returns false gracefully if no store exists. + */ +export const isPinnedForScope = (scopeId: string, entityId: string): boolean => { + const store = stores.get(scopeId); + return store?.isPinned(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). @@ -150,8 +194,10 @@ export class FilterStore { private initialEntityIds: Array<{ id: string; isOrigin: boolean }> = []; private readonly filters$ = new BehaviorSubject([]); private readonly expandedEntityIds$ = new BehaviorSubject>(new Set()); + private readonly pinnedEuids$ = new BehaviorSubject>(new Set()); private readonly filterEventSubscription: Subscription; private readonly entityRelationshipEventSubscription: Subscription; + private readonly pinnedEuidEventSubscription: Subscription; constructor(scopeId: string) { this.scopeId = scopeId; @@ -169,6 +215,13 @@ export class FilterStore { .subscribe((event) => { this.toggleEntityRelationship(event.entityId, event.action); }); + + // Subscribe to pinned EUID toggle events for this scopeId + this.pinnedEuidEventSubscription = pinnedEuidEvents$ + .pipe(rxFilter((event) => event.scopeId === this.scopeId)) + .subscribe((event) => { + this.togglePinnedEuid(event.entityId, event.action); + }); } /** @@ -280,12 +333,56 @@ export class FilterStore { return this.expandedEntityIds$.subscribe(callback); } + // =========================================================================== + // Pinned EUID State + // =========================================================================== + + /** + * Toggle an entity EUID's pinned state. + * Pinned EUIDs are sent to the server to prioritize matching events in query results. + * @param entityId - The entity EUID to pin/unpin + * @param action - 'show' to pin, 'hide' to unpin + */ + togglePinnedEuid(entityId: string, action: 'show' | 'hide'): void { + const next = new Set(this.pinnedEuids$.value); + if (action === 'show') { + next.add(entityId); + } else { + next.delete(entityId); + } + this.pinnedEuids$.next(next); + } + + /** + * Check if an entity EUID is currently pinned. + */ + isPinned(entityId: string): boolean { + return this.pinnedEuids$.value.has(entityId); + } + + /** + * Get the current set of pinned EUIDs. + */ + getPinnedEuids(): Set { + return this.pinnedEuids$.value; + } + + /** + * Subscribe to pinned EUID changes. + * @param callback - Function called when pinned EUIDs change + * @returns Subscription that should be unsubscribed on cleanup + */ + subscribeToPinnedEuids(callback: (pinnedEuids: Set) => void): Subscription { + return this.pinnedEuids$.subscribe(callback); + } + /** * Reset the filter store to empty state. */ reset(): void { this.filters$.next([]); this.expandedEntityIds$.next(new Set()); + this.pinnedEuids$.next(new Set()); } /** @@ -295,8 +392,10 @@ export class FilterStore { destroy(): void { this.filterEventSubscription.unsubscribe(); this.entityRelationshipEventSubscription.unsubscribe(); + this.pinnedEuidEventSubscription.unsubscribe(); this.filters$.complete(); this.expandedEntityIds$.complete(); + this.pinnedEuids$.complete(); } } 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 a8b9fe24291ee..064e4bbfb27d2 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 @@ -43,6 +43,7 @@ export const useGraphFilters = ( searchFilters: Filter[]; setSearchFilters: (filters: Filter[]) => void; entityIdsForApi: Array<{ id: string; isOrigin: boolean }> | undefined; + pinnedEuids: string[]; } => { // Get or create the FilterStore for this scopeId const store = useMemo(() => getOrCreateFilterStore(scopeId), [scopeId]); @@ -101,6 +102,26 @@ export const useGraphFilters = ( [store] ); + // Subscribe function for useSyncExternalStore (pinned EUIDs) + const subscribeToPinnedEuids = useCallback( + (onStoreChange: () => void) => { + const subscription = store.subscribeToPinnedEuids(onStoreChange); + return () => subscription.unsubscribe(); + }, + [store] + ); + + // Snapshot function for useSyncExternalStore (pinned EUIDs) + const getPinnedEuidsSnapshot = useCallback(() => store.getPinnedEuids(), [store]); + + const pinnedEuidsSet = useSyncExternalStore( + subscribeToPinnedEuids, + getPinnedEuidsSnapshot, + getPinnedEuidsSnapshot + ); + + const pinnedEuids = useMemo(() => Array.from(pinnedEuidsSet), [pinnedEuidsSet]); + // Convert expandedEntityIds Set to API format const entityIdsForApi = useMemo(() => { if (expandedEntityIds.size === 0) return initialEntityIds; @@ -119,5 +140,6 @@ export const useGraphFilters = ( searchFilters, setSearchFilters, entityIdsForApi, + pinnedEuids, }; }; 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 0c59260ba4b38..f4f3d4b5bd8e1 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 @@ -24,6 +24,7 @@ import { isFilterActiveForScope, emitEntityRelationshipToggle, isEntityRelationshipExpandedForScope, + emitPinnedEuidToggle, } from '../../../../filters/filter_store'; import { RELATED_ENTITY } from '../../../../../common/constants'; import { useOpenEntityPreviewPanel } from '../../../hooks/use_open_entity_preview_panel'; @@ -55,7 +56,7 @@ export const EntityActionsButton = ({ item, scopeId }: EntityActionsButtonProps) const togglePopover = useCallback(() => setIsPopoverOpen((prev) => !prev), []); const openEntityPreviewPanel = useOpenEntityPreviewPanel(); - const sourceFields = item.entity.sourceFields ?? {}; + const sourceFields = (item.entity.sourceFields ?? {}) as Record; const entityFilterActions: EntityFilterActions = { toggleEntityFilter: (role, action) => { @@ -65,6 +66,21 @@ export const EntityActionsButton = ({ item, scopeId }: EntityActionsButtonProps) emitFilterToggle(scopeId, fieldForRole(field, role), v, action); } } + if (action === 'show') { + emitPinnedEuidToggle(scopeId, item.id, 'show'); + } else { + // Only unpin when no entity filters remain active for either role + const hasRemainingFilters = (['actor', 'target'] as const).some((r) => + Object.entries(sourceFields).some(([field, value]) => + ([] as string[]) + .concat(value) + .some((v) => isFilterActiveForScope(scopeId, fieldForRole(field, r), v)) + ) + ); + if (!hasRemainingFilters) { + emitPinnedEuidToggle(scopeId, item.id, 'hide'); + } + } }, isEntityFilterActive: (role) => Object.entries(sourceFields).some(([field, value]) => @@ -97,6 +113,7 @@ export const EntityActionsButton = ({ item, scopeId }: EntityActionsButtonProps) return ( ( onOpenNetworkPreview, }: GraphInvestigationProps) => { const emptyEntityIds = useMemo(() => [], []); - const { searchFilters, setSearchFilters, entityIdsForApi } = useGraphFilters( + + const { searchFilters, setSearchFilters, entityIdsForApi, pinnedEuids } = useGraphFilters( scopeId, entityIds ?? emptyEntityIds, dataView?.id ?? '' @@ -320,15 +307,6 @@ export const GraphInvestigation = memo( return lastValidEsQuery.current; }, [dataView, kquery, notifications, searchFilters, uiSettings]); - const pinnedIds = useMemo(() => { - const filterValues = getFilterValues(searchFilters, [ - ...GRAPH_ACTOR_EUID_SOURCE_FIELDS, - ...GRAPH_TARGET_EUID_SOURCE_FIELDS, - RELATED_ENTITY, - ]).map(String); - return filterValues; - }, [searchFilters]); - const { data, refresh, isFetching, isError, error } = useFetchGraphData({ req: { query: { @@ -338,7 +316,7 @@ export const GraphInvestigation = memo( start: timeRange.from, end: timeRange.to, entityIds: entityIdsForApi, - pinnedIds, + pinnedIds: pinnedEuids, }, nodesLimit: GRAPH_NODES_LIMIT, }, @@ -488,7 +466,7 @@ export const GraphInvestigation = memo( const searchFilterCounter = useMemo(() => { const filtersCount = searchFilters - .filter((filter) => !filter.meta.disabled) + .filter((filter) => filter.meta && !filter.meta.disabled) .reduce((sum, filter) => { if (isCombinedFilter(filter)) { return sum + filter.meta.params.length; @@ -504,6 +482,7 @@ export const GraphInvestigation = memo( const searchWarningMessage = searchFilters.filter( (filter) => + filter.meta && !filter.meta.disabled && filter.meta.negate && filter.meta.controlledBy === CONTROLLED_BY_GRAPH_INVESTIGATION_FILTER diff --git a/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/popovers/node_expand/use_entity_node_expand_popover.test.tsx b/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/popovers/node_expand/use_entity_node_expand_popover.test.tsx index 610a0682c68b3..6705b69746fc9 100644 --- a/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/popovers/node_expand/use_entity_node_expand_popover.test.tsx +++ b/x-pack/solutions/security/packages/kbn-cloud-security-posture/graph/src/components/popovers/node_expand/use_entity_node_expand_popover.test.tsx @@ -25,6 +25,7 @@ import { isFilterActiveForScope, isEntityRelationshipExpandedForScope, emitEntityRelationshipToggle, + emitPinnedEuidToggle, } from '../../filters/filter_store'; // Mock filter_store module @@ -36,6 +37,7 @@ jest.mock('../../filters/filter_store', () => { isEntityRelationshipExpandedForScope: jest.fn(() => false), emitFilterToggle: jest.fn(), emitEntityRelationshipToggle: jest.fn(), + emitPinnedEuidToggle: jest.fn(), }; }); @@ -50,6 +52,9 @@ const mockEmitFilterToggle = emitFilterToggle as jest.MockedFunction; +const mockEmitPinnedEuidToggle = emitPinnedEuidToggle as jest.MockedFunction< + typeof emitPinnedEuidToggle +>; // Mock useNodeExpandGraphPopover to capture and expose itemsFn let capturedItemsFn: @@ -163,6 +168,7 @@ describe('useEntityNodeExpandPopover', () => { capturedItemsFn = null; mockEmitFilterToggle.mockClear(); mockEmitEntityRelationshipToggle.mockClear(); + mockEmitPinnedEuidToggle.mockClear(); mockIsFilterActiveForScope.mockReturnValue(false); mockIsEntityRelationshipExpandedForScope.mockReturnValue(false); }); @@ -432,6 +438,75 @@ describe('useEntityNodeExpandPopover', () => { ); }); + it('should emit pinned EUID toggle when "actions by" filter item is clicked', () => { + const node = createMockNode('single-entity'); + renderHook(() => useEntityNodeExpandPopover(scopeId)); + + expect(capturedItemsFn).not.toBeNull(); + const items = capturedItemsFn!(node); + + const actionsByItem = items.find( + (item) => + item.type === 'item' && item.testSubject === GRAPH_NODE_POPOVER_SHOW_ACTIONS_BY_ITEM_ID + ); + + if (actionsByItem?.type === 'item' && actionsByItem.onClick) { + actionsByItem.onClick(); + } + + expect(mockEmitPinnedEuidToggle).toHaveBeenCalledWith(scopeId, node.id, 'show'); + }); + + it('should emit pinned EUID hide when hiding the last entity filter', () => { + // Before toggle: return true so button says "Hide" + // After emitFilterToggle calls: return false (no remaining filters) + let afterToggle = false; + mockEmitFilterToggle.mockImplementation(() => { + afterToggle = true; + }); + mockIsFilterActiveForScope.mockImplementation(() => !afterToggle); + + const node = createMockNode('single-entity'); + renderHook(() => useEntityNodeExpandPopover(scopeId)); + + expect(capturedItemsFn).not.toBeNull(); + const items = capturedItemsFn!(node); + + const actionsByItem = items.find( + (item) => + item.type === 'item' && item.testSubject === GRAPH_NODE_POPOVER_SHOW_ACTIONS_BY_ITEM_ID + ); + + if (actionsByItem?.type === 'item' && actionsByItem.onClick) { + actionsByItem.onClick(); + } + + expect(mockEmitPinnedEuidToggle).toHaveBeenCalledWith(scopeId, node.id, 'hide'); + }); + + it('should not emit pinned EUID hide when other entity filters remain active', () => { + // All filter checks return true (both roles have active filters) + mockIsFilterActiveForScope.mockReturnValue(true); + + const node = createMockNode('single-entity'); + renderHook(() => useEntityNodeExpandPopover(scopeId)); + + expect(capturedItemsFn).not.toBeNull(); + const items = capturedItemsFn!(node); + + const actionsByItem = items.find( + (item) => + item.type === 'item' && item.testSubject === GRAPH_NODE_POPOVER_SHOW_ACTIONS_BY_ITEM_ID + ); + + if (actionsByItem?.type === 'item' && actionsByItem.onClick) { + actionsByItem.onClick(); + } + + // Should NOT unpin because other role filters are still active + expect(mockEmitPinnedEuidToggle).not.toHaveBeenCalledWith(scopeId, node.id, 'hide'); + }); + it('should call onOpenEventPreview callback when entity details item is clicked', () => { const node = createMockNode('single-entity'); 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 0d11dacd48c33..cdff776fd5de0 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 @@ -22,6 +22,7 @@ import { emitEntityRelationshipToggle, isEntityRelationshipExpandedForScope, isInitialEntityForScope, + emitPinnedEuidToggle, } from '../../filters/filter_store'; import { RELATED_ENTITY } from '../../../common/constants'; @@ -61,6 +62,21 @@ export const useEntityNodeExpandPopover = ( emitFilterToggle(scopeId, fieldForRole(field, role), v, action); } } + if (action === 'show') { + emitPinnedEuidToggle(scopeId, node.id, 'show'); + } else { + // Only unpin when no entity filters remain active for either role + const hasRemainingFilters = (['actor', 'target'] as const).some((r) => + Object.entries(sourceFields ?? {}).some(([field, value]) => + ([] as string[]) + .concat(value) + .some((v) => isFilterActiveForScope(scopeId, fieldForRole(field, r), v)) + ) + ); + if (!hasRemainingFilters) { + emitPinnedEuidToggle(scopeId, node.id, 'hide'); + } + } }, isEntityFilterActive: (role) => Object.entries(sourceFields ?? {}).some(([field, value]) => diff --git a/x-pack/solutions/security/plugins/cloud_security_posture/server/routes/graph/constants.ts b/x-pack/solutions/security/plugins/cloud_security_posture/server/routes/graph/constants.ts new file mode 100644 index 0000000000000..e54d7ddedc691 --- /dev/null +++ b/x-pack/solutions/security/plugins/cloud_security_posture/server/routes/graph/constants.ts @@ -0,0 +1,17 @@ +/* + * 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 { + getGraphActorEuidSourceFields, + getGraphTargetEuidSourceFields, +} from '@kbn/cloud-security-posture-common/constants'; + +export { type EuidSourceFields } from '@kbn/cloud-security-posture-common/constants'; +import { euid } from '@kbn/entity-store/common/euid_helpers'; + +export const GRAPH_ACTOR_EUID_SOURCE_FIELDS = getGraphActorEuidSourceFields(euid); +export const GRAPH_TARGET_EUID_SOURCE_FIELDS = getGraphTargetEuidSourceFields(euid); 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 59f03ff1dcd3d..fb50f685da80a 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,6 +9,7 @@ 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 { type EuidSourceFields, GRAPH_ACTOR_EUID_SOURCE_FIELDS } from './constants'; import { checkIfEntitiesIndexLookupMode, concatJsonObjectPropertyBool, @@ -66,8 +67,15 @@ const buildRelationshipsEsqlQuery = ({ }) .join('\n'); - // Store source entity fields before LOOKUP JOIN as they get overwritten by target entity fields - const enrichmentSection = `// Store source entity fields before lookup (they get overwritten by target entity fields) + return `SET unmapped_fields="nullify"; +FROM ${indexName} +| EVAL _source_source_fields = ${buildSourceFieldsJson(GRAPH_ACTOR_EUID_SOURCE_FIELDS)} +| EVAL + ${targetsEval} +| FORK +${forkBranches} +| WHERE _target_id != "" +// Store source entity fields before lookup (they get overwritten by target entity fields) | RENAME _source_id = entity.id | RENAME _source_name = entity.name | RENAME _source_type = entity.type @@ -77,6 +85,7 @@ const buildRelationshipsEsqlQuery = ({ // Lookup target entity metadata | EVAL entity.id = _target_id | LOOKUP JOIN ${indexName} ON entity.id +| EVAL _target_source_fields = ${buildSourceFieldsJson(GRAPH_ACTOR_EUID_SOURCE_FIELDS)} | RENAME _target_name = entity.name | RENAME _target_type = entity.type | RENAME _target_sub_type = entity.sub_type @@ -88,15 +97,7 @@ const buildRelationshipsEsqlQuery = ({ | RENAME entity.type = _source_type | RENAME entity.sub_type = _source_sub_type | RENAME host.ip = _source_host_ip -| RENAME entity.EngineMetadata.Type = _source_engine_metadata_type`; - - return `FROM ${indexName} -| EVAL - ${targetsEval} -| FORK -${forkBranches} -| WHERE _target_id != "" -${enrichmentSection} +| RENAME entity.EngineMetadata.Type = _source_engine_metadata_type // Build enriched actors doc data with entity metadata (from the queried entity) | EVAL actorDocData = CONCAT(${JSON_OBJECT_START}, ${concatJsonObjectPropertyEsqlExprSafe('id', 'entity.id')}, @@ -121,9 +122,7 @@ ${enrichmentSection} ${JSON_OBJECT_END}), "" ), - ${JSON_OBJECT_SEPARATOR}, "\\"sourceFields\\":", ${JSON_OBJECT_START}, - ${concatJsonObjectPropertyEsqlExprAsString('entity.id', 'entity.id')}, - ${JSON_OBJECT_END}, + ${JSON_OBJECT_SEPARATOR}, _source_source_fields, ${JSON_OBJECT_END}, ${JSON_OBJECT_END}) // Build enriched targets doc data with entity metadata @@ -131,33 +130,31 @@ ${enrichmentSection} ${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(${JSON_OBJECT_SEPARATOR}, "\\"host\\":", ${JSON_OBJECT_START}, - "\\"ip\\":\\"", TO_STRING(_target_host_ip), "\\"", - ${JSON_OBJECT_END}), - "" - ), - 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')}, + ${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(${JSON_OBJECT_SEPARATOR}, "\\"host\\":", ${JSON_OBJECT_START}, + "\\"ip\\":\\"", TO_STRING(_target_host_ip), "\\"", + ${JSON_OBJECT_END}), + "" + ), + CASE(_target_engine_metadata_type IS NOT NULL, CONCAT(${JSON_OBJECT_SEPARATOR}, + ${concatJsonObjectPropertyEsqlExprAsString( + 'engine_type', + '_target_engine_metadata_type' + )}), ""), + ${JSON_OBJECT_SEPARATOR}, _target_source_fields, ${JSON_OBJECT_END}, - ${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(*), @@ -310,10 +307,15 @@ export const fetchEntities = async ({ entityIds: EntityId[]; spaceId: string; }): Promise> => { + if (entityIds.length === 0) { + return { columns: [], records: [] }; + } + const indexName = getEntitiesLatestIndexName(spaceId); logger.trace(`Fetching entities from index [${indexName}] for ${entityIds.length} entities`); - const esqlQuery = `FROM ${indexName} + const esqlQuery = `SET unmapped_fields="nullify"; + FROM ${indexName} | WHERE entity.id IN (${entityIds.map((_, idx) => `?entityId${idx}`).join(',')}) | EVAL id = entity.id | EVAL name = entity.name @@ -342,9 +344,7 @@ export const fetchEntities = async ({ ${JSON_OBJECT_END}), "" ), - ${JSON_OBJECT_SEPARATOR}, "\\"sourceFields\\":", ${JSON_OBJECT_START}, - ${concatJsonObjectPropertyEsqlExprAsString('entity.id', 'entity.id')}, - ${JSON_OBJECT_END}, + ${JSON_OBJECT_SEPARATOR}, ${buildSourceFieldsJson(GRAPH_ACTOR_EUID_SOURCE_FIELDS)}, ${JSON_OBJECT_END}, ${JSON_OBJECT_END}) | KEEP id, name, type, sub_type, docData`; @@ -371,3 +371,41 @@ export const fetchEntities = async ({ throw error; } }; + +const TYPED_ENTITY_PREFIXES = ['user', 'host', 'service']; + +const buildSourceFieldsJson = (fields: EuidSourceFields): string => { + const properties = Object.keys(fields) + .map((type) => { + if (type === 'all') { + return fields.all.map( + (field) => + `CASE(${field} IS NOT NULL, ${concatJsonObjectPropertyEsqlExprSafe(field, field)}, "")` + ); + } else if (type === 'generic') { + // Generic entities don't have a type prefix in their EUID, + // so use a negative condition to match them + const notTypedCondition = TYPED_ENTITY_PREFIXES.map( + (p) => `NOT STARTS_WITH(entity.id, "${p}:")` + ).join(' AND '); + return fields[type as keyof EuidSourceFields].map( + (field) => `CASE(${notTypedCondition} AND ${field} IS NOT NULL, + ${concatJsonObjectPropertyEsqlExprSafe(field.replace('.target', ''), field)}, "")` + ); + } else { + const typeEuidFields = fields[type as keyof EuidSourceFields]; + return typeEuidFields.map( + (field) => `CASE(STARTS_WITH(entity.id, "${type}:") AND ${field} IS NOT NULL, + ${concatJsonObjectPropertyEsqlExprSafe(field.replace('.target', ''), field)}, "")` + ); + } + }) + .flat() + .join(`, ${JSON_OBJECT_SEPARATOR},\n `); + return ` + REPLACE( + REPLACE( + REPLACE(CONCAT("\\"sourceFields\\":", ${JSON_OBJECT_START}, ${properties}, ${JSON_OBJECT_END}), "[,]+", ","), + "\\\\{,", ${JSON_OBJECT_START}), + ",}", ${JSON_OBJECT_END})`; +}; diff --git a/x-pack/solutions/security/plugins/cloud_security_posture/server/routes/graph/fetch_events_graph.test.ts b/x-pack/solutions/security/plugins/cloud_security_posture/server/routes/graph/fetch_events_graph.test.ts index d83a448666cf4..1e0fb6f123748 100644 --- a/x-pack/solutions/security/plugins/cloud_security_posture/server/routes/graph/fetch_events_graph.test.ts +++ b/x-pack/solutions/security/plugins/cloud_security_posture/server/routes/graph/fetch_events_graph.test.ts @@ -10,10 +10,7 @@ import { fetchEvents } from './fetch_events_graph'; import type { Logger } from '@kbn/core/server'; import type { OriginEventId, EsQuery } from './types'; import { getEntitiesLatestIndexName } from '@kbn/cloud-security-posture-common/utils/helpers'; -import { - GRAPH_ACTOR_EUID_SOURCE_FIELDS, - GRAPH_TARGET_EUID_SOURCE_FIELDS, -} from '@kbn/cloud-security-posture-common/constants'; +import { GRAPH_ACTOR_EUID_SOURCE_FIELDS, GRAPH_TARGET_EUID_SOURCE_FIELDS } from './constants'; describe('fetchEvents', () => { const esClient = elasticsearchServiceMock.createScopedClusterClient(); @@ -287,7 +284,12 @@ describe('fetchEvents', () => { expect(query).toContain('_actor_service_euid'); // Verify EUID source fields are referenced in the query - for (const field of GRAPH_ACTOR_EUID_SOURCE_FIELDS) { + for (const field of [ + ...GRAPH_ACTOR_EUID_SOURCE_FIELDS.user, + ...GRAPH_ACTOR_EUID_SOURCE_FIELDS.host, + ...GRAPH_ACTOR_EUID_SOURCE_FIELDS.service, + ...GRAPH_ACTOR_EUID_SOURCE_FIELDS.generic, + ]) { expect(query).toContain(field); } @@ -398,12 +400,13 @@ describe('fetchEvents', () => { const esqlCallArgs = esClient.asCurrentUser.helpers.esql.mock.calls[0]; const query = esqlCallArgs[0].query; - // Verify target EUID source fields appear in the sourceFields JSON construction - expect(query).toContain('\\"user.target.email\\"'); - expect(query).toContain('\\"user.target.id\\"'); - expect(query).toContain('\\"host.target.id\\"'); - expect(query).toContain('\\"service.target.name\\"'); - expect(query).toContain('\\"entity.target.id\\"'); + // Verify target EUID source field values appear in the sourceFields JSON construction + // The JSON keys use actor-namespace names (e.g., "user.email") while the values + // reference saved target-namespace field variables (e.g., _sf_user_target_email) + expect(query).toContain('_sf_user_target_email'); + expect(query).toContain('_sf_user_target_id'); + expect(query).toContain('_sf_host_target_id'); + expect(query).toContain('_sf_service_target_name'); }); }); @@ -429,7 +432,13 @@ describe('fetchEvents', () => { const filterArg = esqlCallArgs[0].filter as any; // Should have bool.filter with target EUID source field exists checks - const expectedExistsChecks = GRAPH_TARGET_EUID_SOURCE_FIELDS.map((field) => ({ + const allTargetFields = [ + ...GRAPH_TARGET_EUID_SOURCE_FIELDS.user, + ...GRAPH_TARGET_EUID_SOURCE_FIELDS.host, + ...GRAPH_TARGET_EUID_SOURCE_FIELDS.service, + ...GRAPH_TARGET_EUID_SOURCE_FIELDS.generic, + ]; + const expectedExistsChecks = allTargetFields.map((field) => ({ exists: { field }, })); expect(filterArg.bool.filter).toEqual( @@ -446,7 +455,7 @@ describe('fetchEvents', () => { const targetFilter = filterArg.bool.filter.find((f: any) => f.bool?.should?.some((s: any) => s.exists?.field?.includes('target')) ); - expect(targetFilter?.bool?.should).toHaveLength(GRAPH_TARGET_EUID_SOURCE_FIELDS.length); + expect(targetFilter?.bool?.should).toHaveLength(allTargetFields.length); }); it('should not filter targets when showUnknownTarget is true', async () => { 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 5526fb42e754f..99de4e02a5206 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 @@ -15,10 +15,6 @@ import { DOCUMENT_TYPE_ENTITY, INDEX_PATTERN_REGEX, } from '@kbn/cloud-security-posture-common/schema/graph/v1'; -import { - GRAPH_ACTOR_EUID_SOURCE_FIELDS, - GRAPH_TARGET_EUID_SOURCE_FIELDS, -} from '@kbn/cloud-security-posture-common/constants'; import { ALL_ENTITY_TYPES } from '@kbn/entity-store/common'; import { getEuidEsqlEvaluation, @@ -35,6 +31,11 @@ import { concatJsonObjectPropertyEsqlExprAsString, concatJsonObjectPropertyString, } from './utils'; +import { + type EuidSourceFields, + GRAPH_ACTOR_EUID_SOURCE_FIELDS, + GRAPH_TARGET_EUID_SOURCE_FIELDS, +} from './constants'; import { getTargetEuidEsqlEvaluation } from './target_euid'; import { SECURITY_ALERTS_PARTIAL_IDENTIFIER } from '../../../common/constants'; import type { EsQuery, OriginEventId, EventEdge } from './types'; @@ -145,7 +146,14 @@ const buildDslFilter = ( : [ { bool: { - should: GRAPH_TARGET_EUID_SOURCE_FIELDS.map((field) => ({ exists: { field } })), + should: [ + ...GRAPH_TARGET_EUID_SOURCE_FIELDS.generic, + ...GRAPH_TARGET_EUID_SOURCE_FIELDS.host, + ...GRAPH_TARGET_EUID_SOURCE_FIELDS.user, + ...GRAPH_TARGET_EUID_SOURCE_FIELDS.service, + ].map((field) => ({ + exists: { field }, + })), minimum_should_match: 1, }, }, @@ -196,8 +204,14 @@ const buildV2ActorResolution = (): string => { // MV_EXPAND typed actor source fields so EUID CONCAT receives single values. // entity.id is excluded: its EUID is the raw value (no CONCAT), // and multi-value is handled by the downstream MV_EXPAND actorEntityId. - const typedActorFields = GRAPH_ACTOR_EUID_SOURCE_FIELDS.filter((f) => f !== 'entity.id'); - const mvExpandStatements = typedActorFields.map((field) => `| MV_EXPAND \`${field}\``).join('\n'); + const typedActorFields = Object.keys(GRAPH_ACTOR_EUID_SOURCE_FIELDS) + .filter((f) => TYPED_ENTITY_PREFIXES.includes(f)) + .map((f) => GRAPH_ACTOR_EUID_SOURCE_FIELDS[f as keyof EuidSourceFields]) + .flat(); + const mvExpandStatements = typedActorFields + .filter((f) => !f.startsWith('event.') && !f.startsWith('data_stream.')) + .map((field) => `| MV_EXPAND \`${field}\``) + .join('\n'); // Combine field evaluations (entity.namespace) and user EUID into a single EVAL // to prevent the ES|QL optimizer from pruning the intermediate entity.namespace column. @@ -240,8 +254,12 @@ const buildV2TargetResolution = (): string => { // MV_EXPAND typed target source fields so EUID CONCAT receives single values. // entity.target.id is excluded: its EUID is the raw value (no CONCAT), // and multi-value is handled by the downstream MV_EXPAND targetEntityId. - const typedTargetFields = GRAPH_TARGET_EUID_SOURCE_FIELDS.filter((f) => f !== 'entity.target.id'); + const typedTargetFields = Object.keys(GRAPH_TARGET_EUID_SOURCE_FIELDS) + .filter((f) => TYPED_ENTITY_PREFIXES.includes(f)) + .map((f) => GRAPH_TARGET_EUID_SOURCE_FIELDS[f as keyof EuidSourceFields]) + .flat(); const mvExpandStatements = typedTargetFields + .filter((f) => !f.startsWith('event.') && !f.startsWith('data_stream.')) .map((field) => `| MV_EXPAND \`${field}\``) .join('\n'); @@ -284,20 +302,10 @@ const buildPinnedEsql = (pinnedIds?: string[]): string => { const pinnedParamsStr = pinnedIds.map((_id, idx) => `?pinned_id${idx}`).join(', '); - const actorRawChecks = GRAPH_ACTOR_EUID_SOURCE_FIELDS.map( - (f) => `${ENTITY_FIELD_COLUMN_MAP[f] ?? f} IN (${pinnedParamsStr})` - ).join(' OR '); - - const targetRawChecks = GRAPH_TARGET_EUID_SOURCE_FIELDS.map( - (f) => `${ENTITY_FIELD_COLUMN_MAP[f] ?? f} IN (${pinnedParamsStr})` - ).join(' OR '); - return `| EVAL pinned = CASE( _id IN (${pinnedParamsStr}), _id, actorEntityId IN (${pinnedParamsStr}), actorEntityId, - ${actorRawChecks}, actorEntityId, targetEntityId IN (${pinnedParamsStr}), targetEntityId, - ${targetRawChecks}, targetEntityId, null )`; }; @@ -308,10 +316,20 @@ const buildPinnedEsql = (pinnedIds?: string[]): string => { * with entity store values. The saved variables are used by buildSourceFieldsJson() * to build the sourceFields JSON from the original log event values. */ -const ENTITY_FIELD_COLUMN_MAP: Record = Object.fromEntries([ - ...GRAPH_ACTOR_EUID_SOURCE_FIELDS.map((f) => [f, `_sf_${f.replace(/\./g, '_')}`]), - ...GRAPH_TARGET_EUID_SOURCE_FIELDS.map((f) => [f, `_sf_${f.replace(/\./g, '_')}`]), -]); +const ENTITY_FIELD_COLUMN_MAP: Record = Object.fromEntries( + [ + ...GRAPH_ACTOR_EUID_SOURCE_FIELDS.all, + ...GRAPH_ACTOR_EUID_SOURCE_FIELDS.generic, + ...GRAPH_ACTOR_EUID_SOURCE_FIELDS.host, + ...GRAPH_ACTOR_EUID_SOURCE_FIELDS.user, + ...GRAPH_ACTOR_EUID_SOURCE_FIELDS.service, + ...GRAPH_TARGET_EUID_SOURCE_FIELDS.all, + ...GRAPH_TARGET_EUID_SOURCE_FIELDS.generic, + ...GRAPH_TARGET_EUID_SOURCE_FIELDS.host, + ...GRAPH_TARGET_EUID_SOURCE_FIELDS.user, + ...GRAPH_TARGET_EUID_SOURCE_FIELDS.service, + ].map((f) => [f, `_sf_${f.replace(/\./g, '_')}`]) +); /** * Generates an EVAL statement that saves all EUID source fields. @@ -329,18 +347,7 @@ const buildSaveSourceFieldsEsql = (): string => { return `| EVAL ${assignments}`; }; -/** - * Maps a source field name to its entity type prefix for EUID matching. - * Returns null for generic fields (entity.id, entity.target.id). - */ -const getFieldEntityTypePrefix = (field: string): string | null => { - if (field.startsWith('user')) return 'user'; - if (field.startsWith('host')) return 'host'; - if (field.startsWith('service')) return 'service'; - return null; // generic entity -}; - -const TYPED_ENTITY_PREFIXES = ['user', 'host', 'service'] as const; +const TYPED_ENTITY_PREFIXES = ['user', 'host', 'service']; /** * Generates an ESQL CONCAT fragment that builds a JSON "sourceFields" object. @@ -353,26 +360,33 @@ const TYPED_ENTITY_PREFIXES = ['user', 'host', 'service'] as const; * entity.id value post MV_EXPAND). * Uses REPLACE to fix null properties. */ -const buildSourceFieldsJson = (fields: readonly string[], euidColumn: string): string => { - const properties = fields - .map((field) => { - const typePrefix = getFieldEntityTypePrefix(field); - - 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}:"), - ${concatJsonObjectPropertyEsqlExprSafe(field, `TO_STRING(${column})`)}, "")`; +const buildSourceFieldsJson = (fields: EuidSourceFields, euidColumn: string): string => { + const properties = Object.keys(fields) + .map((type) => { + if (type === 'all') { + return fields[type as keyof EuidSourceFields].map((field) => { + const column = ENTITY_FIELD_COLUMN_MAP[field] ?? `\`${field}\``; + return concatJsonObjectPropertyEsqlExprSafe(field.replace('.target', ''), column); + }); + } else if (type === 'generic') { + // Generic field: include when EUID doesn't match any typed prefix + // Use the EUID column directly as the value (it IS the raw entity.id post MV_EXPAND) + const notTypedCondition = TYPED_ENTITY_PREFIXES.map( + (p) => `NOT STARTS_WITH(${euidColumn}, "${p}:")` + ).join(' AND '); + return fields[type as keyof EuidSourceFields].map((field) => { + return `CASE(${notTypedCondition}, + ${concatJsonObjectPropertyEsqlExprSafe(field.replace('.target', ''), euidColumn)}, "")`; + }); + } else { + return fields[type as keyof EuidSourceFields].map((field) => { + const column = ENTITY_FIELD_COLUMN_MAP[field] ?? `\`${field}\``; + return `CASE(STARTS_WITH(${euidColumn}, "${type}:"), + ${concatJsonObjectPropertyEsqlExprSafe(field.replace('.target', ''), column)}, "")`; + }); } - - // Generic field: include when EUID doesn't match any typed prefix - // Use the EUID column directly as the value (it IS the raw entity.id post MV_EXPAND) - const notTypedCondition = TYPED_ENTITY_PREFIXES.map( - (p) => `NOT STARTS_WITH(${euidColumn}, "${p}:")` - ).join(' AND '); - return `CASE(${notTypedCondition}, - ${concatJsonObjectPropertyEsqlExprSafe(field, `TO_STRING(${euidColumn})`)}, "")`; }) + .flat() .join(`, ${JSON_OBJECT_SEPARATOR},\n `); return ` REPLACE( 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 4b6d341e8f0ba..b872c7a2c0a6e 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 @@ -7,7 +7,7 @@ import { createHash } from 'crypto'; import type { Logger } from '@kbn/core/server'; -import { castArray } from 'lodash'; +import { castArray, omit } from 'lodash'; import { v4 as uuidv4 } from 'uuid'; import { ApiMessageCode } from '@kbn/cloud-security-posture-common/types/graph/latest'; import type { @@ -24,6 +24,7 @@ import type { } from '@kbn/cloud-security-posture-common/types/graph/v1'; import type { Writable } from '@kbn/utility-types'; import { ENTITY_RELATIONSHIP_LABELS } from '@kbn/cloud-security-posture-common/constants'; +import { GRAPH_ACTOR_EUID_SOURCE_FIELDS } from './constants'; import { type EventEdge, type RelationshipEdge, @@ -33,46 +34,6 @@ import { } from './types'; import { transformEntityTypeToIconAndShape, compareConnectorNodes } from './utils'; -/** - * Deduplicates entity documentsData by entity ID and merges sourceFields. - * MV_EXPAND in the ES|QL query can create Cartesian products when multiple source fields - * are multi-value, producing duplicate documents for the same entity with different - * sourceField combinations. This function merges those into a single document per entity, - * collecting all unique values into arrays where values differ. - */ -const deduplicateEntityDocuments = (docs: NodeDocumentDataModel[]): NodeDocumentDataModel[] => { - const byId = new Map(); - - for (const doc of docs) { - const existing = byId.get(doc.id); - if (!existing) { - byId.set(doc.id, doc); - continue; - } - - // Merge sourceFields: collect unique values per field - const existingSf = existing.entity?.sourceFields; - const docSf = doc.entity?.sourceFields; - if (existingSf && docSf) { - for (const [key, value] of Object.entries(docSf)) { - const existingVal = existingSf[key]; - if (existingVal === undefined) { - existingSf[key] = value; - } else if (existingVal !== value) { - const arr = Array.isArray(existingVal) ? existingVal : [existingVal]; - const valuesToAdd = Array.isArray(value) ? value : [value]; - const newValues = valuesToAdd.filter((v) => !arr.includes(v)); - if (newValues.length > 0) { - existingSf[key] = [...arr, ...newValues]; - } - } - } - } - } - - return Array.from(byId.values()); -}; - interface ConnectorEdges { source: string; target: string; @@ -253,27 +214,31 @@ const createEntityNode = ( logger?: Logger ): void => { const { nodeId, idsCount, entityType, entitySubType, entityName, docData, hostIps } = params; + const EXPAND_DOT_NOTATION = false; if (nodesMap[nodeId] !== undefined) return; const resolvedType = resolveEntityType(entityType, idsCount); const label = generateEntityLabel(idsCount, nodeId, resolvedType, entityName, entitySubType); - const documentsData: NodeDocumentDataModel[] = deduplicateEntityDocuments( - docData - ? castArray(docData) - .filter((d): d is string => d != null) - .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; - } - }) - : [] - ); + const documentsData: NodeDocumentDataModel[] | undefined = docData + ? parseDocumentsData(logger, docData) + : undefined; + + documentsData?.forEach((doc) => { + if (doc.entity?.sourceFields) { + const currentlySupportedSourceFields = omit( + doc.entity.sourceFields, + GRAPH_ACTOR_EUID_SOURCE_FIELDS.all + ); + (doc as Writable).entity = { + ...doc.entity, + sourceFields: EXPAND_DOT_NOTATION + ? expandDotNotation(currentlySupportedSourceFields) + : currentlySupportedSourceFields, + }; + } + }); nodesMap[nodeId] = { id: nodeId, @@ -293,7 +258,7 @@ const createGroupedActorAndTargetNodes = ( actorId: string; targetId: string; } => { - const { nodesMap } = context; + const { nodesMap, logger } = context; const { actorNodeId, actorIdsCount, @@ -323,7 +288,7 @@ const createGroupedActorAndTargetNodes = ( docData: actorsDocData, hostIps: actorHostIps ? castArray(actorHostIps) : [], }, - context.logger + logger ); // Create target entity node (or unknown target) @@ -341,7 +306,7 @@ const createGroupedActorAndTargetNodes = ( docData: targetsDocData, hostIps: targetHostIps ? castArray(targetHostIps) : [], }, - context.logger + logger ); } else if (nodesMap[targetId] === undefined) { // Unknown target @@ -360,7 +325,7 @@ const createGroupedActorAndTargetNodes = ( }; }; -const createLabelNode = (record: EventEdge): LabelNodeDataModel => { +const createLabelNode = (logger: Logger | undefined, record: EventEdge): LabelNodeDataModel => { const { labelNodeId, action, @@ -390,7 +355,7 @@ const createLabelNode = (record: EventEdge): LabelNodeDataModel => { label: action, color, shape: 'label', - documentsData: parseDocumentsData(docs), + documentsData: parseDocumentsData(logger, docs), count: badge, ...(uniqueEventsCount > 0 ? { uniqueEventsCount } : {}), ...(uniqueAlertsCount > 0 ? { uniqueAlertsCount } : {}), @@ -447,7 +412,7 @@ const emitAPINodesLimitMessage = (context: ParseContext) => { const processEventRecord = (record: EventEdge, context: ParseContext) => { const { actorId, targetId } = createGroupedActorAndTargetNodes(record, context); - const labelNode = createLabelNode(record); + const labelNode = createLabelNode(context.logger, record); processConnectorNode(context, { sourceId: actorId, @@ -737,10 +702,45 @@ const connectNodes = ( }; }; -const parseDocumentsData = (docs: string[] | string): NodeDocumentDataModel[] => { +const parseDocumentsData = ( + logger: Logger | undefined, + docs: Array | string +): NodeDocumentDataModel[] => { if (typeof docs === 'string') { - return [JSON.parse(docs)]; + try { + return [JSON.parse(docs)]; + } catch (e) { + logger?.error(`Failed to parse document data: ${e}`); + logger?.trace(docs); + throw e; + } } - return docs.map((doc) => JSON.parse(doc)); + return docs + .filter((d): d is string => d != null) + .map((doc) => { + try { + return JSON.parse(doc); + } catch (e) { + logger?.error(`Failed to parse document data: ${e}`); + logger?.trace(doc); + throw e; + } + }); +}; + +const expandDotNotation = (flat: Record): Record => { + const result: Record = {}; + for (const [dotKey, value] of Object.entries(flat)) { + const parts = dotKey.split('.'); + let cursor = result; + for (let i = 0; i < parts.length - 1; i++) { + if (cursor[parts[i]] == null || typeof cursor[parts[i]] !== 'object') { + cursor[parts[i]] = {}; + } + cursor = cursor[parts[i]] as Record; + } + cursor[parts[parts.length - 1]] = value; + } + return result; }; diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/shared/hooks/use_graph_preview.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/shared/hooks/use_graph_preview.test.tsx index eb0218341c0d4..04267a80e3a6a 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/shared/hooks/use_graph_preview.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/shared/hooks/use_graph_preview.test.tsx @@ -14,9 +14,13 @@ import { mockFieldData } from '../mocks/mock_get_fields_data'; import { mockDataFormattedForFieldBrowser } from '../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, + getGraphActorEuidSourceFields, + getGraphTargetEuidSourceFields, } from '@kbn/cloud-security-posture-common/constants'; +import { useEntityStoreEuidApi } from '@kbn/entity-store/public'; + +jest.mock('@kbn/entity-store/public'); +const mockUseEntityStoreEuidApi = useEntityStoreEuidApi as jest.Mock; jest.mock('../../../../common/hooks/use_has_graph_visualization_license'); const mockUseHasGraphVisualizationLicense = useHasGraphVisualizationLicense as jest.Mock; @@ -25,10 +29,36 @@ jest.mock('../../../shared/hooks/use_should_show_graph'); import { useShouldShowGraph } from '../../../shared/hooks/use_should_show_graph'; const mockUseShouldShowGraph = useShouldShowGraph as jest.Mock; +// Mock EUID that returns known identity fields per entity type +const mockEuid = { + getEuidSourceFields: (entityType: string) => { + const fieldsMap: Record = { + user: ['user.email', 'user.id', 'user.name'], + host: ['host.id', 'host.name', 'host.hostname'], + service: ['service.name'], + generic: ['entity.id'], + }; + return { identitySourceFields: fieldsMap[entityType] ?? [] }; + }, +}; + +const mockActorFields = getGraphActorEuidSourceFields( + mockEuid as unknown as Parameters[0] +); +const mockTargetFields = getGraphTargetEuidSourceFields( + mockEuid as unknown as Parameters[0] +); + // 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, + ...mockActorFields.user, + ...mockActorFields.host, + ...mockActorFields.service, + ...mockActorFields.generic, + ...mockTargetFields.user, + ...mockTargetFields.host, + ...mockTargetFields.service, + ...mockTargetFields.generic, ]; // Mock uses EUID source fields (user.id as actor, entity.target.id as target) @@ -77,6 +107,8 @@ const eventMockDataFormattedForFieldBrowser: TimelineEventsDetailsItem[] = []; describe('useGraphPreview', () => { beforeEach(() => { jest.clearAllMocks(); + // Default mock: euid API returns mock euid + mockUseEntityStoreEuidApi.mockReturnValue({ euid: mockEuid }); // Default mock: graph visualization feature is available mockUseHasGraphVisualizationLicense.mockReturnValue(true); // Default mock: graph should be shown (license + entity store available) 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 ee9143bef3814..779b7dbd4ff3f 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 @@ -5,13 +5,16 @@ * 2.0. */ +import { useMemo } from 'react'; 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, + type EuidSourceFields, + getGraphActorEuidSourceFields, + getGraphTargetEuidSourceFields, } from '@kbn/cloud-security-posture-common'; +import { useEntityStoreEuidApi } from '@kbn/entity-store/public'; import type { GetFieldsData } from './use_get_fields_data'; import { getField, getFieldArray } from '../utils'; import { useBasicDataFromDetailsData } from './use_basic_data_from_details_data'; @@ -88,6 +91,26 @@ export const useGraphPreview = ({ ecsData, dataFormattedForFieldBrowser, }: UseGraphPreviewParams): UseGraphPreviewResult => { + const euidApi = useEntityStoreEuidApi(); + const euid = euidApi?.euid; + const EMPTY_EUID_SOURCE_FIELDS: EuidSourceFields = useMemo( + () => ({ + user: [], + host: [], + service: [], + generic: [], + all: [], + }), + [] + ); + const GRAPH_ACTOR_EUID_SOURCE_FIELDS = useMemo( + () => (euid ? getGraphActorEuidSourceFields(euid) : EMPTY_EUID_SOURCE_FIELDS), + [euid, EMPTY_EUID_SOURCE_FIELDS] + ); + const GRAPH_TARGET_EUID_SOURCE_FIELDS = useMemo( + () => (euid ? getGraphTargetEuidSourceFields(euid) : EMPTY_EUID_SOURCE_FIELDS), + [euid, EMPTY_EUID_SOURCE_FIELDS] + ); const timestamp = getField(getFieldsData('@timestamp')); const originalEventId = getFieldsData('kibana.alert.original_event.id'); const eventId = getFieldsData('event.id'); @@ -95,14 +118,24 @@ export const useGraphPreview = ({ // 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) => { + [ + ...GRAPH_ACTOR_EUID_SOURCE_FIELDS.user, + ...GRAPH_ACTOR_EUID_SOURCE_FIELDS.host, + ...GRAPH_ACTOR_EUID_SOURCE_FIELDS.service, + ...GRAPH_ACTOR_EUID_SOURCE_FIELDS.generic, + ].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) => { + [ + ...GRAPH_TARGET_EUID_SOURCE_FIELDS.user, + ...GRAPH_TARGET_EUID_SOURCE_FIELDS.host, + ...GRAPH_TARGET_EUID_SOURCE_FIELDS.service, + ...GRAPH_TARGET_EUID_SOURCE_FIELDS.generic, + ].forEach((field) => { const fieldValues = getFieldArray(getFieldsData(field)); targetIds.push(...fieldValues); }); 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 index adc47e973c409..cd10a0c8d2005 100644 --- 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 @@ -14,9 +14,13 @@ import { mockFieldData } from '../../document_details/shared/mocks/mock_get_fiel 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, + getGraphActorEuidSourceFields, + getGraphTargetEuidSourceFields, } from '@kbn/cloud-security-posture-common'; +import { useEntityStoreEuidApi } from '@kbn/entity-store/public'; + +jest.mock('@kbn/entity-store/public'); +const mockUseEntityStoreEuidApi = useEntityStoreEuidApi as jest.Mock; jest.mock('../../../common/hooks/use_has_graph_visualization_license'); const mockUseHasGraphVisualizationLicense = useHasGraphVisualizationLicense as jest.Mock; @@ -25,10 +29,36 @@ jest.mock('./use_should_show_graph'); import { useShouldShowGraph } from './use_should_show_graph'; const mockUseShouldShowGraph = useShouldShowGraph as jest.Mock; +// Mock EUID that returns known identity fields per entity type +const mockEuid = { + getEuidSourceFields: (entityType: string) => { + const fieldsMap: Record = { + user: ['user.email', 'user.id', 'user.name'], + host: ['host.id', 'host.name', 'host.hostname'], + service: ['service.name'], + generic: ['entity.id'], + }; + return { identitySourceFields: fieldsMap[entityType] ?? [] }; + }, +}; + +const mockActorFields = getGraphActorEuidSourceFields( + mockEuid as unknown as Parameters[0] +); +const mockTargetFields = getGraphTargetEuidSourceFields( + mockEuid as unknown as Parameters[0] +); + // 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, + ...mockActorFields.user, + ...mockActorFields.host, + ...mockActorFields.service, + ...mockActorFields.generic, + ...mockTargetFields.user, + ...mockTargetFields.host, + ...mockTargetFields.service, + ...mockTargetFields.generic, ]; // Mock uses EUID source fields (user.id as actor, entity.target.id as target) @@ -77,6 +107,8 @@ const eventMockDataFormattedForFieldBrowser: TimelineEventsDetailsItem[] = []; describe('useGraphPreview', () => { beforeEach(() => { jest.clearAllMocks(); + // Default mock: euid API returns mock euid + mockUseEntityStoreEuidApi.mockReturnValue({ euid: mockEuid }); // Default mock: graph visualization feature is available mockUseHasGraphVisualizationLicense.mockReturnValue(true); // Default mock: graph should be shown (license + entity store available) 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 index c1974423a0ffd..7bc38e9f8c596 100644 --- 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 @@ -5,13 +5,16 @@ * 2.0. */ +import { useMemo } from 'react'; 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, + type EuidSourceFields, + getGraphActorEuidSourceFields, + getGraphTargetEuidSourceFields, } from '@kbn/cloud-security-posture-common'; +import { useEntityStoreEuidApi } from '@kbn/entity-store/public'; 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'; @@ -88,6 +91,26 @@ export const useGraphPreview = ({ ecsData, dataFormattedForFieldBrowser, }: UseGraphPreviewParams): UseGraphPreviewResult => { + const euidApi = useEntityStoreEuidApi(); + const euid = euidApi?.euid; + const EMPTY_EUID_SOURCE_FIELDS: EuidSourceFields = useMemo( + () => ({ + user: [], + host: [], + service: [], + generic: [], + all: [], + }), + [] + ); + const GRAPH_ACTOR_EUID_SOURCE_FIELDS = useMemo( + () => (euid ? getGraphActorEuidSourceFields(euid) : EMPTY_EUID_SOURCE_FIELDS), + [euid, EMPTY_EUID_SOURCE_FIELDS] + ); + const GRAPH_TARGET_EUID_SOURCE_FIELDS = useMemo( + () => (euid ? getGraphTargetEuidSourceFields(euid) : EMPTY_EUID_SOURCE_FIELDS), + [euid, EMPTY_EUID_SOURCE_FIELDS] + ); const timestamp = getField(getFieldsData('@timestamp')); const originalEventId = getFieldsData('kibana.alert.original_event.id'); const eventId = getFieldsData('event.id'); @@ -95,14 +118,24 @@ export const useGraphPreview = ({ // 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) => { + [ + ...GRAPH_ACTOR_EUID_SOURCE_FIELDS.user, + ...GRAPH_ACTOR_EUID_SOURCE_FIELDS.host, + ...GRAPH_ACTOR_EUID_SOURCE_FIELDS.service, + ...GRAPH_ACTOR_EUID_SOURCE_FIELDS.generic, + ].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) => { + [ + ...GRAPH_TARGET_EUID_SOURCE_FIELDS.user, + ...GRAPH_TARGET_EUID_SOURCE_FIELDS.host, + ...GRAPH_TARGET_EUID_SOURCE_FIELDS.service, + ...GRAPH_TARGET_EUID_SOURCE_FIELDS.generic, + ].forEach((field) => { const fieldValues = getFieldArray(getFieldsData(field)); targetIds.push(...fieldValues); }); diff --git a/x-pack/solutions/security/test/cloud_security_posture_api/routes/graph.ts b/x-pack/solutions/security/test/cloud_security_posture_api/routes/graph.ts index c7a637dccb12f..10412072fa556 100644 --- a/x-pack/solutions/security/test/cloud_security_posture_api/routes/graph.ts +++ b/x-pack/solutions/security/test/cloud_security_posture_api/routes/graph.ts @@ -767,7 +767,7 @@ export default function (providerContext: FtrProviderContext) { entity: expectExpect.objectContaining({ availableInEntityStore: false, sourceFields: expectExpect.objectContaining({ - 'entity.target.id': 'projects/your-project-id/roles/customRole', + 'entity.id': 'projects/your-project-id/roles/customRole', }), }), }) @@ -1116,7 +1116,7 @@ export default function (providerContext: FtrProviderContext) { entity: expectExpect.objectContaining({ availableInEntityStore: false, sourceFields: expectExpect.objectContaining({ - 'entity.target.id': 'target-mv-1', + 'entity.id': 'target-mv-1', }), }), }) @@ -1128,7 +1128,7 @@ export default function (providerContext: FtrProviderContext) { entity: expectExpect.objectContaining({ availableInEntityStore: false, sourceFields: expectExpect.objectContaining({ - 'entity.target.id': 'target-mv-2', + 'entity.id': 'target-mv-2', }), }), }) @@ -1140,7 +1140,7 @@ export default function (providerContext: FtrProviderContext) { entity: expectExpect.objectContaining({ availableInEntityStore: false, sourceFields: expectExpect.objectContaining({ - 'entity.target.id': 'target-mv-3', + 'entity.id': 'target-mv-3', }), }), }) @@ -1784,7 +1784,7 @@ export default function (providerContext: FtrProviderContext) { entity: expectExpect.objectContaining({ availableInEntityStore: false, sourceFields: expectExpect.objectContaining({ - 'entity.target.id': 'projects/your-project-id/roles/customRole', + 'entity.id': 'projects/your-project-id/roles/customRole', }), }), }) @@ -1930,6 +1930,7 @@ export default function (providerContext: FtrProviderContext) { type: 'Service', sub_type: 'GCP Service Account', availableInEntityStore: true, + engine_type: 'service', sourceFields: expectExpect.objectContaining({ 'service.name': 'ServiceAccount123', }), @@ -1959,8 +1960,9 @@ export default function (providerContext: FtrProviderContext) { type: 'Container', sub_type: 'GCP Compute Instance', availableInEntityStore: true, + engine_type: 'host', sourceFields: expectExpect.objectContaining({ - 'host.target.id': 'host-instance-1', + 'host.id': 'host-instance-1', }), }), }) @@ -1974,8 +1976,9 @@ export default function (providerContext: FtrProviderContext) { type: 'Container', sub_type: 'GCP Compute Instance', availableInEntityStore: true, + engine_type: 'host', sourceFields: expectExpect.objectContaining({ - 'host.target.id': 'host-instance-2', + 'host.id': 'host-instance-2', }), }), }) @@ -2021,6 +2024,7 @@ export default function (providerContext: FtrProviderContext) { type: 'Identity', sub_type: 'GCP IAM User', availableInEntityStore: true, + engine_type: 'user', sourceFields: expectExpect.objectContaining({ 'user.id': 'entity-user@example.com', }), @@ -2046,8 +2050,9 @@ export default function (providerContext: FtrProviderContext) { type: 'Compute', sub_type: 'GCP Compute Instance', availableInEntityStore: true, + engine_type: 'generic', sourceFields: expectExpect.objectContaining({ - 'entity.target.id': 'entity-service-target-1', + 'entity.id': 'entity-service-target-1', }), }), }) @@ -2127,6 +2132,7 @@ export default function (providerContext: FtrProviderContext) { type: 'Identity', sub_type: 'GCP IAM User', availableInEntityStore: true, + engine_type: 'user', sourceFields: expectExpect.objectContaining({ 'user.id': 'multi-target-user@example.com', }), @@ -2155,9 +2161,9 @@ export default function (providerContext: FtrProviderContext) { type: 'Storage', sub_type: 'GCP Storage Bucket', availableInEntityStore: true, + engine_type: 'generic', sourceFields: expectExpect.objectContaining({ - 'entity.target.id': - 'projects/multi-target-project-id/buckets/target-bucket-a', + 'entity.id': 'projects/multi-target-project-id/buckets/target-bucket-a', }), }), }) @@ -2171,9 +2177,9 @@ export default function (providerContext: FtrProviderContext) { type: 'Storage', sub_type: 'GCP Storage Bucket', availableInEntityStore: true, + engine_type: 'generic', sourceFields: expectExpect.objectContaining({ - 'entity.target.id': - 'projects/multi-target-project-id/buckets/target-bucket-b', + 'entity.id': 'projects/multi-target-project-id/buckets/target-bucket-b', }), }), }) @@ -2187,9 +2193,9 @@ export default function (providerContext: FtrProviderContext) { type: 'Storage', sub_type: 'GCP Storage Bucket', availableInEntityStore: true, + engine_type: 'generic', sourceFields: expectExpect.objectContaining({ - 'entity.target.id': - 'projects/multi-target-project-id/buckets/target-bucket-c', + 'entity.id': 'projects/multi-target-project-id/buckets/target-bucket-c', }), }), }) @@ -2215,8 +2221,9 @@ export default function (providerContext: FtrProviderContext) { type: 'Service', sub_type: 'GCP Service Account', availableInEntityStore: true, + engine_type: 'service', sourceFields: expectExpect.objectContaining({ - 'service.target.name': 'TargetServiceDifferent', + 'service.name': 'TargetServiceDifferent', }), }), }) @@ -2287,6 +2294,7 @@ export default function (providerContext: FtrProviderContext) { entity: expectExpect.objectContaining({ name: 'PartialUserNameOnly', availableInEntityStore: true, + engine_type: 'user', sourceFields: expectExpect.objectContaining({ 'user.id': 'partial-user@example.com', }), @@ -2315,8 +2323,9 @@ export default function (providerContext: FtrProviderContext) { type: 'Container', sub_type: 'GCP Compute Instance', availableInEntityStore: true, + engine_type: 'host', sourceFields: expectExpect.objectContaining({ - 'host.target.id': 'partial-host-instance-1', + 'host.id': 'partial-host-instance-1', }), }), }) @@ -2445,6 +2454,7 @@ export default function (providerContext: FtrProviderContext) { type: 'entity', entity: expectExpect.objectContaining({ availableInEntityStore: true, + engine_type: 'user', name: 'Relationships Test User', type: 'Identity', sub_type: 'AWS IAM User', @@ -2473,8 +2483,9 @@ export default function (providerContext: FtrProviderContext) { type: 'Service', sub_type: 'AWS Lambda', availableInEntityStore: true, + engine_type: 'service', sourceFields: expectExpect.objectContaining({ - 'service.target.name': 'Relationships Target Service', + 'service.name': 'Relationships Target Service', }), }), }) @@ -2499,6 +2510,7 @@ export default function (providerContext: FtrProviderContext) { type: 'Host', sub_type: 'AWS EC2 Instance', availableInEntityStore: true, + engine_type: 'host', }), }) ); @@ -2573,6 +2585,7 @@ export default function (providerContext: FtrProviderContext) { type: 'entity', entity: expectExpect.objectContaining({ availableInEntityStore: true, + engine_type: 'user', name: 'Relationships Test User', type: 'Identity', sub_type: 'AWS IAM User', @@ -2599,6 +2612,7 @@ export default function (providerContext: FtrProviderContext) { type: 'Host', sub_type: 'AWS EC2 Instance', availableInEntityStore: true, + engine_type: 'host', }), }) ); @@ -2742,6 +2756,7 @@ export default function (providerContext: FtrProviderContext) { type: 'entity', entity: expectExpect.objectContaining({ availableInEntityStore: true, + engine_type: 'user', name: 'GCP Admin User', type: 'Service Account', sub_type: 'GCP Service Account', @@ -2770,6 +2785,7 @@ export default function (providerContext: FtrProviderContext) { type: 'entity', entity: expectExpect.objectContaining({ availableInEntityStore: true, + engine_type: 'user', name: 'GCP Compute Operator', type: 'Identity', sub_type: 'GCP IAM User', @@ -2798,11 +2814,12 @@ export default function (providerContext: FtrProviderContext) { type: 'entity', entity: expectExpect.objectContaining({ availableInEntityStore: true, + engine_type: 'user', name: 'data-pipeline Service Account', type: 'Service Account', sub_type: 'GCP Service Account', sourceFields: expectExpect.objectContaining({ - 'user.target.id': 'data-pipeline@my-gcp-project.iam.gserviceaccount.com', + 'user.id': 'data-pipeline@my-gcp-project.iam.gserviceaccount.com', }), }), }) @@ -2827,11 +2844,12 @@ export default function (providerContext: FtrProviderContext) { type: 'entity', entity: expectExpect.objectContaining({ availableInEntityStore: true, + engine_type: 'host', name: 'database-server-prod-1', type: 'Host', sub_type: 'GCP Compute Instance', sourceFields: expectExpect.objectContaining({ - 'host.target.id': + 'host.id': 'projects/my-gcp-project/zones/us-west1-a/instances/database-server-prod-1', }), }), @@ -2854,6 +2872,7 @@ export default function (providerContext: FtrProviderContext) { type: 'entity', entity: expectExpect.objectContaining({ availableInEntityStore: true, + engine_type: 'host', name: 'web-server-prod-1', type: 'Host', sub_type: 'GCP Compute Instance', @@ -2866,6 +2885,7 @@ export default function (providerContext: FtrProviderContext) { type: 'entity', entity: expectExpect.objectContaining({ availableInEntityStore: true, + engine_type: 'host', name: 'api-gateway-prod-1', type: 'Host', sub_type: 'GCP Compute Instance', @@ -3013,6 +3033,7 @@ export default function (providerContext: FtrProviderContext) { type: 'Identity', sub_type: 'AWS IAM User', availableInEntityStore: true, + engine_type: 'user', sourceFields: expectExpect.objectContaining({ 'user.id': 'rel-hierarchy-root-user', }), @@ -3128,8 +3149,9 @@ export default function (providerContext: FtrProviderContext) { type: 'User', sub_type: 'AWS Organizations Admin', availableInEntityStore: true, + engine_type: 'user', sourceFields: expectExpect.objectContaining({ - 'user.target.id': 'rel-hierarchy-delegate-1', + 'user.id': 'rel-hierarchy-delegate-1', }), }), }) @@ -3155,6 +3177,7 @@ export default function (providerContext: FtrProviderContext) { type: 'Service', sub_type: 'AWS Lambda Function', availableInEntityStore: true, + engine_type: 'service', }), }) ); diff --git a/x-pack/solutions/security/test/cloud_security_posture_functional/group5/pages/alerts_flyout.ts b/x-pack/solutions/security/test/cloud_security_posture_functional/group5/pages/alerts_flyout.ts index c34250213bd72..f5b659cb7dbe7 100644 --- a/x-pack/solutions/security/test/cloud_security_posture_functional/group5/pages/alerts_flyout.ts +++ b/x-pack/solutions/security/test/cloud_security_posture_functional/group5/pages/alerts_flyout.ts @@ -335,11 +335,11 @@ export default function ({ getPageObjects, getService }: SecurityTelemetryFtrPro await expandedFlyoutGraph.clickOnFitGraphIntoViewControl(); await expandedFlyoutGraph.expectFilterTextEquals( 0, - 'user.email: serviceaccount@example.com OR user.id: serviceaccount@example.com OR user.name: Service Account' + 'user.name: Service Account OR user.email: serviceaccount@example.com OR user.id: serviceaccount@example.com' ); await expandedFlyoutGraph.expectFilterPreviewEquals( 0, - 'user.email: serviceaccount@example.com OR user.id: serviceaccount@example.com OR user.name: Service Account' + 'user.name: Service Account OR user.email: serviceaccount@example.com OR user.id: serviceaccount@example.com' ); await expandedFlyoutGraph.showEntityDetails('d45b28b33930cc202a6c9d8d8eab3ae6');