diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/components/alert_count_by_status/alert_count_by_rule_by_status.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/common/components/alert_count_by_status/alert_count_by_rule_by_status.test.tsx index df001ccf70ca9..bb631cc9e208f 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/common/components/alert_count_by_status/alert_count_by_rule_by_status.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/common/components/alert_count_by_status/alert_count_by_rule_by_status.test.tsx @@ -12,7 +12,11 @@ import { render } from '@testing-library/react'; import { TestProviders } from '../../mock'; import { AlertCountByRuleByStatus } from './alert_count_by_rule_by_status'; import { COLUMN_HEADER_COUNT, COLUMN_HEADER_RULE_NAME } from './translations'; -import type { UseAlertCountByRuleByStatus } from './use_alert_count_by_rule_by_status'; +import type { + UseAlertCountByRuleByStatus, + UseAlertCountByRuleByStatusProps, +} from './use_alert_count_by_rule_by_status'; +import type { EntityStoreRecord } from '../../../flyout/entity_details/shared/hooks/use_entity_from_store'; type UseAlertCountByRuleByStatusReturn = ReturnType; const defaultUseAlertCountByRuleByStatusReturn: UseAlertCountByRuleByStatusReturn = { @@ -21,7 +25,9 @@ const defaultUseAlertCountByRuleByStatusReturn: UseAlertCountByRuleByStatusRetur updatedAt: Date.now(), }; -const mockUseAlertCountByRuleByStatus = jest.fn(() => defaultUseAlertCountByRuleByStatusReturn); +const mockUseAlertCountByRuleByStatus = jest.fn( + (_props: UseAlertCountByRuleByStatusProps) => defaultUseAlertCountByRuleByStatusReturn +); const mockUseAlertCountByRuleByStatusReturn = ( overrides: Partial ) => { @@ -32,19 +38,32 @@ const mockUseAlertCountByRuleByStatusReturn = ( }; jest.mock('./use_alert_count_by_rule_by_status', () => ({ - useAlertCountByRuleByStatus: () => mockUseAlertCountByRuleByStatus(), + useAlertCountByRuleByStatus: (props: UseAlertCountByRuleByStatusProps) => + mockUseAlertCountByRuleByStatus(props), })); -const renderComponent = () => +jest.mock('@kbn/entity-store/public', () => ({ + FF_ENABLE_ENTITY_STORE_V2: 'securitySolution:entityStoreEnableV2', + useEntityStoreEuidApi: jest.fn(() => undefined), +})); + +jest.mock('../../lib/kibana/kibana_react', () => { + const actual = jest.requireActual('../../lib/kibana/kibana_react'); + return { ...actual, useUiSetting: jest.fn(() => false) }; +}); + +jest.mock('../../hooks/timeline/use_investigate_in_timeline', () => ({ + useInvestigateInTimeline: jest.fn(() => ({ investigateInTimeline: jest.fn() })), +})); + +const entityFilter = { field: 'host.hostname', value: 'some_host_name' }; + +const renderComponent = ( + overrides: Partial> = {} +) => render( - + ); @@ -83,6 +102,36 @@ describe('AlertCountByRuleByStatus', () => { expect(queryByTestId(COLUMN_HEADER_RULE_NAME)).toHaveTextContent('Test Name'); expect(queryByTestId(COLUMN_HEADER_COUNT)).toHaveTextContent('100'); }); + + it('should pass resolved identityFields from entityFilter to the hook', () => { + renderComponent(); + + expect(mockUseAlertCountByRuleByStatus).toHaveBeenCalledWith( + expect.objectContaining({ + identityFields: { 'host.hostname': 'some_host_name' }, + }) + ); + }); + + it('should prefer identityFields prop over entityFilter when both are provided', () => { + const identityFields = { 'host.id': 'host-uuid-123', 'entity.id': 'entity-abc' }; + renderComponent({ identityFields }); + + expect(mockUseAlertCountByRuleByStatus).toHaveBeenCalledWith( + expect.objectContaining({ + identityFields, + }) + ); + }); + + it('should pass entityRecord and entityType to the hook if defined', () => { + const entityRecord = { 'host.name': ['some_host_name'] } as unknown as EntityStoreRecord; + renderComponent({ entityRecord, entityType: 'host' }); + + expect(mockUseAlertCountByRuleByStatus).toHaveBeenCalledWith( + expect.objectContaining({ entityRecord, entityType: 'host' }) + ); + }); }); const mockItem = [ diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/components/alert_count_by_status/alert_count_by_rule_by_status.tsx b/x-pack/solutions/security/plugins/security_solution/public/common/components/alert_count_by_status/alert_count_by_rule_by_status.tsx index b6f95f53d04e1..d49750dc9d2b7 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/common/components/alert_count_by_status/alert_count_by_rule_by_status.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/common/components/alert_count_by_status/alert_count_by_rule_by_status.tsx @@ -10,12 +10,12 @@ import React, { useCallback, useMemo } from 'react'; import type { EuiBasicTableColumn } from '@elastic/eui'; import { EuiBasicTable, EuiEmptyPrompt, EuiLink, EuiPanel, EuiToolTip } from '@elastic/eui'; import { euiStyled } from '@kbn/kibana-react-plugin/common'; - +import type { EntityType } from '@kbn/entity-store/public'; +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 type { ESBoolQuery } from '../../../../common/typed_json'; import type { Status } from '../../../../common/api/detection_engine'; import { SecurityPageName } from '../../../../common/constants'; -import type { Filter } from '../../../overview/components/detection_response/hooks/use_navigate_to_timeline'; -import { useNavigateToTimeline } from '../../../overview/components/detection_response/hooks/use_navigate_to_timeline'; import { SIGNAL_RULE_NAME_FIELD_NAME, SIGNAL_STATUS_FIELD_NAME, @@ -33,14 +33,15 @@ import { MultiSelectPopover } from './components'; import * as i18n from './translations'; import type { AlertCountByRuleByStatusItem } from './use_alert_count_by_rule_by_status'; import { useAlertCountByRuleByStatus } from './use_alert_count_by_rule_by_status'; +import { useUiSetting } from '../../lib/kibana/kibana_react'; +import { useInvestigateInTimeline } from '../../hooks/timeline/use_investigate_in_timeline'; interface EntityFilter { field: string; value: string; - entityType?: string; } interface AlertCountByStatusProps { - entityFilter: EntityFilter; + entityFilter?: EntityFilter; /** * When set (e.g. host/user details from entity resolution), preferred over legacy `entityFilter.field`. * Same semantics as `AlertsByStatus` `identityFields`. @@ -48,12 +49,16 @@ interface AlertCountByStatusProps { identityFields?: Record | null; additionalFilters?: ESBoolQuery[]; signalIndexName: string | null; + entityType?: string; + entityRecord?: EntityStoreRecord | null; } interface StatusSelection { [fieldName: string]: Status[]; } +const DEFAULT_STATUSES: Status[] = ['open']; + type GetTableColumns = ( openRuleInTimelineWithAdditionalFields: (ruleName: string) => void ) => Array>; @@ -72,36 +77,45 @@ const StyledEuiPanel = euiStyled(EuiPanel)` export const AlertCountByRuleByStatus = React.memo( ({ - entityFilter, - identityFields, - signalIndexName, additionalFilters, + signalIndexName, + identityFields, + entityFilter, + entityType, + entityRecord, }: AlertCountByStatusProps) => { - const { field, value, entityType } = entityFilter; - + const entityTypeCacheKey = entityType ?? 'generic'; + const entityStoreV2Enabled = useUiSetting(FF_ENABLE_ENTITY_STORE_V2, false); + const euidApi = useEntityStoreEuidApi(); const entityIdentifiersResolved = useMemo( () => resolveEntityIdentifiers(identityFields, entityFilter), [identityFields, entityFilter] ); - const entityFiltersForTimeline: Filter[] = useMemo(() => { - if (entityIdentifiersResolved != null && Object.keys(entityIdentifiersResolved).length > 0) { - return Object.entries(entityIdentifiersResolved).map(([entityField, entityValue]) => ({ - field: entityField, - value: entityValue, - })); + const euidEntityKqlFilter = useMemo((): string => { + let kqlFilter: string | null | undefined = ''; + if (!entityStoreV2Enabled || !euidApi?.euid || !entityRecord || !entityType) { + kqlFilter = entityIdentifiersResolved + ? Object.entries(entityIdentifiersResolved) + .map(([field, value]) => `${field}: "${value}"`) + .join(' AND ') + : null; + } else { + kqlFilter = euidApi.euid.kql.getEuidFilterBasedOnDocument( + entityType as EntityType, + entityRecord + ); } - return [{ field, value }]; - }, [entityIdentifiersResolved, field, value]); + return kqlFilter && kqlFilter.length > 0 ? kqlFilter : ''; + }, [euidApi?.euid, entityType, entityRecord, entityIdentifiersResolved, entityStoreV2Enabled]); - const queryId = `${ALERT_COUNT_BY_RULE_BY_STATUS}-by-${field}`; + const queryId = `${ALERT_COUNT_BY_RULE_BY_STATUS}-by-${entityType}`; const { toggleStatus, setToggleStatus } = useQueryToggle(queryId); - - const { openTimelineWithFilters } = useNavigateToTimeline(); + const { investigateInTimeline } = useInvestigateInTimeline(); const [selectedStatusesByField, setSelectedStatusesByField] = useLocalStorage({ defaultValue: { - [field]: ['open'], + [entityTypeCacheKey]: DEFAULT_STATUSES, }, key: LOCAL_STORAGE_KEY, isInvalidDefault: (valueFromStorage) => { @@ -111,40 +125,44 @@ export const AlertCountByRuleByStatus = React.memo( const columns = useMemo(() => { return getTableColumns((ruleName: string) => { - const timelineFilters: Filter[][] = []; - - for (const status of selectedStatusesByField[field]) { - timelineFilters.push([ - ...entityFiltersForTimeline, - { field: SIGNAL_RULE_NAME_FIELD_NAME, value: ruleName }, - { - field: SIGNAL_STATUS_FIELD_NAME, - value: status, - }, - ]); + if (!euidEntityKqlFilter || euidEntityKqlFilter.length === 0) { + return; } - openTimelineWithFilters(timelineFilters); + + const timelineFilters: string[] = []; + + for (const status of selectedStatusesByField[entityTypeCacheKey] || DEFAULT_STATUSES) { + timelineFilters.push( + `${euidEntityKqlFilter} AND ${SIGNAL_RULE_NAME_FIELD_NAME}: "${ruleName}" AND ${SIGNAL_STATUS_FIELD_NAME}: "${status}"` + ); + } + investigateInTimeline({ + keepDataView: true, + query: { + language: 'kuery', + query: timelineFilters.map((filter) => `(${filter})`).join(' OR '), + }, + }); }); - }, [entityFiltersForTimeline, field, openTimelineWithFilters, selectedStatusesByField]); + }, [entityTypeCacheKey, euidEntityKqlFilter, investigateInTimeline, selectedStatusesByField]); const updateSelection = useCallback( (selection: Status[]) => { setSelectedStatusesByField({ ...selectedStatusesByField, - [field]: selection, + [entityTypeCacheKey]: selection, }); }, - [field, selectedStatusesByField, setSelectedStatusesByField] + [entityTypeCacheKey, selectedStatusesByField, setSelectedStatusesByField] ); const { items, isLoading, updatedAt } = useAlertCountByRuleByStatus({ additionalFilters, - identityFields: entityIdentifiersResolved, - field, - value, + identityFields: entityIdentifiersResolved ?? identityFields ?? {}, entityType, + entityRecord, queryId, - statuses: selectedStatusesByField[field] as Status[], + statuses: (selectedStatusesByField[entityTypeCacheKey] || DEFAULT_STATUSES) as Status[], skip: !toggleStatus, signalIndexName, }); @@ -164,7 +182,7 @@ export const AlertCountByRuleByStatus = React.memo( updateSelection(selectedItems as Status[]) } diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/components/alert_count_by_status/mock_data.ts b/x-pack/solutions/security/plugins/security_solution/public/common/components/alert_count_by_status/mock_data.ts index a0320edf2842b..d21a3f32b3e55 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/common/components/alert_count_by_status/mock_data.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/common/components/alert_count_by_status/mock_data.ts @@ -165,8 +165,7 @@ export const mockQuery = () => ({ from: '2020-07-07T08:20:18.966Z', to: '2020-07-08T08:20:18.966Z', statuses: ['open'], - field: 'test_field', - value: 'test_value', + entityFilters: [], }), indexName: 'signalIndexName', skip: false, diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/components/alert_count_by_status/use_alert_count_by_rule_by_status.test.ts b/x-pack/solutions/security/plugins/security_solution/public/common/components/alert_count_by_status/use_alert_count_by_rule_by_status.test.ts index b8207ac95d606..8d778e3a6352f 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/common/components/alert_count_by_status/use_alert_count_by_rule_by_status.test.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/common/components/alert_count_by_status/use_alert_count_by_rule_by_status.test.ts @@ -13,6 +13,7 @@ import { buildRuleAlertsByEntityQuery, useAlertCountByRuleByStatus, } from './use_alert_count_by_rule_by_status'; +import { buildEntityIdentifierTermFilters } from '../../../overview/components/detection_response/alerts_by_status/use_alerts_by_status'; const dateNow = new Date('2022-04-15T12:00:00.000Z').valueOf(); const mockDateNow = jest.fn().mockReturnValue(dateNow); @@ -50,6 +51,11 @@ jest.mock('../../lib/kibana', () => ({ useUiSetting: jest.fn(() => false), })); +jest.mock('@kbn/entity-store/public', () => ({ + FF_ENABLE_ENTITY_STORE_V2: 'securitySolution:entityStoreEnableV2', + useEntityStoreEuidApi: jest.fn(() => undefined), +})); + const from = '2020-07-07T08:20:18.966Z'; const to = '2020-07-08T08:20:18.966Z'; @@ -72,8 +78,6 @@ const renderUseAlertCountByRuleByStatus = ( renderHook(() => useAlertCountByRuleByStatus({ skip: false, - field: 'test_field', - value: 'test_value', statuses: ['open'], queryId: 'queryId', signalIndexName: 'signalIndexName', @@ -156,9 +160,9 @@ describe('useAlertCountByRuleByStatus', () => { from, to, statuses: ['open'], - field: 'test_field', - value: 'test_value', - identityFields: { 'host.id': 'host-uuid', 'host.name': 'hostname' }, + entityFilters: [ + buildEntityIdentifierTermFilters({ 'host.id': 'host-uuid', 'host.name': 'hostname' }), + ], }), }) ); diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/components/alert_count_by_status/use_alert_count_by_rule_by_status.ts b/x-pack/solutions/security/plugins/security_solution/public/common/components/alert_count_by_status/use_alert_count_by_rule_by_status.ts index 605630830a29f..9261faca11bb7 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/common/components/alert_count_by_status/use_alert_count_by_rule_by_status.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/common/components/alert_count_by_status/use_alert_count_by_rule_by_status.ts @@ -9,13 +9,16 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { FF_ENABLE_ENTITY_STORE_V2, useEntityStoreEuidApi } from '@kbn/entity-store/public'; +import type { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/types'; +import { buildEntityIdentifierTermFilters } from '../../../overview/components/detection_response/alerts_by_status/use_alerts_by_status'; import { firstNonNullValue } from '../../../../common/endpoint/models/ecs_safety_helpers'; import { EntityType } from '../../../../common/entity_analytics/types'; import type { Status } from '../../../../common/api/detection_engine'; import type { GenericBuckets } from '../../../../common/search_strategy'; -import type { ESBoolQuery, ESQuery, ESTermQuery } from '../../../../common/typed_json'; +import type { ESBoolQuery, ESQuery } from '../../../../common/typed_json'; import { ALERTS_QUERY_NAMES } from '../../../detections/containers/detection_engine/alerts/constants'; import { useQueryAlerts } from '../../../detections/containers/detection_engine/alerts/use_query'; +import type { EntityStoreRecord } from '../../../flyout/entity_details/shared/hooks/use_entity_from_store'; import { useEntityFromStore } from '../../../flyout/entity_details/shared/hooks/use_entity_from_store'; import { useGlobalTime } from '../../containers/use_global_time'; import { useUiSetting } from '../../lib/kibana'; @@ -49,9 +52,8 @@ export interface UseAlertCountByRuleByStatusProps { * When empty or omitted, {@link field} / {@link value} are used as a single legacy term filter. */ identityFields?: Record | null; - field: string; - value: string; entityType?: string; + entityRecord?: EntityStoreRecord | null; queryId: string; statuses: Status[]; skip?: boolean; @@ -68,13 +70,12 @@ const ALERTS_BY_RULE_AGG = 'alertsByRuleAggregation'; export const useAlertCountByRuleByStatus: UseAlertCountByRuleByStatus = ({ additionalFilters, identityFields, - field, - value, entityType, queryId, statuses, skip = false, signalIndexName, + entityRecord: entityRecordInput, }) => { const [updatedAt, setUpdatedAt] = useState(Date.now()); const [items, setItems] = useState([]); @@ -88,8 +89,12 @@ export const useAlertCountByRuleByStatus: UseAlertCountByRuleByStatus = ({ : 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, @@ -100,20 +105,30 @@ export const useAlertCountByRuleByStatus: UseAlertCountByRuleByStatus = ({ const { entityRecord, isLoading: entityFromStoreLoading } = entityFromStore; const euidApi = useEntityStoreEuidApi(); - const identityFieldsForQuery = useMemo( - () => - entityStoreV2Enabled - ? euidApi?.euid?.getEntityIdentifiersFromDocument( - storeEntityType ?? 'generic', - entityRecord - ) - : identityFieldsStable, - [entityStoreV2Enabled, euidApi?.euid, storeEntityType, entityRecord, identityFieldsStable] - ); + const entityFilters = useMemo(() => { + 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)] + : []; + }, [ + entityStoreV2Enabled, + euidApi?.euid, + entityRecord, + entityRecordInput, + identityFieldsStable, + storeEntityType, + ]); const skipAlertsQuery = - skip || - (shouldResolveEntityIdFromStore && (entityFromStoreLoading || identityFieldsForQuery == null)); + skip || (shouldResolveEntityIdFromStore && (entityFromStoreLoading || !entityFilters.length)); const isResolvingEntityId = shouldResolveEntityIdFromStore && entityFromStoreLoading; @@ -129,10 +144,8 @@ export const useAlertCountByRuleByStatus: UseAlertCountByRuleByStatus = ({ additionalFilters, from, to, - field, - value, statuses, - identityFields: identityFieldsForQuery ?? identityFieldsStable, + entityFilters: entityFilters ?? [], }), skip: skipAlertsQuery, queryName: ALERTS_QUERY_NAMES.ALERTS_COUNT_BY_STATUS, @@ -145,23 +158,11 @@ export const useAlertCountByRuleByStatus: UseAlertCountByRuleByStatus = ({ additionalFilters, from, to, - field, - value, statuses, - identityFields: identityFieldsForQuery ?? identityFieldsStable, + entityFilters: entityFilters ?? [], }) ); - }, [ - setAlertsQuery, - from, - to, - field, - value, - statuses, - additionalFilters, - identityFieldsForQuery, - identityFieldsStable, - ]); + }, [setAlertsQuery, from, to, statuses, additionalFilters, identityFieldsStable, entityFilters]); useEffect(() => { if (!data) { @@ -199,35 +200,16 @@ export const buildRuleAlertsByEntityQuery = ({ additionalFilters = [], from, to, - field, - value, statuses, - identityFields, + entityFilters = [], }: { additionalFilters?: ESBoolQuery[]; from: string; to: string; statuses: string[]; - field: string; - value: string; - identityFields?: Record; + entityFilters?: QueryDslQueryContainer[]; }) => { - const entityTermFilters: ESTermQuery[] = - identityFields != null && Object.keys(identityFields).length > 0 - ? Object.entries(identityFields).map(([entityField, entityValue]) => ({ - term: { - [entityField]: entityValue, - }, - })) - : [ - { - term: { - [field]: value, - }, - }, - ]; - - const filterClauses: Array = [ + const filterClauses: Array = [ ...additionalFilters, { range: { @@ -246,7 +228,7 @@ export const buildRuleAlertsByEntityQuery = ({ }, ] : []), - ...entityTermFilters, + ...entityFilters, ]; return { diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/components/events_tab/events_query_tab_body.tsx b/x-pack/solutions/security/plugins/security_solution/public/common/components/events_tab/events_query_tab_body.tsx index 7f8bf0184ebe3..15465b697f927 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/common/components/events_tab/events_query_tab_body.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/common/components/events_tab/events_query_tab_body.tsx @@ -58,11 +58,6 @@ export type EventsQueryTabBodyComponentProps = Omit = deleteQuery, endDate, filterQuery, - histogramFilterQuery, startDate, tableId, }) => { @@ -177,8 +171,6 @@ const EventsQueryTabBodyComponent: React.FC = [additionalFilters, showExternalAlerts] ); - const matrixHistogramFilterQuery = histogramFilterQuery ?? filterQuery; - const addBulkToTimelineActions = useAddBulkToTimelineAction({ localFilters: composedPageFilters, tableId, @@ -205,7 +197,8 @@ const EventsQueryTabBodyComponent: React.FC = id={ALERTS_EVENTS_HISTOGRAM_ID} startDate={startDate} endDate={endDate} - filterQuery={matrixHistogramFilterQuery} + filterQuery={filterQuery} + applyPageAndTabsFilters={false} {...(showExternalAlerts ? alertsHistogramConfig : eventsHistogramConfig)} subtitle={getHistogramSubtitle} sourcererScopeId={newDataViewPickerEnabled ? PageScope.explore : PageScope.default} diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/components/matrix_histogram/index.tsx b/x-pack/solutions/security/plugins/security_solution/public/common/components/matrix_histogram/index.tsx index ab029a09c780a..95a783f924920 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/common/components/matrix_histogram/index.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/common/components/matrix_histogram/index.tsx @@ -36,6 +36,7 @@ export type MatrixHistogramComponentProps = MatrixHistogramQueryProps & sourcererScopeId?: PageScope; hideQueryToggle?: boolean; applyGlobalQueriesAndFilters?: boolean; + applyPageAndTabsFilters?: boolean; /** * Additional drop-list of index patterns layered on top of the chart's * allowlist as a negated `_index` filter (CPS-expanded). Forwarded to the @@ -77,6 +78,7 @@ export const MatrixHistogramComponent: React.FC = titleSize, hideQueryToggle = false, applyGlobalQueriesAndFilters = true, + applyPageAndTabsFilters = true, excludedPatterns, }) => { const visualizationId = `${id}-embeddable`; @@ -204,6 +206,7 @@ export const MatrixHistogramComponent: React.FC = = ({ userName, skip, type, + entityRecord, identityFields, }) => { const dispatch = useDispatch(); @@ -123,6 +124,7 @@ const AnomaliesUserTableComponent: React.FC = ({ entityType: 'user', isScopedToEntity, identityFields, + entityRecord: isScopedToEntity && entityRecord ? entityRecord : undefined, fallbackDisplayName: userName, }), // eslint-disable-next-line react-hooks/exhaustive-deps @@ -137,6 +139,7 @@ const AnomaliesUserTableComponent: React.FC = ({ type, userName, identityFields, + entityRecord: isScopedToEntity && entityRecord ? entityRecord : undefined, euid, }), filterQuery: anomaliesInfluencersFilterQuery, diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/components/ml/types.ts b/x-pack/solutions/security/plugins/security_solution/public/common/components/ml/types.ts index 151313c7d4eba..37f1fae21f003 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/common/components/ml/types.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/common/components/ml/types.ts @@ -6,6 +6,7 @@ */ import type { MlInfluencer } from '@kbn/ml-anomaly-utils'; +import type { EntityStoreRecord } from '../../../flyout/entity_details/shared/hooks/use_entity_from_store'; import type { FlowTarget } from '../../../../common/search_strategy'; import type { HostsType } from '../../../explore/hosts/store/model'; @@ -106,6 +107,7 @@ export type AnomaliesNetworkTableProps = AnomaliesTableCommonProps & { export type AnomaliesUserTableProps = AnomaliesTableCommonProps & { userName?: string; type: UsersType; + entityRecord?: EntityStoreRecord | null; /** Entity Store / EUID identity fields; drives ML exists filter and anomaly row matching. */ identityFields?: Record; }; diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/containers/anomalies/anomalies_query_tab_body/index.tsx b/x-pack/solutions/security/plugins/security_solution/public/common/containers/anomalies/anomalies_query_tab_body/index.tsx index be1ced64c097a..f058c94e83566 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/common/containers/anomalies/anomalies_query_tab_body/index.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/common/containers/anomalies/anomalies_query_tab_body/index.tsx @@ -32,6 +32,7 @@ const AnomaliesQueryTabBodyComponent: React.FC = ({ hostName, userName, identityFields, + entityRecord, }) => { const { jobs } = useInstalledSecurityJobs(); const [anomalyScore] = useUiSetting$(DEFAULT_ANOMALY_SCORE); @@ -73,6 +74,7 @@ const AnomaliesQueryTabBodyComponent: React.FC = ({ ip={ip} hostName={hostName} userName={userName} + entityRecord={entityRecord} identityFields={identityFields} /> diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/containers/anomalies/anomalies_query_tab_body/types.ts b/x-pack/solutions/security/plugins/security_solution/public/common/containers/anomalies/anomalies_query_tab_body/types.ts index feb50547a0196..3faa4c83565c5 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/common/containers/anomalies/anomalies_query_tab_body/types.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/common/containers/anomalies/anomalies_query_tab_body/types.ts @@ -5,6 +5,7 @@ * 2.0. */ +import type { EntityStoreRecord } from '../../../../flyout/entity_details/shared/hooks/use_entity_from_store'; import type { ESTermQuery } from '../../../../../common/typed_json'; import type { GlobalTimeArgs } from '../../use_global_time'; import type { HostsType } from '../../../../explore/hosts/store/model'; @@ -15,6 +16,7 @@ import type { UsersType } from '../../../../explore/users/store/model'; interface QueryTabBodyProps { type: HostsType | NetworkType | UsersType; filterQuery?: string | ESTermQuery; + entityRecord?: EntityStoreRecord | null; identityFields?: Record; } diff --git a/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/components/risk_details_tab_body/index.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/components/risk_details_tab_body/index.test.tsx index 5c1a7191e0cf2..1d78ee8adb49b 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/components/risk_details_tab_body/index.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/components/risk_details_tab_body/index.test.tsx @@ -9,10 +9,9 @@ import React from 'react'; import { render } from '@testing-library/react'; import { TestProviders } from '../../../common/mock'; import { useQueryToggle } from '../../../common/containers/query_toggle'; +import { useUiSetting } from '../../../common/lib/kibana'; import { RiskDetailsTabBody } from '.'; import { EntityType } from '../../../../common/search_strategy'; -import { HostsType } from '../../../explore/hosts/store/model'; -import { UsersType } from '../../../explore/users/store/model'; import { useRiskScore } from '../../api/hooks/use_risk_score'; jest.mock('../../api/hooks/use_risk_score'); @@ -22,17 +21,16 @@ jest.mock('../../../common/lib/kibana'); describe.each([EntityType.host, EntityType.user])('Risk Tab Body entityType: %s', (riskEntity) => { const defaultProps = { entityName: 'testEntity', - indexNames: [], setQuery: jest.fn(), - skip: false, startDate: '2019-06-25T04:31:59.345Z', endDate: '2019-06-25T06:31:59.345Z', - type: riskEntity === EntityType.host ? HostsType.page : UsersType.page, riskEntity, }; const mockUseRiskScore = useRiskScore as jest.Mock; const mockUseQueryToggle = useQueryToggle as jest.Mock; + const mockUseUiSetting = useUiSetting as jest.Mock; + beforeEach(() => { jest.clearAllMocks(); @@ -48,6 +46,7 @@ describe.each([EntityType.host, EntityType.user])('Risk Tab Body entityType: %s' hasEngineBeenInstalled: true, }); mockUseQueryToggle.mockReturnValue({ toggleStatus: true, setToggleStatus: jest.fn() }); + mockUseUiSetting.mockReturnValue(false); }); it('calls with correct arguments for each entity', () => { @@ -72,67 +71,25 @@ describe.each([EntityType.host, EntityType.user])('Risk Tab Body entityType: %s' }); }); - it('uses identityScopedFilterQuery when provided', () => { - const scoped = JSON.stringify({ term: { 'host.hostname': { value: 'h1' } } }); - render( - - - - ); - expect(mockUseRiskScore).toBeCalledWith( - expect.objectContaining({ - filterQuery: scoped, - }) - ); - }); - - it('uses identityFields as bool filter when identityScopedFilterQuery is absent', () => { + it('uses entityId as filter when entityStoreV2 is enabled', () => { + mockUseUiSetting.mockReturnValueOnce(true); render( - + ); expect(mockUseRiskScore).toBeCalledWith( expect.objectContaining({ filterQuery: { - bool: { - filter: [ - { - match: { - 'host.name': { query: 'n1', type: 'phrase' }, - }, - }, - { - match: { - 'host.hostname': { query: 'h1', type: 'phrase' }, - }, - }, - ], + terms: { + [`${riskEntity}.name`]: ['entity-123'], }, }, }) ); }); - it("doesn't skip when both toggleStatus are true", () => { - render( - - - - ); - expect(mockUseRiskScore.mock.calls[0][0].skip).toEqual(false); - }); - - it("doesn't skip when at least one toggleStatus is true", () => { - mockUseQueryToggle.mockReturnValueOnce({ toggleStatus: true, setToggleStatus: jest.fn() }); - mockUseQueryToggle.mockReturnValueOnce({ toggleStatus: false, setToggleStatus: jest.fn() }); - + it("doesn't skip when toggleStatus is true", () => { render( @@ -141,7 +98,7 @@ describe.each([EntityType.host, EntityType.user])('Risk Tab Body entityType: %s' expect(mockUseRiskScore.mock.calls[0][0].skip).toEqual(false); }); - it('does skip when both toggleStatus are false', () => { + it('skips when toggleStatus is false', () => { mockUseQueryToggle.mockReturnValue({ toggleStatus: false, setToggleStatus: jest.fn() }); render( diff --git a/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/components/risk_details_tab_body/index.tsx b/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/components/risk_details_tab_body/index.tsx index f5a24d26301be..856a65deed397 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/components/risk_details_tab_body/index.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/components/risk_details_tab_body/index.tsx @@ -8,7 +8,8 @@ import { EuiFlexGroup, EuiFlexItem, EuiPanel } from '@elastic/eui'; import React, { useCallback, useMemo } from 'react'; import styled from '@emotion/styled'; - +import { FF_ENABLE_ENTITY_STORE_V2 } from '@kbn/entity-store/public'; +import { useUiSetting } from '../../../common/lib/kibana'; import { useUpsellingComponent } from '../../../common/hooks/use_upselling'; import { EnableRiskScore } from '../enable_risk_score'; import { useDeepEqualSelector } from '../../../common/hooks/use_selector'; @@ -19,7 +20,6 @@ import { useQueryInspector } from '../../../common/components/page/manage_query' import { TopRiskScoreContributorsAlerts } from '../top_risk_score_contributors_alerts'; import { useQueryToggle } from '../../../common/containers/query_toggle'; import { buildEntityNameFilter, EntityType } from '../../../../common/search_strategy'; -import type { ESQuery } from '../../../../common/typed_json'; import type { UsersComponentsQueryProps } from '../../../explore/users/pages/navigation/types'; import type { HostsComponentsQueryProps } from '../../../explore/hosts/pages/navigation/types'; import { HostRiskScoreQueryId, UserRiskScoreQueryId } from '../../common/utils'; @@ -34,51 +34,14 @@ const StyledEuiFlexGroup = styled(EuiFlexGroup)` type ComponentsQueryProps = HostsComponentsQueryProps | UsersComponentsQueryProps; -const buildFilterQueryFromIdentityFields = ( - identityFields?: Record -): ESQuery | undefined => { - if (identityFields == null) { - return undefined; - } - const clauses: ESQuery[] = Object.entries(identityFields) - .filter(([, fieldValue]) => typeof fieldValue === 'string' && fieldValue.trim() !== '') - .map( - ([fieldKey, fieldValue]) => - ({ - match: { - [fieldKey]: { - query: fieldValue, - type: 'phrase', - }, - }, - } as ESQuery) - ); - if (clauses.length === 0) { - return undefined; - } - if (clauses.length === 1) { - return clauses[0]; - } - return { bool: { filter: clauses } } as ESQuery; -}; - const RiskDetailsTabBodyComponent: React.FC< Pick & { entityName: string; + entityId?: string; riskEntity: EntityType; - identityScopedFilterQuery?: string; - identityFields?: Record; } -> = ({ - entityName, - startDate, - endDate, - setQuery, - deleteQuery, - riskEntity, - identityScopedFilterQuery, - identityFields, -}) => { +> = ({ entityName, startDate, endDate, setQuery, deleteQuery, riskEntity, entityId }) => { + const entityStoreV2Enabled = useUiSetting(FF_ENABLE_ENTITY_STORE_V2, false); const queryId = useMemo( () => riskEntity === EntityType.host @@ -105,15 +68,12 @@ const RiskDetailsTabBodyComponent: React.FC< useQueryToggle(`${queryId} contributors`); const filterQuery = useMemo(() => { - if (identityScopedFilterQuery) { - return identityScopedFilterQuery; - } - const identityFieldsQuery = buildFilterQueryFromIdentityFields(identityFields); - if (identityFieldsQuery !== undefined) { - return identityFieldsQuery; + if (entityStoreV2Enabled && entityId != null) { + return buildEntityNameFilter(riskEntity, [entityId]); } + return entityName ? buildEntityNameFilter(riskEntity, [entityName]) : {}; - }, [entityName, identityFields, identityScopedFilterQuery, riskEntity]); + }, [entityStoreV2Enabled, entityId, entityName, riskEntity]); const { data, loading, refetch, inspect, hasEngineBeenInstalled } = useRiskScore({ filterQuery, diff --git a/x-pack/solutions/security/plugins/security_solution/public/explore/hosts/pages/details/details_tabs.tsx b/x-pack/solutions/security/plugins/security_solution/public/explore/hosts/pages/details/details_tabs.tsx index e9fda8d3d6ed1..b126c66b1c15c 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/explore/hosts/pages/details/details_tabs.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/explore/hosts/pages/details/details_tabs.tsx @@ -55,10 +55,7 @@ export const HostDetailsTabs = React.memo( return ( - + @@ -70,7 +67,6 @@ export const HostDetailsTabs = React.memo( @@ -80,7 +76,7 @@ export const HostDetailsTabs = React.memo( {...tabProps} riskEntity={EntityType.host} entityName={detailName} - identityScopedFilterQuery={hostDetailsIdentityFilterQuery} + entityId={entityId} /> 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 b1d3c1140fab0..995fdc3a6690a 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 @@ -588,10 +588,12 @@ const HostDetailsComponent: React.FC = ({ diff --git a/x-pack/solutions/security/plugins/security_solution/public/explore/users/containers/users/use_all_entity_store_users.test.ts b/x-pack/solutions/security/plugins/security_solution/public/explore/users/containers/users/use_all_entity_store_users.test.ts new file mode 100644 index 0000000000000..5b1344bd022f4 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/explore/users/containers/users/use_all_entity_store_users.test.ts @@ -0,0 +1,124 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { Entity } from '@kbn/entity-store/common'; +import { mapUserEntityRecordToUser } from './use_all_entity_store_users'; + +const sampleUser = { + domain: ['acmecrm.com'], + name: 'guadalupeoconnor', + id: ['S-1-5-21-465498160-1809111990-1636550433-1002'], + email: ['guadalupe.oconnor@acmecrm.com'], +}; + +const sampleRecord: Entity = { + '@timestamp': '2026-05-04T15:21:42.692Z', + entity: { + id: 'user:guadalupe.oconnor@acmecrm.com@active_directory', + name: 'guadalupeoconnor', + namespace: 'active_directory', + source: ['entityanalytics_ad'], + type: 'Identity', + confidence: 'high', + lifecycle: { + first_seen: '2026-05-04T14:34:25.970Z', + last_seen: '2026-05-04T14:34:25.970Z', + }, + risk: { + calculated_score: 99, + calculated_score_norm: 38.1885511495, + calculated_level: 'Low', + }, + }, + user: sampleUser, + asset: {}, +} as unknown as Entity; + +describe('mapUserEntityRecordToUser', () => { + it('maps a full record to a User', () => { + expect(mapUserEntityRecordToUser(sampleRecord)).toEqual({ + name: 'guadalupeoconnor', + lastSeen: '2026-05-04T14:34:25.970Z', + domain: 'acmecrm.com', + risk: 'Low', + criticality: undefined, + entityId: 'user:guadalupe.oconnor@acmecrm.com@active_directory', + identityFields: { + 'user.name': 'guadalupeoconnor', + 'user.domain': 'acmecrm.com', + }, + }); + }); + + it('returns null for a non-user entity record', () => { + const hostRecord: Entity = { + entity: { id: 'host:some-host', name: 'some-host' }, + host: { name: 'some-host' }, + }; + expect(mapUserEntityRecordToUser(hostRecord)).toBeNull(); + }); + + it('returns null when user.name is absent', () => { + const record: Entity = { + ...sampleRecord, + user: { ...sampleUser, name: '' }, + }; + expect(mapUserEntityRecordToUser(record)).toBeNull(); + }); + + it('handles domain returned as a string instead of an array', () => { + const record = { + ...sampleRecord, + user: { ...sampleUser, domain: 'acmecrm.com' as unknown as string[] }, + }; + expect(mapUserEntityRecordToUser(record)).toMatchObject({ + domain: 'acmecrm.com', + identityFields: { 'user.name': 'guadalupeoconnor', 'user.domain': 'acmecrm.com' }, + }); + }); + + it('uses empty string for domain and omits it from identityFields when domain is absent', () => { + const record = { + ...sampleRecord, + user: { name: 'guadalupeoconnor' }, + }; + expect(mapUserEntityRecordToUser(record)).toMatchObject({ + domain: '', + identityFields: { 'user.name': 'guadalupeoconnor' }, + }); + expect(mapUserEntityRecordToUser(record)?.identityFields).not.toHaveProperty('user.domain'); + }); + + it('uses empty string for lastSeen when lifecycle.last_seen is absent', () => { + const record = { + ...sampleRecord, + entity: { ...sampleRecord.entity, lifecycle: undefined }, + }; + expect(mapUserEntityRecordToUser(record)).toMatchObject({ lastSeen: '' }); + }); + + it('maps user.risk.calculated_level to risk', () => { + const record = { + ...sampleRecord, + entity: { + ...sampleRecord.entity, + risk: { calculated_level: 'High', calculated_score: 75, calculated_score_norm: 75 }, + }, + }; + expect(mapUserEntityRecordToUser(record as Entity)).toMatchObject({ risk: 'High' }); + }); + + it('maps asset.criticality', () => { + const record = { + ...sampleRecord, + asset: { criticality: 'very_important' }, + }; + expect(mapUserEntityRecordToUser(record as Entity)).toMatchObject({ + criticality: 'very_important', + }); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/public/explore/users/containers/users/use_all_entity_store_users.ts b/x-pack/solutions/security/plugins/security_solution/public/explore/users/containers/users/use_all_entity_store_users.ts index 8cc0d62830ea8..71932c64982a4 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/explore/users/containers/users/use_all_entity_store_users.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/explore/users/containers/users/use_all_entity_store_users.ts @@ -10,7 +10,7 @@ import { noop } from 'lodash/fp'; import { useQuery } from '@kbn/react-query'; import type { IHttpFetchError } from '@kbn/core/public'; -import type { UserEntity } from '../../../../../common/api/entity_analytics/entity_store/entities/common.gen'; +import type { Entity } from '@kbn/entity-store/common'; import type { ListEntitiesResponse } from '../../../../../common/api/entity_analytics/entity_store/entities/list_entities.gen'; import type { User } from '../../../../../common/search_strategy/security_solution/users/all'; import { UsersFields } from '../../../../../common/search_strategy/security_solution/users/common'; @@ -30,37 +30,37 @@ import * as i18n from './translations'; const ENTITY_STORE_USERS_LIST_QUERY_KEY = 'ENTITY_STORE_USERS_LIST'; -const isUserEntityRecord = ( - record: ListEntitiesResponse['records'][number] -): record is UserEntity => 'user' in record && record.user != null; +export const mapUserEntityRecordToUser = (record: Entity): User | null => { + if ('user' in record && record.user != null) { + const userName = record.user?.name; + if (userName == null || userName === '') { + return null; + } -const mapUserEntityRecordToUser = (record: UserEntity): User | null => { - const userName = record.user?.name; - if (userName == null || userName === '') { - return null; - } + const lastSeenIso = record.entity?.lifecycle?.last_seen; + const domainValues = record.user?.domain as string[] | string | undefined; + const domain = Array.isArray(domainValues) ? domainValues?.[0] ?? '' : domainValues ?? ''; + const riskLevel = record.entity?.risk?.calculated_level as RiskSeverity | undefined; - const lastSeenIso = record.entity.lifecycle?.last_seen; - const domainValues = record.user?.domain; - const domain = domainValues != null && domainValues.length > 0 ? domainValues[0] : ''; - const riskLevel = record.user?.risk?.calculated_level as RiskSeverity | undefined; + const identityFields: Record = { + 'user.name': userName, + }; + if (domain !== '') { + identityFields['user.domain'] = domain; + } - const identityFields: Record = { - 'user.name': userName, - }; - if (domain !== '') { - identityFields['user.domain'] = domain; + return { + name: userName, + lastSeen: lastSeenIso ?? '', + domain, + risk: riskLevel, + criticality: record.asset?.criticality, + entityId: record.entity?.id, + identityFields, + }; + } else { + return null; } - - return { - name: userName, - lastSeen: lastSeenIso ?? '', - domain, - risk: riskLevel, - criticality: record.asset?.criticality, - entityId: record.entity.id, - identityFields, - }; }; const parseFilterClauses = (filterQuery?: ESTermQuery | string): object[] => { @@ -181,10 +181,7 @@ export const useAllEntityStoreUsers = ( return []; } return data.records.flatMap((record) => { - if (!isUserEntityRecord(record)) { - return []; - } - const user = mapUserEntityRecordToUser(record); + const user = mapUserEntityRecordToUser(record as Entity); return user != null ? [user] : []; }); }, [data?.records]); diff --git a/x-pack/solutions/security/plugins/security_solution/public/explore/users/pages/details/details_tabs.tsx b/x-pack/solutions/security/plugins/security_solution/public/explore/users/pages/details/details_tabs.tsx index 9b23d499d7ce6..c0d76943092fa 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/explore/users/pages/details/details_tabs.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/explore/users/pages/details/details_tabs.tsx @@ -30,9 +30,9 @@ export const UsersDetailsTabs = React.memo( type, detailName, userDetailFilter, - userDetailsIdentityFilterQuery, identityFields, entityId, + entityRecord, }) => { const tabProps = { deleteQuery, @@ -45,16 +45,14 @@ export const UsersDetailsTabs = React.memo( indexNames, identityFields, entityId, + entityRecord, + userName: detailName, }; return ( - + @@ -62,7 +60,6 @@ export const UsersDetailsTabs = React.memo( @@ -72,7 +69,7 @@ export const UsersDetailsTabs = React.memo( {...tabProps} riskEntity={EntityType.user} entityName={detailName} - identityScopedFilterQuery={userDetailsIdentityFilterQuery} + entityId={entityId} /> diff --git a/x-pack/solutions/security/plugins/security_solution/public/explore/users/pages/details/helpers.ts b/x-pack/solutions/security/plugins/security_solution/public/explore/users/pages/details/helpers.ts index 90a271412e408..9155a6da5c46e 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/explore/users/pages/details/helpers.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/explore/users/pages/details/helpers.ts @@ -6,6 +6,12 @@ */ import type { Filter } from '@kbn/es-query'; +import type { QueryDslQueryContainer } from '@kbn/data-views-plugin/common/types'; + +export const euidDslFilterToPageFilters = (dsl: QueryDslQueryContainer | undefined): Filter[] => { + if (dsl == null) return []; + return [{ meta: { alias: null, negate: false, disabled: false }, query: dsl }]; +}; export { userNameExistsFilter } from '../../../../common/components/visualization_actions/utils'; diff --git a/x-pack/solutions/security/plugins/security_solution/public/explore/users/pages/details/index.tsx b/x-pack/solutions/security/plugins/security_solution/public/explore/users/pages/details/index.tsx index 52efe74d9c58d..e5ef0f3c05678 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/explore/users/pages/details/index.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/explore/users/pages/details/index.tsx @@ -19,7 +19,6 @@ import { noop } from 'lodash/fp'; import React, { useCallback, useEffect, useMemo } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { useLocation } from 'react-router-dom'; -import type { Filter } from '@kbn/es-query'; import { buildEsQuery } from '@kbn/es-query'; import { getEsQueryConfig } from '@kbn/data-plugin/common'; import { LastEventIndexKey } from '@kbn/timelines-plugin/common'; @@ -64,11 +63,7 @@ import { UsersDetailsTabs } from './details_tabs'; import { navTabsUsersDetails } from './nav_tabs'; import type { UsersDetailsProps } from './types'; import { UsersType } from '../../store/model'; -import { getUsersDetailsPageFilters, getIdentityFieldsPageFilters } from './helpers'; -import { - identityFieldsHaveUsableValues, - mergeLegacyIdentityWhenStoreEntityMissing, -} from '../../../../flyout/document_details/shared/utils'; +import { getUsersDetailsPageFilters, euidDslFilterToPageFilters } from './helpers'; import { useGlobalFullScreen } from '../../../../common/containers/use_full_screen'; import { Display } from '../../../hosts/pages/display'; import { useDeepEqualSelector } from '../../../../common/hooks/use_selector'; @@ -187,11 +182,6 @@ const UsersDetailsComponent: React.FC = ({ [identityFields, detailName] ); - const usersDetailsPageFilters: Filter[] = useMemo( - () => getUsersDetailsPageFilters(detailName), - [detailName] - ); - const narrowDateRange = useCallback( (score, interval) => { const fromTo = scoreIntervalToDateTime(score, interval); @@ -220,6 +210,7 @@ const UsersDetailsComponent: React.FC = ({ const indicesExist = newDataViewPickerEnabled ? !!experimentalDataView.matchedIndices?.length : oldIndicesExist; + const selectedPatterns = newDataViewPickerEnabled ? experimentalSelectedPatterns : oldSelectedPatterns; @@ -246,24 +237,24 @@ const UsersDetailsComponent: React.FC = ({ entityStoreV2Enabled && !entityFromStoreResult.isLoading && !entityFromStoreResult.entityRecord; const usersDetailsEventsPageFilters = useMemo(() => { + // Builds Kibana (not ES DSL) filters + // Fallback to user.name filter if EUID-based filter cannot be built for any reason + const legacyFilters = getUsersDetailsPageFilters(detailName); if (!entityStoreV2Enabled || noEntityInStore) { - return getUsersDetailsPageFilters(detailName); - } - const fromStore = - euidApi?.euid?.getEntityIdentifiersFromDocument('user', entityFromStoreResult.entityRecord) ?? - {}; - const merged = mergeLegacyIdentityWhenStoreEntityMissing(fromStore, resolvedIdentityFields); - if (identityFieldsHaveUsableValues(merged)) { - return getIdentityFieldsPageFilters(merged); + return legacyFilters; } - return getUsersDetailsPageFilters(detailName); + + const entityDslFilter = euidApi?.euid?.dsl.getEuidFilterBasedOnDocument( + EntityType.user, + entityFromStoreResult.entityRecord + ); + return entityDslFilter ? euidDslFilterToPageFilters(entityDslFilter) : legacyFilters; }, [ detailName, entityFromStoreResult.entityRecord, entityStoreV2Enabled, noEntityInStore, euidApi?.euid, - resolvedIdentityFields, ]); const oldSecurityDefaultPatterns = @@ -273,6 +264,7 @@ const UsersDetailsComponent: React.FC = ({ ? experimentalSecurityDefaultIndexPatterns : oldSecurityDefaultPatterns; + // observedUser.entityRecord returns entityStoreFromResult.entityRecord when entityStoreV2Enabled const observedUser = useObservedUser( detailName, PageScope.explore, @@ -316,7 +308,7 @@ const UsersDetailsComponent: React.FC = ({ [entityId, entityStoreV2Enabled, observedUser.entityRecord?.entity?.id] ); - const [rawFilteredQuery, kqlError] = useMemo(() => { + const [rawFilteredQueryForUserDetailsIdentity, kqlError] = useMemo(() => { try { return [ buildEsQuery( @@ -324,38 +316,13 @@ const UsersDetailsComponent: React.FC = ({ ? experimentalDataView : dataViewSpecToViewBase(oldSourcererDataViewSpec), [query], - [...usersDetailsPageFilters, ...globalFilters], + [...usersDetailsEventsPageFilters, ...globalFilters], getEsQueryConfig(uiSettings) ), ]; } catch (e) { return [undefined, e]; } - }, [ - newDataViewPickerEnabled, - experimentalDataView, - oldSourcererDataViewSpec, - query, - usersDetailsPageFilters, - globalFilters, - uiSettings, - ]); - - const [rawFilteredQueryForUserDetailsIdentity] = useMemo(() => { - try { - return [ - buildEsQuery( - newDataViewPickerEnabled - ? experimentalDataView - : dataViewSpecToViewBase(oldSourcererDataViewSpec), - [query], - [...usersDetailsEventsPageFilters, ...globalFilters], - getEsQueryConfig(uiSettings) - ), - ]; - } catch { - return [undefined]; - } }, [ newDataViewPickerEnabled, experimentalDataView, @@ -374,10 +341,9 @@ const UsersDetailsComponent: React.FC = ({ [rawFilteredQueryForUserDetailsIdentity] ); - const stringifiedAdditionalFilters = JSON.stringify(rawFilteredQuery); useInvalidFilterQuery({ id: USERS_DETAILS_OVERVIEW_QUERY_ID, - filterQuery: stringifiedAdditionalFilters, + filterQuery: stringifiedUserDetailsIdentityFilterQuery, kqlError, query, startDate: from, @@ -391,17 +357,9 @@ const UsersDetailsComponent: React.FC = ({ const { hasAlertsRead, hasIndexRead } = useAlertsPrivileges(); const canReadAlerts = hasAlertsRead && hasIndexRead; - const entityFilter = useMemo( - () => ({ - field: ES_USER_FIELD, - value: detailName, - }), - [detailName] - ); - const additionalFilters = useMemo( - () => (rawFilteredQuery ? [rawFilteredQuery] : []), - [rawFilteredQuery] + () => (rawFilteredQueryForUserDetailsIdentity ? [rawFilteredQueryForUserDetailsIdentity] : []), + [rawFilteredQueryForUserDetailsIdentity] ); const entity = useMemo( @@ -579,16 +537,18 @@ const UsersDetailsComponent: React.FC = ({ @@ -613,16 +573,18 @@ const UsersDetailsComponent: React.FC = ({ isInitializing={isInitializing} deleteQuery={deleteQuery} userDetailFilter={usersDetailsEventsPageFilters} - userDetailsIdentityFilterQuery={stringifiedUserDetailsIdentityFilterQuery} to={to} from={from} detailName={detailName} type={UsersType.details} setQuery={setQuery} - filterQuery={stringifiedAdditionalFilters} + entityRecord={ + entityStoreV2Enabled ? observedUser.entityRecord ?? undefined : undefined + } + filterQuery={stringifiedUserDetailsIdentityFilterQuery} usersDetailsPagePath={usersDetailsPagePath} identityFields={resolvedIdentityFields} - entityId={entityId} + entityId={displayEntityId} /> diff --git a/x-pack/solutions/security/plugins/security_solution/public/explore/users/pages/details/types.ts b/x-pack/solutions/security/plugins/security_solution/public/explore/users/pages/details/types.ts index 102f38046de8e..b093edeb85e90 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/explore/users/pages/details/types.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/explore/users/pages/details/types.ts @@ -9,6 +9,7 @@ import type { ActionCreator } from 'typescript-fsa'; import type { Filter, Query } from '@kbn/es-query'; +import type { EntityStoreRecord } from '../../../../flyout/entity_details/shared/hooks/use_entity_from_store'; import type { UsersQueryProps } from '../types'; import type { NavTab } from '../../../../common/components/navigation/types'; @@ -51,15 +52,17 @@ export type UsersDetailsNavTab = Partial>; export type UsersDetailsTabsProps = UserBodyComponentDispatchProps & UsersQueryProps & { indexNames: string[]; + /** + * Filter for user identity (either generated from euidApi or fallback user.name filter) + */ userDetailFilter: Filter[]; /** - * Serialized ES query built with {@link UsersDetailsTabsProps.userDetailFilter} (identity fields - * when Entity Store v2). Used for the Events histogram, Authentications tab, and Risk tab; other - * tabs use {@link UsersDetailsTabsProps.filterQuery} only. + * Stringified filter query that includes the user identity filter + * (either generated from euidApi or fallback user.name filter) and any global filters applied on the page. */ - userDetailsIdentityFilterQuery?: string; filterQuery?: string; type: usersModel.UsersType; entityId?: string; + entityRecord?: EntityStoreRecord | null; identityFields?: Record; }; diff --git a/x-pack/solutions/security/plugins/security_solution/public/explore/users/pages/navigation/authentications_query_tab_body.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/explore/users/pages/navigation/authentications_query_tab_body.test.tsx index c627120bdf634..1832817d0fb38 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/explore/users/pages/navigation/authentications_query_tab_body.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/explore/users/pages/navigation/authentications_query_tab_body.test.tsx @@ -12,6 +12,7 @@ import { useQueryToggle } from '../../../../common/containers/query_toggle'; import { AuthenticationsQueryTabBody } from './authentications_query_tab_body'; import { UsersType } from '../../store/model'; import { useAuthentications } from '../../../containers/authentications'; +import { MatrixHistogram } from '../../../../common/components/matrix_histogram'; jest.mock('../../../containers/authentications'); jest.mock('../../../../common/containers/query_toggle'); @@ -19,6 +20,9 @@ jest.mock('../../../../common/lib/kibana'); jest.mock('../../../../common/components/visualization_actions/actions'); jest.mock('../../../../common/components/visualization_actions/lens_embeddable'); +jest.mock('../../../../common/components/matrix_histogram', () => ({ + MatrixHistogram: jest.fn(() => null), +})); describe('Authentications query tab body', () => { const mockUseAuthentications = useAuthentications as jest.Mock; @@ -68,4 +72,76 @@ describe('Authentications query tab body', () => { ); expect(mockUseAuthentications.mock.calls[0][0].skip).toEqual(true); }); + + describe('histogram filterQuery', () => { + const mockMatrixHistogram = MatrixHistogram as unknown as jest.Mock; + + const getHistogramFilterQuery = () => + mockMatrixHistogram.mock.calls[0][0].filterQuery as string; + + it('adds user.name exists clause when no filterQuery and no userName', () => { + render( + + + + ); + expect(JSON.parse(getHistogramFilterQuery())).toEqual({ + exists: { field: 'user.name' }, + }); + }); + + it('adds host.name exists clause when no filterQuery and userName is set', () => { + render( + + + + ); + expect(JSON.parse(getHistogramFilterQuery())).toEqual({ + exists: { field: 'host.name' }, + }); + }); + + it('combines filterQuery with user.name exists clause when no userName', () => { + const baseQuery = { bool: { must: [], filter: [], should: [], must_not: [] } }; + render( + + + + ); + expect(JSON.parse(getHistogramFilterQuery())).toEqual({ + bool: { filter: [baseQuery, { exists: { field: 'user.name' } }] }, + }); + }); + + it('combines filterQuery with host.name exists clause when userName is set', () => { + const baseQuery = { bool: { must: [], filter: [], should: [], must_not: [] } }; + render( + + + + ); + expect(JSON.parse(getHistogramFilterQuery())).toEqual({ + bool: { filter: [baseQuery, { exists: { field: 'host.name' } }] }, + }); + }); + + it('combines a complex filterQuery with user.name exists clause', () => { + const complexFilterQuery = + '{"bool":{"must":[],"filter":[{"bool":{"filter":[{"term":{"user.email":"tyshawn.dellaquila@acmecrm.com"}},{"bool":{"should":[{"term":{"event.module":"okta"}},{"prefix":{"data_stream.dataset":"okta"}},{"term":{"event.module":"entityanalytics_okta"}},{"prefix":{"data_stream.dataset":"entityanalytics_okta"}}],"minimum_should_match":1}}]}}],"should":[],"must_not":[]}}'; + render( + + + + ); + expect(JSON.parse(getHistogramFilterQuery())).toEqual({ + bool: { + filter: [JSON.parse(complexFilterQuery), { exists: { field: 'user.name' } }], + }, + }); + }); + }); }); diff --git a/x-pack/solutions/security/plugins/security_solution/public/explore/users/pages/navigation/authentications_query_tab_body.tsx b/x-pack/solutions/security/plugins/security_solution/public/explore/users/pages/navigation/authentications_query_tab_body.tsx index b6480871b5111..6f7f05c07ecb7 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/explore/users/pages/navigation/authentications_query_tab_body.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/explore/users/pages/navigation/authentications_query_tab_body.tsx @@ -5,7 +5,8 @@ * 2.0. */ -import React from 'react'; +import React, { useMemo } from 'react'; +import { AuthStackByField } from '../../../../../common/api/search_strategy/users/authentications'; import { PageScope } from '../../../../data_view_manager/constants'; import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features'; import { AuthenticationsUserTable } from '../../../components/authentication/authentications_user_table'; @@ -20,7 +21,6 @@ const HISTOGRAM_QUERY_ID = 'usersAuthenticationsHistogramQuery'; export const AuthenticationsQueryTabBody = ({ endDate, filterQuery, - identityScopedFilterQuery, indexNames, skip, setQuery, @@ -30,22 +30,37 @@ export const AuthenticationsQueryTabBody = ({ userName, }: AuthenticationsUserTableProps) => { const newDataViewPickerEnabled = useIsExperimentalFeatureEnabled('newDataViewPickerEnabled'); - const effectiveFilterQuery = identityScopedFilterQuery ?? filterQuery; + + const histogramFilterQuery = useMemo(() => { + const existsClause = { + exists: { field: userName ? AuthStackByField.hostName : AuthStackByField.userName }, + }; + if (!filterQuery) { + return JSON.stringify(existsClause); + } + try { + const parsed = typeof filterQuery === 'string' ? JSON.parse(filterQuery) : filterQuery; + return JSON.stringify({ bool: { filter: [parsed, existsClause] } }); + } catch { + return JSON.stringify(existsClause); + } + }, [filterQuery, userName]); return ( <> ; entityFilter?: Filter; + entityType?: string; + entityRecord?: EntityStoreRecord | null; signalIndexName: string | null; } @@ -101,14 +107,43 @@ export const AlertsByStatus = ({ signalIndexName, identityFields, entityFilter, + entityType, + entityRecord, }: AlertsByStatusProps) => { const { euiTheme } = useEuiTheme(); + const entityStoreV2Enabled = useUiSetting(FF_ENABLE_ENTITY_STORE_V2, false); + const euidApi = useEntityStoreEuidApi(); const entityIdentifiersResolved = useMemo( () => resolveEntityIdentifiers(identityFields, entityFilter), [identityFields, entityFilter] ); + + const euidEntityKqlFilter = useMemo((): string => { + let kqlFilter: string | null | undefined = ''; + if (!entityStoreV2Enabled || !euidApi?.euid || !entityRecord || !entityType) { + kqlFilter = entityIdentifiersResolved + ? Object.entries(entityIdentifiersResolved) + .map(([field, value]) => `${field}: "${value}"`) + .join(' AND ') + : null; + } else { + kqlFilter = euidApi.euid.kql.getEuidFilterBasedOnDocument( + entityType as EntityType, + entityRecord + ); + } + return kqlFilter && kqlFilter.length > 0 ? `(${kqlFilter}) AND event.kind: "signal"` : ''; + }, [euidApi?.euid, entityType, entityRecord, entityIdentifiersResolved, entityStoreV2Enabled]); + const { toggleStatus, setToggleStatus } = useQueryToggle(DETECTION_RESPONSE_ALERTS_BY_STATUS_ID); - const { openTimelineWithFilters } = useNavigateToTimeline(); + const { investigateInTimeline } = useInvestigateInTimeline(); + + const openTimelineCallback = useCallback(async () => { + investigateInTimeline({ + keepDataView: true, + query: { language: 'kuery', query: euidEntityKqlFilter }, + }); + }, [euidEntityKqlFilter, investigateInTimeline]); const navigateToAlerts = useNavigateToAlertsPageWithFilters(); const { timelinePrivileges: { read: canAccessTimelines }, @@ -122,27 +157,20 @@ export const AlertsByStatus = ({ const isLargerBreakpoint = useIsWithinMinBreakpoint('xl'); const isSmallBreakpoint = useIsWithinMaxBreakpoint('s'); const donutHeight = isSmallBreakpoint || isLargerBreakpoint ? 120 : 90; + const shouldInvestigateInTimeline: boolean = + canAccessTimelines && euidEntityKqlFilter?.length > 0; const detailsButtonOptions = useMemo( () => ({ - name: canAccessTimelines && entityIdentifiersResolved ? INVESTIGATE_IN_TIMELINE : VIEW_ALERTS, - href: canAccessTimelines && entityIdentifiersResolved ? undefined : href, - onClick: - canAccessTimelines && entityIdentifiersResolved - ? async () => { - const entityFilters = Object.entries(entityIdentifiersResolved).map( - ([field, value]) => ({ - field, - value, - }) - ); - await openTimelineWithFilters([ - [...entityFilters, { field: 'event.kind', value: 'signal' }], - ]); - } - : goToAlerts, + name: shouldInvestigateInTimeline ? INVESTIGATE_IN_TIMELINE : VIEW_ALERTS, + href: shouldInvestigateInTimeline ? undefined : href, + onClick: shouldInvestigateInTimeline + ? async () => { + await openTimelineCallback(); + } + : goToAlerts, }), - [entityIdentifiersResolved, href, goToAlerts, openTimelineWithFilters, canAccessTimelines] + [shouldInvestigateInTimeline, href, goToAlerts, openTimelineCallback] ); const { @@ -157,6 +185,8 @@ export const AlertsByStatus = ({ queryId: DETECTION_RESPONSE_ALERTS_BY_STATUS_ID, to, from, + entityType, + entityRecord, }); const legendItems: LegendItem[] = useMemo(() => getChartConfigs(euiTheme), [euiTheme]);