diff --git a/x-pack/platform/plugins/private/translations/translations/de-DE.json b/x-pack/platform/plugins/private/translations/translations/de-DE.json index 9594b27e7b45a..c05e401f06da1 100644 --- a/x-pack/platform/plugins/private/translations/translations/de-DE.json +++ b/x-pack/platform/plugins/private/translations/translations/de-DE.json @@ -35820,7 +35820,6 @@ "xpack.securitySolution.entityAnalytics.entityStore.entitiesList.tableTitle": "Entitäten", "xpack.securitySolution.entityAnalytics.entityStore.entitiesList.tableTooltip": "Es kann einige Minuten dauern, bis Entitätsdaten angezeigt werden", "xpack.securitySolution.entityAnalytics.entityStore.entitySource.filterTitle": "Quelle", - "xpack.securitySolution.entityAnalytics.entityStore.helpers.sourceField.criticalityDescription": "Kritikalität von Assets", "xpack.securitySolution.entityAnalytics.entityStore.helpers.sourceField.eventDescription": "Ereignisse", "xpack.securitySolution.entityAnalytics.entityStore.helpers.sourceField.riskDescription": "Risiko", "xpack.securitySolution.entityAnalytics.header.anomalies": "Anomalien", diff --git a/x-pack/platform/plugins/private/translations/translations/fr-FR.json b/x-pack/platform/plugins/private/translations/translations/fr-FR.json index 01894f87e8750..37b4af8a11b1a 100644 --- a/x-pack/platform/plugins/private/translations/translations/fr-FR.json +++ b/x-pack/platform/plugins/private/translations/translations/fr-FR.json @@ -36198,7 +36198,6 @@ "xpack.securitySolution.entityAnalytics.entityStore.entitiesList.tableTitle": "Entités", "xpack.securitySolution.entityAnalytics.entityStore.entitiesList.tableTooltip": "L’affichage des données d'entité peut prendre quelques minutes", "xpack.securitySolution.entityAnalytics.entityStore.entitySource.filterTitle": "Source", - "xpack.securitySolution.entityAnalytics.entityStore.helpers.sourceField.criticalityDescription": "Criticité des ressources", "xpack.securitySolution.entityAnalytics.entityStore.helpers.sourceField.eventDescription": "Événements", "xpack.securitySolution.entityAnalytics.entityStore.helpers.sourceField.riskDescription": "Risque", "xpack.securitySolution.entityAnalytics.header.anomalies": "Anomalies", diff --git a/x-pack/platform/plugins/private/translations/translations/ja-JP.json b/x-pack/platform/plugins/private/translations/translations/ja-JP.json index 157b9918b72b2..6919fedd90626 100644 --- a/x-pack/platform/plugins/private/translations/translations/ja-JP.json +++ b/x-pack/platform/plugins/private/translations/translations/ja-JP.json @@ -36253,7 +36253,6 @@ "xpack.securitySolution.entityAnalytics.entityStore.entitiesList.tableTitle": "エンティティ", "xpack.securitySolution.entityAnalytics.entityStore.entitiesList.tableTooltip": "エンティティデータが表示されるまでに数分かかる場合があります", "xpack.securitySolution.entityAnalytics.entityStore.entitySource.filterTitle": "送信元", - "xpack.securitySolution.entityAnalytics.entityStore.helpers.sourceField.criticalityDescription": "アセット重要度", "xpack.securitySolution.entityAnalytics.entityStore.helpers.sourceField.eventDescription": "イベント", "xpack.securitySolution.entityAnalytics.entityStore.helpers.sourceField.riskDescription": "リスク", "xpack.securitySolution.entityAnalytics.header.anomalies": "異常", diff --git a/x-pack/platform/plugins/private/translations/translations/zh-CN.json b/x-pack/platform/plugins/private/translations/translations/zh-CN.json index 1f5e573a8fa62..dbcbac4387ca5 100644 --- a/x-pack/platform/plugins/private/translations/translations/zh-CN.json +++ b/x-pack/platform/plugins/private/translations/translations/zh-CN.json @@ -36233,7 +36233,6 @@ "xpack.securitySolution.entityAnalytics.entityStore.entitiesList.tableTitle": "实体", "xpack.securitySolution.entityAnalytics.entityStore.entitiesList.tableTooltip": "实体数据可能需要几分钟才能显示", "xpack.securitySolution.entityAnalytics.entityStore.entitySource.filterTitle": "源", - "xpack.securitySolution.entityAnalytics.entityStore.helpers.sourceField.criticalityDescription": "资产关键度", "xpack.securitySolution.entityAnalytics.entityStore.helpers.sourceField.eventDescription": "事件", "xpack.securitySolution.entityAnalytics.entityStore.helpers.sourceField.riskDescription": "风险", "xpack.securitySolution.entityAnalytics.header.anomalies": "异常", diff --git a/x-pack/solutions/security/plugins/security_solution/common/entity_analytics/entity_store/sanitize_entity_record_for_upsert.test.ts b/x-pack/solutions/security/plugins/security_solution/common/entity_analytics/entity_store/sanitize_entity_record_for_upsert.test.ts new file mode 100644 index 0000000000000..ada9509d97e93 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/common/entity_analytics/entity_store/sanitize_entity_record_for_upsert.test.ts @@ -0,0 +1,117 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { z } from '@kbn/zod/v4'; +import type { HostEntity } from '../../api/entity_analytics/entity_store/entities/common.gen'; +import { UpsertEntitiesBulkRequestBody } from '../../api/entity_analytics/entity_store/entities/upsert_entities_bulk.gen'; +import { + preprocessUpsertEntitiesBulkRequestBody, + sanitizeEntityRecordForUpsert, +} from './sanitize_entity_record_for_upsert'; + +const bulkBodySchema = z.preprocess( + preprocessUpsertEntitiesBulkRequestBody, + UpsertEntitiesBulkRequestBody +); + +describe('sanitize_entity_record_for_upsert', () => { + it('coerces host.risk.inputs object to array and notes string to array', () => { + const record = { + entity: { + id: 'host:test', + type: 'host', + attributes: { watchlists: ['wl-1'], asset: true }, + behaviors: { rule_names: ['a'], brute_force_victim: true }, + relationships: { + communicates_with: ['host:a'], + resolution: { risk: { calculated_level: 'Low' } }, + }, + }, + host: { + name: 'h1', + risk: { + '@timestamp': '2026-03-28T01:41:12.286Z', + id_field: 'host.name', + id_value: 'h1', + calculated_level: 'Moderate', + calculated_score: 42.5, + calculated_score_norm: 55, + category_1_score: 30, + category_1_count: 3, + inputs: { + id: 'alert-1', + index: '.alerts', + category: 'category_1', + description: 'demo', + risk_score: 65, + timestamp: '2026-03-28T01:41:12.286Z', + contribution_score: 12, + }, + notes: 'one note', + criticality_level: 'medium_impact', + }, + }, + event: { ingested: '2026-03-28T01:41:12.286Z' }, + }; + + const out = sanitizeEntityRecordForUpsert(record as never); + + expect( + (out as { host?: { risk?: { inputs?: unknown; notes?: unknown } } }).host?.risk?.inputs + ).toEqual([ + { + id: 'alert-1', + index: '.alerts', + category: 'category_1', + description: 'demo', + risk_score: 65, + timestamp: '2026-03-28T01:41:12.286Z', + contribution_score: 12, + }, + ]); + expect((out as { host?: { risk?: { notes?: string[] } } }).host?.risk?.notes).toEqual([ + 'one note', + ]); + expect( + (out as { entity?: { attributes?: { watchlists?: unknown } } }).entity?.attributes + ).toEqual({ + asset: true, + }); + expect( + (out as { entity?: { behaviors?: { rule_names?: unknown; brute_force_victim?: boolean } } }) + .entity?.behaviors + ).toEqual({ brute_force_victim: true }); + expect( + ( + out as { + entity?: { relationships?: { communicates_with?: string[]; resolution?: unknown } }; + } + ).entity?.relationships + ).toEqual({ communicates_with: ['host:a'] }); + }); + + it('accepts bulk body with doc instead of record after preprocess + Zod', () => { + const raw = { + entities: [ + { + type: 'host', + doc: { + entity: { id: 'host:x', type: 'host' }, + host: { name: 'host-a' }, + }, + }, + ], + }; + + const parsed = bulkBodySchema.safeParse(raw); + expect(parsed.success).toBe(true); + if (parsed.success) { + const record = parsed.data.entities[0].record as HostEntity; + expect(record.host?.name).toBe('host-a'); + } + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/common/entity_analytics/entity_store/sanitize_entity_record_for_upsert.ts b/x-pack/solutions/security/plugins/security_solution/common/entity_analytics/entity_store/sanitize_entity_record_for_upsert.ts new file mode 100644 index 0000000000000..b0d0d58e774c4 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/common/entity_analytics/entity_store/sanitize_entity_record_for_upsert.ts @@ -0,0 +1,413 @@ +/* + * 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 { + EntityRiskLevels, + EntityRiskScoreRecord, +} from '../../api/entity_analytics/common/common.gen'; +import type { + Entity, + EntityField, + GenericEntity, + HostEntity, + ServiceEntity, + UserEntity, +} from '../../api/entity_analytics/entity_store/entities/common.gen'; +import { EntityType } from '../types'; + +const ALLOWED_ENTITY_ATTRIBUTE_KEYS = ['privileged', 'asset', 'managed', 'mfa_enabled'] as const; + +const ALLOWED_ENTITY_BEHAVIOR_KEYS = [ + 'brute_force_victim', + 'new_country_login', + 'used_usb_device', +] as const; + +const ALLOWED_RELATIONSHIP_KEYS = [ + 'communicates_with', + 'depends_on', + 'dependent_of', + 'owns', + 'owned_by', + 'accesses_frequently', + 'accessed_frequently_by', + 'supervises', + 'supervised_by', +] as const; + +const FLAT_ENTITY_TYPE_KEY = 'entity.type'; +const FLAT_ENTITY_ENGINE_TYPE_KEY = 'entity.EngineMetadata.Type'; + +const ENTITY_TYPE_DISPLAY_TO_ENUM: Record = { + Host: EntityType.host, + Identity: EntityType.user, + Service: EntityType.service, +}; + +function pickBooleanFields( + source: Record, + keys: T +): Partial> | undefined { + const out: Partial> = {}; + for (const key of keys) { + if (key in source && typeof source[key] === 'boolean') { + out[key] = source[key] as boolean; + } + } + return Object.keys(out).length > 0 ? (out as Partial>) : undefined; +} + +function sanitizeEntityAttributes(attributes: unknown): EntityField['attributes'] | undefined { + if (!attributes || typeof attributes !== 'object') return undefined; + return pickBooleanFields(attributes as Record, ALLOWED_ENTITY_ATTRIBUTE_KEYS) as + | EntityField['attributes'] + | undefined; +} + +function sanitizeEntityBehaviors(behaviors: unknown): EntityField['behaviors'] | undefined { + if (!behaviors || typeof behaviors !== 'object') return undefined; + return pickBooleanFields(behaviors as Record, ALLOWED_ENTITY_BEHAVIOR_KEYS) as + | EntityField['behaviors'] + | undefined; +} + +function sanitizeEntityLifecycle(lifecycle: unknown): EntityField['lifecycle'] | undefined { + if (!lifecycle || typeof lifecycle !== 'object') return undefined; + const l = lifecycle as Record; + const out: NonNullable = {}; + if (typeof l.first_seen === 'string') out.first_seen = l.first_seen; + if (typeof l.last_seen === 'string') out.last_seen = l.last_seen; + if (typeof l.last_activity === 'string') out.last_activity = l.last_activity; + return Object.keys(out).length > 0 ? out : undefined; +} + +function sanitizeEntityRelationships( + relationships: unknown +): EntityField['relationships'] | undefined { + if (!relationships || typeof relationships !== 'object') return undefined; + const r = relationships as Record; + const out: NonNullable = {}; + for (const key of ALLOWED_RELATIONSHIP_KEYS) { + if (key in r) { + const val = r[key]; + if (Array.isArray(val) && val.every((x) => typeof x === 'string')) { + (out as Record)[key] = val; + } + } + } + return Object.keys(out).length > 0 ? out : undefined; +} + +function sanitizeEntityLevelRisk(risk: unknown): EntityField['risk'] | undefined { + if (!risk || typeof risk !== 'object') return undefined; + const r = risk as Record; + const out: NonNullable = {}; + const level = EntityRiskLevels.safeParse(r.calculated_level); + if (level.success) { + out.calculated_level = level.data; + } + if (typeof r.calculated_score === 'number') out.calculated_score = r.calculated_score; + if (typeof r.calculated_score_norm === 'number') + out.calculated_score_norm = r.calculated_score_norm; + return Object.keys(out).length > 0 ? out : undefined; +} + +function normalizeRiskDocumentForSchema(risk: unknown): unknown { + if (!risk || typeof risk !== 'object') return risk; + const r = { ...(risk as Record) }; + if (r.inputs != null && !Array.isArray(r.inputs)) { + r.inputs = [r.inputs]; + } + if (typeof r.notes === 'string') { + r.notes = [r.notes]; + } + return r; +} + +function sanitizeHostUserServiceRisk(risk: unknown) { + const parsed = EntityRiskScoreRecord.safeParse(normalizeRiskDocumentForSchema(risk)); + return parsed.success ? parsed.data : undefined; +} + +/** + * Builds a strict `EntityField` for upsert: drops EngineMetadata and keys not allowed by the API schema. + */ +function sanitizeEntityFieldFromUnknown(field: unknown): EntityField | undefined { + if (!field || typeof field !== 'object') return undefined; + const f = field as Record; + if (typeof f.id !== 'string') return undefined; + + const attributes = sanitizeEntityAttributes(f.attributes); + const behaviors = sanitizeEntityBehaviors(f.behaviors); + const lifecycle = sanitizeEntityLifecycle(f.lifecycle); + const relationships = sanitizeEntityRelationships(f.relationships); + const risk = sanitizeEntityLevelRisk(f.risk); + + return { + id: f.id, + ...(typeof f.name === 'string' && { name: f.name }), + ...(typeof f.type === 'string' && { type: f.type }), + ...(typeof f.sub_type === 'string' && { sub_type: f.sub_type }), + ...(typeof f.source === 'string' && { source: f.source }), + ...(attributes !== undefined && { attributes }), + ...(behaviors !== undefined && { behaviors }), + ...(lifecycle !== undefined && { lifecycle }), + ...(relationships !== undefined && { relationships }), + ...(risk !== undefined && { risk }), + }; +} + +function toArray(value: unknown): string[] | undefined { + if (value == null) return undefined; + if (Array.isArray(value)) { + return value.every((x) => typeof x === 'string') ? value : undefined; + } + if (typeof value === 'string') return [value]; + return undefined; +} + +function sanitizeHostForUpsert(host: Record): HostEntity['host'] { + const name = host.name; + if (typeof name !== 'string') return { name: '' }; + const out: NonNullable = { + name, + ...(toArray(host.hostname) && { hostname: toArray(host.hostname) }), + ...(toArray(host.domain) && { domain: toArray(host.domain) }), + ...(toArray(host.ip) && { ip: toArray(host.ip) }), + ...(toArray(host.id) && { id: toArray(host.id) }), + ...(toArray(host.type) && { type: toArray(host.type) }), + ...(toArray(host.mac) && { mac: toArray(host.mac) }), + ...(toArray(host.architecture) && { architecture: toArray(host.architecture) }), + }; + if (host.os != null && typeof host.os === 'object') { + out.os = host.os as NonNullable['os']; + } + if (host.risk != null && typeof host.risk === 'object') { + const normalizedRisk = sanitizeHostUserServiceRisk(host.risk); + if (normalizedRisk) { + out.risk = normalizedRisk; + } + } + if (host.entity != null && typeof host.entity === 'object') { + const nested = sanitizeEntityFieldFromUnknown(host.entity); + if (nested) { + out.entity = nested; + } + } + return out; +} + +function sanitizeUserForUpsert(user: Record): NonNullable { + const name = user.name; + if (typeof name !== 'string') return { name: '' }; + const out: NonNullable = { + name, + ...(toArray(user.id) && { id: toArray(user.id) }), + ...(toArray(user.full_name) && { full_name: toArray(user.full_name) }), + ...(toArray(user.domain) && { domain: toArray(user.domain) }), + ...(toArray(user.roles) && { roles: toArray(user.roles) }), + ...(toArray(user.email) && { email: toArray(user.email) }), + ...(toArray(user.hash) && { hash: toArray(user.hash) }), + }; + if (user.risk != null && typeof user.risk === 'object') { + const normalizedRisk = sanitizeHostUserServiceRisk(user.risk); + if (normalizedRisk) { + out.risk = normalizedRisk; + } + } + return out; +} + +function sanitizeServiceForUpsert( + service: Record +): NonNullable { + const name = service.name; + if (typeof name !== 'string') return { name: '' }; + const out: NonNullable = { name }; + if (service.risk != null && typeof service.risk === 'object') { + const normalizedRisk = sanitizeHostUserServiceRisk(service.risk); + if (normalizedRisk) { + out.risk = normalizedRisk; + } + } + if (service.entity != null && typeof service.entity === 'object') { + const nested = sanitizeEntityFieldFromUnknown(service.entity); + if (nested) { + out.entity = nested; + } + } + return out; +} + +function sanitizeEventForUpsert( + event: Record +): NonNullable | undefined { + if (typeof event.ingested !== 'string') { + return undefined; + } + return { ingested: event.ingested }; +} + +function buildHostEntityForUpsert(entity: EntityField, raw: Record): HostEntity { + const event = + raw.event != null && typeof raw.event === 'object' + ? sanitizeEventForUpsert(raw.event as Record) + : undefined; + + return { + entity, + ...(raw.host != null && + typeof raw.host === 'object' && { + host: sanitizeHostForUpsert(raw.host as Record), + }), + ...(raw.asset != null && + typeof raw.asset === 'object' && { + asset: raw.asset as HostEntity['asset'], + }), + ...(event && { event }), + }; +} + +function buildUserEntityForUpsert(entity: EntityField, raw: Record): UserEntity { + const event = + raw.event != null && typeof raw.event === 'object' + ? sanitizeEventForUpsert(raw.event as Record) + : undefined; + + return { + entity, + ...(raw.user != null && + typeof raw.user === 'object' && { + user: sanitizeUserForUpsert(raw.user as Record), + }), + ...(raw.asset != null && + typeof raw.asset === 'object' && { + asset: raw.asset as UserEntity['asset'], + }), + ...(event && { event }), + }; +} + +function buildServiceEntityForUpsert( + entity: EntityField, + raw: Record +): ServiceEntity { + const event = + raw.event != null && typeof raw.event === 'object' + ? sanitizeEventForUpsert(raw.event as Record) + : undefined; + + return { + entity, + ...(raw.service != null && + typeof raw.service === 'object' && { + service: sanitizeServiceForUpsert(raw.service as Record), + }), + ...(raw.asset != null && + typeof raw.asset === 'object' && { + asset: raw.asset as ServiceEntity['asset'], + }), + ...(event && { event }), + }; +} + +function buildGenericEntityForUpsert( + entity: EntityField, + raw: Record +): GenericEntity { + return { + entity, + ...(raw.asset != null && + typeof raw.asset === 'object' && { + asset: raw.asset as GenericEntity['asset'], + }), + }; +} + +export const getEntityType = (record: Entity): EntityType => { + const recordAny = record as Record; + const rawType = + record.entity?.EngineMetadata?.Type ?? + record.entity?.type ?? + recordAny[FLAT_ENTITY_ENGINE_TYPE_KEY] ?? + recordAny[FLAT_ENTITY_TYPE_KEY]; + + if (!rawType || typeof rawType !== 'string') { + throw new Error(`Unexpected entity: ${JSON.stringify(record)}`); + } + + const normalized = + ENTITY_TYPE_DISPLAY_TO_ENUM[rawType] ?? + (Object.values(EntityType).includes(rawType as EntityType) + ? (rawType as EntityType) + : undefined); + if (normalized === undefined) { + throw new Error(`Unexpected entity: ${JSON.stringify(record)}`); + } + + return normalized; +}; + +/** + * Returns a record that conforms to the Entity Store upsert API schema. + * List/index documents can include extra fields (e.g. `agent`, `entity.EngineMetadata.UntypedId`, + * or Elasticsearch-style `host.risk.inputs` as a single object). The upsert API uses a strict schema. + */ +export function sanitizeEntityRecordForUpsert(record: Entity): Entity { + const raw = record as Record; + const entity = sanitizeEntityFieldFromUnknown(record.entity); + + if (!entity) { + throw new Error('Entity record must have a valid entity field with id'); + } + + const entityType = getEntityType(record); + + if (entityType === EntityType.host) { + return buildHostEntityForUpsert(entity, raw); + } + if (entityType === EntityType.user) { + return buildUserEntityForUpsert(entity, raw); + } + if (entityType === EntityType.service) { + return buildServiceEntityForUpsert(entity, raw); + } + return buildGenericEntityForUpsert(entity, raw); +} + +/** + * Normalizes bulk upsert JSON before Zod validation: `doc` → `record`, strips unknown ECS/API-mismatched + * fields, and coerces host/user/service risk payloads into the shape expected by the API. + */ +export function preprocessUpsertEntitiesBulkRequestBody(body: unknown): unknown { + if (!body || typeof body !== 'object') return body; + const b = body as { entities?: unknown }; + if (!Array.isArray(b.entities)) return body; + + return { + ...b, + entities: b.entities.map((row: unknown) => { + if (!row || typeof row !== 'object') return row; + const c = row as Record; + const record = c.record !== undefined ? c.record : c.doc; + if (record === undefined) return row; + + let sanitizedRecord: unknown = record; + try { + sanitizedRecord = sanitizeEntityRecordForUpsert(record as Entity); + } catch { + // Leave as-is for Zod to surface a validation error + } + + const next: Record = { record: sanitizedRecord }; + if (c.type !== undefined) { + next.type = c.type; + } + return next; + }), + }; +} diff --git a/x-pack/solutions/security/plugins/security_solution/common/search_strategy/security_solution/hosts/common/index.ts b/x-pack/solutions/security/plugins/security_solution/common/search_strategy/security_solution/hosts/common/index.ts index 15dda8058221d..e282966ac85b6 100644 --- a/x-pack/solutions/security/plugins/security_solution/common/search_strategy/security_solution/hosts/common/index.ts +++ b/x-pack/solutions/security/plugins/security_solution/common/search_strategy/security_solution/hosts/common/index.ts @@ -38,6 +38,8 @@ export interface HostItem { lastSeen?: Maybe; risk?: string; criticality?: string; + /** Canonical Entity Store id when the row comes from the entity store hosts list. */ + entityId?: Maybe; } export interface HostValue { diff --git a/x-pack/solutions/security/plugins/security_solution/common/search_strategy/security_solution/hosts/uncommon_processes/index.ts b/x-pack/solutions/security/plugins/security_solution/common/search_strategy/security_solution/hosts/uncommon_processes/index.ts index b331a277ed240..c5ed90c9529d0 100644 --- a/x-pack/solutions/security/plugins/security_solution/common/search_strategy/security_solution/hosts/uncommon_processes/index.ts +++ b/x-pack/solutions/security/plugins/security_solution/common/search_strategy/security_solution/hosts/uncommon_processes/index.ts @@ -32,11 +32,17 @@ export interface HostsUncommonProcessesEdges { cursor: CursorType; } +/** Host with entityIdentifiers for HostDetailsLink URL resolution */ +export type HostsUncommonProcessHostItem = HostEcs & { + identityFields?: Record; + entityId?: string; +}; + export interface HostsUncommonProcessItem { _id: string; instances: number; process: ProcessEcs; - hosts: HostEcs[]; + hosts: HostsUncommonProcessHostItem[]; user?: Maybe; } @@ -48,12 +54,16 @@ type ProcessUserFields = CommonFields & [Property in keyof UserEcs as `user.${Property}`]: unknown[]; }>; +export interface HostsUncommonProcessHost { + id: string[] | undefined; + name: string[] | undefined; + /** Entity identifiers for HostDetailsLink URL resolution (host.entity.id, host.id, host.name, host.hostname) */ + identityFields?: Record; +} + export interface HostsUncommonProcessHit extends Hit { total: TotalHit; - host: Array<{ - id: string[] | undefined; - name: string[] | undefined; - }>; + host: HostsUncommonProcessHost[]; fields: ProcessUserFields; cursor: string; sort: StringOrNumber[]; diff --git a/x-pack/solutions/security/plugins/security_solution/common/search_strategy/security_solution/users/all/index.ts b/x-pack/solutions/security/plugins/security_solution/common/search_strategy/security_solution/users/all/index.ts index 1d8feedf95e74..ed221e8f96832 100644 --- a/x-pack/solutions/security/plugins/security_solution/common/search_strategy/security_solution/users/all/index.ts +++ b/x-pack/solutions/security/plugins/security_solution/common/search_strategy/security_solution/users/all/index.ts @@ -15,6 +15,10 @@ export interface User { domain: string; risk?: RiskSeverity; criticality?: string; + /** Canonical Entity Store id when the row comes from the entity store users list. */ + entityId?: string; + /** Identity fields for user details URL resolution when entity store data is present. */ + identityFields?: Record; } export interface UsersStrategyResponse extends IEsSearchResponse { 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 7babccc71ad7a..b6f95f53d04e1 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 @@ -28,6 +28,7 @@ import { BUTTON_CLASS as INSPECT_BUTTON_CLASS } from '../inspect'; import { LastUpdatedAt } from '../last_updated_at'; import { SecuritySolutionLinkAnchor } from '../links'; import { useLocalStorage } from '../local_storage'; +import { resolveEntityIdentifiers } from '../../utils/resolve_entity_identifiers_for_alerts'; import { MultiSelectPopover } from './components'; import * as i18n from './translations'; import type { AlertCountByRuleByStatusItem } from './use_alert_count_by_rule_by_status'; @@ -40,6 +41,11 @@ interface EntityFilter { } interface AlertCountByStatusProps { entityFilter: EntityFilter; + /** + * When set (e.g. host/user details from entity resolution), preferred over legacy `entityFilter.field`. + * Same semantics as `AlertsByStatus` `identityFields`. + */ + identityFields?: Record | null; additionalFilters?: ESBoolQuery[]; signalIndexName: string | null; } @@ -65,9 +71,29 @@ const StyledEuiPanel = euiStyled(EuiPanel)` `; export const AlertCountByRuleByStatus = React.memo( - ({ entityFilter, signalIndexName, additionalFilters }: AlertCountByStatusProps) => { + ({ + entityFilter, + identityFields, + signalIndexName, + additionalFilters, + }: AlertCountByStatusProps) => { const { field, value, entityType } = entityFilter; + 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, + })); + } + return [{ field, value }]; + }, [entityIdentifiersResolved, field, value]); + const queryId = `${ALERT_COUNT_BY_RULE_BY_STATUS}-by-${field}`; const { toggleStatus, setToggleStatus } = useQueryToggle(queryId); @@ -89,7 +115,7 @@ export const AlertCountByRuleByStatus = React.memo( for (const status of selectedStatusesByField[field]) { timelineFilters.push([ - entityFilter, + ...entityFiltersForTimeline, { field: SIGNAL_RULE_NAME_FIELD_NAME, value: ruleName }, { field: SIGNAL_STATUS_FIELD_NAME, @@ -99,7 +125,7 @@ export const AlertCountByRuleByStatus = React.memo( } openTimelineWithFilters(timelineFilters); }); - }, [entityFilter, field, openTimelineWithFilters, selectedStatusesByField]); + }, [entityFiltersForTimeline, field, openTimelineWithFilters, selectedStatusesByField]); const updateSelection = useCallback( (selection: Status[]) => { @@ -113,6 +139,7 @@ export const AlertCountByRuleByStatus = React.memo( const { items, isLoading, updatedAt } = useAlertCountByRuleByStatus({ additionalFilters, + identityFields: entityIdentifiersResolved, field, value, entityType, 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 17f65fc7b1d1a..b8207ac95d606 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 @@ -9,7 +9,10 @@ import { renderHook } from '@testing-library/react'; import { mockQuery, mockAlertCountByRuleResult, parsedAlertCountByRuleResult } from './mock_data'; import type { UseAlertCountByRuleByStatusProps } from './use_alert_count_by_rule_by_status'; -import { useAlertCountByRuleByStatus } from './use_alert_count_by_rule_by_status'; +import { + buildRuleAlertsByEntityQuery, + useAlertCountByRuleByStatus, +} from './use_alert_count_by_rule_by_status'; const dateNow = new Date('2022-04-15T12:00:00.000Z').valueOf(); const mockDateNow = jest.fn().mockReturnValue(dateNow); @@ -141,4 +144,23 @@ describe('useAlertCountByRuleByStatus', () => { updatedAt: dateNow, }); }); + + it('should filter by identityFields when provided', () => { + renderUseAlertCountByRuleByStatus({ + identityFields: { 'host.id': 'host-uuid', 'host.name': 'hostname' }, + }); + + expect(mockUseQueryAlerts).toBeCalledWith( + expect.objectContaining({ + query: buildRuleAlertsByEntityQuery({ + from, + to, + statuses: ['open'], + field: 'test_field', + value: 'test_value', + identityFields: { '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 69d6e763c674c..605630830a29f 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 @@ -23,6 +23,9 @@ import { useQueryInspector } from '../page/manage_query'; const ENTITY_ID_FIELD = 'entity.id'; +/** Stable empty map so callers passing inline `{}` do not invalidate query memoization every render. */ +const EMPTY_IDENTITY_FIELDS: Record = {}; + const toStoreEntityType = (type: string | undefined): 'host' | 'user' | undefined => { if (type === EntityType.host || type === 'host') { return 'host'; @@ -41,6 +44,11 @@ export interface AlertCountByRuleByStatusItem { export interface UseAlertCountByRuleByStatusProps { additionalFilters?: ESBoolQuery[]; + /** + * Resolved entity identifiers (e.g. `host.id`, `entity.id`), aligned with {@link useAlertsByStatus}. + * 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; @@ -59,6 +67,7 @@ const ALERTS_BY_RULE_AGG = 'alertsByRuleAggregation'; export const useAlertCountByRuleByStatus: UseAlertCountByRuleByStatus = ({ additionalFilters, + identityFields, field, value, entityType, @@ -73,31 +82,40 @@ export const useAlertCountByRuleByStatus: UseAlertCountByRuleByStatus = ({ const { to, from, deleteQuery, setQuery } = useGlobalTime(); const entityStoreV2Enabled = useUiSetting(FF_ENABLE_ENTITY_STORE_V2, false); - const isEntityIdField = field === ENTITY_ID_FIELD; + const identityFieldsStable = + identityFields != null && Object.keys(identityFields).length > 0 + ? identityFields + : EMPTY_IDENTITY_FIELDS; + const entityIdValue = identityFieldsStable[ENTITY_ID_FIELD]; const storeEntityType = toStoreEntityType(entityType); - - const shouldFetchFromEntityStore = - isEntityIdField && entityStoreV2Enabled && storeEntityType != null; + const shouldResolveEntityIdFromStore = + Boolean(entityIdValue) && entityStoreV2Enabled && storeEntityType != null; const entityFromStore = useEntityFromStore({ - entityId: value, + entityId: entityIdValue, entityType: storeEntityType ?? 'host', - skip: skip || !shouldFetchFromEntityStore, + skip: skip || !shouldResolveEntityIdFromStore, }); const { entityRecord, isLoading: entityFromStoreLoading } = entityFromStore; const euidApi = useEntityStoreEuidApi(); - const identityFieldsForQuery = useMemo>( + const identityFieldsForQuery = useMemo( () => - euidApi?.euid?.getEntityIdentifiersFromDocument(storeEntityType ?? 'generic', entityRecord) ?? - {}, - [euidApi?.euid, entityRecord, storeEntityType] + entityStoreV2Enabled + ? euidApi?.euid?.getEntityIdentifiersFromDocument( + storeEntityType ?? 'generic', + entityRecord + ) + : identityFieldsStable, + [entityStoreV2Enabled, euidApi?.euid, storeEntityType, entityRecord, identityFieldsStable] ); const skipAlertsQuery = skip || - (shouldFetchFromEntityStore && (entityFromStoreLoading || identityFieldsForQuery == null)); + (shouldResolveEntityIdFromStore && (entityFromStoreLoading || identityFieldsForQuery == null)); + + const isResolvingEntityId = shouldResolveEntityIdFromStore && entityFromStoreLoading; const { loading: isLoading, @@ -114,7 +132,7 @@ export const useAlertCountByRuleByStatus: UseAlertCountByRuleByStatus = ({ field, value, statuses, - identityFields: identityFieldsForQuery, + identityFields: identityFieldsForQuery ?? identityFieldsStable, }), skip: skipAlertsQuery, queryName: ALERTS_QUERY_NAMES.ALERTS_COUNT_BY_STATUS, @@ -130,10 +148,20 @@ export const useAlertCountByRuleByStatus: UseAlertCountByRuleByStatus = ({ field, value, statuses, - identityFields: identityFieldsForQuery, + identityFields: identityFieldsForQuery ?? identityFieldsStable, }) ); - }, [setAlertsQuery, from, to, field, value, statuses, additionalFilters, identityFieldsForQuery]); + }, [ + setAlertsQuery, + from, + to, + field, + value, + statuses, + additionalFilters, + identityFieldsForQuery, + identityFieldsStable, + ]); useEffect(() => { if (!data) { @@ -159,10 +187,10 @@ export const useAlertCountByRuleByStatus: UseAlertCountByRuleByStatus = ({ refetch, setQuery, queryId, - loading: isLoading, + loading: isLoading || isResolvingEntityId, }); - return { items, isLoading, updatedAt }; + return { items, isLoading: isLoading || isResolvingEntityId, updatedAt }; }; export const KIBANA_RULE_ID = 'kibana.alert.rule.uuid'; 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 363748553119a..96e2b812fd196 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,6 +58,11 @@ export type EventsQueryTabBodyComponentProps = Omit = deleteQuery, endDate, filterQuery, + histogramFilterQuery, startDate, tableId, }) => { @@ -171,6 +177,8 @@ const EventsQueryTabBodyComponent: React.FC = [additionalFilters, showExternalAlerts] ); + const matrixHistogramFilterQuery = histogramFilterQuery ?? filterQuery; + const addBulkToTimelineActions = useAddBulkToTimelineAction({ localFilters: composedPageFilters, tableId, @@ -197,7 +205,7 @@ const EventsQueryTabBodyComponent: React.FC = id={ALERTS_EVENTS_HISTOGRAM_ID} startDate={startDate} endDate={endDate} - filterQuery={filterQuery} + filterQuery={matrixHistogramFilterQuery} {...(showExternalAlerts ? alertsHistogramConfig : eventsHistogramConfig)} subtitle={getHistogramSubtitle} sourcererScopeId={newDataViewPickerEnabled ? PageScope.explore : PageScope.default} diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/components/link_to/entity_resolution_query_params.ts b/x-pack/solutions/security/plugins/security_solution/public/common/components/link_to/entity_resolution_query_params.ts new file mode 100644 index 0000000000000..8a4ec5314b3a4 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/common/components/link_to/entity_resolution_query_params.ts @@ -0,0 +1,166 @@ +/* + * 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 { ParsedQuery } from 'query-string'; +import { parse } from 'query-string'; +import { encode, safeDecode } from '@kbn/rison'; +import { URL_PARAM_KEY } from '../../hooks/constants'; +import { + encodeQueryString, + getParamFromQueryString, +} from '../../utils/global_query_string/helpers'; + +/** @deprecated Use URL_PARAM_KEY.entityId */ +export const ENTITY_ID_URL_PARAM = URL_PARAM_KEY.entityId; +/** @deprecated Use URL_PARAM_KEY.identityFields */ +export const IDENTITY_FIELDS_URL_PARAM = URL_PARAM_KEY.identityFields; + +const stripLeadingQuestion = (urlStateQuery: string): string => + urlStateQuery.startsWith('?') ? urlStateQuery.slice(1) : urlStateQuery; + +const isDefaultHostIdentity = ( + displayName: string, + identityFields: Record +): boolean => { + const keys = Object.keys(identityFields); + return ( + keys.length === 1 && keys[0] === 'host.name' && identityFields['host.name'] === displayName + ); +}; + +const isDefaultUserIdentity = ( + displayName: string, + identityFields: Record +): boolean => { + const keys = Object.keys(identityFields); + return ( + keys.length === 1 && keys[0] === 'user.name' && identityFields['user.name'] === displayName + ); +}; + +export interface EntityResolutionQueryOptions { + entityId?: string; + identityFields?: Record; + /** Used to omit redundant default-only identity maps from the URL */ + displayName?: string; + entityType?: 'host' | 'user'; +} + +const shouldOmitIdentityFields = (options: EntityResolutionQueryOptions): boolean => { + if (options.identityFields === undefined) { + return true; + } + if (Object.keys(options.identityFields).length === 0) { + return true; + } + /** + * Without a canonical entity store id in the URL, keep identityFields encoded so resolution, + * tab navigation, and entity-store v2 fallbacks still receive explicit identifiers when the + * entity is not (or not yet) in the store. + */ + if (options.entityId === undefined || options.entityId === '') { + return false; + } + if (options.displayName === undefined || options.entityType === undefined) { + return false; + } + if (options.entityType === 'host') { + return isDefaultHostIdentity(options.displayName, options.identityFields); + } + return isDefaultUserIdentity(options.displayName, options.identityFields); +}; + +const parseIdentityFieldsLegacy = (raw: string): Record | undefined => { + try { + const parsed = JSON.parse(decodeURIComponent(raw)) as unknown; + if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) { + return parsed as Record; + } + } catch { + // ignore + } + return undefined; +}; + +/** + * Merges Rison-encoded entityId / identityFields into the Security app URL state query + * (same encoding as timerange, valueReport, timeline, etc.). + */ +export const mergeEntityResolutionIntoUrlState = ( + urlStateQuery: string | undefined, + options: EntityResolutionQueryOptions +): string => { + const raw = stripLeadingQuestion(urlStateQuery ?? ''); + const urlParams = parse(raw, { sort: false }) as ParsedQuery; + + delete urlParams[URL_PARAM_KEY.entityId]; + delete urlParams[URL_PARAM_KEY.identityFields]; + + if (options.entityId !== undefined && options.entityId !== '') { + try { + urlParams[URL_PARAM_KEY.entityId] = encode(options.entityId); + } catch { + // ignore encode failures + } + } + + if ( + !shouldOmitIdentityFields(options) && + options.identityFields !== undefined && + Object.keys(options.identityFields).length > 0 + ) { + try { + urlParams[URL_PARAM_KEY.identityFields] = encode(options.identityFields); + } catch { + // ignore encode failures + } + } + + const encoded = encodeQueryString(urlParams); + return encoded === '' ? '' : `?${encoded}`; +}; + +/** + * Reads entityId and identityFields from location.search (Rison values; supports legacy JSON identityFields). + */ +export const parseEntityResolutionFromUrlState = ( + urlStateQuery: string | undefined +): { entityId?: string; identityFields?: Record } => { + const raw = stripLeadingQuestion(urlStateQuery ?? ''); + if (raw === '') { + return {}; + } + + const entityIdRaw = getParamFromQueryString(raw, URL_PARAM_KEY.entityId); + const identityRaw = getParamFromQueryString(raw, URL_PARAM_KEY.identityFields); + + let entityId: string | undefined; + if (entityIdRaw != null && entityIdRaw !== '') { + const decoded = safeDecode(entityIdRaw); + if (typeof decoded === 'string') { + entityId = decoded; + } else { + try { + entityId = decodeURIComponent(entityIdRaw); + } catch { + entityId = entityIdRaw; + } + } + } + + let identityFields: Record | undefined; + if (identityRaw != null && identityRaw !== '') { + const decoded = safeDecode(identityRaw); + if (typeof decoded === 'object' && decoded !== null && !Array.isArray(decoded)) { + identityFields = decoded as Record; + } else { + identityFields = parseIdentityFieldsLegacy(identityRaw); + } + } + + return { entityId, identityFields }; +}; diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/components/link_to/index.ts b/x-pack/solutions/security/plugins/security_solution/public/common/components/link_to/index.ts index 8762911b121ec..74b0dd26f05a7 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/common/components/link_to/index.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/common/components/link_to/index.ts @@ -17,6 +17,18 @@ import type { SecurityPageName } from '../../../app/types'; export { getDetectionEngineUrl, getRuleDetailsUrl } from './redirect_to_detection_engine'; export { getHostDetailsUrl, getTabsOnHostDetailsUrl, getHostsUrl } from './redirect_to_hosts'; +export { + getUsersDetailsUrl, + getTabsOnUsersDetailsUrl, + getTabsOnUsersUrl, + parseEntityIdentifiersFromUrlParam, +} from './redirect_to_users'; +export { + mergeEntityResolutionIntoUrlState, + parseEntityResolutionFromUrlState, + ENTITY_ID_URL_PARAM, + IDENTITY_FIELDS_URL_PARAM, +} from './entity_resolution_query_params'; export { getKubernetesUrl, getKubernetesDetailsUrl } from './redirect_to_kubernetes'; export { getNetworkUrl, getNetworkDetailsUrl } from './redirect_to_network'; export { getTimelineTabsUrl, getTimelineUrl } from './redirect_to_timelines'; diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/components/link_to/redirect_to_hosts.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/common/components/link_to/redirect_to_hosts.test.tsx new file mode 100644 index 0000000000000..18e84cfa04259 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/common/components/link_to/redirect_to_hosts.test.tsx @@ -0,0 +1,101 @@ +/* + * 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 { HostsTableType } from '../../../explore/hosts/store/model'; +import { HOSTS_PATH } from '../../../../common/constants'; +import { parseEntityResolutionFromUrlState } from './entity_resolution_query_params'; +import { + getHostDetailsUrl, + getHostsUrl, + getTabsOnHostDetailsUrl, + getTabsOnHostsUrl, +} from './redirect_to_hosts'; + +const queryFromUrl = (url: string): string | undefined => { + const i = url.indexOf('?'); + return i === -1 ? undefined : url.slice(i + 1); +}; + +describe('redirect_to_hosts', () => { + describe('getHostsUrl', () => { + it('returns the hosts path with no search when urlStateQuery is empty', () => { + expect(getHostsUrl()).toBe(HOSTS_PATH); + expect(getHostsUrl('')).toBe(HOSTS_PATH); + }); + + it('appends a leading ? when urlStateQuery omits it', () => { + expect(getHostsUrl('timerange=(from:now-24h,to:now)')).toBe( + `${HOSTS_PATH}?timerange=(from:now-24h,to:now)` + ); + }); + + it('preserves an existing leading ? on urlStateQuery', () => { + expect(getHostsUrl('?foo=bar')).toBe(`${HOSTS_PATH}?foo=bar`); + }); + }); + + describe('getTabsOnHostsUrl', () => { + it('returns a path rooted at the tab segment', () => { + expect(getTabsOnHostsUrl(HostsTableType.events)).toBe(`/${HostsTableType.events}`); + }); + + it('appends search when provided', () => { + expect(getTabsOnHostsUrl(HostsTableType.risk, 'page=2')).toBe( + `/${HostsTableType.risk}?page=2` + ); + }); + }); + + describe('getHostDetailsUrl', () => { + it('defaults to the events tab and encodes the host name in the path', () => { + expect(getHostDetailsUrl('host&name')).toBe( + `/name/${encodeURIComponent('host&name')}/${HostsTableType.events}` + ); + }); + + it('merges entityId into URL state when provided', () => { + const url = getHostDetailsUrl('my-host', undefined, 'entity-1'); + expect(url.startsWith(`/name/my-host/${HostsTableType.events}?`)).toBe(true); + expect(parseEntityResolutionFromUrlState(queryFromUrl(url))).toEqual({ + entityId: 'entity-1', + }); + }); + + it('omits default host.name identityFields when entityId matches display name resolution', () => { + const url = getHostDetailsUrl('my-host', undefined, 'entity-1', { + 'host.name': 'my-host', + }); + expect(parseEntityResolutionFromUrlState(queryFromUrl(url))).toEqual({ + entityId: 'entity-1', + }); + }); + + it('keeps non-default identityFields in URL state when entityId is absent', () => { + const url = getHostDetailsUrl('shown-name', undefined, undefined, { + 'host.name': 'canonical-host', + }); + expect(parseEntityResolutionFromUrlState(queryFromUrl(url))).toEqual({ + identityFields: { 'host.name': 'canonical-host' }, + }); + }); + }); + + describe('getTabsOnHostDetailsUrl', () => { + it('builds path with explicit tab and optional query state', () => { + const baseOnly = getTabsOnHostDetailsUrl('srv', HostsTableType.authentications); + expect(baseOnly).toBe(`/name/srv/${HostsTableType.authentications}`); + + const withQuery = getTabsOnHostDetailsUrl( + 'srv', + HostsTableType.sessions, + 'timerange=(from:now-24h,to:now)' + ); + expect(withQuery.startsWith(`/name/srv/${HostsTableType.sessions}?`)).toBe(true); + expect(withQuery).toContain('timerange='); + }); + }); +}); diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/components/link_to/redirect_to_hosts.tsx b/x-pack/solutions/security/plugins/security_solution/public/common/components/link_to/redirect_to_hosts.tsx index a9bfd7dda5641..8433669c10795 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/common/components/link_to/redirect_to_hosts.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/common/components/link_to/redirect_to_hosts.tsx @@ -5,20 +5,40 @@ * 2.0. */ -import type { HostsTableType } from '../../../explore/hosts/store/model'; +import { HostsTableType } from '../../../explore/hosts/store/model'; import { HOSTS_PATH } from '../../../../common/constants'; import { appendSearch } from './helpers'; +import { mergeEntityResolutionIntoUrlState } from './entity_resolution_query_params'; -export const getHostsUrl = (search?: string) => `${HOSTS_PATH}${appendSearch(search)}`; +export const getHostsUrl = (urlStateQuery?: string) => + `${HOSTS_PATH}${appendSearch(urlStateQuery)}`; -export const getTabsOnHostsUrl = (tabName: HostsTableType, search?: string) => - `/${tabName}${appendSearch(search)}`; +export const getTabsOnHostsUrl = (tabName: HostsTableType, urlStateQuery?: string) => + `/${tabName}${appendSearch(urlStateQuery)}`; -export const getHostDetailsUrl = (detailName: string, search?: string) => - `/name/${encodeURIComponent(detailName)}${appendSearch(search)}`; +const DEFAULT_HOST_TAB = HostsTableType.events; +export const getHostDetailsUrl = ( + detailName: string, + urlStateQuery?: string, + entityId?: string, + identityFields?: Record +) => getTabsOnHostDetailsUrl(detailName, DEFAULT_HOST_TAB, urlStateQuery, entityId, identityFields); + +/** Parameter order matches getTabsOnUsersDetailsUrl (urlStateQuery, entityId, identityFields). */ export const getTabsOnHostDetailsUrl = ( detailName: string, tabName: HostsTableType, - search?: string -) => `/name/${encodeURIComponent(detailName)}/${tabName}${appendSearch(search)}`; + urlStateQuery?: string, + entityId?: string, + identityFields?: Record +) => { + const base = `/name/${encodeURIComponent(detailName)}/${tabName}`; + const query = mergeEntityResolutionIntoUrlState(urlStateQuery, { + entityId, + identityFields, + displayName: detailName, + entityType: 'host', + }); + return query === '' ? base : `${base}${query}`; +}; diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/components/link_to/redirect_to_users.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/common/components/link_to/redirect_to_users.test.tsx index 007f2ff439df1..8c2c297d1e662 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/common/components/link_to/redirect_to_users.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/common/components/link_to/redirect_to_users.test.tsx @@ -5,31 +5,171 @@ * 2.0. */ -import { getUsersDetailsUrl } from './redirect_to_users'; - -describe('getUserDetailsUrl', () => { - const TESTS = [ - { - userId: 'domain\\some_user', - expectedUrl: '/name/domain%5Csome_user', - }, - { - userId: 'user&name', - expectedUrl: '/name/user%26name', - }, - { - userId: 'normaluser', - expectedUrl: '/name/normaluser', - }, - { - userId: 'user?name', - expectedUrl: '/name/user%3Fname', - }, - ]; - - TESTS.forEach(({ userId, expectedUrl }) => { - it(`should return the correct URL for user ID: ${userId}`, () => { - expect(getUsersDetailsUrl(userId)).toBe(expectedUrl); +import { UsersTableType } from '../../../explore/users/store/model'; +import { parseEntityResolutionFromUrlState } from './entity_resolution_query_params'; +import { + getTabsOnUsersDetailsUrl, + getTabsOnUsersUrl, + getUsersDetailsUrl, + parseEntityIdentifiersFromUrlParam, +} from './redirect_to_users'; + +/** Inverse of decodeEntityIdentifiersFromUrl — builds a base64url segment for tests. */ +const encodeJsonForLegacyEntitySegment = (value: unknown): string => { + const json = JSON.stringify(value); + const binary = unescape(encodeURIComponent(json)); + const base64 = btoa(binary); + return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +}; + +const queryFromUrl = (url: string): string | undefined => { + const i = url.indexOf('?'); + return i === -1 ? undefined : url.slice(i + 1); +}; + +describe('redirect_to_users', () => { + describe('parseEntityIdentifiersFromUrlParam', () => { + it('returns an empty object when the param is undefined or empty', () => { + expect(parseEntityIdentifiersFromUrlParam(undefined)).toEqual({}); + expect(parseEntityIdentifiersFromUrlParam('')).toEqual({}); + }); + + it('returns an empty object when the segment cannot be decoded', () => { + expect(parseEntityIdentifiersFromUrlParam('not-valid-base64!!!')).toEqual({}); + // Valid base64 but invalid JSON + expect(parseEntityIdentifiersFromUrlParam('e30')).toEqual({}); // base64url for "{" + }); + + it('returns undefined entityId and identityFields when decoded JSON is an empty object', () => { + expect(parseEntityIdentifiersFromUrlParam(encodeJsonForLegacyEntitySegment({}))).toEqual({ + entityId: undefined, + identityFields: undefined, + }); + }); + + it('returns an empty object when JSON is not a plain object', () => { + expect(parseEntityIdentifiersFromUrlParam(encodeJsonForLegacyEntitySegment([]))).toEqual({}); + expect(parseEntityIdentifiersFromUrlParam(encodeJsonForLegacyEntitySegment('x'))).toEqual({}); + }); + + it('extracts entityId when it is a non-empty string', () => { + expect( + parseEntityIdentifiersFromUrlParam(encodeJsonForLegacyEntitySegment({ entityId: 'ent-1' })) + ).toEqual({ entityId: 'ent-1' }); + }); + + it('omits entityId when it is empty or not a string', () => { + expect( + parseEntityIdentifiersFromUrlParam(encodeJsonForLegacyEntitySegment({ entityId: '' })) + ).toEqual({ entityId: undefined, identityFields: undefined }); + expect( + parseEntityIdentifiersFromUrlParam(encodeJsonForLegacyEntitySegment({ entityId: 99 })) + ).toEqual({ entityId: undefined, identityFields: undefined }); + }); + + it('extracts identityFields from keys other than entityId', () => { + expect( + parseEntityIdentifiersFromUrlParam( + encodeJsonForLegacyEntitySegment({ 'user.name': 'alice', 'user.domain': 'corp' }) + ) + ).toEqual({ + identityFields: { 'user.name': 'alice', 'user.domain': 'corp' }, + }); + }); + + it('returns both entityId and identityFields when both are present', () => { + expect( + parseEntityIdentifiersFromUrlParam( + encodeJsonForLegacyEntitySegment({ + entityId: 'id-1', + 'user.name': 'alice', + }) + ) + ).toEqual({ + entityId: 'id-1', + identityFields: { 'user.name': 'alice' }, + }); + }); + }); + + describe('getUsersDetailsUrl', () => { + const detailNameCases = [ + { userId: 'domain\\some_user', expectedPathSegment: 'domain%5Csome_user' }, + { userId: 'user&name', expectedPathSegment: 'user%26name' }, + { userId: 'normaluser', expectedPathSegment: 'normaluser' }, + { userId: 'user?name', expectedPathSegment: 'user%3Fname' }, + ]; + + it.each(detailNameCases)( + 'encodes the user name in the path for $userId', + ({ userId, expectedPathSegment }) => { + expect(getUsersDetailsUrl(userId)).toBe(`/name/${expectedPathSegment}`); + } + ); + + it('merges entityId when passed after urlStateQuery (parameter order)', () => { + const url = getUsersDetailsUrl('alice', undefined, undefined, 'user-entity-1'); + expect(url.startsWith('/name/alice?')).toBe(true); + expect(parseEntityResolutionFromUrlState(queryFromUrl(url))).toEqual({ + entityId: 'user-entity-1', + }); + }); + + it('merges identityFields when entityId is absent', () => { + const url = getUsersDetailsUrl('alice', undefined, { 'user.name': 'canonical' }); + expect(parseEntityResolutionFromUrlState(queryFromUrl(url))).toEqual({ + identityFields: { 'user.name': 'canonical' }, + }); + }); + + it('omits default user.name identityFields when entityId is present', () => { + const url = getUsersDetailsUrl('alice', undefined, { 'user.name': 'alice' }, 'id-1'); + expect(parseEntityResolutionFromUrlState(queryFromUrl(url))).toEqual({ entityId: 'id-1' }); + }); + + it('preserves existing URL state keys alongside entity resolution', () => { + const url = getUsersDetailsUrl('alice', 'timerange=(from:now-24h,to:now)', undefined, 'e1'); + expect(url).toContain('timerange='); + expect(parseEntityResolutionFromUrlState(queryFromUrl(url))).toEqual({ entityId: 'e1' }); + }); + }); + + describe('getTabsOnUsersDetailsUrl', () => { + it('includes tab in the path and merges resolution params', () => { + const url = getTabsOnUsersDetailsUrl('u1', UsersTableType.risk, undefined, 'ent', { + 'user.name': 'other', + }); + expect(url.startsWith(`/name/u1/${UsersTableType.risk}?`)).toBe(true); + expect(parseEntityResolutionFromUrlState(queryFromUrl(url))).toEqual({ + entityId: 'ent', + identityFields: { 'user.name': 'other' }, + }); + }); + }); + + describe('getTabsOnUsersUrl', () => { + it('uses string entityIdentifiers as entityId', () => { + const url = getTabsOnUsersUrl(UsersTableType.allUsers, undefined, 'single-id'); + expect(url.startsWith(`/${UsersTableType.allUsers}?`)).toBe(true); + expect(parseEntityResolutionFromUrlState(queryFromUrl(url))).toEqual({ + entityId: 'single-id', + }); + }); + + it('uses object entityIdentifiers as identityFields', () => { + const url = getTabsOnUsersUrl(UsersTableType.events, undefined, { + 'user.name': 'x', + }); + expect(url.startsWith(`/${UsersTableType.events}?`)).toBe(true); + expect(parseEntityResolutionFromUrlState(queryFromUrl(url))).toEqual({ + identityFields: { 'user.name': 'x' }, + }); + }); + + it('returns only the tab path when resolution and url state add nothing', () => { + expect(getTabsOnUsersUrl(UsersTableType.authentications)).toBe( + `/${UsersTableType.authentications}` + ); }); }); }); diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/components/link_to/redirect_to_users.tsx b/x-pack/solutions/security/plugins/security_solution/public/common/components/link_to/redirect_to_users.tsx index c62efe67f0fb1..e0223c683130f 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/common/components/link_to/redirect_to_users.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/common/components/link_to/redirect_to_users.tsx @@ -6,16 +6,89 @@ */ import type { UsersTableType } from '../../../explore/users/store/model'; -import { appendSearch } from './helpers'; +import { mergeEntityResolutionIntoUrlState } from './entity_resolution_query_params'; -export const getUsersDetailsUrl = (detailName: string, search?: string) => - `/name/${encodeURIComponent(detailName)}${appendSearch(search)}`; +export type EntityIdentifiers = Record; + +/** + * Decodes entity identifiers from a legacy base64url path segment. Returns null if invalid. + */ +export const decodeEntityIdentifiersFromUrl = (encoded: string): EntityIdentifiers | null => { + if (!encoded) return null; + try { + const base64 = encoded.replace(/-/g, '+').replace(/_/g, '/'); + const padded = base64.padEnd(base64.length + ((4 - (base64.length % 4)) % 4), '='); + const json = decodeURIComponent(escape(atob(padded))); + const parsed = JSON.parse(json) as EntityIdentifiers; + return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) ? parsed : null; + } catch { + return null; + } +}; + +/** + * Parses the legacy encoded entity-identifiers path segment (redirect compatibility). + */ +export const parseEntityIdentifiersFromUrlParam = ( + encoded: string | undefined +): { entityId?: string; identityFields?: Record } => { + if (encoded == null || encoded === '') { + return {}; + } + const decoded = decodeEntityIdentifiersFromUrl(encoded); + if (!decoded) { + return {}; + } + const { entityId: rawEntityId, ...rest } = decoded; + const entityId = typeof rawEntityId === 'string' && rawEntityId !== '' ? rawEntityId : undefined; + const identityFields = + Object.keys(rest).length > 0 ? (rest as Record) : undefined; + return { entityId, identityFields }; +}; + +export const getUsersDetailsUrl = ( + detailName: string, + urlStateQuery?: string, + identityFields?: Record, + entityId?: string +) => { + const base = `/name/${encodeURIComponent(detailName)}`; + const query = mergeEntityResolutionIntoUrlState(urlStateQuery, { + entityId, + identityFields, + displayName: detailName, + entityType: 'user', + }); + return query === '' ? base : `${base}${query}`; +}; export const getTabsOnUsersDetailsUrl = ( detailName: string, tabName: UsersTableType, - search?: string -) => `/name/${encodeURIComponent(detailName)}/${tabName}${appendSearch(search)}`; + urlStateQuery?: string, + entityId?: string, + identityFields?: Record +) => { + const base = `/name/${encodeURIComponent(detailName)}/${tabName}`; + const query = mergeEntityResolutionIntoUrlState(urlStateQuery, { + entityId, + identityFields, + displayName: detailName, + entityType: 'user', + }); + return query === '' ? base : `${base}${query}`; +}; -export const getTabsOnUsersUrl = (tabName: UsersTableType, search?: string) => - `/${tabName}${appendSearch(search)}`; +export const getTabsOnUsersUrl = ( + tabName: UsersTableType, + urlStateQuery?: string, + entityIdentifiers?: EntityIdentifiers | string +) => { + const resolution = + typeof entityIdentifiers === 'string' + ? { entityId: entityIdentifiers } + : { identityFields: entityIdentifiers }; + const query = mergeEntityResolutionIntoUrlState(urlStateQuery, resolution); + const tabPath = `/${tabName}`; + return query === '' ? tabPath : `${tabPath}${query}`; +}; diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/components/links/index.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/common/components/links/index.test.tsx index 9b49877b8c3e9..8360647b73f45 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/common/components/links/index.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/common/components/links/index.test.tsx @@ -11,6 +11,7 @@ import userEvent from '@testing-library/user-event'; import { removeExternalLinkText } from '@kbn/securitysolution-io-ts-utils'; import { encodeIpv6 } from '../../lib/helpers'; import { + EntityDetailsLink, GoogleLink, HostDetailsLink, NetworkDetailsLink, @@ -28,6 +29,8 @@ import { SecurityPageName } from '../../../app/types'; import { mockGetAppUrl, mockNavigateTo } from '@kbn/security-solution-navigation/mocks/navigation'; import { APP_UI_ID } from '../../../../common'; import { TestProviders } from '../../mock'; +import { getHostDetailsUrl, getUsersDetailsUrl } from '../link_to'; +import { EntityType } from '../../../../common/entity_analytics/types'; jest.mock('@kbn/security-solution-navigation/src/navigation'); jest.mock('../navigation/use_url_state_query_params'); @@ -61,6 +64,10 @@ describe('Custom Links', () => { const ipv6Encoded = encodeIpv6(ipv6); describe('HostDetailsLink', () => { + const expectedHostDetailsHref = getHostDetailsUrl(hostName, undefined, undefined, { + 'host.name': hostName, + }); + test('should render valid link to Host Details with hostName as the display text', () => { render( @@ -68,7 +75,7 @@ describe('Custom Links', () => { ); const link = screen.getByTestId('host-details-button'); - expect(link).toHaveAttribute('href', `/name/${encodeURIComponent(hostName)}`); + expect(link).toHaveAttribute('href', expectedHostDetailsHref); expect(link).toHaveTextContent(hostName); }); @@ -79,11 +86,29 @@ describe('Custom Links', () => { ); const link = screen.getByTestId('host-details-button'); - expect(link).toHaveAttribute('href', `/name/${encodeURIComponent(hostName)}`); + expect(link).toHaveAttribute('href', expectedHostDetailsHref); expect(link).toHaveTextContent(hostName); }); }); + describe('EntityDetailsLink', () => { + test('forwards entityId for user entities (parity with host branch)', () => { + const userName = 'test-user'; + const entityId = 'entity-store-id'; + const expectedHref = getUsersDetailsUrl(userName, undefined, undefined, entityId); + render( + + + + ); + expect(screen.getByTestId('users-link-anchor')).toHaveAttribute('href', expectedHref); + }); + }); + describe('NetworkDetailsLink', () => { test('can handle array of ips', () => { const { container } = render( diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/components/links/index.tsx b/x-pack/solutions/security/plugins/security_solution/public/common/components/links/index.tsx index e5dea2d10770c..8f956c952f631 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/common/components/links/index.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/common/components/links/index.tsx @@ -64,34 +64,64 @@ const UserDetailsLinkComponent: React.FC<{ title?: string; isButton?: boolean; onClick?: (e: SyntheticEvent) => void; -}> = ({ children, Component, userName, isButton, onClick: onClickParam, title, userTab }) => { - const encodedUserName = encodeURIComponent(userName); - const { formatUrl, search } = useFormatUrl(SecurityPageName.users); + entityId?: string; + identityFields?: Record; +}> = ({ + children, + Component, + userName, + isButton, + onClick: onClickParam, + title, + userTab, + entityId, + identityFields, +}) => { + const { formatUrl, search: urlStateQuery } = useFormatUrl(SecurityPageName.users); const { application: { navigateToApp }, telemetry, } = useKibana().services; + const resolutionIdentityFields = useMemo( + () => + identityFields ?? + (entityId === undefined || entityId === '' ? { 'user.name': userName } : undefined), + [entityId, identityFields, userName] + ); + const goToUsersDetails = useCallback( (ev: SyntheticEvent) => { ev.preventDefault(); navigateToApp(APP_UI_ID, { deepLinkId: SecurityPageName.users, path: userTab - ? getTabsOnUsersDetailsUrl(encodedUserName, userTab, search) - : getUsersDetailsUrl(encodedUserName, search), + ? getTabsOnUsersDetailsUrl( + userName, + userTab, + urlStateQuery, + entityId, + resolutionIdentityFields + ) + : getUsersDetailsUrl(userName, urlStateQuery, resolutionIdentityFields, entityId), }); }, - [encodedUserName, navigateToApp, search, userTab] + [userName, navigateToApp, urlStateQuery, userTab, entityId, resolutionIdentityFields] ); const href = useMemo( () => formatUrl( userTab - ? getTabsOnUsersDetailsUrl(encodedUserName, userTab) - : getUsersDetailsUrl(encodedUserName) + ? getTabsOnUsersDetailsUrl( + userName, + userTab, + undefined, + entityId, + resolutionIdentityFields + ) + : getUsersDetailsUrl(userName, undefined, resolutionIdentityFields, entityId) ), - [formatUrl, encodedUserName, userTab] + [formatUrl, userName, userTab, entityId, resolutionIdentityFields] ); const onClick = useCallback( @@ -159,6 +189,8 @@ export interface HostDetailsLinkProps { onClick?: (e: SyntheticEvent) => void; hostTab?: HostsTableType; title?: string; + entityId?: string; + identityFields?: Record; } const HostDetailsLinkComponent: React.FC = ({ children, @@ -168,29 +200,54 @@ const HostDetailsLinkComponent: React.FC = ({ onClick: onClickParam, title, hostTab, + entityId, + identityFields, }) => { - const { formatUrl, search } = useFormatUrl(SecurityPageName.hosts); + const { formatUrl, search: urlStateQuery } = useFormatUrl(SecurityPageName.hosts); const { application: { navigateToApp }, telemetry, } = useKibana().services; + const resolutionIdentityFields = useMemo( + () => + identityFields ?? + (entityId === undefined || entityId === '' ? { 'host.name': hostName } : undefined), + [entityId, hostName, identityFields] + ); + const goToHostDetails = useCallback( (ev: SyntheticEvent) => { ev.preventDefault(); navigateToApp(APP_UI_ID, { deepLinkId: SecurityPageName.hosts, path: hostTab - ? getTabsOnHostDetailsUrl(hostName, hostTab, search) - : getHostDetailsUrl(hostName, search), + ? getTabsOnHostDetailsUrl( + hostName, + hostTab, + urlStateQuery, + entityId, + resolutionIdentityFields + ) + : getHostDetailsUrl(hostName, urlStateQuery, entityId, resolutionIdentityFields), }); }, - [hostName, navigateToApp, search, hostTab] + [navigateToApp, hostTab, hostName, urlStateQuery, entityId, resolutionIdentityFields] ); const href = useMemo( () => - formatUrl(hostTab ? getTabsOnHostDetailsUrl(hostName, hostTab) : getHostDetailsUrl(hostName)), - [formatUrl, hostName, hostTab] + formatUrl( + hostTab + ? getTabsOnHostDetailsUrl( + hostName, + hostTab, + undefined, + entityId, + resolutionIdentityFields + ) + : getHostDetailsUrl(hostName, undefined, entityId, resolutionIdentityFields) + ), + [formatUrl, hostName, hostTab, entityId, resolutionIdentityFields] ); const onClick = useCallback( @@ -233,17 +290,37 @@ export interface EntityDetailsLinkProps { tab?: HostsTableType | UsersTableType; title?: string; entityType: EntityType; + entityId?: string; + identityFields?: Record; } export const EntityDetailsLink = ({ entityType, tab, entityName, + entityId, + identityFields, ...props }: EntityDetailsLinkProps) => { if (entityType === EntityType.host) { - return ; + return ( + + ); } else if (entityType === EntityType.user) { - return ; + return ( + + ); } else if (entityType === EntityType.service) { return ; } 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 new file mode 100644 index 0000000000000..c841dd7c32d16 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/common/components/ml/anomaly/anomaly_table_euid.test.ts @@ -0,0 +1,163 @@ +/* + * 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 { EntityStoreEuid } from '@kbn/entity-store/public'; + +import type { Anomaly } from '../types'; +import { + anomalyMatchesMlEntityField, + anomalyRowMatchesIdentityIdentifiers, + buildAnomaliesTableInfluencersFilterQuery, + buildBroadMlIdentityFieldsExistFilter, + buildEuidSampleDocumentForAnomaliesTable, + getCriteriaFieldsForAnomaliesTable, +} from './anomaly_table_euid'; + +describe('anomaly_table_euid', () => { + describe('buildEuidSampleDocumentForAnomaliesTable', () => { + test('merges trimmed identity fields and falls back to user.name', () => { + expect( + buildEuidSampleDocumentForAnomaliesTable('user', { 'user.id': ' u1 ' }, 'alice') + ).toEqual({ + 'user.id': 'u1', + }); + expect(buildEuidSampleDocumentForAnomaliesTable('user', {}, 'alice')).toEqual({ + 'user.name': 'alice', + }); + }); + }); + + describe('buildBroadMlIdentityFieldsExistFilter', () => { + test('OR exists across identity source fields from EUID definitions', () => { + const euid = { + getEuidSourceFields: () => ({ + requiresOneOf: ['user.email', 'user.name'], + identitySourceFields: ['user.email', 'user.name'], + }), + } as unknown as EntityStoreEuid; + + expect(buildBroadMlIdentityFieldsExistFilter(euid, 'user')).toEqual({ + bool: { + should: [{ exists: { field: 'user.email' } }, { exists: { field: 'user.name' } }], + minimum_should_match: 1, + }, + }); + }); + }); + + describe('buildAnomaliesTableInfluencersFilterQuery', () => { + test('uses legacy single-field exists when EUID API is unavailable', () => { + expect( + buildAnomaliesTableInfluencersFilterQuery({ + euid: undefined, + entityType: 'user', + isScopedToEntity: false, + }) + ).toEqual({ exists: { field: 'user.name' } }); + }); + + test('scoped path prefers dsl.getEuidFilterBasedOnDocument when it returns a query', () => { + const scopedDsl = { bool: { filter: [{ term: { 'user.email': 'a@b.c' } }] } }; + const euid = { + dsl: { + getEuidFilterBasedOnDocument: jest.fn().mockReturnValue(scopedDsl), + }, + getEuidSourceFields: () => ({ + requiresOneOf: ['user.name'], + identitySourceFields: ['user.name'], + }), + } as unknown as EntityStoreEuid; + + const q = buildAnomaliesTableInfluencersFilterQuery({ + euid, + entityType: 'user', + isScopedToEntity: true, + identityFields: { 'user.email': 'a@b.c' }, + fallbackDisplayName: 'alice', + }); + expect(q).toEqual(scopedDsl); + }); + }); + + describe('getCriteriaFieldsForAnomaliesTable', () => { + test('returns empty criteria when scoped DSL is produced (terms live in influencers filter)', () => { + const euid = { + dsl: { + getEuidFilterBasedOnDocument: jest.fn().mockReturnValue({ bool: { filter: [] } }), + }, + getEntityIdentifiersFromDocument: jest.fn(), + } as unknown as EntityStoreEuid; + + expect( + getCriteriaFieldsForAnomaliesTable({ + euid, + entityType: 'user', + isScopedToEntity: true, + identityFields: { 'user.name': 'bob' }, + fallbackDisplayName: 'bob', + }) + ).toEqual([]); + }); + + test('falls back to identifier map terms when DSL is not available', () => { + const euid = { + dsl: { + getEuidFilterBasedOnDocument: jest.fn().mockReturnValue(undefined), + }, + getEntityIdentifiersFromDocument: jest + .fn() + .mockReturnValue({ 'user.id': 'uid-9', 'user.name': 'bob' }), + } as unknown as EntityStoreEuid; + + expect( + getCriteriaFieldsForAnomaliesTable({ + euid, + entityType: 'user', + isScopedToEntity: true, + fallbackDisplayName: 'bob', + }) + ).toEqual([ + { fieldName: 'user.id', fieldValue: 'uid-9' }, + { fieldName: 'user.name', fieldValue: 'bob' }, + ]); + }); + }); + + describe('anomalyRowMatchesIdentityIdentifiers', () => { + test('matches partition entity or influencer key', () => { + const identifiers = { 'user.name': 'root' }; + expect( + anomalyRowMatchesIdentityIdentifiers( + { entityName: 'user.name', entityValue: 'root', influencers: [] } as unknown as Anomaly, + identifiers + ) + ).toEqual(true); + expect( + anomalyRowMatchesIdentityIdentifiers( + { + entityName: 'host.name', + entityValue: 'x', + influencers: [{ 'user.name': 'root' }], + } as unknown as Anomaly, + identifiers + ) + ).toEqual(true); + }); + }); + + describe('anomalyMatchesMlEntityField', () => { + test('legacy single-field behavior', () => { + expect( + anomalyMatchesMlEntityField( + { entityName: 'user.name', entityValue: 'u' } as Anomaly, + 'user.name', + 'u' + ) + ).toEqual(true); + }); + }); +}); 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 new file mode 100644 index 0000000000000..3eac5975d5ead --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/common/components/ml/anomaly/anomaly_table_euid.ts @@ -0,0 +1,203 @@ +/* + * 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 { estypes } from '@elastic/elasticsearch'; +import type { EntityStoreEuid } from '@kbn/entity-store/public'; + +import type { Anomaly, CriteriaFields } from '../types'; + +/** Entity kinds that have Security Solution host/user anomaly tables. */ +export type AnomaliesTableEntityType = 'host' | 'user'; + +const hasUsableStringValues = (fields: Record): boolean => + Object.values(fields).some((v) => typeof v === 'string' && v.trim() !== ''); + +/** + * Builds a sample document for EUID helpers from URL/query identity plus optional legacy display name. + * Flattened ECS keys are accepted by {@link EntityStoreEuid.getEntityIdentifiersFromDocument} / + * {@link EntityStoreEuid.dsl.getEuidFilterBasedOnDocument}. + */ +export const buildEuidSampleDocumentForAnomaliesTable = ( + entityType: AnomaliesTableEntityType, + identityFields?: Record, + fallbackDisplayName?: string +): Record => { + const base: Record = {}; + if (identityFields) { + for (const [k, v] of Object.entries(identityFields)) { + if (typeof v === 'string' && v.trim() !== '') { + base[k] = v.trim(); + } + } + } + if ( + !hasUsableStringValues(base) && + typeof fallbackDisplayName === 'string' && + fallbackDisplayName.trim() !== '' + ) { + if (entityType === 'user') { + base['user.name'] = fallbackDisplayName.trim(); + } else { + base['host.name'] = fallbackDisplayName.trim(); + } + } + return base; +}; + +/** + * Broad pre-filter for ML anomaly indices: at least one identity-ranking field exists on the record. + * Uses {@link EntityStoreEuid.getEuidSourceFields} so new identity fields in definitions apply automatically. + * + * Not {@link EntityStoreEuid.dsl.getEuidDocumentsContainsIdFilter}: that targets log-shaped docs (`event.*`, etc.) + * and would exclude `.ml-anomalies-*` records. + */ +export const buildBroadMlIdentityFieldsExistFilter = ( + euid: EntityStoreEuid, + entityType: AnomaliesTableEntityType +): estypes.QueryDslQueryContainer => { + const { identitySourceFields } = euid.getEuidSourceFields(entityType); + return { + bool: { + should: identitySourceFields.map((field) => ({ exists: { field } })), + minimum_should_match: 1, + }, + }; +}; + +export const buildAnomaliesTableInfluencersFilterQuery = ({ + euid, + entityType, + isScopedToEntity, + identityFields, + fallbackDisplayName, +}: { + euid: EntityStoreEuid | undefined; + entityType: AnomaliesTableEntityType; + 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); + if (scoped != null) { + return scoped as estypes.QueryDslQueryContainer; + } + } + return buildBroadMlIdentityFieldsExistFilter(euid, entityType); + } + return entityType === 'user' + ? { exists: { field: 'user.name' } } + : { exists: { field: 'host.name' } }; +}; + +export const getCriteriaFieldsForAnomaliesTable = ({ + euid, + entityType, + isScopedToEntity, + identityFields, + fallbackDisplayName, +}: { + euid: EntityStoreEuid | undefined; + entityType: AnomaliesTableEntityType; + isScopedToEntity: boolean; + identityFields?: Record; + fallbackDisplayName?: string; +}): CriteriaFields[] => { + if (!isScopedToEntity || fallbackDisplayName == null) { + return []; + } + if (euid) { + const doc = buildEuidSampleDocumentForAnomaliesTable( + entityType, + identityFields, + fallbackDisplayName + ); + const scopedDsl = euid.dsl.getEuidFilterBasedOnDocument(entityType, doc); + if (scopedDsl != null) { + return []; + } + const identifiers = euid.getEntityIdentifiersFromDocument(entityType, doc); + if (identifiers != null && Object.keys(identifiers).length > 0) { + return Object.entries(identifiers).map(([fieldName, fieldValue]) => ({ + fieldName, + fieldValue, + })); + } + } + return [ + { + fieldName: entityType === 'user' ? 'user.name' : 'host.name', + fieldValue: fallbackDisplayName, + }, + ]; +}; + +export const anomalyMatchesMlEntityField = ( + anomaly: Anomaly, + entityField: string, + entityValue: string | undefined +): boolean => { + if (anomaly.entityName !== entityField) { + return false; + } + if (entityValue == null) { + return true; + } + return anomaly.entityValue === entityValue; +}; + +export const anomalyRowMatchesIdentityIdentifiers = ( + anomaly: Anomaly, + identifiers: Record +): boolean => { + if (anomaly.entityName != null && identifiers[anomaly.entityName] !== undefined) { + return String(anomaly.entityValue) === identifiers[anomaly.entityName]; + } + for (const infl of anomaly.influencers ?? []) { + for (const [k, v] of Object.entries(infl)) { + if (identifiers[k] !== undefined && identifiers[k] === v) { + return true; + } + } + } + return false; +}; + +export const anomalyEntityNameInEuidIdentitySourceFields = ( + anomaly: Anomaly, + euid: EntityStoreEuid, + entityType: AnomaliesTableEntityType +): boolean => { + if (anomaly.entityName == null) { + return false; + } + const { identitySourceFields } = euid.getEuidSourceFields(entityType); + return identitySourceFields.includes(anomaly.entityName); +}; + +export const pickAnomalyRowLabelMatchingIdentifiers = ( + anomaly: Anomaly, + identifiers: Record +): string => { + if (anomaly.entityName != null && identifiers[anomaly.entityName] !== undefined) { + return String(anomaly.entityValue); + } + for (const infl of anomaly.influencers ?? []) { + for (const [k, v] of Object.entries(infl)) { + if (identifiers[k] === v) { + return v; + } + } + } + return String(anomaly.entityValue); +}; diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/components/ml/anomaly/anomaly_table_provider.tsx b/x-pack/solutions/security/plugins/security_solution/public/common/components/ml/anomaly/anomaly_table_provider.tsx index fdea3d011c623..1625933ee79ec 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/common/components/ml/anomaly/anomaly_table_provider.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/common/components/ml/anomaly/anomaly_table_provider.tsx @@ -5,6 +5,7 @@ * 2.0. */ +import type { estypes } from '@elastic/elasticsearch'; import React, { useMemo } from 'react'; import { useInstalledSecurityJobNameById } from '../hooks/use_installed_security_jobs'; import type { InfluencerInput, Anomalies, CriteriaFields } from '../types'; @@ -23,10 +24,11 @@ interface Props { criteriaFields?: CriteriaFields[]; children: (args: AnomalyTableProviderChildrenProps) => React.ReactNode; skip: boolean; + filterQuery?: estypes.QueryDslQueryContainer; } export const AnomalyTableProvider = React.memo( - ({ influencers, startDate, endDate, children, criteriaFields, skip }) => { + ({ influencers, startDate, endDate, children, criteriaFields, skip, filterQuery }) => { const { jobNameById } = useInstalledSecurityJobNameById(); const jobIds = useMemo(() => Object.keys(jobNameById), [jobNameById]); @@ -38,6 +40,7 @@ export const AnomalyTableProvider = React.memo( skip, jobIds, aggregationInterval: 'auto', + filterQuery, }); return <>{children({ isLoadingAnomaliesData, anomaliesData, jobNameById })}; } diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/components/ml/anomaly/use_anomalies_table_data.ts b/x-pack/solutions/security/plugins/security_solution/public/common/components/ml/anomaly/use_anomalies_table_data.ts index f3a6c935c472b..4dc5720e4eac2 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/common/components/ml/anomaly/use_anomalies_table_data.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/common/components/ml/anomaly/use_anomalies_table_data.ts @@ -74,6 +74,11 @@ export const useAnomaliesTableData = ({ } }, [error, addError]); + const influencersFilterQueryKey = useMemo( + () => (filterQuery == null ? '' : JSON.stringify(filterQuery)), + [filterQuery] + ); + useEffect(() => { if (isMlUser && jobIds.length > 0) { fetch({ @@ -96,6 +101,7 @@ export const useAnomaliesTableData = ({ influencersOrCriteriaToString(influencers), // eslint-disable-next-line react-hooks/exhaustive-deps influencersOrCriteriaToString(criteriaFields), + influencersFilterQueryKey, startDateMs, endDateMs, isMlUser, diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/components/ml/criteria/get_criteria_from_host_type.test.ts b/x-pack/solutions/security/plugins/security_solution/public/common/components/ml/criteria/get_criteria_from_host_type.test.ts index 10cec0f6adde3..44fc89a8421eb 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/common/components/ml/criteria/get_criteria_from_host_type.test.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/common/components/ml/criteria/get_criteria_from_host_type.test.ts @@ -5,6 +5,8 @@ * 2.0. */ +import type { EntityStoreEuid } from '@kbn/entity-store/public'; + import { getCriteriaFromHostType } from './get_criteria_from_host_type'; import { HostsType } from '../../../../explore/hosts/store/model'; @@ -23,4 +25,38 @@ describe('get_criteria_from_host_type', () => { const criteria = getCriteriaFromHostType(HostsType.details, undefined); expect(criteria).toEqual([]); }); + + test('without EUID API, identity fields alone still use legacy host.name criteria', () => { + const criteria = getCriteriaFromHostType(HostsType.details, 'zeek-iowa', { + 'host.id': 'hid-1', + 'host.name': 'zeek-iowa', + }); + expect(criteria).toEqual([{ fieldName: 'host.name', fieldValue: 'zeek-iowa' }]); + }); + + test('with EUID API, uses identifier map when scoped DSL is unavailable', () => { + const euid = { + dsl: { + getEuidFilterBasedOnDocument: jest.fn().mockReturnValue(undefined), + }, + getEntityIdentifiersFromDocument: jest.fn().mockReturnValue({ + 'host.id': 'hid-1', + 'host.name': 'zeek-iowa', + }), + } as unknown as EntityStoreEuid; + + const criteria = getCriteriaFromHostType( + HostsType.details, + 'zeek-iowa', + { + 'host.id': 'hid-1', + 'host.name': 'zeek-iowa', + }, + euid + ); + expect(criteria).toEqual([ + { fieldName: 'host.id', fieldValue: 'hid-1' }, + { fieldName: 'host.name', fieldValue: 'zeek-iowa' }, + ]); + }); }); diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/components/ml/criteria/get_criteria_from_host_type.ts b/x-pack/solutions/security/plugins/security_solution/public/common/components/ml/criteria/get_criteria_from_host_type.ts index 2e9b912dc5b4a..be9e3ef8e286a 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/common/components/ml/criteria/get_criteria_from_host_type.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/common/components/ml/criteria/get_criteria_from_host_type.ts @@ -5,16 +5,26 @@ * 2.0. */ +import type { EntityStoreEuid } from '@kbn/entity-store/public'; + import { HostsType } from '../../../../explore/hosts/store/model'; import type { CriteriaFields } from '../types'; +import { getCriteriaFieldsForAnomaliesTable } from '../anomaly/anomaly_table_euid'; export const getCriteriaFromHostType = ( type: HostsType, - hostName: string | undefined + hostName: string | undefined, + identityFields?: Record, + euid?: EntityStoreEuid ): CriteriaFields[] => { - if (type === HostsType.details && hostName != null) { - return [{ fieldName: 'host.name', fieldValue: hostName }]; - } else { + if (type !== HostsType.details || hostName == null) { return []; } + return getCriteriaFieldsForAnomaliesTable({ + euid, + entityType: 'host', + isScopedToEntity: true, + identityFields, + fallbackDisplayName: hostName, + }); }; 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 01cd61ef341a6..c56dcb2f08973 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 @@ -5,6 +5,8 @@ * 2.0. */ +import type { EntityStoreEuid } from '@kbn/entity-store/public'; + import { UsersType } from '../../../../explore/users/store/model'; import { getCriteriaFromUsersType } from './get_criteria_from_users_type'; @@ -23,4 +25,38 @@ describe('get_criteria_from_user_type', () => { const criteria = getCriteriaFromUsersType(UsersType.details, 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', + }); + expect(criteria).toEqual([{ fieldName: 'user.name', fieldValue: 'admin' }]); + }); + + test('with EUID API, defers to getEntityIdentifiersFromDocument when scoped DSL is unavailable', () => { + const euid = { + dsl: { + getEuidFilterBasedOnDocument: jest.fn().mockReturnValue(undefined), + }, + getEntityIdentifiersFromDocument: jest.fn().mockReturnValue({ + 'user.id': 'uid-1', + 'user.name': 'from-identity', + }), + } as unknown as EntityStoreEuid; + + const criteria = getCriteriaFromUsersType( + UsersType.details, + 'admin', + { + 'user.id': 'uid-1', + 'user.name': 'from-identity', + }, + euid + ); + expect(criteria).toEqual([ + { fieldName: 'user.id', fieldValue: 'uid-1' }, + { fieldName: 'user.name', fieldValue: 'from-identity' }, + ]); + }); }); 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 daec38c7712d1..62332d1275860 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 @@ -5,16 +5,26 @@ * 2.0. */ +import type { EntityStoreEuid } from '@kbn/entity-store/public'; + import { UsersType } from '../../../../explore/users/store/model'; import type { CriteriaFields } from '../types'; +import { getCriteriaFieldsForAnomaliesTable } from '../anomaly/anomaly_table_euid'; export const getCriteriaFromUsersType = ( type: UsersType, - userName: string | undefined + userName: string | undefined, + identityFields?: Record, + euid?: EntityStoreEuid ): CriteriaFields[] => { - if (type === UsersType.details && userName != null) { - return [{ fieldName: 'user.name', fieldValue: userName }]; - } else { + if (type !== UsersType.details || userName == null) { return []; } + return getCriteriaFieldsForAnomaliesTable({ + euid, + entityType: 'user', + 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 54fee52a30867..36e92b1adab89 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 @@ -41,4 +41,19 @@ describe('host_to_criteria', () => { }; expect(hostToCriteria(hostItem)).toEqual([]); }); + + test('prefers host.id over host.name when id is non-empty', () => { + const hostItem: HostItem = { + host: { + id: ['hid-1'], + name: ['host-name'], + }, + }; + expect(hostToCriteria(hostItem)).toEqual([ + { + fieldName: 'host.id', + fieldValue: 'hid-1', + }, + ]); + }); }); 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 895f2521b244f..af37ec906e665 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 @@ -5,22 +5,42 @@ * 2.0. */ +import type { EntityStoreEuid } from '@kbn/entity-store/public'; + import type { HostItem } from '../../../../../common/search_strategy/security_solution/hosts'; import type { CriteriaFields } from '../types'; -export const hostToCriteria = (hostItem: HostItem): CriteriaFields[] => { +export const hostToCriteria = (hostItem: HostItem, euid?: EntityStoreEuid): CriteriaFields[] => { if (hostItem == null) { return []; } - if (hostItem.host != null && hostItem.host.name != null) { - const criteria: CriteriaFields[] = [ + if (euid) { + const scopedDsl = euid.dsl.getEuidFilterBasedOnDocument('host', hostItem); + if (scopedDsl != null) { + return []; + } + const identifiers = euid.getEntityIdentifiersFromDocument('host', hostItem); + if (identifiers != null && Object.keys(identifiers).length > 0) { + return Object.entries(identifiers).map(([fieldName, fieldValue]) => ({ + fieldName, + fieldValue, + })); + } + } + if (hostItem.host == null) { + return []; + } + const hostId = hostItem.host.id?.[0]; + if (typeof hostId === 'string' && hostId.trim() !== '') { + return [{ fieldName: 'host.id', fieldValue: hostId.trim() }]; + } + if (hostItem.host.name != null) { + return [ { fieldName: 'host.name', fieldValue: hostItem.host.name[0], }, ]; - return criteria; - } else { - return []; } + return []; }; diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/components/ml/influencers/get_host_name_from_influencers.ts b/x-pack/solutions/security/plugins/security_solution/public/common/components/ml/influencers/get_host_name_from_influencers.ts index 6c0698eab8ce3..4a9c6030d4cf4 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/common/components/ml/influencers/get_host_name_from_influencers.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/common/components/ml/influencers/get_host_name_from_influencers.ts @@ -9,11 +9,12 @@ import { getEntries } from '../get_entries'; export const getHostNameFromInfluencers = ( influencers: Array> = [], - hostName?: string + hostName?: string, + influencerFieldName: string = 'host.name' ): string | null => { const recordFound = influencers.find((influencer) => { const [influencerName, influencerValue] = getEntries(influencer); - if (influencerName === 'host.name') { + if (influencerName === influencerFieldName) { if (hostName == null) { return true; } else { diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/components/ml/influencers/get_user_name_from_influencers.ts b/x-pack/solutions/security/plugins/security_solution/public/common/components/ml/influencers/get_user_name_from_influencers.ts index da27c10aeaf85..7fa362fe775d1 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/common/components/ml/influencers/get_user_name_from_influencers.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/common/components/ml/influencers/get_user_name_from_influencers.ts @@ -9,11 +9,12 @@ import { getEntries } from '../get_entries'; export const getUserNameFromInfluencers = ( influencers: Array> = [], - userName?: string + userName?: string, + influencerFieldName: string = 'user.name' ): string | null => { const recordFound = influencers.find((influencer) => { const [influencerName, influencerValue] = getEntries(influencer); - if (influencerName === 'user.name') { + if (influencerName === influencerFieldName) { if (userName == null) { return true; } else { diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/components/ml/tables/anomalies_host_table.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/common/components/ml/tables/anomalies_host_table.test.tsx index 13139c3a369d4..e18cc6c5c79a6 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/common/components/ml/tables/anomalies_host_table.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/common/components/ml/tables/anomalies_host_table.test.tsx @@ -22,6 +22,9 @@ jest.mock('../anomaly/use_anomalies_table_data'); jest.mock('../../../../../common/machine_learning/has_ml_user_permissions'); jest.mock('../hooks/use_installed_security_jobs'); jest.mock('@kbn/ml-plugin/public'); +jest.mock('@kbn/entity-store/public', () => ({ + useEntityStoreEuidApi: jest.fn(() => ({ euid: undefined })), +})); const mockUseQueryToggle = useQueryToggle as jest.Mock; const mockUseAnomaliesTableData = useAnomaliesTableData as jest.Mock; diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/components/ml/tables/anomalies_host_table.tsx b/x-pack/solutions/security/plugins/security_solution/public/common/components/ml/tables/anomalies_host_table.tsx index 323d67f566cc8..6588d2c51c966 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/common/components/ml/tables/anomalies_host_table.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/common/components/ml/tables/anomalies_host_table.tsx @@ -9,6 +9,7 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { useDispatch } from 'react-redux'; import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { useEntityStoreEuidApi } from '@kbn/entity-store/public'; import { useAnomaliesTableData } from '../anomaly/use_anomalies_table_data'; import { HeaderSection } from '../../header_section'; import { hasMlUserPermissions } from '../../../../../common/machine_learning/has_ml_user_permissions'; @@ -19,6 +20,7 @@ import { Loader } from '../../loader'; import type { AnomaliesHostTableProps } from '../types'; import { useMlCapabilities } from '../hooks/use_ml_capabilities'; import { BasicTable } from './basic_table'; +import { buildAnomaliesTableInfluencersFilterQuery } from '../anomaly/anomaly_table_euid'; import { getCriteriaFromHostType } from '../criteria/get_criteria_from_host_type'; import { Panel } from '../../panel'; import { useQueryToggle } from '../../../containers/query_toggle'; @@ -28,6 +30,7 @@ import type { State } from '../../../store'; import { JobIdFilter } from './job_id_filter'; import { SelectInterval } from './select_interval'; import { hostsActions, hostsSelectors } from '../../../../explore/hosts/store'; +import { HostsType } from '../../../../explore/hosts/store/model'; const sorting = { sort: { @@ -42,6 +45,7 @@ const AnomaliesHostTableComponent: React.FC = ({ hostName, skip, type, + identityFields, }) => { const dispatch = useDispatch(); const capabilities = useMlCapabilities(); @@ -61,6 +65,8 @@ const AnomaliesHostTableComponent: React.FC = ({ const { jobNameById, loading: loadingJobs } = useInstalledSecurityJobNameById(); const jobIds = useMemo(() => Object.keys(jobNameById), [jobNameById]); + const euidApi = useEntityStoreEuidApi(); + const euid = euidApi?.euid; const getAnomaliesHostsTableFilterQuerySelector = useMemo( () => hostsSelectors.hostsAnomaliesJobIdFilterSelector(), @@ -103,19 +109,32 @@ const AnomaliesHostTableComponent: React.FC = ({ [dispatch, type] ); + const identitySignature = JSON.stringify(identityFields ?? {}); + const isScopedToEntity = type === HostsType.details && hostName != null; + const anomaliesInfluencersFilterQuery = useMemo( + () => + buildAnomaliesTableInfluencersFilterQuery({ + euid, + entityType: 'host', + isScopedToEntity, + identityFields, + fallbackDisplayName: hostName, + }), + // eslint-disable-next-line react-hooks/exhaustive-deps + [euid, isScopedToEntity, hostName, identitySignature] + ); + const [loadingTable, tableData] = useAnomaliesTableData({ startDate, endDate, skip: querySkip, - criteriaFields: getCriteriaFromHostType(type, hostName), - filterQuery: { - exists: { field: 'host.name' }, - }, + criteriaFields: getCriteriaFromHostType(type, hostName, identityFields, euid), + filterQuery: anomaliesInfluencersFilterQuery, jobIds: selectedJobIds.length > 0 ? selectedJobIds : jobIds, aggregationInterval: selectedInterval, }); - const hosts = convertAnomaliesToHosts(tableData, jobNameById, hostName); + const hosts = convertAnomaliesToHosts(tableData, jobNameById, hostName, identityFields, euid); const columns = getAnomaliesHostTableColumnsCurated(type, startDate, endDate); const pagination = { diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/components/ml/tables/anomalies_user_table.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/common/components/ml/tables/anomalies_user_table.test.tsx index c4384979801c2..2f7d4353c85b4 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/common/components/ml/tables/anomalies_user_table.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/common/components/ml/tables/anomalies_user_table.test.tsx @@ -21,6 +21,9 @@ jest.mock('../anomaly/use_anomalies_table_data'); jest.mock('../../../../../common/machine_learning/has_ml_user_permissions'); jest.mock('../hooks/use_installed_security_jobs'); jest.mock('@kbn/ml-plugin/public'); +jest.mock('@kbn/entity-store/public', () => ({ + useEntityStoreEuidApi: jest.fn(() => ({ euid: undefined })), +})); const mockUseQueryToggle = useQueryToggle as jest.Mock; const mockUseAnomaliesTableData = useAnomaliesTableData as jest.Mock; 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 b93e2037776f1..34f353c661ab4 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 @@ -9,6 +9,7 @@ import React, { useCallback, useEffect, useState, useMemo } from 'react'; import { useDispatch } from 'react-redux'; import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { useEntityStoreEuidApi } from '@kbn/entity-store/public'; import { useAnomaliesTableData } from '../anomaly/use_anomalies_table_data'; import { HeaderSection } from '../../header_section'; @@ -20,6 +21,7 @@ import type { AnomaliesUserTableProps } from '../types'; import { useMlCapabilities } from '../hooks/use_ml_capabilities'; import { BasicTable } from './basic_table'; +import { buildAnomaliesTableInfluencersFilterQuery } from '../anomaly/anomaly_table_euid'; import { getCriteriaFromUsersType } from '../criteria/get_criteria_from_users_type'; import { Panel } from '../../panel'; import { convertAnomaliesToUsers } from './convert_anomalies_to_users'; @@ -29,6 +31,7 @@ import { JobIdFilter } from './job_id_filter'; import { SelectInterval } from './select_interval'; import { useDeepEqualSelector } from '../../../hooks/use_selector'; import { usersActions, usersSelectors } from '../../../../explore/users/store'; +import { UsersType } from '../../../../explore/users/store/model'; import type { State } from '../../../store/types'; import { useInstalledSecurityJobNameById } from '../hooks/use_installed_security_jobs'; @@ -45,6 +48,7 @@ const AnomaliesUserTableComponent: React.FC = ({ userName, skip, type, + identityFields, }) => { const dispatch = useDispatch(); const capabilities = useMlCapabilities(); @@ -65,6 +69,8 @@ const AnomaliesUserTableComponent: React.FC = ({ const { jobNameById, loading: loadingJobs } = useInstalledSecurityJobNameById(); const jobIds = useMemo(() => Object.keys(jobNameById), [jobNameById]); + const euidApi = useEntityStoreEuidApi(); + const euid = euidApi?.euid; const getAnomaliesUserTableFilterQuerySelector = useMemo( () => usersSelectors.usersAnomaliesJobIdFilterSelector(), @@ -108,19 +114,32 @@ const AnomaliesUserTableComponent: React.FC = ({ [dispatch, type] ); + const identitySignature = JSON.stringify(identityFields ?? {}); + const isScopedToEntity = type === UsersType.details && userName != null; + const anomaliesInfluencersFilterQuery = useMemo( + () => + buildAnomaliesTableInfluencersFilterQuery({ + euid, + entityType: 'user', + isScopedToEntity, + identityFields, + fallbackDisplayName: userName, + }), + // eslint-disable-next-line react-hooks/exhaustive-deps + [euid, isScopedToEntity, userName, identitySignature] + ); + const [loadingTable, tableData] = useAnomaliesTableData({ startDate, endDate, skip: querySkip, - criteriaFields: getCriteriaFromUsersType(type, userName), - filterQuery: { - exists: { field: 'user.name' }, - }, + criteriaFields: getCriteriaFromUsersType(type, userName, identityFields, euid), + filterQuery: anomaliesInfluencersFilterQuery, jobIds: selectedJobIds.length > 0 ? selectedJobIds : jobIds, aggregationInterval: selectedInterval, }); - const users = convertAnomaliesToUsers(tableData, jobNameById, userName); + const users = convertAnomaliesToUsers(tableData, jobNameById, userName, identityFields, euid); const columns = getAnomaliesUserTableColumnsCurated(type, startDate, endDate); const pagination = { initialPageIndex: 0, diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/components/ml/tables/convert_anomalies_to_hosts.ts b/x-pack/solutions/security/plugins/security_solution/public/common/components/ml/tables/convert_anomalies_to_hosts.ts index 021c4c1a413b8..19718e271554d 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/common/components/ml/tables/convert_anomalies_to_hosts.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/common/components/ml/tables/convert_anomalies_to_hosts.ts @@ -5,52 +5,94 @@ * 2.0. */ +import type { EntityStoreEuid } from '@kbn/entity-store/public'; + import type { Anomalies, AnomaliesByHost, Anomaly } from '../types'; +import { + anomalyEntityNameInEuidIdentitySourceFields, + anomalyMatchesMlEntityField, + anomalyRowMatchesIdentityIdentifiers, + buildEuidSampleDocumentForAnomaliesTable, + pickAnomalyRowLabelMatchingIdentifiers, +} from '../anomaly/anomaly_table_euid'; import { getHostNameFromInfluencers } from '../influencers/get_host_name_from_influencers'; export const convertAnomaliesToHosts = ( anomalies: Anomalies | null, jobNameById: Record, - hostName?: string + hostName?: string, + identityFields?: Record, + euid?: EntityStoreEuid ): AnomaliesByHost[] => { if (anomalies == null) { return []; - } else { - return anomalies.anomalies.reduce((accum, item) => { - if (getHostNameFromEntity(item, hostName)) { - return [ - ...accum, - { - hostName: item.entityValue, - jobName: jobNameById[item.jobId] ?? item.jobId, - anomaly: item, - }, - ]; + } + const isScoped = hostName != null; + const doc = buildEuidSampleDocumentForAnomaliesTable('host', identityFields, hostName); + const identifiers = + euid && isScoped ? euid.getEntityIdentifiersFromDocument('host', doc) : undefined; + const identifiersUsable = identifiers != null && Object.keys(identifiers).length > 0; + const identitySourceFields = euid?.getEuidSourceFields('host').identitySourceFields ?? []; + + return anomalies.anomalies.reduce((accum, item) => { + let matched = false; + let label: string | null = null; + + if (isScoped) { + if (identifiersUsable && identifiers) { + if (anomalyRowMatchesIdentityIdentifiers(item, identifiers)) { + matched = true; + label = pickAnomalyRowLabelMatchingIdentifiers(item, identifiers); + } + } else if (anomalyMatchesMlEntityField(item, 'host.name', hostName)) { + matched = true; + label = String(item.entityValue); } else { - const hostNameFromInfluencers = getHostNameFromInfluencers(item.influencers, hostName); - if (hostNameFromInfluencers != null) { - return [ - ...accum, - { - hostName: hostNameFromInfluencers, - jobName: jobNameById[item.jobId] ?? item.jobId, - anomaly: item, - }, - ]; - } else { - return accum; + const fromInfl = getHostNameFromInfluencers(item.influencers, hostName, 'host.name'); + if (fromInfl != null) { + matched = true; + label = fromInfl; } } - }, []); - } -}; + } else if (euid) { + if (anomalyEntityNameInEuidIdentitySourceFields(item, euid, 'host')) { + matched = true; + label = String(item.entityValue); + } else { + for (const field of identitySourceFields) { + const fromInfl = getHostNameFromInfluencers(item.influencers, undefined, field); + if (fromInfl != null) { + matched = true; + label = fromInfl; + break; + } + } + } + } else if (anomalyMatchesMlEntityField(item, 'host.name', undefined)) { + matched = true; + label = String(item.entityValue); + } else { + const fromInfl = getHostNameFromInfluencers(item.influencers, undefined, 'host.name'); + if (fromInfl != null) { + matched = true; + label = fromInfl; + } + } -export const getHostNameFromEntity = (anomaly: Anomaly, hostName?: string): boolean => { - if (anomaly.entityName !== 'host.name') { - return false; - } else if (hostName == null) { - return true; - } else { - return anomaly.entityValue === hostName; - } + if (!matched || label == null) { + return accum; + } + + return [ + ...accum, + { + hostName: label, + jobName: jobNameById[item.jobId] ?? item.jobId, + anomaly: item, + }, + ]; + }, []); }; + +export const getHostNameFromEntity = (anomaly: Anomaly, hostName?: string): boolean => + anomalyMatchesMlEntityField(anomaly, 'host.name', hostName); diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/components/ml/tables/convert_anomalies_to_users.ts b/x-pack/solutions/security/plugins/security_solution/public/common/components/ml/tables/convert_anomalies_to_users.ts index ad09df846c66e..44f8962c623c1 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/common/components/ml/tables/convert_anomalies_to_users.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/common/components/ml/tables/convert_anomalies_to_users.ts @@ -5,52 +5,94 @@ * 2.0. */ +import type { EntityStoreEuid } from '@kbn/entity-store/public'; + import type { Anomalies, AnomaliesByUser, Anomaly } from '../types'; +import { + anomalyEntityNameInEuidIdentitySourceFields, + anomalyMatchesMlEntityField, + anomalyRowMatchesIdentityIdentifiers, + buildEuidSampleDocumentForAnomaliesTable, + pickAnomalyRowLabelMatchingIdentifiers, +} from '../anomaly/anomaly_table_euid'; import { getUserNameFromInfluencers } from '../influencers/get_user_name_from_influencers'; export const convertAnomaliesToUsers = ( anomalies: Anomalies | null, jobNameById: Record, - userName?: string + userName?: string, + identityFields?: Record, + euid?: EntityStoreEuid ): AnomaliesByUser[] => { if (anomalies == null) { return []; - } else { - return anomalies.anomalies.reduce((accum, item) => { - if (getUserNameFromEntity(item, userName)) { - return [ - ...accum, - { - userName: item.entityValue, - jobName: jobNameById[item.jobId] ?? item.jobId, - anomaly: item, - }, - ]; + } + const isScoped = userName != null; + const doc = buildEuidSampleDocumentForAnomaliesTable('user', identityFields, userName); + const identifiers = + euid && isScoped ? euid.getEntityIdentifiersFromDocument('user', doc) : undefined; + const identifiersUsable = identifiers != null && Object.keys(identifiers).length > 0; + const identitySourceFields = euid?.getEuidSourceFields('user').identitySourceFields ?? []; + + return anomalies.anomalies.reduce((accum, item) => { + let matched = false; + let label: string | null = null; + + if (isScoped) { + if (identifiersUsable && identifiers) { + if (anomalyRowMatchesIdentityIdentifiers(item, identifiers)) { + matched = true; + label = pickAnomalyRowLabelMatchingIdentifiers(item, identifiers); + } + } else if (anomalyMatchesMlEntityField(item, 'user.name', userName)) { + matched = true; + label = String(item.entityValue); } else { - const userNameFromInfluencers = getUserNameFromInfluencers(item.influencers, userName); - if (userNameFromInfluencers != null) { - return [ - ...accum, - { - userName: userNameFromInfluencers, - jobName: jobNameById[item.jobId] ?? item.jobId, - anomaly: item, - }, - ]; - } else { - return accum; + const fromInfl = getUserNameFromInfluencers(item.influencers, userName, 'user.name'); + if (fromInfl != null) { + matched = true; + label = fromInfl; } } - }, []); - } -}; + } else if (euid) { + if (anomalyEntityNameInEuidIdentitySourceFields(item, euid, 'user')) { + matched = true; + label = String(item.entityValue); + } else { + for (const field of identitySourceFields) { + const fromInfl = getUserNameFromInfluencers(item.influencers, undefined, field); + if (fromInfl != null) { + matched = true; + label = fromInfl; + break; + } + } + } + } else if (anomalyMatchesMlEntityField(item, 'user.name', undefined)) { + matched = true; + label = String(item.entityValue); + } else { + const fromInfl = getUserNameFromInfluencers(item.influencers, undefined, 'user.name'); + if (fromInfl != null) { + matched = true; + label = fromInfl; + } + } -export const getUserNameFromEntity = (anomaly: Anomaly, userName?: string): boolean => { - if (anomaly.entityName !== 'user.name') { - return false; - } else if (userName == null) { - return true; - } else { - return anomaly.entityValue === userName; - } + if (!matched || label == null) { + return accum; + } + + return [ + ...accum, + { + userName: label, + jobName: jobNameById[item.jobId] ?? item.jobId, + anomaly: item, + }, + ]; + }, []); }; + +export const getUserNameFromEntity = (anomaly: Anomaly, userName?: string): boolean => + anomalyMatchesMlEntityField(anomaly, 'user.name', userName); 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 24c0d9314735d..151313c7d4eba 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 @@ -93,6 +93,8 @@ export interface AnomaliesTableCommonProps { export type AnomaliesHostTableProps = AnomaliesTableCommonProps & { hostName?: string; type: HostsType; + /** Entity Store / EUID identity fields; drives ML exists filter and anomaly row matching. */ + identityFields?: Record; }; export type AnomaliesNetworkTableProps = AnomaliesTableCommonProps & { @@ -104,6 +106,8 @@ export type AnomaliesNetworkTableProps = AnomaliesTableCommonProps & { export type AnomaliesUserTableProps = AnomaliesTableCommonProps & { userName?: string; type: UsersType; + /** Entity Store / EUID identity fields; drives ML exists filter and anomaly row matching. */ + identityFields?: Record; }; const sourceOrDestination = ['source.ip', 'destination.ip']; diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/components/navigation/tab_navigation/tab_navigation.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/common/components/navigation/tab_navigation/tab_navigation.test.tsx index 40f68187e723c..770b72bf01e26 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/common/components/navigation/tab_navigation/tab_navigation.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/common/components/navigation/tab_navigation/tab_navigation.test.tsx @@ -6,12 +6,18 @@ */ import { screen, render } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import React from 'react'; import { navTabsHostDetails } from '../../../../explore/hosts/pages/details/nav_tabs'; import { HostsTableType } from '../../../../explore/hosts/store/model'; +import { navTabsUsersDetails } from '../../../../explore/users/pages/details/nav_tabs'; +import { UsersTableType } from '../../../../explore/users/store/model'; import { TabNavigationComponent } from './tab_navigation'; import type { TabNavigationProps } from './types'; -import { mockGetUrlForApp } from '@kbn/security-solution-navigation/mocks/context'; +import { + mockGetUrlForApp, + mockNavigateToUrl, +} from '@kbn/security-solution-navigation/mocks/context'; jest.mock('@kbn/security-solution-navigation/src/context'); @@ -38,6 +44,7 @@ jest.mock('react-router-dom', () => { }); const hostName = 'siem-window'; +const userName = 'siem-user'; describe('Table Navigation', () => { const mockHasMlUserPermissions = true; @@ -75,4 +82,49 @@ describe('Table Navigation', () => { `/app/securitySolutionUI/hosts/name/siem-window/authentications${SEARCH_QUERY}` ); }); + + test('does not append location.search when href already encodes query (host details tabs)', () => { + const propsWithEmbeddedSearch: TabNavigationProps = { + navTabs: navTabsHostDetails({ + hostName, + hasMlUserPermissions: mockHasMlUserPermissions, + urlStateQuery: SEARCH_QUERY, + }), + }; + + render(); + + const firstTab = screen.getByTestId(`navigation-${HostsTableType.authentications}`); + const { href } = firstTab as HTMLAnchorElement; + expect((href.match(/\?/g) ?? []).length).toBe(1); + expect(href).toContain('search=test'); + }); + + test('does not append location.search when href already encodes query (user details tabs)', () => { + mockUseRouteSpy.mockReturnValue([{ tabName: UsersTableType.authentications }]); + const propsWithEmbeddedSearch: TabNavigationProps = { + navTabs: navTabsUsersDetails(userName, mockHasMlUserPermissions, { + urlStateQuery: SEARCH_QUERY, + }), + }; + + render(); + + const firstTab = screen.getByTestId(`navigation-${UsersTableType.authentications}`); + const { href } = firstTab as HTMLAnchorElement; + expect((href.match(/\?/g) ?? []).length).toBe(1); + expect(href).toContain('search=test'); + mockUseRouteSpy.mockReturnValue([{ tabName: HostsTableType.authentications }]); + }); + + test('tab click navigates via navigateToUrl with the resolved app href', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByTestId(`navigation-${HostsTableType.events}`)); + + expect(mockNavigateToUrl).toHaveBeenCalledWith( + `/app/securitySolutionUI/hosts/name/siem-window/events${SEARCH_QUERY}` + ); + }); }); diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/components/navigation/tab_navigation/tab_navigation.tsx b/x-pack/solutions/security/plugins/security_solution/public/common/components/navigation/tab_navigation/tab_navigation.tsx index 2d97d043cff0d..fbb12910b3454 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/common/components/navigation/tab_navigation/tab_navigation.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/common/components/navigation/tab_navigation/tab_navigation.tsx @@ -28,19 +28,19 @@ const TabNavigationItemComponent = ({ }: TabNavigationItemProps) => { const { getAppUrl, navigateTo } = useNavigation(); + const appHref = getAppUrl({ + path: hrefWithSearch, + }); + const handleClick = useCallback( (ev: React.SyntheticEvent) => { ev.preventDefault(); - navigateTo({ path: hrefWithSearch, restoreScroll: true }); + navigateTo({ url: appHref, restoreScroll: true }); track(METRIC_TYPE.CLICK, `${TELEMETRY_EVENT.TAB_CLICKED}${id}`); }, - [navigateTo, hrefWithSearch, id] + [navigateTo, appHref, id] ); - const appHref = getAppUrl({ - path: hrefWithSearch, - }); - return ( = ({ navTabs } () => Object.values(navTabs).map((tab) => { const isSelected = selectedTabId === tab.id; + /** + * Detail pages (host/user) bake `location.search` into `tab.href` via merge helpers. + * Appending `search` again would emit a second `?`, duplicate timeline/timerange, and + * corrupt values (e.g. `identityFields` absorbing a trailing `?entityId=...`). + */ + const hrefWithSearch = tab.href.includes('?') ? tab.href : `${tab.href}${search}`; return ( ({ ]), })); +const mockEuiTheme = { + colors: { + vis: { + euiColorVis0: '#000', + euiColorVis7: '#111', + }, + }, +} as unknown as EuiThemeComputed; + describe('getAuthenticationLensAttributes', () => { beforeAll(() => { const dataView = getMockDataViewWithMatchedIndices(['auditbeat-mytest-*']); @@ -60,4 +70,18 @@ describe('getAuthenticationLensAttributes', () => { expect(result?.current).toMatchSnapshot(); }); + + it('merges extraOptions.filters after the authentication category filter (host details identity scope)', () => { + const identityScopeFilter = { + query: { bool: { must: [{ term: { 'host.entity.id': 'host-entity-1' } }] } }, + meta: {}, + }; + const attrs = getAuthenticationLensAttributes({ + euiTheme: mockEuiTheme, + extraOptions: { filters: [identityScopeFilter] }, + }); + + expect(attrs.state.filters).toHaveLength(2); + expect(attrs.state.filters[1]).toEqual(identityScopeFilter); + }); }); diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/common/authentication.ts b/x-pack/solutions/security/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/common/authentication.ts index 2d41ba63caf5d..f6387c3dc0934 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/common/authentication.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/common/authentication.ts @@ -18,7 +18,10 @@ const columnEventOutcomeFailure = `column-event-outcome-failure-id-${uuidv4()}`; const columnTimestampSuccess = `column-timestamp-success-id-${uuidv4()}`; const columnEventOutcomeSuccess = `column-event-outcome-success-id-${uuidv4()}`; -export const getAuthenticationLensAttributes: GetLensAttributes = ({ euiTheme }) => +export const getAuthenticationLensAttributes: GetLensAttributes = ({ + euiTheme, + extraOptions = {}, +}) => ({ title: 'Authentication', description: '', @@ -105,6 +108,7 @@ export const getAuthenticationLensAttributes: GetLensAttributes = ({ euiTheme }) }, }, }, + ...(extraOptions.filters ?? []), ], datasourceStates: { formBased: { diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/hosts/entity_store_v2_hosts_kpi_lens_shared.ts b/x-pack/solutions/security/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/hosts/entity_store_v2_hosts_kpi_lens_shared.ts new file mode 100644 index 0000000000000..1c5fc312090aa --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/hosts/entity_store_v2_hosts_kpi_lens_shared.ts @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { Filter } from '@kbn/es-query'; + +/** Stable id for ad-hoc data view (Entity Store v2 unified latest index). */ +export const ENTITY_STORE_V2_HOSTS_KPI_LENS_AD_HOC_ID = '7f2a9c1e-4b8d-4e6f-a3c2-9d1e8f7a6b5c'; + +export const getEntityStoreV2LatestHostsIndexTitle = (spaceId?: string) => + `.entities.v2.latest.security_${spaceId ?? 'default'}`; + +export const getEntityStoreV2HostOnlyFilter = (): Filter => ({ + meta: { + alias: null, + disabled: false, + negate: false, + type: 'phrase', + key: 'entity.EngineMetadata.Type', + params: { query: 'host' }, + index: ENTITY_STORE_V2_HOSTS_KPI_LENS_AD_HOC_ID, + }, + query: { + match_phrase: { + 'entity.EngineMetadata.Type': 'host', + }, + }, +}); diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/hosts/kpi_host_area.test.ts b/x-pack/solutions/security/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/hosts/kpi_host_area.test.ts index 16d9d3fe5fc6a..57bc1bee4108d 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/hosts/kpi_host_area.test.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/hosts/kpi_host_area.test.ts @@ -52,4 +52,37 @@ describe('getKpiHostAreaLensAttributes', () => { expect(result?.current).toMatchSnapshot(); }); + + it('uses Entity Store v2 latest index as ad-hoc data source when entityStoreV2Enabled', () => { + const { result } = renderHook( + () => + useLensAttributes({ + extraOptions: { entityStoreV2Enabled: true, spaceId: 'my_space' }, + getLensAttributes: getKpiHostAreaLensAttributes, + stackByField: 'event.dataset', + }), + { wrapper } + ); + + const attrs = result.current; + expect(attrs?.references).toEqual([]); + expect(attrs?.state.internalReferences).toHaveLength(2); + const adHoc = attrs?.state.adHocDataViews; + expect(adHoc).toBeDefined(); + const spec = Object.values(adHoc ?? {})[0]; + expect(spec?.title).toBe('.entities.v2.latest.security_my_space'); + const hostTypeFilter = attrs?.state.filters?.find( + (f) => f.meta?.key === 'entity.EngineMetadata.Type' + ); + expect(hostTypeFilter).toBeDefined(); + expect(hostTypeFilter?.meta?.index).toBe('7f2a9c1e-4b8d-4e6f-a3c2-9d1e8f7a6b5c'); + + const formBased = attrs?.state.datasourceStates?.formBased; + const layer = formBased?.layers && Object.values(formBased.layers)[0]; + const metricColumn = + layer?.columns && Object.values(layer.columns).find((c) => c.isBucketed === false); + expect( + metricColumn && 'sourceField' in metricColumn ? metricColumn.sourceField : undefined + ).toBe('entity.id'); + }); }); diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/hosts/kpi_host_area.ts b/x-pack/solutions/security/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/hosts/kpi_host_area.ts index 053e8f3497caf..809b14bcce4e4 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/hosts/kpi_host_area.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/hosts/kpi_host_area.ts @@ -8,12 +8,18 @@ import { UNIQUE_COUNT } from '../../translations'; import type { LensAttributes, GetLensAttributes } from '../../types'; +import { + ENTITY_STORE_V2_HOSTS_KPI_LENS_AD_HOC_ID, + getEntityStoreV2HostOnlyFilter, + getEntityStoreV2LatestHostsIndexTitle, +} from './entity_store_v2_hosts_kpi_lens_shared'; + const columnTimestamp = '5eea817b-67b7-4268-8ecb-7688d1094721'; const columnHostName = 'b00c65ea-32be-4163-bfc8-f795b1ef9d06'; const layerHostName = '416b6fad-1923-4f6a-a2df-b223bb287e30'; -export const getKpiHostAreaLensAttributes: GetLensAttributes = () => { +const getLegacyKpiHostAreaLensAttributes = (): LensAttributes => { return { description: '', state: { @@ -87,3 +93,102 @@ export const getKpiHostAreaLensAttributes: GetLensAttributes = () => { ], } as LensAttributes; }; + +const getEntityStoreV2KpiHostAreaLensAttributes = (spaceId?: string): LensAttributes => { + const indexTitle = getEntityStoreV2LatestHostsIndexTitle(spaceId); + + return { + description: '', + state: { + adHocDataViews: { + [ENTITY_STORE_V2_HOSTS_KPI_LENS_AD_HOC_ID]: { + allowHidden: false, + allowNoIndex: false, + fieldAttrs: {}, + fieldFormats: {}, + id: ENTITY_STORE_V2_HOSTS_KPI_LENS_AD_HOC_ID, + name: indexTitle, + runtimeFieldMap: {}, + sourceFilters: [], + timeFieldName: '@timestamp', + title: indexTitle, + }, + }, + datasourceStates: { + formBased: { + layers: { + [layerHostName]: { + columnOrder: [columnTimestamp, columnHostName], + columns: { + [columnTimestamp]: { + dataType: 'date', + isBucketed: true, + label: '@timestamp', + operationType: 'date_histogram', + params: { interval: 'auto' }, + scale: 'interval', + sourceField: '@timestamp', + }, + [columnHostName]: { + customLabel: true, + dataType: 'number', + isBucketed: false, + label: UNIQUE_COUNT('entity.id'), + operationType: 'unique_count', + scale: 'ratio', + sourceField: 'entity.id', + }, + }, + incompleteColumns: {}, + }, + }, + }, + }, + filters: [getEntityStoreV2HostOnlyFilter()], + internalReferences: [ + { + id: ENTITY_STORE_V2_HOSTS_KPI_LENS_AD_HOC_ID, + name: 'indexpattern-datasource-current-indexpattern', + type: 'index-pattern', + }, + { + id: ENTITY_STORE_V2_HOSTS_KPI_LENS_AD_HOC_ID, + name: `indexpattern-datasource-layer-${layerHostName}`, + type: 'index-pattern', + }, + ], + query: { language: 'kuery', query: '' }, + visualization: { + axisTitlesVisibilitySettings: { x: false, yLeft: false, yRight: false }, + fittingFunction: 'None', + gridlinesVisibilitySettings: { x: true, yLeft: true, yRight: true }, + labelsOrientation: { x: 0, yLeft: 0, yRight: 0 }, + layers: [ + { + accessors: [columnHostName], + layerId: layerHostName, + layerType: 'data', + seriesType: 'area', + xAccessor: columnTimestamp, + }, + ], + legend: { isVisible: false, position: 'right' }, + preferredSeriesType: 'area', + tickLabelsVisibilitySettings: { x: true, yLeft: true, yRight: true }, + valueLabels: 'hide', + yLeftExtent: { mode: 'full' }, + yRightExtent: { mode: 'full' }, + }, + }, + title: '[Host] Hosts - area', + visualizationType: 'lnsXY', + references: [], + } as LensAttributes; +}; + +export const getKpiHostAreaLensAttributes: GetLensAttributes = ({ extraOptions }) => { + if (extraOptions?.entityStoreV2Enabled === true) { + return getEntityStoreV2KpiHostAreaLensAttributes(extraOptions.spaceId); + } + return getLegacyKpiHostAreaLensAttributes(); +}; diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/hosts/kpi_host_metric.test.ts b/x-pack/solutions/security/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/hosts/kpi_host_metric.test.ts index a6f9b21dccfbf..771a048946735 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/hosts/kpi_host_metric.test.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/hosts/kpi_host_metric.test.ts @@ -10,7 +10,7 @@ import { wrapper } from '../../mocks'; import { useLensAttributes } from '../../use_lens_attributes'; -import { kpiHostMetricLensAttributes } from './kpi_host_metric'; +import { buildKpiHostMetricLensAttributes, kpiHostMetricLensAttributes } from './kpi_host_metric'; import { useDataView } from '../../../../../data_view_manager/hooks/use_data_view'; import { withIndices } from '../../../../../data_view_manager/hooks/__mocks__/use_data_view'; @@ -52,4 +52,33 @@ describe('kpiHostMetricLensAttributes', () => { expect(result?.current).toMatchSnapshot(); }); + + it('uses Entity Store v2 latest index when entityStoreV2Enabled', () => { + const { result } = renderHook( + () => + useLensAttributes({ + lensAttributes: buildKpiHostMetricLensAttributes({ + entityStoreV2Enabled: true, + spaceId: 'custom_space', + }), + stackByField: 'event.dataset', + }), + { wrapper } + ); + + const attrs = result.current; + expect(attrs?.references).toEqual([]); + expect(attrs?.state.internalReferences).toHaveLength(2); + const spec = Object.values(attrs?.state.adHocDataViews ?? {})[0]; + expect(spec?.title).toBe('.entities.v2.latest.security_custom_space'); + const hostTypeFilter = attrs?.state.filters?.find( + (f) => f.meta?.key === 'entity.EngineMetadata.Type' + ); + expect(hostTypeFilter).toBeDefined(); + + const formBased = attrs?.state.datasourceStates?.formBased; + const layer = formBased?.layers && Object.values(formBased.layers)[0]; + const col = layer?.columns && Object.values(layer.columns)[0]; + expect(col && 'sourceField' in col ? col.sourceField : undefined).toBe('entity.id'); + }); }); diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/hosts/kpi_host_metric.ts b/x-pack/solutions/security/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/hosts/kpi_host_metric.ts index 66b6b63a69321..88e18c36e09a5 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/hosts/kpi_host_metric.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/hosts/kpi_host_metric.ts @@ -5,52 +5,146 @@ * 2.0. */ -import type { LensAttributes } from '../../types'; - -export const kpiHostMetricLensAttributes: LensAttributes = { - description: '', - state: { - datasourceStates: { - formBased: { - layers: { - '416b6fad-1923-4f6a-a2df-b223bb287e30': { - columnOrder: ['b00c65ea-32be-4163-bfc8-f795b1ef9d06'], - columns: { - 'b00c65ea-32be-4163-bfc8-f795b1ef9d06': { - customLabel: true, - dataType: 'number', - isBucketed: false, - label: ' ', - operationType: 'unique_count', - scale: 'ratio', - sourceField: 'host.name', +import type { ExtraOptions, LensAttributes } from '../../types'; + +import { + ENTITY_STORE_V2_HOSTS_KPI_LENS_AD_HOC_ID, + getEntityStoreV2HostOnlyFilter, + getEntityStoreV2LatestHostsIndexTitle, +} from './entity_store_v2_hosts_kpi_lens_shared'; + +const layerId = '416b6fad-1923-4f6a-a2df-b223bb287e30'; +const columnHostUnique = 'b00c65ea-32be-4163-bfc8-f795b1ef9d06'; + +const getLegacyKpiHostMetricLensAttributes = (): LensAttributes => { + return { + description: '', + state: { + datasourceStates: { + formBased: { + layers: { + [layerId]: { + columnOrder: [columnHostUnique], + columns: { + [columnHostUnique]: { + customLabel: true, + dataType: 'number', + isBucketed: false, + label: ' ', + operationType: 'unique_count', + scale: 'ratio', + sourceField: 'host.name', + }, }, + incompleteColumns: {}, }, - incompleteColumns: {}, }, }, }, + filters: [], + query: { language: 'kuery', query: '' }, + visualization: { + accessor: columnHostUnique, + layerId, + layerType: 'data', + }, }, - filters: [], - query: { language: 'kuery', query: '' }, - visualization: { - accessor: 'b00c65ea-32be-4163-bfc8-f795b1ef9d06', - layerId: '416b6fad-1923-4f6a-a2df-b223bb287e30', - layerType: 'data', - }, - }, - title: '[Host] Hosts - metric', - visualizationType: 'lnsLegacyMetric', - references: [ - { - id: '{dataViewId}', - name: 'indexpattern-datasource-current-indexpattern', - type: 'index-pattern', - }, - { - id: '{dataViewId}', - name: 'indexpattern-datasource-layer-416b6fad-1923-4f6a-a2df-b223bb287e30', - type: 'index-pattern', + title: '[Host] Hosts - metric', + visualizationType: 'lnsLegacyMetric', + references: [ + { + id: '{dataViewId}', + name: 'indexpattern-datasource-current-indexpattern', + type: 'index-pattern', + }, + { + id: '{dataViewId}', + name: `indexpattern-datasource-layer-${layerId}`, + type: 'index-pattern', + }, + ], + } as LensAttributes; +}; + +const getEntityStoreV2KpiHostMetricLensAttributes = (spaceId?: string): LensAttributes => { + const indexTitle = getEntityStoreV2LatestHostsIndexTitle(spaceId); + + return { + description: '', + state: { + adHocDataViews: { + [ENTITY_STORE_V2_HOSTS_KPI_LENS_AD_HOC_ID]: { + allowHidden: false, + allowNoIndex: false, + fieldAttrs: {}, + fieldFormats: {}, + id: ENTITY_STORE_V2_HOSTS_KPI_LENS_AD_HOC_ID, + name: indexTitle, + runtimeFieldMap: {}, + sourceFilters: [], + timeFieldName: '@timestamp', + title: indexTitle, + }, + }, + datasourceStates: { + formBased: { + layers: { + [layerId]: { + columnOrder: [columnHostUnique], + columns: { + [columnHostUnique]: { + customLabel: true, + dataType: 'number', + isBucketed: false, + label: ' ', + operationType: 'unique_count', + scale: 'ratio', + sourceField: 'entity.id', + }, + }, + incompleteColumns: {}, + }, + }, + }, + }, + filters: [getEntityStoreV2HostOnlyFilter()], + internalReferences: [ + { + id: ENTITY_STORE_V2_HOSTS_KPI_LENS_AD_HOC_ID, + name: 'indexpattern-datasource-current-indexpattern', + type: 'index-pattern', + }, + { + id: ENTITY_STORE_V2_HOSTS_KPI_LENS_AD_HOC_ID, + name: `indexpattern-datasource-layer-${layerId}`, + type: 'index-pattern', + }, + ], + query: { language: 'kuery', query: '' }, + visualization: { + accessor: columnHostUnique, + layerId, + layerType: 'data', + }, }, - ], -} as LensAttributes; + title: '[Host] Hosts - metric', + visualizationType: 'lnsLegacyMetric', + references: [], + } as LensAttributes; +}; + +/** + * Hosts KPI metric Lens attributes. When `entityStoreV2Enabled` is true, uses Entity Store v2 + * latest index as an ad-hoc data source (same as the hosts KPI area chart). + */ +export const buildKpiHostMetricLensAttributes = ( + extraOptions?: Pick +): LensAttributes => { + if (extraOptions?.entityStoreV2Enabled === true) { + return getEntityStoreV2KpiHostMetricLensAttributes(extraOptions.spaceId); + } + return getLegacyKpiHostMetricLensAttributes(); +}; + +/** Default (sourcerer data view) attributes — used where Entity Store v2 is off or unknown. */ +export const kpiHostMetricLensAttributes: LensAttributes = buildKpiHostMetricLensAttributes(); diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/users/entity_store_v2_users_kpi_lens_shared.ts b/x-pack/solutions/security/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/users/entity_store_v2_users_kpi_lens_shared.ts new file mode 100644 index 0000000000000..d7460c46c8fce --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/users/entity_store_v2_users_kpi_lens_shared.ts @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { Filter } from '@kbn/es-query'; + +/** Stable id for users KPI ad-hoc data view (Entity Store v2 unified latest index). */ +export const ENTITY_STORE_V2_USERS_KPI_LENS_AD_HOC_ID = 'b8e3f2a1-6c4d-5e7f-8a9b-0c1d2e3f4a5b'; + +export const getEntityStoreV2LatestUsersIndexTitle = (spaceId?: string) => + `.entities.v2.latest.security_${spaceId ?? 'default'}`; + +export const getEntityStoreV2UserOnlyFilter = (): Filter => ({ + meta: { + alias: null, + disabled: false, + negate: false, + type: 'phrase', + key: 'entity.EngineMetadata.Type', + params: { query: 'user' }, + index: ENTITY_STORE_V2_USERS_KPI_LENS_AD_HOC_ID, + }, + query: { + match_phrase: { + 'entity.EngineMetadata.Type': 'user', + }, + }, +}); diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/users/kpi_total_users_area.test.ts b/x-pack/solutions/security/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/users/kpi_total_users_area.test.ts index 4c3afc10df0e4..026bf5120916d 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/users/kpi_total_users_area.test.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/users/kpi_total_users_area.test.ts @@ -52,4 +52,35 @@ describe('getKpiTotalUsersAreaLensAttributes', () => { expect(result?.current).toMatchSnapshot(); }); + + it('uses Entity Store v2 latest index as ad-hoc data source when entityStoreV2Enabled', () => { + const { result } = renderHook( + () => + useLensAttributes({ + extraOptions: { entityStoreV2Enabled: true, spaceId: 'my_space' }, + getLensAttributes: getKpiTotalUsersAreaLensAttributes, + stackByField: 'event.dataset', + }), + { wrapper } + ); + + const attrs = result.current; + expect(attrs?.references).toEqual([]); + expect(attrs?.state.internalReferences).toHaveLength(2); + const spec = Object.values(attrs?.state.adHocDataViews ?? {})[0]; + expect(spec?.title).toBe('.entities.v2.latest.security_my_space'); + const userTypeFilter = attrs?.state.filters?.find( + (f) => f.meta?.key === 'entity.EngineMetadata.Type' + ); + expect(userTypeFilter).toBeDefined(); + expect(userTypeFilter?.meta?.index).toBe('b8e3f2a1-6c4d-5e7f-8a9b-0c1d2e3f4a5b'); + + const formBased = attrs?.state.datasourceStates?.formBased; + const layer = formBased?.layers && Object.values(formBased.layers)[0]; + const metricColumn = + layer?.columns && Object.values(layer.columns).find((c) => c.isBucketed === false); + expect( + metricColumn && 'sourceField' in metricColumn ? metricColumn.sourceField : undefined + ).toBe('entity.id'); + }); }); diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/users/kpi_total_users_area.ts b/x-pack/solutions/security/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/users/kpi_total_users_area.ts index 786c738315db1..16295d1fb4159 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/users/kpi_total_users_area.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/users/kpi_total_users_area.ts @@ -8,13 +8,19 @@ import { UNIQUE_COUNT } from '../../translations'; import type { LensAttributes, GetLensAttributes } from '../../types'; +import { + ENTITY_STORE_V2_USERS_KPI_LENS_AD_HOC_ID, + getEntityStoreV2LatestUsersIndexTitle, + getEntityStoreV2UserOnlyFilter, +} from './entity_store_v2_users_kpi_lens_shared'; + const columnTimestamp = '5eea817b-67b7-4268-8ecb-7688d1094721'; const columnUserName = 'b00c65ea-32be-4163-bfc8-f795b1ef9d06'; const layerUserName = '416b6fad-1923-4f6a-a2df-b223bb287e30'; -export const getKpiTotalUsersAreaLensAttributes: GetLensAttributes = () => - ({ +const getLegacyKpiTotalUsersAreaLensAttributes = (): LensAttributes => { + return { description: '', state: { datasourceStates: { @@ -85,4 +91,104 @@ export const getKpiTotalUsersAreaLensAttributes: GetLensAttributes = () => type: 'index-pattern', }, ], - } as LensAttributes); + } as LensAttributes; +}; + +const getEntityStoreV2KpiTotalUsersAreaLensAttributes = (spaceId?: string): LensAttributes => { + const indexTitle = getEntityStoreV2LatestUsersIndexTitle(spaceId); + + return { + description: '', + state: { + adHocDataViews: { + [ENTITY_STORE_V2_USERS_KPI_LENS_AD_HOC_ID]: { + allowHidden: false, + allowNoIndex: false, + fieldAttrs: {}, + fieldFormats: {}, + id: ENTITY_STORE_V2_USERS_KPI_LENS_AD_HOC_ID, + name: indexTitle, + runtimeFieldMap: {}, + sourceFilters: [], + timeFieldName: '@timestamp', + title: indexTitle, + }, + }, + datasourceStates: { + formBased: { + layers: { + [layerUserName]: { + columnOrder: [columnTimestamp, columnUserName], + columns: { + [columnTimestamp]: { + dataType: 'date', + isBucketed: true, + label: '@timestamp', + operationType: 'date_histogram', + params: { interval: 'auto' }, + scale: 'interval', + sourceField: '@timestamp', + }, + [columnUserName]: { + customLabel: true, + dataType: 'number', + isBucketed: false, + label: UNIQUE_COUNT('entity.id'), + operationType: 'unique_count', + scale: 'ratio', + sourceField: 'entity.id', + }, + }, + incompleteColumns: {}, + }, + }, + }, + }, + filters: [getEntityStoreV2UserOnlyFilter()], + internalReferences: [ + { + id: ENTITY_STORE_V2_USERS_KPI_LENS_AD_HOC_ID, + name: 'indexpattern-datasource-current-indexpattern', + type: 'index-pattern', + }, + { + id: ENTITY_STORE_V2_USERS_KPI_LENS_AD_HOC_ID, + name: `indexpattern-datasource-layer-${layerUserName}`, + type: 'index-pattern', + }, + ], + query: { language: 'kuery', query: '' }, + visualization: { + axisTitlesVisibilitySettings: { x: false, yLeft: false, yRight: false }, + fittingFunction: 'None', + gridlinesVisibilitySettings: { x: true, yLeft: true, yRight: true }, + labelsOrientation: { x: 0, yLeft: 0, yRight: 0 }, + layers: [ + { + accessors: [columnUserName], + layerId: layerUserName, + layerType: 'data', + seriesType: 'area', + xAccessor: columnTimestamp, + }, + ], + legend: { isVisible: true, position: 'right' }, + preferredSeriesType: 'area', + tickLabelsVisibilitySettings: { x: true, yLeft: true, yRight: true }, + valueLabels: 'hide', + yLeftExtent: { mode: 'full' }, + yRightExtent: { mode: 'full' }, + }, + }, + title: '[User] Users - area', + visualizationType: 'lnsXY', + references: [], + } as LensAttributes; +}; + +export const getKpiTotalUsersAreaLensAttributes: GetLensAttributes = ({ extraOptions }) => { + if (extraOptions?.entityStoreV2Enabled === true) { + return getEntityStoreV2KpiTotalUsersAreaLensAttributes(extraOptions.spaceId); + } + return getLegacyKpiTotalUsersAreaLensAttributes(); +}; diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/users/kpi_total_users_metric.test.ts b/x-pack/solutions/security/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/users/kpi_total_users_metric.test.ts index 7fb87ce042549..19e60de0ab8a2 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/users/kpi_total_users_metric.test.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/users/kpi_total_users_metric.test.ts @@ -10,7 +10,10 @@ import { wrapper } from '../../mocks'; import { useLensAttributes } from '../../use_lens_attributes'; -import { kpiTotalUsersMetricLensAttributes } from './kpi_total_users_metric'; +import { + buildKpiTotalUsersMetricLensAttributes, + kpiTotalUsersMetricLensAttributes, +} from './kpi_total_users_metric'; import { useDataView } from '../../../../../data_view_manager/hooks/use_data_view'; import { withIndices } from '../../../../../data_view_manager/hooks/__mocks__/use_data_view'; @@ -52,4 +55,33 @@ describe('kpiTotalUsersMetricLensAttributes', () => { expect(result?.current).toMatchSnapshot(); }); + + it('uses Entity Store v2 latest index when entityStoreV2Enabled', () => { + const { result } = renderHook( + () => + useLensAttributes({ + lensAttributes: buildKpiTotalUsersMetricLensAttributes({ + entityStoreV2Enabled: true, + spaceId: 'custom_space', + }), + stackByField: 'event.dataset', + }), + { wrapper } + ); + + const attrs = result.current; + expect(attrs?.references).toEqual([]); + expect(attrs?.state.internalReferences).toHaveLength(2); + const spec = Object.values(attrs?.state.adHocDataViews ?? {})[0]; + expect(spec?.title).toBe('.entities.v2.latest.security_custom_space'); + const userTypeFilter = attrs?.state.filters?.find( + (f) => f.meta?.key === 'entity.EngineMetadata.Type' + ); + expect(userTypeFilter).toBeDefined(); + + const formBased = attrs?.state.datasourceStates?.formBased; + const layer = formBased?.layers && Object.values(formBased.layers)[0]; + const col = layer?.columns && Object.values(layer.columns)[0]; + expect(col && 'sourceField' in col ? col.sourceField : undefined).toBe('entity.id'); + }); }); diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/users/kpi_total_users_metric.ts b/x-pack/solutions/security/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/users/kpi_total_users_metric.ts index 76ad650582e91..35b8359589b7d 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/users/kpi_total_users_metric.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/common/components/visualization_actions/lens_attributes/users/kpi_total_users_metric.ts @@ -5,52 +5,147 @@ * 2.0. */ -import type { LensAttributes } from '../../types'; - -export const kpiTotalUsersMetricLensAttributes: LensAttributes = { - description: '', - state: { - datasourceStates: { - formBased: { - layers: { - '416b6fad-1923-4f6a-a2df-b223bb287e30': { - columnOrder: ['3e51b035-872c-4b44-824b-fe069c222e91'], - columns: { - '3e51b035-872c-4b44-824b-fe069c222e91': { - dataType: 'number', - isBucketed: false, - label: ' ', - operationType: 'unique_count', - scale: 'ratio', - sourceField: 'user.name', - customLabel: true, +import type { ExtraOptions, LensAttributes } from '../../types'; + +import { + ENTITY_STORE_V2_USERS_KPI_LENS_AD_HOC_ID, + getEntityStoreV2LatestUsersIndexTitle, + getEntityStoreV2UserOnlyFilter, +} from './entity_store_v2_users_kpi_lens_shared'; + +const layerId = '416b6fad-1923-4f6a-a2df-b223bb287e30'; +const columnUserUnique = '3e51b035-872c-4b44-824b-fe069c222e91'; + +const getLegacyKpiTotalUsersMetricLensAttributes = (): LensAttributes => { + return { + description: '', + state: { + datasourceStates: { + formBased: { + layers: { + [layerId]: { + columnOrder: [columnUserUnique], + columns: { + [columnUserUnique]: { + dataType: 'number', + isBucketed: false, + label: ' ', + operationType: 'unique_count', + scale: 'ratio', + sourceField: 'user.name', + customLabel: true, + }, }, + incompleteColumns: {}, }, - incompleteColumns: {}, }, }, }, + filters: [], + query: { language: 'kuery', query: '' }, + visualization: { + accessor: columnUserUnique, + layerId, + layerType: 'data', + }, }, - filters: [], - query: { language: 'kuery', query: '' }, - visualization: { - accessor: '3e51b035-872c-4b44-824b-fe069c222e91', - layerId: '416b6fad-1923-4f6a-a2df-b223bb287e30', - layerType: 'data', - }, - }, - title: '[User] Users - metric', - visualizationType: 'lnsLegacyMetric', - references: [ - { - id: '{dataViewId}', - name: 'indexpattern-datasource-current-indexpattern', - type: 'index-pattern', - }, - { - id: '{dataViewId}', - name: 'indexpattern-datasource-layer-416b6fad-1923-4f6a-a2df-b223bb287e30', - type: 'index-pattern', + title: '[User] Users - metric', + visualizationType: 'lnsLegacyMetric', + references: [ + { + id: '{dataViewId}', + name: 'indexpattern-datasource-current-indexpattern', + type: 'index-pattern', + }, + { + id: '{dataViewId}', + name: `indexpattern-datasource-layer-${layerId}`, + type: 'index-pattern', + }, + ], + } as LensAttributes; +}; + +const getEntityStoreV2KpiTotalUsersMetricLensAttributes = (spaceId?: string): LensAttributes => { + const indexTitle = getEntityStoreV2LatestUsersIndexTitle(spaceId); + + return { + description: '', + state: { + adHocDataViews: { + [ENTITY_STORE_V2_USERS_KPI_LENS_AD_HOC_ID]: { + allowHidden: false, + allowNoIndex: false, + fieldAttrs: {}, + fieldFormats: {}, + id: ENTITY_STORE_V2_USERS_KPI_LENS_AD_HOC_ID, + name: indexTitle, + runtimeFieldMap: {}, + sourceFilters: [], + timeFieldName: '@timestamp', + title: indexTitle, + }, + }, + datasourceStates: { + formBased: { + layers: { + [layerId]: { + columnOrder: [columnUserUnique], + columns: { + [columnUserUnique]: { + dataType: 'number', + isBucketed: false, + label: ' ', + operationType: 'unique_count', + scale: 'ratio', + sourceField: 'entity.id', + customLabel: true, + }, + }, + incompleteColumns: {}, + }, + }, + }, + }, + filters: [getEntityStoreV2UserOnlyFilter()], + internalReferences: [ + { + id: ENTITY_STORE_V2_USERS_KPI_LENS_AD_HOC_ID, + name: 'indexpattern-datasource-current-indexpattern', + type: 'index-pattern', + }, + { + id: ENTITY_STORE_V2_USERS_KPI_LENS_AD_HOC_ID, + name: `indexpattern-datasource-layer-${layerId}`, + type: 'index-pattern', + }, + ], + query: { language: 'kuery', query: '' }, + visualization: { + accessor: columnUserUnique, + layerId, + layerType: 'data', + }, }, - ], -} as LensAttributes; + title: '[User] Users - metric', + visualizationType: 'lnsLegacyMetric', + references: [], + } as LensAttributes; +}; + +/** + * Users KPI metric Lens attributes. When `entityStoreV2Enabled` is true, uses Entity Store v2 + * latest index as an ad-hoc data source (same as the users KPI area chart). + */ +export const buildKpiTotalUsersMetricLensAttributes = ( + extraOptions?: Pick +): LensAttributes => { + if (extraOptions?.entityStoreV2Enabled === true) { + return getEntityStoreV2KpiTotalUsersMetricLensAttributes(extraOptions.spaceId); + } + return getLegacyKpiTotalUsersMetricLensAttributes(); +}; + +/** Default (sourcerer data view) attributes — used where Entity Store v2 is off or unknown. */ +export const kpiTotalUsersMetricLensAttributes: LensAttributes = + buildKpiTotalUsersMetricLensAttributes(); diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/components/visualization_actions/types.ts b/x-pack/solutions/security/plugins/security_solution/public/common/components/visualization_actions/types.ts index 49fd8dc3bb495..195c9108fee7f 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/common/components/visualization_actions/types.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/common/components/visualization_actions/types.ts @@ -189,6 +189,8 @@ export interface Response { export interface ExtraOptions { breakdownField?: string; dnsIsPtrIncluded?: boolean; + /** When true, host KPI Lens charts use Entity Store v2 latest indices as the data source. */ + entityStoreV2Enabled?: boolean; filters?: Filter[]; ruleId?: string; showLegend?: boolean; diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/components/visualization_actions/use_lens_attributes.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/common/components/visualization_actions/use_lens_attributes.test.tsx index b5e124b80078a..e1d75e2acb817 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/common/components/visualization_actions/use_lens_attributes.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/common/components/visualization_actions/use_lens_attributes.test.tsx @@ -103,6 +103,51 @@ describe('useLensAttributes', () => { ]); }); + it('skips host.name exists tab filter when entityStoreV2Enabled extraOption is set', () => { + const { result } = renderHook( + () => + useLensAttributes({ + extraOptions: { entityStoreV2Enabled: true }, + getLensAttributes: getExternalAlertLensAttributes, + stackByField: 'event.dataset', + }), + { wrapper } + ); + + expect(result?.current?.state.filters).toEqual([ + ...getExternalAlertLensAttributes(params).state.filters, + ...getDetailsPageFilter('hosts', 'mockHost'), + ...getIndexFilters(['auditbeat-*']), + ...filterFromSearchBar, + ]); + }); + + it('skips user.name exists tab filter when entityStoreV2Enabled on users page', () => { + (useRouteSpy as jest.Mock).mockReturnValue([ + { + detailName: 'elastic', + pageName: SecurityPageName.users, + tabName: 'events', + }, + ]); + const { result } = renderHook( + () => + useLensAttributes({ + extraOptions: { entityStoreV2Enabled: true }, + getLensAttributes: getExternalAlertLensAttributes, + stackByField: 'event.dataset', + }), + { wrapper } + ); + + expect(result?.current?.state.filters).toEqual([ + ...getExternalAlertLensAttributes(params).state.filters, + ...getDetailsPageFilter(SecurityPageName.users, 'elastic'), + ...getIndexFilters(['auditbeat-*']), + ...filterFromSearchBar, + ]); + }); + it('should add correct filters - network details', () => { (useRouteSpy as jest.Mock).mockReturnValue([ { diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/components/visualization_actions/use_lens_attributes.tsx b/x-pack/solutions/security/plugins/security_solution/public/common/components/visualization_actions/use_lens_attributes.tsx index 3c2fbeb475497..2e8eeafebd19f 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/common/components/visualization_actions/use_lens_attributes.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/common/components/visualization_actions/use_lens_attributes.tsx @@ -10,7 +10,6 @@ import { useEuiTheme } from '@elastic/eui'; import { PageScope } from '../../../data_view_manager/constants'; import { useDataView } from '../../../data_view_manager/hooks/use_data_view'; import { SecurityPageName } from '../../../../common/constants'; -import { NetworkRouteType } from '../../../explore/network/pages/navigation/types'; import { useSourcererDataView } from '../../../sourcerer/containers'; import { useDeepEqualSelector } from '../../hooks/use_selector'; import { inputsSelectors } from '../../store'; @@ -82,15 +81,21 @@ export const useLensAttributes = ({ const [{ detailName, pageName, tabName }] = useRouteSpy(); const tabsFilters = useMemo(() => { - if (tabName === NetworkRouteType.events) { + if (tabName === 'events') { if (pageName === SecurityPageName.network) { return sourceOrDestinationIpExistsFilter; } + if ( + extraOptions?.entityStoreV2Enabled === true && + (pageName === SecurityPageName.hosts || pageName === SecurityPageName.users) + ) { + return []; + } return fieldNameExistsFilter(pageName); } return []; - }, [pageName, tabName]); + }, [extraOptions?.entityStoreV2Enabled, pageName, tabName]); const pageFilters = useMemo(() => { if ( diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/components/visualization_actions/utils.test.ts b/x-pack/solutions/security/plugins/security_solution/public/common/components/visualization_actions/utils.test.ts index 9aca67251499d..7f0ee4dec355c 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/common/components/visualization_actions/utils.test.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/common/components/visualization_actions/utils.test.ts @@ -6,13 +6,45 @@ */ import { + buildAnyFieldExistsFilter, getDetailsPageFilter, getNetworkDetailsPageFilter, getRequestsAndResponses, + hostNameExistsFilter, parseVisualizationData, + userNameExistsFilter, } from './utils'; import { mockRequests } from './__mocks__/utils'; +describe('buildAnyFieldExistsFilter', () => { + test('meta.value serializes the same bool query as query', () => { + const [filter] = buildAnyFieldExistsFilter(['host.name', 'host.id']); + expect(filter.meta.value).toBe(JSON.stringify({ query: filter.query })); + }); + + test('hostNameExistsFilter includes EUID-related host fields and entity.id', () => { + const [filter] = hostNameExistsFilter; + const should = (filter.query as { bool: { filter: Array<{ bool: { should: unknown[] } }> } }) + .bool.filter[0].bool.should; + const fields = should.map((clause) => (clause as { exists: { field: string } }).exists.field); + expect(fields).toEqual([ + 'host.entity.id', + 'host.id', + 'host.name', + 'host.hostname', + 'entity.id', + ]); + }); + + test('userNameExistsFilter includes EUID-related user fields and entity.id', () => { + const [filter] = userNameExistsFilter; + const should = (filter.query as { bool: { filter: Array<{ bool: { should: unknown[] } }> } }) + .bool.filter[0].bool.should; + const fields = should.map((clause) => (clause as { exists: { field: string } }).exists.field); + expect(fields).toEqual(['user.entity.id', 'user.name', 'user.id', 'user.email', 'entity.id']); + }); +}); + describe('getDetailsPageFilter', () => { test('should render host details filter', () => { expect(getDetailsPageFilter('hosts', 'mockHost')).toMatchInlineSnapshot(` diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/components/visualization_actions/utils.ts b/x-pack/solutions/security/plugins/security_solution/public/common/components/visualization_actions/utils.ts index 015a2d1d7df4a..b9567714e9f0d 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/common/components/visualization_actions/utils.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/common/components/visualization_actions/utils.ts @@ -45,6 +45,64 @@ export const getDetailsPageFilter = (pageName: string, detailName?: string): Fil : []; }; +/** + * Events table filter: keep documents where at least one ECS / EUID field is present + * (aligned with Entity Store host identity branches and canonical `entity.id`). + */ +export const HOST_EXPLORE_EVENTS_EXISTENCE_FIELDS = [ + 'host.entity.id', + 'host.id', + 'host.name', + 'host.hostname', + 'entity.id', +] as const; + +/** + * Events table filter: keep documents where at least one ECS / EUID field is present + * (aligned with Entity Store user identity inputs and canonical `entity.id`). + */ +export const USER_EXPLORE_EVENTS_EXISTENCE_FIELDS = [ + 'user.entity.id', + 'user.name', + 'user.id', + 'user.email', + 'entity.id', +] as const; + +export const buildAnyFieldExistsFilter = (fields: readonly string[]): Filter[] => { + const query = { + bool: { + filter: [ + { + bool: { + should: fields.map((field) => ({ exists: { field } })), + minimum_should_match: 1, + }, + }, + ], + }, + }; + return [ + { + query, + meta: { + alias: '', + disabled: false, + key: 'bool', + negate: false, + type: 'custom', + value: JSON.stringify({ query }), + }, + }, + ]; +}; + +/** Hosts explore Events tab — any host / EUID identifier field present */ +export const hostNameExistsFilter = buildAnyFieldExistsFilter(HOST_EXPLORE_EVENTS_EXISTENCE_FIELDS); + +/** Users explore Events tab — any user / EUID identifier field present */ +export const userNameExistsFilter = buildAnyFieldExistsFilter(USER_EXPLORE_EVENTS_EXISTENCE_FIELDS); + export const fieldNameExistsFilter = (pageName: string): Filter[] => { const field = pageFilterFieldMap[pageName]; 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 14d8b592288d5..be1ced64c097a 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 @@ -31,6 +31,7 @@ const AnomaliesQueryTabBodyComponent: React.FC = ({ ip, hostName, userName, + identityFields, }) => { const { jobs } = useInstalledSecurityJobs(); const [anomalyScore] = useUiSetting$(DEFAULT_ANOMALY_SCORE); @@ -72,6 +73,7 @@ const AnomaliesQueryTabBodyComponent: React.FC = ({ ip={ip} hostName={hostName} userName={userName} + 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 bef630308e477..feb50547a0196 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 @@ -15,6 +15,7 @@ import type { UsersType } from '../../../../explore/users/store/model'; interface QueryTabBodyProps { type: HostsType | NetworkType | UsersType; filterQuery?: string | ESTermQuery; + identityFields?: Record; } export type AnomaliesQueryTabBodyProps = QueryTabBodyProps & { diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/hooks/constants.ts b/x-pack/solutions/security/plugins/security_solution/public/common/hooks/constants.ts index 8ff90991e9ef2..7b7e0fd77972e 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/common/hooks/constants.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/common/hooks/constants.ts @@ -18,4 +18,8 @@ export const URL_PARAM_KEY = { timerange: 'timerange', pageFilter: 'pageFilters', rulesTable: 'rulesTable', + /** Canonical entity store id for host/user explore detail URLs (Rison-encoded). */ + entityId: 'entityId', + /** Identity field map for entity resolution on explore detail URLs (Rison-encoded object). */ + identityFields: 'identityFields', } as const; diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/utils/resolve_entity_identifiers_for_alerts.ts b/x-pack/solutions/security/plugins/security_solution/public/common/utils/resolve_entity_identifiers_for_alerts.ts new file mode 100644 index 0000000000000..0f1241ba38316 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/common/utils/resolve_entity_identifiers_for_alerts.ts @@ -0,0 +1,29 @@ +/* + * 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. + */ + +/** + * Normalizes identityFields or legacy entityFilter into a single Record for alerts queries and UI. + * Prefers identityFields when both are provided. + */ +export const resolveEntityIdentifiers = ( + identityFields?: Record | null, + entityFilter?: { field: string; value: string | string[] } | null +): Record | undefined => { + if (identityFields != null && Object.keys(identityFields).length > 0) { + return identityFields; + } + if (entityFilter != null) { + const value = + typeof entityFilter.value === 'string' + ? entityFilter.value + : Array.isArray(entityFilter.value) + ? entityFilter.value[0] + : ''; + return { [entityFilter.field]: String(value) }; + } + return undefined; +}; diff --git a/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/api/hooks/entity_store_host_risk_common.ts b/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/api/hooks/entity_store_host_risk_common.ts new file mode 100644 index 0000000000000..3b6f7795814d8 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/api/hooks/entity_store_host_risk_common.ts @@ -0,0 +1,171 @@ +/* + * 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 { HostEntity } from '../../../../common/api/entity_analytics/entity_store/entities/common.gen'; +import type { ListEntitiesResponse } from '../../../../common/api/entity_analytics/entity_store/entities/list_entities.gen'; +import { EntityType } from '../../../../common/entity_analytics/types'; +import type { + HostRiskScore, + RiskScoreSortField, + RiskSeverity, + RiskStats, +} from '../../../../common/search_strategy'; +import { RiskScoreFields } from '../../../../common/search_strategy'; +import type { ESQuery } from '../../../../common/typed_json'; +import { createFilter } from '../../../common/containers/helpers'; + +export const ENTITY_STORE_HOST_RISK_LIST_QUERY_KEY = 'ENTITY_STORE_HOST_RISK_LIST'; +export const ENTITY_STORE_HOST_RISK_KPI_QUERY_KEY = 'ENTITY_STORE_HOST_RISK_KPI'; + +export const isHostEntityRecord = ( + record: ListEntitiesResponse['records'][number] +): record is HostEntity => 'host' in record && record.host != null; + +/** + * Kibana / risk-score filters use `host.risk.*` (risk index field names). Entity Store v2 latest + * indices map scores on `entity.risk.*` (see BASE_ENTITY_INDEX_MAPPING in entity_store + * component_templates). Rewrite query keys so bool / match_phrase clauses target mapped fields. + */ +const rewriteEntityStoreRiskFieldPaths = (value: unknown): unknown => { + if (value === null || typeof value !== 'object') { + return value; + } + if (Array.isArray(value)) { + return value.map(rewriteEntityStoreRiskFieldPaths); + } + const record = value as Record; + const out: Record = {}; + for (const [key, val] of Object.entries(record)) { + const mappedKey = + key === 'host.risk.calculated_level' + ? 'entity.risk.calculated_level' + : key === 'host.risk.calculated_score_norm' + ? 'entity.risk.calculated_score_norm' + : key === 'host.risk.calculated_score' + ? 'entity.risk.calculated_score' + : key === 'host.risk.@timestamp' + ? '@timestamp' + : key; + out[mappedKey] = rewriteEntityStoreRiskFieldPaths(val); + } + return out; +}; + +const parseFilterClauses = (filterQuery?: ESQuery | string): object[] => { + const filtered = createFilter(filterQuery); + if (filtered == null || filtered === '') { + return []; + } + try { + const parsed = JSON.parse(filtered) as object; + return [rewriteEntityStoreRiskFieldPaths(parsed) as object]; + } catch { + return []; + } +}; + +const hasEntityRiskClause = { + exists: { field: 'entity.risk.calculated_score_norm' }, +}; + +const entityLifecycleTimeRangeClause = (startDate: string, endDate: string) => ({ + range: { + 'entity.lifecycle.last_seen': { + gte: startDate, + lte: endDate, + format: 'strict_date_optional_time', + }, + }, +}); + +/** + * Builds a bool filter for Entity Store v2: Kibana filterQuery (JSON), optional time range on + * `entity.lifecycle.last_seen`, and hosts that have a materialized risk score. + */ +export const buildHostRiskEntityStoreFilterQuery = ({ + filterQuery, + startDate, + endDate, +}: { + filterQuery?: ESQuery | string; + startDate?: string; + endDate?: string; +}): string => { + const filterClauses: object[] = [...parseFilterClauses(filterQuery), hasEntityRiskClause]; + if (startDate != null && startDate !== '' && endDate != null && endDate !== '') { + filterClauses.push(entityLifecycleTimeRangeClause(startDate, endDate)); + } + + return JSON.stringify({ + bool: { + filter: filterClauses, + }, + }); +}; + +export const riskScoreSortFieldToEntityStoreField = (field: RiskScoreFields): string => { + switch (field) { + case RiskScoreFields.hostName: + return 'host.name'; + case RiskScoreFields.timestamp: + return '@timestamp'; + case RiskScoreFields.hostRiskScore: + return 'entity.risk.calculated_score_norm'; + case RiskScoreFields.hostRisk: + return 'entity.risk.calculated_level'; + default: + return 'entity.risk.calculated_score_norm'; + } +}; + +export const mapHostEntityRecordToHostRiskScore = (record: HostEntity): HostRiskScore | null => { + const hostName = record.host?.name; + if (hostName == null || hostName === '') { + return null; + } + + const risk = record.host?.risk ?? record.entity?.risk; + if (risk == null) { + return null; + } + + const riskStats: RiskStats = { + ...(risk as object), + rule_risks: [], + multipliers: [], + } as unknown as RiskStats; + + const timestamp = + ('@timestamp' in risk && typeof risk['@timestamp'] === 'string' ? risk['@timestamp'] : null) ?? + record['@timestamp'] ?? + record.entity?.lifecycle?.last_seen ?? + ''; + + return { + '@timestamp': timestamp, + host: { + name: hostName, + risk: riskStats, + }, + }; +}; + +export const entityStoreRiskSortToApiParams = ( + sort: RiskScoreSortField | undefined +): { sortField: string; sortOrder: 'asc' | 'desc' } => ({ + sortField: riskScoreSortFieldToEntityStoreField(sort?.field ?? RiskScoreFields.hostRiskScore), + sortOrder: sort?.direction === 'asc' ? 'asc' : 'desc', +}); + +export const isHostRiskEntityTarget = (riskEntity: EntityType | EntityType[]): boolean => + riskEntity === EntityType.host || + (Array.isArray(riskEntity) && riskEntity.length === 1 && riskEntity[0] === EntityType.host); + +export const severityFromHostRecord = (record: HostEntity): RiskSeverity | undefined => + (record.host?.risk?.calculated_level ?? record.entity?.risk?.calculated_level) as + | RiskSeverity + | undefined; diff --git a/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/api/hooks/entity_store_user_risk_common.ts b/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/api/hooks/entity_store_user_risk_common.ts new file mode 100644 index 0000000000000..c2024099d3334 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/api/hooks/entity_store_user_risk_common.ts @@ -0,0 +1,171 @@ +/* + * 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 { UserEntity } from '../../../../common/api/entity_analytics/entity_store/entities/common.gen'; +import type { ListEntitiesResponse } from '../../../../common/api/entity_analytics/entity_store/entities/list_entities.gen'; +import { EntityType } from '../../../../common/entity_analytics/types'; +import type { + RiskScoreSortField, + RiskSeverity, + RiskStats, + UserRiskScore, +} from '../../../../common/search_strategy'; +import { RiskScoreFields } from '../../../../common/search_strategy'; +import type { ESQuery } from '../../../../common/typed_json'; +import { createFilter } from '../../../common/containers/helpers'; + +export const ENTITY_STORE_USER_RISK_LIST_QUERY_KEY = 'ENTITY_STORE_USER_RISK_LIST'; +export const ENTITY_STORE_USER_RISK_KPI_QUERY_KEY = 'ENTITY_STORE_USER_RISK_KPI'; + +export const isUserEntityRecord = ( + record: ListEntitiesResponse['records'][number] +): record is UserEntity => 'user' in record && record.user != null; + +/** + * Kibana / risk-score filters use `user.risk.*` (risk index field names). Entity Store v2 latest + * indices map scores on `entity.risk.*` (see BASE_ENTITY_INDEX_MAPPING in entity_store + * component_templates). Rewrite query keys so bool / match_phrase clauses target mapped fields. + */ +const rewriteEntityStoreUserRiskFieldPaths = (value: unknown): unknown => { + if (value === null || typeof value !== 'object') { + return value; + } + if (Array.isArray(value)) { + return value.map(rewriteEntityStoreUserRiskFieldPaths); + } + const record = value as Record; + const out: Record = {}; + for (const [key, val] of Object.entries(record)) { + const mappedKey = + key === 'user.risk.calculated_level' + ? 'entity.risk.calculated_level' + : key === 'user.risk.calculated_score_norm' + ? 'entity.risk.calculated_score_norm' + : key === 'user.risk.calculated_score' + ? 'entity.risk.calculated_score' + : key === 'user.risk.@timestamp' + ? '@timestamp' + : key; + out[mappedKey] = rewriteEntityStoreUserRiskFieldPaths(val); + } + return out; +}; + +const parseFilterClauses = (filterQuery?: ESQuery | string): object[] => { + const filtered = createFilter(filterQuery); + if (filtered == null || filtered === '') { + return []; + } + try { + const parsed = JSON.parse(filtered) as object; + return [rewriteEntityStoreUserRiskFieldPaths(parsed) as object]; + } catch { + return []; + } +}; + +const hasEntityRiskClause = { + exists: { field: 'entity.risk.calculated_score_norm' }, +}; + +const entityLifecycleTimeRangeClause = (startDate: string, endDate: string) => ({ + range: { + 'entity.lifecycle.last_seen': { + gte: startDate, + lte: endDate, + format: 'strict_date_optional_time', + }, + }, +}); + +/** + * Builds a bool filter for Entity Store v2: Kibana filterQuery (JSON), optional time range on + * `entity.lifecycle.last_seen`, and users that have a materialized risk score. + */ +export const buildUserRiskEntityStoreFilterQuery = ({ + filterQuery, + startDate, + endDate, +}: { + filterQuery?: ESQuery | string; + startDate?: string; + endDate?: string; +}): string => { + const filterClauses: object[] = [...parseFilterClauses(filterQuery), hasEntityRiskClause]; + if (startDate != null && startDate !== '' && endDate != null && endDate !== '') { + filterClauses.push(entityLifecycleTimeRangeClause(startDate, endDate)); + } + + return JSON.stringify({ + bool: { + filter: filterClauses, + }, + }); +}; + +export const riskScoreSortFieldToUserEntityStoreField = (field: RiskScoreFields): string => { + switch (field) { + case RiskScoreFields.userName: + return 'user.name'; + case RiskScoreFields.timestamp: + return '@timestamp'; + case RiskScoreFields.userRiskScore: + return 'entity.risk.calculated_score_norm'; + case RiskScoreFields.userRisk: + return 'entity.risk.calculated_level'; + default: + return 'entity.risk.calculated_score_norm'; + } +}; + +export const mapUserEntityRecordToUserRiskScore = (record: UserEntity): UserRiskScore | null => { + const userName = record.user?.name; + if (userName == null || userName === '') { + return null; + } + + const risk = record.user?.risk ?? record.entity?.risk; + if (risk == null) { + return null; + } + + const riskStats: RiskStats = { + ...(risk as object), + rule_risks: [], + multipliers: [], + } as unknown as RiskStats; + + const timestamp = + ('@timestamp' in risk && typeof risk['@timestamp'] === 'string' ? risk['@timestamp'] : null) ?? + record['@timestamp'] ?? + record.entity?.lifecycle?.last_seen ?? + ''; + + return { + '@timestamp': timestamp, + user: { + name: userName, + risk: riskStats, + }, + }; +}; + +export const entityStoreUserRiskSortToApiParams = ( + sort: RiskScoreSortField | undefined +): { sortField: string; sortOrder: 'asc' | 'desc' } => ({ + sortField: riskScoreSortFieldToUserEntityStoreField(sort?.field ?? RiskScoreFields.userRiskScore), + sortOrder: sort?.direction === 'asc' ? 'asc' : 'desc', +}); + +export const isUserRiskEntityTarget = (riskEntity: EntityType | EntityType[]): boolean => + riskEntity === EntityType.user || + (Array.isArray(riskEntity) && riskEntity.length === 1 && riskEntity[0] === EntityType.user); + +export const severityFromUserRecord = (record: UserEntity): RiskSeverity | undefined => + (record.user?.risk?.calculated_level ?? record.entity?.risk?.calculated_level) as + | RiskSeverity + | undefined; diff --git a/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/api/hooks/use_entity_store_risk_score.tsx b/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/api/hooks/use_entity_store_risk_score.tsx new file mode 100644 index 0000000000000..274ec4ad4b29e --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/api/hooks/use_entity_store_risk_score.tsx @@ -0,0 +1,222 @@ +/* + * 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 { useCallback, useMemo } from 'react'; + +import { i18n } from '@kbn/i18n'; +import { useQuery } from '@kbn/react-query'; +import type { IHttpFetchError } from '@kbn/core/public'; + +import type { EntityType } from '../../../../common/search_strategy'; +import type { ListEntitiesResponse } from '../../../../common/api/entity_analytics/entity_store/entities/list_entities.gen'; +import type { InspectResponse } from '../../../types'; +import type { inputsModel } from '../../../common/store'; +import { useErrorToast } from '../../../common/hooks/use_error_toast'; +import { useMlCapabilities } from '../../../common/components/ml/hooks/use_ml_capabilities'; +import { useHasSecurityCapability } from '../../../helper_hooks'; +import { useEntityAnalyticsRoutes } from '../api'; +import { + buildHostRiskEntityStoreFilterQuery, + ENTITY_STORE_HOST_RISK_LIST_QUERY_KEY, + entityStoreRiskSortToApiParams, + isHostEntityRecord, + isHostRiskEntityTarget, + mapHostEntityRecordToHostRiskScore, +} from './entity_store_host_risk_common'; +import { + buildUserRiskEntityStoreFilterQuery, + ENTITY_STORE_USER_RISK_LIST_QUERY_KEY, + entityStoreUserRiskSortToApiParams, + isUserEntityRecord, + isUserRiskEntityTarget, + mapUserEntityRecordToUserRiskScore, +} from './entity_store_user_risk_common'; +import type { RiskScoreState, UseRiskScoreParams } from './use_risk_score'; +import { useRiskEngineStatus } from './use_risk_engine_status'; + +interface UseEntityStoreRiskScoreParams extends UseRiskScoreParams { + riskEntity: EntityType; +} + +export function useEntityStoreRiskScore( + params: UseEntityStoreRiskScoreParams & { riskEntity: EntityType.user } +): RiskScoreState; +export function useEntityStoreRiskScore( + params: UseEntityStoreRiskScoreParams & { riskEntity: EntityType.host } +): RiskScoreState; +export function useEntityStoreRiskScore({ + timerange, + filterQuery, + sort, + skip = false, + pagination, + riskEntity, +}: UseEntityStoreRiskScoreParams): + | RiskScoreState + | RiskScoreState { + const { fetchEntitiesListV2 } = useEntityAnalyticsRoutes(); + const { + data: riskEngineStatus, + isFetching: isStatusLoading, + refetch: refetchEngineStatus, + } = useRiskEngineStatus(); + const { isPlatinumOrTrialLicense } = useMlCapabilities(); + const hasEntityAnalyticsCapability = useHasSecurityCapability('entity-analytics'); + const isAuthorized = isPlatinumOrTrialLicense && hasEntityAnalyticsCapability; + const hasEngineBeenInstalled = riskEngineStatus?.risk_engine_status !== 'NOT_INSTALLED'; + + const hostTarget = isHostRiskEntityTarget(riskEntity); + const userTarget = isUserRiskEntityTarget(riskEntity); + + const { querySize, cursorStart } = pagination ?? {}; + const { sortField, sortOrder } = useMemo(() => { + if (hostTarget) { + return entityStoreRiskSortToApiParams(sort); + } + if (userTarget) { + return entityStoreUserRiskSortToApiParams(sort); + } + return { sortField: 'entity.risk.calculated_score_norm', sortOrder: 'desc' as const }; + }, [hostTarget, userTarget, sort]); + + const listFilterQuery = useMemo(() => { + if (hostTarget) { + return buildHostRiskEntityStoreFilterQuery({ + filterQuery, + startDate: timerange?.from, + endDate: timerange?.to, + }); + } + if (userTarget) { + return buildUserRiskEntityStoreFilterQuery({ + filterQuery, + startDate: timerange?.from, + endDate: timerange?.to, + }); + } + return JSON.stringify({ bool: { filter: [] } }); + }, [hostTarget, userTarget, filterQuery, timerange?.from, timerange?.to]); + + const page = + cursorStart !== undefined && querySize !== undefined + ? Math.floor(cursorStart / querySize) + 1 + : 1; + const perPage = querySize ?? 10; + + const queryEnabled = + !skip && + (hostTarget || userTarget) && + isAuthorized && + hasEngineBeenInstalled && + cursorStart !== undefined && + querySize !== undefined; + + const listQueryKey = hostTarget + ? ENTITY_STORE_HOST_RISK_LIST_QUERY_KEY + : ENTITY_STORE_USER_RISK_LIST_QUERY_KEY; + + const entityTypes = hostTarget ? (['host'] as const) : userTarget ? (['user'] as const) : []; + + const { data, isLoading, isFetching, error, refetch } = useQuery< + ListEntitiesResponse | null, + IHttpFetchError + >({ + queryKey: [listQueryKey, listFilterQuery, page, perPage, sortField, sortOrder, queryEnabled], + queryFn: async ({ signal }) => + fetchEntitiesListV2({ + signal, + params: { + entityTypes: [...entityTypes], + filterQuery: listFilterQuery, + page, + perPage, + sortField, + sortOrder, + }, + }), + enabled: queryEnabled, + cacheTime: 0, + refetchOnWindowFocus: false, + keepPreviousData: true, + }); + + const failSearchTitle = useMemo(() => { + if (hostTarget) { + return i18n.translate( + 'xpack.securitySolution.entityStore.hostRiskScore.failSearchDescription', + { + defaultMessage: 'Failed to load host risk scores from the entity store', + } + ); + } + if (userTarget) { + return i18n.translate( + 'xpack.securitySolution.entityStore.userRiskScore.failSearchDescription', + { + defaultMessage: 'Failed to load user risk scores from the entity store', + } + ); + } + return ''; + }, [hostTarget, userTarget]); + + useErrorToast(failSearchTitle, queryEnabled ? error : undefined); + + const rows = useMemo(() => { + if (data?.records == null) { + return []; + } + if (hostTarget) { + return data.records.flatMap((record) => { + if (!isHostEntityRecord(record)) { + return []; + } + const row = mapHostEntityRecordToHostRiskScore(record); + return row != null ? [row] : []; + }); + } + if (userTarget) { + return data.records.flatMap((record) => { + if (!isUserEntityRecord(record)) { + return []; + } + const row = mapUserEntityRecordToUserRiskScore(record); + return row != null ? [row] : []; + }); + } + return []; + }, [data?.records, hostTarget, userTarget]); + + const inspect: InspectResponse = useMemo( + () => ({ + dsl: data?.inspect?.dsl ?? [], + response: data?.inspect?.response ?? [], + }), + [data?.inspect?.dsl, data?.inspect?.response] + ); + + const refetchAll: inputsModel.Refetch = useCallback(() => { + void refetchEngineStatus(); + void refetch(); + }, [refetch, refetchEngineStatus]); + + const totalCount = data?.total ?? 0; + const storeTarget = hostTarget || userTarget; + + return { + data: hostTarget ? rows : userTarget ? rows : undefined, + error, + hasEngineBeenInstalled, + inspect, + isAuthorized, + isInspected: false, + loading: + storeTarget && queryEnabled ? isLoading || isFetching || isStatusLoading : isStatusLoading, + refetch: refetchAll, + totalCount: storeTarget ? totalCount : 0, + } as RiskScoreState | RiskScoreState; +} diff --git a/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/api/hooks/use_entity_store_risk_score_kpi.tsx b/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/api/hooks/use_entity_store_risk_score_kpi.tsx new file mode 100644 index 0000000000000..619fff3296dea --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/api/hooks/use_entity_store_risk_score_kpi.tsx @@ -0,0 +1,229 @@ +/* + * 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 { useCallback, useEffect, useMemo } from 'react'; + +import { i18n } from '@kbn/i18n'; +import { useQuery } from '@kbn/react-query'; +import type { IHttpFetchError } from '@kbn/core/public'; + +import { EMPTY_SEVERITY_COUNT, RiskSeverity } from '../../../../common/search_strategy'; +import { isIndexNotFoundError } from '../../../common/utils/exceptions'; +import type { InspectResponse } from '../../../types'; +import type { inputsModel } from '../../../common/store'; +import type { SeverityCount } from '../../components/severity/types'; +import { useAppToasts } from '../../../common/hooks/use_app_toasts'; +import { useMlCapabilities } from '../../../common/components/ml/hooks/use_ml_capabilities'; +import { useHasSecurityCapability } from '../../../helper_hooks'; +import { useEntityAnalyticsRoutes } from '../api'; +import { + buildHostRiskEntityStoreFilterQuery, + ENTITY_STORE_HOST_RISK_KPI_QUERY_KEY, + isHostEntityRecord, + isHostRiskEntityTarget, + severityFromHostRecord, +} from './entity_store_host_risk_common'; +import { + buildUserRiskEntityStoreFilterQuery, + ENTITY_STORE_USER_RISK_KPI_QUERY_KEY, + isUserEntityRecord, + isUserRiskEntityTarget, + severityFromUserRecord, +} from './entity_store_user_risk_common'; +import type { UseRiskScoreKpiProps } from './use_risk_score_kpi'; +import { useRiskEngineStatus } from './use_risk_engine_status'; + +const KPI_PAGE_SIZE = 10_000; +const MAX_KPI_PAGES = 500; + +interface EntityStoreRiskKpiQueryResult { + inspect: InspectResponse; + severityCount: SeverityCount; +} + +export const useEntityStoreRiskScoreKpi = ({ + filterQuery, + skip, + riskEntity, + timerange, +}: UseRiskScoreKpiProps) => { + const { addError } = useAppToasts(); + const { fetchEntitiesListV2 } = useEntityAnalyticsRoutes(); + const { + data: riskEngineStatus, + isFetching: isStatusLoading, + refetch: refetchEngineStatus, + } = useRiskEngineStatus(); + const { isPlatinumOrTrialLicense } = useMlCapabilities(); + const hasEntityAnalyticsCapability = useHasSecurityCapability('entity-analytics'); + const isAuthorized = isPlatinumOrTrialLicense && hasEntityAnalyticsCapability; + const hasEngineBeenInstalled = riskEngineStatus?.risk_engine_status !== 'NOT_INSTALLED'; + + const hostTarget = isHostRiskEntityTarget(riskEntity); + const userTarget = isUserRiskEntityTarget(riskEntity); + const storeTarget = hostTarget || userTarget; + + const listFilterQuery = useMemo(() => { + if (hostTarget) { + return buildHostRiskEntityStoreFilterQuery({ + filterQuery, + startDate: timerange?.from, + endDate: timerange?.to, + }); + } + if (userTarget) { + return buildUserRiskEntityStoreFilterQuery({ + filterQuery, + startDate: timerange?.from, + endDate: timerange?.to, + }); + } + return JSON.stringify({ bool: { filter: [] } }); + }, [hostTarget, userTarget, filterQuery, timerange?.from, timerange?.to]); + + const kpiQueryKey = hostTarget + ? ENTITY_STORE_HOST_RISK_KPI_QUERY_KEY + : ENTITY_STORE_USER_RISK_KPI_QUERY_KEY; + + const queryEnabled = + !skip && storeTarget && isAuthorized && hasEngineBeenInstalled && !isStatusLoading; + + const failSearchTitle = useMemo(() => { + if (hostTarget) { + return i18n.translate( + 'xpack.securitySolution.entityStore.hostRiskScore.kpi.failSearchDescription', + { + defaultMessage: 'Failed to load host risk score KPIs from the entity store', + } + ); + } + if (userTarget) { + return i18n.translate( + 'xpack.securitySolution.entityStore.userRiskScore.kpi.failSearchDescription', + { + defaultMessage: 'Failed to load user risk score KPIs from the entity store', + } + ); + } + return ''; + }, [hostTarget, userTarget]); + + const { data, isLoading, isFetching, error, refetch } = useQuery< + EntityStoreRiskKpiQueryResult, + IHttpFetchError + >({ + queryKey: [kpiQueryKey, listFilterQuery, queryEnabled], + queryFn: async ({ signal }) => { + const severityCount: SeverityCount = { ...EMPTY_SEVERITY_COUNT }; + const inspect: InspectResponse = { dsl: [], response: [] }; + + const entityTypes = hostTarget ? (['host'] as const) : (['user'] as const); + const sortField = hostTarget ? 'host.name' : 'user.name'; + + let page = 1; + let fetched = 0; + let total = 0; + + do { + const res = await fetchEntitiesListV2({ + signal, + params: { + entityTypes: [...entityTypes], + filterQuery: listFilterQuery, + page, + perPage: KPI_PAGE_SIZE, + sortField, + sortOrder: 'asc', + }, + }); + + total = res.total; + if (res.inspect?.dsl != null) { + inspect.dsl.push(...res.inspect.dsl); + } + if (res.inspect?.response != null) { + inspect.response.push(...res.inspect.response); + } + + for (const record of res.records) { + if (hostTarget && isHostEntityRecord(record)) { + const level = severityFromHostRecord(record); + if (level != null && level in severityCount) { + severityCount[level] += 1; + } + } else if (userTarget && isUserEntityRecord(record)) { + const level = severityFromUserRecord(record); + if (level != null && level in severityCount) { + severityCount[level] += 1; + } + } + } + + fetched += res.records.length; + page += 1; + + if (res.records.length === 0 || fetched >= total || page > MAX_KPI_PAGES) { + break; + } + } while (fetched < total); + + return { inspect, severityCount }; + }, + enabled: queryEnabled, + cacheTime: 0, + refetchOnWindowFocus: false, + }); + + useEffect(() => { + if (error != null && !isIndexNotFoundError(error)) { + addError(error, { + title: failSearchTitle, + }); + } + }, [addError, error, failSearchTitle]); + + const refetchAll: inputsModel.Refetch = useCallback(() => { + void refetchEngineStatus(); + void refetch(); + }, [refetch, refetchEngineStatus]); + + const isModuleDisabled = !!error && isIndexNotFoundError(error); + + const severityCount = useMemo(() => { + if (!storeTarget || isLoading || isFetching || error != null) { + return undefined; + } + if (data == null) { + return undefined; + } + return { + [RiskSeverity.Unknown]: data.severityCount[RiskSeverity.Unknown] ?? 0, + [RiskSeverity.Low]: data.severityCount[RiskSeverity.Low] ?? 0, + [RiskSeverity.Moderate]: data.severityCount[RiskSeverity.Moderate] ?? 0, + [RiskSeverity.High]: data.severityCount[RiskSeverity.High] ?? 0, + [RiskSeverity.Critical]: data.severityCount[RiskSeverity.Critical] ?? 0, + }; + }, [data, error, storeTarget, isFetching, isLoading]); + + const inspect: InspectResponse = useMemo( + () => + data?.inspect ?? { + dsl: [], + response: [], + }, + [data?.inspect] + ); + + return { + error, + inspect, + isModuleDisabled, + loading: storeTarget ? isLoading || isFetching || isStatusLoading : false, + refetch: refetchAll, + severityCount, + }; +}; diff --git a/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/api/hooks/use_risk_score_kpi.tsx b/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/api/hooks/use_risk_score_kpi.tsx index 96f1eb57396aa..6b9b8005db3cd 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/api/hooks/use_risk_score_kpi.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/api/hooks/use_risk_score_kpi.tsx @@ -31,7 +31,7 @@ interface RiskScoreKpi { timerange?: { to: string; from: string }; } -interface UseRiskScoreKpiProps { +export interface UseRiskScoreKpiProps { filterQuery?: string | ESQuery; skip?: boolean; riskEntity: EntityType | EntityType[]; diff --git a/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/common/get_start_date_from_risk_score.ts b/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/common/get_start_date_from_risk_score.ts index a3c7cfee663bd..58e4a00156f06 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/common/get_start_date_from_risk_score.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/common/get_start_date_from_risk_score.ts @@ -7,6 +7,12 @@ import dateMath from '@kbn/datemath'; import moment from 'moment'; +/** Moment constructor that parses/builds in UTC, while keeping datemath-required statics like `isMoment`. */ +const utcMomentInstance: typeof moment = Object.assign( + (input?: moment.MomentInput) => (input === undefined ? moment.utc() : moment.utc(input)), + moment +); + /** * return start date of risk scoring by calculating the difference between risk score timestamp and risk range start date * return the same risk range start date if it's a date @@ -23,14 +29,15 @@ export const getStartDateFromRiskScore = ({ if (moment(riskRangeStart).isValid()) { return riskRangeStart; } - const startDateFromNow = dateMath.parse(riskRangeStart); + // Use UTC for datemath + diffs so relative units (e.g. now-30d) are not skewed by local DST. + const startDateFromNow = dateMath.parse(riskRangeStart, { momentInstance: utcMomentInstance }); if (!startDateFromNow || !startDateFromNow.isValid()) { throw new Error('error parsing risk range start date'); } - const now = moment(); + const now = moment.utc(); const rangeInHours = now.diff(startDateFromNow, 'minutes'); - const riskScoreDate = dateMath.parse(riskScoreTimestamp); + const riskScoreDate = dateMath.parse(riskScoreTimestamp, { momentInstance: utcMomentInstance }); if (!riskScoreDate || !riskScoreDate.isValid()) { throw new Error('error parsing risk score timestamp'); } diff --git a/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/components/entity_store/helpers.tsx b/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/components/entity_store/helpers.tsx index 12a72114d671c..172583cec79ba 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/components/entity_store/helpers.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/components/entity_store/helpers.tsx @@ -13,242 +13,16 @@ import { ASSET_CRITICALITY_INDEX_PATTERN, RISK_SCORE_INDEX_PATTERN, } from '../../../../common/constants'; -import type { - Entity, - EntityField, - HostEntity, - UserEntity, - ServiceEntity, - GenericEntity, -} from '../../../../common/api/entity_analytics/entity_store/entities/common.gen'; - -/** - * Sanitizes entity field for upsert: only pass through keys allowed by EntityField. - * Omits EngineMetadata so the backend does not reject "entity.EngineMetadata.Type" as not allowed to be updated. - */ -function sanitizeEntityField(field: EntityField | undefined): EntityField | undefined { - if (!field || typeof field.id !== 'string') return undefined; - return { - id: field.id, - ...(field.name !== undefined && { name: field.name }), - ...(field.type !== undefined && { type: field.type }), - ...(field.sub_type !== undefined && { sub_type: field.sub_type }), - ...(field.source !== undefined && { source: field.source }), - ...(field.attributes !== undefined && { attributes: field.attributes }), - ...(field.behaviors !== undefined && { behaviors: field.behaviors }), - ...(field.lifecycle !== undefined && { lifecycle: field.lifecycle }), - ...(field.relationships !== undefined && { relationships: field.relationships }), - ...(field.risk !== undefined && { risk: field.risk }), - }; -} - -/** Allowed keys for HostEntity.host (strict schema; e.g. 'os' is not allowed). */ -function toArray(value: unknown): string[] | undefined { - if (value == null) return undefined; - if (Array.isArray(value)) { - return value.every((x) => typeof x === 'string') ? (value as string[]) : undefined; - } - if (typeof value === 'string') return [value]; - return undefined; -} - -function sanitizeHostForUpsert(host: Record): HostEntity['host'] { - const name = host.name; - if (typeof name !== 'string') return { name: '' }; - const out: NonNullable = { - name, - ...(toArray(host.hostname) && { hostname: toArray(host.hostname) }), - ...(toArray(host.domain) && { domain: toArray(host.domain) }), - ...(toArray(host.ip) && { ip: toArray(host.ip) }), - ...(toArray(host.id) && { id: toArray(host.id) }), - ...(toArray(host.type) && { type: toArray(host.type) }), - ...(toArray(host.mac) && { mac: toArray(host.mac) }), - ...(toArray(host.architecture) && { architecture: toArray(host.architecture) }), - }; - if (host.risk != null && typeof host.risk === 'object') { - out.risk = host.risk as NonNullable['risk']; - } - if (host.entity != null && typeof host.entity === 'object') { - const sanitized = sanitizeEntityField(host.entity as EntityField); - if (sanitized) out.entity = sanitized; - } - return out; -} - -/** Allowed keys for UserEntity.user (strict schema; e.g. 'group' is not allowed). user.id must be array. */ -function sanitizeUserForUpsert(user: Record): NonNullable { - const name = user.name; - if (typeof name !== 'string') return { name: '' }; - const out: NonNullable = { - name, - ...(toArray(user.id) && { id: toArray(user.id) }), - ...(toArray(user.full_name) && { full_name: toArray(user.full_name) }), - ...(toArray(user.domain) && { domain: toArray(user.domain) }), - ...(toArray(user.roles) && { roles: toArray(user.roles) }), - ...(toArray(user.email) && { email: toArray(user.email) }), - ...(toArray(user.hash) && { hash: toArray(user.hash) }), - }; - if (user.risk != null && typeof user.risk === 'object') { - out.risk = user.risk as NonNullable['risk']; - } - return out; -} - -/** - * Entity Store upsert schema allows only `event.ingested` (strict). Index/search hits include ECS - * fields such as `kind`, `module`, `category`, and `type`, which must be dropped. - */ -function sanitizeEventForUpsert( - event: Record -): NonNullable | undefined { - if (typeof event.ingested !== 'string') { - return undefined; - } - return { ingested: event.ingested }; -} - -/** - * Returns a record that conforms to the Entity Store upsert API schema. - * List/index documents can include extra fields (e.g. `agent`, `entity.EngineMetadata.UntypedId`). - * The upsert API uses a strict schema and rejects unknown keys. - */ -export function sanitizeEntityRecordForUpsert(record: Entity): Entity { - const entityType = getEntityType(record); - const raw = record as Record; - const entity: EntityField = - sanitizeEntityField(record.entity) ?? - (record.entity && { - ...record.entity, - EngineMetadata: undefined, - }); - - if (!entity) { - throw new Error('Entity record must have a valid entity field with id'); - } - - if (entityType === 'host') { - return buildHostEntityForUpsert(entity, raw); - } - if (entityType === 'user') { - return buildUserEntityForUpsert(entity, raw); - } - if (entityType === 'service') { - return buildServiceEntityForUpsert(entity, raw); - } - return buildGenericEntityForUpsert(entity, raw); -} - -function buildHostEntityForUpsert(entity: EntityField, raw: Record): HostEntity { - const event = - raw.event != null && typeof raw.event === 'object' - ? sanitizeEventForUpsert(raw.event as Record) - : undefined; - - return { - entity, - ...(raw.host != null && - typeof raw.host === 'object' && { - host: sanitizeHostForUpsert(raw.host as Record), - }), - ...(raw.asset != null && - typeof raw.asset === 'object' && { - asset: raw.asset as HostEntity['asset'], - }), - ...(event && { event }), - }; -} - -function buildUserEntityForUpsert(entity: EntityField, raw: Record): UserEntity { - const event = - raw.event != null && typeof raw.event === 'object' - ? sanitizeEventForUpsert(raw.event as Record) - : undefined; - - return { - entity, - ...(raw.user != null && - typeof raw.user === 'object' && { - user: sanitizeUserForUpsert(raw.user as Record), - }), - ...(raw.asset != null && - typeof raw.asset === 'object' && { - asset: raw.asset as UserEntity['asset'], - }), - ...(event && { event }), - }; -} - -function buildServiceEntityForUpsert( - entity: EntityField, - raw: Record -): ServiceEntity { - const event = - raw.event != null && typeof raw.event === 'object' - ? sanitizeEventForUpsert(raw.event as Record) - : undefined; - - return { - entity, - ...(raw.service != null && - typeof raw.service === 'object' && { - service: raw.service as ServiceEntity['service'], - }), - ...(raw.asset != null && - typeof raw.asset === 'object' && { - asset: raw.asset as ServiceEntity['asset'], - }), - ...(event && { event }), - }; -} - -function buildGenericEntityForUpsert( - entity: EntityField, - raw: Record -): GenericEntity { - return { - entity, - ...(raw.asset != null && - typeof raw.asset === 'object' && { - asset: raw.asset as GenericEntity['asset'], - }), - }; -} - -/** Keys used when entity fields are flattened at top level (e.g. from ES/search API) */ -const FLAT_ENTITY_TYPE_KEY = 'entity.type'; -const FLAT_ENTITY_ENGINE_TYPE_KEY = 'entity.EngineMetadata.Type'; - -/** Display values for entity.type (e.g. from entity store extraction) mapped to EntityType */ -const ENTITY_TYPE_DISPLAY_TO_ENUM: Record = { - Host: EntityType.host, - Identity: EntityType.user, - Service: EntityType.service, -}; - -export const getEntityType = (record: Entity): EntityType => { - // Prefer nested form, then flattened top-level keys (e.g. entity store / search results) - const recordAny = record as Record; - const rawType = - record.entity?.EngineMetadata?.Type ?? - record.entity?.type ?? - recordAny[FLAT_ENTITY_ENGINE_TYPE_KEY] ?? - recordAny[FLAT_ENTITY_TYPE_KEY]; - - if (!rawType || typeof rawType !== 'string') { - throw new Error(`Unexpected entity: ${JSON.stringify(record)}`); - } +import type { Entity } from '../../../../common/api/entity_analytics/entity_store/entities/common.gen'; +import { + getEntityType as getEntityTypeFromCommon, + sanitizeEntityRecordForUpsert as sanitizeEntityRecordForUpsertFromCommon, +} from '../../../../common/entity_analytics/entity_store/sanitize_entity_record_for_upsert'; - const normalized = - ENTITY_TYPE_DISPLAY_TO_ENUM[rawType] ?? - (Object.values(EntityType).includes(rawType as EntityType) - ? (rawType as EntityType) - : undefined); - if (normalized === undefined) { - throw new Error(`Unexpected entity: ${JSON.stringify(record)}`); - } +export const getEntityType = getEntityTypeFromCommon; - return normalized; -}; +export const sanitizeEntityRecordForUpsert = (record: Entity): Entity => + sanitizeEntityRecordForUpsertFromCommon(record); export const EntityIconByType: Record = { [EntityType.user]: 'user', @@ -270,7 +44,7 @@ export const sourceFieldToText = (source: string) => { if (source.match(`^${ASSET_CRITICALITY_INDEX_PATTERN}`)) { return ( ); 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 6b20d66c136d1..5c1a7191e0cf2 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 @@ -72,6 +72,54 @@ 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', () => { + render( + + + + ); + expect(mockUseRiskScore).toBeCalledWith( + expect.objectContaining({ + filterQuery: { + bool: { + filter: [ + { + match: { + 'host.name': { query: 'n1', type: 'phrase' }, + }, + }, + { + match: { + 'host.hostname': { query: 'h1', type: 'phrase' }, + }, + }, + ], + }, + }, + }) + ); + }); + it("doesn't skip when both toggleStatus are true", () => { 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 1d66bea440c69..f5a24d26301be 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 @@ -19,6 +19,7 @@ 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'; @@ -33,12 +34,51 @@ 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; riskEntity: EntityType; + identityScopedFilterQuery?: string; + identityFields?: Record; } -> = ({ entityName, startDate, endDate, setQuery, deleteQuery, riskEntity }) => { +> = ({ + entityName, + startDate, + endDate, + setQuery, + deleteQuery, + riskEntity, + identityScopedFilterQuery, + identityFields, +}) => { const queryId = useMemo( () => riskEntity === EntityType.host @@ -64,10 +104,16 @@ const RiskDetailsTabBodyComponent: React.FC< const { toggleStatus: contributorsToggleStatus, setToggleStatus: setContributorsToggleStatus } = useQueryToggle(`${queryId} contributors`); - const filterQuery = useMemo( - () => (entityName ? buildEntityNameFilter(riskEntity, [entityName]) : {}), - [entityName, riskEntity] - ); + const filterQuery = useMemo(() => { + if (identityScopedFilterQuery) { + return identityScopedFilterQuery; + } + const identityFieldsQuery = buildFilterQueryFromIdentityFields(identityFields); + if (identityFieldsQuery !== undefined) { + return identityFieldsQuery; + } + return entityName ? buildEntityNameFilter(riskEntity, [entityName]) : {}; + }, [entityName, identityFields, identityScopedFilterQuery, riskEntity]); const { data, loading, refetch, inspect, hasEngineBeenInstalled } = useRiskScore({ filterQuery, diff --git a/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/components/user_risk_score_tab_body.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/components/user_risk_score_tab_body.test.tsx index a8e1c2ac20fbf..dc9f97b4f40de 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/components/user_risk_score_tab_body.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/components/user_risk_score_tab_body.test.tsx @@ -13,15 +13,21 @@ import { UserRiskScoreQueryTabBody } from './user_risk_score_tab_body'; import { UsersType } from '../../explore/users/store/model'; import { useRiskScore } from '../api/hooks/use_risk_score'; import { useRiskScoreKpi } from '../api/hooks/use_risk_score_kpi'; +import { useEntityStoreRiskScore } from '../api/hooks/use_entity_store_risk_score'; +import { useEntityStoreRiskScoreKpi } from '../api/hooks/use_entity_store_risk_score_kpi'; jest.mock('../api/hooks/use_risk_score_kpi'); jest.mock('../api/hooks/use_risk_score'); +jest.mock('../api/hooks/use_entity_store_risk_score'); +jest.mock('../api/hooks/use_entity_store_risk_score_kpi'); jest.mock('../../common/containers/query_toggle'); jest.mock('../../common/lib/kibana'); describe('All users query tab body', () => { const mockUseRiskScore = useRiskScore as jest.Mock; const mockUseRiskScoreKpi = useRiskScoreKpi as jest.Mock; + const mockUseEntityStoreRiskScore = useEntityStoreRiskScore as jest.Mock; + const mockUseEntityStoreRiskScoreKpi = useEntityStoreRiskScoreKpi as jest.Mock; const mockUseQueryToggle = useQueryToggle as jest.Mock; const defaultProps = { indexNames: [], @@ -57,6 +63,31 @@ describe('All users query tab body', () => { critical: 12, }, }); + mockUseEntityStoreRiskScore.mockReturnValue({ + loading: false, + data: [], + error: undefined, + hasEngineBeenInstalled: true, + inspect: { dsl: [], response: [] }, + isAuthorized: true, + isInspected: false, + refetch: jest.fn(), + totalCount: 0, + }); + mockUseEntityStoreRiskScoreKpi.mockReturnValue({ + loading: false, + error: undefined, + inspect: { dsl: [], response: [] }, + isModuleDisabled: false, + refetch: jest.fn(), + severityCount: { + unknown: 0, + low: 0, + moderate: 0, + high: 0, + critical: 0, + }, + }); }); it('toggleStatus=true, do not skip', () => { @@ -67,6 +98,8 @@ describe('All users query tab body', () => { ); expect(mockUseRiskScore.mock.calls[0][0].skip).toEqual(false); expect(mockUseRiskScoreKpi.mock.calls[0][0].skip).toEqual(false); + expect(mockUseEntityStoreRiskScore.mock.calls[0][0].skip).toEqual(true); + expect(mockUseEntityStoreRiskScoreKpi.mock.calls[0][0].skip).toEqual(true); }); it('toggleStatus=false, skip', () => { @@ -78,5 +111,7 @@ describe('All users query tab body', () => { ); expect(mockUseRiskScore.mock.calls[0][0].skip).toEqual(true); expect(mockUseRiskScoreKpi.mock.calls[0][0].skip).toEqual(true); + expect(mockUseEntityStoreRiskScore.mock.calls[0][0].skip).toEqual(true); + expect(mockUseEntityStoreRiskScoreKpi.mock.calls[0][0].skip).toEqual(true); }); }); diff --git a/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/components/user_risk_score_tab_body.tsx b/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/components/user_risk_score_tab_body.tsx index e1a3dd45cf89f..60e62bec0567b 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/components/user_risk_score_tab_body.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/components/user_risk_score_tab_body.tsx @@ -9,9 +9,17 @@ import React, { useEffect, useMemo, useState } from 'react'; import { noop } from 'lodash/fp'; import { EuiPanel } from '@elastic/eui'; -import { EMPTY_SEVERITY_COUNT, EntityType } from '../../../common/search_strategy'; +import { FF_ENABLE_ENTITY_STORE_V2 } from '@kbn/entity-store/public'; +import { + EMPTY_SEVERITY_COUNT, + EntityType, + type RiskScoreSortField, +} from '../../../common/search_strategy'; import { useRiskScoreKpi } from '../api/hooks/use_risk_score_kpi'; import { useRiskScore } from '../api/hooks/use_risk_score'; +import { useEntityStoreRiskScoreKpi } from '../api/hooks/use_entity_store_risk_score_kpi'; +import { useEntityStoreRiskScore } from '../api/hooks/use_entity_store_risk_score'; +import { useUiSetting } from '../../common/lib/kibana'; import { UserRiskScoreQueryId } from '../common/utils'; import { EnableRiskScore } from './enable_risk_score'; import type { UsersComponentsQueryProps } from '../../explore/users/pages/navigation/types'; @@ -28,6 +36,62 @@ import { RiskScoresNoDataDetected } from './risk_score_no_data_detected'; const UserRiskScoreTableManage = manageQuery(UserRiskScoreTable); +const useUserRiskScoreTabData = ({ + entityStoreV2Enabled, + filterQuery, + pagination, + querySkip, + sort, + timerange, +}: { + entityStoreV2Enabled: boolean; + filterQuery?: UsersComponentsQueryProps['filterQuery']; + pagination: { cursorStart: number; querySize: number }; + querySkip: boolean; + sort: RiskScoreSortField; + timerange: { from: string; to: string }; +}) => { + const legacyRiskScore = useRiskScore({ + filterQuery, + pagination, + riskEntity: EntityType.user, + skip: querySkip || entityStoreV2Enabled, + sort, + timerange, + }); + + const entityStoreRiskScore = useEntityStoreRiskScore({ + filterQuery, + pagination, + riskEntity: EntityType.user, + skip: querySkip || !entityStoreV2Enabled, + sort, + timerange, + }); + + const risk = entityStoreV2Enabled ? entityStoreRiskScore : legacyRiskScore; + + const legacyKpi = useRiskScoreKpi({ + filterQuery, + skip: querySkip || entityStoreV2Enabled, + riskEntity: EntityType.user, + }); + + const entityStoreKpi = useEntityStoreRiskScoreKpi({ + filterQuery, + skip: querySkip || !entityStoreV2Enabled, + riskEntity: EntityType.user, + }); + + const kpi = entityStoreV2Enabled ? entityStoreKpi : legacyKpi; + + return { + ...risk, + isKpiLoading: kpi.loading, + severityCount: kpi.severityCount, + }; +}; + export const UserRiskScoreQueryTabBody = ({ deleteQuery, endDate: to, @@ -37,6 +101,7 @@ export const UserRiskScoreQueryTabBody = ({ startDate: from, type, }: UsersComponentsQueryProps) => { + const entityStoreV2Enabled = useUiSetting(FF_ENABLE_ENTITY_STORE_V2, false) === true; const getUserRiskScoreSelector = useMemo(() => usersSelectors.userRiskScoreSelector(), []); const { activePage, limit, sort } = useDeepEqualSelector((state: State) => getUserRiskScoreSelector(state) @@ -66,20 +131,23 @@ export const UserRiskScoreQueryTabBody = ({ const privileges = useMissingRiskEnginePrivileges({ readonly: true }); - const { data, inspect, isInspected, hasEngineBeenInstalled, loading, refetch, totalCount } = - useRiskScore({ - filterQuery, - pagination, - riskEntity: EntityType.user, - skip: querySkip, - sort, - timerange, - }); - - const { severityCount, loading: isKpiLoading } = useRiskScoreKpi({ + const { + data, + inspect, + isInspected, + hasEngineBeenInstalled, + loading, + refetch, + totalCount, + isKpiLoading, + severityCount, + } = useUserRiskScoreTabData({ + entityStoreV2Enabled, filterQuery, - riskEntity: EntityType.user, - skip: querySkip, + pagination, + querySkip, + sort, + timerange, }); const isDisabled = !hasEngineBeenInstalled && !loading; @@ -100,12 +168,13 @@ export const UserRiskScoreQueryTabBody = ({ if (isDisabled) { return ( - + ); } if ( + !loading && hasEngineBeenInstalled && userSeveritySelectionRedux.length === 0 && data && diff --git a/x-pack/solutions/security/plugins/security_solution/public/explore/components/paginated_table/__snapshots__/index.test.tsx.snap b/x-pack/solutions/security/plugins/security_solution/public/explore/components/paginated_table/__snapshots__/index.test.tsx.snap index fadf4c202c03d..79aeeebb3be1a 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/explore/components/paginated_table/__snapshots__/index.test.tsx.snap +++ b/x-pack/solutions/security/plugins/security_solution/public/explore/components/paginated_table/__snapshots__/index.test.tsx.snap @@ -240,7 +240,7 @@ exports[`Paginated Table Component rendering it renders the default load more ta @@ -249,15 +249,15 @@ exports[`Paginated Table Component rendering it renders the default load more ta > - OS + Operating system @@ -286,11 +286,7 @@ exports[`Paginated Table Component rendering it renders the default load more ta
- - — - + elrond.elstc.co
- - — - + 2018-12-06T15:40:53.319Z - - — - + Ubuntu - - — - + 18.04.1 LTS (Bionic Beaver) @@ -342,11 +326,7 @@ exports[`Paginated Table Component rendering it renders the default load more ta
- - — - + siem-kibana
- - — - + 2018-12-07T14:12:38.560Z - - — - + Debian GNU/Linux - - — - + 9 (stretch) diff --git a/x-pack/solutions/security/plugins/security_solution/public/explore/components/paginated_table/index.mock.tsx b/x-pack/solutions/security/plugins/security_solution/public/explore/components/paginated_table/index.mock.tsx index 2720f4640957e..e83003301084f 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/explore/components/paginated_table/index.mock.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/explore/components/paginated_table/index.mock.tsx @@ -14,11 +14,16 @@ export const mockData = { totalCount: 4, edges: [ { - host: { - _id: 'cPsuhGcB0WOhS6qyTKC0', - name: 'elrond.elstc.co', - os: 'Ubuntu', - version: '18.04.1 LTS (Bionic Beaver)', + node: { + host: { + _id: 'cPsuhGcB0WOhS6qyTKC0', + name: ['elrond.elstc.co'], + os: { + name: ['Ubuntu'], + version: ['18.04.1 LTS (Bionic Beaver)'], + }, + firstSeen: '2018-12-06T15:40:53.319Z', + }, firstSeen: '2018-12-06T15:40:53.319Z', }, cursor: { @@ -26,11 +31,16 @@ export const mockData = { }, }, { - host: { - _id: 'KwQDiWcB0WOhS6qyXmrW', - name: 'siem-kibana', - os: 'Debian GNU/Linux', - version: '9 (stretch)', + node: { + host: { + _id: 'KwQDiWcB0WOhS6qyXmrW', + name: ['siem-kibana'], + os: { + name: ['Debian GNU/Linux'], + version: ['9 (stretch)'], + }, + firstSeen: '2018-12-07T14:12:38.560Z', + }, firstSeen: '2018-12-07T14:12:38.560Z', }, cursor: { @@ -58,28 +68,32 @@ export const getHostsColumns = (): [ name: 'Host', truncateText: false, mobileOptions: { show: true }, - render: (name: string) => getOrEmptyTagFromValue(name), + render: (name: string | string[]) => + getOrEmptyTagFromValue(Array.isArray(name) ? name[0] : name), }, { field: 'node.host.firstSeen', name: 'First seen', truncateText: false, mobileOptions: { show: true }, - render: (firstSeen: string) => getOrEmptyTagFromValue(firstSeen), + render: (firstSeen: string | string[]) => + getOrEmptyTagFromValue(Array.isArray(firstSeen) ? firstSeen[0] : firstSeen), }, { - field: 'node.host.os', - name: 'OS', + field: 'node.host.os.name', + name: 'Operating system', truncateText: false, mobileOptions: { show: true }, - render: (os: string) => getOrEmptyTagFromValue(os), + render: (osName: string | string[]) => + getOrEmptyTagFromValue(Array.isArray(osName) ? osName[0] : osName), }, { - field: 'node.host.version', + field: 'node.host.os.version', name: 'Version', truncateText: false, mobileOptions: { show: true }, - render: (version: string) => getOrEmptyTagFromValue(version), + render: (osVersion: string | string[]) => + getOrEmptyTagFromValue(Array.isArray(osVersion) ? osVersion[0] : osVersion), }, ]; diff --git a/x-pack/solutions/security/plugins/security_solution/public/explore/components/stat_items/metric_embeddable.tsx b/x-pack/solutions/security/plugins/security_solution/public/explore/components/stat_items/metric_embeddable.tsx index e804c7ec60b13..38721ea8d8d4d 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/explore/components/stat_items/metric_embeddable.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/explore/components/stat_items/metric_embeddable.tsx @@ -9,10 +9,12 @@ import React from 'react'; import { PageScope } from '../../../data_view_manager/constants'; import { FlexItem, StatValue } from './utils'; import { VisualizationEmbeddable } from '../../../common/components/visualization_actions/visualization_embeddable'; +import type { ExtraOptions } from '../../../common/components/visualization_actions/types'; import type { FieldConfigs } from './types'; import { useIsExperimentalFeatureEnabled } from '../../../common/hooks/use_experimental_features'; export interface MetricEmbeddableProps { + extraOptions?: ExtraOptions; fields: FieldConfigs[]; id: string; inspectTitle?: string; @@ -22,6 +24,7 @@ export interface MetricEmbeddableProps { const CHART_HEIGHT = 36; const MetricEmbeddableComponent = ({ + extraOptions, fields, id, inspectTitle, @@ -50,6 +53,7 @@ const MetricEmbeddableComponent = ({
(({ statItems, from, id, to }) => { @@ -37,6 +41,13 @@ export const StatItemsComponent = React.memo(({ statItems, from, const { isToggleExpanded, onToggle } = useToggleStatus({ id }); const newDataViewPickerEnabled = useIsExperimentalFeatureEnabled('newDataViewPickerEnabled'); + const spaceId = useSpaceId(); + const entityStoreV2Enabled = useUiSetting(FF_ENABLE_ENTITY_STORE_V2, false) === true; + + const kpiLensExtraOptions = useMemo( + () => (entityStoreV2Enabled ? { entityStoreV2Enabled: true, spaceId } : undefined), + [entityStoreV2Enabled, spaceId] + ); return ( @@ -50,6 +61,7 @@ export const StatItemsComponent = React.memo(({ statItems, from, {isToggleExpanded && ( <> (({ statItems, from, { + render: (hostName, hostEdge) => { if (hostName != null && hostName.length > 0) { + const name = hostName[0]; return ( - + ); } @@ -59,7 +60,8 @@ export const getHostsColumns = ( name: ( <> - {i18n.LAST_SEEN} + {i18n.LAST_SEEN}{' '} + ), @@ -82,7 +84,8 @@ export const getHostsColumns = ( name: ( <> - {i18n.OS} + {i18n.OS}{' '} + ), diff --git a/x-pack/solutions/security/plugins/security_solution/public/explore/hosts/components/hosts_table/index.tsx b/x-pack/solutions/security/plugins/security_solution/public/explore/hosts/components/hosts_table/index.tsx index e7a9808461beb..4afcc319d3572 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/explore/hosts/components/hosts_table/index.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/explore/hosts/components/hosts_table/index.tsx @@ -51,7 +51,7 @@ interface HostsTableProps { } export type HostsTableColumns = [ - Columns, + Columns, Columns, Columns, Columns, diff --git a/x-pack/solutions/security/plugins/security_solution/public/explore/hosts/components/kpi_hosts/hosts/index.tsx b/x-pack/solutions/security/plugins/security_solution/public/explore/hosts/components/kpi_hosts/hosts/index.tsx index 1b4cfdf401843..3b058eaa1be5c 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/explore/hosts/components/kpi_hosts/hosts/index.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/explore/hosts/components/kpi_hosts/hosts/index.tsx @@ -7,10 +7,14 @@ import React, { useMemo } from 'react'; import { useEuiTheme } from '@elastic/eui'; +import { useUiSetting } from '@kbn/kibana-react-plugin/public'; + +import { FF_ENABLE_ENTITY_STORE_V2 } from '@kbn/entity-store/public'; import type { StatItems } from '../../../../components/stat_items'; +import { useSpaceId } from '../../../../../common/hooks/use_space_id'; import { getKpiHostAreaLensAttributes } from '../../../../../common/components/visualization_actions/lens_attributes/hosts/kpi_host_area'; -import { kpiHostMetricLensAttributes } from '../../../../../common/components/visualization_actions/lens_attributes/hosts/kpi_host_metric'; +import { buildKpiHostMetricLensAttributes } from '../../../../../common/components/visualization_actions/lens_attributes/hosts/kpi_host_metric'; import { KpiBaseComponent } from '../../../../components/kpi'; import type { HostsKpiProps } from '../types'; import * as i18n from './translations'; @@ -19,6 +23,9 @@ export const ID = 'hostsKpiHostsQuery'; export const useGetHostsStatItems: () => Readonly = () => { const { euiTheme } = useEuiTheme(); + const spaceId = useSpaceId(); + const entityStoreV2Enabled = useUiSetting(FF_ENABLE_ENTITY_STORE_V2, false) === true; + return useMemo( () => [ { @@ -28,7 +35,9 @@ export const useGetHostsStatItems: () => Readonly = () => { key: 'hosts', color: euiTheme.colors.vis.euiColorVis1, icon: 'storage', - lensAttributes: kpiHostMetricLensAttributes, + lensAttributes: buildKpiHostMetricLensAttributes( + entityStoreV2Enabled ? { entityStoreV2Enabled: true, spaceId } : undefined + ), }, ], enableAreaChart: true, @@ -36,7 +45,7 @@ export const useGetHostsStatItems: () => Readonly = () => { getAreaChartLensAttributes: getKpiHostAreaLensAttributes, }, ], - [euiTheme.colors.vis.euiColorVis1] + [euiTheme.colors.vis.euiColorVis1, entityStoreV2Enabled, spaceId] ); }; diff --git a/x-pack/solutions/security/plugins/security_solution/public/explore/hosts/components/uncommon_process_table/__snapshots__/index.test.tsx.snap b/x-pack/solutions/security/plugins/security_solution/public/explore/hosts/components/uncommon_process_table/__snapshots__/index.test.tsx.snap index ae2a6120d5571..f24242c565b52 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/explore/hosts/components/uncommon_process_table/__snapshots__/index.test.tsx.snap +++ b/x-pack/solutions/security/plugins/security_solution/public/explore/hosts/components/uncommon_process_table/__snapshots__/index.test.tsx.snap @@ -426,7 +426,7 @@ exports[`Uncommon Process Table Component rendering it renders the default Uncom hello-world @@ -527,7 +527,7 @@ exports[`Uncommon Process Table Component rendering it renders the default Uncom hello-world @@ -547,7 +547,7 @@ exports[`Uncommon Process Table Component rendering it renders the default Uncom hello-world-2 @@ -733,7 +733,7 @@ exports[`Uncommon Process Table Component rendering it renders the default Uncom hello-world @@ -753,7 +753,7 @@ exports[`Uncommon Process Table Component rendering it renders the default Uncom hello-world-2 diff --git a/x-pack/solutions/security/plugins/security_solution/public/explore/hosts/containers/hosts/hosts_table_query_types.ts b/x-pack/solutions/security/plugins/security_solution/public/explore/hosts/containers/hosts/hosts_table_query_types.ts new file mode 100644 index 0000000000000..9416e624bbc93 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/explore/hosts/containers/hosts/hosts_table_query_types.ts @@ -0,0 +1,27 @@ +/* + * 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 { HostsEdges, PageInfoPaginated } from '../../../../../common/search_strategy'; +import type { inputsModel } from '../../../../common/store'; +import type { InspectResponse } from '../../../../types'; + +export const HOSTS_ALL_TABLE_QUERY_ID = 'hostsAllQuery'; + +export type LoadPage = (newActivePage: number) => void; + +export interface HostsArgs { + endDate: string; + hosts: HostsEdges[]; + id: string; + inspect: InspectResponse; + isInspected: boolean; + loadPage: LoadPage; + pageInfo: PageInfoPaginated; + refetch: inputsModel.Refetch; + startDate: string; + totalCount: number; +} diff --git a/x-pack/solutions/security/plugins/security_solution/public/explore/hosts/containers/hosts/index.tsx b/x-pack/solutions/security/plugins/security_solution/public/explore/hosts/containers/hosts/index.tsx index 01b5cfb6422c3..2f99ef51fd5af 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/explore/hosts/containers/hosts/index.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/explore/hosts/containers/hosts/index.tsx @@ -9,35 +9,23 @@ import deepEqual from 'fast-deep-equal'; import { useCallback, useEffect, useMemo, useState } from 'react'; import type { HostsRequestOptionsInput } from '../../../../../common/api/search_strategy'; -import type { inputsModel, State } from '../../../../common/store'; +import type { State } from '../../../../common/store'; import { createFilter } from '../../../../common/containers/helpers'; import { useDeepEqualSelector } from '../../../../common/hooks/use_selector'; import type { hostsModel } from '../../store'; import { hostsSelectors } from '../../store'; import { generateTablePaginationOptions } from '../../../components/paginated_table/helpers'; -import type { HostsEdges, PageInfoPaginated } from '../../../../../common/search_strategy'; import { HostsQueries } from '../../../../../common/search_strategy'; import type { ESTermQuery } from '../../../../../common/typed_json'; import * as i18n from './translations'; -import type { InspectResponse } from '../../../../types'; import { useSearchStrategy } from '../../../../common/containers/use_search_strategy'; +import type { HostsArgs } from './hosts_table_query_types'; +import { HOSTS_ALL_TABLE_QUERY_ID } from './hosts_table_query_types'; -export const ID = 'hostsAllQuery'; +export const ID = HOSTS_ALL_TABLE_QUERY_ID; -type LoadPage = (newActivePage: number) => void; -export interface HostsArgs { - endDate: string; - hosts: HostsEdges[]; - id: string; - inspect: InspectResponse; - isInspected: boolean; - loadPage: LoadPage; - pageInfo: PageInfoPaginated; - refetch: inputsModel.Refetch; - startDate: string; - totalCount: number; -} +export type { HostsArgs } from './hosts_table_query_types'; interface UseAllHost { endDate: string; @@ -104,7 +92,7 @@ export const useAllHost = ({ () => ({ endDate, hosts: response.edges, - id: ID, + id: HOSTS_ALL_TABLE_QUERY_ID, inspect, isInspected: false, loadPage: wrappedLoadMore, @@ -158,3 +146,5 @@ export const useAllHost = ({ return [loading, hostsResponse]; }; + +export { useAllEntityStoreHosts } from './use_all_entity_store_hosts'; diff --git a/x-pack/solutions/security/plugins/security_solution/public/explore/hosts/containers/hosts/use_all_entity_store_hosts.ts b/x-pack/solutions/security/plugins/security_solution/public/explore/hosts/containers/hosts/use_all_entity_store_hosts.ts new file mode 100644 index 0000000000000..7a4dacfdf17a3 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/explore/hosts/containers/hosts/use_all_entity_store_hosts.ts @@ -0,0 +1,244 @@ +/* + * 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 { useMemo, useCallback } from 'react'; +import { noop } from 'lodash/fp'; +import { useQuery } from '@kbn/react-query'; +import type { IHttpFetchError } from '@kbn/core/public'; + +import type { InspectResponse } from '../../../../types'; +import { HostsFields } from '../../../../../common/api/search_strategy/hosts/model/sort'; +import type { HostEntity } from '../../../../../common/api/entity_analytics/entity_store/entities/common.gen'; +import type { ListEntitiesResponse } from '../../../../../common/api/entity_analytics/entity_store/entities/list_entities.gen'; +import type { HostItem } from '../../../../../common/search_strategy/security_solution/hosts/common'; +import type { HostsEdges } from '../../../../../common/search_strategy/security_solution/hosts/all'; +import type { RiskSeverity } from '../../../../../common/search_strategy/security_solution/risk_score/all'; +import type { ESTermQuery } from '../../../../../common/typed_json'; +import { createFilter } from '../../../../common/containers/helpers'; +import { useErrorToast } from '../../../../common/hooks/use_error_toast'; +import { useDeepEqualSelector } from '../../../../common/hooks/use_selector'; +import type { inputsModel, State } from '../../../../common/store'; +import { useEntityAnalyticsRoutes } from '../../../../entity_analytics/api/api'; +import { getLimitedPaginationTotalCount } from '../../../components/paginated_table/helpers'; +import type { hostsModel } from '../../store'; +import { hostsSelectors } from '../../store'; +import type { HostsArgs } from './hosts_table_query_types'; +import { HOSTS_ALL_TABLE_QUERY_ID } from './hosts_table_query_types'; +import * as i18n from './translations'; + +const ENTITY_STORE_HOSTS_LIST_QUERY_KEY = 'ENTITY_STORE_HOSTS_LIST'; + +const isHostEntityRecord = ( + record: ListEntitiesResponse['records'][number] +): record is HostEntity => 'host' in record && record.host != null; + +const toOsFieldArray = (value: string | string[] | undefined): string[] | undefined => { + if (value == null) { + return undefined; + } + return Array.isArray(value) ? value : [value]; +}; + +const mapHostEntityRecordToHostsEdge = (record: HostEntity): HostsEdges | null => { + const hostName = record.host?.name; + if (hostName == null || hostName === '') { + return null; + } + + const lastSeenIso = record.entity.lifecycle?.last_seen; + const riskLevel = record.host?.risk?.calculated_level as RiskSeverity | undefined; + + const node: HostItem = { + host: { + name: [hostName], + os: { + name: toOsFieldArray(record.host?.os?.name), + version: toOsFieldArray(record.host?.os?.version), + }, + }, + lastSeen: lastSeenIso != null ? [lastSeenIso] : undefined, + risk: riskLevel, + criticality: record.asset?.criticality, + entityId: record.entity.id, + }; + + return { + node, + cursor: { value: record.entity.id ?? hostName, tiebreaker: null }, + }; +}; + +const parseFilterClauses = (filterQuery?: ESTermQuery | string): object[] => { + const filtered = createFilter(filterQuery); + if (filtered == null || filtered === '') { + return []; + } + try { + return [JSON.parse(filtered) as object]; + } catch { + return []; + } +}; + +const buildHostsListFilterQuery = ({ + filterQuery, + startDate, + endDate, +}: { + filterQuery?: ESTermQuery | string; + startDate: string; + endDate: string; +}): string => { + const timeRangeClause = { + range: { + 'entity.lifecycle.last_seen': { + gte: startDate, + lte: endDate, + format: 'strict_date_optional_time', + }, + }, + }; + + return JSON.stringify({ + bool: { + filter: [...parseFilterClauses(filterQuery), timeRangeClause], + }, + }); +}; + +const hostsSortFieldToEntityStoreField = (field: HostsFields): string => { + switch (field) { + case HostsFields.hostName: + return 'host.name'; + case HostsFields.lastSeen: + return 'entity.lifecycle.last_seen'; + case HostsFields.success: + default: + return 'entity.lifecycle.last_seen'; + } +}; + +interface UseAllEntityStoreHostsParams { + endDate: string; + filterQuery?: ESTermQuery | string; + indexNames: string[]; + skip?: boolean; + startDate: string; + type: hostsModel.HostsType; +} + +export const useAllEntityStoreHosts = ( + params: UseAllEntityStoreHostsParams +): [boolean, HostsArgs] => { + const { endDate, filterQuery, skip = false, startDate, type } = params; + const { fetchEntitiesListV2 } = useEntityAnalyticsRoutes(); + const getHostsSelector = useMemo(() => hostsSelectors.hostsSelector(), []); + const { activePage, direction, limit, sortField } = useDeepEqualSelector((state: State) => + getHostsSelector(state, type) + ); + + const listFilterQuery = useMemo( + () => buildHostsListFilterQuery({ filterQuery, startDate, endDate }), + [endDate, filterQuery, startDate] + ); + + const sortFieldForApi = hostsSortFieldToEntityStoreField(sortField); + + const { data, isLoading, isFetching, error, refetch } = useQuery< + ListEntitiesResponse | null, + IHttpFetchError + >({ + queryKey: [ + ENTITY_STORE_HOSTS_LIST_QUERY_KEY, + listFilterQuery, + activePage, + limit, + sortFieldForApi, + direction, + skip, + ], + queryFn: async ({ signal }) => + fetchEntitiesListV2({ + signal, + params: { + entityTypes: ['host'], + filterQuery: listFilterQuery, + page: activePage + 1, + perPage: limit, + sortField: sortFieldForApi, + sortOrder: direction, + }, + }), + enabled: !skip, + cacheTime: 0, + refetchOnWindowFocus: false, + keepPreviousData: true, + }); + + useErrorToast(i18n.FAIL_ALL_HOST, skip ? undefined : error); + + const totalCount = data?.total ?? 0; + const fakeTotalCount = getLimitedPaginationTotalCount({ activePage, limit, totalCount }); + const showMorePagesIndicator = totalCount > fakeTotalCount; + + const hosts: HostsEdges[] = useMemo(() => { + if (data?.records == null) { + return []; + } + return data.records.flatMap((record) => { + if (!isHostEntityRecord(record)) { + return []; + } + const edge = mapHostEntityRecordToHostsEdge(record); + return edge != null ? [edge] : []; + }); + }, [data?.records]); + + const inspect: InspectResponse = useMemo( + () => ({ + dsl: data?.inspect?.dsl ?? [], + response: data?.inspect?.response ?? [], + }), + [data?.inspect?.dsl, data?.inspect?.response] + ); + + const refetchHosts: inputsModel.Refetch = useCallback(() => { + void refetch(); + }, [refetch]); + + const hostsResponse: HostsArgs = useMemo( + () => ({ + endDate, + hosts, + id: HOSTS_ALL_TABLE_QUERY_ID, + inspect, + isInspected: false, + loadPage: noop, + pageInfo: { + activePage, + fakeTotalCount, + showMorePagesIndicator, + }, + refetch: refetchHosts, + startDate, + totalCount, + }), + [ + activePage, + endDate, + fakeTotalCount, + hosts, + inspect, + refetchHosts, + showMorePagesIndicator, + startDate, + totalCount, + ] + ); + + return [isLoading || isFetching, hostsResponse]; +}; diff --git a/x-pack/solutions/security/plugins/security_solution/public/explore/hosts/pages/details/details_tabs.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/explore/hosts/pages/details/details_tabs.test.tsx index 9414b0ca9ebe5..9e861e3b0b137 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/explore/hosts/pages/details/details_tabs.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/explore/hosts/pages/details/details_tabs.test.tsx @@ -179,7 +179,6 @@ describe('body', () => { skip: false, startDate: '2020-07-07T08:20:18.966Z', type: 'details', - hostName: 'host-1', ...(path === 'events' && { additionalFilters: mockHostDetailsPageFilters }), }); }) 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 1edb02de0b0af..e9fda8d3d6ed1 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 @@ -25,7 +25,16 @@ import { } from '../navigation'; export const HostDetailsTabs = React.memo( - ({ detailName, filterQuery, indexNames, hostDetailsPagePath, hostDetailsFilter }) => { + ({ + detailName, + filterQuery, + indexNames, + hostDetailsPagePath, + hostDetailsFilter, + hostDetailsIdentityFilterQuery, + identityFields, + entityId, + }) => { const { from, to, isInitializing, deleteQuery, setQuery } = useGlobalTime(); const tabProps = { @@ -37,36 +46,44 @@ export const HostDetailsTabs = React.memo( startDate: from, type: HostsType.details, indexNames, - hostName: detailName, + identityFields, + entityId, }; + const tabPath = (tab: HostsTableType) => `${hostDetailsPagePath}/:tabName(${tab})`; + return ( - - + + - + - + - + - + - + diff --git a/x-pack/solutions/security/plugins/security_solution/public/explore/hosts/pages/details/helpers.test.ts b/x-pack/solutions/security/plugins/security_solution/public/explore/hosts/pages/details/helpers.test.ts index 2707d32788b18..3e49b23a0a5e4 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/explore/hosts/pages/details/helpers.test.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/explore/hosts/pages/details/helpers.test.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { getHostDetailsPageFilters } from './helpers'; +import { getHostDetailsPageFilters, getIdentityFieldsPageFilters } from './helpers'; import type { Filter } from '@kbn/es-query'; describe('hosts page helpers', () => { @@ -37,4 +37,52 @@ describe('hosts page helpers', () => { expect(getHostDetailsPageFilters('host-1')).toEqual(expected); }); }); + + describe('getIdentityFieldsPageFilters', () => { + it('builds one phrase filter per non-empty identity field', () => { + const expected: Filter[] = [ + { + meta: { + alias: null, + negate: false, + disabled: false, + type: 'phrase', + key: 'host.id', + value: 'hid-1', + params: { query: 'hid-1' }, + }, + query: { + match: { + 'host.id': { query: 'hid-1', type: 'phrase' }, + }, + }, + }, + { + meta: { + alias: null, + negate: false, + disabled: false, + type: 'phrase', + key: 'host.name', + value: 'host-1', + params: { query: 'host-1' }, + }, + query: { + match: { + 'host.name': { query: 'host-1', type: 'phrase' }, + }, + }, + }, + ]; + expect(getIdentityFieldsPageFilters({ 'host.id': 'hid-1', 'host.name': 'host-1' })).toEqual( + expected + ); + }); + + it('omits empty or whitespace-only values', () => { + expect( + getIdentityFieldsPageFilters({ 'host.name': 'ok', 'host.id': ' ', 'entity.id': '' }) + ).toEqual(getHostDetailsPageFilters('ok')); + }); + }); }); diff --git a/x-pack/solutions/security/plugins/security_solution/public/explore/hosts/pages/details/helpers.ts b/x-pack/solutions/security/plugins/security_solution/public/explore/hosts/pages/details/helpers.ts index e219fdda541da..9af3f396d0ef2 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/explore/hosts/pages/details/helpers.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/explore/hosts/pages/details/helpers.ts @@ -7,6 +7,8 @@ import type { Filter } from '@kbn/es-query'; +export { hostNameExistsFilter } from '../../../../common/components/visualization_actions/utils'; + export const getHostDetailsPageFilters = (hostName: string): Filter[] => [ { meta: { @@ -30,3 +32,32 @@ export const getHostDetailsPageFilters = (hostName: string): Filter[] => [ }, }, ]; + +/** + * Kibana {@link Filter} clauses for Events (and similar) views: one phrase match per + * non-empty identity field (AND semantics when combined in the query bar). + */ +export const getIdentityFieldsPageFilters = (identityFields: Record): Filter[] => + Object.entries(identityFields) + .filter(([, fieldValue]) => typeof fieldValue === 'string' && fieldValue.trim() !== '') + .map(([fieldKey, fieldValue]) => ({ + meta: { + alias: null, + negate: false, + disabled: false, + type: 'phrase', + key: fieldKey, + value: fieldValue, + params: { + query: fieldValue, + }, + }, + query: { + match: { + [fieldKey]: { + query: fieldValue, + type: 'phrase', + }, + }, + }, + })); 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 7cbc571f41137..710e03163f6bf 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 @@ -6,15 +6,24 @@ */ import { + EuiCallOut, EuiFlexGroup, EuiFlexItem, EuiHorizontalRule, EuiSpacer, + EuiText, EuiWindowEvent, } from '@elastic/eui'; +import { + bulkUpdateEntities, + FF_ENABLE_ENTITY_STORE_V2, + useEntityStoreEuidApi, +} from '@kbn/entity-store/public'; +import { useQueryClient } from '@kbn/react-query'; import { noop } from 'lodash/fp'; import React, { useCallback, useEffect, useMemo } from 'react'; -import { useDispatch } from 'react-redux'; +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'; @@ -23,14 +32,8 @@ import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_ex import type { NarrowDateRange } from '../../../../common/components/ml/types'; import { dataViewSpecToViewBase } from '../../../../common/lib/kuery'; import { useCalculateEntityRiskScore } from '../../../../entity_analytics/api/hooks/use_calculate_entity_risk_score'; -import { - useAssetCriticalityData, - useAssetCriticalityPrivileges, -} from '../../../../entity_analytics/components/asset_criticality/use_asset_criticality'; -import { - AssetCriticalitySelector, - AssetCriticalityTitle, -} from '../../../../entity_analytics/components/asset_criticality/asset_criticality_selector'; +import { useAssetCriticalityPrivileges } from '../../../../entity_analytics/components/asset_criticality/use_asset_criticality'; +import { AssetCriticalityAccordion } from '../../../../entity_analytics/components/asset_criticality/asset_criticality_selector'; import { AlertsByStatus } from '../../../../overview/components/detection_response/alerts_by_status'; import { useSignalIndex } from '../../../../detections/containers/detection_engine/alerts/use_signal_index'; import { useAlertsPrivileges } from '../../../../detections/containers/detection_engine/alerts/use_alerts_privileges'; @@ -40,8 +43,10 @@ import { EntityType } from '../../../../../common/entity_analytics/types'; import { SecurityPageName } from '../../../../app/types'; import { FiltersGlobal } from '../../../../common/components/filters_global'; import { HeaderPage } from '../../../../common/components/header_page'; +import { Title } from '../../../../common/components/header_page/title'; import { LastEventTime } from '../../../../common/components/last_event_time'; import { AnomalyTableProvider } from '../../../../common/components/ml/anomaly/anomaly_table_provider'; +import { buildAnomaliesTableInfluencersFilterQuery } from '../../../../common/components/ml/anomaly/anomaly_table_euid'; import { hostToCriteria } from '../../../../common/components/ml/criteria/host_to_criteria'; import { hasMlUserPermissions } from '../../../../../common/machine_learning/has_ml_user_permissions'; import { useMlCapabilities } from '../../../../common/components/ml/hooks/use_ml_capabilities'; @@ -54,7 +59,7 @@ import { import { SiemSearchBar } from '../../../../common/components/search_bar'; import { SecuritySolutionPageWrapper } from '../../../../common/components/page_wrapper'; import { useGlobalTime } from '../../../../common/containers/use_global_time'; -import { useKibana } from '../../../../common/lib/kibana'; +import { useKibana, useUiSetting } from '../../../../common/lib/kibana'; import { inputsSelectors } from '../../../../common/store'; import { setHostDetailsTablesActivePageToZero } from '../../store/actions'; import { setAbsoluteRangeDatePicker } from '../../../../common/store/inputs/actions'; @@ -63,7 +68,11 @@ import { HostDetailsTabs } from './details_tabs'; import { navTabsHostDetails } from './nav_tabs'; import type { HostDetailsProps } from './types'; import { HostsType } from '../../store/model'; -import { getHostDetailsPageFilters } from './helpers'; +import { getHostDetailsPageFilters, getIdentityFieldsPageFilters } from './helpers'; +import { + identityFieldsHaveUsableValues, + mergeLegacyIdentityWhenStoreEntityMissing, +} from '../../../../flyout/document_details/shared/utils'; import { useGlobalFullScreen } from '../../../../common/containers/use_full_screen'; import { Display } from '../display'; import { useDeepEqualSelector } from '../../../../common/hooks/use_selector'; @@ -79,11 +88,86 @@ import { useRefetchOverviewPageRiskScore } from '../../../../entity_analytics/ap import { useDataView } from '../../../../data_view_manager/hooks/use_data_view'; import { useSelectedPatterns } from '../../../../data_view_manager/hooks/use_selected_patterns'; import { PageLoader } from '../../../../common/components/page_loader'; +import { + applyEntityStoreSearchCachePatch, + useEntityFromStore, + type EntityStoreRecord, +} from '../../../../flyout/entity_details/shared/hooks/use_entity_from_store'; +import { ObservedDataSection as HostObservedDataSection } from '../../../../flyout/entity_details/host_right/components/observed_data_section'; +import { HOST_PANEL_OBSERVED_HOST_QUERY_ID } from '../../../../flyout/entity_details/host_right'; +import { useObservedHost } from '../../../../flyout/entity_details/host_right/hooks/use_observed_host'; +import { buildRiskScoreStateFromEntityRecord } from '../../../../flyout/entity_details/shared/entity_store_risk_utils'; +import { NO_CORRESPONDING_ENTITY_EXISTS } from '../../../../flyout/entity_details/shared/translations'; +import { useSecurityDefaultPatterns } from '../../../../data_view_manager/hooks/use_security_default_patterns'; +import { sourcererSelectors } from '../../../../sourcerer/store'; +import type { Entity } from '../../../../../common/api/entity_analytics'; const ES_HOST_FIELD = 'host.name'; const HostOverviewManage = manageQuery(HostOverview); -const HostDetailsComponent: React.FC = ({ detailName, hostDetailsPagePath }) => { +const HostDetailsHeaderTitle: React.FC<{ + detailName: string; + displayEntityId?: string; +}> = ({ detailName, displayEntityId }) => ( + <> + + {displayEntityId ? ( + <> + <EuiSpacer size="xs" /> + <EuiText size="xs" color="subdued" data-test-subj="host-details-page-entity-id"> + {displayEntityId} + </EuiText> + </> + ) : null} + </> +); +HostDetailsHeaderTitle.displayName = 'HostDetailsHeaderTitle'; + +const HostDetailsAssetCriticalitySection: React.FC<{ + canRead: boolean; + detailName: string; + entityStoreV2Enabled: boolean; + noEntityInStore: boolean; + observedHostEntityRecord: EntityStoreRecord | null | undefined; + storeRecord: EntityStoreRecord | null | undefined; + onSaveViaEntityStore: (updatedRecord: Entity) => Promise<void>; + onCriticalityChange: () => void; +}> = ({ + canRead, + detailName, + entityStoreV2Enabled, + noEntityInStore, + observedHostEntityRecord, + storeRecord, + onSaveViaEntityStore, + onCriticalityChange, +}) => { + if (!canRead || (entityStoreV2Enabled && noEntityInStore)) { + return null; + } + return ( + <AssetCriticalityAccordion + entity={{ name: detailName, type: EntityType.host }} + onChange={onCriticalityChange} + entityRecord={entityStoreV2Enabled ? observedHostEntityRecord ?? undefined : undefined} + criticalityFromEntityStore={ + entityStoreV2Enabled && observedHostEntityRecord + ? storeRecord?.asset?.criticality + : undefined + } + onSaveViaEntityStore={entityStoreV2Enabled && storeRecord ? onSaveViaEntityStore : undefined} + /> + ); +}; +HostDetailsAssetCriticalitySection.displayName = 'HostDetailsAssetCriticalitySection'; + +const HostDetailsComponent: React.FC<HostDetailsProps> = ({ + detailName, + hostDetailsPagePath, + entityId, + identityFields, +}) => { + const { search: urlStateQuery } = useLocation(); const dispatch = useDispatch(); const getGlobalFiltersQuerySelector = useMemo( () => inputsSelectors.globalFiltersQuerySelector(), @@ -99,8 +183,14 @@ const HostDetailsComponent: React.FC<HostDetailsProps> = ({ detailName, hostDeta const capabilities = useMlCapabilities(); const { - services: { uiSettings }, + services: { http, uiSettings }, } = useKibana(); + const queryClient = useQueryClient(); + + const resolvedIdentityFields = useMemo( + () => identityFields ?? { [ES_HOST_FIELD]: detailName }, + [identityFields, detailName] + ); const hostDetailsPageFilters: Filter[] = useMemo( () => getHostDetailsPageFilters(detailName), @@ -141,14 +231,96 @@ const HostDetailsComponent: React.FC<HostDetailsProps> = ({ detailName, hostDeta ? experimentalSelectedPatterns : oldSelectedPatterns; + const entityStoreV2Enabled = useUiSetting<boolean>(FF_ENABLE_ENTITY_STORE_V2, false); + + const hostStoreIdentityFields = useMemo(() => { + if (entityId) { + return undefined; + } + return Object.keys(resolvedIdentityFields).length > 0 ? resolvedIdentityFields : undefined; + }, [entityId, resolvedIdentityFields]); + + const entityFromStoreResult = useEntityFromStore({ + entityId, + identityFields: hostStoreIdentityFields, + entityType: 'host', + skip: !entityStoreV2Enabled || isInitializing, + }); + + const euidApi = useEntityStoreEuidApi(); + + const noEntityInStore = + entityStoreV2Enabled && !entityFromStoreResult.isLoading && !entityFromStoreResult.entityRecord; + + const hostDetailsEventsPageFilters = useMemo(() => { + if (!entityStoreV2Enabled || noEntityInStore) { + return getHostDetailsPageFilters(detailName); + } + const fromStore = + euidApi?.euid?.getEntityIdentifiersFromDocument('host', entityFromStoreResult.entityRecord) ?? + {}; + const merged = mergeLegacyIdentityWhenStoreEntityMissing(fromStore, resolvedIdentityFields); + if (identityFieldsHaveUsableValues(merged)) { + return getIdentityFieldsPageFilters(merged); + } + return getHostDetailsPageFilters(detailName); + }, [ + detailName, + entityFromStoreResult.entityRecord, + entityStoreV2Enabled, + noEntityInStore, + euidApi?.euid, + resolvedIdentityFields, + ]); + + const oldSecurityDefaultPatterns = + useSelector(sourcererSelectors.defaultDataView)?.patternList ?? []; + const { indexPatterns: experimentalSecurityDefaultIndexPatterns } = useSecurityDefaultPatterns(); + const securityDefaultPatterns = newDataViewPickerEnabled + ? experimentalSecurityDefaultIndexPatterns + : oldSecurityDefaultPatterns; + + const observedHost = useObservedHost( + detailName, + PageScope.explore, + entityStoreV2Enabled ? entityFromStoreResult : undefined + ); + const [loading, { inspect, hostDetails: hostOverview, id, refetch }] = useHostDetails({ endDate: to, startDate: from, hostName: detailName, indexNames: selectedPatterns, - skip: selectedPatterns.length === 0, + skip: selectedPatterns.length === 0 || entityStoreV2Enabled, }); + const hostDetailsForOverview = entityStoreV2Enabled ? observedHost.details : hostOverview; + const isHostOverviewLoading = entityStoreV2Enabled ? observedHost.isLoading : loading; + + const hostRiskScoreStateFromEntityStore = useMemo( + () => + entityStoreV2Enabled && observedHost.entityRecord + ? buildRiskScoreStateFromEntityRecord(EntityType.host, observedHost.entityRecord, { + refetch: observedHost.refetchEntityStore ?? (() => {}), + isLoading: observedHost.isLoading, + error: null, + inspect: entityFromStoreResult?.inspect, + }) + : undefined, + [ + entityFromStoreResult?.inspect, + entityStoreV2Enabled, + observedHost.entityRecord, + observedHost.isLoading, + observedHost.refetchEntityStore, + ] + ); + + const displayEntityId = useMemo( + () => (entityStoreV2Enabled ? observedHost.entityRecord?.entity?.id : entityId), + [entityId, entityStoreV2Enabled, observedHost.entityRecord?.entity?.id] + ); + const [rawFilteredQuery, kqlError] = useMemo(() => { try { return [ @@ -174,6 +346,39 @@ const HostDetailsComponent: React.FC<HostDetailsProps> = ({ detailName, hostDeta uiSettings, ]); + const [rawFilteredQueryForHostDetailsIdentity] = useMemo(() => { + try { + return [ + buildEsQuery( + newDataViewPickerEnabled + ? experimentalDataView + : dataViewSpecToViewBase(oldSourcererDataView), + [query], + [...hostDetailsEventsPageFilters, ...globalFilters], + getEsQueryConfig(uiSettings) + ), + ]; + } catch { + return [undefined]; + } + }, [ + newDataViewPickerEnabled, + experimentalDataView, + oldSourcererDataView, + query, + hostDetailsEventsPageFilters, + globalFilters, + uiSettings, + ]); + + const stringifiedHostDetailsIdentityFilterQuery = useMemo( + () => + rawFilteredQueryForHostDetailsIdentity != null + ? JSON.stringify(rawFilteredQueryForHostDetailsIdentity) + : undefined, + [rawFilteredQueryForHostDetailsIdentity] + ); + const stringifiedAdditionalFilters = JSON.stringify(rawFilteredQuery); useInvalidFilterQuery({ id: ID, @@ -208,9 +413,9 @@ const HostDetailsComponent: React.FC<HostDetailsProps> = ({ detailName, hostDeta () => ({ type: EntityType.host as const, name: detailName, - identifiers: { 'host.name': detailName }, + identifiers: resolvedIdentityFields, }), - [detailName] + [detailName, resolvedIdentityFields] ); const privileges = useAssetCriticalityPrivileges(entity.name); @@ -219,12 +424,20 @@ const HostDetailsComponent: React.FC<HostDetailsProps> = ({ detailName, hostDeta onSuccess: refetchRiskScore, }); + const handleSaveAssetCriticalityViaEntityStore = useCallback( + async (updatedRecord: Entity) => { + await bulkUpdateEntities(http, { + entityType: 'host', + body: updatedRecord as Record<string, unknown>, + force: true, + }); + applyEntityStoreSearchCachePatch(queryClient, 'host', updatedRecord as EntityStoreRecord); + calculateEntityRiskScore(); + }, + [http, queryClient, calculateEntityRiskScore] + ); + const canReadAssetCriticality = !!privileges.data?.has_read_permissions; - const criticality = useAssetCriticalityData({ - entity, - enabled: canReadAssetCriticality, - onChange: calculateEntityRiskScore, - }); if (newDataViewPickerEnabled && status === 'pristine') { return <PageLoader />; @@ -258,52 +471,117 @@ const HostDetailsComponent: React.FC<HostDetailsProps> = ({ detailName, hostDeta /> } title={detailName} + titleNode={ + <HostDetailsHeaderTitle + detailName={detailName} + displayEntityId={displayEntityId} + /> + } rightSideItems={[ - hostOverview.endpoint?.hostInfo?.metadata.elastic.agent.id && ( + hostDetailsForOverview.endpoint?.hostInfo?.metadata.elastic.agent.id && ( <ResponderActionButton - agentId={hostOverview.endpoint?.hostInfo?.metadata.elastic.agent.id} + agentId={hostDetailsForOverview.endpoint?.hostInfo?.metadata.elastic.agent.id} agentType="endpoint" /> ), ]} /> - {canReadAssetCriticality && ( + {noEntityInStore && ( <> - <AssetCriticalityTitle /> - <EuiSpacer size="s" /> - <AssetCriticalitySelector compressed criticality={criticality} entity={entity} /> - <EuiHorizontalRule margin="m" /> + <EuiCallOut + title={NO_CORRESPONDING_ENTITY_EXISTS} + color="warning" + iconType="warning" + data-test-subj="host-details-no-entity-warning" + announceOnMount + /> + <EuiSpacer size="m" /> + <HostObservedDataSection + identityFields={resolvedIdentityFields} + observedHost={observedHost} + contextID={PageScope.explore} + scopeId={PageScope.explore} + queryId={HOST_PANEL_OBSERVED_HOST_QUERY_ID} + /> + <EuiHorizontalRule /> + <EuiSpacer /> </> )} - <AnomalyTableProvider - criteriaFields={hostToCriteria(hostOverview)} - startDate={from} - endDate={to} - skip={isInitializing} - > - {({ isLoadingAnomaliesData, anomaliesData, jobNameById }) => ( - <HostOverviewManage - id={id} - isInDetailsSidePanel={false} - data={hostOverview as HostItem} - anomaliesData={anomaliesData} - isLoadingAnomaliesData={isLoadingAnomaliesData} - loading={loading} + <HostDetailsAssetCriticalitySection + canRead={canReadAssetCriticality} + detailName={detailName} + entityStoreV2Enabled={entityStoreV2Enabled} + noEntityInStore={noEntityInStore} + observedHostEntityRecord={observedHost.entityRecord} + storeRecord={entityFromStoreResult.entityRecord} + onSaveViaEntityStore={handleSaveAssetCriticalityViaEntityStore} + onCriticalityChange={calculateEntityRiskScore} + /> + {!noEntityInStore && ( + <> + <AnomalyTableProvider + criteriaFields={hostToCriteria(hostDetailsForOverview, euidApi?.euid)} + filterQuery={buildAnomaliesTableInfluencersFilterQuery({ + euid: euidApi?.euid, + entityType: 'host', + isScopedToEntity: true, + identityFields: resolvedIdentityFields, + fallbackDisplayName: detailName, + })} startDate={from} endDate={to} - narrowDateRange={narrowDateRange} - setQuery={setQuery} - refetch={refetch} - inspect={inspect} - hostName={detailName} - indexNames={selectedPatterns} - jobNameById={jobNameById} - scopeId={PageScope.explore} - /> - )} - </AnomalyTableProvider> - <EuiHorizontalRule /> - <EuiSpacer /> + skip={isInitializing} + > + {({ isLoadingAnomaliesData, anomaliesData, jobNameById }) => ( + <HostOverviewManage + id={id} + isInDetailsSidePanel={false} + data={hostDetailsForOverview as HostItem} + anomaliesData={anomaliesData} + isLoadingAnomaliesData={isLoadingAnomaliesData} + loading={isHostOverviewLoading} + startDate={from} + endDate={to} + narrowDateRange={narrowDateRange} + setQuery={setQuery} + refetch={ + entityStoreV2Enabled + ? observedHost.refetchEntityStore ?? + observedHost.refetchObservedDetails ?? + refetch + : refetch + } + inspect={ + entityStoreV2Enabled + ? entityFromStoreResult?.inspect ?? + observedHost.observedDetailsInspect ?? + inspect + : inspect + } + hostName={detailName} + indexNames={ + entityStoreV2Enabled ? securityDefaultPatterns : selectedPatterns + } + jobNameById={jobNameById} + scopeId={PageScope.explore} + riskScoreState={hostRiskScoreStateFromEntityStore} + firstSeenFromEntityStore={ + entityStoreV2Enabled + ? observedHost.firstSeen?.date ?? undefined + : undefined + } + lastSeenFromEntityStore={ + entityStoreV2Enabled + ? observedHost.lastSeen?.date ?? undefined + : undefined + } + /> + )} + </AnomalyTableProvider> + <EuiHorizontalRule /> + <EuiSpacer /> + </> + )} {canReadAlerts && ( <> @@ -312,12 +590,14 @@ const HostDetailsComponent: React.FC<HostDetailsProps> = ({ detailName, hostDeta <AlertsByStatus signalIndexName={signalIndexName} entityFilter={entityFilter} + identityFields={resolvedIdentityFields} additionalFilters={additionalFilters} /> </EuiFlexItem> <EuiFlexItem> <AlertCountByRuleByStatus entityFilter={{ ...entityFilter, entityType: EntityType.host }} + identityFields={resolvedIdentityFields} signalIndexName={signalIndexName} additionalFilters={additionalFilters} /> @@ -332,6 +612,9 @@ const HostDetailsComponent: React.FC<HostDetailsProps> = ({ detailName, hostDeta hasMlUserPermissions: hasMlUserPermissions(capabilities), hostName: detailName, isEnterprise: isEnterprisePlus, + entityId, + identityFields: resolvedIdentityFields, + urlStateQuery, })} /> @@ -342,7 +625,8 @@ const HostDetailsComponent: React.FC<HostDetailsProps> = ({ detailName, hostDeta indexNames={selectedPatterns} isInitializing={isInitializing} deleteQuery={deleteQuery} - hostDetailsFilter={hostDetailsPageFilters} + hostDetailsFilter={hostDetailsEventsPageFilters} + hostDetailsIdentityFilterQuery={stringifiedHostDetailsIdentityFilterQuery} to={to} from={from} detailName={detailName} @@ -350,6 +634,8 @@ const HostDetailsComponent: React.FC<HostDetailsProps> = ({ detailName, hostDeta setQuery={setQuery} filterQuery={stringifiedAdditionalFilters} hostDetailsPagePath={hostDetailsPagePath} + identityFields={resolvedIdentityFields} + entityId={entityId} /> </SecuritySolutionPageWrapper> </> diff --git a/x-pack/solutions/security/plugins/security_solution/public/explore/hosts/pages/details/nav_tabs.tsx b/x-pack/solutions/security/plugins/security_solution/public/explore/hosts/pages/details/nav_tabs.tsx index 4f22bf46fa2b8..7f0039d1cb9cb 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/explore/hosts/pages/details/nav_tabs.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/explore/hosts/pages/details/nav_tabs.tsx @@ -10,56 +10,69 @@ import * as i18n from '../translations'; import type { HostDetailsNavTab } from './types'; import { HostsTableType } from '../../store/model'; import { HOSTS_PATH } from '../../../../../common/constants'; - -const getTabsOnHostDetailsUrl = (hostName: string, tabName: HostsTableType) => - `${HOSTS_PATH}/name/${hostName}/${tabName}`; +import { getTabsOnHostDetailsUrl } from '../../../../common/components/link_to'; export const navTabsHostDetails = ({ hasMlUserPermissions, hostName, isEnterprise, + entityId, + identityFields, + urlStateQuery = '', }: { hostName: string; hasMlUserPermissions: boolean; isEnterprise?: boolean; + entityId?: string; + identityFields?: Record<string, string>; + /** Current location.search (timerange, timeline, entityId, identityFields, …) */ + urlStateQuery?: string; }): HostDetailsNavTab => { + const getTabHref = (tabName: HostsTableType) => + `${HOSTS_PATH}${getTabsOnHostDetailsUrl( + hostName, + tabName, + urlStateQuery, + entityId, + identityFields + )}`; const hiddenTabs = []; const hostDetailsNavTabs = { [HostsTableType.events]: { id: HostsTableType.events, name: i18n.NAVIGATION_EVENTS_TITLE, - href: getTabsOnHostDetailsUrl(hostName, HostsTableType.events), + href: getTabHref(HostsTableType.events), disabled: false, }, [HostsTableType.authentications]: { id: HostsTableType.authentications, name: i18n.NAVIGATION_AUTHENTICATIONS_TITLE, - href: getTabsOnHostDetailsUrl(hostName, HostsTableType.authentications), + href: getTabHref(HostsTableType.authentications), disabled: false, }, [HostsTableType.uncommonProcesses]: { id: HostsTableType.uncommonProcesses, name: i18n.NAVIGATION_UNCOMMON_PROCESSES_TITLE, - href: getTabsOnHostDetailsUrl(hostName, HostsTableType.uncommonProcesses), + href: getTabHref(HostsTableType.uncommonProcesses), disabled: false, }, [HostsTableType.anomalies]: { id: HostsTableType.anomalies, name: i18n.NAVIGATION_ANOMALIES_TITLE, - href: getTabsOnHostDetailsUrl(hostName, HostsTableType.anomalies), + href: getTabHref(HostsTableType.anomalies), disabled: false, }, [HostsTableType.risk]: { id: HostsTableType.risk, name: i18n.NAVIGATION_HOST_RISK_TITLE, - href: getTabsOnHostDetailsUrl(hostName, HostsTableType.risk), + href: getTabHref(HostsTableType.risk), disabled: false, }, [HostsTableType.sessions]: { id: HostsTableType.sessions, name: i18n.NAVIGATION_SESSIONS_TITLE, - href: getTabsOnHostDetailsUrl(hostName, HostsTableType.sessions), + href: getTabHref(HostsTableType.sessions), disabled: false, isBeta: false, }, diff --git a/x-pack/solutions/security/plugins/security_solution/public/explore/hosts/pages/details/types.ts b/x-pack/solutions/security/plugins/security_solution/public/explore/hosts/pages/details/types.ts index fe23a0485dac8..31ddf0eba836f 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/explore/hosts/pages/details/types.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/explore/hosts/pages/details/types.ts @@ -15,11 +15,15 @@ import type { hostsModel } from '../../store'; interface HostBodyComponentDispatchProps { detailName: string; + identityFields?: Record<string, string>; + entityId?: string; hostDetailsPagePath: string; } export interface HostDetailsProps { detailName: string; + identityFields?: Record<string, string>; + entityId?: string; hostDetailsPagePath: string; } @@ -40,7 +44,15 @@ export type HostDetailsTabsProps = HostBodyComponentDispatchProps & HostsQueryProps & { indexNames: string[]; hostDetailsFilter: Filter[]; + /** + * Serialized ES query built with {@link HostDetailsTabsProps.hostDetailsFilter} (identity fields + * when Entity Store v2). Used for the Events histogram, Authentications tab, and Risk tab; + * other tabs use {@link HostDetailsTabsProps.filterQuery} only. + */ + hostDetailsIdentityFilterQuery?: string; filterQuery?: string; dataViewSpec?: DataViewSpec; type: hostsModel.HostsType; + identityFields?: Record<string, string>; + entityId?: string; }; diff --git a/x-pack/solutions/security/plugins/security_solution/public/explore/hosts/pages/hosts.tsx b/x-pack/solutions/security/plugins/security_solution/public/explore/hosts/pages/hosts.tsx index 3c3a5cd91c3cc..596909fef7ed4 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/explore/hosts/pages/hosts.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/explore/hosts/pages/hosts.tsx @@ -51,7 +51,6 @@ import { useDeepEqualSelector } from '../../../common/hooks/use_selector'; import { useInvalidFilterQuery } from '../../../common/hooks/use_invalid_filter_query'; import { ID } from '../containers/hosts'; import { EmptyPrompt } from '../../../common/components/empty_prompt'; -import { fieldNameExistsFilter } from '../../../common/components/visualization_actions/utils'; import { useLicense } from '../../../common/hooks/use_license'; import { useDataView } from '../../../data_view_manager/hooks/use_data_view'; import { useSelectedPatterns } from '../../../data_view_manager/hooks/use_selected_patterns'; @@ -90,14 +89,13 @@ const HostsComponent = () => { const { uiSettings } = useKibana().services; const { tabName } = useParams<{ tabName: string }>(); const tabsFilters: Filter[] = React.useMemo(() => { - const hostNameExistsFilter = fieldNameExistsFilter(SecurityPageName.hosts); if (tabName === HostsTableType.events) { - return [...globalFilters, ...hostNameExistsFilter]; + return [...globalFilters]; } if (tabName === HostsTableType.risk) { const severityFilter = generateSeverityFilter(severitySelection, EntityType.host); - return [...globalFilters, ...hostNameExistsFilter, ...severityFilter]; + return [...globalFilters, ...severityFilter]; } return globalFilters; @@ -200,7 +198,11 @@ const HostsComponent = () => { <Display show={!globalFullScreen}> <HeaderPage subtitle={ - <LastEventTime indexKey={LastEventIndexKey.hosts} indexNames={selectedPatterns} /> + <LastEventTime + hostName={''} + indexKey={LastEventIndexKey.hosts} + indexNames={selectedPatterns} + /> } title={i18n.PAGE_TITLE} border diff --git a/x-pack/solutions/security/plugins/security_solution/public/explore/hosts/pages/hosts_tabs.tsx b/x-pack/solutions/security/plugins/security_solution/public/explore/hosts/pages/hosts_tabs.tsx index f43d037e25cd2..6ef3af1a8d774 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/explore/hosts/pages/hosts_tabs.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/explore/hosts/pages/hosts_tabs.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useMemo } from 'react'; +import React from 'react'; import { Routes, Route } from '@kbn/shared-ux-router'; import { TableId } from '@kbn/securitysolution-data-table'; @@ -14,7 +14,7 @@ import { HostsTableType } from '../store/model'; import { AnomaliesQueryTabBody } from '../../../common/containers/anomalies/anomalies_query_tab_body'; import { AnomaliesHostTable } from '../../../common/components/ml/tables/anomalies_host_table'; import { EventsQueryTabBody } from '../../../common/components/events_tab'; -import { HOSTS_PATH, SecurityPageName } from '../../../../common/constants'; +import { HOSTS_PATH } from '../../../../common/constants'; import { HostsQueryTabBody, @@ -22,7 +22,7 @@ import { UncommonProcessQueryTabBody, SessionsTabBody, } from './navigation'; -import { fieldNameExistsFilter } from '../../../common/components/visualization_actions/utils'; +import { hostNameExistsFilter } from './details/helpers'; export const HostsTabs = React.memo<HostsTabsProps>( ({ deleteQuery, filterQuery, from, indexNames, isInitializing, setQuery, to, type }) => { @@ -37,8 +37,6 @@ export const HostsTabs = React.memo<HostsTabsProps>( type, }; - const hostNameExistsFilter = useMemo(() => fieldNameExistsFilter(SecurityPageName.hosts), []); - return ( <Routes> <Route path={`${HOSTS_PATH}/:tabName(${HostsTableType.hosts})`}> diff --git a/x-pack/solutions/security/plugins/security_solution/public/explore/hosts/pages/index.tsx b/x-pack/solutions/security/plugins/security_solution/public/explore/hosts/pages/index.tsx index 1ffafc786265a..e16661f918588 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/explore/hosts/pages/index.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/explore/hosts/pages/index.tsx @@ -10,6 +10,11 @@ import { Redirect } from 'react-router-dom'; import { Routes, Route } from '@kbn/shared-ux-router'; import { HOSTS_PATH } from '../../../../common/constants'; +import { + mergeEntityResolutionIntoUrlState, + parseEntityIdentifiersFromUrlParam, + parseEntityResolutionFromUrlState, +} from '../../../common/components/link_to'; import { HostDetails } from './details'; import { HostsTableType } from '../store/model'; @@ -17,24 +22,29 @@ import { MlHostConditionalContainer } from '../../../common/components/ml/condit import { Hosts } from './hosts'; import { hostDetailsPagePath } from './types'; -const getHostsTabPath = () => - `${HOSTS_PATH}/:tabName(` + +const HOST_DETAILS_TAB_NAMES = `${HostsTableType.events}|` + - `${HostsTableType.hosts}|` + + `${HostsTableType.authentications}|` + `${HostsTableType.uncommonProcesses}|` + `${HostsTableType.anomalies}|` + `${HostsTableType.risk}|` + - `${HostsTableType.sessions})`; + `${HostsTableType.sessions}`; -const getHostDetailsTabPath = () => - `${hostDetailsPagePath}/:tabName(` + +const getHostsTabPath = () => + `${HOSTS_PATH}/:tabName(` + `${HostsTableType.events}|` + - `${HostsTableType.authentications}|` + + `${HostsTableType.hosts}|` + `${HostsTableType.uncommonProcesses}|` + `${HostsTableType.anomalies}|` + `${HostsTableType.risk}|` + `${HostsTableType.sessions})`; +const getHostDetailsTabPath = () => `${hostDetailsPagePath}/:tabName(${HOST_DETAILS_TAB_NAMES})`; + +/** Legacy bookmarked URLs with a base64 entity segment after the tab name. */ +const getHostDetailsLegacyEntityTabPath = () => + `${hostDetailsPagePath}/:tabName(${HOST_DETAILS_TAB_NAMES})/:legacyEntityIdentifiers`; + export const HostsContainer = React.memo(() => ( <Routes> <Route path={`${HOSTS_PATH}/ml-hosts`}> @@ -54,20 +64,53 @@ export const HostsContainer = React.memo(() => ( <Route path={getHostsTabPath()}> <Hosts /> </Route> + <Route + path={getHostDetailsLegacyEntityTabPath()} + render={({ + match: { + params: { detailName, tabName, legacyEntityIdentifiers }, + }, + location, + }) => { + const { entityId, identityFields } = + parseEntityIdentifiersFromUrlParam(legacyEntityIdentifiers); + const urlStateQuery = mergeEntityResolutionIntoUrlState(location.search, { + entityId, + identityFields, + displayName: decodeURIComponent(detailName), + entityType: 'host', + }); + return ( + <Redirect + to={{ + pathname: `${HOSTS_PATH}/name/${detailName}/${tabName}`, + search: urlStateQuery.replace(/^\?/, ''), + }} + /> + ); + }} + /> <Route path={getHostDetailsTabPath()} render={({ match: { params: { detailName }, }, - }) => ( - <HostDetails - hostDetailsPagePath={hostDetailsPagePath} - detailName={decodeURIComponent(detailName)} - /> - )} + location, + }) => { + const { entityId, identityFields } = parseEntityResolutionFromUrlState(location.search); + return ( + <HostDetails + hostDetailsPagePath={hostDetailsPagePath} + detailName={decodeURIComponent(detailName)} + entityId={entityId} + identityFields={identityFields} + /> + ); + }} /> <Route // Redirect to the first tab when tabName is not present. + exact path={hostDetailsPagePath} render={({ match: { diff --git a/x-pack/solutions/security/plugins/security_solution/public/explore/hosts/pages/navigation/authentications_query_tab_body.tsx b/x-pack/solutions/security/plugins/security_solution/public/explore/hosts/pages/navigation/authentications_query_tab_body.tsx index 14dd1e6a6252d..5850dbcbb7e12 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/explore/hosts/pages/navigation/authentications_query_tab_body.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/explore/hosts/pages/navigation/authentications_query_tab_body.tsx @@ -6,10 +6,11 @@ */ import React from 'react'; -import type { HostsComponentsQueryProps } from './types'; + import { MatrixHistogram } from '../../../../common/components/matrix_histogram'; import { AuthenticationsHostTable } from '../../../components/authentication/authentications_host_table'; import { histogramConfigs } from '../../../components/authentication/helpers'; +import type { HostsComponentsQueryProps } from './types'; const HISTOGRAM_QUERY_ID = 'authenticationsHistogramQuery'; @@ -17,17 +18,20 @@ const AuthenticationsQueryTabBodyComponent: React.FC<HostsComponentsQueryProps> deleteQuery, endDate, filterQuery, + identityScopedFilterQuery, indexNames, skip, setQuery, startDate, type, }) => { + const effectiveFilterQuery = identityScopedFilterQuery ?? filterQuery; + return ( <> <MatrixHistogram endDate={endDate} - filterQuery={filterQuery} + filterQuery={effectiveFilterQuery} id={HISTOGRAM_QUERY_ID} startDate={startDate} {...histogramConfigs} @@ -35,7 +39,7 @@ const AuthenticationsQueryTabBodyComponent: React.FC<HostsComponentsQueryProps> <AuthenticationsHostTable endDate={endDate} - filterQuery={filterQuery} + filterQuery={effectiveFilterQuery} indexNames={indexNames} setQuery={setQuery} deleteQuery={deleteQuery} diff --git a/x-pack/solutions/security/plugins/security_solution/public/explore/hosts/pages/navigation/host_risk_score_tab_body.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/explore/hosts/pages/navigation/host_risk_score_tab_body.test.tsx index 30083b2452d94..8e45dc68799dd 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/explore/hosts/pages/navigation/host_risk_score_tab_body.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/explore/hosts/pages/navigation/host_risk_score_tab_body.test.tsx @@ -11,18 +11,60 @@ import { TestProviders } from '../../../../common/mock'; import { useQueryToggle } from '../../../../common/containers/query_toggle'; import { HostRiskScoreQueryTabBody } from './host_risk_score_tab_body'; import { HostsType } from '../../store/model'; -import { useRiskScore } from '../../../../entity_analytics/api/hooks/use_risk_score'; import { useRiskScoreKpi } from '../../../../entity_analytics/api/hooks/use_risk_score_kpi'; +import { useRiskScore } from '../../../../entity_analytics/api/hooks/use_risk_score'; +import { useEntityStoreRiskScoreKpi } from '../../../../entity_analytics/api/hooks/use_entity_store_risk_score_kpi'; +import { useEntityStoreRiskScore } from '../../../../entity_analytics/api/hooks/use_entity_store_risk_score'; +import { useUiSetting } from '../../../../common/lib/kibana'; +import { RiskSeverity } from '../../../../../common/search_strategy'; jest.mock('../../../../entity_analytics/api/hooks/use_risk_score_kpi'); jest.mock('../../../../entity_analytics/api/hooks/use_risk_score'); +jest.mock('../../../../entity_analytics/api/hooks/use_entity_store_risk_score_kpi'); +jest.mock('../../../../entity_analytics/api/hooks/use_entity_store_risk_score'); jest.mock('../../../../common/containers/query_toggle'); -jest.mock('../../../../common/lib/kibana'); +jest.mock('../../../../common/lib/kibana', () => { + const actual = jest.requireActual('../../../../common/lib/kibana'); + return { + ...actual, + useUiSetting: jest.fn(() => false), + }; +}); + +const sharedRiskScoreReturn = { + data: [], + error: undefined, + hasEngineBeenInstalled: true, + inspect: { dsl: [], response: [] }, + isAuthorized: true, + isInspected: false, + loading: false, + refetch: jest.fn(), + totalCount: 0, +}; + +const sharedKpiReturn = { + error: undefined, + inspect: { dsl: [], response: [] }, + isModuleDisabled: false, + loading: false, + refetch: jest.fn(), + severityCount: { + [RiskSeverity.Unknown]: 12, + [RiskSeverity.Low]: 12, + [RiskSeverity.Moderate]: 12, + [RiskSeverity.High]: 12, + [RiskSeverity.Critical]: 12, + }, +}; describe('Host risk score query tab body', () => { const mockUseRiskScore = useRiskScore as jest.Mock; + const mockUseEntityStoreRiskScore = useEntityStoreRiskScore as jest.Mock; const mockUseRiskScoreKpi = useRiskScoreKpi as jest.Mock; + const mockUseEntityStoreRiskScoreKpi = useEntityStoreRiskScoreKpi as jest.Mock; const mockUseQueryToggle = useQueryToggle as jest.Mock; + const mockUseUiSetting = useUiSetting as jest.Mock; const defaultProps = { indexNames: [], setQuery: jest.fn(), @@ -33,44 +75,25 @@ describe('Host risk score query tab body', () => { }; beforeEach(() => { jest.clearAllMocks(); + mockUseUiSetting.mockReturnValue(false); mockUseQueryToggle.mockReturnValue({ toggleStatus: true, setToggleStatus: jest.fn() }); - mockUseRiskScoreKpi.mockReturnValue({ - loading: false, - severityCount: { - unknown: 12, - low: 12, - moderate: 12, - high: 12, - critical: 12, - }, - }); - mockUseRiskScore.mockReturnValue([ - false, - { - hosts: [], - id: '123', - inspect: { - dsl: [], - response: [], - }, - isInspected: false, - totalCount: 0, - pageInfo: { activePage: 1, fakeTotalCount: 100, showMorePagesIndicator: false }, - loadPage: jest.fn(), - refetch: jest.fn(), - }, - ]); + mockUseRiskScore.mockReturnValue(sharedRiskScoreReturn); + mockUseEntityStoreRiskScore.mockReturnValue(sharedRiskScoreReturn); + mockUseRiskScoreKpi.mockReturnValue(sharedKpiReturn); + mockUseEntityStoreRiskScoreKpi.mockReturnValue(sharedKpiReturn); }); - it('toggleStatus=true, do not skip', () => { + it('toggleStatus=true, entity store v2 off: legacy hooks are not skipped; entity store hooks are skipped', () => { render( <TestProviders> <HostRiskScoreQueryTabBody {...defaultProps} /> </TestProviders> ); expect(mockUseRiskScore.mock.calls[0][0].skip).toEqual(false); + expect(mockUseEntityStoreRiskScore.mock.calls[0][0].skip).toEqual(true); expect(mockUseRiskScoreKpi.mock.calls[0][0].skip).toEqual(false); + expect(mockUseEntityStoreRiskScoreKpi.mock.calls[0][0].skip).toEqual(true); }); - it('toggleStatus=false, skip', () => { + it('toggleStatus=false, entity store v2 off: all hooks skip', () => { mockUseQueryToggle.mockReturnValue({ toggleStatus: false, setToggleStatus: jest.fn() }); render( <TestProviders> @@ -78,6 +101,20 @@ describe('Host risk score query tab body', () => { </TestProviders> ); expect(mockUseRiskScore.mock.calls[0][0].skip).toEqual(true); + expect(mockUseEntityStoreRiskScore.mock.calls[0][0].skip).toEqual(true); + expect(mockUseRiskScoreKpi.mock.calls[0][0].skip).toEqual(true); + expect(mockUseEntityStoreRiskScoreKpi.mock.calls[0][0].skip).toEqual(true); + }); + it('toggleStatus=true, entity store v2 on: entity store hooks are not skipped; legacy hooks are skipped', () => { + mockUseUiSetting.mockReturnValue(true); + render( + <TestProviders> + <HostRiskScoreQueryTabBody {...defaultProps} /> + </TestProviders> + ); + expect(mockUseRiskScore.mock.calls[0][0].skip).toEqual(true); + expect(mockUseEntityStoreRiskScore.mock.calls[0][0].skip).toEqual(false); expect(mockUseRiskScoreKpi.mock.calls[0][0].skip).toEqual(true); + expect(mockUseEntityStoreRiskScoreKpi.mock.calls[0][0].skip).toEqual(false); }); }); diff --git a/x-pack/solutions/security/plugins/security_solution/public/explore/hosts/pages/navigation/host_risk_score_tab_body.tsx b/x-pack/solutions/security/plugins/security_solution/public/explore/hosts/pages/navigation/host_risk_score_tab_body.tsx index d671d30410200..1b1dbd744f4e2 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/explore/hosts/pages/navigation/host_risk_score_tab_body.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/explore/hosts/pages/navigation/host_risk_score_tab_body.tsx @@ -7,6 +7,7 @@ import React, { useEffect, useMemo, useState } from 'react'; import { EuiPanel } from '@elastic/eui'; +import { FF_ENABLE_ENTITY_STORE_V2 } from '@kbn/entity-store/public'; import { noop } from 'lodash/fp'; import { RiskScoresNoDataDetected } from '../../../../entity_analytics/components/risk_score_no_data_detected'; import { useUpsellingComponent } from '../../../../common/hooks/use_upselling'; @@ -15,18 +16,81 @@ import { useMissingRiskEnginePrivileges } from '../../../../entity_analytics/hoo import { HostRiskScoreQueryId } from '../../../../entity_analytics/common/utils'; import { useRiskScoreKpi } from '../../../../entity_analytics/api/hooks/use_risk_score_kpi'; import { useRiskScore } from '../../../../entity_analytics/api/hooks/use_risk_score'; +import { useEntityStoreRiskScoreKpi } from '../../../../entity_analytics/api/hooks/use_entity_store_risk_score_kpi'; +import { useEntityStoreRiskScore } from '../../../../entity_analytics/api/hooks/use_entity_store_risk_score'; +import { useUiSetting } from '../../../../common/lib/kibana'; import { EnableRiskScore } from '../../../../entity_analytics/components/enable_risk_score'; -import type { HostsComponentsQueryProps } from './types'; import { manageQuery } from '../../../../common/components/page/manage_query'; import { HostRiskScoreTable } from '../../../../entity_analytics/components/host_risk_score_table'; import { useDeepEqualSelector } from '../../../../common/hooks/use_selector'; import { hostsModel, hostsSelectors } from '../../store'; import type { State } from '../../../../common/store'; import { useQueryToggle } from '../../../../common/containers/query_toggle'; -import { EMPTY_SEVERITY_COUNT, EntityType } from '../../../../../common/search_strategy'; +import { + EMPTY_SEVERITY_COUNT, + EntityType, + type RiskScoreSortField, +} from '../../../../../common/search_strategy'; +import type { HostsComponentsQueryProps } from './types'; const HostRiskScoreTableManage = manageQuery(HostRiskScoreTable); +const useHostRiskScoreTabData = ({ + entityStoreV2Enabled, + filterQuery, + pagination, + querySkip, + sort, + timerange, +}: { + entityStoreV2Enabled: boolean; + filterQuery?: HostsComponentsQueryProps['filterQuery']; + pagination: { cursorStart: number; querySize: number }; + querySkip: boolean; + sort: RiskScoreSortField; + timerange: { from: string; to: string }; +}) => { + const legacyRiskScore = useRiskScore({ + filterQuery, + pagination, + riskEntity: EntityType.host, + skip: querySkip || entityStoreV2Enabled, + sort, + timerange, + }); + + const entityStoreRiskScore = useEntityStoreRiskScore({ + filterQuery, + pagination, + riskEntity: EntityType.host, + skip: querySkip || !entityStoreV2Enabled, + sort, + timerange, + }); + + const risk = entityStoreV2Enabled ? entityStoreRiskScore : legacyRiskScore; + + const legacyKpi = useRiskScoreKpi({ + filterQuery, + skip: querySkip || entityStoreV2Enabled, + riskEntity: EntityType.host, + }); + + const entityStoreKpi = useEntityStoreRiskScoreKpi({ + filterQuery, + skip: querySkip || !entityStoreV2Enabled, + riskEntity: EntityType.host, + }); + + const kpi = entityStoreV2Enabled ? entityStoreKpi : legacyKpi; + + return { + ...risk, + isKpiLoading: kpi.loading, + severityCount: kpi.severityCount, + }; +}; + export const HostRiskScoreQueryTabBody = ({ deleteQuery, endDate: to, @@ -36,6 +100,7 @@ export const HostRiskScoreQueryTabBody = ({ startDate: from, type, }: HostsComponentsQueryProps) => { + const entityStoreV2Enabled = useUiSetting<boolean>(FF_ENABLE_ENTITY_STORE_V2, false) === true; const getHostRiskScoreSelector = useMemo(() => hostsSelectors.hostRiskScoreSelector(), []); const { activePage, limit, sort } = useDeepEqualSelector((state: State) => getHostRiskScoreSelector(state, hostsModel.HostsType.page) @@ -64,20 +129,23 @@ export const HostRiskScoreQueryTabBody = ({ const timerange = useMemo(() => ({ from, to }), [from, to]); const privileges = useMissingRiskEnginePrivileges({ readonly: true }); - const { data, inspect, isInspected, hasEngineBeenInstalled, loading, refetch, totalCount } = - useRiskScore({ - filterQuery, - pagination, - riskEntity: EntityType.host, - skip: querySkip, - sort, - timerange, - }); - - const { severityCount, loading: isKpiLoading } = useRiskScoreKpi({ + const { + data, + hasEngineBeenInstalled, + inspect, + isInspected, + isKpiLoading, + loading, + refetch, + severityCount, + totalCount, + } = useHostRiskScoreTabData({ + entityStoreV2Enabled, filterQuery, - skip: querySkip, - riskEntity: EntityType.host, + pagination, + querySkip, + sort, + timerange, }); const isDisabled = !hasEngineBeenInstalled && !loading; diff --git a/x-pack/solutions/security/plugins/security_solution/public/explore/hosts/pages/navigation/hosts_query_tab_body.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/explore/hosts/pages/navigation/hosts_query_tab_body.test.tsx index 3778292359d64..eae72b6d88b7d 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/explore/hosts/pages/navigation/hosts_query_tab_body.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/explore/hosts/pages/navigation/hosts_query_tab_body.test.tsx @@ -8,7 +8,7 @@ import React from 'react'; import { render } from '@testing-library/react'; import { TestProviders } from '../../../../common/mock'; -import { useAllHost } from '../../containers/hosts'; +import { useAllEntityStoreHosts, useAllHost } from '../../containers/hosts'; import { useQueryToggle } from '../../../../common/containers/query_toggle'; import { HostsQueryTabBody } from './hosts_query_tab_body'; import { HostsType } from '../../store/model'; @@ -19,6 +19,7 @@ jest.mock('../../../../common/lib/kibana'); describe('Hosts query tab body', () => { const mockUseAllHost = useAllHost as jest.Mock; + const mockUseAllEntityStoreHosts = useAllEntityStoreHosts as jest.Mock; const mockUseQueryToggle = useQueryToggle as jest.Mock; const defaultProps = { indexNames: [], @@ -31,22 +32,21 @@ describe('Hosts query tab body', () => { beforeEach(() => { jest.clearAllMocks(); mockUseQueryToggle.mockReturnValue({ toggleStatus: true, setToggleStatus: jest.fn() }); - mockUseAllHost.mockReturnValue([ - false, - { - hosts: [], - id: '123', - inspect: { - dsl: [], - response: [], - }, - isInspected: false, - totalCount: 0, - pageInfo: { activePage: 1, fakeTotalCount: 100, showMorePagesIndicator: false }, - loadPage: jest.fn(), - refetch: jest.fn(), + const emptyHostsArgs = { + hosts: [], + id: '123', + inspect: { + dsl: [], + response: [], }, - ]); + isInspected: false, + totalCount: 0, + pageInfo: { activePage: 1, fakeTotalCount: 100, showMorePagesIndicator: false }, + loadPage: jest.fn(), + refetch: jest.fn(), + }; + mockUseAllHost.mockReturnValue([false, emptyHostsArgs]); + mockUseAllEntityStoreHosts.mockReturnValue([false, emptyHostsArgs]); }); it('toggleStatus=true, do not skip', () => { render( diff --git a/x-pack/solutions/security/plugins/security_solution/public/explore/hosts/pages/navigation/hosts_query_tab_body.tsx b/x-pack/solutions/security/plugins/security_solution/public/explore/hosts/pages/navigation/hosts_query_tab_body.tsx index 7290ce5a98d0b..1201f1e854d4d 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/explore/hosts/pages/navigation/hosts_query_tab_body.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/explore/hosts/pages/navigation/hosts_query_tab_body.tsx @@ -7,8 +7,10 @@ import { getOr } from 'lodash/fp'; import React, { useEffect, useState } from 'react'; -import { useAllHost, ID } from '../../containers/hosts'; +import { FF_ENABLE_ENTITY_STORE_V2 } from '@kbn/entity-store/public'; +import { useAllEntityStoreHosts, useAllHost, ID } from '../../containers/hosts'; import type { HostsComponentsQueryProps } from './types'; +import { useUiSetting } from '../../../../common/lib/kibana'; import { HostsTable } from '../../components/hosts_table'; import { manageQuery } from '../../../../common/components/page/manage_query'; import { useQueryToggle } from '../../../../common/containers/query_toggle'; @@ -25,20 +27,30 @@ export const HostsQueryTabBody = ({ startDate, type, }: HostsComponentsQueryProps) => { + const entityStoreV2Enabled = useUiSetting<boolean>(FF_ENABLE_ENTITY_STORE_V2, false) === true; const { toggleStatus } = useQueryToggle(ID); const [querySkip, setQuerySkip] = useState(skip || !toggleStatus); useEffect(() => { setQuerySkip(skip || !toggleStatus); }, [skip, toggleStatus]); - const [loading, { hosts, totalCount, pageInfo, loadPage, id, inspect, isInspected, refetch }] = - useAllHost({ - endDate, - filterQuery, - indexNames, - skip: querySkip, - startDate, - type, - }); + const commonHostQueryArgs = { + endDate, + filterQuery, + indexNames, + startDate, + type, + }; + const [legacyLoading, legacyHostsArgs] = useAllHost({ + ...commonHostQueryArgs, + skip: querySkip || entityStoreV2Enabled, + }); + const [entityStoreLoading, entityStoreHostsArgs] = useAllEntityStoreHosts({ + ...commonHostQueryArgs, + skip: querySkip || !entityStoreV2Enabled, + }); + const loading = entityStoreV2Enabled ? entityStoreLoading : legacyLoading; + const { hosts, totalCount, pageInfo, loadPage, id, inspect, isInspected, refetch } = + entityStoreV2Enabled ? entityStoreHostsArgs : legacyHostsArgs; return ( <HostsTableManage diff --git a/x-pack/solutions/security/plugins/security_solution/public/explore/hosts/pages/navigation/sessions_tab_body.tsx b/x-pack/solutions/security/plugins/security_solution/public/explore/hosts/pages/navigation/sessions_tab_body.tsx index f0682817b7493..1998419abc186 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/explore/hosts/pages/navigation/sessions_tab_body.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/explore/hosts/pages/navigation/sessions_tab_body.tsx @@ -7,16 +7,14 @@ import React, { useMemo } from 'react'; import { TableId } from '@kbn/securitysolution-data-table'; -import { SecurityPageName } from '../../../../app/types'; import { SessionsView } from '../../../../common/components/sessions_viewer'; -import { fieldNameExistsFilter } from '../../../../common/components/visualization_actions/utils'; +import { hostNameExistsFilter } from '../../../../common/components/visualization_actions/utils'; import { useLicense } from '../../../../common/hooks/use_license'; import type { AlertsComponentQueryProps } from './types'; export const SessionsTabBody = React.memo((alertsProps: AlertsComponentQueryProps) => { const { pageFilters, filterQuery, ...rest } = alertsProps; const hostPageFilters = useMemo(() => { - const hostNameExistsFilter = fieldNameExistsFilter(SecurityPageName.hosts); return pageFilters != null ? [...hostNameExistsFilter, ...pageFilters] : hostNameExistsFilter; }, [pageFilters]); const isEnterprisePlus = useLicense().isEnterprise(); diff --git a/x-pack/solutions/security/plugins/security_solution/public/explore/hosts/pages/navigation/types.ts b/x-pack/solutions/security/plugins/security_solution/public/explore/hosts/pages/navigation/types.ts index df302e8885797..084ff77a48c95 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/explore/hosts/pages/navigation/types.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/explore/hosts/pages/navigation/types.ts @@ -36,6 +36,11 @@ export type HostsComponentsQueryProps = QueryTabBodyProps & { pageFilters?: Filter[]; skip: boolean; setQuery: GlobalTimeArgs['setQuery']; + /** + * Host details: serialized ES query built with entity identity filters (Entity Store v2). + * Used by {@link AuthenticationsQueryTabBody} and {@link RiskDetailsTabBody} when set. + */ + identityScopedFilterQuery?: string; }; export type AlertsComponentQueryProps = HostsComponentsQueryProps & { diff --git a/x-pack/solutions/security/plugins/security_solution/public/explore/hosts/pages/types.ts b/x-pack/solutions/security/plugins/security_solution/public/explore/hosts/pages/types.ts index 75ccdcbb55afe..0e7fde8fdccbc 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/explore/hosts/pages/types.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/explore/hosts/pages/types.ts @@ -9,8 +9,16 @@ import type { hostsModel } from '../store'; import type { GlobalTimeArgs } from '../../../common/containers/use_global_time'; import { HOSTS_PATH } from '../../../../common/constants'; +/** Tab names for host details routes */ +const HOST_DETAILS_TAB_NAMES = + 'events|authentications|uncommonProcesses|anomalies|hostRisk|sessions'; + +/** Base path for host details (used by details_tabs for route matching) */ export const hostDetailsPagePath = `${HOSTS_PATH}/name/:detailName`; +/** Path for host details tabs (entity resolution uses URL search params). */ +export const hostDetailsPagePathWithEntityIdentifiers = `${hostDetailsPagePath}/:tabName(${HOST_DETAILS_TAB_NAMES})`; + export type HostsTabsProps = GlobalTimeArgs & { filterQuery?: string; indexNames: string[]; diff --git a/x-pack/solutions/security/plugins/security_solution/public/explore/users/components/all_users/index.tsx b/x-pack/solutions/security/plugins/security_solution/public/explore/users/components/all_users/index.tsx index dccb3d89f4f65..ea2ddc5e55d2e 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/explore/users/components/all_users/index.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/explore/users/components/all_users/index.tsx @@ -9,9 +9,11 @@ import React, { useCallback, useMemo } from 'react'; import { useDispatch } from 'react-redux'; import { EuiLink, EuiText } from '@elastic/eui'; +import { SECURITY_CELL_ACTIONS_DEFAULT } from '@kbn/ui-actions-plugin/common/trigger_ids'; import { AssetCriticalityBadge } from '../../../../entity_analytics/components/asset_criticality'; import type { CriticalityLevelWithUnassigned } from '../../../../../common/entity_analytics/asset_criticality/types'; import { FormattedRelativePreferenceDate } from '../../../../common/components/formatted_date'; +import { SecurityCellActions, CellActionsMode } from '../../../../common/components/cell_actions'; import { UserDetailsLink } from '../../../../common/components/links'; import { getEmptyTagValue, @@ -57,7 +59,7 @@ interface UsersTableProps { } export type UsersTableColumns = [ - Columns<User['name']>, + Columns<User['name'], User>, Columns<User['lastSeen']>, Columns<User['domain']>, Columns<RiskSeverity>?, @@ -86,15 +88,27 @@ const getUsersColumns = ( truncateText: false, sortable: true, mobileOptions: { show: true }, - render: (name) => - name != null && name.length > 0 - ? getRowItemsWithActions({ - fieldName: 'user.name', - values: [name], - idPrefix: `users-table-${name}-name`, - render: (item) => <UserDetailsLink userName={item} />, - }) - : getOrEmptyTagFromValue(name), + render: (name, user: User) => + name != null && name.length > 0 ? ( + <SecurityCellActions + mode={CellActionsMode.HOVER_DOWN} + visibleCellActions={5} + showActionTooltips + triggerId={SECURITY_CELL_ACTIONS_DEFAULT} + data={{ + value: name, + field: 'user.name', + }} + > + <UserDetailsLink + userName={name} + entityId={user.entityId} + identityFields={user.identityFields} + /> + </SecurityCellActions> + ) : ( + getOrEmptyTagFromValue(name) + ), }, { field: 'lastSeen', diff --git a/x-pack/solutions/security/plugins/security_solution/public/explore/users/components/kpi_users/total_users/index.tsx b/x-pack/solutions/security/plugins/security_solution/public/explore/users/components/kpi_users/total_users/index.tsx index c9839d06fd8fb..cfb4496eff78f 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/explore/users/components/kpi_users/total_users/index.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/explore/users/components/kpi_users/total_users/index.tsx @@ -7,15 +7,23 @@ import React, { useMemo } from 'react'; import { useEuiTheme } from '@elastic/eui'; +import { useUiSetting } from '@kbn/kibana-react-plugin/public'; + +import { FF_ENABLE_ENTITY_STORE_V2 } from '@kbn/entity-store/public'; + import type { StatItems } from '../../../../components/stat_items'; +import { useSpaceId } from '../../../../../common/hooks/use_space_id'; import { KpiBaseComponent } from '../../../../components/kpi'; -import { kpiTotalUsersMetricLensAttributes } from '../../../../../common/components/visualization_actions/lens_attributes/users/kpi_total_users_metric'; +import { buildKpiTotalUsersMetricLensAttributes } from '../../../../../common/components/visualization_actions/lens_attributes/users/kpi_total_users_metric'; import { getKpiTotalUsersAreaLensAttributes } from '../../../../../common/components/visualization_actions/lens_attributes/users/kpi_total_users_area'; import * as i18n from './translations'; import type { UsersKpiProps } from '../types'; export const useGetUsersStatItems: () => Readonly<StatItems[]> = () => { const { euiTheme } = useEuiTheme(); + const spaceId = useSpaceId(); + const entityStoreV2Enabled = useUiSetting<boolean>(FF_ENABLE_ENTITY_STORE_V2, false) === true; + return useMemo( () => [ { @@ -25,7 +33,9 @@ export const useGetUsersStatItems: () => Readonly<StatItems[]> = () => { key: 'users', color: euiTheme.colors.vis.euiColorVis0, icon: 'storage', - lensAttributes: kpiTotalUsersMetricLensAttributes, + lensAttributes: buildKpiTotalUsersMetricLensAttributes( + entityStoreV2Enabled ? { entityStoreV2Enabled: true, spaceId } : undefined + ), }, ], enableAreaChart: true, @@ -33,7 +43,7 @@ export const useGetUsersStatItems: () => Readonly<StatItems[]> = () => { getAreaChartLensAttributes: getKpiTotalUsersAreaLensAttributes, }, ], - [euiTheme.colors.vis.euiColorVis0] + [euiTheme.colors.vis.euiColorVis0, entityStoreV2Enabled, spaceId] ); }; diff --git a/x-pack/solutions/security/plugins/security_solution/public/explore/users/containers/users/translations.ts b/x-pack/solutions/security/plugins/security_solution/public/explore/users/containers/users/translations.ts new file mode 100644 index 0000000000000..b0881f5ecbec6 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/explore/users/containers/users/translations.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const FAIL_ALL_USERS = i18n.translate( + 'xpack.securitySolution.allUsers.failSearchDescription', + { + defaultMessage: `Failed to load users from the entity store`, + } +); 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 new file mode 100644 index 0000000000000..8cc0d62830ea8 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/explore/users/containers/users/use_all_entity_store_users.ts @@ -0,0 +1,235 @@ +/* + * 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 { useMemo, useCallback } from 'react'; +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 { 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'; +import type { RiskSeverity } from '../../../../../common/search_strategy/security_solution/risk_score/all'; +import type { ESTermQuery } from '../../../../../common/typed_json'; +import { createFilter } from '../../../../common/containers/helpers'; +import { useErrorToast } from '../../../../common/hooks/use_error_toast'; +import { useDeepEqualSelector } from '../../../../common/hooks/use_selector'; +import type { inputsModel, State } from '../../../../common/store'; +import { useEntityAnalyticsRoutes } from '../../../../entity_analytics/api/api'; +import { getLimitedPaginationTotalCount } from '../../../components/paginated_table/helpers'; +import { usersSelectors } from '../../store'; +import type { InspectResponse } from '../../../../types'; +import type { UsersListArgs } from './users_table_query_types'; +import { USERS_ALL_TABLE_QUERY_ID } from './users_table_query_types'; +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; + +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; + const domain = domainValues != null && domainValues.length > 0 ? domainValues[0] : ''; + const riskLevel = record.user?.risk?.calculated_level as RiskSeverity | undefined; + + const identityFields: Record<string, string> = { + '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, + }; +}; + +const parseFilterClauses = (filterQuery?: ESTermQuery | string): object[] => { + const filtered = createFilter(filterQuery); + if (filtered == null || filtered === '') { + return []; + } + try { + return [JSON.parse(filtered) as object]; + } catch { + return []; + } +}; + +const buildUsersListFilterQuery = ({ + filterQuery, + startDate, + endDate, +}: { + filterQuery?: ESTermQuery | string; + startDate: string; + endDate: string; +}): string => { + const timeRangeClause = { + range: { + 'entity.lifecycle.last_seen': { + gte: startDate, + lte: endDate, + format: 'strict_date_optional_time', + }, + }, + }; + + return JSON.stringify({ + bool: { + filter: [...parseFilterClauses(filterQuery), timeRangeClause], + }, + }); +}; + +const usersSortFieldToEntityStoreField = (field: UsersFields): string => { + switch (field) { + case UsersFields.name: + return 'user.name'; + case UsersFields.lastSeen: + return 'entity.lifecycle.last_seen'; + case UsersFields.domain: + default: + return 'entity.lifecycle.last_seen'; + } +}; + +interface UseAllEntityStoreUsersParams { + endDate: string; + filterQuery?: ESTermQuery | string; + indexNames: string[]; + skip?: boolean; + startDate: string; +} + +export const useAllEntityStoreUsers = ( + params: UseAllEntityStoreUsersParams +): [boolean, UsersListArgs] => { + const { endDate, filterQuery, skip = false, startDate } = params; + const { fetchEntitiesListV2 } = useEntityAnalyticsRoutes(); + const getAllUsersSelector = useMemo(() => usersSelectors.allUsersSelector(), []); + const { activePage, limit, sort } = useDeepEqualSelector((state: State) => + getAllUsersSelector(state) + ); + const { field: sortField, direction } = sort; + + const listFilterQuery = useMemo( + () => buildUsersListFilterQuery({ filterQuery, startDate, endDate }), + [endDate, filterQuery, startDate] + ); + + const sortFieldForApi = usersSortFieldToEntityStoreField(sortField); + + const { data, isLoading, isFetching, error, refetch } = useQuery< + ListEntitiesResponse | null, + IHttpFetchError + >({ + queryKey: [ + ENTITY_STORE_USERS_LIST_QUERY_KEY, + listFilterQuery, + activePage, + limit, + sortFieldForApi, + direction, + skip, + ], + queryFn: async ({ signal }) => + fetchEntitiesListV2({ + signal, + params: { + entityTypes: ['user'], + filterQuery: listFilterQuery, + page: activePage + 1, + perPage: limit, + sortField: sortFieldForApi, + sortOrder: direction, + }, + }), + enabled: !skip, + cacheTime: 0, + refetchOnWindowFocus: false, + keepPreviousData: true, + }); + + useErrorToast(i18n.FAIL_ALL_USERS, skip ? undefined : error); + + const totalCount = data?.total ?? 0; + const fakeTotalCount = getLimitedPaginationTotalCount({ activePage, limit, totalCount }); + const showMorePagesIndicator = totalCount > fakeTotalCount; + + const users: User[] = useMemo(() => { + if (data?.records == null) { + return []; + } + return data.records.flatMap((record) => { + if (!isUserEntityRecord(record)) { + return []; + } + const user = mapUserEntityRecordToUser(record); + return user != null ? [user] : []; + }); + }, [data?.records]); + + const inspect: InspectResponse = useMemo( + () => ({ + dsl: data?.inspect?.dsl ?? [], + response: data?.inspect?.response ?? [], + }), + [data?.inspect?.dsl, data?.inspect?.response] + ); + + const refetchUsers: inputsModel.Refetch = useCallback(() => { + void refetch(); + }, [refetch]); + + const usersResponse: UsersListArgs = useMemo( + () => ({ + endDate, + id: USERS_ALL_TABLE_QUERY_ID, + inspect, + isInspected: false, + loadPage: noop, + pageInfo: { + activePage, + fakeTotalCount, + showMorePagesIndicator, + }, + refetch: refetchUsers, + startDate, + totalCount, + users, + }), + [ + activePage, + endDate, + fakeTotalCount, + inspect, + refetchUsers, + showMorePagesIndicator, + startDate, + totalCount, + users, + ] + ); + + return [isLoading || isFetching, usersResponse]; +}; diff --git a/x-pack/solutions/security/plugins/security_solution/public/explore/users/containers/users/users_table_query_types.ts b/x-pack/solutions/security/plugins/security_solution/public/explore/users/containers/users/users_table_query_types.ts new file mode 100644 index 0000000000000..46e8072ff8703 --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/public/explore/users/containers/users/users_table_query_types.ts @@ -0,0 +1,28 @@ +/* + * 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 { PageInfoPaginated } from '../../../../../common/search_strategy'; +import type { User } from '../../../../../common/search_strategy/security_solution/users/all'; +import type { inputsModel } from '../../../../common/store'; +import type { InspectResponse } from '../../../../types'; + +export const USERS_ALL_TABLE_QUERY_ID = 'UsersTable'; + +export type LoadPage = (newActivePage: number) => void; + +export interface UsersListArgs { + endDate: string; + id: string; + inspect: InspectResponse; + isInspected: boolean; + loadPage: LoadPage; + pageInfo: PageInfoPaginated; + refetch: inputsModel.Refetch; + startDate: string; + totalCount: number; + users: User[]; +} 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 59235bc47fc39..9b23d499d7ce6 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,23 +30,31 @@ export const UsersDetailsTabs = React.memo<UsersDetailsTabsProps>( type, detailName, userDetailFilter, + userDetailsIdentityFilterQuery, + identityFields, + entityId, }) => { const tabProps = { deleteQuery, endDate: to, filterQuery, - indexNames, skip: isInitializing || filterQuery === undefined, setQuery, startDate: from, type, - userName: detailName, + indexNames, + identityFields, + entityId, }; return ( <Routes> <Route path={`${usersDetailsPagePath}/:tabName(${UsersTableType.authentications})`}> - <AuthenticationsQueryTabBody {...tabProps} /> + <AuthenticationsQueryTabBody + {...tabProps} + identityScopedFilterQuery={userDetailsIdentityFilterQuery} + userName={detailName} + /> </Route> <Route path={`${usersDetailsPagePath}/:tabName(${UsersTableType.anomalies})`}> <AnomaliesQueryTabBody {...tabProps} AnomaliesTableComponent={AnomaliesUserTable} /> @@ -54,6 +62,7 @@ export const UsersDetailsTabs = React.memo<UsersDetailsTabsProps>( <Route path={`${usersDetailsPagePath}/:tabName(${UsersTableType.events})`}> <EventsQueryTabBody additionalFilters={userDetailFilter} + histogramFilterQuery={userDetailsIdentityFilterQuery} tableId={TableId.usersPageEvents} {...tabProps} /> @@ -62,7 +71,8 @@ export const UsersDetailsTabs = React.memo<UsersDetailsTabsProps>( <RiskDetailsTabBody {...tabProps} riskEntity={EntityType.user} - entityName={tabProps.userName} + entityName={detailName} + identityScopedFilterQuery={userDetailsIdentityFilterQuery} /> </Route> </Routes> 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 bca7ff5c6c336..90a271412e408 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 @@ -7,6 +7,8 @@ import type { Filter } from '@kbn/es-query'; +export { userNameExistsFilter } from '../../../../common/components/visualization_actions/utils'; + export const getUsersDetailsPageFilters = (userName: string): Filter[] => [ { meta: { @@ -31,34 +33,31 @@ export const getUsersDetailsPageFilters = (userName: string): Filter[] => [ }, ]; -export const userNameExistsFilter: Filter[] = [ - { - query: { - bool: { - filter: [ - { - bool: { - should: [ - { - exists: { - field: 'user.name', - }, - }, - ], - minimum_should_match: 1, - }, +/** + * Kibana {@link Filter} clauses for Events (and similar) views: one phrase match per + * non-empty identity field (AND semantics when combined in the query bar). + */ +export const getIdentityFieldsPageFilters = (identityFields: Record<string, string>): Filter[] => + Object.entries(identityFields) + .filter(([, fieldValue]) => typeof fieldValue === 'string' && fieldValue.trim() !== '') + .map(([fieldKey, fieldValue]) => ({ + meta: { + alias: null, + negate: false, + disabled: false, + type: 'phrase', + key: fieldKey, + value: fieldValue, + params: { + query: fieldValue, + }, + }, + query: { + match: { + [fieldKey]: { + query: fieldValue, + type: 'phrase', }, - ], + }, }, - }, - meta: { - alias: '', - disabled: false, - key: 'bool', - negate: false, - type: 'custom', - value: - '{"query": {"bool": {"filter": [{"bool": {"should": [{"exists": {"field": "user.name"}}],"minimum_should_match": 1}}]}}}', - }, - }, -]; + })); 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 932a0fa5c3bfc..abff3baa820ee 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 @@ -6,84 +6,169 @@ */ import { + EuiCallOut, EuiFlexGroup, EuiFlexItem, EuiHorizontalRule, EuiSpacer, + EuiText, EuiWindowEvent, } from '@elastic/eui'; +import { + bulkUpdateEntities, + FF_ENABLE_ENTITY_STORE_V2, + useEntityStoreEuidApi, +} from '@kbn/entity-store/public'; +import { useQueryClient } from '@kbn/react-query'; import { noop } from 'lodash/fp'; import React, { useCallback, useEffect, useMemo } from 'react'; -import { useDispatch } from 'react-redux'; -import { getEsQueryConfig } from '@kbn/data-plugin/common'; +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'; import { PageScope } from '../../../../data_view_manager/constants'; import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features'; import { dataViewSpecToViewBase } from '../../../../common/lib/kuery'; import { useCalculateEntityRiskScore } from '../../../../entity_analytics/api/hooks/use_calculate_entity_risk_score'; -import { - useAssetCriticalityData, - useAssetCriticalityPrivileges, -} from '../../../../entity_analytics/components/asset_criticality/use_asset_criticality'; -import { - AssetCriticalitySelector, - AssetCriticalityTitle, -} from '../../../../entity_analytics/components/asset_criticality/asset_criticality_selector'; +import { useAssetCriticalityPrivileges } from '../../../../entity_analytics/components/asset_criticality/use_asset_criticality'; +import { AssetCriticalityAccordion } from '../../../../entity_analytics/components/asset_criticality/asset_criticality_selector'; import { AlertsByStatus } from '../../../../overview/components/detection_response/alerts_by_status'; import { useSignalIndex } from '../../../../detections/containers/detection_engine/alerts/use_signal_index'; -import { AlertCountByRuleByStatus } from '../../../../common/components/alert_count_by_status'; +import { useAlertsPrivileges } from '../../../../detections/containers/detection_engine/alerts/use_alerts_privileges'; import { InputsModelId } from '../../../../common/store/inputs/constants'; +import { EntityType } from '../../../../../common/entity_analytics/types'; import { SecurityPageName } from '../../../../app/types'; import { FiltersGlobal } from '../../../../common/components/filters_global'; import { HeaderPage } from '../../../../common/components/header_page'; +import { Title } from '../../../../common/components/header_page/title'; +import { LastEventTime } from '../../../../common/components/last_event_time'; +import { AnomalyTableProvider } from '../../../../common/components/ml/anomaly/anomaly_table_provider'; +import { buildAnomaliesTableInfluencersFilterQuery } from '../../../../common/components/ml/anomaly/anomaly_table_euid'; +import { getCriteriaFromUsersType } from '../../../../common/components/ml/criteria/get_criteria_from_users_type'; +import { hasMlUserPermissions } from '../../../../../common/machine_learning/has_ml_user_permissions'; +import { useMlCapabilities } from '../../../../common/components/ml/hooks/use_ml_capabilities'; +import { scoreIntervalToDateTime } from '../../../../common/components/ml/score/score_interval_to_datetime'; import { TabNavigation } from '../../../../common/components/navigation/tab_navigation'; +import { + USER_OVERVIEW_RISK_SCORE_QUERY_ID, + UserOverview, + type UserSummaryProps, +} from '../../../../overview/components/user_overview'; import { SiemSearchBar } from '../../../../common/components/search_bar'; import { SecuritySolutionPageWrapper } from '../../../../common/components/page_wrapper'; import { useGlobalTime } from '../../../../common/containers/use_global_time'; -import { useKibana } from '../../../../common/lib/kibana'; +import { useKibana, useUiSetting } from '../../../../common/lib/kibana'; import { inputsSelectors } from '../../../../common/store'; -import { useAlertsPrivileges } from '../../../../detections/containers/detection_engine/alerts/use_alerts_privileges'; import { setUsersDetailsTablesActivePageToZero } from '../../store/actions'; import { setAbsoluteRangeDatePicker } from '../../../../common/store/inputs/actions'; import { SpyRoute } from '../../../../common/utils/route/spy_routes'; import { UsersDetailsTabs } from './details_tabs'; import { navTabsUsersDetails } from './nav_tabs'; import type { UsersDetailsProps } from './types'; -import { getUsersDetailsPageFilters } from './helpers'; +import { UsersType } from '../../store/model'; +import { getUsersDetailsPageFilters, getIdentityFieldsPageFilters } from './helpers'; +import { + identityFieldsHaveUsableValues, + mergeLegacyIdentityWhenStoreEntityMissing, +} from '../../../../flyout/document_details/shared/utils'; import { useGlobalFullScreen } from '../../../../common/containers/use_full_screen'; -import { useSourcererDataView } from '../../../../sourcerer/containers'; +import { Display } from '../../../hosts/pages/display'; import { useDeepEqualSelector } from '../../../../common/hooks/use_selector'; -import { useInvalidFilterQuery } from '../../../../common/hooks/use_invalid_filter_query'; -import { LastEventTime } from '../../../../common/components/last_event_time'; -import { EntityType } from '../../../../../common/entity_analytics/types'; -import { AnomalyTableProvider } from '../../../../common/components/ml/anomaly/anomaly_table_provider'; -import type { UserSummaryProps } from '../../../../overview/components/user_overview'; -import { - USER_OVERVIEW_RISK_SCORE_QUERY_ID, - UserOverview, -} from '../../../../overview/components/user_overview'; import { useObservedUserDetails } from '../../containers/users/observed_details'; -import { useQueryInspector } from '../../../../common/components/page/manage_query'; -import { scoreIntervalToDateTime } from '../../../../common/components/ml/score/score_interval_to_datetime'; -import { getCriteriaFromUsersType } from '../../../../common/components/ml/criteria/get_criteria_from_users_type'; -import { UsersType } from '../../store/model'; -import { hasMlUserPermissions } from '../../../../../common/machine_learning/has_ml_user_permissions'; -import { useMlCapabilities } from '../../../../common/components/ml/hooks/use_ml_capabilities'; +import { manageQuery } from '../../../../common/components/page/manage_query'; +import { useInvalidFilterQuery } from '../../../../common/hooks/use_invalid_filter_query'; +import { useSourcererDataView } from '../../../../sourcerer/containers'; import { EmptyPrompt } from '../../../../common/components/empty_prompt'; +import { AlertCountByRuleByStatus } from '../../../../common/components/alert_count_by_status'; import { useRefetchOverviewPageRiskScore } from '../../../../entity_analytics/api/hooks/use_refetch_overview_page_risk_score'; import { useDataView } from '../../../../data_view_manager/hooks/use_data_view'; import { useSelectedPatterns } from '../../../../data_view_manager/hooks/use_selected_patterns'; import { PageLoader } from '../../../../common/components/page_loader'; - -const QUERY_ID = 'UsersDetailsQueryId'; +import { + applyEntityStoreSearchCachePatch, + useEntityFromStore, + type EntityStoreRecord, +} from '../../../../flyout/entity_details/shared/hooks/use_entity_from_store'; +import { ObservedDataSection as UserObservedDataSection } from '../../../../flyout/entity_details/user_right/components/observed_data_section'; +import { USER_PANEL_OBSERVED_USER_QUERY_ID } from '../../../../flyout/entity_details/user_right'; +import { useObservedUser } from '../../../../flyout/entity_details/user_right/hooks/use_observed_user'; +import { buildRiskScoreStateFromEntityRecord } from '../../../../flyout/entity_details/shared/entity_store_risk_utils'; +import { NO_CORRESPONDING_ENTITY_EXISTS } from '../../../../flyout/entity_details/shared/translations'; +import { useSecurityDefaultPatterns } from '../../../../data_view_manager/hooks/use_security_default_patterns'; +import { sourcererSelectors } from '../../../../sourcerer/store'; +import type { UserItem } from '../../../../../common/search_strategy'; +import type { Entity } from '../../../../../common/api/entity_analytics'; + +const USERS_DETAILS_OVERVIEW_QUERY_ID = 'UsersDetailsQueryId'; const ES_USER_FIELD = 'user.name'; +const UserOverviewManage = manageQuery(UserOverview); + +const UserDetailsHeaderTitle: React.FC<{ + detailName: string; + displayEntityId?: string; +}> = ({ detailName, displayEntityId }) => ( + <> + <Title title={detailName} /> + {displayEntityId ? ( + <> + <EuiSpacer size="xs" /> + <EuiText size="xs" color="subdued" data-test-subj="user-details-page-entity-id"> + {displayEntityId} + </EuiText> + </> + ) : null} + </> +); +UserDetailsHeaderTitle.displayName = 'UserDetailsHeaderTitle'; + +const UserDetailsAssetCriticalitySection: React.FC<{ + canRead: boolean; + detailName: string; + entityStoreV2Enabled: boolean; + noEntityInStore: boolean; + observedUserEntityRecord: EntityStoreRecord | null | undefined; + storeRecord: EntityStoreRecord | null | undefined; + onSaveViaEntityStore: (updatedRecord: Entity) => Promise<void>; + onCriticalityChange: () => void; +}> = ({ + canRead, + detailName, + entityStoreV2Enabled, + noEntityInStore, + observedUserEntityRecord, + storeRecord, + onSaveViaEntityStore, + onCriticalityChange, +}) => { + if (!canRead || (entityStoreV2Enabled && noEntityInStore)) { + return null; + } + return ( + <AssetCriticalityAccordion + entity={{ name: detailName, type: EntityType.user }} + onChange={onCriticalityChange} + entityRecord={entityStoreV2Enabled ? observedUserEntityRecord ?? undefined : undefined} + criticalityFromEntityStore={ + entityStoreV2Enabled && observedUserEntityRecord + ? storeRecord?.asset?.criticality + : undefined + } + onSaveViaEntityStore={entityStoreV2Enabled && storeRecord ? onSaveViaEntityStore : undefined} + /> + ); +}; +UserDetailsAssetCriticalitySection.displayName = 'UserDetailsAssetCriticalitySection'; + const UsersDetailsComponent: React.FC<UsersDetailsProps> = ({ detailName, usersDetailsPagePath, + entityId, + identityFields, }) => { + const { search: urlStateQuery } = useLocation(); const dispatch = useDispatch(); const getGlobalFiltersQuerySelector = useMemo( () => inputsSelectors.globalFiltersQuerySelector(), @@ -93,22 +178,40 @@ const UsersDetailsComponent: React.FC<UsersDetailsProps> = ({ const query = useDeepEqualSelector(getGlobalQuerySelector); const globalFilters = useDeepEqualSelector(getGlobalFiltersQuerySelector); - const { signalIndexName } = useSignalIndex(); - const { hasAlertsRead, hasIndexRead } = useAlertsPrivileges(); - const canReadAlerts = hasAlertsRead && hasIndexRead; - const { to, from, deleteQuery, setQuery, isInitializing } = useGlobalTime(); const { globalFullScreen } = useGlobalFullScreen(); + const { signalIndexName } = useSignalIndex(); + const capabilities = useMlCapabilities(); const { - services: { uiSettings }, + services: { http, uiSettings }, } = useKibana(); + const queryClient = useQueryClient(); + + const resolvedIdentityFields = useMemo( + () => identityFields ?? { [ES_USER_FIELD]: detailName }, + [identityFields, detailName] + ); const usersDetailsPageFilters: Filter[] = useMemo( () => getUsersDetailsPageFilters(detailName), [detailName] ); + const narrowDateRange = useCallback<UserSummaryProps['narrowDateRange']>( + (score, interval) => { + const fromTo = scoreIntervalToDateTime(score, interval); + dispatch( + setAbsoluteRangeDatePicker({ + id: InputsModelId.global, + from: fromTo.from, + to: fromTo.to, + }) + ); + }, + [dispatch] + ); + const { indicesExist: oldIndicesExist, selectedPatterns: oldSelectedPatterns, @@ -121,12 +224,103 @@ const UsersDetailsComponent: React.FC<UsersDetailsProps> = ({ const experimentalSelectedPatterns = useSelectedPatterns(PageScope.explore); const indicesExist = newDataViewPickerEnabled - ? experimentalDataView.hasMatchedIndices() + ? !!experimentalDataView.matchedIndices?.length : oldIndicesExist; const selectedPatterns = newDataViewPickerEnabled ? experimentalSelectedPatterns : oldSelectedPatterns; + const entityStoreV2Enabled = useUiSetting<boolean>(FF_ENABLE_ENTITY_STORE_V2, false); + + const userStoreIdentityFields = useMemo(() => { + if (entityId) { + return undefined; + } + return Object.keys(resolvedIdentityFields).length > 0 ? resolvedIdentityFields : undefined; + }, [entityId, resolvedIdentityFields]); + + const entityFromStoreResult = useEntityFromStore({ + entityId, + identityFields: userStoreIdentityFields, + entityType: 'user', + skip: !entityStoreV2Enabled || isInitializing, + }); + + const euidApi = useEntityStoreEuidApi(); + + const noEntityInStore = + entityStoreV2Enabled && !entityFromStoreResult.isLoading && !entityFromStoreResult.entityRecord; + + const usersDetailsEventsPageFilters = useMemo(() => { + 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 getUsersDetailsPageFilters(detailName); + }, [ + detailName, + entityFromStoreResult.entityRecord, + entityStoreV2Enabled, + noEntityInStore, + euidApi?.euid, + resolvedIdentityFields, + ]); + + const oldSecurityDefaultPatterns = + useSelector(sourcererSelectors.defaultDataView)?.patternList ?? []; + const { indexPatterns: experimentalSecurityDefaultIndexPatterns } = useSecurityDefaultPatterns(); + const securityDefaultPatterns = newDataViewPickerEnabled + ? experimentalSecurityDefaultIndexPatterns + : oldSecurityDefaultPatterns; + + const observedUser = useObservedUser( + detailName, + PageScope.explore, + entityStoreV2Enabled ? entityFromStoreResult : undefined + ); + + const [loading, { inspect, userDetails: userOverview, id, refetch }] = useObservedUserDetails({ + id: USERS_DETAILS_OVERVIEW_QUERY_ID, + endDate: to, + startDate: from, + userName: detailName, + indexNames: selectedPatterns, + skip: selectedPatterns.length === 0 || entityStoreV2Enabled, + }); + + const userDetailsForOverview = entityStoreV2Enabled ? observedUser.details : userOverview; + const isUserOverviewLoading = entityStoreV2Enabled ? observedUser.isLoading : loading; + + const userRiskScoreStateFromEntityStore = useMemo( + () => + entityStoreV2Enabled && observedUser.entityRecord + ? buildRiskScoreStateFromEntityRecord(EntityType.user, observedUser.entityRecord, { + refetch: observedUser.refetchEntityStore ?? (() => {}), + isLoading: observedUser.isLoading, + error: null, + inspect: entityFromStoreResult?.inspect, + }) + : undefined, + [ + entityFromStoreResult?.inspect, + entityStoreV2Enabled, + observedUser.entityRecord, + observedUser.isLoading, + observedUser.refetchEntityStore, + ] + ); + + const displayEntityId = useMemo( + () => (entityStoreV2Enabled ? observedUser.entityRecord?.entity?.id : entityId), + [entityId, entityStoreV2Enabled, observedUser.entityRecord?.entity?.id] + ); + const [rawFilteredQuery, kqlError] = useMemo(() => { try { return [ @@ -143,18 +337,51 @@ const UsersDetailsComponent: React.FC<UsersDetailsProps> = ({ 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, oldSourcererDataViewSpec, query, + usersDetailsEventsPageFilters, + globalFilters, uiSettings, - usersDetailsPageFilters, ]); + const stringifiedUserDetailsIdentityFilterQuery = useMemo( + () => + rawFilteredQueryForUserDetailsIdentity != null + ? JSON.stringify(rawFilteredQueryForUserDetailsIdentity) + : undefined, + [rawFilteredQueryForUserDetailsIdentity] + ); + const stringifiedAdditionalFilters = JSON.stringify(rawFilteredQuery); useInvalidFilterQuery({ - id: QUERY_ID, + id: USERS_DETAILS_OVERVIEW_QUERY_ID, filterQuery: stringifiedAdditionalFilters, kqlError, query, @@ -166,32 +393,8 @@ const UsersDetailsComponent: React.FC<UsersDetailsProps> = ({ dispatch(setUsersDetailsTablesActivePageToZero()); }, [dispatch, detailName]); - const [loading, { inspect, userDetails, refetch }] = useObservedUserDetails({ - id: QUERY_ID, - endDate: to, - startDate: from, - userName: detailName, - indexNames: selectedPatterns, - skip: selectedPatterns.length === 0, - }); - - const capabilities = useMlCapabilities(); - - useQueryInspector({ setQuery, deleteQuery, refetch, inspect, loading, queryId: QUERY_ID }); - - const narrowDateRange = useCallback<UserSummaryProps['narrowDateRange']>( - (score, interval) => { - const fromTo = scoreIntervalToDateTime(score, interval); - dispatch( - setAbsoluteRangeDatePicker({ - id: InputsModelId.global, - from: fromTo.from, - to: fromTo.to, - }) - ); - }, - [dispatch] - ); + const { hasAlertsRead, hasIndexRead } = useAlertsPrivileges(); + const canReadAlerts = hasAlertsRead && hasIndexRead; const entityFilter = useMemo( () => ({ @@ -201,7 +404,19 @@ const UsersDetailsComponent: React.FC<UsersDetailsProps> = ({ [detailName] ); - const entity = useMemo(() => ({ type: EntityType.user, name: detailName }), [detailName]); + const additionalFilters = useMemo( + () => (rawFilteredQuery ? [rawFilteredQuery] : []), + [rawFilteredQuery] + ); + + const entity = useMemo( + () => ({ + type: EntityType.user as const, + name: detailName, + identifiers: resolvedIdentityFields, + }), + [detailName, resolvedIdentityFields] + ); const privileges = useAssetCriticalityPrivileges(entity.name); const refetchRiskScore = useRefetchOverviewPageRiskScore(USER_OVERVIEW_RISK_SCORE_QUERY_ID); @@ -209,17 +424,20 @@ const UsersDetailsComponent: React.FC<UsersDetailsProps> = ({ onSuccess: refetchRiskScore, }); - const additionalFilters = useMemo( - () => (rawFilteredQuery ? [rawFilteredQuery] : []), - [rawFilteredQuery] + const handleSaveAssetCriticalityViaEntityStore = useCallback( + async (updatedRecord: Entity) => { + await bulkUpdateEntities(http, { + entityType: 'user', + body: updatedRecord as Record<string, unknown>, + force: true, + }); + applyEntityStoreSearchCachePatch(queryClient, 'user', updatedRecord as EntityStoreRecord); + calculateEntityRiskScore(); + }, + [http, queryClient, calculateEntityRiskScore] ); const canReadAssetCriticality = !!privileges.data?.has_read_permissions; - const criticality = useAssetCriticalityData({ - entity, - enabled: canReadAssetCriticality, - onChange: calculateEntityRiskScore, - }); if (newDataViewPickerEnabled && status === 'pristine') { return <PageLoader />; @@ -238,93 +456,181 @@ const UsersDetailsComponent: React.FC<UsersDetailsProps> = ({ /> </FiltersGlobal> - <SecuritySolutionPageWrapper noPadding={globalFullScreen}> - <HeaderPage - subtitle={ - <LastEventTime - indexKey={LastEventIndexKey.userDetails} - indexNames={selectedPatterns} - userName={detailName} - /> - } - title={detailName} - /> + <SecuritySolutionPageWrapper + noPadding={globalFullScreen} + data-test-subj="usersDetailsPage" + > + <Display show={!globalFullScreen}> + <HeaderPage + border + subtitle={ + <LastEventTime + indexKey={LastEventIndexKey.userDetails} + indexNames={selectedPatterns} + userName={detailName} + /> + } + title={detailName} + titleNode={ + <UserDetailsHeaderTitle + detailName={detailName} + displayEntityId={displayEntityId} + /> + } + /> + {noEntityInStore && ( + <> + <EuiCallOut + title={NO_CORRESPONDING_ENTITY_EXISTS} + color="warning" + iconType="warning" + data-test-subj="user-details-no-entity-warning" + announceOnMount + /> + <EuiSpacer size="m" /> + <UserObservedDataSection + userName={detailName} + identityFields={resolvedIdentityFields} + observedUser={observedUser} + contextID={PageScope.explore} + scopeId={PageScope.explore} + queryId={USER_PANEL_OBSERVED_USER_QUERY_ID} + /> + <EuiHorizontalRule /> + <EuiSpacer /> + </> + )} + <UserDetailsAssetCriticalitySection + canRead={canReadAssetCriticality} + detailName={detailName} + entityStoreV2Enabled={entityStoreV2Enabled} + noEntityInStore={noEntityInStore} + observedUserEntityRecord={observedUser.entityRecord} + storeRecord={entityFromStoreResult.entityRecord} + onSaveViaEntityStore={handleSaveAssetCriticalityViaEntityStore} + onCriticalityChange={calculateEntityRiskScore} + /> + {!noEntityInStore && ( + <> + <AnomalyTableProvider + criteriaFields={getCriteriaFromUsersType( + UsersType.details, + detailName, + resolvedIdentityFields, + euidApi?.euid + )} + filterQuery={buildAnomaliesTableInfluencersFilterQuery({ + euid: euidApi?.euid, + entityType: 'user', + isScopedToEntity: true, + identityFields: resolvedIdentityFields, + fallbackDisplayName: detailName, + })} + startDate={from} + endDate={to} + skip={isInitializing} + > + {({ isLoadingAnomaliesData, anomaliesData, jobNameById }) => ( + <UserOverviewManage + id={id} + isInDetailsSidePanel={false} + data={userDetailsForOverview as UserItem} + anomaliesData={anomaliesData} + isLoadingAnomaliesData={isLoadingAnomaliesData} + loading={isUserOverviewLoading} + startDate={from} + endDate={to} + narrowDateRange={narrowDateRange} + setQuery={setQuery} + refetch={ + entityStoreV2Enabled + ? observedUser.refetchEntityStore ?? + observedUser.refetchObservedDetails ?? + refetch + : refetch + } + inspect={ + entityStoreV2Enabled + ? entityFromStoreResult?.inspect ?? + observedUser.observedDetailsInspect ?? + inspect + : inspect + } + userName={detailName} + indexPatterns={ + entityStoreV2Enabled ? securityDefaultPatterns : selectedPatterns + } + jobNameById={jobNameById} + scopeId={PageScope.explore} + riskScoreState={userRiskScoreStateFromEntityStore} + firstSeenFromEntityStore={ + entityStoreV2Enabled + ? observedUser.firstSeen?.date ?? undefined + : undefined + } + lastSeenFromEntityStore={ + entityStoreV2Enabled + ? observedUser.lastSeen?.date ?? undefined + : undefined + } + /> + )} + </AnomalyTableProvider> + <EuiHorizontalRule /> + <EuiSpacer /> + </> + )} - {canReadAssetCriticality && ( - <> - <EuiHorizontalRule margin="m" /> - <AssetCriticalityTitle /> - <EuiSpacer size="s" /> - <AssetCriticalitySelector compressed criticality={criticality} entity={entity} /> - <EuiHorizontalRule margin="m" /> - </> - )} - - <AnomalyTableProvider - criteriaFields={getCriteriaFromUsersType(UsersType.details, detailName)} - startDate={from} - endDate={to} - skip={isInitializing} - > - {({ isLoadingAnomaliesData, anomaliesData, jobNameById }) => ( - <UserOverview - userName={detailName} - id={QUERY_ID} - isInDetailsSidePanel={false} - data={userDetails} - anomaliesData={anomaliesData} - isLoadingAnomaliesData={isLoadingAnomaliesData} - loading={loading} - startDate={from} - endDate={to} - narrowDateRange={narrowDateRange} - indexPatterns={selectedPatterns} - jobNameById={jobNameById} - scopeId={newDataViewPickerEnabled ? PageScope.explore : PageScope.default} - /> + {canReadAlerts && ( + <> + <EuiFlexGroup> + <EuiFlexItem> + <AlertsByStatus + signalIndexName={signalIndexName} + entityFilter={entityFilter} + identityFields={resolvedIdentityFields} + additionalFilters={additionalFilters} + /> + </EuiFlexItem> + <EuiFlexItem> + <AlertCountByRuleByStatus + entityFilter={{ ...entityFilter, entityType: EntityType.user }} + identityFields={resolvedIdentityFields} + signalIndexName={signalIndexName} + additionalFilters={additionalFilters} + /> + </EuiFlexItem> + </EuiFlexGroup> + <EuiSpacer /> + </> )} - </AnomalyTableProvider> - <EuiHorizontalRule /> - <EuiSpacer /> - - {canReadAlerts && ( - <> - <EuiFlexGroup> - <EuiFlexItem> - <AlertsByStatus - signalIndexName={signalIndexName} - entityFilter={entityFilter} - additionalFilters={additionalFilters} - /> - </EuiFlexItem> - <EuiFlexItem> - <AlertCountByRuleByStatus - entityFilter={entityFilter} - signalIndexName={signalIndexName} - additionalFilters={additionalFilters} - /> - </EuiFlexItem> - </EuiFlexGroup> - <EuiSpacer /> - </> - )} - - <TabNavigation - navTabs={navTabsUsersDetails(detailName, hasMlUserPermissions(capabilities))} - /> - <EuiSpacer /> + + <TabNavigation + navTabs={navTabsUsersDetails(detailName, hasMlUserPermissions(capabilities), { + entityId, + identityFields: resolvedIdentityFields, + urlStateQuery, + })} + /> + + <EuiSpacer /> + </Display> + <UsersDetailsTabs - deleteQuery={deleteQuery} - detailName={detailName} - filterQuery={stringifiedAdditionalFilters} - from={from} indexNames={selectedPatterns} isInitializing={isInitializing} - userDetailFilter={usersDetailsPageFilters} - setQuery={setQuery} + deleteQuery={deleteQuery} + userDetailFilter={usersDetailsEventsPageFilters} + userDetailsIdentityFilterQuery={stringifiedUserDetailsIdentityFilterQuery} to={to} + from={from} + detailName={detailName} type={UsersType.details} + setQuery={setQuery} + filterQuery={stringifiedAdditionalFilters} usersDetailsPagePath={usersDetailsPagePath} + identityFields={resolvedIdentityFields} + entityId={entityId} /> </SecuritySolutionPageWrapper> </> diff --git a/x-pack/solutions/security/plugins/security_solution/public/explore/users/pages/details/nav_tabs.tsx b/x-pack/solutions/security/plugins/security_solution/public/explore/users/pages/details/nav_tabs.tsx index c05f1562975a0..3d054b7b956d3 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/explore/users/pages/details/nav_tabs.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/explore/users/pages/details/nav_tabs.tsx @@ -10,39 +10,52 @@ import * as i18n from '../translations'; import type { UsersDetailsNavTab } from './types'; import { UsersTableType } from '../../store/model'; import { USERS_PATH } from '../../../../../common/constants'; - -const getTabsOnUsersDetailsUrl = (userName: string, tabName: UsersTableType) => - `${USERS_PATH}/name/${userName}/${tabName}`; +import { getTabsOnUsersDetailsUrl } from '../../../../common/components/link_to'; export const navTabsUsersDetails = ( userName: string, - hasMlUserPermissions: boolean + hasMlUserPermissions: boolean, + options?: { + entityId?: string; + identityFields?: Record<string, string>; + urlStateQuery?: string; + } ): UsersDetailsNavTab => { - const hiddenTabs = []; + const { entityId, identityFields, urlStateQuery = '' } = options ?? {}; + const hiddenTabs: UsersTableType[] = []; + + const hrefFor = (tabName: UsersTableType) => + `${USERS_PATH}${getTabsOnUsersDetailsUrl( + userName, + tabName, + urlStateQuery, + entityId, + identityFields + )}`; const userDetailsNavTabs = { [UsersTableType.events]: { id: UsersTableType.events, name: i18n.NAVIGATION_EVENTS_TITLE, - href: getTabsOnUsersDetailsUrl(userName, UsersTableType.events), + href: hrefFor(UsersTableType.events), disabled: false, }, [UsersTableType.authentications]: { id: UsersTableType.authentications, name: i18n.NAVIGATION_AUTHENTICATIONS_TITLE, - href: getTabsOnUsersDetailsUrl(userName, UsersTableType.authentications), + href: hrefFor(UsersTableType.authentications), disabled: false, }, [UsersTableType.anomalies]: { id: UsersTableType.anomalies, name: i18n.NAVIGATION_ANOMALIES_TITLE, - href: getTabsOnUsersDetailsUrl(userName, UsersTableType.anomalies), + href: hrefFor(UsersTableType.anomalies), disabled: false, }, [UsersTableType.risk]: { id: UsersTableType.risk, name: i18n.NAVIGATION_RISK_TITLE, - href: getTabsOnUsersDetailsUrl(userName, UsersTableType.risk), + href: hrefFor(UsersTableType.risk), disabled: false, }, }; 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 65ee492c584c6..102f38046de8e 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 @@ -18,11 +18,15 @@ import type { usersModel } from '../../store'; interface UsersDetailsComponentReduxProps { query: Query; filters: Filter[]; + entityId?: string; + identityFields?: Record<string, string>; } interface UserBodyComponentDispatchProps { detailName: string; usersDetailsPagePath: string; + entityId?: string; + identityFields?: Record<string, string>; } interface UsersDetailsComponentDispatchProps extends UserBodyComponentDispatchProps { @@ -32,6 +36,8 @@ interface UsersDetailsComponentDispatchProps extends UserBodyComponentDispatchPr export interface UsersDetailsProps { detailName: string; usersDetailsPagePath: string; + entityId?: string; + identityFields?: Record<string, string>; } export type UsersDetailsComponentProps = UsersDetailsComponentReduxProps & @@ -46,6 +52,14 @@ export type UsersDetailsTabsProps = UserBodyComponentDispatchProps & UsersQueryProps & { indexNames: string[]; 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. + */ + userDetailsIdentityFilterQuery?: string; filterQuery?: string; type: usersModel.UsersType; + entityId?: string; + identityFields?: Record<string, string>; }; diff --git a/x-pack/solutions/security/plugins/security_solution/public/explore/users/pages/index.tsx b/x-pack/solutions/security/plugins/security_solution/public/explore/users/pages/index.tsx index 9f4787cff2886..b4dc9702bfb6a 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/explore/users/pages/index.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/explore/users/pages/index.tsx @@ -10,11 +10,21 @@ import { Redirect } from 'react-router-dom'; import { Routes, Route } from '@kbn/shared-ux-router'; import { USERS_PATH } from '../../../../common/constants'; +import { + mergeEntityResolutionIntoUrlState, + parseEntityIdentifiersFromUrlParam, + parseEntityResolutionFromUrlState, +} from '../../../common/components/link_to'; import { UsersTableType } from '../store/model'; import { Users } from './users'; import { UsersDetails } from './details'; import { usersDetailsPagePath, usersDetailsTabPath, usersTabPath } from './constants'; +const USERS_DETAILS_TAB_NAMES = `${UsersTableType.authentications}|${UsersTableType.anomalies}|${UsersTableType.events}|${UsersTableType.risk}`; + +/** Legacy URLs with a base64 entity segment between user name and tab. */ +const usersDetailsLegacyEntityTabPath = `${usersDetailsPagePath}/:legacyEntityIdentifiers/:tabName(${USERS_DETAILS_TAB_NAMES})`; + export const UsersContainer = React.memo(() => { return ( <Routes> @@ -32,20 +42,53 @@ export const UsersContainer = React.memo(() => { /> )} /> + <Route + path={usersDetailsLegacyEntityTabPath} + render={({ + match: { + params: { detailName, legacyEntityIdentifiers, tabName }, + }, + location, + }) => { + const { entityId, identityFields } = + parseEntityIdentifiersFromUrlParam(legacyEntityIdentifiers); + const urlStateQuery = mergeEntityResolutionIntoUrlState(location.search, { + entityId, + identityFields, + displayName: decodeURIComponent(detailName), + entityType: 'user', + }); + return ( + <Redirect + to={{ + pathname: `${USERS_PATH}/name/${detailName}/${tabName}`, + search: urlStateQuery.replace(/^\?/, ''), + }} + /> + ); + }} + /> <Route path={usersDetailsTabPath} render={({ match: { params: { detailName }, }, - }) => ( - <UsersDetails - usersDetailsPagePath={usersDetailsPagePath} - detailName={decodeURIComponent(detailName)} - /> - )} + location, + }) => { + const { entityId, identityFields } = parseEntityResolutionFromUrlState(location.search); + return ( + <UsersDetails + usersDetailsPagePath={usersDetailsPagePath} + detailName={decodeURIComponent(detailName)} + entityId={entityId} + identityFields={identityFields} + /> + ); + }} /> <Route // Redirect to the first tab when tabName is not present. + exact path={usersDetailsPagePath} render={({ match: { diff --git a/x-pack/solutions/security/plugins/security_solution/public/explore/users/pages/navigation/all_users_query_tab_body.test.tsx b/x-pack/solutions/security/plugins/security_solution/public/explore/users/pages/navigation/all_users_query_tab_body.test.tsx index 6879f65791733..960ab4ec4fdd9 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/explore/users/pages/navigation/all_users_query_tab_body.test.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/explore/users/pages/navigation/all_users_query_tab_body.test.tsx @@ -10,9 +10,11 @@ import { render } from '@testing-library/react'; import { TestProviders } from '../../../../common/mock'; import { useQueryToggle } from '../../../../common/containers/query_toggle'; +import { useAllEntityStoreUsers } from '../../containers/users/use_all_entity_store_users'; import { AllUsersQueryTabBody } from './all_users_query_tab_body'; import { UsersType } from '../../store/model'; +jest.mock('../../containers/users/use_all_entity_store_users'); jest.mock('../../../../common/containers/query_toggle'); jest.mock('../../../../common/lib/kibana'); @@ -40,6 +42,7 @@ jest.mock('../../../../common/containers/use_search_strategy', () => { }); describe('All users query tab body', () => { + const mockUseAllEntityStoreUsers = useAllEntityStoreUsers as jest.Mock; const mockUseQueryToggle = useQueryToggle as jest.Mock; const defaultProps = { skip: false, @@ -50,11 +53,28 @@ describe('All users query tab body', () => { type: UsersType.page, }; + const emptyUsersArgs = { + users: [], + id: 'UsersTable', + inspect: { + dsl: [], + response: [], + }, + isInspected: false, + totalCount: 0, + pageInfo: { activePage: 0, fakeTotalCount: 50, showMorePagesIndicator: false }, + loadPage: jest.fn(), + refetch: jest.fn(), + startDate: defaultProps.startDate, + endDate: defaultProps.endDate, + }; + beforeEach(() => { jest.clearAllMocks(); + mockUseAllEntityStoreUsers.mockReturnValue([false, emptyUsersArgs]); }); - it('calls search when toggleStatus=true', () => { + it('calls search when toggleStatus=true and entity store v2 is disabled', () => { mockUseQueryToggle.mockReturnValue({ toggleStatus: true, setToggleStatus: jest.fn() }); render( <TestProviders> @@ -62,9 +82,10 @@ describe('All users query tab body', () => { </TestProviders> ); expect(mockSearch).toHaveBeenCalled(); + expect(mockUseAllEntityStoreUsers.mock.calls[0][0].skip).toEqual(true); }); - it("doesn't calls search when toggleStatus=false", () => { + it("doesn't call search when toggleStatus=false", () => { mockUseQueryToggle.mockReturnValue({ toggleStatus: false, setToggleStatus: jest.fn() }); render( <TestProviders> @@ -72,5 +93,6 @@ describe('All users query tab body', () => { </TestProviders> ); expect(mockSearch).not.toHaveBeenCalled(); + expect(mockUseAllEntityStoreUsers.mock.calls[0][0].skip).toEqual(true); }); }); diff --git a/x-pack/solutions/security/plugins/security_solution/public/explore/users/pages/navigation/all_users_query_tab_body.tsx b/x-pack/solutions/security/plugins/security_solution/public/explore/users/pages/navigation/all_users_query_tab_body.tsx index e503684aa500b..e8a19d68a7f56 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/explore/users/pages/navigation/all_users_query_tab_body.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/explore/users/pages/navigation/all_users_query_tab_body.tsx @@ -7,6 +7,7 @@ import { getOr, noop } from 'lodash/fp'; import React, { useEffect, useMemo, useState } from 'react'; +import { FF_ENABLE_ENTITY_STORE_V2 } from '@kbn/entity-store/public'; import type { UsersComponentsQueryProps } from './types'; @@ -19,6 +20,8 @@ import { generateTablePaginationOptions } from '../../../components/paginated_ta import { useDeepEqualSelector } from '../../../../common/hooks/use_selector'; import { usersSelectors } from '../../store'; import { useQueryToggle } from '../../../../common/containers/query_toggle'; +import { useUiSetting } from '../../../../common/lib/kibana'; +import { useAllEntityStoreUsers } from '../../containers/users/use_all_entity_store_users'; const UsersTableManage = manageQuery(UsersTable); @@ -34,6 +37,7 @@ export const AllUsersQueryTabBody = ({ type, deleteQuery, }: UsersComponentsQueryProps) => { + const entityStoreV2Enabled = useUiSetting<boolean>(FF_ENABLE_ENTITY_STORE_V2, false) === true; const { toggleStatus } = useQueryToggle(QUERY_ID); const [querySkip, setQuerySkip] = useState(skip || !toggleStatus); useEffect(() => { @@ -43,12 +47,19 @@ export const AllUsersQueryTabBody = ({ const getUsersSelector = useMemo(() => usersSelectors.allUsersSelector(), []); const { activePage, limit, sort } = useDeepEqualSelector((state) => getUsersSelector(state)); + const commonUsersQueryArgs = { + endDate, + filterQuery, + indexNames, + startDate, + }; + const { - loading, - result: { users, pageInfo, totalCount }, + loading: legacyLoading, + result: { users: legacyUsers, pageInfo: legacyPageInfo, totalCount: legacyTotalCount }, search, - refetch, - inspect, + refetch: legacyRefetch, + inspect: legacyInspect, } = useSearchStrategy<UsersQueries.users>({ factoryQueryType: UsersQueries.users, initialResult: { @@ -61,11 +72,16 @@ export const AllUsersQueryTabBody = ({ }, }, errorMessage: i18n.ERROR_FETCHING_USERS_DATA, - abort: querySkip, + abort: querySkip || entityStoreV2Enabled, + }); + + const [entityStoreLoading, entityStoreUsersArgs] = useAllEntityStoreUsers({ + ...commonUsersQueryArgs, + skip: querySkip || !entityStoreV2Enabled, }); useEffect(() => { - if (!querySkip) { + if (!querySkip && !entityStoreV2Enabled) { search({ filterQuery, defaultIndex: indexNames, @@ -78,7 +94,25 @@ export const AllUsersQueryTabBody = ({ sort, }); } - }, [search, startDate, endDate, filterQuery, indexNames, querySkip, activePage, limit, sort]); + }, [ + search, + startDate, + endDate, + filterQuery, + indexNames, + querySkip, + activePage, + limit, + sort, + entityStoreV2Enabled, + ]); + + const loading = entityStoreV2Enabled ? entityStoreLoading : legacyLoading; + const users = entityStoreV2Enabled ? entityStoreUsersArgs.users : legacyUsers; + const pageInfo = entityStoreV2Enabled ? entityStoreUsersArgs.pageInfo : legacyPageInfo; + const totalCount = entityStoreV2Enabled ? entityStoreUsersArgs.totalCount : legacyTotalCount; + const refetch = entityStoreV2Enabled ? entityStoreUsersArgs.refetch : legacyRefetch; + const inspect = entityStoreV2Enabled ? entityStoreUsersArgs.inspect : legacyInspect; return ( <UsersTableManage @@ -88,7 +122,7 @@ export const AllUsersQueryTabBody = ({ id={QUERY_ID} inspect={inspect} loading={loading} - loadPage={noop} // It isn't necessary because PaginatedTable updates redux store and we load the page when activePage updates on the store + loadPage={noop} refetch={refetch} showMorePagesIndicator={getOr(false, 'showMorePagesIndicator', pageInfo)} setQuery={setQuery} 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 9396ff1bada12..b6480871b5111 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 @@ -20,6 +20,7 @@ const HISTOGRAM_QUERY_ID = 'usersAuthenticationsHistogramQuery'; export const AuthenticationsQueryTabBody = ({ endDate, filterQuery, + identityScopedFilterQuery, indexNames, skip, setQuery, @@ -29,12 +30,13 @@ export const AuthenticationsQueryTabBody = ({ userName, }: AuthenticationsUserTableProps) => { const newDataViewPickerEnabled = useIsExperimentalFeatureEnabled('newDataViewPickerEnabled'); + const effectiveFilterQuery = identityScopedFilterQuery ?? filterQuery; return ( <> <MatrixHistogram endDate={endDate} - filterQuery={filterQuery} + filterQuery={effectiveFilterQuery} id={HISTOGRAM_QUERY_ID} startDate={startDate} {...histogramConfigs} @@ -43,7 +45,7 @@ export const AuthenticationsQueryTabBody = ({ <AuthenticationsUserTable endDate={endDate} - filterQuery={filterQuery} + filterQuery={effectiveFilterQuery} indexNames={indexNames} setQuery={setQuery} deleteQuery={deleteQuery} diff --git a/x-pack/solutions/security/plugins/security_solution/public/explore/users/pages/navigation/types.ts b/x-pack/solutions/security/plugins/security_solution/public/explore/users/pages/navigation/types.ts index 61545928a9baf..d0378e59f7aba 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/explore/users/pages/navigation/types.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/explore/users/pages/navigation/types.ts @@ -24,4 +24,9 @@ export type UsersComponentsQueryProps = QueryTabBodyProps & { indexNames: string[]; skip: boolean; setQuery: GlobalTimeArgs['setQuery']; + /** + * User details: serialized ES query built with entity identity filters (Entity Store v2). + * Used by {@link AuthenticationsQueryTabBody} and {@link RiskDetailsTabBody} when set. + */ + identityScopedFilterQuery?: string; }; diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/left/components/user_details.tsx b/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/left/components/user_details.tsx index 12f20f2f369d6..836bb0188c8dc 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/left/components/user_details.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/document_details/left/components/user_details.tsx @@ -202,7 +202,11 @@ export const UserDetails: React.FC<UserDetailsProps> = ({ entityType: 'user', skip: !entityStoreV2Enabled || isInitializing, }); - const observedUser = useObservedUser(userName, scopeId, entityId); + const observedUser = useObservedUser( + userName, + scopeId, + entityStoreV2Enabled ? entityFromStoreResult : undefined + ); const filterQuery = useMemo( () => (userName ? buildUserNamesFilter([userName]) : undefined), @@ -229,6 +233,7 @@ export const UserDetails: React.FC<UserDetailsProps> = ({ refetch: observedUser.refetchEntityStore ?? (() => {}), isLoading: observedUser.isLoading, error: null, + inspect: entityFromStoreResult?.inspect, }) : undefined, [ @@ -236,6 +241,7 @@ export const UserDetails: React.FC<UserDetailsProps> = ({ observedUser.entityRecord, observedUser.refetchEntityStore, observedUser.isLoading, + entityFromStoreResult?.inspect, ] ); diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/host_right/components/observed_data_section.tsx b/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/host_right/components/observed_data_section.tsx index fa325c4df6788..ff065299972c8 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/host_right/components/observed_data_section.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/host_right/components/observed_data_section.tsx @@ -16,6 +16,7 @@ import { import React, { memo, useMemo } from 'react'; import { css } from '@emotion/react'; import { FormattedMessage } from '@kbn/i18n-react'; +import { useEntityStoreEuidApi } from '@kbn/entity-store/public'; import { hostToCriteria } from '../../../../common/components/ml/criteria/host_to_criteria'; import { useGlobalTime } from '../../../../common/containers/use_global_time'; import { useInstalledSecurityJobNameById } from '../../../../common/components/ml/hooks/use_installed_security_jobs'; @@ -26,6 +27,7 @@ import { useObservedHostFields } from '../hooks/use_observed_host_fields'; import { FormattedRelativePreferenceDate } from '../../../../common/components/formatted_date'; import { InspectButton, InspectButtonContainer } from '../../../../common/components/inspect'; import type { HostItem } from '../../../../../common/search_strategy'; +import { buildAnomaliesTableInfluencersFilterQuery } from '../../../../common/components/ml/anomaly/anomaly_table_euid'; import { useAnomaliesTableData } from '../../../../common/components/ml/anomaly/use_anomalies_table_data'; import type { IdentityFields } from '../../../document_details/shared/utils'; import type { EntityStoreRecord } from '../../shared/hooks/use_entity_from_store'; @@ -159,8 +161,18 @@ const ObservedDataSectionContent = memo( const { jobNameById } = useInstalledSecurityJobNameById(); const jobIds = useMemo(() => Object.keys(jobNameById), [jobNameById]); + const euidApi = useEntityStoreEuidApi(); + const euid = euidApi?.euid; + const hostNameFallback = observedHost.details?.host?.name?.[0]; const [isLoadingAnomaliesData, anomaliesData] = useAnomaliesTableData({ - criteriaFields: hostToCriteria(observedHost.details), + criteriaFields: hostToCriteria(observedHost.details, euid), + filterQuery: buildAnomaliesTableInfluencersFilterQuery({ + euid, + entityType: 'host', + isScopedToEntity: true, + identityFields, + fallbackDisplayName: hostNameFallback, + }), startDate: from, endDate: to, skip: isInitializing, diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/host_right/header.tsx b/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/host_right/header.tsx index 102a116c79bde..388d89034b6fb 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/host_right/header.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/host_right/header.tsx @@ -22,18 +22,25 @@ import { PreferenceFormattedDate } from '../../../common/components/formatted_da import { FlyoutHeader } from '../../shared/components/flyout_header'; import { FlyoutTitle } from '../../../flyout_v2/shared/components/flyout_title'; import type { FirstLastSeenData } from '../shared/components/observed_entity/types'; +import type { IdentityFields } from '../../document_details/shared/utils'; interface HostPanelHeaderProps { hostName: string; lastSeen: FirstLastSeenData; entityId?: string; + identityFields?: IdentityFields; } const linkTitleCSS = { width: 'fit-content' }; const urlParamOverride = { timeline: { isOpen: false } }; -export const HostPanelHeader = ({ hostName, lastSeen, entityId }: HostPanelHeaderProps) => { +export const HostPanelHeader = ({ + hostName, + lastSeen, + entityId, + identityFields, +}: HostPanelHeaderProps) => { const lastSeenDate = lastSeen?.date; const isLoading = lastSeen?.isLoading ?? false; const lastSeenDateFormatted = useMemo( @@ -67,7 +74,14 @@ export const HostPanelHeader = ({ hostName, lastSeen, entityId }: HostPanelHeade <EuiFlexItem grow={false}> <SecuritySolutionLinkAnchor deepLinkId={SecurityPageName.hosts} - path={getHostDetailsUrl(hostName)} + path={getHostDetailsUrl( + hostName, + undefined, + entityId, + identityFields && Object.keys(identityFields).length > 0 + ? identityFields + : undefined + )} target={'_blank'} external={false} css={linkTitleCSS} 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 491473c3e71b8..398781834896f 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 @@ -289,7 +289,8 @@ export const HostPanel = ({ <HostPanelHeader hostName={hostName} lastSeen={observedHost.lastSeen} - entityId={entityStoreV2Enabled ? panelDisplayEntityId : undefined} + entityId={panelDisplayEntityId} + identityFields={documentEntityIdentifiers} /> {noEntityInStore && ( <EuiCallOut 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 44f0e4aec1d1e..36b59660db930 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 @@ -16,12 +16,14 @@ import { import React, { memo, useMemo } from 'react'; import { css } from '@emotion/react'; import { FormattedMessage } from '@kbn/i18n-react'; +import { useEntityStoreEuidApi } from '@kbn/entity-store/public'; import { useInstalledSecurityJobNameById } from '../../../../common/components/ml/hooks/use_installed_security_jobs'; import { ONE_WEEK_IN_HOURS } from '../../shared/constants'; import { ObservedEntity } from '../../shared/components/observed_entity'; import { useObservedUserItems } from '../hooks/use_observed_user_items'; import { FormattedRelativePreferenceDate } from '../../../../common/components/formatted_date'; import { InspectButton, InspectButtonContainer } from '../../../../common/components/inspect'; +import { buildAnomaliesTableInfluencersFilterQuery } from '../../../../common/components/ml/anomaly/anomaly_table_euid'; import { useAnomaliesTableData } from '../../../../common/components/ml/anomaly/use_anomalies_table_data'; import { useGlobalTime } from '../../../../common/containers/use_global_time'; import { getCriteriaFromUsersType } from '../../../../common/components/ml/criteria/get_criteria_from_users_type'; @@ -159,8 +161,17 @@ const ObservedDataSectionContent = memo( const { jobNameById } = useInstalledSecurityJobNameById(); const jobIds = useMemo(() => Object.keys(jobNameById), [jobNameById]); + const euidApi = useEntityStoreEuidApi(); + const euid = euidApi?.euid; const [isLoadingAnomaliesData, anomaliesData] = useAnomaliesTableData({ - criteriaFields: getCriteriaFromUsersType(UsersType.details, userName), + criteriaFields: getCriteriaFromUsersType(UsersType.details, userName, identityFields, euid), + filterQuery: buildAnomaliesTableInfluencersFilterQuery({ + euid, + entityType: 'user', + isScopedToEntity: true, + identityFields, + fallbackDisplayName: userName, + }), startDate: from, endDate: to, skip: isInitializing, diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/user_right/content.tsx b/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/user_right/content.tsx index 8d7df45ea5077..acdf1437c8282 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/user_right/content.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/user_right/content.tsx @@ -13,11 +13,10 @@ import { ObservedDataSection } from './components/observed_data_section'; import { useIsExperimentalFeatureEnabled } from '../../../common/hooks/use_experimental_features'; import { EntityHighlightsAccordion } from '../../../entity_analytics/components/entity_details_flyout/components/entity_highlights'; import { AssetCriticalityAccordion } from '../../../entity_analytics/components/asset_criticality/asset_criticality_selector'; -import { OBSERVED_USER_QUERY_ID } from '../../../explore/users/containers/users/observed_details'; import { FlyoutRiskSummary } from '../../../entity_analytics/components/risk_summary_flyout/risk_summary'; import type { RiskScoreState } from '../../../entity_analytics/api/hooks/use_risk_score'; import { EntityIdentifierFields, EntityType } from '../../../../common/entity_analytics/types'; -import { USER_PANEL_RISK_SCORE_QUERY_ID } from '.'; +import { USER_PANEL_OBSERVED_USER_QUERY_ID, USER_PANEL_RISK_SCORE_QUERY_ID } from '.'; import { FlyoutBody } from '../../shared/components/flyout_body'; import type { EntityDetailsPath } from '../shared/components/left_panel/left_panel_header'; import { EntityInsight } from '../../../cloud_security_posture/components/entity_insight'; @@ -123,7 +122,7 @@ export const UserPanelContent = ({ observedUser={observedUser} contextID={contextID} scopeId={scopeId} - queryId={OBSERVED_USER_QUERY_ID} + queryId={USER_PANEL_OBSERVED_USER_QUERY_ID} /> <EuiHorizontalRule margin="m" /> </FlyoutBody> diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/user_right/header.tsx b/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/user_right/header.tsx index a84a2fd8ddc09..2097888055967 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/user_right/header.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/user_right/header.tsx @@ -26,12 +26,14 @@ import { FlyoutHeader } from '../../shared/components/flyout_header'; import { FlyoutTitle } from '../../../flyout_v2/shared/components/flyout_title'; import type { FirstLastSeenData } from '../shared/components/observed_entity/types'; import type { ManagedUserData } from '../shared/hooks/use_managed_user'; +import type { IdentityFields } from '../../document_details/shared/utils'; interface UserPanelHeaderProps { userName: string; managedUser: ManagedUserData; lastSeen: FirstLastSeenData; entityId?: string; + identityFields?: IdentityFields; } const linkTitleCSS = { width: 'fit-content' }; @@ -42,6 +44,7 @@ export const UserPanelHeader = ({ managedUser, lastSeen, entityId, + identityFields, }: UserPanelHeaderProps) => { const oktaTimestamp = managedUser.data?.[ManagedUserDatasetKey.OKTA]?.fields?.[ '@timestamp' @@ -88,7 +91,15 @@ export const UserPanelHeader = ({ <EuiFlexItem grow={false}> <SecuritySolutionLinkAnchor deepLinkId={SecurityPageName.users} - path={getTabsOnUsersDetailsUrl(userName, UsersTableType.events, undefined)} + path={getTabsOnUsersDetailsUrl( + userName, + UsersTableType.events, + undefined, + entityId, + identityFields && Object.keys(identityFields).length > 0 + ? identityFields + : undefined + )} target={'_blank'} external={false} css={linkTitleCSS} diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/user_right/hooks/use_observed_user.ts b/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/user_right/hooks/use_observed_user.ts index b40c3d0ba4446..87e06fef7cb3a 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/user_right/hooks/use_observed_user.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/user_right/hooks/use_observed_user.ts @@ -7,12 +7,8 @@ import { useMemo } from 'react'; import { useSelector } from 'react-redux'; -import { FF_ENABLE_ENTITY_STORE_V2 } from '@kbn/entity-store/public'; import { useDeepEqualSelector } from '../../../../common/hooks/use_selector'; -import { inputsSelectors, type inputsModel } from '../../../../common/store'; -import { useQueryInspector } from '../../../../common/components/page/manage_query'; -import type { EntityStoreRecord } from '../../shared/hooks/use_entity_from_store'; -import type { ObservedEntityData } from '../../shared/components/observed_entity/types'; +import { inputsSelectors, sourcererSelectors, type inputsModel } from '../../../../common/store'; import { useObservedUserDetails } from '../../../../explore/users/containers/users/observed_details'; import type { UserItem } from '../../../../../common/search_strategy'; import { Direction, NOT_EVENT_KIND_ASSET_FILTER } from '../../../../../common/search_strategy'; @@ -21,10 +17,14 @@ import { useFirstLastSeen } from '../../../../common/containers/use_first_last_s import { isActiveTimeline } from '../../../../helpers'; import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features'; import { useSecurityDefaultPatterns } from '../../../../data_view_manager/hooks/use_security_default_patterns'; -import { sourcererSelectors } from '../../../../sourcerer/store'; -import { useUiSetting } from '../../../../common/lib/kibana'; +import { useQueryInspector } from '../../../../common/components/page/manage_query'; import type { InspectResponse } from '../../../../types'; -import { useEntityFromStore } from '../../shared/hooks/use_entity_from_store'; +import type { + EntityStoreRecord, + EntityFromStoreResult, +} from '../../shared/hooks/use_entity_from_store'; +import type { ObservedEntityData } from '../../shared/components/observed_entity/types'; +import { USER_PANEL_OBSERVED_USER_QUERY_ID, USER_PANEL_RISK_SCORE_QUERY_ID } from '..'; export type ObservedUserResult = Omit<ObservedEntityData<UserItem>, 'anomalies'> & { entityRecord?: EntityStoreRecord | null; @@ -38,7 +38,7 @@ export type ObservedUserResult = Omit<ObservedEntityData<UserItem>, 'anomalies'> export const useObservedUser = ( userName: string, scopeId: string, - entityId?: string + entityFromStore?: EntityFromStoreResult<UserItem> | null ): ObservedUserResult => { const timelineTime = useDeepEqualSelector((state) => inputsSelectors.timelineTimeRangeSelector(state) @@ -47,7 +47,6 @@ export const useObservedUser = ( const isActiveTimelines = isActiveTimeline(scopeId); const { to, from } = isActiveTimelines ? timelineTime : globalTime; const { isInitializing, setQuery, deleteQuery } = globalTime; - const entityStoreV2Enabled = useUiSetting<boolean>(FF_ENABLE_ENTITY_STORE_V2, false); const newDataViewPickerEnabled = useIsExperimentalFeatureEnabled('newDataViewPickerEnabled'); const oldSecurityDefaultPatterns = @@ -57,67 +56,53 @@ export const useObservedUser = ( ? experimentalSecurityDefaultIndexPatterns : oldSecurityDefaultPatterns; - const identityFieldsForStoreLookup = useMemo( - () => (!entityId && userName ? { 'user.name': userName } : undefined), - [entityId, userName] + const useEntityStoreObservedData = Boolean( + entityFromStore?.entityRecord ?? entityFromStore?.entity ); - const entityFromStore = useEntityFromStore({ - entityId, - identityFields: identityFieldsForStoreLookup, - entityType: 'user', - skip: !entityStoreV2Enabled || isInitializing, - }); - - const useEntityStoreData = - entityStoreV2Enabled && (entityFromStore.entityRecord ?? entityFromStore.entity); - - const [loadingObservedUser, { userDetails: observedUserDetails, inspect, refetch, id: queryId }] = + const [isLoading, { userDetails, inspect: inspectObservedUser, refetch: refetchUserDetails }] = useObservedUserDetails({ endDate: to, startDate: from, userName, indexNames: securityDefaultPatterns, - skip: isInitializing, + id: USER_PANEL_RISK_SCORE_QUERY_ID, + skip: isInitializing || useEntityStoreObservedData, }); useQueryInspector({ deleteQuery, - inspect: useEntityStoreData ? entityFromStore.inspect : inspect, - refetch: useEntityStoreData ? entityFromStore.refetch : refetch, + inspect: useEntityStoreObservedData ? entityFromStore?.inspect : inspectObservedUser, + loading: useEntityStoreObservedData ? entityFromStore?.isLoading ?? false : isLoading, + queryId: USER_PANEL_OBSERVED_USER_QUERY_ID, + refetch: useEntityStoreObservedData + ? entityFromStore?.refetch ?? (() => {}) + : refetchUserDetails, setQuery, - queryId, - loading: useEntityStoreData ? entityFromStore.isLoading : loadingObservedUser, }); - const [loading, { firstSeen, lastSeen }] = useFirstLastSeen({ + const [loadingFirstSeen, { firstSeen }] = useFirstLastSeen({ field: 'user.name', value: userName, defaultIndex: securityDefaultPatterns, order: Direction.asc, filterQuery: NOT_EVENT_KIND_ASSET_FILTER, - skip: !!useEntityStoreData, + skip: useEntityStoreObservedData, + }); + + const [loadingLastSeen, { lastSeen }] = useFirstLastSeen({ + field: 'user.name', + value: userName, + defaultIndex: securityDefaultPatterns, + order: Direction.desc, + filterQuery: NOT_EVENT_KIND_ASSET_FILTER, + skip: useEntityStoreObservedData, }); return useMemo((): ObservedUserResult => { - if (useEntityStoreData) { - const entityDetails = (entityFromStore.entity ?? {}) as UserItem; - const fromAggregation = observedUserDetails ?? {}; - const mergedDetails: UserItem = { - ...entityDetails, - user: { - ...entityDetails.user, - id: entityDetails.user?.id ?? fromAggregation.user?.id, - domain: entityDetails.user?.domain ?? fromAggregation.user?.domain, - email: entityDetails.user?.email ?? fromAggregation.user?.email, - full_name: entityDetails.user?.full_name ?? fromAggregation.user?.full_name, - name: entityDetails.user?.name ?? fromAggregation.user?.name, - hash: entityDetails.user?.hash ?? fromAggregation.user?.hash, - }, - host: entityDetails.host ?? fromAggregation.host, - }; + if (useEntityStoreObservedData && entityFromStore) { return { - details: mergedDetails, + details: (entityFromStore.entity ?? {}) as UserItem, isLoading: entityFromStore.isLoading, firstSeen: { date: entityFromStore.firstSeen ?? undefined, @@ -129,38 +114,31 @@ export const useObservedUser = ( }, entityRecord: entityFromStore.entityRecord ?? null, refetchEntityStore: entityFromStore.refetch, - observedDetailsInspect: inspect, - refetchObservedDetails: refetch, + observedDetailsInspect: undefined, + refetchObservedDetails: undefined, }; } return { - details: observedUserDetails, - isLoading: loadingObservedUser || loading, + details: userDetails, + isLoading: isLoading || loadingLastSeen || loadingFirstSeen, firstSeen: { date: firstSeen, - isLoading: loading, + isLoading: loadingFirstSeen, }, - lastSeen: { date: lastSeen, isLoading: loading }, - entityRecord: null, - refetchEntityStore: entityStoreV2Enabled ? entityFromStore.refetch : undefined, - observedDetailsInspect: inspect, - refetchObservedDetails: refetch, + lastSeen: { date: lastSeen, isLoading: loadingLastSeen }, + observedDetailsInspect: inspectObservedUser, + refetchObservedDetails: refetchUserDetails, }; }, [ - useEntityStoreData, - observedUserDetails, - loadingObservedUser, - loading, + useEntityStoreObservedData, + entityFromStore, + userDetails, + isLoading, + loadingLastSeen, + loadingFirstSeen, firstSeen, lastSeen, - entityStoreV2Enabled, - entityFromStore.refetch, - entityFromStore.entity, - entityFromStore.isLoading, - entityFromStore.firstSeen, - entityFromStore.lastSeen, - entityFromStore.entityRecord, - inspect, - refetch, + inspectObservedUser, + refetchUserDetails, ]); }; diff --git a/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/user_right/index.tsx b/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/user_right/index.tsx index 192fd5b10ddfd..ace737757a082 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/user_right/index.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/flyout/entity_details/user_right/index.tsx @@ -77,6 +77,7 @@ export interface UserPanelExpandableFlyoutProps extends FlyoutPanelProps { export const UserPreviewPanelKey: UserPanelExpandableFlyoutProps['key'] = 'user-preview-panel'; export const USER_PANEL_RISK_SCORE_QUERY_ID = 'userPanelRiskScoreQuery'; +export const USER_PANEL_OBSERVED_USER_QUERY_ID = 'UserPanelObservedUserQuery'; const FIRST_RECORD_PAGINATION = { cursorStart: 0, querySize: 1, @@ -129,7 +130,11 @@ export const UserPanel = ({ () => (userName ? buildUserNamesFilter([userName]) : undefined), [userName] ); - const observedUser = useObservedUser(userName, scopeId, entityIdProp); + const observedUser = useObservedUser( + userName, + scopeId, + entityStoreV2Enabled ? entityFromStoreResult : undefined + ); const panelDisplayEntityId = useMemo( () => (entityStoreV2Enabled ? observedUser.entityRecord?.entity?.id : entityIdProp), @@ -285,7 +290,8 @@ export const UserPanel = ({ lastSeen={observedUser.lastSeen} managedUser={managedUser} userName={userName} - entityId={entityStoreV2Enabled ? panelDisplayEntityId : undefined} + entityId={panelDisplayEntityId} + identityFields={documentEntityIdentifiers} /> {noEntityInStore && ( <EuiCallOut diff --git a/x-pack/solutions/security/plugins/security_solution/public/overview/components/detection_response/alerts_by_status/alerts_by_status.tsx b/x-pack/solutions/security/plugins/security_solution/public/overview/components/detection_response/alerts_by_status/alerts_by_status.tsx index 2af6f263c48cb..e0009282c7267 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/overview/components/detection_response/alerts_by_status/alerts_by_status.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/overview/components/detection_response/alerts_by_status/alerts_by_status.tsx @@ -61,6 +61,7 @@ import { useAlertsByStatusVisualizationData } from './use_alerts_by_status_visua import { DETECTION_RESPONSE_ALERTS_BY_STATUS_ID } from './types'; import type { Status } from '../../../../../common/api/detection_engine'; import { getRiskSeverityColors } from '../../../../common/utils/risk_color_palette'; +import { resolveEntityIdentifiers } from '../../../../common/utils/resolve_entity_identifiers_for_alerts'; const StyledFlexItem = styled(EuiFlexItem)` padding: 0 4px; @@ -79,29 +80,6 @@ interface AlertsByStatusProps { signalIndexName: string | null; } -/** - * Normalizes identityFields or legacy entityFilter into a single Record for queries and UI. - * Prefers identityFields when both are provided. - */ -const resolveEntityIdentifiers = ( - identityFields?: Record<string, string> | null, - entityFilter?: Filter | null -): Record<string, string> | undefined => { - if (identityFields != null && Object.keys(identityFields).length > 0) { - return identityFields; - } - if (entityFilter != null) { - const value = - typeof entityFilter.value === 'string' - ? entityFilter.value - : Array.isArray(entityFilter.value) - ? entityFilter.value[0] - : ''; - return { [entityFilter.field]: String(value) }; - } - return undefined; -}; - const getChartConfigs = (euiTheme: EuiThemeComputed) => { const palette = getRiskSeverityColors(euiTheme); diff --git a/x-pack/solutions/security/plugins/security_solution/public/overview/components/host_overview/index.tsx b/x-pack/solutions/security/plugins/security_solution/public/overview/components/host_overview/index.tsx index 276605bc968d2..01734c8c7d19f 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/overview/components/host_overview/index.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/overview/components/host_overview/index.tsx @@ -44,6 +44,7 @@ import { RiskScoreDocTooltip } from '../common'; import { useRiskScore } from '../../../entity_analytics/api/hooks/use_risk_score'; import type { RiskScoreState } from '../../../entity_analytics/api/hooks/use_risk_score'; import { PreferenceFormattedDateFromPrimitive } from '../../../common/components/formatted_date'; +import type { InspectQuery } from '../../../common/store/inputs/model'; interface HostSummaryProps { contextID?: string; // used to provide unique draggable context when viewing in the side panel @@ -80,6 +81,10 @@ const HostRiskOverviewWrapper = styled(EuiFlexGroup, { export const HOST_OVERVIEW_RISK_SCORE_QUERY_ID = 'riskInputsTabQuery'; +/** Stable references for useQueryInspector when risk data comes from the entity store (avoids render loops). */ +const ENTITY_STORE_RISK_INSPECT_PLACEHOLDER: InspectQuery = { dsl: [], response: [] }; +const noopRiskScoreRefetch = (): void => {}; + export const HostOverview = React.memo<HostSummaryProps>( ({ anomaliesData, @@ -130,10 +135,12 @@ export const HostOverview = React.memo<HostSummaryProps>( useQueryInspector({ deleteQuery, - inspect: riskScoreStateFromEntityStore ? { dsl: [], response: [] } : inspectRiskScore, + inspect: riskScoreStateFromEntityStore + ? ENTITY_STORE_RISK_INSPECT_PLACEHOLDER + : inspectRiskScore, loading: riskScoreStateFromEntityStore ? false : loadingRiskScore, queryId: HOST_OVERVIEW_RISK_SCORE_QUERY_ID, - refetch: riskScoreStateFromEntityStore ? () => {} : refetchRiskScore, + refetch: riskScoreStateFromEntityStore ? noopRiskScoreRefetch : refetchRiskScore, setQuery, }); diff --git a/x-pack/solutions/security/plugins/security_solution/public/overview/components/user_overview/index.tsx b/x-pack/solutions/security/plugins/security_solution/public/overview/components/user_overview/index.tsx index abf66cb7cc7b5..ce2a842dd7c64 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/overview/components/user_overview/index.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/overview/components/user_overview/index.tsx @@ -45,6 +45,7 @@ import type { UserItem } from '../../../../common/search_strategy/security_solut import { RiskScoreDocTooltip } from '../common'; import type { RiskScoreState } from '../../../entity_analytics/api/hooks/use_risk_score'; import { PreferenceFormattedDateFromPrimitive } from '../../../common/components/formatted_date'; +import type { InspectQuery } from '../../../common/store/inputs/model'; export interface UserSummaryProps { contextID?: string; // used to provide unique draggable context when viewing in the side panel @@ -81,6 +82,10 @@ const UserRiskOverviewWrapper = styled(EuiFlexGroup, { export const USER_OVERVIEW_RISK_SCORE_QUERY_ID = 'riskInputsTabQuery'; +/** Stable references for useQueryInspector when risk data comes from the entity store (avoids render loops). */ +const ENTITY_STORE_RISK_INSPECT_PLACEHOLDER: InspectQuery = { dsl: [], response: [] }; +const noopRiskScoreRefetch = (): void => {}; + export const UserOverview = React.memo<UserSummaryProps>( ({ anomaliesData, @@ -132,10 +137,12 @@ export const UserOverview = React.memo<UserSummaryProps>( useQueryInspector({ deleteQuery, - inspect: riskScoreStateFromEntityStore ? { dsl: [], response: [] } : inspectRiskScore, + inspect: riskScoreStateFromEntityStore + ? ENTITY_STORE_RISK_INSPECT_PLACEHOLDER + : inspectRiskScore, loading: riskScoreStateFromEntityStore ? false : loadingRiskScore, queryId: USER_OVERVIEW_RISK_SCORE_QUERY_ID, - refetch: riskScoreStateFromEntityStore ? () => {} : refetchRiskScore, + refetch: riskScoreStateFromEntityStore ? noopRiskScoreRefetch : refetchRiskScore, setQuery, }); diff --git a/x-pack/solutions/security/plugins/security_solution/public/timelines/components/timeline/body/renderers/service_name.tsx b/x-pack/solutions/security/plugins/security_solution/public/timelines/components/timeline/body/renderers/service_name.tsx index 8fc199be4b21e..72070a30e49a5 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/timelines/components/timeline/body/renderers/service_name.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/timelines/components/timeline/body/renderers/service_name.tsx @@ -102,6 +102,7 @@ const ServiceNameComponent: React.FC<Props> = ({ onClick={isInTimelineContext || !isInSecurityApp ? openServiceDetailsSidePanel : undefined} title={title} entityType={EntityType.service} + entityId={entityId} > <TruncatableText data-test-subj="draggable-truncatable-content"> {serviceName} @@ -109,13 +110,14 @@ const ServiceNameComponent: React.FC<Props> = ({ </EntityDetailsLink> ), [ + Component, serviceName, isButton, isInTimelineContext, + isInSecurityApp, openServiceDetailsSidePanel, - Component, title, - isInSecurityApp, + entityId, ] ); diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/entity_analytics/entity_store/routes/entity_crud/upsert_entities_bulk.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/entity_analytics/entity_store/routes/entity_crud/upsert_entities_bulk.ts index c29568b7fd129..153fc5d4dd550 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/entity_analytics/entity_store/routes/entity_crud/upsert_entities_bulk.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/entity_analytics/entity_store/routes/entity_crud/upsert_entities_bulk.ts @@ -5,8 +5,10 @@ * 2.0. */ +import { z } from '@kbn/zod/v4'; import { buildRouteValidationWithZod } from '@kbn/zod-helpers/v4'; import type { IKibanaResponse, Logger } from '@kbn/core/server'; +import { preprocessUpsertEntitiesBulkRequestBody } from '../../../../../../common/entity_analytics/entity_store/sanitize_entity_record_for_upsert'; import { UpsertEntitiesBulkRequestBody, UpsertEntitiesBulkRequestQuery, @@ -18,6 +20,11 @@ import { CapabilityNotEnabledError } from '../../errors/capability_not_enabled_e import type { ITelemetryEventsSender } from '../../../../telemetry/sender'; import { ENTITY_STORE_API_CALL_EVENT } from '../../../../telemetry/event_based/events'; +const UpsertEntitiesBulkRequestBodyPreprocessed = z.preprocess( + preprocessUpsertEntitiesBulkRequestBody, + UpsertEntitiesBulkRequestBody +); + export const upsertEntitiesBulk = ( router: EntityAnalyticsRoutesDeps['router'], telemetry: ITelemetryEventsSender, @@ -44,7 +51,7 @@ export const upsertEntitiesBulk = ( validate: { request: { query: buildRouteValidationWithZod(UpsertEntitiesBulkRequestQuery), - body: buildRouteValidationWithZod(UpsertEntitiesBulkRequestBody), + body: buildRouteValidationWithZod(UpsertEntitiesBulkRequestBodyPreprocessed), }, }, }, diff --git a/x-pack/solutions/security/test/security_solution_cypress/cypress/e2e/explore/urls/state.cy.ts b/x-pack/solutions/security/test/security_solution_cypress/cypress/e2e/explore/urls/state.cy.ts index 9db51acb111b4..1f83370b5cc5e 100644 --- a/x-pack/solutions/security/test/security_solution_cypress/cypress/e2e/explore/urls/state.cy.ts +++ b/x-pack/solutions/security/test/security_solution_cypress/cypress/e2e/explore/urls/state.cy.ts @@ -292,7 +292,7 @@ describe('url state', { tags: ['@ess', '@skipInServerless'] }, () => { .should('have.attr', 'href') .and( 'contain', - "/app/security/hosts/name/siem-kibana?query=(language:kuery,query:'agent.type:%20%22auditbeat%22%20')" + + "/app/security/hosts/name/siem-kibana/events?query=(language:kuery,query:'agent.type:%20%22auditbeat%22%20')" + "&timeline=(activeTab:query,isOpen:!f,query:(expression:'',kind:kuery))" + "&timerange=(global:(linkTo:!(timeline),timerange:(from:'2019-08-01T20:03:29.186Z',kind:absolute,to:'2023-01-01T21:33:29.186Z')),timeline:(linkTo:!(global),timerange:(from:'2019-08-01T20:03:29.186Z',kind:absolute,to:'2023-01-01T21:33:29.186Z')),valueReport:(linkTo:!(),timerange:(from:'2019-08-01T20:03:29.186Z',kind:absolute,to:'2019-08-01T20:33:29.186Z')))" );