diff --git a/x-pack/solutions/security/plugins/security_solution/public/cloud_security_posture/components/entity_insight.tsx b/x-pack/solutions/security/plugins/security_solution/public/cloud_security_posture/components/entity_insight.tsx index 0e8b3433e84cd..59c15754ecf2a 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/cloud_security_posture/components/entity_insight.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/cloud_security_posture/components/entity_insight.tsx @@ -57,15 +57,21 @@ export const EntityInsight = ({ const insightContent: React.ReactElement[] = []; const cspPreviewEntityType = inferEntityTypeFromIdentityFields(identityFields); - const { hasMisconfigurationFindings: showMisconfigurationsPreview } = useHasMisconfigurations( - buildEuidCspPreviewOptions(cspPreviewEntityType, identityFields, euidApi, { + const { + hasMisconfigurationFindings: showMisconfigurationsPreview, + passedFindings, + failedFindings, + } = useHasMisconfigurations( + buildEuidCspPreviewOptions(cspPreviewEntityType, entityRecord, euidApi, { entityStoreV2Enabled, + legacyIdentityFields: identityFields, }) ); const { hasVulnerabilitiesFindings } = useHasVulnerabilities( - buildEuidCspPreviewOptions(cspPreviewEntityType, identityFields, euidApi, { + buildEuidCspPreviewOptions(cspPreviewEntityType, entityRecord, euidApi, { entityStoreV2Enabled, + legacyIdentityFields: identityFields, }) ); @@ -99,8 +105,9 @@ export const EntityInsight = ({ insightContent.push( <> @@ -111,6 +118,7 @@ export const EntityInsight = ({ <> diff --git a/x-pack/solutions/security/plugins/security_solution/public/cloud_security_posture/components/misconfiguration/misconfiguration_preview.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/cloud_security_posture/components/misconfiguration/misconfiguration_preview.test.tsx index 7d3eb84f94e8e..3d7d438aceb9f 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/cloud_security_posture/components/misconfiguration/misconfiguration_preview.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/cloud_security_posture/components/misconfiguration/misconfiguration_preview.test.tsx @@ -8,33 +8,19 @@ import React from 'react'; import { render } from '@testing-library/react'; import { MisconfigurationsPreview } from './misconfiguration_preview'; -import { useMisconfigurationPreview } from '@kbn/cloud-security-posture/src/hooks/use_misconfiguration_preview'; -import { useVulnerabilitiesPreview } from '@kbn/cloud-security-posture/src/hooks/use_vulnerabilities_preview'; import { TestProviders } from '../../../common/mock/test_providers'; -// Mock hooks -jest.mock('@kbn/cloud-security-posture/src/hooks/use_misconfiguration_preview'); -jest.mock('@kbn/cloud-security-posture/src/hooks/use_vulnerabilities_preview'); - describe('MisconfigurationsPreview', () => { - const mockOpenLeftPanel = jest.fn(); - - beforeEach(() => { - (useVulnerabilitiesPreview as jest.Mock).mockReturnValue({ - data: { count: { CRITICAL: 0, HIGH: 1, MEDIUM: 1, LOW: 0, UNKNOWN: 0 } }, - }); - (useMisconfigurationPreview as jest.Mock).mockReturnValue({ - data: { count: { passed: 1, failed: 1 } }, - }); - }); + const mockOpenDetailsPanel = jest.fn(); it('renders', () => { const { getByTestId } = render( ); diff --git a/x-pack/solutions/security/plugins/security_solution/public/cloud_security_posture/components/misconfiguration/misconfiguration_preview.tsx b/x-pack/solutions/security/plugins/security_solution/public/cloud_security_posture/components/misconfiguration/misconfiguration_preview.tsx index 228f7731c75e8..6dc85736899b2 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/cloud_security_posture/components/misconfiguration/misconfiguration_preview.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/cloud_security_posture/components/misconfiguration/misconfiguration_preview.tsx @@ -10,7 +10,6 @@ import { css } from '@emotion/react'; import { EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiText, EuiTitle, useEuiTheme } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; import { DistributionBar } from '@kbn/security-solution-distribution-bar'; -import { useHasMisconfigurations } from '@kbn/cloud-security-posture/src/hooks/use_has_misconfigurations'; import { i18n } from '@kbn/i18n'; import { useGetMisconfigurationStatusColor } from '@kbn/cloud-security-posture'; import { MISCONFIGURATION_STATUS } from '@kbn/cloud-security-posture-common'; @@ -19,19 +18,12 @@ import { ENTITY_FLYOUT_WITH_MISCONFIGURATION_VISIT, uiMetricService, } from '@kbn/cloud-security-posture-common/utils/ui_metrics'; -import { FF_ENABLE_ENTITY_STORE_V2, useEntityStoreEuidApi } from '@kbn/entity-store/public'; -import { - buildEuidCspPreviewOptions, - inferEntityTypeFromIdentityFields, -} from '../../utils/build_euid_csp_preview_options'; import { ExpandablePanel } from '../../../flyout_v2/shared/components/expandable_panel'; import type { EntityDetailsPath } from '../../../flyout/entity_details/shared/components/left_panel/left_panel_header'; import { CspInsightLeftPanelSubTab, EntityDetailsLeftPanelTab, } from '../../../flyout/entity_details/shared/components/left_panel/left_panel_header'; -import type { IdentityFields } from '../../../flyout/document_details/shared/utils'; -import { useUiSetting } from '../../../common/lib/kibana'; interface MisconfigurationPreviewDistributionBarProps { key: string; @@ -111,24 +103,16 @@ const MisconfigurationPreviewScore = ({ }; export const MisconfigurationsPreview = ({ - identityFields, isPreviewMode, openDetailsPanel, + passedFindings, + failedFindings, }: { - identityFields: IdentityFields; isPreviewMode: boolean; + passedFindings: number; + failedFindings: number; openDetailsPanel: (path: EntityDetailsPath) => void; }) => { - const euidApi = useEntityStoreEuidApi(); - const entityStoreV2Enabled = useUiSetting(FF_ENABLE_ENTITY_STORE_V2); - const { hasMisconfigurationFindings, passedFindings, failedFindings } = useHasMisconfigurations( - buildEuidCspPreviewOptions( - inferEntityTypeFromIdentityFields(identityFields), - identityFields, - euidApi, - { entityStoreV2Enabled } - ) - ); const findingsStats = useGetFindingsStats(passedFindings, failedFindings); useEffect(() => { @@ -160,7 +144,7 @@ export const MisconfigurationsPreview = ({ return ( ), - link: hasMisconfigurationFindings ? link : undefined, + link, }} data-test-subj={'securitySolutionFlyoutInsightsMisconfigurations'} > diff --git a/x-pack/solutions/security/plugins/security_solution/public/cloud_security_posture/components/vulnerabilities/vulnerabilities_preview.tsx b/x-pack/solutions/security/plugins/security_solution/public/cloud_security_posture/components/vulnerabilities/vulnerabilities_preview.tsx index 2a4be8087c1ba..024cb3515964e 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/cloud_security_posture/components/vulnerabilities/vulnerabilities_preview.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/cloud_security_posture/components/vulnerabilities/vulnerabilities_preview.tsx @@ -20,6 +20,7 @@ import { } from '@kbn/cloud-security-posture-common/utils/ui_metrics'; import { METRIC_TYPE } from '@kbn/analytics'; import { FF_ENABLE_ENTITY_STORE_V2, useEntityStoreEuidApi } from '@kbn/entity-store/public'; +import type { EntityStoreRecord } from '../../../flyout/entity_details/shared/hooks/use_entity_from_store'; import { buildEuidCspPreviewOptions, inferEntityTypeFromIdentityFields, @@ -68,10 +69,12 @@ const VulnerabilitiesCount = ({ export const VulnerabilitiesPreview = ({ identityFields, + entityRecord, isPreviewMode, openDetailsPanel, }: { identityFields: IdentityFields; + entityRecord?: EntityStoreRecord | null; isPreviewMode: boolean; openDetailsPanel: (path: EntityDetailsPath) => void; }) => { @@ -83,8 +86,12 @@ export const VulnerabilitiesPreview = ({ const entityStoreV2Enabled = useUiSetting(FF_ENABLE_ENTITY_STORE_V2); const entityType = inferEntityTypeFromIdentityFields(identityFields); const cspPreviewOptions = useMemo( - () => buildEuidCspPreviewOptions(entityType, identityFields, euidApi, { entityStoreV2Enabled }), - [euidApi, entityStoreV2Enabled, entityType, identityFields] + () => + buildEuidCspPreviewOptions(entityType, entityRecord, euidApi, { + entityStoreV2Enabled, + legacyIdentityFields: identityFields, + }), + [entityType, entityRecord, euidApi, entityStoreV2Enabled, identityFields] ); const { data } = useVulnerabilitiesPreview(cspPreviewOptions); diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/components/ml/anomaly/anomaly_table_euid.test.ts b/x-pack/solutions/security/plugins/security_solution/public/common/components/ml/anomaly/anomaly_table_euid.test.ts index c841dd7c32d16..4dbf84d3de12b 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/common/components/ml/anomaly/anomaly_table_euid.test.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/common/components/ml/anomaly/anomaly_table_euid.test.ts @@ -7,6 +7,7 @@ import type { EntityStoreEuid } from '@kbn/entity-store/public'; +import type { EntityStoreRecord } from '../../../../flyout/entity_details/shared/hooks/use_entity_from_store'; import type { Anomaly } from '../types'; import { anomalyMatchesMlEntityField, @@ -81,6 +82,28 @@ describe('anomaly_table_euid', () => { }); expect(q).toEqual(scopedDsl); }); + + test('scoped path passes entityRecord directly when provided', () => { + const scopedDsl = { bool: { filter: [{ term: { 'user.name': 'alice' } }] } }; + const getEuidFilterBasedOnDocument = jest.fn().mockReturnValue(scopedDsl); + const euid = { + dsl: { getEuidFilterBasedOnDocument }, + getEuidSourceFields: () => ({ + requiresOneOf: ['user.name'], + identitySourceFields: ['user.name'], + }), + } as unknown as EntityStoreEuid; + const entityRecord = { 'user.name': 'alice' } as unknown as EntityStoreRecord; + + const q = buildAnomaliesTableInfluencersFilterQuery({ + euid, + entityType: 'user', + entityRecord, + isScopedToEntity: true, + }); + expect(getEuidFilterBasedOnDocument).toHaveBeenCalledWith('user', entityRecord); + expect(q).toEqual(scopedDsl); + }); }); describe('getCriteriaFieldsForAnomaliesTable', () => { @@ -125,6 +148,27 @@ describe('anomaly_table_euid', () => { { fieldName: 'user.name', fieldValue: 'bob' }, ]); }); + + test('passes entityRecord directly when provided', () => { + const getEuidFilterBasedOnDocument = jest.fn().mockReturnValue(undefined); + const getEntityIdentifiersFromDocument = jest.fn().mockReturnValue({ 'user.name': 'carol' }); + const euid = { + dsl: { getEuidFilterBasedOnDocument }, + getEntityIdentifiersFromDocument, + } as unknown as EntityStoreEuid; + const entityRecord = { 'user.name': 'carol' } as unknown as EntityStoreRecord; + + const result = getCriteriaFieldsForAnomaliesTable({ + euid, + entityType: 'user', + entityRecord, + isScopedToEntity: true, + fallbackDisplayName: 'carol', + }); + expect(getEuidFilterBasedOnDocument).toHaveBeenCalledWith('user', entityRecord); + expect(getEntityIdentifiersFromDocument).toHaveBeenCalledWith('user', entityRecord); + expect(result).toEqual([{ fieldName: 'user.name', fieldValue: 'carol' }]); + }); }); describe('anomalyRowMatchesIdentityIdentifiers', () => { diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/components/ml/anomaly/anomaly_table_euid.ts b/x-pack/solutions/security/plugins/security_solution/public/common/components/ml/anomaly/anomaly_table_euid.ts index 3eac5975d5ead..9da882001680e 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/common/components/ml/anomaly/anomaly_table_euid.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/common/components/ml/anomaly/anomaly_table_euid.ts @@ -8,6 +8,7 @@ import type { estypes } from '@elastic/elasticsearch'; import type { EntityStoreEuid } from '@kbn/entity-store/public'; +import type { EntityStoreRecord } from '../../../../flyout/entity_details/shared/hooks/use_entity_from_store'; import type { Anomaly, CriteriaFields } from '../types'; /** Entity kinds that have Security Solution host/user anomaly tables. */ @@ -71,24 +72,24 @@ export const buildBroadMlIdentityFieldsExistFilter = ( export const buildAnomaliesTableInfluencersFilterQuery = ({ euid, entityType, + entityRecord, isScopedToEntity, identityFields, fallbackDisplayName, }: { euid: EntityStoreEuid | undefined; entityType: AnomaliesTableEntityType; + entityRecord?: EntityStoreRecord | null; isScopedToEntity: boolean; identityFields?: Record; fallbackDisplayName?: string; }): estypes.QueryDslQueryContainer => { if (euid) { if (isScopedToEntity) { - const doc = buildEuidSampleDocumentForAnomaliesTable( - entityType, - identityFields, - fallbackDisplayName - ); - const scoped = euid.dsl.getEuidFilterBasedOnDocument(entityType, doc); + const inputDoc = entityRecord + ? entityRecord + : buildEuidSampleDocumentForAnomaliesTable(entityType, identityFields, fallbackDisplayName); + const scoped = euid.dsl.getEuidFilterBasedOnDocument(entityType, inputDoc); if (scoped != null) { return scoped as estypes.QueryDslQueryContainer; } @@ -103,12 +104,14 @@ export const buildAnomaliesTableInfluencersFilterQuery = ({ export const getCriteriaFieldsForAnomaliesTable = ({ euid, entityType, + entityRecord, isScopedToEntity, identityFields, fallbackDisplayName, }: { euid: EntityStoreEuid | undefined; entityType: AnomaliesTableEntityType; + entityRecord?: EntityStoreRecord | null; isScopedToEntity: boolean; identityFields?: Record; fallbackDisplayName?: string; @@ -117,16 +120,14 @@ export const getCriteriaFieldsForAnomaliesTable = ({ return []; } if (euid) { - const doc = buildEuidSampleDocumentForAnomaliesTable( - entityType, - identityFields, - fallbackDisplayName - ); - const scopedDsl = euid.dsl.getEuidFilterBasedOnDocument(entityType, doc); + const inputDoc = entityRecord + ? entityRecord + : buildEuidSampleDocumentForAnomaliesTable(entityType, identityFields, fallbackDisplayName); + const scopedDsl = euid.dsl.getEuidFilterBasedOnDocument(entityType, inputDoc); if (scopedDsl != null) { return []; } - const identifiers = euid.getEntityIdentifiersFromDocument(entityType, doc); + const identifiers = euid.getEntityIdentifiersFromDocument(entityType, inputDoc); if (identifiers != null && Object.keys(identifiers).length > 0) { return Object.entries(identifiers).map(([fieldName, fieldValue]) => ({ fieldName, diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/components/ml/criteria/get_criteria_from_users_type.test.ts b/x-pack/solutions/security/plugins/security_solution/public/common/components/ml/criteria/get_criteria_from_users_type.test.ts index c56dcb2f08973..1b17967b45cf7 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/common/components/ml/criteria/get_criteria_from_users_type.test.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/common/components/ml/criteria/get_criteria_from_users_type.test.ts @@ -7,29 +7,31 @@ import type { EntityStoreEuid } from '@kbn/entity-store/public'; +import type { EntityStoreRecord } from '../../../../flyout/entity_details/shared/hooks/use_entity_from_store'; import { UsersType } from '../../../../explore/users/store/model'; import { getCriteriaFromUsersType } from './get_criteria_from_users_type'; describe('get_criteria_from_user_type', () => { test('returns user name from criteria if the user type is details', () => { - const criteria = getCriteriaFromUsersType(UsersType.details, 'admin'); + const criteria = getCriteriaFromUsersType({ type: UsersType.details, userName: 'admin' }); expect(criteria).toEqual([{ fieldName: 'user.name', fieldValue: 'admin' }]); }); test('returns empty array from criteria if the user type is page but rather an empty array', () => { - const criteria = getCriteriaFromUsersType(UsersType.page, 'admin'); + const criteria = getCriteriaFromUsersType({ type: UsersType.page, userName: 'admin' }); expect(criteria).toEqual([]); }); test('returns empty array from criteria if the user name is undefined and user type is details', () => { - const criteria = getCriteriaFromUsersType(UsersType.details, undefined); + const criteria = getCriteriaFromUsersType({ type: UsersType.details, userName: undefined }); expect(criteria).toEqual([]); }); test('without EUID API, identity fields alone still use legacy user.name criteria', () => { - const criteria = getCriteriaFromUsersType(UsersType.details, 'admin', { - 'user.id': 'uid-1', - 'user.name': 'admin', + const criteria = getCriteriaFromUsersType({ + type: UsersType.details, + userName: 'admin', + identityFields: { 'user.id': 'uid-1', 'user.name': 'admin' }, }); expect(criteria).toEqual([{ fieldName: 'user.name', fieldValue: 'admin' }]); }); @@ -45,18 +47,46 @@ describe('get_criteria_from_user_type', () => { }), } as unknown as EntityStoreEuid; - const criteria = getCriteriaFromUsersType( - UsersType.details, - 'admin', - { - 'user.id': 'uid-1', - 'user.name': 'from-identity', - }, - euid - ); + const criteria = getCriteriaFromUsersType({ + type: UsersType.details, + userName: 'admin', + identityFields: { 'user.id': 'uid-1', 'user.name': 'from-identity' }, + euid, + }); expect(criteria).toEqual([ { fieldName: 'user.id', fieldValue: 'uid-1' }, { fieldName: 'user.name', fieldValue: 'from-identity' }, ]); }); + + test('with EUID API, uses entityRecord as input document instead of building from identityFields', () => { + const entityRecord = { + 'user.id': 'record-uid', + 'user.name': 'from-record', + } as unknown as EntityStoreRecord; + + const euid = { + dsl: { + getEuidFilterBasedOnDocument: jest.fn().mockReturnValue(undefined), + }, + getEntityIdentifiersFromDocument: jest.fn().mockReturnValue({ + 'user.id': 'record-uid', + 'user.name': 'from-record', + }), + } as unknown as EntityStoreEuid; + + const criteria = getCriteriaFromUsersType({ + type: UsersType.details, + userName: 'admin', + identityFields: { 'user.name': 'admin' }, + entityRecord, + euid, + }); + + expect(euid.dsl.getEuidFilterBasedOnDocument).toHaveBeenCalledWith('user', entityRecord); + expect(criteria).toEqual([ + { fieldName: 'user.id', fieldValue: 'record-uid' }, + { fieldName: 'user.name', fieldValue: 'from-record' }, + ]); + }); }); diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/components/ml/criteria/get_criteria_from_users_type.ts b/x-pack/solutions/security/plugins/security_solution/public/common/components/ml/criteria/get_criteria_from_users_type.ts index 62332d1275860..2dacea97753c0 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/common/components/ml/criteria/get_criteria_from_users_type.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/common/components/ml/criteria/get_criteria_from_users_type.ts @@ -6,23 +6,30 @@ */ import type { EntityStoreEuid } from '@kbn/entity-store/public'; - +import type { EntityStoreRecord } from '../../../../flyout/entity_details/shared/hooks/use_entity_from_store'; import { UsersType } from '../../../../explore/users/store/model'; import type { CriteriaFields } from '../types'; import { getCriteriaFieldsForAnomaliesTable } from '../anomaly/anomaly_table_euid'; +interface GetCriteriaFromUsersTypeOptions { + type: UsersType; + userName: string | undefined; + entityRecord?: EntityStoreRecord | null; + identityFields?: Record; + euid?: EntityStoreEuid; +} + export const getCriteriaFromUsersType = ( - type: UsersType, - userName: string | undefined, - identityFields?: Record, - euid?: EntityStoreEuid + opts: GetCriteriaFromUsersTypeOptions ): CriteriaFields[] => { + const { type, userName, entityRecord, identityFields, euid } = opts; if (type !== UsersType.details || userName == null) { return []; } return getCriteriaFieldsForAnomaliesTable({ euid, entityType: 'user', + entityRecord, isScopedToEntity: true, identityFields, fallbackDisplayName: userName, diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/components/ml/criteria/host_to_criteria.test.ts b/x-pack/solutions/security/plugins/security_solution/public/common/components/ml/criteria/host_to_criteria.test.ts index 36e92b1adab89..ec0d6b04ff441 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/common/components/ml/criteria/host_to_criteria.test.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/common/components/ml/criteria/host_to_criteria.test.ts @@ -5,6 +5,8 @@ * 2.0. */ +import type { EntityStoreEuid } from '@kbn/entity-store/public'; +import type { EntityStoreRecord } from '../../../../flyout/entity_details/shared/hooks/use_entity_from_store'; import type { HostItem } from '../../../../../common/search_strategy/security_solution/hosts'; import type { CriteriaFields } from '../types'; import { hostToCriteria } from './host_to_criteria'; @@ -22,7 +24,7 @@ describe('host_to_criteria', () => { fieldValue: 'host-name', }, ]; - expect(hostToCriteria(hostItem)).toEqual(expectedCriteria); + expect(hostToCriteria({ hostItem })).toEqual(expectedCriteria); }); test('returns an empty array if the host.name is null', () => { @@ -32,14 +34,14 @@ describe('host_to_criteria', () => { name: null, }, }; - expect(hostToCriteria(hostItem)).toEqual([]); + expect(hostToCriteria({ hostItem })).toEqual([]); }); test('returns an empty array if the host is null', () => { const hostItem: HostItem = { host: null, }; - expect(hostToCriteria(hostItem)).toEqual([]); + expect(hostToCriteria({ hostItem })).toEqual([]); }); test('prefers host.id over host.name when id is non-empty', () => { @@ -49,11 +51,48 @@ describe('host_to_criteria', () => { name: ['host-name'], }, }; - expect(hostToCriteria(hostItem)).toEqual([ + expect(hostToCriteria({ hostItem })).toEqual([ { fieldName: 'host.id', fieldValue: 'hid-1', }, ]); }); + + test('returns empty array when euid produces a scoped DSL filter', () => { + const hostItem: HostItem = { host: { name: ['host-name'] } }; + const entityRecord = { 'host.name': 'host-name' } as unknown as EntityStoreRecord; + const euid = { + dsl: { + getEuidFilterBasedOnDocument: jest.fn().mockReturnValue({ bool: { filter: [] } }), + }, + getEntityIdentifiersFromDocument: jest.fn(), + } as unknown as EntityStoreEuid; + + expect(hostToCriteria({ hostItem, entityRecord, euid })).toEqual([]); + expect(euid.dsl.getEuidFilterBasedOnDocument).toHaveBeenCalledWith('host', entityRecord); + }); + + test('returns identifier map criteria when euid has no scoped DSL filter', () => { + const hostItem: HostItem = { host: { name: ['host-name'] } }; + const entityRecord = { + 'host.id': 'eid-1', + 'host.name': 'host-name', + } as unknown as EntityStoreRecord; + const euid = { + dsl: { + getEuidFilterBasedOnDocument: jest.fn().mockReturnValue(undefined), + }, + getEntityIdentifiersFromDocument: jest.fn().mockReturnValue({ + 'host.id': 'eid-1', + 'host.name': 'host-name', + }), + } as unknown as EntityStoreEuid; + + expect(hostToCriteria({ hostItem, entityRecord, euid })).toEqual([ + { fieldName: 'host.id', fieldValue: 'eid-1' }, + { fieldName: 'host.name', fieldValue: 'host-name' }, + ]); + expect(euid.getEntityIdentifiersFromDocument).toHaveBeenCalledWith('host', entityRecord); + }); }); diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/components/ml/criteria/host_to_criteria.ts b/x-pack/solutions/security/plugins/security_solution/public/common/components/ml/criteria/host_to_criteria.ts index af37ec906e665..ae531d6a5638a 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/common/components/ml/criteria/host_to_criteria.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/common/components/ml/criteria/host_to_criteria.ts @@ -7,19 +7,27 @@ import type { EntityStoreEuid } from '@kbn/entity-store/public'; +import type { EntityStoreRecord } from '../../../../flyout/entity_details/shared/hooks/use_entity_from_store'; import type { HostItem } from '../../../../../common/search_strategy/security_solution/hosts'; import type { CriteriaFields } from '../types'; -export const hostToCriteria = (hostItem: HostItem, euid?: EntityStoreEuid): CriteriaFields[] => { +interface HostToCriteriaOptions { + hostItem: HostItem; + entityRecord?: EntityStoreRecord | null; + euid?: EntityStoreEuid; +} +export const hostToCriteria = (opts: HostToCriteriaOptions): CriteriaFields[] => { + const { hostItem, entityRecord, euid } = opts; if (hostItem == null) { return []; } if (euid) { - const scopedDsl = euid.dsl.getEuidFilterBasedOnDocument('host', hostItem); + const inputDoc = entityRecord ? entityRecord : hostItem; + const scopedDsl = euid.dsl.getEuidFilterBasedOnDocument('host', inputDoc); if (scopedDsl != null) { return []; } - const identifiers = euid.getEntityIdentifiersFromDocument('host', hostItem); + const identifiers = euid.getEntityIdentifiersFromDocument('host', inputDoc); if (identifiers != null && Object.keys(identifiers).length > 0) { return Object.entries(identifiers).map(([fieldName, fieldValue]) => ({ fieldName, diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/components/ml/tables/anomalies_user_table.tsx b/x-pack/solutions/security/plugins/security_solution/public/common/components/ml/tables/anomalies_user_table.tsx index 34f353c661ab4..371a8b2ad115a 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/common/components/ml/tables/anomalies_user_table.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/common/components/ml/tables/anomalies_user_table.tsx @@ -133,7 +133,12 @@ const AnomaliesUserTableComponent: React.FC = ({ startDate, endDate, skip: querySkip, - criteriaFields: getCriteriaFromUsersType(type, userName, identityFields, euid), + criteriaFields: getCriteriaFromUsersType({ + type, + userName, + identityFields, + euid, + }), filterQuery: anomaliesInfluencersFilterQuery, jobIds: selectedJobIds.length > 0 ? selectedJobIds : jobIds, aggregationInterval: selectedInterval, diff --git a/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/components/host_risk_score_table/columns.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/components/host_risk_score_table/columns.test.tsx index d439c627365a0..a33a698900879 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/components/host_risk_score_table/columns.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/components/host_risk_score_table/columns.test.tsx @@ -14,6 +14,7 @@ describe('getHostRiskScoreColumns', () => { test('should render host score rounded', () => { const columns: HostRiskScoreColumns = getHostRiskScoreColumns({ dispatchSeverityUpdate: jest.fn(), + openHostFlyout: jest.fn(), }); const riskScore = 10.11111111; diff --git a/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/components/host_risk_score_table/columns.tsx b/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/components/host_risk_score_table/columns.tsx index 845b9a469e289..d21db239bccca 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/components/host_risk_score_table/columns.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/components/host_risk_score_table/columns.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React from 'react'; +import React, { type SyntheticEvent } from 'react'; import { EuiLink, EuiText } from '@elastic/eui'; import { SECURITY_CELL_ACTIONS_DEFAULT } from '@kbn/ui-actions-plugin/common/trigger_ids'; import { SecurityCellActions, CellActionsMode } from '../../../common/components/cell_actions'; @@ -24,8 +24,10 @@ import { formatRiskScore } from '../../common'; export const getHostRiskScoreColumns = ({ dispatchSeverityUpdate, + openHostFlyout, }: { dispatchSeverityUpdate: (s: RiskSeverity) => void; + openHostFlyout: (hostName: string) => void; }): HostRiskScoreColumns => [ { field: 'host.name', @@ -50,7 +52,14 @@ export const getHostRiskScoreColumns = ({ telemetry: CELL_ACTIONS_TELEMETRY, }} > - + { + e.preventDefault(); + openHostFlyout(hostName); + }} + /> ); } diff --git a/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/components/host_risk_score_table/index.tsx b/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/components/host_risk_score_table/index.tsx index b999aa0e16668..f685ae04eb280 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/components/host_risk_score_table/index.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/components/host_risk_score_table/index.tsx @@ -7,8 +7,9 @@ import React, { useMemo, useCallback } from 'react'; import { useDispatch } from 'react-redux'; - +import { useExpandableFlyoutApi } from '@kbn/expandable-flyout'; import { EuiFilterGroup, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { HostPanelKey } from '../../../flyout/entity_details/shared/constants'; import type { Columns, Criteria, @@ -82,6 +83,7 @@ const HostRiskScoreTableComponent: React.FC = ({ type, }) => { const dispatch = useDispatch(); + const { openFlyout } = useExpandableFlyoutApi(); const getHostRiskScoreSelector = useMemo(() => hostsSelectors.hostRiskScoreSelector(), []); const { activePage, limit, sort } = useDeepEqualSelector((state: State) => getHostRiskScoreSelector(state, hostsModel.HostsType.page) @@ -137,9 +139,26 @@ const HostRiskScoreTableComponent: React.FC = ({ }, [dispatch, type] ); + const openHostFlyout = useCallback( + (hostName: string) => { + openFlyout({ + right: { + id: HostPanelKey, + params: { + hostName, + contextID: tableType, + scopeId: tableType, + isPreviewMode: false, + }, + }, + }); + }, + [openFlyout] + ); + const columns = useMemo( - () => getHostRiskScoreColumns({ dispatchSeverityUpdate }), - [dispatchSeverityUpdate] + () => getHostRiskScoreColumns({ dispatchSeverityUpdate, openHostFlyout }), + [dispatchSeverityUpdate, openHostFlyout] ); const risk = ( diff --git a/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/components/user_risk_score_table/columns.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/components/user_risk_score_table/columns.test.tsx index 5fbbcf30d13bf..a947ea3fc79b6 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/components/user_risk_score_table/columns.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/components/user_risk_score_table/columns.test.tsx @@ -14,6 +14,7 @@ import { RiskScoreFields } from '../../../../common/search_strategy'; describe('getUserRiskScoreColumns', () => { const defaultProps = { dispatchSeverityUpdate: jest.fn(), + openUserFlyout: jest.fn(), }; test('should have expected fields', () => { diff --git a/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/components/user_risk_score_table/columns.tsx b/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/components/user_risk_score_table/columns.tsx index caaa6196ca400..1bf2efbac3144 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/components/user_risk_score_table/columns.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/components/user_risk_score_table/columns.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React from 'react'; +import React, { type SyntheticEvent } from 'react'; import { EuiLink, EuiText } from '@elastic/eui'; import { SECURITY_CELL_ACTIONS_DEFAULT } from '@kbn/ui-actions-plugin/common/trigger_ids'; import { SecurityCellActions, CellActionsMode } from '../../../common/components/cell_actions'; @@ -25,8 +25,10 @@ import { formatRiskScore } from '../../common'; export const getUserRiskScoreColumns = ({ dispatchSeverityUpdate, + openUserFlyout, }: { dispatchSeverityUpdate: (s: RiskSeverity) => void; + openUserFlyout: (userName: string) => void; }): UserRiskScoreColumns => [ { field: 'user.name', @@ -53,7 +55,14 @@ export const getUserRiskScoreColumns = ({ telemetry: CELL_ACTIONS_TELEMETRY, }} > - + { + e.preventDefault(); + openUserFlyout(userName); + }} + /> ); } diff --git a/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/components/user_risk_score_table/index.tsx b/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/components/user_risk_score_table/index.tsx index a8841cfc22c9e..a203978a0bc05 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/components/user_risk_score_table/index.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/components/user_risk_score_table/index.tsx @@ -7,8 +7,9 @@ import React, { useMemo, useCallback } from 'react'; import { useDispatch } from 'react-redux'; - +import { useExpandableFlyoutApi } from '@kbn/expandable-flyout'; import { EuiFilterGroup, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { UserPanelKey } from '../../../flyout/entity_details/shared/constants'; import type { Columns, Criteria, @@ -82,6 +83,7 @@ const UserRiskScoreTableComponent: React.FC = ({ type, }) => { const dispatch = useDispatch(); + const { openFlyout } = useExpandableFlyoutApi(); const getUserRiskScoreSelector = useMemo(() => usersSelectors.userRiskScoreSelector(), []); const { activePage, limit, sort } = useDeepEqualSelector((state: State) => @@ -139,9 +141,27 @@ const UserRiskScoreTableComponent: React.FC = ({ }, [dispatch] ); + + const openUserFlyout = useCallback( + (userName: string) => { + openFlyout({ + right: { + id: UserPanelKey, + params: { + userName, + contextID: tableType, + scopeId: tableType, + isPreviewMode: false, + }, + }, + }); + }, + [openFlyout] + ); + const columns = useMemo( - () => getUserRiskScoreColumns({ dispatchSeverityUpdate }), - [dispatchSeverityUpdate] + () => getUserRiskScoreColumns({ dispatchSeverityUpdate, openUserFlyout }), + [dispatchSeverityUpdate, openUserFlyout] ); const risk = ( diff --git a/x-pack/solutions/security/plugins/security_solution/public/explore/hosts/pages/details/index.tsx b/x-pack/solutions/security/plugins/security_solution/public/explore/hosts/pages/details/index.tsx index 6dd9477eb054d..b1d3c1140fab0 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/explore/hosts/pages/details/index.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/explore/hosts/pages/details/index.tsx @@ -506,7 +506,13 @@ const HostDetailsComponent: React.FC = ({ {!noEntityInStore && ( <> = ({ {!noEntityInStore && ( <> = ({ = ({ , 'anomalies'> & { export const ObservedDataSection = memo( ({ identityFields, + entityRecord, observedHost, contextID, scopeId, queryId, }: { identityFields: IdentityFields; + entityRecord?: EntityStoreRecord | null; observedHost: ObservedHostData; contextID: string; scopeId: string; @@ -130,6 +132,7 @@ export const ObservedDataSection = memo( ) : ( diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/host_right/index.tsx b/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/host_right/index.tsx index d9ebed173e744..6b397838ae9aa 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/host_right/index.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/host_right/index.tsx @@ -171,6 +171,7 @@ export const HostPanel = memo(function HostPanel({ const { hasNonClosedAlerts } = useNonClosedAlerts({ identityFields: documentEntityIdentifiers, entityType: EntityType.host, + entityRecord: entityStoreV2Enabled ? entityFromStoreResult.entityRecord : undefined, to, from, queryId: `${DETECTION_RESPONSE_ALERTS_BY_STATUS_ID}HOST_NAME_RIGHT`, diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/user_right/components/observed_data_section.tsx b/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/user_right/components/observed_data_section.tsx index d46255c827961..f10415f664fe3 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/user_right/components/observed_data_section.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/user_right/components/observed_data_section.tsx @@ -30,11 +30,13 @@ import { getCriteriaFromUsersType } from '../../../../common/components/ml/crite import { UsersType } from '../../../../explore/users/store/model'; import type { ObservedUserData } from '../content'; import type { IdentityFields } from '../../../document_details/shared/utils'; +import type { EntityStoreRecord } from '../../shared/hooks/use_entity_from_store'; export const ObservedDataSection = memo( ({ userName, identityFields, + entityRecord, observedUser, contextID, scopeId, @@ -42,6 +44,7 @@ export const ObservedDataSection = memo( }: { userName: string; identityFields: IdentityFields; + entityRecord?: EntityStoreRecord | null; observedUser: ObservedUserData; contextID: string; scopeId: string; @@ -128,6 +131,7 @@ export const ObservedDataSection = memo( { }; }); +jest.mock('../../../../flyout/entity_details/shared/hooks/use_entity_from_store', () => ({ + useEntityFromStore: jest.fn(() => ({ + entity: null, + entityRecord: null, + firstSeen: null, + lastSeen: null, + isLoading: false, + error: null, + refetch: jest.fn(), + })), +})); + +jest.mock('../../../../common/lib/kibana', () => { + const actual = jest.requireActual('../../../../common/lib/kibana'); + return { ...actual, useUiSetting: jest.fn(() => false) }; +}); + +jest.mock('@kbn/entity-store/public', () => ({ + FF_ENABLE_ENTITY_STORE_V2: 'securitySolution:entityStoreEnableV2', + useEntityStoreEuidApi: jest.fn(() => undefined), +})); + +const mockUseUiSetting = useUiSetting as jest.Mock; +const mockUseEntityStoreEuidApi = useEntityStoreEuidApi as jest.Mock; +const mockUseEntityFromStore = useEntityFromStore as jest.Mock; + // helper function to render the hook const renderUseAlertsByStatus = (props: Partial = {}) => renderHook( @@ -62,6 +91,17 @@ describe('useAlertsByStatus', () => { jest.clearAllMocks(); mockDateNow.mockReturnValue(dateNow); mockUseQueryAlerts.mockReturnValue(defaultUseQueryAlertsReturn); + mockUseUiSetting.mockReturnValue(false); + mockUseEntityStoreEuidApi.mockReturnValue(undefined); + mockUseEntityFromStore.mockReturnValue({ + entity: null, + entityRecord: null, + firstSeen: null, + lastSeen: null, + isLoading: false, + error: null, + refetch: jest.fn(), + }); }); it('should return default values', () => { @@ -132,6 +172,80 @@ describe('useAlertsByStatus', () => { }); }); + it('should look up the entity store record when entity store v2 is enabled, entity.id is provided, and no entityRecord is given', () => { + mockUseUiSetting.mockReturnValue(true); + + renderUseAlertsByStatus({ + entityType: 'host', + identityFields: { [ENTITY_ID_FIELD]: 'host-entity-123', 'host.name': 'my-host' }, + }); + + expect(mockUseEntityFromStore).toHaveBeenCalledWith( + expect.objectContaining({ + entityId: 'host-entity-123', + entityType: 'host', + skip: false, + }) + ); + }); + + it('should fall back to identity field term filters when entity store v2 is disabled and no entityRecord is provided', () => { + const mockGetEuidFilterBasedOnDocument = jest.fn(); + mockUseUiSetting.mockReturnValue(false); + mockUseEntityStoreEuidApi.mockReturnValue({ + euid: { dsl: { getEuidFilterBasedOnDocument: mockGetEuidFilterBasedOnDocument } }, + }); + + renderUseAlertsByStatus({ + entityType: 'host', + identityFields: { 'host.name': 'my-host' }, + }); + + expect(mockGetEuidFilterBasedOnDocument).not.toHaveBeenCalled(); + expect(mockUseQueryAlerts).toBeCalledWith( + expect.objectContaining({ + query: expect.objectContaining({ + query: { + bool: { + filter: expect.arrayContaining([ + { bool: { filter: [{ term: { 'host.name': 'my-host' } }] } }, + ]), + }, + }, + }), + }) + ); + }); + + it('should use entityRecord to build the filter when entityRecord is passed in', () => { + const mockEntityRecord = { entity: { id: 'host-1' }, host: { name: 'my-host' } }; + const mockFilter = { term: { 'entity.id': 'host-1' } }; + const mockGetEuidFilterBasedOnDocument = jest.fn().mockReturnValue(mockFilter); + mockUseUiSetting.mockReturnValue(true); + mockUseEntityStoreEuidApi.mockReturnValue({ + euid: { dsl: { getEuidFilterBasedOnDocument: mockGetEuidFilterBasedOnDocument } }, + }); + + renderUseAlertsByStatus({ + entityRecord: mockEntityRecord as never, + entityType: 'host', + identityFields: { 'host.name': 'my-host' }, + }); + + expect(mockGetEuidFilterBasedOnDocument).toHaveBeenCalledWith('host', mockEntityRecord); + expect(mockUseQueryAlerts).toBeCalledWith( + expect.objectContaining({ + query: expect.objectContaining({ + query: { + bool: { + filter: expect.arrayContaining([mockFilter]), + }, + }, + }), + }) + ); + }); + it('should include runtime_mappings in the query if provided', () => { const customRuntimeMappings = { 'host.name.custom': { diff --git a/x-pack/solutions/security/plugins/security_solution/public/overview/components/detection_response/alerts_by_status/use_alerts_by_status.ts b/x-pack/solutions/security/plugins/security_solution/public/overview/components/detection_response/alerts_by_status/use_alerts_by_status.ts index 49e3fd5245c6b..a67ba9a7cd9c9 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/overview/components/detection_response/alerts_by_status/use_alerts_by_status.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/overview/components/detection_response/alerts_by_status/use_alerts_by_status.ts @@ -140,11 +140,6 @@ export interface UseAlertsByStatusProps { signalIndexName: string | null; skip?: boolean; identityFields: Record; - /** - * When `identityFields` includes `entity.id`, resolves the store record (Entity Store v2) - * and expands to ECS-style identifier terms (e.g. `user.entity.id`, `user.name`) for the alerts query. - * Required for v2 resolution when filtering by canonical store id; if omitted, a plain `entity.id` term is used. - */ entityType?: string; entityRecord?: EntityStoreRecord | null; additionalFilters?: ESBoolQuery[]; @@ -179,8 +174,12 @@ export const useAlertsByStatus: UseAlertsByStatus = ({ : EMPTY_IDENTITY_FIELDS; const entityIdValue = identityFieldsStable[ENTITY_ID_FIELD]; const storeEntityType = toStoreEntityType(entityType); + const shouldResolveEntityIdFromStore = - Boolean(entityIdValue) && entityStoreV2Enabled && storeEntityType != null; + Boolean(entityIdValue) && // entity ID is defined + entityStoreV2Enabled && // entity store v2 is enabled + storeEntityType != null && // entity type is mappable to store-supported types + !entityRecordInput; // entity record not already provided as input const entityFromStore = useEntityFromStore({ entityId: entityIdValue, @@ -192,13 +191,15 @@ export const useAlertsByStatus: UseAlertsByStatus = ({ const euidApi = useEntityStoreEuidApi(); const entityFilters = useMemo(() => { - if (entityStoreV2Enabled && euidApi?.euid) { + if (entityStoreV2Enabled && euidApi?.euid && (entityRecord || entityRecordInput)) { + // Use the entity record to generate a DSL query fragment const filter = euidApi.euid?.dsl.getEuidFilterBasedOnDocument( storeEntityType ?? 'generic', entityRecord ?? entityRecordInput ); return filter != null ? [filter] : []; } + return identityFieldsStable != null && Object.keys(identityFieldsStable).length > 0 ? [buildEntityIdentifierTermFilters(identityFieldsStable)] : [];