diff --git a/x-pack/plugins/security_solution/common/api/entity_analytics/asset_criticality/common.gen.ts b/x-pack/plugins/security_solution/common/api/entity_analytics/asset_criticality/common.gen.ts index 3da400bdef9e0..141991327fb2d 100644 --- a/x-pack/plugins/security_solution/common/api/entity_analytics/asset_criticality/common.gen.ts +++ b/x-pack/plugins/security_solution/common/api/entity_analytics/asset_criticality/common.gen.ts @@ -40,6 +40,20 @@ export const CreateAssetCriticalityRecord = AssetCriticalityRecordIdParts.merge( * The criticality level of the asset. */ criticality_level: z.enum(['low_impact', 'medium_impact', 'high_impact', 'extreme_impact']), + /** + * If 'wait_for' the request will wait for the index refresh. + */ + refresh: z.literal('wait_for').optional(), + }) +); + +export type DeleteAssetCriticalityRecord = z.infer; +export const DeleteAssetCriticalityRecord = AssetCriticalityRecordIdParts.merge( + z.object({ + /** + * If 'wait_for' the request will wait for the index refresh. + */ + refresh: z.literal('wait_for').optional(), }) ); diff --git a/x-pack/plugins/security_solution/common/api/entity_analytics/asset_criticality/common.schema.yaml b/x-pack/plugins/security_solution/common/api/entity_analytics/asset_criticality/common.schema.yaml index 9fa0c7bcfce18..3d3e82524109d 100644 --- a/x-pack/plugins/security_solution/common/api/entity_analytics/asset_criticality/common.schema.yaml +++ b/x-pack/plugins/security_solution/common/api/entity_analytics/asset_criticality/common.schema.yaml @@ -50,8 +50,21 @@ components: type: string enum: [low_impact, medium_impact, high_impact, extreme_impact] description: The criticality level of the asset. + refresh: + type: string + enum: [wait_for] + description: If 'wait_for' the request will wait for the index refresh. required: - criticality_level + DeleteAssetCriticalityRecord: + allOf: + - $ref: '#/components/schemas/AssetCriticalityRecordIdParts' + - type: object + properties: + refresh: + type: string + enum: [wait_for] + description: If 'wait_for' the request will wait for the index refresh. AssetCriticalityRecord: allOf: - $ref: '#/components/schemas/CreateAssetCriticalityRecord' diff --git a/x-pack/plugins/security_solution/common/api/entity_analytics/risk_engine/entity_calculation_route.gen.ts b/x-pack/plugins/security_solution/common/api/entity_analytics/risk_engine/entity_calculation_route.gen.ts index c583616198313..f4d7c393f6e7f 100644 --- a/x-pack/plugins/security_solution/common/api/entity_analytics/risk_engine/entity_calculation_route.gen.ts +++ b/x-pack/plugins/security_solution/common/api/entity_analytics/risk_engine/entity_calculation_route.gen.ts @@ -28,6 +28,10 @@ export const RiskScoresEntityCalculationRequest = z.object({ * Used to define the type of entity. */ identifier_type: IdentifierType, + /** + * If 'wait_for' the request will wait for the index refresh. + */ + refresh: z.literal('wait_for').optional(), }); export type RiskScoresEntityCalculationResponse = z.infer< diff --git a/x-pack/plugins/security_solution/common/api/entity_analytics/risk_engine/entity_calculation_route.schema.yaml b/x-pack/plugins/security_solution/common/api/entity_analytics/risk_engine/entity_calculation_route.schema.yaml index de5f01f850187..1229cf0fb6615 100644 --- a/x-pack/plugins/security_solution/common/api/entity_analytics/risk_engine/entity_calculation_route.schema.yaml +++ b/x-pack/plugins/security_solution/common/api/entity_analytics/risk_engine/entity_calculation_route.schema.yaml @@ -50,6 +50,10 @@ components: identifier_type: description: Used to define the type of entity. $ref: './common.schema.yaml#/components/schemas/IdentifierType' + refresh: + type: string + enum: [wait_for] + description: If 'wait_for' the request will wait for the index refresh. RiskScoresEntityCalculationResponse: type: object diff --git a/x-pack/plugins/security_solution/public/common/hooks/use_inspect_query.test.tsx b/x-pack/plugins/security_solution/public/common/hooks/use_inspect_query.test.tsx deleted file mode 100644 index 1bf2de3242ac7..0000000000000 --- a/x-pack/plugins/security_solution/public/common/hooks/use_inspect_query.test.tsx +++ /dev/null @@ -1,85 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ -import { renderHook } from '@testing-library/react-hooks'; -import { useInspectQuery } from './use_inspect_query'; - -import { useGlobalTime } from '../containers/use_global_time'; - -jest.mock('../containers/use_global_time'); - -const QUERY_ID = 'tes_query_id'; - -const RESPONSE = { - inspect: { dsl: [], response: [] }, - isPartial: false, - isRunning: false, - total: 0, - loaded: 0, - rawResponse: { - took: 0, - timed_out: false, - _shards: { - total: 0, - successful: 0, - failed: 0, - skipped: 0, - }, - results: { - hits: { - total: 0, - }, - }, - hits: { - total: 0, - max_score: 0, - hits: [], - }, - }, - totalCount: 0, - enrichments: [], -}; - -describe('useInspectQuery', () => { - let deleteQuery: jest.Mock; - let setQuery: jest.Mock; - - beforeEach(() => { - deleteQuery = jest.fn(); - setQuery = jest.fn(); - (useGlobalTime as jest.Mock).mockImplementation(() => ({ - deleteQuery, - setQuery, - isInitializing: false, - })); - }); - - it('it calls setQuery', () => { - renderHook(() => useInspectQuery(QUERY_ID, false, RESPONSE)); - - expect(setQuery).toHaveBeenCalledTimes(1); - expect(setQuery.mock.calls[0][0].id).toBe(QUERY_ID); - }); - - it("doesn't call setQuery when response is undefined", () => { - renderHook(() => useInspectQuery(QUERY_ID, false, undefined)); - - expect(setQuery).not.toHaveBeenCalled(); - }); - - it("doesn't call setQuery when loading", () => { - renderHook(() => useInspectQuery(QUERY_ID, true)); - - expect(setQuery).not.toHaveBeenCalled(); - }); - - it('calls deleteQuery when unmouting', () => { - const result = renderHook(() => useInspectQuery(QUERY_ID, false, RESPONSE)); - result.unmount(); - - expect(deleteQuery).toHaveBeenCalledWith({ id: QUERY_ID }); - }); -}); diff --git a/x-pack/plugins/security_solution/public/common/hooks/use_inspect_query.ts b/x-pack/plugins/security_solution/public/common/hooks/use_inspect_query.ts deleted file mode 100644 index 4c0cb1c4fcdca..0000000000000 --- a/x-pack/plugins/security_solution/public/common/hooks/use_inspect_query.ts +++ /dev/null @@ -1,44 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ -import { noop } from 'lodash'; -import { useEffect } from 'react'; -import type { FactoryQueryTypes, StrategyResponseType } from '../../../common/search_strategy'; -import { getInspectResponse } from '../../helpers'; -import { useGlobalTime } from '../containers/use_global_time'; -import type { Refetch, RefetchKql } from '../store/inputs/model'; - -/** - * Add and remove query response from global input store. - */ -export const useInspectQuery = ( - id: string, - loading: boolean, - response?: StrategyResponseType, - refetch: Refetch | RefetchKql = noop -) => { - const { deleteQuery, setQuery, isInitializing } = useGlobalTime(); - - useEffect(() => { - if (!loading && !isInitializing && response?.inspect) { - setQuery({ - id, - inspect: getInspectResponse(response, { - dsl: [], - response: [], - }), - loading, - refetch, - }); - } - - return () => { - if (deleteQuery) { - deleteQuery({ id }); - } - }; - }, [deleteQuery, setQuery, loading, response, isInitializing, id, refetch]); -}; diff --git a/x-pack/plugins/security_solution/public/entity_analytics/api/api.ts b/x-pack/plugins/security_solution/public/entity_analytics/api/api.ts index fb14efde91a26..225ae72c57616 100644 --- a/x-pack/plugins/security_solution/public/entity_analytics/api/api.ts +++ b/x-pack/plugins/security_solution/public/entity_analytics/api/api.ts @@ -6,6 +6,10 @@ */ import { useMemo } from 'react'; +import type { + RiskScoresEntityCalculationRequest, + RiskScoresEntityCalculationResponse, +} from '../../../common/api/entity_analytics/risk_engine/entity_calculation_route.gen'; import type { AssetCriticalityCsvUploadResponse } from '../../../common/entity_analytics/asset_criticality/types'; import type { AssetCriticalityRecord } from '../../../common/api/entity_analytics/asset_criticality'; import type { RiskScoreEntity } from '../../../common/search_strategy'; @@ -21,6 +25,7 @@ import { RISK_SCORE_INDEX_STATUS_API_URL, RISK_ENGINE_SETTINGS_URL, ASSET_CRITICALITY_CSV_UPLOAD_URL, + RISK_SCORE_ENTITY_CALCULATION_URL, } from '../../../common/constants'; import type { @@ -97,6 +102,17 @@ export const useEntityAnalyticsRoutes = () => { method: 'POST', }); + /** + * Calculate and stores risk score for an entity + */ + const calculateEntityRiskScore = (params: RiskScoresEntityCalculationRequest) => { + return http.fetch(RISK_SCORE_ENTITY_CALCULATION_URL, { + version: '1', + method: 'POST', + body: JSON.stringify(params), + }); + }; + /** * Get risk engine privileges */ @@ -119,7 +135,9 @@ export const useEntityAnalyticsRoutes = () => { * Create asset criticality */ const createAssetCriticality = async ( - params: Pick + params: Pick & { + refresh?: 'wait_for'; + } ): Promise => http.fetch(ASSET_CRITICALITY_URL, { version: '1', @@ -128,16 +146,17 @@ export const useEntityAnalyticsRoutes = () => { id_value: params.idValue, id_field: params.idField, criticality_level: params.criticalityLevel, + refresh: params.refresh, }), }); const deleteAssetCriticality = async ( - params: Pick + params: Pick & { refresh?: 'wait_for' } ): Promise<{ deleted: true }> => { await http.fetch(ASSET_CRITICALITY_URL, { version: '1', method: 'DELETE', - query: { id_value: params.idValue, id_field: params.idField }, + query: { id_value: params.idValue, id_field: params.idField, refresh: params.refresh }, }); // spoof a response to allow us to better distnguish a delete from a create in use_asset_criticality.ts @@ -219,6 +238,7 @@ export const useEntityAnalyticsRoutes = () => { uploadAssetCriticalityFile, getRiskScoreIndexStatus, fetchRiskEngineSettings, + calculateEntityRiskScore, }; }, [http]); }; diff --git a/x-pack/plugins/security_solution/public/entity_analytics/api/hooks/use_calculate_entity_risk_score.test.ts b/x-pack/plugins/security_solution/public/entity_analytics/api/hooks/use_calculate_entity_risk_score.test.ts new file mode 100644 index 0000000000000..106fb9404372d --- /dev/null +++ b/x-pack/plugins/security_solution/public/entity_analytics/api/hooks/use_calculate_entity_risk_score.test.ts @@ -0,0 +1,103 @@ +/* + * 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 { act, renderHook } from '@testing-library/react-hooks'; +import { TestProviders } from '../../../common/mock'; +import { RiskScoreEntity } from '../../../../common/search_strategy'; +import { useCalculateEntityRiskScore } from './use_calculate_entity_risk_score'; +import { RiskEngineStatus } from '../../../../common/entity_analytics/risk_engine'; +import { waitFor } from '@testing-library/react'; + +const enabledRiskEngineStatus = { + risk_engine_status: RiskEngineStatus.ENABLED, +}; +const disabledRiskEngineStatus = { + risk_engine_status: RiskEngineStatus.DISABLED, +}; + +const mockUseRiskEngineStatus = jest.fn(); +jest.mock('./use_risk_engine_status', () => ({ + useRiskEngineStatus: () => mockUseRiskEngineStatus(), +})); + +const mockCalculateEntityRiskScore = jest.fn(); +jest.mock('../api', () => ({ + useEntityAnalyticsRoutes: () => ({ + calculateEntityRiskScore: mockCalculateEntityRiskScore, + }), +})); + +const mockAddError = jest.fn(); +jest.mock('../../../common/hooks/use_app_toasts', () => ({ + useAppToasts: jest.fn().mockReturnValue({ + addError: () => mockAddError(), + }), +})); + +const identifierType = RiskScoreEntity.user; +const identifier = 'test-user'; +const options = { + onSuccess: jest.fn(), +}; + +describe('useRiskScoreData', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUseRiskEngineStatus.mockReturnValue({ data: enabledRiskEngineStatus }); + mockCalculateEntityRiskScore.mockResolvedValue({}); + }); + + it('should call calculateEntityRiskScore API when the callback function is called', async () => { + const { result } = renderHook( + () => useCalculateEntityRiskScore(identifierType, identifier, options), + { wrapper: TestProviders } + ); + + await act(async () => { + result.current.calculateEntityRiskScore(); + + await waitFor(() => + expect(mockCalculateEntityRiskScore).toHaveBeenCalledWith( + expect.objectContaining({ + identifier_type: identifierType, + identifier, + }) + ) + ); + }); + }); + + it('should NOT call calculateEntityRiskScore API when risk engine is disabled', async () => { + mockUseRiskEngineStatus.mockReturnValue({ + data: disabledRiskEngineStatus, + }); + const { result } = renderHook( + () => useCalculateEntityRiskScore(identifierType, identifier, options), + { wrapper: TestProviders } + ); + + await act(async () => { + result.current.calculateEntityRiskScore(); + + await waitFor(() => expect(mockCalculateEntityRiskScore).not.toHaveBeenCalled()); + }); + }); + + it('should display a toast error when the API returns an error', async () => { + mockCalculateEntityRiskScore.mockRejectedValue({}); + const { result } = renderHook( + () => useCalculateEntityRiskScore(identifierType, identifier, options), + { wrapper: TestProviders } + ); + + await act(async () => { + result.current.calculateEntityRiskScore(); + + await waitFor(() => expect(mockAddError).toHaveBeenCalled()); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/entity_analytics/api/hooks/use_calculate_entity_risk_score.ts b/x-pack/plugins/security_solution/public/entity_analytics/api/hooks/use_calculate_entity_risk_score.ts new file mode 100644 index 0000000000000..ff1eb5c46a702 --- /dev/null +++ b/x-pack/plugins/security_solution/public/entity_analytics/api/hooks/use_calculate_entity_risk_score.ts @@ -0,0 +1,58 @@ +/* + * 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 } from 'react'; + +import { i18n } from '@kbn/i18n'; +import { useMutation } from '@tanstack/react-query'; +import { useEntityAnalyticsRoutes } from '../api'; +import { useAppToasts } from '../../../common/hooks/use_app_toasts'; +import { useRiskEngineStatus } from './use_risk_engine_status'; +import { RiskScoreEntity, RiskEngineStatus } from '../../../../common/entity_analytics/risk_engine'; + +export const useCalculateEntityRiskScore = ( + identifierType: RiskScoreEntity, + identifier: string, + { onSuccess }: { onSuccess: () => void } +) => { + const { addError } = useAppToasts(); + const { data: riskEngineStatus } = useRiskEngineStatus(); + const { calculateEntityRiskScore } = useEntityAnalyticsRoutes(); + + const onError = useCallback( + (error: unknown) => { + addError(error, { + title: i18n.translate('xpack.securitySolution.entityDetails.userPanel.error', { + defaultMessage: 'There was a problem calculating the {entity} risk score', + values: { entity: identifierType === RiskScoreEntity.host ? "host's" : "user's" }, + }), + }); + }, + [addError, identifierType] + ); + + const { mutate, isLoading, data } = useMutation(calculateEntityRiskScore, { + onSuccess, + onError, + }); + + const calculateEntityRiskScoreCb = useCallback(async () => { + if (riskEngineStatus?.risk_engine_status === RiskEngineStatus.ENABLED) { + mutate({ + identifier_type: identifierType, + identifier, + refresh: 'wait_for', + }); + } + }, [riskEngineStatus?.risk_engine_status, mutate, identifierType, identifier]); + + return { + isLoading, + calculateEntityRiskScore: calculateEntityRiskScoreCb, + data, + }; +}; diff --git a/x-pack/plugins/security_solution/public/entity_analytics/api/hooks/use_refetch_overview_page_risk_score.ts b/x-pack/plugins/security_solution/public/entity_analytics/api/hooks/use_refetch_overview_page_risk_score.ts new file mode 100644 index 0000000000000..bf0c3db69b53c --- /dev/null +++ b/x-pack/plugins/security_solution/public/entity_analytics/api/hooks/use_refetch_overview_page_risk_score.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 { TableId } from '@kbn/securitysolution-data-table'; +import { useCallback } from 'react'; +import type { Refetch } from '../../../common/types'; +import { useRefetchQueryById } from './use_refetch_query_by_id'; + +export const useRefetchOverviewPageRiskScore = (overviewRiskScoreQueryId: string) => { + const refetchOverviewRiskScore = useRefetchQueryById(overviewRiskScoreQueryId); + const refetchAlertsRiskInputs = useRefetchQueryById(TableId.alertsRiskInputs); + + const refetchRiskScore = useCallback(() => { + if (refetchOverviewRiskScore) { + (refetchOverviewRiskScore as Refetch)(); + } + + if (refetchAlertsRiskInputs) { + (refetchAlertsRiskInputs as Refetch)(); + } + }, [refetchAlertsRiskInputs, refetchOverviewRiskScore]); + return refetchRiskScore; +}; diff --git a/x-pack/plugins/security_solution/public/entity_analytics/api/hooks/use_refetch_query_by_id.ts b/x-pack/plugins/security_solution/public/entity_analytics/api/hooks/use_refetch_query_by_id.ts new file mode 100644 index 0000000000000..331855560da7c --- /dev/null +++ b/x-pack/plugins/security_solution/public/entity_analytics/api/hooks/use_refetch_query_by_id.ts @@ -0,0 +1,16 @@ +/* + * 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 } from 'react'; +import { useDeepEqualSelector } from '../../../common/hooks/use_selector'; +import { inputsSelectors } from '../../../common/store'; + +export const useRefetchQueryById = (QueryId: string) => { + const getGlobalQuery = useMemo(() => inputsSelectors.globalQueryByIdSelector(), []); + const { refetch } = useDeepEqualSelector((state) => getGlobalQuery(state, QueryId)); + return refetch; +}; diff --git a/x-pack/plugins/security_solution/public/entity_analytics/common/utils.ts b/x-pack/plugins/security_solution/public/entity_analytics/common/utils.ts index 69080b13631e3..3756780e18ae6 100644 --- a/x-pack/plugins/security_solution/public/entity_analytics/common/utils.ts +++ b/x-pack/plugins/security_solution/public/entity_analytics/common/utils.ts @@ -63,3 +63,8 @@ export enum HostRiskScoreQueryId { */ export const formatRiskScore = (riskScore: number) => (Math.round(riskScore * 100) / 100).toFixed(2); + +export const FIRST_RECORD_PAGINATION = { + cursorStart: 0, + querySize: 1, +}; diff --git a/x-pack/plugins/security_solution/public/entity_analytics/components/asset_criticality/asset_criticality_selector.tsx b/x-pack/plugins/security_solution/public/entity_analytics/components/asset_criticality/asset_criticality_selector.tsx index 587ef695ff94c..e29dca9d48f3d 100644 --- a/x-pack/plugins/security_solution/public/entity_analytics/components/asset_criticality/asset_criticality_selector.tsx +++ b/x-pack/plugins/security_solution/public/entity_analytics/components/asset_criticality/asset_criticality_selector.tsx @@ -39,10 +39,14 @@ import { PICK_ASSET_CRITICALITY } from './translations'; import { AssetCriticalityBadge } from './asset_criticality_badge'; import type { Entity, State } from './use_asset_criticality'; import { useAssetCriticalityData, useAssetCriticalityPrivileges } from './use_asset_criticality'; -import type { CriticalityLevelWithUnassigned } from '../../../../common/entity_analytics/asset_criticality/types'; +import type { + CriticalityLevel, + CriticalityLevelWithUnassigned, +} from '../../../../common/entity_analytics/asset_criticality/types'; interface Props { entity: Entity; + onChange?: () => void; } const AssetCriticalitySelectorComponent: React.FC<{ criticality: State; @@ -52,6 +56,15 @@ const AssetCriticalitySelectorComponent: React.FC<{ const [visible, toggleModal] = useToggle(false); const sFontSize = useEuiFontSize('s').fontSize; + const onSave = (value: CriticalityLevelWithUnassigned) => { + criticality.mutation.mutate({ + criticalityLevel: value, + idField: `${entity.type}.name`, + idValue: entity.name, + }); + toggleModal(false); + }; + return ( <> {criticality.query.isLoading || criticality.mutation.isLoading ? ( @@ -121,7 +134,11 @@ const AssetCriticalitySelectorComponent: React.FC<{ )} {visible ? ( - + ) : null} ); @@ -130,12 +147,13 @@ const AssetCriticalitySelectorComponent: React.FC<{ export const AssetCriticalitySelector = React.memo(AssetCriticalitySelectorComponent); AssetCriticalitySelector.displayName = 'AssetCriticalitySelector'; -const AssetCriticalityAccordionComponent: React.FC = ({ entity }) => { +const AssetCriticalityAccordionComponent: React.FC = ({ entity, onChange }) => { const { euiTheme } = useEuiTheme(); const privileges = useAssetCriticalityPrivileges(entity.name); const criticality = useAssetCriticalityData({ entity, enabled: !!privileges.data?.has_read_permissions, + onChange, }); if (privileges.isLoading || !privileges.data?.has_read_permissions) { @@ -191,15 +209,19 @@ export const AssetCriticalityTitle = () => ( ); interface ModalProps { - criticality: State; + initialCriticalityLevel: CriticalityLevel | undefined; toggle: (nextValue: boolean) => void; - entity: Entity; + onSave: (value: CriticalityLevelWithUnassigned) => void; } -const AssetCriticalityModal: React.FC = ({ criticality, entity, toggle }) => { +const AssetCriticalityModal: React.FC = ({ + initialCriticalityLevel, + toggle, + onSave, +}) => { const basicSelectId = useGeneratedHtmlId({ prefix: 'basicSelect' }); const [value, setNewValue] = useState( - criticality.query.data?.criticality_level ?? 'unassigned' + initialCriticalityLevel ?? 'unassigned' ); return ( @@ -228,14 +250,7 @@ const AssetCriticalityModal: React.FC = ({ criticality, entity, togg { - criticality.mutation.mutate({ - criticalityLevel: value, - idField: `${entity.type}.name`, - idValue: entity.name, - }); - toggle(false); - }} + onClick={() => onSave(value)} fill data-test-subj="asset-criticality-modal-save-btn" > diff --git a/x-pack/plugins/security_solution/public/entity_analytics/components/asset_criticality/use_asset_criticality.ts b/x-pack/plugins/security_solution/public/entity_analytics/components/asset_criticality/use_asset_criticality.ts index 403ca5c656bf0..c56b394236326 100644 --- a/x-pack/plugins/security_solution/public/entity_analytics/components/asset_criticality/use_asset_criticality.ts +++ b/x-pack/plugins/security_solution/public/entity_analytics/components/asset_criticality/use_asset_criticality.ts @@ -46,9 +46,11 @@ export const useAssetCriticalityPrivileges = ( export const useAssetCriticalityData = ({ entity, enabled = true, + onChange, }: { entity: Entity; enabled?: boolean; + onChange?: () => void; }): State => { const QC = useQueryClient(); const QUERY_KEY = [ASSET_CRITICALITY_KEY, entity.name]; @@ -71,18 +73,24 @@ export const useAssetCriticalityData = ({ >({ mutationFn: (params: Params) => { if (params.criticalityLevel === 'unassigned') { - return deleteAssetCriticality({ idField: params.idField, idValue: params.idValue }); + return deleteAssetCriticality({ + idField: params.idField, + idValue: params.idValue, + refresh: 'wait_for', + }); } return createAssetCriticality({ idField: params.idField, idValue: params.idValue, criticalityLevel: params.criticalityLevel, + refresh: 'wait_for', }); }, onSuccess: (data) => { const queryData = 'deleted' in data ? null : data; QC.setQueryData(QUERY_KEY, queryData); + onChange?.(); }, }); diff --git a/x-pack/plugins/security_solution/public/entity_analytics/components/entity_details_flyout/tabs/risk_inputs/risk_inputs_tab.tsx b/x-pack/plugins/security_solution/public/entity_analytics/components/entity_details_flyout/tabs/risk_inputs/risk_inputs_tab.tsx index 80b425bec15bb..48f004cbd7069 100644 --- a/x-pack/plugins/security_solution/public/entity_analytics/components/entity_details_flyout/tabs/risk_inputs/risk_inputs_tab.tsx +++ b/x-pack/plugins/security_solution/public/entity_analytics/components/entity_details_flyout/tabs/risk_inputs/risk_inputs_tab.tsx @@ -14,6 +14,8 @@ import { useUiSetting$ } from '@kbn/kibana-react-plugin/public'; import { ALERT_RULE_NAME } from '@kbn/rule-data-utils'; import { get } from 'lodash/fp'; +import { useGlobalTime } from '../../../../../common/containers/use_global_time'; +import { useQueryInspector } from '../../../../../common/components/page/manage_query'; import { formatRiskScore } from '../../../../common'; import type { InputAlert, @@ -45,7 +47,10 @@ const FIRST_RECORD_PAGINATION = { querySize: 1, }; +export const RISK_INPUTS_TAB_QUERY_ID = 'RiskInputsTabQuery'; + export const RiskInputsTab = ({ entityType, entityName }: RiskInputsTabProps) => { + const { setQuery, deleteQuery } = useGlobalTime(); const [selectedItems, setSelectedItems] = useState([]); const nameFilterQuery = useMemo(() => { @@ -60,6 +65,8 @@ export const RiskInputsTab = ({ entityType, entityName }: RiskInputsTabProps) => data: riskScoreData, error: riskScoreError, loading: loadingRiskScore, + inspect: inspectRiskScore, + refetch, } = useRiskScore({ riskEntity: entityType, filterQuery: nameFilterQuery, @@ -68,6 +75,15 @@ export const RiskInputsTab = ({ entityType, entityName }: RiskInputsTabProps) => skip: nameFilterQuery === undefined, }); + useQueryInspector({ + deleteQuery, + inspect: inspectRiskScore, + loading: loadingRiskScore, + queryId: RISK_INPUTS_TAB_QUERY_ID, + refetch, + setQuery, + }); + const riskScore = riskScoreData && riskScoreData.length > 0 ? riskScoreData[0] : undefined; const alerts = useRiskContributingAlerts({ riskScore }); diff --git a/x-pack/plugins/security_solution/public/entity_analytics/components/risk_summary_flyout/risk_summary.stories.tsx b/x-pack/plugins/security_solution/public/entity_analytics/components/risk_summary_flyout/risk_summary.stories.tsx index cece761cd9d6f..b48629cbbe26f 100644 --- a/x-pack/plugins/security_solution/public/entity_analytics/components/risk_summary_flyout/risk_summary.stories.tsx +++ b/x-pack/plugins/security_solution/public/entity_analytics/components/risk_summary_flyout/risk_summary.stories.tsx @@ -26,6 +26,7 @@ export const Default: Story = () => { openDetailsPanel={() => {}} riskScoreData={{ ...mockRiskScoreState, data: [] }} queryId={'testQuery'} + recalculatingScore={false} /> diff --git a/x-pack/plugins/security_solution/public/entity_analytics/components/risk_summary_flyout/risk_summary.test.tsx b/x-pack/plugins/security_solution/public/entity_analytics/components/risk_summary_flyout/risk_summary.test.tsx index c7debcdbdc965..56a84c340dee3 100644 --- a/x-pack/plugins/security_solution/public/entity_analytics/components/risk_summary_flyout/risk_summary.test.tsx +++ b/x-pack/plugins/security_solution/public/entity_analytics/components/risk_summary_flyout/risk_summary.test.tsx @@ -49,6 +49,7 @@ describe('RiskSummary', () => { riskScoreData={mockHostRiskScoreState} queryId={'testQuery'} openDetailsPanel={() => {}} + recalculatingScore={false} /> ); @@ -79,6 +80,7 @@ describe('RiskSummary', () => { riskScoreData={mockHostRiskScoreState} queryId={'testQuery'} openDetailsPanel={() => {}} + recalculatingScore={false} /> ); @@ -110,12 +112,28 @@ describe('RiskSummary', () => { riskScoreData={{ ...mockHostRiskScoreState, data: undefined }} queryId={'testQuery'} openDetailsPanel={() => {}} + recalculatingScore={false} /> ); expect(getByTestId('risk-summary-table')).toBeInTheDocument(); }); + it('risk summary header does not render link when riskScoreData is loading', () => { + const { queryByTestId } = render( + + {}} + recalculatingScore={false} + /> + + ); + + expect(queryByTestId('riskInputsTitleLink')).not.toBeInTheDocument(); + }); + it('renders visualization embeddable', () => { const { getByTestId } = render( @@ -123,6 +141,7 @@ describe('RiskSummary', () => { riskScoreData={mockHostRiskScoreState} queryId={'testQuery'} openDetailsPanel={() => {}} + recalculatingScore={false} /> ); @@ -137,6 +156,7 @@ describe('RiskSummary', () => { riskScoreData={mockHostRiskScoreState} queryId={'testQuery'} openDetailsPanel={() => {}} + recalculatingScore={false} /> ); @@ -151,6 +171,7 @@ describe('RiskSummary', () => { riskScoreData={mockHostRiskScoreState} queryId={'testQuery'} openDetailsPanel={() => {}} + recalculatingScore={false} /> ); @@ -177,6 +198,7 @@ describe('RiskSummary', () => { riskScoreData={mockHostRiskScoreState} queryId={'testQuery'} openDetailsPanel={() => {}} + recalculatingScore={false} /> ); @@ -198,6 +220,7 @@ describe('RiskSummary', () => { riskScoreData={mockUserRiskScoreState} queryId={'testQuery'} openDetailsPanel={() => {}} + recalculatingScore={false} /> ); @@ -219,6 +242,7 @@ describe('RiskSummary', () => { riskScoreData={mockUserRiskScoreState} queryId={'testQuery'} openDetailsPanel={() => {}} + recalculatingScore={false} /> ); diff --git a/x-pack/plugins/security_solution/public/entity_analytics/components/risk_summary_flyout/risk_summary.tsx b/x-pack/plugins/security_solution/public/entity_analytics/components/risk_summary_flyout/risk_summary.tsx index dc3471d46254c..b7a88353ddd4a 100644 --- a/x-pack/plugins/security_solution/public/entity_analytics/components/risk_summary_flyout/risk_summary.tsx +++ b/x-pack/plugins/security_solution/public/entity_analytics/components/risk_summary_flyout/risk_summary.tsx @@ -49,12 +49,14 @@ import { export interface RiskSummaryProps { riskScoreData: RiskScoreState; + recalculatingScore: boolean; queryId: string; openDetailsPanel: (tab: EntityDetailsLeftPanelTab) => void; } const RiskSummaryComponent = ({ riskScoreData, + recalculatingScore, queryId, openDetailsPanel, }: RiskSummaryProps) => { @@ -119,11 +121,13 @@ const RiskSummaryComponent = ({ [entityData?.name, riskData] ); + const riskDataTimestamp = riskData?.['@timestamp']; const timerange = useMemo(() => { const from = dateMath.parse(LAST_30_DAYS.from)?.toISOString() ?? LAST_30_DAYS.from; const to = dateMath.parse(LAST_30_DAYS.to)?.toISOString() ?? LAST_30_DAYS.to; return { from, to }; - }, []); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [riskDataTimestamp]); // Update the timerange whenever the risk score timestamp changes to include new entries return ( ({ defaultMessage="View risk contributions" /> ), - link: { - callback: () => openDetailsPanel(EntityDetailsLeftPanelTab.RISK_INPUTS), - tooltip: ( - - ), - }, + link: riskScoreData.loading + ? undefined + : { + callback: () => openDetailsPanel(EntityDetailsLeftPanelTab.RISK_INPUTS), + tooltip: ( + + ), + }, iconType: 'arrowStart', }} expand={{ @@ -265,6 +271,7 @@ const RiskSummaryComponent = ({ columns={columns} items={rows} compressed + loading={riskScoreData.loading || recalculatingScore} /> diff --git a/x-pack/plugins/security_solution/public/explore/hosts/pages/details/index.tsx b/x-pack/plugins/security_solution/public/explore/hosts/pages/details/index.tsx index 08260caa4eb91..dda6437ba783d 100644 --- a/x-pack/plugins/security_solution/public/explore/hosts/pages/details/index.tsx +++ b/x-pack/plugins/security_solution/public/explore/hosts/pages/details/index.tsx @@ -20,6 +20,7 @@ import type { Filter } from '@kbn/es-query'; import { buildEsQuery } from '@kbn/es-query'; import { getEsQueryConfig } from '@kbn/data-plugin/common'; import { tableDefaults, dataTableSelectors, TableId } from '@kbn/securitysolution-data-table'; +import { useCalculateEntityRiskScore } from '../../../../entity_analytics/api/hooks/use_calculate_entity_risk_score'; import { useAssetCriticalityData, useAssetCriticalityPrivileges, @@ -33,7 +34,7 @@ import { useSignalIndex } from '../../../../detections/containers/detection_engi import { useAlertsPrivileges } from '../../../../detections/containers/detection_engine/alerts/use_alerts_privileges'; import { InputsModelId } from '../../../../common/store/inputs/constants'; import type { HostItem } from '../../../../../common/search_strategy'; -import { LastEventIndexKey } from '../../../../../common/search_strategy'; +import { LastEventIndexKey, RiskScoreEntity } from '../../../../../common/search_strategy'; import { SecurityPageName } from '../../../../app/types'; import { FiltersGlobal } from '../../../../common/components/filters_global'; import { HeaderPage } from '../../../../common/components/header_page'; @@ -44,7 +45,10 @@ import { hasMlUserPermissions } from '../../../../../common/machine_learning/has 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 { HostOverview } from '../../../../overview/components/host_overview'; +import { + HostOverview, + HOST_OVERVIEW_RISK_SCORE_QUERY_ID, +} from '../../../../overview/components/host_overview'; import { SiemSearchBar } from '../../../../common/components/search_bar'; import { SecuritySolutionPageWrapper } from '../../../../common/components/page_wrapper'; import { useGlobalTime } from '../../../../common/containers/use_global_time'; @@ -75,6 +79,7 @@ import { AlertCountByRuleByStatus } from '../../../../common/components/alert_co import { useLicense } from '../../../../common/hooks/use_license'; import { ResponderActionButton } from '../../../../detections/components/endpoint_responder/responder_action_button'; import { useHasSecurityCapability } from '../../../../helper_hooks'; +import { useRefetchOverviewPageRiskScore } from '../../../../entity_analytics/api/hooks/use_refetch_overview_page_risk_score'; const ES_HOST_FIELD = 'host.name'; const HostOverviewManage = manageQuery(HostOverview); @@ -175,12 +180,26 @@ const HostDetailsComponent: React.FC = ({ detailName, hostDeta [detailName] ); + const additionalFilters = useMemo( + () => (rawFilteredQuery ? [rawFilteredQuery] : []), + [rawFilteredQuery] + ); + const entity = useMemo(() => ({ type: 'host' as const, name: detailName }), [detailName]); const privileges = useAssetCriticalityPrivileges(entity.name); + + const refetchRiskScore = useRefetchOverviewPageRiskScore(HOST_OVERVIEW_RISK_SCORE_QUERY_ID); + const { calculateEntityRiskScore } = useCalculateEntityRiskScore( + RiskScoreEntity.host, + detailName, + { onSuccess: refetchRiskScore } + ); + const canReadAssetCriticality = !!privileges.data?.has_read_permissions; const criticality = useAssetCriticalityData({ entity, enabled: canReadAssetCriticality, + onChange: calculateEntityRiskScore, }); return ( @@ -259,14 +278,14 @@ const HostDetailsComponent: React.FC = ({ detailName, hostDeta diff --git a/x-pack/plugins/security_solution/public/explore/network/pages/details/index.tsx b/x-pack/plugins/security_solution/public/explore/network/pages/details/index.tsx index f0356b9125dc3..f7e30dd47bf93 100644 --- a/x-pack/plugins/security_solution/public/explore/network/pages/details/index.tsx +++ b/x-pack/plugins/security_solution/public/explore/network/pages/details/index.tsx @@ -124,6 +124,11 @@ const NetworkDetailsComponent: React.FC = () => { } }, [globalFilters, indexPattern, networkDetailsFilter, query, uiSettings]); + const additionalFilters = useMemo( + () => (rawFilteredQuery ? [rawFilteredQuery] : []), + [rawFilteredQuery] + ); + const stringifiedAdditionalFilters = JSON.stringify(rawFilteredQuery); useInvalidFilterQuery({ id: ID, @@ -226,14 +231,14 @@ const NetworkDetailsComponent: React.FC = () => { diff --git a/x-pack/plugins/security_solution/public/explore/users/pages/details/index.tsx b/x-pack/plugins/security_solution/public/explore/users/pages/details/index.tsx index 88d9b260c8d38..4e61c7083d038 100644 --- a/x-pack/plugins/security_solution/public/explore/users/pages/details/index.tsx +++ b/x-pack/plugins/security_solution/public/explore/users/pages/details/index.tsx @@ -20,6 +20,7 @@ import { getEsQueryConfig } from '@kbn/data-plugin/common'; import type { Filter } from '@kbn/es-query'; import { buildEsQuery } from '@kbn/es-query'; import { dataTableSelectors, TableId } from '@kbn/securitysolution-data-table'; +import { useCalculateEntityRiskScore } from '../../../../entity_analytics/api/hooks/use_calculate_entity_risk_score'; import { useAssetCriticalityData, useAssetCriticalityPrivileges, @@ -60,10 +61,13 @@ import { } from '../../../../common/hooks/use_selector'; import { useInvalidFilterQuery } from '../../../../common/hooks/use_invalid_filter_query'; import { LastEventTime } from '../../../../common/components/last_event_time'; -import { LastEventIndexKey } from '../../../../../common/search_strategy'; +import { LastEventIndexKey, RiskScoreEntity } from '../../../../../common/search_strategy'; import { AnomalyTableProvider } from '../../../../common/components/ml/anomaly/anomaly_table_provider'; -import { UserOverview } from '../../../../overview/components/user_overview'; +import { + UserOverview, + USER_OVERVIEW_RISK_SCORE_QUERY_ID, +} 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'; @@ -73,6 +77,7 @@ import { hasMlUserPermissions } from '../../../../../common/machine_learning/has import { useMlCapabilities } from '../../../../common/components/ml/hooks/use_ml_capabilities'; import { EmptyPrompt } from '../../../../common/components/empty_prompt'; import { useHasSecurityCapability } from '../../../../helper_hooks'; +import { useRefetchOverviewPageRiskScore } from '../../../../entity_analytics/api/hooks/use_refetch_overview_page_risk_score'; const QUERY_ID = 'UsersDetailsQueryId'; const ES_USER_FIELD = 'user.name'; @@ -180,10 +185,24 @@ const UsersDetailsComponent: React.FC = ({ const entity = useMemo(() => ({ type: 'user' as const, name: detailName }), [detailName]); const privileges = useAssetCriticalityPrivileges(entity.name); + + const refetchRiskScore = useRefetchOverviewPageRiskScore(USER_OVERVIEW_RISK_SCORE_QUERY_ID); + const { calculateEntityRiskScore } = useCalculateEntityRiskScore( + RiskScoreEntity.user, + detailName, + { onSuccess: refetchRiskScore } + ); + + const additionalFilters = useMemo( + () => (rawFilteredQuery ? [rawFilteredQuery] : []), + [rawFilteredQuery] + ); + const canReadAssetCriticality = !!privileges.data?.has_read_permissions; const criticality = useAssetCriticalityData({ entity, enabled: canReadAssetCriticality, + onChange: calculateEntityRiskScore, }); return ( @@ -250,14 +269,14 @@ const UsersDetailsComponent: React.FC = ({ diff --git a/x-pack/plugins/security_solution/public/flyout/entity_details/host_right/content.stories.tsx b/x-pack/plugins/security_solution/public/flyout/entity_details/host_right/content.stories.tsx index 4d998153cda1b..3bca4be245646 100644 --- a/x-pack/plugins/security_solution/public/flyout/entity_details/host_right/content.stories.tsx +++ b/x-pack/plugins/security_solution/public/flyout/entity_details/host_right/content.stories.tsx @@ -35,6 +35,8 @@ storiesOf('Components/HostPanelContent', module) isDraggable={false} openDetailsPanel={() => {}} hostName={'test-host-name'} + onAssetCriticalityChange={() => {}} + recalculatingScore={false} /> )) .add('no observed data', () => ( @@ -58,6 +60,8 @@ storiesOf('Components/HostPanelContent', module) isDraggable={false} openDetailsPanel={() => {}} hostName={'test-host-name'} + onAssetCriticalityChange={() => {}} + recalculatingScore={false} /> )) .add('loading', () => ( @@ -81,5 +85,7 @@ storiesOf('Components/HostPanelContent', module) isDraggable={false} openDetailsPanel={() => {}} hostName={'test-host-name'} + onAssetCriticalityChange={() => {}} + recalculatingScore={false} /> )); diff --git a/x-pack/plugins/security_solution/public/flyout/entity_details/host_right/content.tsx b/x-pack/plugins/security_solution/public/flyout/entity_details/host_right/content.tsx index 64d5ea41898a0..a2d7b7b733ee0 100644 --- a/x-pack/plugins/security_solution/public/flyout/entity_details/host_right/content.tsx +++ b/x-pack/plugins/security_solution/public/flyout/entity_details/host_right/content.tsx @@ -26,16 +26,20 @@ interface HostPanelContentProps { isDraggable: boolean; openDetailsPanel: (tab: EntityDetailsLeftPanelTab) => void; hostName: string; + onAssetCriticalityChange: () => void; + recalculatingScore: boolean; } export const HostPanelContent = ({ hostName, observedHost, riskScoreState, + recalculatingScore, contextID, scopeId, isDraggable, openDetailsPanel, + onAssetCriticalityChange, }: HostPanelContentProps) => { const observedFields = useObservedHostFields(observedHost); @@ -45,13 +49,17 @@ export const HostPanelContent = ({ <> )} - + { expect(getByTestId('securitySolutionFlyoutNavigationExpandDetailButton')).toBeInTheDocument(); }); - it('renders loading state when risk score is loading', () => { - mockedHostRiskScore.mockReturnValue({ - ...mockHostRiskScoreState, - data: undefined, - loading: true, - }); - - const { getByTestId } = render( - - - - ); - - expect(getByTestId('securitySolutionFlyoutLoading')).toBeInTheDocument(); - }); - it('renders loading state when observed host is loading', () => { mockedUseObservedHost.mockReturnValue({ ...mockObservedHostData, diff --git a/x-pack/plugins/security_solution/public/flyout/entity_details/host_right/index.tsx b/x-pack/plugins/security_solution/public/flyout/entity_details/host_right/index.tsx index 3d649ad360cd0..2c58eb0eb324c 100644 --- a/x-pack/plugins/security_solution/public/flyout/entity_details/host_right/index.tsx +++ b/x-pack/plugins/security_solution/public/flyout/entity_details/host_right/index.tsx @@ -9,6 +9,10 @@ import React, { useCallback, useMemo } from 'react'; import type { FlyoutPanelProps } from '@kbn/expandable-flyout'; import { useExpandableFlyoutApi } from '@kbn/expandable-flyout'; +import { useRefetchQueryById } from '../../../entity_analytics/api/hooks/use_refetch_query_by_id'; +import { RISK_INPUTS_TAB_QUERY_ID } from '../../../entity_analytics/components/entity_details_flyout/tabs/risk_inputs/risk_inputs_tab'; +import type { Refetch } from '../../../common/types'; +import { useCalculateEntityRiskScore } from '../../../entity_analytics/api/hooks/use_calculate_entity_risk_score'; import { useKibana } from '../../../common/lib/kibana/kibana_react'; import { hostToCriteria } from '../../../common/components/ml/criteria/host_to_criteria'; import { useRiskScore } from '../../../entity_analytics/api/hooks/use_risk_score'; @@ -68,6 +72,18 @@ export const HostPanel = ({ contextID, scopeId, hostName, isDraggable }: HostPan const hostRiskData = hostRisk && hostRisk.length > 0 ? hostRisk[0] : undefined; const isRiskScoreExist = !!hostRiskData?.host.risk; + const refetchRiskInputsTab = useRefetchQueryById(RISK_INPUTS_TAB_QUERY_ID); + const refetchRiskScore = useCallback(() => { + refetch(); + (refetchRiskInputsTab as Refetch | null)?.(); + }, [refetch, refetchRiskInputsTab]); + + const { isLoading: recalculatingScore, calculateEntityRiskScore } = useCalculateEntityRiskScore( + RiskScoreEntity.host, + hostName, + { onSuccess: refetchRiskScore } + ); + useQueryInspector({ deleteQuery, inspect: inspectRiskScore, @@ -98,7 +114,7 @@ export const HostPanel = ({ contextID, scopeId, hostName, isDraggable }: HostPan const openDefaultPanel = useCallback(() => openTabPanel(), [openTabPanel]); const observedHost = useObservedHost(hostName); - if (riskScoreState.loading || observedHost.isLoading) { + if (observedHost.isLoading) { return ; } @@ -134,6 +150,8 @@ export const HostPanel = ({ contextID, scopeId, hostName, isDraggable }: HostPan scopeId={scopeId} isDraggable={!!isDraggable} openDetailsPanel={openTabPanel} + recalculatingScore={recalculatingScore} + onAssetCriticalityChange={calculateEntityRiskScore} /> ); diff --git a/x-pack/plugins/security_solution/public/flyout/entity_details/user_right/content.stories.tsx b/x-pack/plugins/security_solution/public/flyout/entity_details/user_right/content.stories.tsx index 64fa6dcd80913..57705099edc05 100644 --- a/x-pack/plugins/security_solution/public/flyout/entity_details/user_right/content.stories.tsx +++ b/x-pack/plugins/security_solution/public/flyout/entity_details/user_right/content.stories.tsx @@ -39,6 +39,8 @@ storiesOf('Components/UserPanelContent', module) isDraggable={false} openDetailsPanel={() => {}} userName={'test-user-name'} + onAssetCriticalityChange={() => {}} + recalculatingScore={false} /> )) .add('integration disabled', () => ( @@ -55,6 +57,8 @@ storiesOf('Components/UserPanelContent', module) isDraggable={false} openDetailsPanel={() => {}} userName={'test-user-name'} + onAssetCriticalityChange={() => {}} + recalculatingScore={false} /> )) .add('no managed data', () => ( @@ -71,6 +75,8 @@ storiesOf('Components/UserPanelContent', module) isDraggable={false} openDetailsPanel={() => {}} userName={'test-user-name'} + onAssetCriticalityChange={() => {}} + recalculatingScore={false} /> )) .add('no observed data', () => ( @@ -107,6 +113,8 @@ storiesOf('Components/UserPanelContent', module) isDraggable={false} openDetailsPanel={() => {}} userName={'test-user-name'} + onAssetCriticalityChange={() => {}} + recalculatingScore={false} /> )) .add('loading', () => ( @@ -147,5 +155,7 @@ storiesOf('Components/UserPanelContent', module) isDraggable={false} openDetailsPanel={() => {}} userName={'test-user-name'} + onAssetCriticalityChange={() => {}} + recalculatingScore={false} /> )); diff --git a/x-pack/plugins/security_solution/public/flyout/entity_details/user_right/content.tsx b/x-pack/plugins/security_solution/public/flyout/entity_details/user_right/content.tsx index 7c40e4b30507f..46e5e9beaa3aa 100644 --- a/x-pack/plugins/security_solution/public/flyout/entity_details/user_right/content.tsx +++ b/x-pack/plugins/security_solution/public/flyout/entity_details/user_right/content.tsx @@ -29,9 +29,11 @@ interface UserPanelContentProps { observedUser: ObservedEntityData; managedUser: ManagedUserData; riskScoreState: RiskScoreState; + recalculatingScore: boolean; contextID: string; scopeId: string; isDraggable: boolean; + onAssetCriticalityChange: () => void; openDetailsPanel: (tab: EntityDetailsLeftPanelTab) => void; } @@ -40,10 +42,12 @@ export const UserPanelContent = ({ observedUser, managedUser, riskScoreState, + recalculatingScore, contextID, scopeId, isDraggable, openDetailsPanel, + onAssetCriticalityChange, }: UserPanelContentProps) => { const observedFields = useObservedUserItems(observedUser); const isManagedUserEnable = useIsExperimentalFeatureEnabled('newUserDetailsFlyoutManagedUser'); @@ -54,13 +58,17 @@ export const UserPanelContent = ({ <> )} - + { expect(getByTestId('securitySolutionFlyoutNavigationExpandDetailButton')).toBeInTheDocument(); }); - it('renders loading state when risk score is loading', () => { - mockedUseRiskScore.mockReturnValue({ - ...mockRiskScoreState, - data: undefined, - loading: true, - }); - - const { getByTestId } = render( - - - - ); - - expect(getByTestId('securitySolutionFlyoutLoading')).toBeInTheDocument(); - }); - it('renders loading state when observed user is loading', () => { mockedUseObservedUser.mockReturnValue({ ...mockObservedUser, diff --git a/x-pack/plugins/security_solution/public/flyout/entity_details/user_right/index.tsx b/x-pack/plugins/security_solution/public/flyout/entity_details/user_right/index.tsx index a342a1318bb24..3aac81f343016 100644 --- a/x-pack/plugins/security_solution/public/flyout/entity_details/user_right/index.tsx +++ b/x-pack/plugins/security_solution/public/flyout/entity_details/user_right/index.tsx @@ -8,6 +8,10 @@ import React, { useCallback, useMemo } from 'react'; import type { FlyoutPanelProps } from '@kbn/expandable-flyout'; import { useExpandableFlyoutApi } from '@kbn/expandable-flyout'; +import { useRefetchQueryById } from '../../../entity_analytics/api/hooks/use_refetch_query_by_id'; +import type { Refetch } from '../../../common/types'; +import { RISK_INPUTS_TAB_QUERY_ID } from '../../../entity_analytics/components/entity_details_flyout/tabs/risk_inputs/risk_inputs_tab'; +import { useCalculateEntityRiskScore } from '../../../entity_analytics/api/hooks/use_calculate_entity_risk_score'; import { useKibana } from '../../../common/lib/kibana/kibana_react'; import { useRiskScore } from '../../../entity_analytics/api/hooks/use_risk_score'; import { ManagedUserDatasetKey } from '../../../../common/search_strategy/security_solution/users/managed_details'; @@ -70,6 +74,18 @@ export const UserPanel = ({ contextID, scopeId, userName, isDraggable }: UserPan const { data: userRisk } = riskScoreState; const userRiskData = userRisk && userRisk.length > 0 ? userRisk[0] : undefined; + const refetchRiskInputsTab = useRefetchQueryById(RISK_INPUTS_TAB_QUERY_ID); + const refetchRiskScore = useCallback(() => { + refetch(); + (refetchRiskInputsTab as Refetch | null)?.(); + }, [refetch, refetchRiskInputsTab]); + + const { isLoading: recalculatingScore, calculateEntityRiskScore } = useCalculateEntityRiskScore( + RiskScoreEntity.user, + userName, + { onSuccess: refetchRiskScore } + ); + useQueryInspector({ deleteQuery, inspect, @@ -108,7 +124,7 @@ export const UserPanel = ({ contextID, scopeId, userName, isDraggable }: UserPan !!managedUser.data?.[ManagedUserDatasetKey.OKTA] || !!managedUser.data?.[ManagedUserDatasetKey.ENTRA]; - if (riskScoreState.loading || observedUser.isLoading || managedUser.isLoading) { + if (observedUser.isLoading || managedUser.isLoading) { return ; } @@ -144,6 +160,8 @@ export const UserPanel = ({ contextID, scopeId, userName, isDraggable }: UserPan managedUser={managedUser} observedUser={observedUserWithAnomalies} riskScoreState={riskScoreState} + recalculatingScore={recalculatingScore} + onAssetCriticalityChange={calculateEntityRiskScore} contextID={contextID} scopeId={scopeId} isDraggable={!!isDraggable} diff --git a/x-pack/plugins/security_solution/public/overview/components/host_overview/index.tsx b/x-pack/plugins/security_solution/public/overview/components/host_overview/index.tsx index 92ff143585117..6b499b085a23c 100644 --- a/x-pack/plugins/security_solution/public/overview/components/host_overview/index.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/host_overview/index.tsx @@ -10,6 +10,9 @@ import { euiDarkVars as darkTheme, euiLightVars as lightTheme } from '@kbn/ui-th import { getOr } from 'lodash/fp'; import React, { useCallback, useMemo } from 'react'; import styled from 'styled-components'; +import { useGlobalTime } from '../../../common/containers/use_global_time'; +import { useQueryInspector } from '../../../common/components/page/manage_query'; +import { FIRST_RECORD_PAGINATION } from '../../../entity_analytics/common'; import { useRiskScore } from '../../../entity_analytics/api/hooks/use_risk_score'; import type { HostItem } from '../../../../common/search_strategy'; import { buildHostNamesFilter, RiskScoreEntity } from '../../../../common/search_strategy'; @@ -63,6 +66,8 @@ const HostRiskOverviewWrapper = styled(EuiFlexGroup)` width: ${({ $width }: { $width: string }) => $width}; `; +export const HOST_OVERVIEW_RISK_SCORE_QUERY_ID = 'riskInputsTabQuery'; + export const HostOverview = React.memo( ({ anomaliesData, @@ -88,11 +93,29 @@ export const HostOverview = React.memo( () => (hostName ? buildHostNamesFilter([hostName]) : undefined), [hostName] ); + const { deleteQuery, setQuery } = useGlobalTime(); - const { data: hostRisk, isAuthorized } = useRiskScore({ + const { + data: hostRisk, + isAuthorized, + inspect: inspectRiskScore, + loading: loadingRiskScore, + refetch: refetchRiskScore, + } = useRiskScore({ filterQuery, riskEntity: RiskScoreEntity.host, skip: hostName == null, + onlyLatest: false, + pagination: FIRST_RECORD_PAGINATION, + }); + + useQueryInspector({ + deleteQuery, + inspect: inspectRiskScore, + loading: loadingRiskScore, + queryId: HOST_OVERVIEW_RISK_SCORE_QUERY_ID, + refetch: refetchRiskScore, + setQuery, }); const getDefaultRenderer = useCallback( diff --git a/x-pack/plugins/security_solution/public/overview/components/user_overview/index.tsx b/x-pack/plugins/security_solution/public/overview/components/user_overview/index.tsx index bd9e56dffee8d..321f87a3b5984 100644 --- a/x-pack/plugins/security_solution/public/overview/components/user_overview/index.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/user_overview/index.tsx @@ -10,6 +10,9 @@ import { euiDarkVars as darkTheme, euiLightVars as lightTheme } from '@kbn/ui-th import { getOr } from 'lodash/fp'; import React, { useCallback, useMemo } from 'react'; import styled from 'styled-components'; +import { useGlobalTime } from '../../../common/containers/use_global_time'; +import { FIRST_RECORD_PAGINATION } from '../../../entity_analytics/common'; +import { useQueryInspector } from '../../../common/components/page/manage_query'; import { useRiskScore } from '../../../entity_analytics/api/hooks/use_risk_score'; import { buildUserNamesFilter, RiskScoreEntity } from '../../../../common/search_strategy'; import type { DescriptionList } from '../../../../common/utility_types'; @@ -61,6 +64,8 @@ const UserRiskOverviewWrapper = styled(EuiFlexGroup)` width: ${({ $width }: { $width: string }) => $width}; `; +export const USER_OVERVIEW_RISK_SCORE_QUERY_ID = 'riskInputsTabQuery'; + export const UserOverview = React.memo( ({ anomaliesData, @@ -86,11 +91,29 @@ export const UserOverview = React.memo( () => (userName ? buildUserNamesFilter([userName]) : undefined), [userName] ); + const { deleteQuery, setQuery } = useGlobalTime(); - const { data: userRisk, isAuthorized } = useRiskScore({ + const { + data: userRisk, + isAuthorized, + inspect: inspectRiskScore, + loading: loadingRiskScore, + refetch: refetchRiskScore, + } = useRiskScore({ filterQuery, skip: userName == null, riskEntity: RiskScoreEntity.user, + onlyLatest: false, + pagination: FIRST_RECORD_PAGINATION, + }); + + useQueryInspector({ + deleteQuery, + inspect: inspectRiskScore, + loading: loadingRiskScore, + queryId: USER_OVERVIEW_RISK_SCORE_QUERY_ID, + refetch: refetchRiskScore, + setQuery, }); const getDefaultRenderer = useCallback( diff --git a/x-pack/plugins/security_solution/public/overview/pages/detection_response.tsx b/x-pack/plugins/security_solution/public/overview/pages/detection_response.tsx index edfda063ac5ba..3da5612254215 100644 --- a/x-pack/plugins/security_solution/public/overview/pages/detection_response.tsx +++ b/x-pack/plugins/security_solution/public/overview/pages/detection_response.tsx @@ -4,7 +4,7 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import React from 'react'; +import React, { useMemo } from 'react'; import { EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner } from '@elastic/eui'; import type { DocLinks } from '@kbn/doc-links'; import { APP_ID } from '../../../common'; @@ -43,6 +43,8 @@ const DetectionResponseComponent = () => { const canReadCases = userCasesPermissions.read; const canReadAlerts = hasKibanaREAD && hasIndexRead; const isSocTrendsEnabled = useIsExperimentalFeatureEnabled('socTrendsEnabled'); + const additionalFilters = useMemo(() => (filterQuery ? [filterQuery] : []), [filterQuery]); + if (!canReadAlerts && !canReadCases) { return docLinks.siem.privileges} />; } @@ -66,7 +68,7 @@ const DetectionResponseComponent = () => { )} diff --git a/x-pack/plugins/security_solution/server/lib/entity_analytics/asset_criticality/asset_criticality_data_client.ts b/x-pack/plugins/security_solution/server/lib/entity_analytics/asset_criticality/asset_criticality_data_client.ts index 81f0386a6c96c..fd5c2921972f3 100644 --- a/x-pack/plugins/security_solution/server/lib/entity_analytics/asset_criticality/asset_criticality_data_client.ts +++ b/x-pack/plugins/security_solution/server/lib/entity_analytics/asset_criticality/asset_criticality_data_client.ts @@ -141,7 +141,10 @@ export class AssetCriticalityDataClient { } } - public async upsert(record: AssetCriticalityUpsert): Promise { + public async upsert( + record: AssetCriticalityUpsert, + refresh = 'wait_for' as const + ): Promise { const id = createId(record); const doc = { id_field: record.idField, @@ -153,6 +156,7 @@ export class AssetCriticalityDataClient { await this.options.esClient.update({ id, index: this.getIndex(), + refresh: refresh ?? false, body: { doc, doc_as_upsert: true, @@ -240,10 +244,11 @@ export class AssetCriticalityDataClient { return { errors, stats }; }; - public async delete(idParts: AssetCriticalityIdParts) { + public async delete(idParts: AssetCriticalityIdParts, refresh = 'wait_for' as const) { await this.options.esClient.delete({ id: createId(idParts), index: this.getIndex(), + refresh: refresh ?? false, }); } } diff --git a/x-pack/plugins/security_solution/server/lib/entity_analytics/asset_criticality/routes/delete.ts b/x-pack/plugins/security_solution/server/lib/entity_analytics/asset_criticality/routes/delete.ts index f9630542283ef..6f1d677d414c5 100644 --- a/x-pack/plugins/security_solution/server/lib/entity_analytics/asset_criticality/routes/delete.ts +++ b/x-pack/plugins/security_solution/server/lib/entity_analytics/asset_criticality/routes/delete.ts @@ -12,7 +12,7 @@ import { APP_ID, ENABLE_ASSET_CRITICALITY_SETTING, } from '../../../../../common/constants'; -import { AssetCriticalityRecordIdParts } from '../../../../../common/api/entity_analytics/asset_criticality'; +import { DeleteAssetCriticalityRecord } from '../../../../../common/api/entity_analytics/asset_criticality'; import { buildRouteValidationWithZod } from '../../../../utils/build_validation/route_validation'; import { checkAndInitAssetCriticalityResources } from '../check_and_init_asset_criticality_resources'; import { assertAdvancedSettingsEnabled } from '../../utils/assert_advanced_setting_enabled'; @@ -36,7 +36,7 @@ export const assetCriticalityDeleteRoute = ( version: '1', validate: { request: { - query: buildRouteValidationWithZod(AssetCriticalityRecordIdParts), + query: buildRouteValidationWithZod(DeleteAssetCriticalityRecord), }, }, }, @@ -59,10 +59,13 @@ export const assetCriticalityDeleteRoute = ( await checkAndInitAssetCriticalityResources(context, logger); const assetCriticalityClient = securitySolution.getAssetCriticalityDataClient(); - await assetCriticalityClient.delete({ - idField: request.query.id_field, - idValue: request.query.id_value, - }); + await assetCriticalityClient.delete( + { + idField: request.query.id_field, + idValue: request.query.id_value, + }, + request.query.refresh + ); return response.ok(); } catch (e) { diff --git a/x-pack/plugins/security_solution/server/lib/entity_analytics/asset_criticality/routes/upsert.ts b/x-pack/plugins/security_solution/server/lib/entity_analytics/asset_criticality/routes/upsert.ts index 3e460b3f4e543..8a6d475695962 100644 --- a/x-pack/plugins/security_solution/server/lib/entity_analytics/asset_criticality/routes/upsert.ts +++ b/x-pack/plugins/security_solution/server/lib/entity_analytics/asset_criticality/routes/upsert.ts @@ -55,7 +55,10 @@ export const assetCriticalityUpsertRoute = ( criticalityLevel: request.body.criticality_level, }; - const result = await assetCriticalityClient.upsert(assetCriticalityRecord); + const result = await assetCriticalityClient.upsert( + assetCriticalityRecord, + request.body.refresh + ); securitySolution.getAuditLogger()?.log({ message: 'User attempted to assign the asset criticality level for an entity', diff --git a/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_score/calculate_and_persist_risk_scores.ts b/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_score/calculate_and_persist_risk_scores.ts index 09fe204fe69e1..aa67d4abf78ba 100644 --- a/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_score/calculate_and_persist_risk_scores.ts +++ b/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_score/calculate_and_persist_risk_scores.ts @@ -21,7 +21,7 @@ export const calculateAndPersistRiskScores = async ( riskScoreDataClient: RiskScoreDataClient; } ): Promise => { - const { riskScoreDataClient, spaceId, returnScores, ...rest } = params; + const { riskScoreDataClient, spaceId, returnScores, refresh, ...rest } = params; const writer = await riskScoreDataClient.getWriter({ namespace: spaceId, @@ -40,7 +40,7 @@ export const calculateAndPersistRiskScores = async ( ); } - const { errors, docs_written: scoresWritten } = await writer.bulk(scores); + const { errors, docs_written: scoresWritten } = await writer.bulk({ ...scores, refresh }); const result = { after_keys: afterKeys, errors, scores_written: scoresWritten }; diff --git a/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_score/risk_engine_data_writer.ts b/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_score/risk_engine_data_writer.ts index e43b44ab01894..e140090ea55e4 100644 --- a/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_score/risk_engine_data_writer.ts +++ b/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_score/risk_engine_data_writer.ts @@ -18,6 +18,7 @@ interface WriterBulkResponse { interface BulkParams { host?: RiskScore[]; user?: RiskScore[]; + refresh?: 'wait_for'; } export interface RiskEngineDataWriter { @@ -42,6 +43,7 @@ export class RiskEngineDataWriter implements RiskEngineDataWriter { const { errors, items, took } = await this.options.esClient.bulk({ operations: this.buildBulkOperations(params), + refresh: params.refresh ?? false, }); return { diff --git a/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_score/risk_score_service.ts b/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_score/risk_score_service.ts index bfc86643a6fd0..b326f50f767a4 100644 --- a/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_score/risk_score_service.ts +++ b/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_score/risk_score_service.ts @@ -44,6 +44,7 @@ export interface RiskScoreServiceFactoryParams { riskEngineDataClient: RiskEngineDataClient; riskScoreDataClient: RiskScoreDataClient; spaceId: string; + refresh?: 'wait_for'; } export const riskScoreServiceFactory = ({ diff --git a/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_score/routes/entity_calculation.test.ts b/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_score/routes/entity_calculation.test.ts index 1ba609e885dda..dbb8459d0ae42 100644 --- a/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_score/routes/entity_calculation.test.ts +++ b/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_score/routes/entity_calculation.test.ts @@ -92,7 +92,9 @@ describe('entity risk score calculation route', () => { expect(response.status).toEqual(200); expect(mockRiskScoreService.calculateAndPersistScores).toHaveBeenCalledWith( - expect.objectContaining({ filter: [{ term: { 'host.name': 'test-host-name' } }] }) + expect.objectContaining({ + filter: { bool: { filter: [{ term: { 'host.name': 'test-host-name' } }] } }, + }) ); }); @@ -118,9 +120,9 @@ describe('entity risk score calculation route', () => { expect(response.body).toEqual({ message: 'No Risk engine configuration found', - status_code: 405, + status_code: 400, }); - expect(response.status).toEqual(405); + expect(response.status).toEqual(400); }); it('returns an error if the risk engine is disabled', async () => { @@ -133,9 +135,9 @@ describe('entity risk score calculation route', () => { expect(response.body).toEqual({ message: 'Risk engine is disabled', - status_code: 405, + status_code: 400, }); - expect(response.status).toEqual(405); + expect(response.status).toEqual(400); }); it('filter by user provided filter when it is defined', async () => { @@ -149,7 +151,9 @@ describe('entity risk score calculation route', () => { expect(response.status).toEqual(200); expect(mockRiskScoreService.calculateAndPersistScores).toHaveBeenCalledWith( - expect.objectContaining({ filter: expect.arrayContaining([userFilter]) }) + expect.objectContaining({ + filter: { bool: { filter: expect.arrayContaining([userFilter]) } }, + }) ); }); }); diff --git a/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_score/routes/entity_calculation.ts b/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_score/routes/entity_calculation.ts index 497e9ac189100..98b4149f70230 100644 --- a/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_score/routes/entity_calculation.ts +++ b/x-pack/plugins/security_solution/server/lib/entity_analytics/risk_score/routes/entity_calculation.ts @@ -69,7 +69,7 @@ export const riskScoreEntityCalculationRoute = ( logger ); - const { identifier_type: identifierType, identifier } = request.body; + const { identifier_type: identifierType, identifier, refresh } = request.body; try { const entityAnalyticsConfig = await riskScoreService.getConfigurationWithDefaults( @@ -78,7 +78,7 @@ export const riskScoreEntityCalculationRoute = ( if (entityAnalyticsConfig == null) { return siemResponse.error({ - statusCode: 405, + statusCode: 400, body: 'No Risk engine configuration found', }); } @@ -94,7 +94,7 @@ export const riskScoreEntityCalculationRoute = ( if (!enabled) { return siemResponse.error({ - statusCode: 405, + statusCode: 400, body: 'Risk engine is disabled', }); } @@ -112,6 +112,7 @@ export const riskScoreEntityCalculationRoute = ( const identifierFilter = { term: { [getFieldForIdentifier(identifierType)]: identifier }, }; + const filter = isEmpty(userFilter) ? [identifierFilter] : [userFilter, identifierFilter]; const result: CalculateAndPersistScoresResponse = @@ -119,13 +120,18 @@ export const riskScoreEntityCalculationRoute = ( pageSize, identifierType, index, - filter, + filter: { + bool: { + filter, + }, + }, range, runtimeMappings, weights: [], alertSampleSizePerShard, afterKeys, returnScores: true, + refresh, }); if (result.errors.length) { diff --git a/x-pack/plugins/security_solution/server/lib/entity_analytics/types.ts b/x-pack/plugins/security_solution/server/lib/entity_analytics/types.ts index d41edbd215642..a71912d2dffa4 100644 --- a/x-pack/plugins/security_solution/server/lib/entity_analytics/types.ts +++ b/x-pack/plugins/security_solution/server/lib/entity_analytics/types.ts @@ -52,6 +52,7 @@ export interface CalculateAndPersistScoresParams { weights?: RiskWeights; alertSampleSizePerShard?: number; returnScores?: boolean; + refresh?: 'wait_for'; } export interface CalculateAndPersistScoresResponse {