From 123e5357545be95ff8b7060319559086938c0e61 Mon Sep 17 00:00:00 2001 From: christineweng <18648970+christineweng@users.noreply.github.com> Date: Wed, 24 May 2023 16:02:20 -0500 Subject: [PATCH] [Security Solution] Entities details tab in expandable flyout (#155809) ## Summary This PR adds content to the 'Entities' tab under ' Insights', in the left section of the expandable flyout. - User info contains an user overview and related hosts. Related hosts are hosts this user has successfully authenticated after alert time - Host info contains a host overview and related users. Related users are users who are successfully authenticated to this host after alert time - User and host risk scores are displayed if kibana user has platinum license ![image](https://user-images.githubusercontent.com/18648970/234703183-a3fa7809-cc1f-4b9a-8bd0-aa2a991047cb.png) ### How to test - Enable feature flag `securityFlyoutEnabled` - Navigation: - Generate some alerts data and go to Alerts page - Select the expand icon for an alert - Click `Expand alert details` - Go to Insights tab, Entities tab - To see risk score, apply platinum or enterprise license, then go to dashboard -> entity analytics, and click Enable (both user and host). - See comments below on generating test data (if needed) ### Run tests and storybook - `node scripts/storybook security_solution` to run Storybook - `npm run test:jest --config ./x-pack/plugins/security_solution/public/flyout` to run the unit tests - `yarn cypress:open-as-ci` but note that the integration/e2e tests have been written but are now skipped because the feature is protected behind a feature flag, disabled by default. To check them, add `'securityFlyoutEnabled'` [here](https://github.com/elastic/kibana/blob/main/x-pack/test/security_solution_cypress/config.ts#L50) ### Checklist - [x] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md) - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios --------- Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../security_solution/index.ts | 19 ++ .../related_entities/index.ts | 14 + .../related_entities/related_hosts/index.tsx | 42 +++ .../related_entities/related_users/index.tsx | 42 +++ .../alert_details_left_panel.cy.ts | 4 +- ...lert_details_left_panel_entities_tab.cy.ts | 52 ++++ .../screens/document_expandable_flyout.ts | 7 + .../related_hosts/index.test.tsx | 80 +++++ .../related_entities/related_hosts/index.tsx | 81 +++++ .../related_hosts/translations.ts | 15 + .../related_users/index.test.tsx | 80 +++++ .../related_entities/related_users/index.tsx | 81 +++++ .../related_users/translations.ts | 15 + .../left/components/entities_details.test.tsx | 91 ++++++ .../left/components/entities_details.tsx | 26 +- .../left/components/host_details.test.tsx | 235 ++++++++++++++ .../flyout/left/components/host_details.tsx | 289 ++++++++++++++++++ .../public/flyout/left/components/test_ids.ts | 16 +- .../flyout/left/components/translations.ts | 68 ++++- .../left/components/user_details.test.tsx | 236 ++++++++++++++ .../flyout/left/components/user_details.tsx | 288 +++++++++++++++++ .../public/flyout/left/context.tsx | 14 +- .../public/flyout/left/mocks/mock_context.ts | 41 +++ .../components/entities_overview.test.tsx | 26 +- .../right/components/entities_overview.tsx | 14 +- .../right/components/entity_panel.stories.tsx | 31 +- .../right/components/entity_panel.test.tsx | 85 ++++-- .../flyout/right/components/entity_panel.tsx | 131 ++++---- .../flyout/right/components/test_ids.ts | 12 +- .../factory/hosts/all/index.ts | 2 +- .../security_solution/factory/index.ts | 2 + .../factory/related_entities/index.ts | 21 ++ .../related_hosts/__mocks__/index.ts | 158 ++++++++++ .../related_hosts/index.test.ts | 88 ++++++ .../related_entities/related_hosts/index.ts | 92 ++++++ .../query.related_hosts.dsl.test.ts | 15 + .../related_hosts/query.related_hosts.dsl.ts | 61 ++++ .../related_users/__mocks__/index.ts | 154 ++++++++++ .../related_users/index.test.ts | 88 ++++++ .../related_entities/related_users/index.ts | 94 ++++++ .../query.related_users.dsl.test.ts | 15 + .../related_users/query.related_users.dsl.ts | 61 ++++ .../factory/users/all/index.ts | 2 +- .../translations/translations/fr-FR.json | 4 +- .../translations/translations/ja-JP.json | 4 +- .../translations/translations/zh-CN.json | 4 +- 46 files changed, 2882 insertions(+), 118 deletions(-) create mode 100644 x-pack/plugins/security_solution/common/search_strategy/security_solution/related_entities/index.ts create mode 100644 x-pack/plugins/security_solution/common/search_strategy/security_solution/related_entities/related_hosts/index.tsx create mode 100644 x-pack/plugins/security_solution/common/search_strategy/security_solution/related_entities/related_users/index.tsx create mode 100644 x-pack/plugins/security_solution/cypress/e2e/detection_alerts/expandable_flyout/alert_details_left_panel_entities_tab.cy.ts create mode 100644 x-pack/plugins/security_solution/public/common/containers/related_entities/related_hosts/index.test.tsx create mode 100644 x-pack/plugins/security_solution/public/common/containers/related_entities/related_hosts/index.tsx create mode 100644 x-pack/plugins/security_solution/public/common/containers/related_entities/related_hosts/translations.ts create mode 100644 x-pack/plugins/security_solution/public/common/containers/related_entities/related_users/index.test.tsx create mode 100644 x-pack/plugins/security_solution/public/common/containers/related_entities/related_users/index.tsx create mode 100644 x-pack/plugins/security_solution/public/common/containers/related_entities/related_users/translations.ts create mode 100644 x-pack/plugins/security_solution/public/flyout/left/components/entities_details.test.tsx create mode 100644 x-pack/plugins/security_solution/public/flyout/left/components/host_details.test.tsx create mode 100644 x-pack/plugins/security_solution/public/flyout/left/components/host_details.tsx create mode 100644 x-pack/plugins/security_solution/public/flyout/left/components/user_details.test.tsx create mode 100644 x-pack/plugins/security_solution/public/flyout/left/components/user_details.tsx create mode 100644 x-pack/plugins/security_solution/public/flyout/left/mocks/mock_context.ts create mode 100644 x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/related_entities/index.ts create mode 100644 x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/related_entities/related_hosts/__mocks__/index.ts create mode 100644 x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/related_entities/related_hosts/index.test.ts create mode 100644 x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/related_entities/related_hosts/index.ts create mode 100644 x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/related_entities/related_hosts/query.related_hosts.dsl.test.ts create mode 100644 x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/related_entities/related_hosts/query.related_hosts.dsl.ts create mode 100644 x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/related_entities/related_users/__mocks__/index.ts create mode 100644 x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/related_entities/related_users/index.test.ts create mode 100644 x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/related_entities/related_users/index.ts create mode 100644 x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/related_entities/related_users/query.related_users.dsl.test.ts create mode 100644 x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/related_entities/related_users/query.related_users.dsl.ts diff --git a/x-pack/plugins/security_solution/common/search_strategy/security_solution/index.ts b/x-pack/plugins/security_solution/common/search_strategy/security_solution/index.ts index a283ea4cbade9..f15a5e38af5f8 100644 --- a/x-pack/plugins/security_solution/common/search_strategy/security_solution/index.ts +++ b/x-pack/plugins/security_solution/common/search_strategy/security_solution/index.ts @@ -111,6 +111,15 @@ import type { ManagedUserDetailsRequestOptions, ManagedUserDetailsStrategyResponse, } from './users/managed_details'; +import type { RelatedEntitiesQueries } from './related_entities'; +import type { + UsersRelatedHostsRequestOptions, + UsersRelatedHostsStrategyResponse, +} from './related_entities/related_hosts'; +import type { + HostsRelatedUsersRequestOptions, + HostsRelatedUsersStrategyResponse, +} from './related_entities/related_users'; export * from './cti'; export * from './hosts'; @@ -119,6 +128,7 @@ export * from './matrix_histogram'; export * from './network'; export * from './users'; export * from './first_last_seen'; +export * from './related_entities'; export type FactoryQueryTypes = | HostsQueries @@ -130,6 +140,7 @@ export type FactoryQueryTypes = | CtiQueries | typeof MatrixHistogramQuery | typeof FirstLastSeenQuery + | RelatedEntitiesQueries | ResponseActionsQueries; export interface RequestBasicOptions extends IEsSearchRequest { @@ -215,6 +226,10 @@ export type StrategyResponseType = T extends HostsQ ? UsersRiskScoreStrategyResponse : T extends RiskQueries.kpiRiskScore ? KpiRiskScoreStrategyResponse + : T extends RelatedEntitiesQueries.relatedUsers + ? HostsRelatedUsersStrategyResponse + : T extends RelatedEntitiesQueries.relatedHosts + ? UsersRelatedHostsStrategyResponse : T extends ResponseActionsQueries.actions ? ActionRequestStrategyResponse : T extends ResponseActionsQueries.results @@ -285,6 +300,10 @@ export type StrategyRequestType = T extends HostsQu ? RiskScoreRequestOptions : T extends RiskQueries.kpiRiskScore ? KpiRiskScoreRequestOptions + : T extends RelatedEntitiesQueries.relatedHosts + ? UsersRelatedHostsRequestOptions + : T extends RelatedEntitiesQueries.relatedUsers + ? HostsRelatedUsersRequestOptions : T extends ResponseActionsQueries.actions ? ActionRequestOptions : T extends ResponseActionsQueries.results diff --git a/x-pack/plugins/security_solution/common/search_strategy/security_solution/related_entities/index.ts b/x-pack/plugins/security_solution/common/search_strategy/security_solution/related_entities/index.ts new file mode 100644 index 0000000000000..d4f4507c1d577 --- /dev/null +++ b/x-pack/plugins/security_solution/common/search_strategy/security_solution/related_entities/index.ts @@ -0,0 +1,14 @@ +/* + * 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. + */ + +export * from './related_hosts'; +export * from './related_users'; + +export enum RelatedEntitiesQueries { + relatedHosts = 'relatedHosts', + relatedUsers = 'relatedUsers', +} diff --git a/x-pack/plugins/security_solution/common/search_strategy/security_solution/related_entities/related_hosts/index.tsx b/x-pack/plugins/security_solution/common/search_strategy/security_solution/related_entities/related_hosts/index.tsx new file mode 100644 index 0000000000000..bba4a3906f99d --- /dev/null +++ b/x-pack/plugins/security_solution/common/search_strategy/security_solution/related_entities/related_hosts/index.tsx @@ -0,0 +1,42 @@ +/* + * 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 { IEsSearchResponse } from '@kbn/data-plugin/common'; +import type { RiskSeverity, Inspect, Maybe } from '../../..'; +import type { RequestBasicOptions } from '../..'; +import type { BucketItem } from '../../cti'; + +export interface RelatedHost { + host: string; + ip: string[]; + risk?: RiskSeverity; +} + +export interface RelatedHostBucket { + key: string; + doc_count: number; + ip?: IPItems; +} + +interface IPItems { + doc_count_error_upper_bound: number; + sum_other_doc_count: number; + buckets: BucketItem[]; +} + +export interface UsersRelatedHostsStrategyResponse extends IEsSearchResponse { + totalCount: number; + relatedHosts: RelatedHost[]; + inspect?: Maybe; +} + +export interface UsersRelatedHostsRequestOptions extends Partial { + userName: string; + skip?: boolean; + from: string; + inspect?: Maybe; +} diff --git a/x-pack/plugins/security_solution/common/search_strategy/security_solution/related_entities/related_users/index.tsx b/x-pack/plugins/security_solution/common/search_strategy/security_solution/related_entities/related_users/index.tsx new file mode 100644 index 0000000000000..e2f7c0c4bd016 --- /dev/null +++ b/x-pack/plugins/security_solution/common/search_strategy/security_solution/related_entities/related_users/index.tsx @@ -0,0 +1,42 @@ +/* + * 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 { IEsSearchResponse } from '@kbn/data-plugin/common'; +import type { RiskSeverity, Inspect, Maybe } from '../../..'; +import type { RequestBasicOptions } from '../..'; +import type { BucketItem } from '../../cti'; + +export interface RelatedUser { + user: string; + ip: string[]; + risk?: RiskSeverity; +} + +export interface RelatedUserBucket { + key: string; + doc_count: number; + ip?: IPItems; +} + +interface IPItems { + doc_count_error_upper_bound: number; + sum_other_doc_count: number; + buckets: BucketItem[]; +} + +export interface HostsRelatedUsersStrategyResponse extends IEsSearchResponse { + totalCount: number; + relatedUsers: RelatedUser[]; + inspect?: Maybe; +} + +export interface HostsRelatedUsersRequestOptions extends Partial { + hostName: string; + skip?: boolean; + from: string; + inspect?: Maybe; +} diff --git a/x-pack/plugins/security_solution/cypress/e2e/detection_alerts/expandable_flyout/alert_details_left_panel.cy.ts b/x-pack/plugins/security_solution/cypress/e2e/detection_alerts/expandable_flyout/alert_details_left_panel.cy.ts index 1832188159b3d..ded9f85ccac7d 100644 --- a/x-pack/plugins/security_solution/cypress/e2e/detection_alerts/expandable_flyout/alert_details_left_panel.cy.ts +++ b/x-pack/plugins/security_solution/cypress/e2e/detection_alerts/expandable_flyout/alert_details_left_panel.cy.ts @@ -127,9 +127,7 @@ describe.skip('Alert details expandable flyout left panel', { testIsolation: fal it('should display content when switching buttons', () => { openInsightsTab(); openEntities(); - cy.get(DOCUMENT_DETAILS_FLYOUT_INSIGHTS_TAB_ENTITIES_CONTENT) - .should('be.visible') - .and('have.text', 'Entities'); + cy.get(DOCUMENT_DETAILS_FLYOUT_INSIGHTS_TAB_ENTITIES_CONTENT).should('be.visible'); openThreatIntelligence(); cy.get(DOCUMENT_DETAILS_FLYOUT_INSIGHTS_TAB_THREAT_INTELLIGENCE_CONTENT) diff --git a/x-pack/plugins/security_solution/cypress/e2e/detection_alerts/expandable_flyout/alert_details_left_panel_entities_tab.cy.ts b/x-pack/plugins/security_solution/cypress/e2e/detection_alerts/expandable_flyout/alert_details_left_panel_entities_tab.cy.ts new file mode 100644 index 0000000000000..7c9d33b378baf --- /dev/null +++ b/x-pack/plugins/security_solution/cypress/e2e/detection_alerts/expandable_flyout/alert_details_left_panel_entities_tab.cy.ts @@ -0,0 +1,52 @@ +/* + * 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 { + DOCUMENT_DETAILS_FLYOUT_INSIGHTS_TAB_USER_DETAILS, + DOCUMENT_DETAILS_FLYOUT_INSIGHTS_TAB_HOST_DETAILS, +} from '../../../screens/document_expandable_flyout'; +import { + expandFirstAlertExpandableFlyout, + openInsightsTab, + openEntities, + expandDocumentDetailsExpandableFlyoutLeftSection, +} from '../../../tasks/document_expandable_flyout'; +import { cleanKibana } from '../../../tasks/common'; +import { login, visit } from '../../../tasks/login'; +import { createRule } from '../../../tasks/api_calls/rules'; +import { getNewRule } from '../../../objects/rule'; +import { ALERTS_URL } from '../../../urls/navigation'; +import { waitForAlertsToPopulate } from '../../../tasks/create_new_rule'; + +// Skipping these for now as the feature is protected behind a feature flag set to false by default +// To run the tests locally, add 'securityFlyoutEnabled' in the Cypress config.ts here https://github.com/elastic/kibana/blob/main/x-pack/test/security_solution_cypress/config.ts#L50 +describe.skip( + 'Alert details expandable flyout left panel entities', + { testIsolation: false }, + () => { + before(() => { + cleanKibana(); + login(); + createRule(getNewRule()); + visit(ALERTS_URL); + waitForAlertsToPopulate(); + expandFirstAlertExpandableFlyout(); + expandDocumentDetailsExpandableFlyoutLeftSection(); + openInsightsTab(); + openEntities(); + }); + + it('should display analyzer graph and node list', () => { + cy.get(DOCUMENT_DETAILS_FLYOUT_INSIGHTS_TAB_USER_DETAILS) + .scrollIntoView() + .should('be.visible'); + cy.get(DOCUMENT_DETAILS_FLYOUT_INSIGHTS_TAB_HOST_DETAILS) + .scrollIntoView() + .should('be.visible'); + }); + } +); diff --git a/x-pack/plugins/security_solution/cypress/screens/document_expandable_flyout.ts b/x-pack/plugins/security_solution/cypress/screens/document_expandable_flyout.ts index 5028d38385aca..ed3a680a130f2 100644 --- a/x-pack/plugins/security_solution/cypress/screens/document_expandable_flyout.ts +++ b/x-pack/plugins/security_solution/cypress/screens/document_expandable_flyout.ts @@ -12,6 +12,8 @@ import { THREAT_INTELLIGENCE_DETAILS_TEST_ID, PREVALENCE_DETAILS_TEST_ID, CORRELATIONS_DETAILS_TEST_ID, + USER_DETAILS_TEST_ID, + HOST_DETAILS_TEST_ID, } from '../../public/flyout/left/components/test_ids'; import { HISTORY_TAB_CONTENT_TEST_ID, @@ -155,6 +157,11 @@ export const DOCUMENT_DETAILS_FLYOUT_INSIGHTS_TAB_ENTITIES_BUTTON = getDataTestS ); export const DOCUMENT_DETAILS_FLYOUT_INSIGHTS_TAB_ENTITIES_CONTENT = getDataTestSubjectSelector(ENTITIES_DETAILS_TEST_ID); +export const DOCUMENT_DETAILS_FLYOUT_INSIGHTS_TAB_USER_DETAILS = + getDataTestSubjectSelector(USER_DETAILS_TEST_ID); +export const DOCUMENT_DETAILS_FLYOUT_INSIGHTS_TAB_HOST_DETAILS = + getDataTestSubjectSelector(HOST_DETAILS_TEST_ID); + export const DOCUMENT_DETAILS_FLYOUT_INSIGHTS_TAB_THREAT_INTELLIGENCE_BUTTON = getDataTestSubjectSelector(INSIGHTS_TAB_THREAT_INTELLIGENCE_BUTTON_TEST_ID); export const DOCUMENT_DETAILS_FLYOUT_INSIGHTS_TAB_THREAT_INTELLIGENCE_CONTENT = diff --git a/x-pack/plugins/security_solution/public/common/containers/related_entities/related_hosts/index.test.tsx b/x-pack/plugins/security_solution/public/common/containers/related_entities/related_hosts/index.test.tsx new file mode 100644 index 0000000000000..e7ef62fc95b80 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/containers/related_entities/related_hosts/index.test.tsx @@ -0,0 +1,80 @@ +/* + * 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 '../../../mock'; +import { useUserRelatedHosts } from '.'; +import { useSearchStrategy } from '../../use_search_strategy'; + +jest.mock('../../use_search_strategy', () => ({ + useSearchStrategy: jest.fn(), +})); +const mockUseSearchStrategy = useSearchStrategy as jest.Mock; +const mockSearch = jest.fn(); + +const defaultProps = { + userName: 'user1', + indexNames: ['index-*'], + from: '2020-07-07T08:20:18.966Z', + skip: false, +}; + +const mockResult = { + inspect: {}, + totalCount: 1, + relatedHosts: [{ host: 'test host', ip: '100.000.XX' }], + loading: false, + refetch: jest.fn(), +}; + +describe('useUsersRelatedHosts', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUseSearchStrategy.mockReturnValue({ + loading: false, + result: { + totalCount: mockResult.totalCount, + relatedHosts: mockResult.relatedHosts, + }, + search: mockSearch, + refetch: jest.fn(), + inspect: {}, + }); + }); + + it('runs search', () => { + const { result } = renderHook(() => useUserRelatedHosts(defaultProps), { + wrapper: TestProviders, + }); + + expect(mockSearch).toHaveBeenCalled(); + expect(JSON.stringify(result.current)).toEqual(JSON.stringify(mockResult)); // serialize result for array comparison + }); + + it('does not run search when skip = true', () => { + const props = { + ...defaultProps, + skip: true, + }; + renderHook(() => useUserRelatedHosts(props), { + wrapper: TestProviders, + }); + + expect(mockSearch).not.toHaveBeenCalled(); + }); + it('skip = true will cancel any running request', () => { + const props = { + ...defaultProps, + }; + const { rerender } = renderHook(() => useUserRelatedHosts(props), { + wrapper: TestProviders, + }); + props.skip = true; + act(() => rerender()); + expect(mockUseSearchStrategy).toHaveBeenCalledTimes(2); + expect(mockUseSearchStrategy.mock.calls[1][0].abort).toEqual(true); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/containers/related_entities/related_hosts/index.tsx b/x-pack/plugins/security_solution/public/common/containers/related_entities/related_hosts/index.tsx new file mode 100644 index 0000000000000..d43410ae86ae1 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/containers/related_entities/related_hosts/index.tsx @@ -0,0 +1,81 @@ +/* + * 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 { useEffect, useMemo } from 'react'; +import type { inputsModel } from '../../../store'; +import type { InspectResponse } from '../../../../types'; +import { RelatedEntitiesQueries } from '../../../../../common/search_strategy/security_solution/related_entities'; +import type { RelatedHost } from '../../../../../common/search_strategy/security_solution/related_entities/related_hosts'; +import { useSearchStrategy } from '../../use_search_strategy'; +import { FAIL_RELATED_HOSTS } from './translations'; + +export interface UseUserRelatedHostsResult { + inspect: InspectResponse; + totalCount: number; + relatedHosts: RelatedHost[]; + refetch: inputsModel.Refetch; + loading: boolean; +} + +interface UseUserRelatedHostsParam { + userName: string; + indexNames: string[]; + from: string; + skip?: boolean; +} + +export const useUserRelatedHosts = ({ + userName, + indexNames, + from, + skip = false, +}: UseUserRelatedHostsParam): UseUserRelatedHostsResult => { + const { + loading, + result: response, + search, + refetch, + inspect, + } = useSearchStrategy({ + factoryQueryType: RelatedEntitiesQueries.relatedHosts, + initialResult: { + totalCount: 0, + relatedHosts: [], + }, + errorMessage: FAIL_RELATED_HOSTS, + abort: skip, + }); + + const userRelatedHostsResponse = useMemo( + () => ({ + inspect, + totalCount: response.totalCount, + relatedHosts: response.relatedHosts, + refetch, + loading, + }), + [inspect, refetch, response.totalCount, response.relatedHosts, loading] + ); + + const userRelatedHostsRequest = useMemo( + () => ({ + defaultIndex: indexNames, + factoryQueryType: RelatedEntitiesQueries.relatedHosts, + userName, + from, + }), + [indexNames, from, userName] + ); + + useEffect(() => { + if (!skip) { + search(userRelatedHostsRequest); + } + }, [userRelatedHostsRequest, search, skip]); + + return userRelatedHostsResponse; +}; diff --git a/x-pack/plugins/security_solution/public/common/containers/related_entities/related_hosts/translations.ts b/x-pack/plugins/security_solution/public/common/containers/related_entities/related_hosts/translations.ts new file mode 100644 index 0000000000000..89ca68b5e931f --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/containers/related_entities/related_hosts/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_RELATED_HOSTS = i18n.translate( + 'xpack.securitySolution.flyout.entities.failRelatedHostsDescription', + { + defaultMessage: `Failed to run search on related hosts`, + } +); diff --git a/x-pack/plugins/security_solution/public/common/containers/related_entities/related_users/index.test.tsx b/x-pack/plugins/security_solution/public/common/containers/related_entities/related_users/index.test.tsx new file mode 100644 index 0000000000000..904b0e6569e65 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/containers/related_entities/related_users/index.test.tsx @@ -0,0 +1,80 @@ +/* + * 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 '../../../mock'; +import { useHostRelatedUsers } from '.'; +import { useSearchStrategy } from '../../use_search_strategy'; + +jest.mock('../../use_search_strategy', () => ({ + useSearchStrategy: jest.fn(), +})); +const mockUseSearchStrategy = useSearchStrategy as jest.Mock; +const mockSearch = jest.fn(); + +const defaultProps = { + hostName: 'host1', + indexNames: ['index-*'], + from: '2020-07-07T08:20:18.966Z', + skip: false, +}; + +const mockResult = { + inspect: {}, + totalCount: 1, + relatedUsers: [{ user: 'test user', ip: '100.000.XX' }], + refetch: jest.fn(), + loading: false, +}; + +describe('useUsersRelatedHosts', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUseSearchStrategy.mockReturnValue({ + loading: false, + result: { + totalCount: mockResult.totalCount, + relatedUsers: mockResult.relatedUsers, + }, + search: mockSearch, + refetch: jest.fn(), + inspect: {}, + }); + }); + + it('runs search', () => { + const { result } = renderHook(() => useHostRelatedUsers(defaultProps), { + wrapper: TestProviders, + }); + + expect(mockSearch).toHaveBeenCalled(); + expect(JSON.stringify(result.current)).toEqual(JSON.stringify(mockResult)); // serialize result for array comparison + }); + + it('does not run search when skip = true', () => { + const props = { + ...defaultProps, + skip: true, + }; + renderHook(() => useHostRelatedUsers(props), { + wrapper: TestProviders, + }); + + expect(mockSearch).not.toHaveBeenCalled(); + }); + it('skip = true will cancel any running request', () => { + const props = { + ...defaultProps, + }; + const { rerender } = renderHook(() => useHostRelatedUsers(props), { + wrapper: TestProviders, + }); + props.skip = true; + act(() => rerender()); + expect(mockUseSearchStrategy).toHaveBeenCalledTimes(2); + expect(mockUseSearchStrategy.mock.calls[1][0].abort).toEqual(true); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/containers/related_entities/related_users/index.tsx b/x-pack/plugins/security_solution/public/common/containers/related_entities/related_users/index.tsx new file mode 100644 index 0000000000000..7369ca2d57024 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/containers/related_entities/related_users/index.tsx @@ -0,0 +1,81 @@ +/* + * 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 { useEffect, useMemo } from 'react'; +import type { inputsModel } from '../../../store'; +import type { InspectResponse } from '../../../../types'; +import { RelatedEntitiesQueries } from '../../../../../common/search_strategy/security_solution/related_entities'; +import type { RelatedUser } from '../../../../../common/search_strategy/security_solution/related_entities/related_users'; +import { useSearchStrategy } from '../../use_search_strategy'; +import { FAIL_RELATED_USERS } from './translations'; + +export interface UseHostRelatedUsersResult { + inspect: InspectResponse; + totalCount: number; + relatedUsers: RelatedUser[]; + refetch: inputsModel.Refetch; + loading: boolean; +} + +interface UseHostRelatedUsersParam { + hostName: string; + indexNames: string[]; + from: string; + skip?: boolean; +} + +export const useHostRelatedUsers = ({ + hostName, + indexNames, + from, + skip = false, +}: UseHostRelatedUsersParam): UseHostRelatedUsersResult => { + const { + loading, + result: response, + search, + refetch, + inspect, + } = useSearchStrategy({ + factoryQueryType: RelatedEntitiesQueries.relatedUsers, + initialResult: { + totalCount: 0, + relatedUsers: [], + }, + errorMessage: FAIL_RELATED_USERS, + abort: skip, + }); + + const hostRelatedUsersResponse = useMemo( + () => ({ + inspect, + totalCount: response.totalCount, + relatedUsers: response.relatedUsers, + refetch, + loading, + }), + [inspect, refetch, response.totalCount, response.relatedUsers, loading] + ); + + const hostRelatedUsersRequest = useMemo( + () => ({ + defaultIndex: indexNames, + factoryQueryType: RelatedEntitiesQueries.relatedUsers, + hostName, + from, + }), + [indexNames, from, hostName] + ); + + useEffect(() => { + if (!skip) { + search(hostRelatedUsersRequest); + } + }, [hostRelatedUsersRequest, search, skip]); + + return hostRelatedUsersResponse; +}; diff --git a/x-pack/plugins/security_solution/public/common/containers/related_entities/related_users/translations.ts b/x-pack/plugins/security_solution/public/common/containers/related_entities/related_users/translations.ts new file mode 100644 index 0000000000000..241c9bdb5da98 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/containers/related_entities/related_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_RELATED_USERS = i18n.translate( + 'xpack.securitySolution.flyout.entities.failRelatedUsersDescription', + { + defaultMessage: `Failed to run search on related users`, + } +); diff --git a/x-pack/plugins/security_solution/public/flyout/left/components/entities_details.test.tsx b/x-pack/plugins/security_solution/public/flyout/left/components/entities_details.test.tsx new file mode 100644 index 0000000000000..2b83ae558f274 --- /dev/null +++ b/x-pack/plugins/security_solution/public/flyout/left/components/entities_details.test.tsx @@ -0,0 +1,91 @@ +/* + * 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 React from 'react'; +import { render } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { LeftFlyoutContext } from '../context'; +import { TestProviders } from '../../../common/mock'; +import { EntitiesDetails } from './entities_details'; +import { ENTITIES_DETAILS_TEST_ID, HOST_DETAILS_TEST_ID, USER_DETAILS_TEST_ID } from './test_ids'; +import { mockContextValue } from '../mocks/mock_context'; + +jest.mock('react-router-dom', () => { + const actual = jest.requireActual('react-router-dom'); + return { ...actual, useLocation: jest.fn().mockReturnValue({ pathname: '' }) }; +}); + +jest.mock('../../../resolver/view/use_resolver_query_params_cleaner'); + +const mockDispatch = jest.fn(); +jest.mock('react-redux', () => { + const original = jest.requireActual('react-redux'); + + return { + ...original, + useDispatch: () => mockDispatch, + }; +}); + +describe('', () => { + it('renders entities details correctly', () => { + const { getByTestId } = render( + + + + + + ); + expect(getByTestId(ENTITIES_DETAILS_TEST_ID)).toBeInTheDocument(); + expect(getByTestId(USER_DETAILS_TEST_ID)).toBeInTheDocument(); + expect(getByTestId(HOST_DETAILS_TEST_ID)).toBeInTheDocument(); + }); + + it('does not render user and host details if user name and host name are not available', () => { + const { queryByTestId } = render( + + + fieldName === '@timestamp' ? ['2022-07-25T08:20:18.966Z'] : [], + }} + > + + + + ); + expect(queryByTestId(USER_DETAILS_TEST_ID)).not.toBeInTheDocument(); + expect(queryByTestId(HOST_DETAILS_TEST_ID)).not.toBeInTheDocument(); + }); + + it('does not render user and host details if @timestamp is not available', () => { + const { queryByTestId } = render( + + { + switch (fieldName) { + case 'host.name': + return ['host1']; + case 'user.name': + return ['user1']; + default: + return []; + } + }, + }} + > + + + + ); + expect(queryByTestId(USER_DETAILS_TEST_ID)).not.toBeInTheDocument(); + expect(queryByTestId(HOST_DETAILS_TEST_ID)).not.toBeInTheDocument(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/flyout/left/components/entities_details.tsx b/x-pack/plugins/security_solution/public/flyout/left/components/entities_details.tsx index 2109eb145a9a8..40a3f1823f4d3 100644 --- a/x-pack/plugins/security_solution/public/flyout/left/components/entities_details.tsx +++ b/x-pack/plugins/security_solution/public/flyout/left/components/entities_details.tsx @@ -6,7 +6,11 @@ */ import React from 'react'; -import { EuiText } from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { useLeftPanelContext } from '../context'; +import { getField } from '../../shared/utils'; +import { UserDetails } from './user_details'; +import { HostDetails } from './host_details'; import { ENTITIES_DETAILS_TEST_ID } from './test_ids'; export const ENTITIES_TAB_ID = 'entities-details'; @@ -15,7 +19,25 @@ export const ENTITIES_TAB_ID = 'entities-details'; * Entities displayed in the document details expandable flyout left section under the Insights tab */ export const EntitiesDetails: React.FC = () => { - return {'Entities'}; + const { getFieldsData } = useLeftPanelContext(); + const hostName = getField(getFieldsData('host.name')); + const userName = getField(getFieldsData('user.name')); + const timestamp = getField(getFieldsData('@timestamp')); + + return ( + + {userName && timestamp && ( + + + + )} + {hostName && timestamp && ( + + + + )} + + ); }; EntitiesDetails.displayName = 'EntitiesDetails'; diff --git a/x-pack/plugins/security_solution/public/flyout/left/components/host_details.test.tsx b/x-pack/plugins/security_solution/public/flyout/left/components/host_details.test.tsx new file mode 100644 index 0000000000000..f15c081c70d27 --- /dev/null +++ b/x-pack/plugins/security_solution/public/flyout/left/components/host_details.test.tsx @@ -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 React from 'react'; +import { render } from '@testing-library/react'; +import type { Anomalies } from '../../../common/components/ml/types'; +import { TestProviders } from '../../../common/mock'; +import { HostDetails } from './host_details'; +import { useMlCapabilities } from '../../../common/components/ml/hooks/use_ml_capabilities'; +import { useRiskScore } from '../../../explore/containers/risk_score'; +import { mockAnomalies } from '../../../common/components/ml/mock'; +import { useHostDetails } from '../../../explore/hosts/containers/hosts/details'; +import { useHostRelatedUsers } from '../../../common/containers/related_entities/related_users'; +import { RiskSeverity } from '../../../../common/search_strategy'; +import { + HOST_DETAILS_TEST_ID, + HOST_DETAILS_INFO_TEST_ID, + HOST_DETAILS_RELATED_USERS_TABLE_TEST_ID, +} from './test_ids'; + +jest.mock('react-router-dom', () => { + const actual = jest.requireActual('react-router-dom'); + return { ...actual, useLocation: jest.fn().mockReturnValue({ pathname: '' }) }; +}); + +const mockDispatch = jest.fn(); +jest.mock('react-redux', () => { + const original = jest.requireActual('react-redux'); + return { + ...original, + useDispatch: () => mockDispatch, + }; +}); + +const from = '2022-07-28T08:20:18.966Z'; +const to = '2022-07-28T08:20:18.966Z'; +jest.mock('../../../common/containers/use_global_time', () => { + const actual = jest.requireActual('../../../common/containers/use_global_time'); + return { + ...actual, + useGlobalTime: jest + .fn() + .mockReturnValue({ from, to, setQuery: jest.fn(), deleteQuery: jest.fn() }), + }; +}); + +jest.mock('uuid', () => ({ + v4: jest.fn().mockReturnValue('uuid'), +})); + +jest.mock('../../../common/components/ml/hooks/use_ml_capabilities'); +const mockUseMlUserPermissions = useMlCapabilities as jest.Mock; + +jest.mock('../../../common/containers/sourcerer', () => ({ + useSourcererDataView: jest.fn().mockReturnValue({ selectedPatterns: ['index'] }), +})); + +jest.mock('../../../common/components/ml/anomaly/anomaly_table_provider', () => ({ + AnomalyTableProvider: ({ + children, + }: { + children: (args: { + anomaliesData: Anomalies; + isLoadingAnomaliesData: boolean; + jobNameById: Record; + }) => React.ReactNode; + }) => children({ anomaliesData: mockAnomalies, isLoadingAnomaliesData: false, jobNameById: {} }), +})); + +jest.mock('../../../explore/hosts/containers/hosts/details'); +const mockUseHostDetails = useHostDetails as jest.Mock; + +jest.mock('../../../common/containers/related_entities/related_users'); +const mockUseHostsRelatedUsers = useHostRelatedUsers as jest.Mock; + +jest.mock('../../../explore/containers/risk_score'); +const mockUseRiskScore = useRiskScore as jest.Mock; + +const timestamp = '2022-07-25T08:20:18.966Z'; + +const defaultProps = { + hostName: 'test host', + timestamp, +}; + +const mockHostDetailsResponse = [ + false, + { + inspect: jest.fn(), + refetch: jest.fn(), + hostDetails: { host: { name: ['test host'] } }, + }, +]; + +const mockRiskScoreResponse = { + data: [ + { + host: { + name: 'test host', + risk: { calculated_level: 'low', calculated_score_norm: 38 }, + }, + }, + ], + isLicenseValid: true, +}; + +const mockRelatedUsersResponse = { + inspect: jest.fn(), + refetch: jest.fn(), + relatedUsers: [{ user: 'test user', ip: ['100.XXX.XXX'], risk: RiskSeverity.low }], + loading: false, +}; +describe('', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUseMlUserPermissions.mockReturnValue({ isPlatinumOrTrialLicense: false, capabilities: {} }); + mockUseHostDetails.mockReturnValue(mockHostDetailsResponse); + mockUseRiskScore.mockReturnValue(mockRiskScoreResponse); + mockUseHostsRelatedUsers.mockReturnValue(mockRelatedUsersResponse); + }); + + it('should render host details correctly', () => { + const { getByTestId } = render( + + + + ); + expect(getByTestId(HOST_DETAILS_TEST_ID)).toBeInTheDocument(); + }); + + describe('Host overview', () => { + it('should render the HostOverview with correct dates and indices', () => { + const { getByTestId } = render( + + + + ); + expect(mockUseHostDetails).toBeCalledWith({ + id: 'entities-hosts-details-uuid', + startDate: from, + endDate: to, + hostName: 'test host', + indexNames: ['index'], + skip: false, + }); + expect(getByTestId(HOST_DETAILS_INFO_TEST_ID)).toBeInTheDocument(); + }); + + it('should render host risk score when license is valid', () => { + mockUseMlUserPermissions.mockReturnValue({ + isPlatinumOrTrialLicense: true, + capabilities: {}, + }); + const { getByText } = render( + + + + ); + expect(getByText('Host risk score')).toBeInTheDocument(); + }); + + it('should not render host risk score when license is not valid', () => { + mockUseRiskScore.mockReturnValue({ data: [], isLicenseValid: false }); + const { queryByText } = render( + + + + ); + expect(queryByText('Host risk score')).not.toBeInTheDocument(); + }); + }); + + describe('Related users', () => { + it('should render the related user table with correct dates and indices', () => { + const { getByTestId } = render( + + + + ); + expect(mockUseHostsRelatedUsers).toBeCalledWith({ + from: timestamp, + hostName: 'test host', + indexNames: ['index'], + skip: false, + }); + expect(getByTestId(HOST_DETAILS_RELATED_USERS_TABLE_TEST_ID)).toBeInTheDocument(); + }); + + it('should render user risk score column when license is valid', () => { + mockUseMlUserPermissions.mockReturnValue({ + isPlatinumOrTrialLicense: true, + capabilities: {}, + }); + const { queryAllByRole } = render( + + + + ); + expect(queryAllByRole('columnheader').length).toBe(3); + expect(queryAllByRole('row')[1].textContent).toContain('test user'); + expect(queryAllByRole('row')[1].textContent).toContain('100.XXX.XXX'); + expect(queryAllByRole('row')[1].textContent).toContain('Low'); + }); + + it('should not render host risk score column when license is not valid', () => { + const { queryAllByRole } = render( + + + + ); + expect(queryAllByRole('columnheader').length).toBe(2); + }); + + it('should render empty table if no related user is returned', () => { + mockUseHostsRelatedUsers.mockReturnValue({ + ...mockRelatedUsersResponse, + relatedUsers: [], + loading: false, + }); + + const { getByTestId } = render( + + + + ); + expect(getByTestId(HOST_DETAILS_RELATED_USERS_TABLE_TEST_ID).textContent).toContain( + 'No items found' + ); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/flyout/left/components/host_details.tsx b/x-pack/plugins/security_solution/public/flyout/left/components/host_details.tsx new file mode 100644 index 0000000000000..2cd503caf5583 --- /dev/null +++ b/x-pack/plugins/security_solution/public/flyout/left/components/host_details.tsx @@ -0,0 +1,289 @@ +/* + * 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 React, { useCallback, useMemo } from 'react'; +import { useDispatch } from 'react-redux'; +import { v4 as uuid } from 'uuid'; +import { + EuiTitle, + EuiSpacer, + EuiInMemoryTable, + EuiHorizontalRule, + EuiText, + EuiFlexGroup, + EuiFlexItem, + EuiToolTip, + EuiIcon, +} from '@elastic/eui'; +import type { EuiBasicTableColumn } from '@elastic/eui'; +import type { RelatedUser } from '../../../../common/search_strategy/security_solution/related_entities/related_users'; +import type { RiskSeverity } from '../../../../common/search_strategy'; +import { EntityPanel } from '../../right/components/entity_panel'; +import { HostOverview } from '../../../overview/components/host_overview'; +import { AnomalyTableProvider } from '../../../common/components/ml/anomaly/anomaly_table_provider'; +import { InspectButton, InspectButtonContainer } from '../../../common/components/inspect'; +import { NetworkDetailsLink } from '../../../common/components/links'; +import { RiskScoreEntity } from '../../../../common/search_strategy'; +import { RiskScore } from '../../../explore/components/risk_score/severity/common'; +import { DefaultFieldRenderer } from '../../../timelines/components/field_renderers/field_renderers'; +import { InputsModelId } from '../../../common/store/inputs/constants'; +import { + SecurityCellActions, + CellActionsMode, + SecurityCellActionsTrigger, +} from '../../../common/components/cell_actions'; +import { useGlobalTime } from '../../../common/containers/use_global_time'; +import { useSourcererDataView } from '../../../common/containers/sourcerer'; +import { manageQuery } from '../../../common/components/page/manage_query'; +import { scoreIntervalToDateTime } from '../../../common/components/ml/score/score_interval_to_datetime'; +import { setAbsoluteRangeDatePicker } from '../../../common/store/inputs/actions'; +import { hostToCriteria } from '../../../common/components/ml/criteria/host_to_criteria'; +import { useHostDetails } from '../../../explore/hosts/containers/hosts/details'; +import { useHostRelatedUsers } from '../../../common/containers/related_entities/related_users'; +import { useMlCapabilities } from '../../../common/components/ml/hooks/use_ml_capabilities'; +import { getEmptyTagValue } from '../../../common/components/empty_value'; +import { HOST_DETAILS_TEST_ID, HOST_DETAILS_RELATED_USERS_TABLE_TEST_ID } from './test_ids'; +import { ENTITY_RISK_CLASSIFICATION } from '../../../explore/components/risk_score/translations'; +import { USER_RISK_TOOLTIP } from '../../../explore/users/components/all_users/translations'; +import * as i18n from './translations'; + +const HOST_DETAILS_ID = 'entities-hosts-details'; +const RELATED_USERS_ID = 'entities-hosts-related-users'; + +const HostOverviewManage = manageQuery(HostOverview); +const RelatedUsersManage = manageQuery(InspectButtonContainer); + +export interface HostDetailsProps { + /** + * Host name for the entities details + */ + hostName: string; + /** + * timestamp of alert or event + */ + timestamp: string; +} +/** + * Host details and related users, displayed in the document details expandable flyout left section under the Insights tab, Entities tab + */ +export const HostDetails: React.FC = ({ hostName, timestamp }) => { + const { to, from, deleteQuery, setQuery, isInitializing } = useGlobalTime(); + const { selectedPatterns } = useSourcererDataView(); + const dispatch = useDispatch(); + // create a unique, but stable (across re-renders) query id + const hostDetailsQueryId = useMemo(() => `${HOST_DETAILS_ID}-${uuid()}`, []); + const relatedUsersQueryId = useMemo(() => `${RELATED_USERS_ID}-${uuid()}`, []); + const isPlatinumOrTrialLicense = useMlCapabilities().isPlatinumOrTrialLicense; + const narrowDateRange = useCallback( + (score, interval) => { + const fromTo = scoreIntervalToDateTime(score, interval); + dispatch( + setAbsoluteRangeDatePicker({ + id: InputsModelId.global, + from: fromTo.from, + to: fromTo.to, + }) + ); + }, + [dispatch] + ); + + const [isHostLoading, { inspect, hostDetails, refetch }] = useHostDetails({ + id: hostDetailsQueryId, + startDate: from, + endDate: to, + hostName, + indexNames: selectedPatterns, + skip: selectedPatterns.length === 0, + }); + + const { + loading: isRelatedUsersLoading, + inspect: inspectRelatedUsers, + relatedUsers, + totalCount, + refetch: refetchRelatedUsers, + } = useHostRelatedUsers({ + hostName, + indexNames: selectedPatterns, + from: timestamp, // related users are users who were successfully authenticated onto this host AFTER alert time + skip: selectedPatterns.length === 0, + }); + + const relatedUsersColumns: Array> = useMemo( + () => [ + { + field: 'user', + name: i18n.RELATED_ENTITIES_NAME_COLUMN_TITLE, + render: (user: string) => ( + + + {user} + + + ), + }, + { + field: 'ip', + name: i18n.RELATED_ENTITIES_IP_COLUMN_TITLE, + render: (ips: string[]) => { + return ( + (ip != null ? : getEmptyTagValue())} + /> + ); + }, + }, + ...(isPlatinumOrTrialLicense + ? [ + { + field: 'risk', + name: ( + + <> + {ENTITY_RISK_CLASSIFICATION(RiskScoreEntity.user)}{' '} + + + + ), + truncateText: false, + mobileOptions: { show: true }, + sortable: false, + render: (riskScore: RiskSeverity) => { + if (riskScore != null) { + return ; + } + return getEmptyTagValue(); + }, + }, + ] + : []), + ], + [isPlatinumOrTrialLicense] + ); + + const relatedUsersCount = useMemo( + () => ( + + + + + + + {`${i18n.RELATED_USERS_TITLE}: ${totalCount}`} + + + + ), + [totalCount] + ); + + const pagination: {} = { + pageSize: 4, + showPerPageOptions: false, + }; + + return ( + <> + +

{i18n.HOSTS_TITLE}

+
+ + + +
{i18n.HOSTS_INFO_TITLE}
+
+ + + {({ isLoadingAnomaliesData, anomaliesData, jobNameById }) => ( + + )} + + + + + +
{i18n.RELATED_USERS_TITLE}
+
+
+ + + + + +
+ + + + + +
+ + ); +}; + +HostDetails.displayName = 'HostDetails'; diff --git a/x-pack/plugins/security_solution/public/flyout/left/components/test_ids.ts b/x-pack/plugins/security_solution/public/flyout/left/components/test_ids.ts index 61c0488bee2ee..7c9830b4602ce 100644 --- a/x-pack/plugins/security_solution/public/flyout/left/components/test_ids.ts +++ b/x-pack/plugins/security_solution/public/flyout/left/components/test_ids.ts @@ -5,13 +5,27 @@ * 2.0. */ +/* Visualization tab */ const PREFIX = 'securitySolutionDocumentDetailsFlyout' as const; export const ANALYZER_GRAPH_TEST_ID = `${PREFIX}AnalyzerGraph` as const; -export const ANALYZE_GRAPH_ERROR_TEST_ID = `${PREFIX}AnalyzerGraphError`; +export const ANALYZE_GRAPH_ERROR_TEST_ID = `${PREFIX}AnalyzerGraphError` as const; export const SESSION_VIEW_TEST_ID = `${PREFIX}SessionView` as const; export const SESSION_VIEW_ERROR_TEST_ID = `${PREFIX}SessionViewError` as const; + +/* Insights tab */ + +/* Entities */ export const ENTITIES_DETAILS_TEST_ID = `${PREFIX}EntitiesDetails` as const; +export const USER_DETAILS_TEST_ID = `${PREFIX}UsersDetails` as const; +export const USER_DETAILS_INFO_TEST_ID = 'user-overview'; +export const USER_DETAILS_RELATED_HOSTS_TABLE_TEST_ID = + `${PREFIX}UsersDetailsRelatedHostsTable` as const; +export const HOST_DETAILS_TEST_ID = `${PREFIX}HostsDetails` as const; +export const HOST_DETAILS_INFO_TEST_ID = 'host-overview'; +export const HOST_DETAILS_RELATED_USERS_TABLE_TEST_ID = + `${PREFIX}HostsDetailsRelatedUsersTable` as const; + export const THREAT_INTELLIGENCE_DETAILS_TEST_ID = `${PREFIX}ThreatIntelligenceDetails` as const; export const PREVALENCE_DETAILS_TEST_ID = `${PREFIX}PrevalenceDetails` as const; export const CORRELATIONS_DETAILS_TEST_ID = `${PREFIX}CorrelationsDetails` as const; diff --git a/x-pack/plugins/security_solution/public/flyout/left/components/translations.ts b/x-pack/plugins/security_solution/public/flyout/left/components/translations.ts index f82d34c859ddf..5d55aa81a0ef7 100644 --- a/x-pack/plugins/security_solution/public/flyout/left/components/translations.ts +++ b/x-pack/plugins/security_solution/public/flyout/left/components/translations.ts @@ -8,15 +8,79 @@ import { i18n } from '@kbn/i18n'; export const ANALYZER_ERROR_MESSAGE = i18n.translate( - 'xpack.securitySolution.flyout.analyzerErrorTitle', + 'xpack.securitySolution.flyout.analyzerErrorMessage', { defaultMessage: 'analyzer', } ); export const SESSION_VIEW_ERROR_MESSAGE = i18n.translate( - 'xpack.securitySolution.flyout.sessionViewErrorTitle', + 'xpack.securitySolution.flyout.sessionViewErrorMessage', { defaultMessage: 'session view', } ); + +export const USERS_TITLE = i18n.translate('xpack.securitySolution.flyout.entities.usersTitle', { + defaultMessage: 'Users', +}); + +export const USERS_INFO_TITLE = i18n.translate( + 'xpack.securitySolution.flyout.entities.usersInfoTitle', + { + defaultMessage: 'User info', + } +); + +export const RELATED_HOSTS_TITLE = i18n.translate( + 'xpack.securitySolution.flyout.entities.relatedHostsTitle', + { + defaultMessage: 'Related hosts', + } +); + +export const RELATED_HOSTS_TOOL_TIP = i18n.translate( + 'xpack.securitySolution.flyout.entities.relatedHostsToolTip', + { + defaultMessage: 'The user successfully authenticated to these hosts after the alert.', + } +); + +export const RELATED_ENTITIES_NAME_COLUMN_TITLE = i18n.translate( + 'xpack.securitySolution.flyout.entities.relatedEntitiesNameColumn', + { + defaultMessage: 'Name', + } +); + +export const RELATED_ENTITIES_IP_COLUMN_TITLE = i18n.translate( + 'xpack.securitySolution.flyout.entities.relatedEntitiesIpColumn', + { + defaultMessage: 'Ip addresses', + } +); + +export const HOSTS_TITLE = i18n.translate('xpack.securitySolution.flyout.entities.hostsTitle', { + defaultMessage: 'Hosts', +}); + +export const HOSTS_INFO_TITLE = i18n.translate( + 'xpack.securitySolution.flyout.entities.hostsInfoTitle', + { + defaultMessage: 'Host info', + } +); + +export const RELATED_USERS_TITLE = i18n.translate( + 'xpack.securitySolution.flyout.entities.relatedUsersTitle', + { + defaultMessage: 'Related users', + } +); + +export const RELATED_USERS_TOOL_TIP = i18n.translate( + 'xpack.securitySolution.flyout.entities.relatedUsersToolTip', + { + defaultMessage: 'These users successfully authenticated to the affected host after the alert.', + } +); diff --git a/x-pack/plugins/security_solution/public/flyout/left/components/user_details.test.tsx b/x-pack/plugins/security_solution/public/flyout/left/components/user_details.test.tsx new file mode 100644 index 0000000000000..e5bc253829373 --- /dev/null +++ b/x-pack/plugins/security_solution/public/flyout/left/components/user_details.test.tsx @@ -0,0 +1,236 @@ +/* + * 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 React from 'react'; +import { render } from '@testing-library/react'; +import type { Anomalies } from '../../../common/components/ml/types'; +import { TestProviders } from '../../../common/mock'; +import { UserDetails } from './user_details'; +import { useMlCapabilities } from '../../../common/components/ml/hooks/use_ml_capabilities'; +import { useRiskScore } from '../../../explore/containers/risk_score'; +import { mockAnomalies } from '../../../common/components/ml/mock'; +import { useObservedUserDetails } from '../../../explore/users/containers/users/observed_details'; +import { useUserRelatedHosts } from '../../../common/containers/related_entities/related_hosts'; +import { RiskSeverity } from '../../../../common/search_strategy'; +import { + USER_DETAILS_TEST_ID, + USER_DETAILS_INFO_TEST_ID, + USER_DETAILS_RELATED_HOSTS_TABLE_TEST_ID, +} from './test_ids'; + +jest.mock('react-router-dom', () => { + const actual = jest.requireActual('react-router-dom'); + return { ...actual, useLocation: jest.fn().mockReturnValue({ pathname: '' }) }; +}); + +const mockDispatch = jest.fn(); +jest.mock('react-redux', () => { + const original = jest.requireActual('react-redux'); + return { + ...original, + useDispatch: () => mockDispatch, + }; +}); + +const from = '2022-07-20T08:20:18.966Z'; +const to = '2022-07-28T08:20:18.966Z'; +jest.mock('../../../common/containers/use_global_time', () => { + const actual = jest.requireActual('../../../common/containers/use_global_time'); + return { + ...actual, + useGlobalTime: jest + .fn() + .mockReturnValue({ from, to, setQuery: jest.fn(), deleteQuery: jest.fn() }), + }; +}); + +jest.mock('uuid', () => ({ + v4: jest.fn().mockReturnValue('uuid'), +})); + +jest.mock('../../../common/components/ml/hooks/use_ml_capabilities'); +const mockUseMlUserPermissions = useMlCapabilities as jest.Mock; + +jest.mock('../../../common/containers/sourcerer', () => ({ + useSourcererDataView: jest.fn().mockReturnValue({ selectedPatterns: ['index'] }), +})); + +jest.mock('../../../common/components/ml/anomaly/anomaly_table_provider', () => ({ + AnomalyTableProvider: ({ + children, + }: { + children: (args: { + anomaliesData: Anomalies; + isLoadingAnomaliesData: boolean; + jobNameById: Record; + }) => React.ReactNode; + }) => children({ anomaliesData: mockAnomalies, isLoadingAnomaliesData: false, jobNameById: {} }), +})); + +jest.mock('../../../explore/users/containers/users/observed_details'); +const mockUseObservedUserDetails = useObservedUserDetails as jest.Mock; + +jest.mock('../../../common/containers/related_entities/related_hosts'); +const mockUseUsersRelatedHosts = useUserRelatedHosts as jest.Mock; + +jest.mock('../../../explore/containers/risk_score'); +const mockUseRiskScore = useRiskScore as jest.Mock; + +const timestamp = '2022-07-25T08:20:18.966Z'; + +const defaultProps = { + userName: 'test user', + timestamp, +}; + +const mockUserDetailsResponse = [ + false, + { + inspect: jest.fn(), + refetch: jest.fn(), + userDetails: { user: { name: ['test user'] } }, + }, +]; + +const mockRiskScoreResponse = { + data: [ + { + user: { + name: 'test user', + risk: { calculated_level: 'low', calculated_score_norm: 40 }, + }, + }, + ], + isLicenseValid: true, +}; + +const mockRelatedHostsResponse = { + inspect: jest.fn(), + refetch: jest.fn(), + relatedHosts: [{ host: 'test host', ip: ['100.XXX.XXX'], risk: RiskSeverity.low }], + loading: false, +}; + +describe('', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUseMlUserPermissions.mockReturnValue({ isPlatinumOrTrialLicense: false, capabilities: {} }); + mockUseObservedUserDetails.mockReturnValue(mockUserDetailsResponse); + mockUseRiskScore.mockReturnValue(mockRiskScoreResponse); + mockUseUsersRelatedHosts.mockReturnValue(mockRelatedHostsResponse); + }); + + it('should render host details correctly', () => { + const { getByTestId } = render( + + + + ); + expect(getByTestId(USER_DETAILS_TEST_ID)).toBeInTheDocument(); + }); + + describe('Host overview', () => { + it('should render the HostOverview with correct dates and indices', () => { + const { getByTestId } = render( + + + + ); + expect(mockUseObservedUserDetails).toBeCalledWith({ + id: 'entities-users-details-uuid', + startDate: from, + endDate: to, + userName: 'test user', + indexNames: ['index'], + skip: false, + }); + expect(getByTestId(USER_DETAILS_INFO_TEST_ID)).toBeInTheDocument(); + }); + + it('should render user risk score when license is valid', () => { + mockUseMlUserPermissions.mockReturnValue({ + isPlatinumOrTrialLicense: true, + capabilities: {}, + }); + const { getByText } = render( + + + + ); + expect(getByText('User risk score')).toBeInTheDocument(); + }); + + it('should not render user risk score when license is not valid', () => { + mockUseRiskScore.mockReturnValue({ data: [], isLicenseValid: false }); + const { queryByText } = render( + + + + ); + expect(queryByText('User risk score')).not.toBeInTheDocument(); + }); + }); + + describe('Related hosts', () => { + it('should render the related host table with correct dates and indices', () => { + const { getByTestId } = render( + + + + ); + expect(mockUseUsersRelatedHosts).toBeCalledWith({ + from: timestamp, + userName: 'test user', + indexNames: ['index'], + skip: false, + }); + expect(getByTestId(USER_DETAILS_RELATED_HOSTS_TABLE_TEST_ID)).toBeInTheDocument(); + }); + + it('should render host risk score column when license is valid', () => { + mockUseMlUserPermissions.mockReturnValue({ + isPlatinumOrTrialLicense: true, + capabilities: {}, + }); + const { queryAllByRole } = render( + + + + ); + expect(queryAllByRole('columnheader').length).toBe(3); + expect(queryAllByRole('row')[1].textContent).toContain('test host'); + expect(queryAllByRole('row')[1].textContent).toContain('100.XXX.XXX'); + expect(queryAllByRole('row')[1].textContent).toContain('Low'); + }); + + it('should not render host risk score column when license is not valid', () => { + const { queryAllByRole } = render( + + + + ); + expect(queryAllByRole('columnheader').length).toBe(2); + }); + + it('should render empty table if no related host is returned', () => { + mockUseUsersRelatedHosts.mockReturnValue({ + ...mockRelatedHostsResponse, + relatedHosts: [], + loading: false, + }); + + const { getByTestId } = render( + + + + ); + expect(getByTestId(USER_DETAILS_RELATED_HOSTS_TABLE_TEST_ID).textContent).toContain( + 'No items found' + ); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/flyout/left/components/user_details.tsx b/x-pack/plugins/security_solution/public/flyout/left/components/user_details.tsx new file mode 100644 index 0000000000000..0a77ddad023e3 --- /dev/null +++ b/x-pack/plugins/security_solution/public/flyout/left/components/user_details.tsx @@ -0,0 +1,288 @@ +/* + * 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 React, { useCallback, useMemo } from 'react'; +import { useDispatch } from 'react-redux'; +import { v4 as uuid } from 'uuid'; +import { + EuiTitle, + EuiSpacer, + EuiInMemoryTable, + EuiHorizontalRule, + EuiText, + EuiIcon, + EuiFlexGroup, + EuiFlexItem, + EuiToolTip, +} from '@elastic/eui'; +import type { EuiBasicTableColumn } from '@elastic/eui'; +import type { RelatedHost } from '../../../../common/search_strategy/security_solution/related_entities/related_hosts'; +import type { RiskSeverity } from '../../../../common/search_strategy'; +import { EntityPanel } from '../../right/components/entity_panel'; +import { UserOverview } from '../../../overview/components/user_overview'; +import { AnomalyTableProvider } from '../../../common/components/ml/anomaly/anomaly_table_provider'; +import { InspectButton, InspectButtonContainer } from '../../../common/components/inspect'; +import { NetworkDetailsLink } from '../../../common/components/links'; +import { RiskScoreEntity } from '../../../../common/search_strategy'; +import { RiskScore } from '../../../explore/components/risk_score/severity/common'; +import { DefaultFieldRenderer } from '../../../timelines/components/field_renderers/field_renderers'; +import { + SecurityCellActions, + CellActionsMode, + SecurityCellActionsTrigger, +} from '../../../common/components/cell_actions'; +import { InputsModelId } from '../../../common/store/inputs/constants'; +import { useGlobalTime } from '../../../common/containers/use_global_time'; +import { useSourcererDataView } from '../../../common/containers/sourcerer'; +import { scoreIntervalToDateTime } from '../../../common/components/ml/score/score_interval_to_datetime'; +import { setAbsoluteRangeDatePicker } from '../../../common/store/inputs/actions'; +import { hostToCriteria } from '../../../common/components/ml/criteria/host_to_criteria'; +import { manageQuery } from '../../../common/components/page/manage_query'; +import { useObservedUserDetails } from '../../../explore/users/containers/users/observed_details'; +import { useUserRelatedHosts } from '../../../common/containers/related_entities/related_hosts'; +import { useMlCapabilities } from '../../../common/components/ml/hooks/use_ml_capabilities'; +import { getEmptyTagValue } from '../../../common/components/empty_value'; +import { USER_DETAILS_RELATED_HOSTS_TABLE_TEST_ID, USER_DETAILS_TEST_ID } from './test_ids'; +import { ENTITY_RISK_CLASSIFICATION } from '../../../explore/components/risk_score/translations'; +import { HOST_RISK_TOOLTIP } from '../../../explore/hosts/components/hosts_table/translations'; +import * as i18n from './translations'; + +const USER_DETAILS_ID = 'entities-users-details'; +const RELATED_HOSTS_ID = 'entities-users-related-hosts'; + +const UserOverviewManage = manageQuery(UserOverview); +const RelatedHostsManage = manageQuery(InspectButtonContainer); + +export interface UserDetailsProps { + /** + * User name for the entities details + */ + userName: string; + /** + * timestamp of alert or event + */ + timestamp: string; +} +/** + * User details and related users, displayed in the document details expandable flyout left section under the Insights tab, Entities tab + */ +export const UserDetails: React.FC = ({ userName, timestamp }) => { + const { to, from, deleteQuery, setQuery, isInitializing } = useGlobalTime(); + const { selectedPatterns } = useSourcererDataView(); + const dispatch = useDispatch(); + // create a unique, but stable (across re-renders) query id + const userDetailsQueryId = useMemo(() => `${USER_DETAILS_ID}-${uuid()}`, []); + const relatedHostsQueryId = useMemo(() => `${RELATED_HOSTS_ID}-${uuid()}`, []); + const isPlatinumOrTrialLicense = useMlCapabilities().isPlatinumOrTrialLicense; + const narrowDateRange = useCallback( + (score, interval) => { + const fromTo = scoreIntervalToDateTime(score, interval); + dispatch( + setAbsoluteRangeDatePicker({ + id: InputsModelId.global, + from: fromTo.from, + to: fromTo.to, + }) + ); + }, + [dispatch] + ); + + const [isUserLoading, { inspect, userDetails, refetch }] = useObservedUserDetails({ + id: userDetailsQueryId, + startDate: from, + endDate: to, + userName, + indexNames: selectedPatterns, + skip: selectedPatterns.length === 0, + }); + + const { + loading: isRelatedHostLoading, + inspect: inspectRelatedHosts, + relatedHosts, + totalCount, + refetch: refetchRelatedHosts, + } = useUserRelatedHosts({ + userName, + indexNames: selectedPatterns, + from: timestamp, // related hosts are hosts this user has successfully authenticated onto AFTER alert time + skip: selectedPatterns.length === 0, + }); + + const relatedHostsColumns: Array> = useMemo( + () => [ + { + field: 'host', + name: i18n.RELATED_ENTITIES_NAME_COLUMN_TITLE, + render: (host: string) => ( + + + {host} + + + ), + }, + { + field: 'ip', + name: i18n.RELATED_ENTITIES_IP_COLUMN_TITLE, + render: (ips: string[]) => { + return ( + (ip != null ? : getEmptyTagValue())} + /> + ); + }, + }, + ...(isPlatinumOrTrialLicense + ? [ + { + field: 'risk', + name: ( + + <> + {ENTITY_RISK_CLASSIFICATION(RiskScoreEntity.host)}{' '} + + + + ), + truncateText: false, + mobileOptions: { show: true }, + sortable: false, + render: (riskScore: RiskSeverity) => { + if (riskScore != null) { + return ; + } + return getEmptyTagValue(); + }, + }, + ] + : []), + ], + [isPlatinumOrTrialLicense] + ); + + const relatedHostsCount = useMemo( + () => ( + + + + + + + {`${i18n.RELATED_HOSTS_TITLE}: ${totalCount}`} + + + + ), + [totalCount] + ); + + const pagination: {} = { + pageSize: 4, + showPerPageOptions: false, + }; + + return ( + <> + +

{i18n.USERS_TITLE}

+
+ + + +
{i18n.USERS_INFO_TITLE}
+
+ + + {({ isLoadingAnomaliesData, anomaliesData, jobNameById }) => ( + + )} + + + + + +
{i18n.RELATED_HOSTS_TITLE}
+
+
+ + + + + +
+ + + + + +
+ + ); +}; + +UserDetails.displayName = 'UserDetails'; diff --git a/x-pack/plugins/security_solution/public/flyout/left/context.tsx b/x-pack/plugins/security_solution/public/flyout/left/context.tsx index 64662c17305ca..d89955ea565a6 100644 --- a/x-pack/plugins/security_solution/public/flyout/left/context.tsx +++ b/x-pack/plugins/security_solution/public/flyout/left/context.tsx @@ -6,18 +6,18 @@ */ import React, { createContext, useContext, useMemo } from 'react'; +import { css } from '@emotion/react'; import type { TimelineEventsDetailsItem } from '@kbn/timelines-plugin/common'; import { EuiFlexItem, EuiLoadingSpinner } from '@elastic/eui'; -import { css } from '@emotion/react'; +import type { LeftPanelProps } from '.'; +import { useGetFieldsData } from '../../common/hooks/use_get_fields_data'; +import { useTimelineEventsDetails } from '../../timelines/containers/details'; +import { getAlertIndexAlias } from '../../timelines/components/side_panel/event_details/helpers'; +import { useSpaceId } from '../../common/hooks/use_space_id'; +import { useRouteSpy } from '../../common/utils/route/use_route_spy'; import { SecurityPageName } from '../../../common/constants'; import { SourcererScopeName } from '../../common/store/sourcerer/model'; import { useSourcererDataView } from '../../common/containers/sourcerer'; -import { useTimelineEventsDetails } from '../../timelines/containers/details'; -import { useGetFieldsData } from '../../common/hooks/use_get_fields_data'; -import { useRouteSpy } from '../../common/utils/route/use_route_spy'; -import { useSpaceId } from '../../common/hooks/use_space_id'; -import { getAlertIndexAlias } from '../../timelines/components/side_panel/event_details/helpers'; -import type { LeftPanelProps } from '.'; export interface LeftPanelContext { /** diff --git a/x-pack/plugins/security_solution/public/flyout/left/mocks/mock_context.ts b/x-pack/plugins/security_solution/public/flyout/left/mocks/mock_context.ts new file mode 100644 index 0000000000000..754b7c9c8ade2 --- /dev/null +++ b/x-pack/plugins/security_solution/public/flyout/left/mocks/mock_context.ts @@ -0,0 +1,41 @@ +/* + * 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 { ALERT_RISK_SCORE, ALERT_SEVERITY } from '@kbn/rule-data-utils'; +import type { LeftPanelContext } from '../context'; + +/** + * Returns mocked data for field (mock this method: x-pack/plugins/security_solution/public/common/hooks/use_get_fields_data.ts) + * @param field + * @returns string[] + */ +export const mockGetFieldsData = (field: string): string[] => { + switch (field) { + case ALERT_SEVERITY: + return ['low']; + case ALERT_RISK_SCORE: + return ['0']; + case 'host.name': + return ['host1']; + case 'user.name': + return ['user1']; + case '@timestamp': + return ['2022-07-25T08:20:18.966Z']; + default: + return []; + } +}; + +/** + * Mock contextValue for left panel context + */ +export const mockContextValue: LeftPanelContext = { + eventId: 'eventId', + indexName: 'index', + getFieldsData: mockGetFieldsData, + dataFormattedForFieldBrowser: null, +}; diff --git a/x-pack/plugins/security_solution/public/flyout/right/components/entities_overview.test.tsx b/x-pack/plugins/security_solution/public/flyout/right/components/entities_overview.test.tsx index fc98b138879b7..d059b5180abab 100644 --- a/x-pack/plugins/security_solution/public/flyout/right/components/entities_overview.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/right/components/entities_overview.test.tsx @@ -10,7 +10,8 @@ import { render } from '@testing-library/react'; import { RightPanelContext } from '../context'; import { ENTITIES_HEADER_TEST_ID, - ENTITY_PANEL_TEST_ID, + ENTITIES_USER_CONTENT_TEST_ID, + ENTITIES_HOST_CONTENT_TEST_ID, ENTITIES_HOST_OVERVIEW_TEST_ID, ENTITIES_USER_OVERVIEW_TEST_ID, } from './test_ids'; @@ -25,7 +26,7 @@ describe('', () => { getFieldsData: mockGetFieldsData, } as unknown as RightPanelContext; - const { getByTestId, queryByText, getAllByTestId } = render( + const { getByTestId } = render( @@ -33,11 +34,8 @@ describe('', () => { ); expect(getByTestId(ENTITIES_HEADER_TEST_ID)).toHaveTextContent('Entities'); - expect(getAllByTestId(ENTITY_PANEL_TEST_ID)).toHaveLength(2); - expect(queryByText('user1')).toBeInTheDocument(); - expect(getByTestId(ENTITIES_USER_OVERVIEW_TEST_ID)).toBeInTheDocument(); - expect(queryByText('host1')).toBeInTheDocument(); - expect(getByTestId(ENTITIES_HOST_OVERVIEW_TEST_ID)).toBeInTheDocument(); + expect(getByTestId(ENTITIES_USER_CONTENT_TEST_ID)).toBeInTheDocument(); + expect(getByTestId(ENTITIES_HOST_CONTENT_TEST_ID)).toBeInTheDocument(); }); it('should only render user when host name is null', () => { @@ -46,7 +44,7 @@ describe('', () => { getFieldsData: (field: string) => (field === 'user.name' ? 'user1' : null), } as unknown as RightPanelContext; - const { queryByTestId, queryByText, getAllByTestId } = render( + const { queryByTestId, queryByText, getByTestId } = render( @@ -54,8 +52,8 @@ describe('', () => { ); - expect(queryByTestId(ENTITY_PANEL_TEST_ID)).toBeInTheDocument(); - expect(getAllByTestId(ENTITY_PANEL_TEST_ID)).toHaveLength(1); + expect(getByTestId(ENTITIES_USER_CONTENT_TEST_ID)).toBeInTheDocument(); + expect(queryByTestId(ENTITIES_HOST_CONTENT_TEST_ID)).not.toBeInTheDocument(); expect(queryByText('user1')).toBeInTheDocument(); expect(queryByTestId(ENTITIES_USER_OVERVIEW_TEST_ID)).toBeInTheDocument(); }); @@ -66,7 +64,7 @@ describe('', () => { getFieldsData: (field: string) => (field === 'host.name' ? 'host1' : null), } as unknown as RightPanelContext; - const { queryByTestId, queryByText, getAllByTestId } = render( + const { queryByTestId, queryByText, getByTestId } = render( @@ -74,8 +72,8 @@ describe('', () => { ); - expect(queryByTestId(ENTITY_PANEL_TEST_ID)).toBeInTheDocument(); - expect(getAllByTestId(ENTITY_PANEL_TEST_ID)).toHaveLength(1); + expect(getByTestId(ENTITIES_HOST_CONTENT_TEST_ID)).toBeInTheDocument(); + expect(queryByTestId(ENTITIES_USER_CONTENT_TEST_ID)).not.toBeInTheDocument(); expect(queryByText('host1')).toBeInTheDocument(); expect(queryByTestId(ENTITIES_HOST_OVERVIEW_TEST_ID)).toBeInTheDocument(); }); @@ -95,6 +93,8 @@ describe('', () => { ); expect(queryByTestId(ENTITIES_HEADER_TEST_ID)).not.toBeInTheDocument(); + expect(queryByTestId(ENTITIES_HOST_CONTENT_TEST_ID)).not.toBeInTheDocument(); + expect(queryByTestId(ENTITIES_USER_CONTENT_TEST_ID)).not.toBeInTheDocument(); }); it('should not render if eventId is null', () => { diff --git a/x-pack/plugins/security_solution/public/flyout/right/components/entities_overview.tsx b/x-pack/plugins/security_solution/public/flyout/right/components/entities_overview.tsx index b8ebe27ddd7c4..b0a8d5c2faeb2 100644 --- a/x-pack/plugins/security_solution/public/flyout/right/components/entities_overview.tsx +++ b/x-pack/plugins/security_solution/public/flyout/right/components/entities_overview.tsx @@ -12,6 +12,8 @@ import { useRightPanelContext } from '../context'; import { ENTITIES_HEADER_TEST_ID, ENTITIES_CONTENT_TEST_ID, + ENTITIES_HOST_CONTENT_TEST_ID, + ENTITIES_USER_CONTENT_TEST_ID, ENTITIES_VIEW_ALL_BUTTON_TEST_ID, } from './test_ids'; import { ENTITIES_TITLE, ENTITIES_TEXT, VIEW_ALL } from './translations'; @@ -61,8 +63,10 @@ export const EntitiesOverview: React.FC = () => { } - /> + data-test-subj={ENTITIES_USER_CONTENT_TEST_ID} + > + + )} {hostName && ( @@ -70,8 +74,10 @@ export const EntitiesOverview: React.FC = () => { } - /> + data-test-subj={ENTITIES_HOST_CONTENT_TEST_ID} + > + + )} ; + +const children =

{'test content'}

; export const Default: Story = () => { - return ; + return {children}; +}; + +export const DefaultWithHeaderContent: Story = () => { + return ( + + {children} + + ); }; export const Expandable: Story = () => { - return ; + return ( + + {children} + + ); }; export const ExpandableDefaultOpen: Story = () => { - return ; + return ( + + {children} + + ); }; export const EmptyDefault: Story = () => { - return ; + return ; }; export const EmptyDefaultExpanded: Story = () => { - return ; + return ; }; diff --git a/x-pack/plugins/security_solution/public/flyout/right/components/entity_panel.test.tsx b/x-pack/plugins/security_solution/public/flyout/right/components/entity_panel.test.tsx index 0861c3682d555..5eedc99cf5e61 100644 --- a/x-pack/plugins/security_solution/public/flyout/right/components/entity_panel.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/right/components/entity_panel.test.tsx @@ -9,37 +9,66 @@ import React from 'react'; import { render } from '@testing-library/react'; import { EntityPanel } from './entity_panel'; import { - ENTITY_PANEL_TEST_ID, - ENTITY_PANEL_ICON_TEST_ID, ENTITY_PANEL_TOGGLE_BUTTON_TEST_ID, ENTITY_PANEL_HEADER_TEST_ID, + ENTITY_PANEL_HEADER_LEFT_SECTION_TEST_ID, + ENTITY_PANEL_HEADER_RIGHT_SECTION_TEST_ID, ENTITY_PANEL_CONTENT_TEST_ID, } from './test_ids'; +import { ThemeProvider } from 'styled-components'; +import { getMockTheme } from '../../../common/lib/kibana/kibana_react.mock'; +const mockTheme = getMockTheme({ eui: { euiColorMediumShade: '#ece' } }); +const ENTITY_PANEL_TEST_ID = 'entityPanel'; const defaultProps = { title: 'test', iconType: 'storage', - content: 'test content', + 'data-test-subj': ENTITY_PANEL_TEST_ID, }; +const children =

{'test content'}

; describe('', () => { describe('panel is not expandable by default', () => { it('should render non-expandable panel by default', () => { - const { getByTestId, queryByTestId } = render(); - + const { getByTestId, queryByTestId } = render( + + {children} + + ); expect(getByTestId(ENTITY_PANEL_TEST_ID)).toBeInTheDocument(); - expect(getByTestId(ENTITY_PANEL_HEADER_TEST_ID)).toHaveTextContent('test'); + expect(getByTestId(ENTITY_PANEL_HEADER_TEST_ID)).toBeInTheDocument(); expect(getByTestId(ENTITY_PANEL_CONTENT_TEST_ID)).toHaveTextContent('test content'); - expect(queryByTestId(ENTITY_PANEL_TOGGLE_BUTTON_TEST_ID)).not.toBeInTheDocument(); - expect(getByTestId(ENTITY_PANEL_ICON_TEST_ID).firstChild).toHaveAttribute( - 'data-euiicon-type', - 'storage' + }); + + it('should only render left section of panel header when headerContent is not passed', () => { + const { getByTestId, queryByTestId } = render( + + {children} + ); + expect(getByTestId(ENTITY_PANEL_HEADER_LEFT_SECTION_TEST_ID)).toHaveTextContent('test'); + expect(queryByTestId(ENTITY_PANEL_HEADER_RIGHT_SECTION_TEST_ID)).not.toBeInTheDocument(); + }); + + it('should render header properly when headerContent is available', () => { + const { getByTestId } = render( + + {'test header content'}}> + {children} + + + ); + expect(getByTestId(ENTITY_PANEL_HEADER_LEFT_SECTION_TEST_ID)).toBeInTheDocument(); + expect(getByTestId(ENTITY_PANEL_HEADER_RIGHT_SECTION_TEST_ID)).toBeInTheDocument(); }); it('should not render content when content is null', () => { - const { queryByTestId } = render(); + const { queryByTestId } = render( + + + + ); expect(queryByTestId(ENTITY_PANEL_CONTENT_TEST_ID)).not.toBeInTheDocument(); expect(queryByTestId(ENTITY_PANEL_TOGGLE_BUTTON_TEST_ID)).not.toBeInTheDocument(); @@ -49,7 +78,11 @@ describe('', () => { describe('panel is expandable', () => { it('should render panel with toggle and collapsed by default', () => { const { getByTestId, queryByTestId } = render( - + + + {children} + + ); expect(getByTestId(ENTITY_PANEL_TEST_ID)).toBeInTheDocument(); expect(getByTestId(ENTITY_PANEL_HEADER_TEST_ID)).toHaveTextContent('test'); @@ -57,7 +90,13 @@ describe('', () => { }); it('click toggle button should expand the panel', () => { - const { getByTestId } = render(); + const { getByTestId } = render( + + + {children} + + + ); const toggle = getByTestId(ENTITY_PANEL_TOGGLE_BUTTON_TEST_ID); expect(toggle.firstChild).toHaveAttribute('data-euiicon-type', 'arrowRight'); @@ -69,7 +108,9 @@ describe('', () => { it('should not render toggle or content when content is null', () => { const { queryByTestId } = render( - + + + ); expect(queryByTestId(ENTITY_PANEL_TOGGLE_BUTTON_TEST_ID)).not.toBeInTheDocument(); expect(queryByTestId(ENTITY_PANEL_CONTENT_TEST_ID)).not.toBeInTheDocument(); @@ -79,7 +120,11 @@ describe('', () => { describe('panel is expandable and expanded by default', () => { it('should render header and content', () => { const { getByTestId } = render( - + + + {children} + + ); expect(getByTestId(ENTITY_PANEL_TEST_ID)).toBeInTheDocument(); expect(getByTestId(ENTITY_PANEL_HEADER_TEST_ID)).toHaveTextContent('test'); @@ -89,7 +134,11 @@ describe('', () => { it('click toggle button should collapse the panel', () => { const { getByTestId, queryByTestId } = render( - + + + {children} + + ); const toggle = getByTestId(ENTITY_PANEL_TOGGLE_BUTTON_TEST_ID); @@ -103,7 +152,9 @@ describe('', () => { it('should not render content when content is null', () => { const { queryByTestId } = render( - + + + ); expect(queryByTestId(ENTITY_PANEL_TOGGLE_BUTTON_TEST_ID)).not.toBeInTheDocument(); expect(queryByTestId(ENTITY_PANEL_CONTENT_TEST_ID)).not.toBeInTheDocument(); diff --git a/x-pack/plugins/security_solution/public/flyout/right/components/entity_panel.tsx b/x-pack/plugins/security_solution/public/flyout/right/components/entity_panel.tsx index 4321939a487c3..d095bf72e4c39 100644 --- a/x-pack/plugins/security_solution/public/flyout/right/components/entity_panel.tsx +++ b/x-pack/plugins/security_solution/public/flyout/right/components/entity_panel.tsx @@ -14,15 +14,25 @@ import { EuiFlexItem, EuiTitle, EuiPanel, + EuiIcon, } from '@elastic/eui'; +import styled from 'styled-components'; import { - ENTITY_PANEL_TEST_ID, - ENTITY_PANEL_ICON_TEST_ID, ENTITY_PANEL_TOGGLE_BUTTON_TEST_ID, ENTITY_PANEL_HEADER_TEST_ID, + ENTITY_PANEL_HEADER_LEFT_SECTION_TEST_ID, + ENTITY_PANEL_HEADER_RIGHT_SECTION_TEST_ID, ENTITY_PANEL_CONTENT_TEST_ID, } from './test_ids'; +const PanelHeaderRightSectionWrapper = styled(EuiFlexItem)` + margin-right: ${({ theme }) => theme.eui.euiSizeM}; +`; + +const IconWrapper = styled(EuiIcon)` + margin: ${({ theme }) => theme.eui.euiSizeS} 0; +`; + export interface EntityPanelProps { /** * String value of the title to be displayed in the header of panel @@ -32,10 +42,6 @@ export interface EntityPanelProps { * Icon string for displaying the specified icon in the header */ iconType: string; - /** - * Content to show in the content section of the panel - */ - content?: string | React.ReactNode; /** * Boolean to determine the panel to be collapsable (with toggle) */ @@ -44,6 +50,14 @@ export interface EntityPanelProps { * Boolean to allow the component to be expanded or collapsed on first render */ expanded?: boolean; + /** + Optional content and actions to be displayed on the right side of header + */ + headerContent?: React.ReactNode; + /** + Data test subject string for testing + */ + ['data-test-subj']?: string; } /** @@ -52,9 +66,11 @@ export interface EntityPanelProps { export const EntityPanel: React.FC = ({ title, iconType, - content, + children, expandable = false, expanded = false, + headerContent, + 'data-test-subj': dataTestSub, }) => { const [toggleStatus, setToggleStatus] = useState(expanded); const toggleQuery = useCallback(() => { @@ -63,67 +79,78 @@ export const EntityPanel: React.FC = ({ const toggleIcon = useMemo( () => ( - - - - ), - [toggleStatus, toggleQuery] - ); - - const icon = useMemo(() => { - return ( - ); - }, [iconType]); + ), + [toggleStatus, toggleQuery] + ); + + const headerLeftSection = useMemo( + () => ( + + + {expandable && children && toggleIcon} + + + + + + {title} + + + + + ), + [title, children, toggleIcon, expandable, iconType] + ); + + const headerRightSection = useMemo( + () => + headerContent && ( + + {headerContent} + + ), + [headerContent] + ); const showContent = useMemo(() => { - if (!content) { + if (!children) { return false; } return !expandable || (expandable && toggleStatus); - }, [content, expandable, toggleStatus]); + }, [children, expandable, toggleStatus]); - const panelHeader = useMemo(() => { - return ( - + - {expandable && content && toggleIcon} - {icon} - - - {title} - - - - ); - }, [title, icon, content, toggleIcon, expandable]); - - return ( - - - {panelHeader} + + {headerLeftSection} + {headerRightSection} + {showContent && ( - {content} + {children} )} diff --git a/x-pack/plugins/security_solution/public/flyout/right/components/test_ids.ts b/x-pack/plugins/security_solution/public/flyout/right/components/test_ids.ts index 14fd95b17217e..3829db52d6280 100644 --- a/x-pack/plugins/security_solution/public/flyout/right/components/test_ids.ts +++ b/x-pack/plugins/security_solution/public/flyout/right/components/test_ids.ts @@ -61,14 +61,20 @@ export const INSIGHTS_TEST_ID = 'securitySolutionDocumentDetailsFlyoutInsights'; export const INSIGHTS_HEADER_TEST_ID = 'securitySolutionDocumentDetailsFlyoutInsightsHeader'; export const ENTITIES_HEADER_TEST_ID = 'securitySolutionDocumentDetailsFlyoutEntitiesHeader'; export const ENTITIES_CONTENT_TEST_ID = 'securitySolutionDocumentDetailsFlyoutEntitiesContent'; +export const ENTITIES_USER_CONTENT_TEST_ID = + 'securitySolutionDocumentDetailsFlyoutEntitiesUserContent'; +export const ENTITIES_HOST_CONTENT_TEST_ID = + 'securitySolutionDocumentDetailsFlyoutEntitiesHostContent'; export const ENTITIES_VIEW_ALL_BUTTON_TEST_ID = 'securitySolutionDocumentDetailsFlyoutEntitiesViewAllButton'; -export const ENTITY_PANEL_TEST_ID = 'securitySolutionDocumentDetailsFlyoutEntityPanel'; export const ENTITY_PANEL_ICON_TEST_ID = 'securitySolutionDocumentDetailsFlyoutEntityPanelTypeIcon'; export const ENTITY_PANEL_TOGGLE_BUTTON_TEST_ID = 'securitySolutionDocumentDetailsFlyoutEntityPanelToggleButton'; -export const ENTITY_PANEL_HEADER_TEST_ID = - 'securitySolutionDocumentDetailsFlyoutEntityPanelHeaderTitle'; +export const ENTITY_PANEL_HEADER_TEST_ID = 'securitySolutionDocumentDetailsFlyoutEntityPanelHeader'; +export const ENTITY_PANEL_HEADER_LEFT_SECTION_TEST_ID = + 'securitySolutionDocumentDetailsFlyoutEntityPanelHeaderLeftSection'; +export const ENTITY_PANEL_HEADER_RIGHT_SECTION_TEST_ID = + 'securitySolutionDocumentDetailsFlyoutEntityPanelHeaderRightSection'; export const ENTITY_PANEL_CONTENT_TEST_ID = 'securitySolutionDocumentDetailsFlyoutEntityPanelContent'; export const TECHNICAL_PREVIEW_ICON_TEST_ID = diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/all/index.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/all/index.ts index 86a3cfae8b4f3..6c1c661cbfa54 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/all/index.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/all/index.ts @@ -110,7 +110,7 @@ async function enhanceEdges( : edges; } -async function getHostRiskData( +export async function getHostRiskData( esClient: IScopedClusterClient, spaceId: string, hostNames: string[] diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/index.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/index.ts index 3ef80e3fa909c..a24c22aa8dfe0 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/index.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/index.ts @@ -15,6 +15,7 @@ import { ctiFactoryTypes } from './cti'; import { riskScoreFactory } from './risk_score'; import { usersFactory } from './users'; import { firstLastSeenFactory } from './last_first_seen'; +import { relatedEntitiesFactory } from './related_entities'; import { responseActionsFactory } from './response_actions'; export const securitySolutionFactory: Record< @@ -28,5 +29,6 @@ export const securitySolutionFactory: Record< ...ctiFactoryTypes, ...riskScoreFactory, ...firstLastSeenFactory, + ...relatedEntitiesFactory, ...responseActionsFactory, }; diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/related_entities/index.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/related_entities/index.ts new file mode 100644 index 0000000000000..2d948436afb20 --- /dev/null +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/related_entities/index.ts @@ -0,0 +1,21 @@ +/* + * 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 { FactoryQueryTypes } from '../../../../../common/search_strategy/security_solution'; +import { RelatedEntitiesQueries } from '../../../../../common/search_strategy/security_solution/related_entities'; + +import type { SecuritySolutionFactory } from '../types'; +import { hostsRelatedUsers } from './related_users'; +import { usersRelatedHosts } from './related_hosts'; + +export const relatedEntitiesFactory: Record< + RelatedEntitiesQueries, + SecuritySolutionFactory +> = { + [RelatedEntitiesQueries.relatedHosts]: usersRelatedHosts, + [RelatedEntitiesQueries.relatedUsers]: hostsRelatedUsers, +}; diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/related_entities/related_hosts/__mocks__/index.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/related_entities/related_hosts/__mocks__/index.ts new file mode 100644 index 0000000000000..aad8e3a6f1cdc --- /dev/null +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/related_entities/related_hosts/__mocks__/index.ts @@ -0,0 +1,158 @@ +/* + * 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 { KibanaRequest } from '@kbn/core-http-server'; +import type { IEsSearchResponse } from '@kbn/data-plugin/common'; +import type { SavedObjectsClientContract } from '@kbn/core-saved-objects-api-server'; +import type { EndpointAppContextService } from '../../../../../../endpoint/endpoint_app_context_services'; +import type { EndpointAppContext } from '../../../../../../endpoint/types'; +import type { UsersRelatedHostsRequestOptions } from '../../../../../../../common/search_strategy/security_solution/related_entities/related_hosts'; +import { RelatedEntitiesQueries } from '../../../../../../../common/search_strategy/security_solution/related_entities'; +import { elasticsearchServiceMock } from '@kbn/core/server/mocks'; +import { allowedExperimentalValues } from '../../../../../../../common/experimental_features'; +import { createMockConfig } from '../../../../../../lib/detection_engine/routes/__mocks__'; + +export const mockOptions: UsersRelatedHostsRequestOptions = { + defaultIndex: ['test_indices*'], + factoryQueryType: RelatedEntitiesQueries.relatedHosts, + userName: 'user1', + from: '2020-09-02T15:17:13.678Z', +}; + +export const mockSearchStrategyResponse: IEsSearchResponse = { + rawResponse: { + took: 2, + timed_out: false, + _shards: { + total: 2, + successful: 2, + skipped: 1, + failed: 0, + }, + hits: { + max_score: null, + hits: [], + }, + aggregations: { + host_count: { + value: 2, + }, + host_data: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'Host-2qia8v8mzl', + doc_count: 6, + ip: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: '10.7.58.35', + doc_count: 6, + }, + { + key: '10.185.185.41', + doc_count: 6, + }, + { + key: '10.198.197.106', + doc_count: 6, + }, + ], + }, + }, + { + key: 'Host-ly6nig20ty', + doc_count: 6, + ip: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: '10.7.58.35', + doc_count: 6, + }, + { + key: '10.185.185.41', + doc_count: 6, + }, + { + key: '10.198.197.106', + doc_count: 6, + }, + ], + }, + }, + ], + }, + }, + }, +}; + +export const mockDeps = () => ({ + esClient: elasticsearchServiceMock.createScopedClusterClient(), + savedObjectsClient: {} as SavedObjectsClientContract, + endpointContext: { + logFactory: { + get: jest.fn(), + }, + config: jest.fn().mockResolvedValue({}), + experimentalFeatures: { + ...allowedExperimentalValues, + }, + service: {} as EndpointAppContextService, + serverConfig: createMockConfig(), + } as EndpointAppContext, + request: {} as KibanaRequest, + spaceId: 'test-space', +}); + +export const expectedDsl = { + allow_no_indices: true, + track_total_hits: false, + body: { + aggregations: { + host_count: { cardinality: { field: 'host.name' } }, + host_data: { + terms: { field: 'host.name', size: 1000 }, + aggs: { + ip: { terms: { field: 'host.ip', size: 10 } }, + }, + }, + }, + query: { + bool: { + filter: [ + { term: { 'user.name': 'user1' } }, + { term: { 'event.category': 'authentication' } }, + { term: { 'event.outcome': 'success' } }, + { + range: { + '@timestamp': { + format: 'strict_date_optional_time', + gt: '2020-09-02T15:17:13.678Z', + }, + }, + }, + ], + }, + }, + size: 0, + }, + ignore_unavailable: true, + index: ['test_indices*'], +}; + +export const mockRelatedHosts = [ + { host: 'Host-2qia8v8mzl', ip: ['10.7.58.35', '10.185.185.41', '10.198.197.106'] }, + { + host: 'Host-ly6nig20ty', + ip: ['10.7.58.35', '10.185.185.41', '10.198.197.106'], + }, +]; diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/related_entities/related_hosts/index.test.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/related_entities/related_hosts/index.test.ts new file mode 100644 index 0000000000000..9e59bea5b8be0 --- /dev/null +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/related_entities/related_hosts/index.test.ts @@ -0,0 +1,88 @@ +/* + * 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 { usersRelatedHosts } from '.'; +import { mockDeps, mockOptions, mockSearchStrategyResponse, mockRelatedHosts } from './__mocks__'; +import { get } from 'lodash/fp'; +import * as buildQuery from './query.related_hosts.dsl'; + +describe('usersRelatedHosts search strategy', () => { + const buildRelatedHostsQuery = jest.spyOn(buildQuery, 'buildRelatedHostsQuery'); + + afterEach(() => { + buildRelatedHostsQuery.mockClear(); + }); + + describe('buildDsl', () => { + test('should build dsl query', () => { + usersRelatedHosts.buildDsl(mockOptions); + expect(buildRelatedHostsQuery).toHaveBeenCalledWith(mockOptions); + }); + }); + + describe('parse', () => { + test('should parse data correctly', async () => { + const result = await usersRelatedHosts.parse(mockOptions, mockSearchStrategyResponse); + expect(result.relatedHosts).toMatchObject(mockRelatedHosts); + expect(result.totalCount).toBe(2); + }); + + test('should enhance data with risk score', async () => { + const risk = 'TEST_RISK_SCORE'; + const hostName: string = get( + `aggregations.host_data.buckets[0].key`, + mockSearchStrategyResponse.rawResponse + ); + + const mockedDeps = mockDeps(); + + mockedDeps.esClient.asCurrentUser.search.mockResponse({ + hits: { + hits: [ + { + _id: 'id', + _index: 'index', + _source: { + risk, + host: { + name: hostName, + risk: { + multipliers: [], + calculated_score_norm: 9999, + calculated_level: risk, + rule_risks: [], + }, + }, + }, + }, + ], + }, + took: 2, + _shards: { failed: 0, successful: 2, total: 2 }, + timed_out: false, + }); + + const result = await usersRelatedHosts.parse( + mockOptions, + mockSearchStrategyResponse, + mockedDeps + ); + + expect(result.relatedHosts[0].risk).toBe(risk); + }); + + test("should not enhance data when space id doesn't exist", async () => { + const mockedDeps = mockDeps(); + const result = await usersRelatedHosts.parse(mockOptions, mockSearchStrategyResponse, { + ...mockedDeps, + spaceId: undefined, + }); + + expect(result.relatedHosts[0].risk).toBeUndefined(); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/related_entities/related_hosts/index.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/related_entities/related_hosts/index.ts new file mode 100644 index 0000000000000..941faa675482f --- /dev/null +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/related_entities/related_hosts/index.ts @@ -0,0 +1,92 @@ +/* + * 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 { IEsSearchResponse } from '@kbn/data-plugin/common'; +import type { IScopedClusterClient } from '@kbn/core/server'; +import { getOr } from 'lodash/fp'; +import type { RiskSeverity } from '../../../../../../common/search_strategy/security_solution/risk_score/all'; +import type { SecuritySolutionFactory } from '../../types'; +import type { EndpointAppContext } from '../../../../../endpoint/types'; +import type { RelatedEntitiesQueries } from '../../../../../../common/search_strategy/security_solution/related_entities'; +import type { + UsersRelatedHostsRequestOptions, + UsersRelatedHostsStrategyResponse, + RelatedHostBucket, + RelatedHost, +} from '../../../../../../common/search_strategy/security_solution/related_entities/related_hosts'; +import { buildRelatedHostsQuery } from './query.related_hosts.dsl'; +import { getHostRiskData } from '../../hosts/all'; +import { inspectStringifyObject } from '../../../../../utils/build_query'; + +export const usersRelatedHosts: SecuritySolutionFactory = { + buildDsl: (options: UsersRelatedHostsRequestOptions) => buildRelatedHostsQuery(options), + parse: async ( + options: UsersRelatedHostsRequestOptions, + response: IEsSearchResponse, + deps?: { + esClient: IScopedClusterClient; + spaceId?: string; + endpointContext: EndpointAppContext; + } + ): Promise => { + const aggregations = response.rawResponse.aggregations; + + const inspect = { + dsl: [inspectStringifyObject(buildRelatedHostsQuery(options))], + }; + + if (aggregations == null) { + return { ...response, inspect, totalCount: 0, relatedHosts: [] }; + } + + const totalCount = getOr(0, 'aggregations.host_count.value', response.rawResponse); + + const buckets: RelatedHostBucket[] = getOr( + [], + 'aggregations.host_data.buckets', + response.rawResponse + ); + const relatedHosts: RelatedHost[] = buckets.map( + (bucket: RelatedHostBucket) => ({ + host: bucket.key, + ip: bucket.ip?.buckets.map((ip) => ip.key) ?? [], + }), + {} + ); + const enhancedHosts = deps?.spaceId + ? await addHostRiskData(relatedHosts, deps.spaceId, deps.esClient) + : relatedHosts; + + return { + ...response, + inspect, + totalCount, + relatedHosts: enhancedHosts, + }; + }, +}; + +async function addHostRiskData( + relatedHosts: RelatedHost[], + spaceId: string, + esClient: IScopedClusterClient +): Promise { + const hostNames = relatedHosts.map((item) => item.host); + const hostRiskData = await getHostRiskData(esClient, spaceId, hostNames); + const hostsRiskByHostName: Record | undefined = + hostRiskData?.hits.hits.reduce( + (acc, hit) => ({ + ...acc, + [hit._source?.host.name ?? '']: hit._source?.host?.risk?.calculated_level, + }), + {} + ); + + return hostsRiskByHostName + ? relatedHosts.map((item) => ({ ...item, risk: hostsRiskByHostName[item.host] })) + : relatedHosts; +} diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/related_entities/related_hosts/query.related_hosts.dsl.test.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/related_entities/related_hosts/query.related_hosts.dsl.test.ts new file mode 100644 index 0000000000000..088569e84d45d --- /dev/null +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/related_entities/related_hosts/query.related_hosts.dsl.test.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 { buildRelatedHostsQuery } from './query.related_hosts.dsl'; +import { mockOptions, expectedDsl } from './__mocks__'; + +describe('buildRelatedHostsQuery', () => { + test('build query from options correctly', () => { + expect(buildRelatedHostsQuery(mockOptions)).toMatchObject(expectedDsl); + }); +}); diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/related_entities/related_hosts/query.related_hosts.dsl.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/related_entities/related_hosts/query.related_hosts.dsl.ts new file mode 100644 index 0000000000000..cb8668c179fea --- /dev/null +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/related_entities/related_hosts/query.related_hosts.dsl.ts @@ -0,0 +1,61 @@ +/* + * 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 { ISearchRequestParams } from '@kbn/data-plugin/common'; +import type { UsersRelatedHostsRequestOptions } from '../../../../../../common/search_strategy/security_solution/related_entities/related_hosts'; + +export const buildRelatedHostsQuery = ({ + userName, + defaultIndex, + from, +}: UsersRelatedHostsRequestOptions): ISearchRequestParams => { + const now = new Date(); + const filter = [ + { term: { 'user.name': userName } }, + { term: { 'event.category': 'authentication' } }, + { term: { 'event.outcome': 'success' } }, + { + range: { + '@timestamp': { + gt: from, + lte: now.toISOString(), + format: 'strict_date_optional_time', + }, + }, + }, + ]; + + const dslQuery = { + allow_no_indices: true, + index: defaultIndex, + ignore_unavailable: true, + track_total_hits: false, + body: { + aggregations: { + host_count: { cardinality: { field: 'host.name' } }, + host_data: { + terms: { + field: 'host.name', + size: 1000, + }, + aggs: { + ip: { + terms: { + field: 'host.ip', + size: 10, + }, + }, + }, + }, + }, + query: { bool: { filter } }, + size: 0, + }, + }; + + return dslQuery; +}; diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/related_entities/related_users/__mocks__/index.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/related_entities/related_users/__mocks__/index.ts new file mode 100644 index 0000000000000..401df9e31cdac --- /dev/null +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/related_entities/related_users/__mocks__/index.ts @@ -0,0 +1,154 @@ +/* + * 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 { KibanaRequest } from '@kbn/core-http-server'; +import type { IEsSearchResponse } from '@kbn/data-plugin/common'; +import type { SavedObjectsClientContract } from '@kbn/core-saved-objects-api-server'; +import type { EndpointAppContextService } from '../../../../../../endpoint/endpoint_app_context_services'; +import type { EndpointAppContext } from '../../../../../../endpoint/types'; +import type { HostsRelatedUsersRequestOptions } from '../../../../../../../common/search_strategy/security_solution/related_entities/related_users'; +import { RelatedEntitiesQueries } from '../../../../../../../common/search_strategy/security_solution/related_entities'; +import { elasticsearchServiceMock } from '@kbn/core/server/mocks'; +import { allowedExperimentalValues } from '../../../../../../../common/experimental_features'; +import { createMockConfig } from '../../../../../../lib/detection_engine/routes/__mocks__'; + +export const mockOptions: HostsRelatedUsersRequestOptions = { + defaultIndex: ['test_indices*'], + factoryQueryType: RelatedEntitiesQueries.relatedUsers, + hostName: 'host1', + from: '2020-09-02T15:17:13.678Z', +}; + +export const mockSearchStrategyResponse: IEsSearchResponse = { + rawResponse: { + took: 2, + timed_out: false, + _shards: { + total: 2, + successful: 2, + skipped: 1, + failed: 0, + }, + hits: { + max_score: null, + hits: [], + }, + aggregations: { + user_count: { + value: 2, + }, + user_data: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: 'Danny', + doc_count: 3, + ip: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: '10.7.58.35', + doc_count: 3, + }, + { + key: '10.185.185.41', + doc_count: 3, + }, + ], + }, + }, + { + key: 'Aaron', + doc_count: 6, + ip: { + doc_count_error_upper_bound: 0, + sum_other_doc_count: 0, + buckets: [ + { + key: '10.7.58.35', + doc_count: 6, + }, + { + key: '10.185.185.41', + doc_count: 6, + }, + { + key: '10.198.197.106', + doc_count: 6, + }, + ], + }, + }, + ], + }, + }, + }, +}; + +export const mockDeps = () => ({ + esClient: elasticsearchServiceMock.createScopedClusterClient(), + savedObjectsClient: {} as SavedObjectsClientContract, + endpointContext: { + logFactory: { + get: jest.fn(), + }, + config: jest.fn().mockResolvedValue({}), + experimentalFeatures: { + ...allowedExperimentalValues, + }, + service: {} as EndpointAppContextService, + serverConfig: createMockConfig(), + } as EndpointAppContext, + request: {} as KibanaRequest, + spaceId: 'test-space', +}); + +export const expectedDsl = { + allow_no_indices: true, + track_total_hits: false, + body: { + aggregations: { + user_count: { cardinality: { field: 'user.name' } }, + user_data: { + terms: { field: 'user.name', size: 1000 }, + aggs: { + ip: { terms: { field: 'host.ip', size: 10 } }, + }, + }, + }, + query: { + bool: { + filter: [ + { term: { 'host.name': 'host1' } }, + { term: { 'event.category': 'authentication' } }, + { term: { 'event.outcome': 'success' } }, + { + range: { + '@timestamp': { + format: 'strict_date_optional_time', + gt: '2020-09-02T15:17:13.678Z', + }, + }, + }, + ], + }, + }, + size: 0, + }, + ignore_unavailable: true, + index: ['test_indices*'], +}; + +export const mockRelatedHosts = [ + { user: 'Danny', ip: ['10.7.58.35', '10.185.185.41'] }, + { + user: 'Aaron', + ip: ['10.7.58.35', '10.185.185.41', '10.198.197.106'], + }, +]; diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/related_entities/related_users/index.test.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/related_entities/related_users/index.test.ts new file mode 100644 index 0000000000000..76227040f829b --- /dev/null +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/related_entities/related_users/index.test.ts @@ -0,0 +1,88 @@ +/* + * 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 { hostsRelatedUsers } from '.'; +import { mockDeps, mockOptions, mockSearchStrategyResponse, mockRelatedHosts } from './__mocks__'; +import { get } from 'lodash/fp'; +import * as buildQuery from './query.related_users.dsl'; + +describe('hostsRelatedUsers search strategy', () => { + const buildRelatedUsersQuery = jest.spyOn(buildQuery, 'buildRelatedUsersQuery'); + + afterEach(() => { + buildRelatedUsersQuery.mockClear(); + }); + + describe('buildDsl', () => { + test('should build dsl query', () => { + hostsRelatedUsers.buildDsl(mockOptions); + expect(buildRelatedUsersQuery).toHaveBeenCalledWith(mockOptions); + }); + }); + + describe('parse', () => { + test('should parse data correctly', async () => { + const result = await hostsRelatedUsers.parse(mockOptions, mockSearchStrategyResponse); + expect(result.relatedUsers).toMatchObject(mockRelatedHosts); + expect(result.totalCount).toBe(2); + }); + + test('should enhance data with risk score', async () => { + const risk = 'TEST_RISK_SCORE'; + const userName: string = get( + `aggregations.user_data.buckets[0].key`, + mockSearchStrategyResponse.rawResponse + ); + + const mockedDeps = mockDeps(); + + mockedDeps.esClient.asCurrentUser.search.mockResponse({ + hits: { + hits: [ + { + _id: 'id', + _index: 'index', + _source: { + risk, + user: { + name: userName, + risk: { + multipliers: [], + calculated_score_norm: 9999, + calculated_level: risk, + rule_risks: [], + }, + }, + }, + }, + ], + }, + took: 2, + _shards: { failed: 0, successful: 2, total: 2 }, + timed_out: false, + }); + + const result = await hostsRelatedUsers.parse( + mockOptions, + mockSearchStrategyResponse, + mockedDeps + ); + + expect(result.relatedUsers[0].risk).toBe(risk); + }); + + test('should not enhance data when space id does not exist', async () => { + const mockedDeps = mockDeps(); + const result = await hostsRelatedUsers.parse(mockOptions, mockSearchStrategyResponse, { + ...mockedDeps, + spaceId: undefined, + }); + + expect(result.relatedUsers[0].risk).toBeUndefined(); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/related_entities/related_users/index.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/related_entities/related_users/index.ts new file mode 100644 index 0000000000000..ade66732c4a14 --- /dev/null +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/related_entities/related_users/index.ts @@ -0,0 +1,94 @@ +/* + * 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 { IEsSearchResponse } from '@kbn/data-plugin/common'; +import type { IScopedClusterClient } from '@kbn/core-elasticsearch-server'; +import { getOr } from 'lodash/fp'; +import type { RiskSeverity } from '../../../../../../common/search_strategy/security_solution/risk_score/all'; +import type { SecuritySolutionFactory } from '../../types'; +import type { RelatedEntitiesQueries } from '../../../../../../common/search_strategy/security_solution/related_entities'; +import type { + HostsRelatedUsersRequestOptions, + HostsRelatedUsersStrategyResponse, + RelatedUserBucket, + RelatedUser, +} from '../../../../../../common/search_strategy/security_solution/related_entities/related_users'; +import { inspectStringifyObject } from '../../../../../utils/build_query'; +import { buildRelatedUsersQuery } from './query.related_users.dsl'; +import { getUserRiskData } from '../../users/all'; + +export const hostsRelatedUsers: SecuritySolutionFactory = { + buildDsl: (options: HostsRelatedUsersRequestOptions) => buildRelatedUsersQuery(options), + parse: async ( + options: HostsRelatedUsersRequestOptions, + response: IEsSearchResponse, + deps?: { + esClient: IScopedClusterClient; + spaceId?: string; + } + ): Promise => { + const aggregations = response.rawResponse.aggregations; + + const inspect = { + dsl: [inspectStringifyObject(buildRelatedUsersQuery(options))], + }; + + if (aggregations == null) { + return { ...response, inspect, totalCount: 0, relatedUsers: [] }; + } + + const totalCount = getOr(0, 'aggregations.user_count.value', response.rawResponse); + + const buckets: RelatedUserBucket[] = getOr( + [], + 'aggregations.user_data.buckets', + response.rawResponse + ); + const relatedUsers: RelatedUser[] = buckets.map( + (bucket: RelatedUserBucket) => ({ + user: bucket.key, + ip: bucket.ip?.buckets.map((ip) => ip.key) ?? [], + }), + {} + ); + + const enhancedUsers = deps?.spaceId + ? await addUserRiskData(relatedUsers, deps.spaceId, deps.esClient) + : relatedUsers; + + return { + ...response, + inspect, + totalCount, + relatedUsers: enhancedUsers, + }; + }, +}; + +async function addUserRiskData( + relatedUsers: RelatedUser[], + spaceId: string, + esClient: IScopedClusterClient +): Promise { + const userNames = relatedUsers.map((item) => item.user); + const userRiskData = await getUserRiskData(esClient, spaceId, userNames); + const usersRiskByUserName: Record | undefined = + userRiskData?.hits.hits.reduce( + (acc, hit) => ({ + ...acc, + [hit._source?.user.name ?? '']: hit._source?.user?.risk?.calculated_level, + }), + {} + ); + + return usersRiskByUserName + ? relatedUsers.map((item) => ({ + ...item, + risk: usersRiskByUserName[item.user], + })) + : relatedUsers; +} diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/related_entities/related_users/query.related_users.dsl.test.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/related_entities/related_users/query.related_users.dsl.test.ts new file mode 100644 index 0000000000000..9fc371e88364d --- /dev/null +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/related_entities/related_users/query.related_users.dsl.test.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 { buildRelatedUsersQuery } from './query.related_users.dsl'; +import { mockOptions, expectedDsl } from './__mocks__'; + +describe('buildRelatedUsersQuery', () => { + test('build query from options correctly', () => { + expect(buildRelatedUsersQuery(mockOptions)).toMatchObject(expectedDsl); + }); +}); diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/related_entities/related_users/query.related_users.dsl.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/related_entities/related_users/query.related_users.dsl.ts new file mode 100644 index 0000000000000..8824c4c359dec --- /dev/null +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/related_entities/related_users/query.related_users.dsl.ts @@ -0,0 +1,61 @@ +/* + * 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 { ISearchRequestParams } from '@kbn/data-plugin/common'; +import type { HostsRelatedUsersRequestOptions } from '../../../../../../common/search_strategy/security_solution/related_entities/related_users'; + +export const buildRelatedUsersQuery = ({ + hostName, + defaultIndex, + from, +}: HostsRelatedUsersRequestOptions): ISearchRequestParams => { + const now = new Date(); + const filter = [ + { term: { 'host.name': hostName } }, + { term: { 'event.category': 'authentication' } }, + { term: { 'event.outcome': 'success' } }, + { + range: { + '@timestamp': { + gt: from, + lte: now.toISOString(), + format: 'strict_date_optional_time', + }, + }, + }, + ]; + + const dslQuery = { + allow_no_indices: true, + index: defaultIndex, + ignore_unavailable: true, + track_total_hits: false, + body: { + aggregations: { + user_count: { cardinality: { field: 'user.name' } }, + user_data: { + terms: { + field: 'user.name', + size: 1000, + }, + aggs: { + ip: { + terms: { + field: 'host.ip', + size: 10, + }, + }, + }, + }, + }, + query: { bool: { filter } }, + size: 0, + }, + }; + + return dslQuery; +}; diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/users/all/index.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/users/all/index.ts index 2bdc6c6956633..ece9391f2e39c 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/users/all/index.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/users/all/index.ts @@ -116,7 +116,7 @@ async function enhanceEdges( : edges; } -async function getUserRiskData( +export async function getUserRiskData( esClient: IScopedClusterClient, spaceId: string, userNames: string[] diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index 5d252a92a217f..9046cccb857b4 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -32496,7 +32496,7 @@ "xpack.securitySolution.fleetIntegration.assets.name": "Hôtes", "xpack.securitySolution.fleetIntegration.elasticDefend.eventFilter.nonInteractiveSessions.description": "Filtre d'événement pour Cloud Security. Créé par l'intégration Elastic Defend.", "xpack.securitySolution.fleetIntegration.elasticDefend.eventFilter.nonInteractiveSessions.name": "Sessions non interactives", - "xpack.securitySolution.flyout.analyzerErrorTitle": "analyseur", + "xpack.securitySolution.flyout.analyzerErrorMessage": "analyseur", "xpack.securitySolution.flyout.button.timeline": "chronologie", "xpack.securitySolution.flyout.documentDetails.alertReasonTitle": "Raison d'alerte", "xpack.securitySolution.flyout.documentDetails.analyzerGraphButton": "Graph Analyseur", @@ -32543,7 +32543,7 @@ "xpack.securitySolution.flyout.documentDetails.visualizeTab": "Visualiser", "xpack.securitySolution.flyout.documentErrorMessage": "les valeurs et champs du document", "xpack.securitySolution.flyout.documentErrorTitle": "informations du document", - "xpack.securitySolution.flyout.sessionViewErrorTitle": "vue de session", + "xpack.securitySolution.flyout.sessionViewErrorMessage": "vue de session", "xpack.securitySolution.footer.autoRefreshActiveDescription": "Actualisation automatique active", "xpack.securitySolution.footer.cancel": "Annuler", "xpack.securitySolution.footer.data": "données", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 56faf3a995aa8..c555139f778a4 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -32477,7 +32477,7 @@ "xpack.securitySolution.fleetIntegration.assets.name": "ホスト", "xpack.securitySolution.fleetIntegration.elasticDefend.eventFilter.nonInteractiveSessions.description": "クラウドセキュリティのイベントフィルター。Elastic Defend統合によって作成。", "xpack.securitySolution.fleetIntegration.elasticDefend.eventFilter.nonInteractiveSessions.name": "非インタラクティブセッション", - "xpack.securitySolution.flyout.analyzerErrorTitle": "アナライザー", + "xpack.securitySolution.flyout.analyzerErrorMessage": "アナライザー", "xpack.securitySolution.flyout.button.timeline": "タイムライン", "xpack.securitySolution.flyout.documentDetails.alertReasonTitle": "アラートの理由", "xpack.securitySolution.flyout.documentDetails.analyzerGraphButton": "アナライザーグラフ", @@ -32524,7 +32524,7 @@ "xpack.securitySolution.flyout.documentDetails.visualizeTab": "可視化", "xpack.securitySolution.flyout.documentErrorMessage": "ドキュメントフィールドおよび値", "xpack.securitySolution.flyout.documentErrorTitle": "ドキュメント情報", - "xpack.securitySolution.flyout.sessionViewErrorTitle": "セッションビュー", + "xpack.securitySolution.flyout.sessionViewErrorMessage": "セッションビュー", "xpack.securitySolution.footer.autoRefreshActiveDescription": "自動更新アクション", "xpack.securitySolution.footer.cancel": "キャンセル", "xpack.securitySolution.footer.data": "データ", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index c1e2edca82715..e60befbaed231 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -32473,7 +32473,7 @@ "xpack.securitySolution.fleetIntegration.assets.name": "主机", "xpack.securitySolution.fleetIntegration.elasticDefend.eventFilter.nonInteractiveSessions.description": "云安全事件筛选。已由 Elastic Defend 集成创建。", "xpack.securitySolution.fleetIntegration.elasticDefend.eventFilter.nonInteractiveSessions.name": "非交互式会话", - "xpack.securitySolution.flyout.analyzerErrorTitle": "分析器", + "xpack.securitySolution.flyout.analyzerErrorMessage": "分析器", "xpack.securitySolution.flyout.button.timeline": "时间线", "xpack.securitySolution.flyout.documentDetails.alertReasonTitle": "告警原因", "xpack.securitySolution.flyout.documentDetails.analyzerGraphButton": "分析器图表", @@ -32520,7 +32520,7 @@ "xpack.securitySolution.flyout.documentDetails.visualizeTab": "Visualize", "xpack.securitySolution.flyout.documentErrorMessage": "文档字段和值", "xpack.securitySolution.flyout.documentErrorTitle": "文档信息", - "xpack.securitySolution.flyout.sessionViewErrorTitle": "会话视图", + "xpack.securitySolution.flyout.sessionViewErrorMessage": "会话视图", "xpack.securitySolution.footer.autoRefreshActiveDescription": "自动刷新已启用", "xpack.securitySolution.footer.cancel": "取消", "xpack.securitySolution.footer.data": "数据",