[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>
This commit is contained in:
christineweng 2023-05-24 16:02:20 -05:00 committed by GitHub
parent c247899225
commit 123e535754
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
46 changed files with 2887 additions and 123 deletions

View file

@ -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 FactoryQueryTypes> = 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 FactoryQueryTypes> = 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

View file

@ -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',
}

View file

@ -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<Inspect>;
}
export interface UsersRelatedHostsRequestOptions extends Partial<RequestBasicOptions> {
userName: string;
skip?: boolean;
from: string;
inspect?: Maybe<Inspect>;
}

View file

@ -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<Inspect>;
}
export interface HostsRelatedUsersRequestOptions extends Partial<RequestBasicOptions> {
hostName: string;
skip?: boolean;
from: string;
inspect?: Maybe<Inspect>;
}

View file

@ -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)

View file

@ -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');
});
}
);

View file

@ -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 =

View file

@ -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);
});
});

View file

@ -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<RelatedEntitiesQueries.relatedHosts>({
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;
};

View file

@ -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`,
}
);

View file

@ -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);
});
});

View file

@ -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<RelatedEntitiesQueries.relatedUsers>({
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;
};

View file

@ -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`,
}
);

View file

@ -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('<EntitiesDetails />', () => {
it('renders entities details correctly', () => {
const { getByTestId } = render(
<TestProviders>
<LeftFlyoutContext.Provider value={mockContextValue}>
<EntitiesDetails />
</LeftFlyoutContext.Provider>
</TestProviders>
);
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(
<TestProviders>
<LeftFlyoutContext.Provider
value={{
...mockContextValue,
getFieldsData: (fieldName) =>
fieldName === '@timestamp' ? ['2022-07-25T08:20:18.966Z'] : [],
}}
>
<EntitiesDetails />
</LeftFlyoutContext.Provider>
</TestProviders>
);
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(
<TestProviders>
<LeftFlyoutContext.Provider
value={{
...mockContextValue,
getFieldsData: (fieldName) => {
switch (fieldName) {
case 'host.name':
return ['host1'];
case 'user.name':
return ['user1'];
default:
return [];
}
},
}}
>
<EntitiesDetails />
</LeftFlyoutContext.Provider>
</TestProviders>
);
expect(queryByTestId(USER_DETAILS_TEST_ID)).not.toBeInTheDocument();
expect(queryByTestId(HOST_DETAILS_TEST_ID)).not.toBeInTheDocument();
});
});

View file

@ -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 <EuiText data-test-subj={ENTITIES_DETAILS_TEST_ID}>{'Entities'}</EuiText>;
const { getFieldsData } = useLeftPanelContext();
const hostName = getField(getFieldsData('host.name'));
const userName = getField(getFieldsData('user.name'));
const timestamp = getField(getFieldsData('@timestamp'));
return (
<EuiFlexGroup direction="column" gutterSize="m" data-test-subj={ENTITIES_DETAILS_TEST_ID}>
{userName && timestamp && (
<EuiFlexItem>
<UserDetails userName={userName} timestamp={timestamp} />
</EuiFlexItem>
)}
{hostName && timestamp && (
<EuiFlexItem>
<HostDetails hostName={hostName} timestamp={timestamp} />
</EuiFlexItem>
)}
</EuiFlexGroup>
);
};
EntitiesDetails.displayName = 'EntitiesDetails';

View file

@ -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<string, string | undefined>;
}) => 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('<HostDetails />', () => {
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(
<TestProviders>
<HostDetails {...defaultProps} />
</TestProviders>
);
expect(getByTestId(HOST_DETAILS_TEST_ID)).toBeInTheDocument();
});
describe('Host overview', () => {
it('should render the HostOverview with correct dates and indices', () => {
const { getByTestId } = render(
<TestProviders>
<HostDetails {...defaultProps} />
</TestProviders>
);
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(
<TestProviders>
<HostDetails {...defaultProps} />
</TestProviders>
);
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(
<TestProviders>
<HostDetails {...defaultProps} />
</TestProviders>
);
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(
<TestProviders>
<HostDetails {...defaultProps} />
</TestProviders>
);
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(
<TestProviders>
<HostDetails {...defaultProps} />
</TestProviders>
);
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(
<TestProviders>
<HostDetails {...defaultProps} />
</TestProviders>
);
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(
<TestProviders>
<HostDetails {...defaultProps} />
</TestProviders>
);
expect(getByTestId(HOST_DETAILS_RELATED_USERS_TABLE_TEST_ID).textContent).toContain(
'No items found'
);
});
});
});

View file

@ -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<HostDetailsProps> = ({ 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<EuiBasicTableColumn<RelatedUser>> = useMemo(
() => [
{
field: 'user',
name: i18n.RELATED_ENTITIES_NAME_COLUMN_TITLE,
render: (user: string) => (
<EuiText grow={false} size="xs">
<SecurityCellActions
mode={CellActionsMode.HOVER_RIGHT}
visibleCellActions={5}
showActionTooltips
triggerId={SecurityCellActionsTrigger.DEFAULT}
field={{
name: 'user.name',
value: user,
type: 'keyword',
}}
>
{user}
</SecurityCellActions>
</EuiText>
),
},
{
field: 'ip',
name: i18n.RELATED_ENTITIES_IP_COLUMN_TITLE,
render: (ips: string[]) => {
return (
<DefaultFieldRenderer
rowItems={ips}
attrName={'host.ip'}
idPrefix={''}
isDraggable={false}
render={(ip) => (ip != null ? <NetworkDetailsLink ip={ip} /> : getEmptyTagValue())}
/>
);
},
},
...(isPlatinumOrTrialLicense
? [
{
field: 'risk',
name: (
<EuiToolTip content={USER_RISK_TOOLTIP}>
<>
{ENTITY_RISK_CLASSIFICATION(RiskScoreEntity.user)}{' '}
<EuiIcon color="subdued" type="iInCircle" className="eui-alignTop" />
</>
</EuiToolTip>
),
truncateText: false,
mobileOptions: { show: true },
sortable: false,
render: (riskScore: RiskSeverity) => {
if (riskScore != null) {
return <RiskScore severity={riskScore} />;
}
return getEmptyTagValue();
},
},
]
: []),
],
[isPlatinumOrTrialLicense]
);
const relatedUsersCount = useMemo(
() => (
<EuiFlexGroup alignItems="center" gutterSize="s">
<EuiFlexItem grow={false}>
<EuiIcon type="user" />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiTitle size="xxxs">
<EuiText>{`${i18n.RELATED_USERS_TITLE}: ${totalCount}`}</EuiText>
</EuiTitle>
</EuiFlexItem>
</EuiFlexGroup>
),
[totalCount]
);
const pagination: {} = {
pageSize: 4,
showPerPageOptions: false,
};
return (
<>
<EuiTitle size="xs">
<h4>{i18n.HOSTS_TITLE}</h4>
</EuiTitle>
<EuiSpacer size="s" />
<EntityPanel
title={hostName}
iconType={'storage'}
expandable={true}
expanded={true}
headerContent={relatedUsersCount}
data-test-subj={HOST_DETAILS_TEST_ID}
>
<EuiTitle size="xxs">
<h5>{i18n.HOSTS_INFO_TITLE}</h5>
</EuiTitle>
<EuiSpacer size="s" />
<AnomalyTableProvider
criteriaFields={hostToCriteria(hostDetails)}
startDate={from}
endDate={to}
skip={isInitializing}
>
{({ isLoadingAnomaliesData, anomaliesData, jobNameById }) => (
<HostOverviewManage
id={hostDetailsQueryId}
hostName={hostName}
data={hostDetails}
indexNames={selectedPatterns}
jobNameById={jobNameById}
anomaliesData={anomaliesData}
isLoadingAnomaliesData={isLoadingAnomaliesData}
isInDetailsSidePanel={false}
loading={isHostLoading}
startDate={from}
endDate={to}
narrowDateRange={narrowDateRange}
setQuery={setQuery}
refetch={refetch}
inspect={inspect}
deleteQuery={deleteQuery}
/>
)}
</AnomalyTableProvider>
<EuiHorizontalRule margin="m" />
<EuiFlexGroup direction="row" gutterSize="xs" alignItems="center">
<EuiFlexItem grow={false}>
<EuiTitle size="xxs">
<h5>{i18n.RELATED_USERS_TITLE}</h5>
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiToolTip content={i18n.RELATED_USERS_TOOL_TIP}>
<EuiIcon color="subdued" type="iInCircle" className="eui-alignTop" />
</EuiToolTip>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="s" />
<RelatedUsersManage
id={relatedUsersQueryId}
inspect={inspectRelatedUsers}
loading={isRelatedUsersLoading}
setQuery={setQuery}
deleteQuery={deleteQuery}
refetch={refetchRelatedUsers}
>
<EuiInMemoryTable
columns={relatedUsersColumns}
items={relatedUsers}
loading={isRelatedUsersLoading}
data-test-subj={HOST_DETAILS_RELATED_USERS_TABLE_TEST_ID}
pagination={pagination}
/>
<InspectButton
queryId={relatedUsersQueryId}
title={i18n.RELATED_USERS_TITLE}
inspectIndex={0}
/>
</RelatedUsersManage>
</EntityPanel>
</>
);
};
HostDetails.displayName = 'HostDetails';

View file

@ -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;

View file

@ -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.',
}
);

View file

@ -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<string, string | undefined>;
}) => 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('<HostDetails />', () => {
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(
<TestProviders>
<UserDetails {...defaultProps} />
</TestProviders>
);
expect(getByTestId(USER_DETAILS_TEST_ID)).toBeInTheDocument();
});
describe('Host overview', () => {
it('should render the HostOverview with correct dates and indices', () => {
const { getByTestId } = render(
<TestProviders>
<UserDetails {...defaultProps} />
</TestProviders>
);
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(
<TestProviders>
<UserDetails {...defaultProps} />
</TestProviders>
);
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(
<TestProviders>
<UserDetails {...defaultProps} />
</TestProviders>
);
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(
<TestProviders>
<UserDetails {...defaultProps} />
</TestProviders>
);
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(
<TestProviders>
<UserDetails {...defaultProps} />
</TestProviders>
);
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(
<TestProviders>
<UserDetails {...defaultProps} />
</TestProviders>
);
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(
<TestProviders>
<UserDetails {...defaultProps} />
</TestProviders>
);
expect(getByTestId(USER_DETAILS_RELATED_HOSTS_TABLE_TEST_ID).textContent).toContain(
'No items found'
);
});
});
});

View file

@ -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<UserDetailsProps> = ({ 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<EuiBasicTableColumn<RelatedHost>> = useMemo(
() => [
{
field: 'host',
name: i18n.RELATED_ENTITIES_NAME_COLUMN_TITLE,
render: (host: string) => (
<EuiText grow={false} size="xs">
<SecurityCellActions
mode={CellActionsMode.HOVER_RIGHT}
visibleCellActions={5}
showActionTooltips
triggerId={SecurityCellActionsTrigger.DEFAULT}
field={{
name: 'host.name',
value: host,
type: 'keyword',
}}
>
{host}
</SecurityCellActions>
</EuiText>
),
},
{
field: 'ip',
name: i18n.RELATED_ENTITIES_IP_COLUMN_TITLE,
render: (ips: string[]) => {
return (
<DefaultFieldRenderer
rowItems={ips}
attrName={'host.ip'}
idPrefix={''}
isDraggable={false}
render={(ip) => (ip != null ? <NetworkDetailsLink ip={ip} /> : getEmptyTagValue())}
/>
);
},
},
...(isPlatinumOrTrialLicense
? [
{
field: 'risk',
name: (
<EuiToolTip content={HOST_RISK_TOOLTIP}>
<>
{ENTITY_RISK_CLASSIFICATION(RiskScoreEntity.host)}{' '}
<EuiIcon color="subdued" type="iInCircle" className="eui-alignTop" />
</>
</EuiToolTip>
),
truncateText: false,
mobileOptions: { show: true },
sortable: false,
render: (riskScore: RiskSeverity) => {
if (riskScore != null) {
return <RiskScore severity={riskScore} />;
}
return getEmptyTagValue();
},
},
]
: []),
],
[isPlatinumOrTrialLicense]
);
const relatedHostsCount = useMemo(
() => (
<EuiFlexGroup alignItems="center" gutterSize="m">
<EuiFlexItem grow={false}>
<EuiIcon type="storage" />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiTitle size="xxxs">
<EuiText>{`${i18n.RELATED_HOSTS_TITLE}: ${totalCount}`}</EuiText>
</EuiTitle>
</EuiFlexItem>
</EuiFlexGroup>
),
[totalCount]
);
const pagination: {} = {
pageSize: 4,
showPerPageOptions: false,
};
return (
<>
<EuiTitle size="xs">
<h4>{i18n.USERS_TITLE}</h4>
</EuiTitle>
<EuiSpacer size="s" />
<EntityPanel
title={userName}
iconType={'user'}
expandable={true}
expanded={true}
headerContent={relatedHostsCount}
data-test-subj={USER_DETAILS_TEST_ID}
>
<EuiTitle size="xxs">
<h5>{i18n.USERS_INFO_TITLE}</h5>
</EuiTitle>
<EuiSpacer size="s" />
<AnomalyTableProvider
criteriaFields={hostToCriteria(userDetails)}
startDate={from}
endDate={to}
skip={isInitializing}
>
{({ isLoadingAnomaliesData, anomaliesData, jobNameById }) => (
<UserOverviewManage
id={userDetailsQueryId}
isInDetailsSidePanel={false}
data={userDetails}
anomaliesData={anomaliesData}
isLoadingAnomaliesData={isLoadingAnomaliesData}
loading={isUserLoading}
startDate={from}
endDate={to}
narrowDateRange={narrowDateRange}
setQuery={setQuery}
refetch={refetch}
inspect={inspect}
userName={userName}
indexPatterns={selectedPatterns}
jobNameById={jobNameById}
/>
)}
</AnomalyTableProvider>
<EuiHorizontalRule margin="m" />
<EuiFlexGroup direction="row" gutterSize="xs" alignItems="center">
<EuiFlexItem grow={false}>
<EuiTitle size="xxs">
<h5>{i18n.RELATED_HOSTS_TITLE}</h5>
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiToolTip content={i18n.RELATED_HOSTS_TOOL_TIP}>
<EuiIcon color="subdued" type="iInCircle" className="eui-alignTop" />
</EuiToolTip>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="s" />
<RelatedHostsManage
id={relatedHostsQueryId}
inspect={inspectRelatedHosts}
loading={isRelatedHostLoading}
setQuery={setQuery}
deleteQuery={deleteQuery}
refetch={refetchRelatedHosts}
>
<EuiInMemoryTable
columns={relatedHostsColumns}
items={relatedHosts}
loading={isRelatedHostLoading}
data-test-subj={USER_DETAILS_RELATED_HOSTS_TABLE_TEST_ID}
pagination={pagination}
/>
<InspectButton
queryId={relatedHostsQueryId}
title={i18n.RELATED_HOSTS_TITLE}
inspectIndex={0}
/>
</RelatedHostsManage>
</EntityPanel>
</>
);
};
UserDetails.displayName = 'UserDetails';

View file

@ -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 {
/**

View file

@ -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,
};

View file

@ -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('<EntitiesOverview />', () => {
getFieldsData: mockGetFieldsData,
} as unknown as RightPanelContext;
const { getByTestId, queryByText, getAllByTestId } = render(
const { getByTestId } = render(
<TestProviders>
<RightPanelContext.Provider value={contextValue}>
<EntitiesOverview />
@ -33,11 +34,8 @@ describe('<EntitiesOverview />', () => {
</TestProviders>
);
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('<EntitiesOverview />', () => {
getFieldsData: (field: string) => (field === 'user.name' ? 'user1' : null),
} as unknown as RightPanelContext;
const { queryByTestId, queryByText, getAllByTestId } = render(
const { queryByTestId, queryByText, getByTestId } = render(
<TestProviders>
<RightPanelContext.Provider value={contextValue}>
<EntitiesOverview />
@ -54,8 +52,8 @@ describe('<EntitiesOverview />', () => {
</TestProviders>
);
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('<EntitiesOverview />', () => {
getFieldsData: (field: string) => (field === 'host.name' ? 'host1' : null),
} as unknown as RightPanelContext;
const { queryByTestId, queryByText, getAllByTestId } = render(
const { queryByTestId, queryByText, getByTestId } = render(
<TestProviders>
<RightPanelContext.Provider value={contextValue}>
<EntitiesOverview />
@ -74,8 +72,8 @@ describe('<EntitiesOverview />', () => {
</TestProviders>
);
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('<EntitiesOverview />', () => {
);
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', () => {

View file

@ -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 = () => {
<EntityPanel
title={userName}
iconType={USER_ICON}
content={<UserEntityOverview userName={userName} />}
/>
data-test-subj={ENTITIES_USER_CONTENT_TEST_ID}
>
<UserEntityOverview userName={userName} />
</EntityPanel>
</EuiFlexItem>
)}
{hostName && (
@ -70,8 +74,10 @@ export const EntitiesOverview: React.FC = () => {
<EntityPanel
title={hostName}
iconType={HOST_ICON}
content={<HostEntityOverview hostName={hostName} />}
/>
data-test-subj={ENTITIES_HOST_CONTENT_TEST_ID}
>
<HostEntityOverview hostName={hostName} />
</EntityPanel>
</EuiFlexItem>
)}
<EuiButtonEmpty

View file

@ -7,6 +7,7 @@
import React from 'react';
import type { Story } from '@storybook/react';
import { EuiIcon } from '@elastic/eui';
import { EntityPanel } from './entity_panel';
export default {
@ -17,25 +18,43 @@ export default {
const defaultProps = {
title: 'title',
iconType: 'storage',
content: 'test content',
};
const headerContent = <EuiIcon type="expand" />;
const children = <p>{'test content'}</p>;
export const Default: Story<void> = () => {
return <EntityPanel {...defaultProps} />;
return <EntityPanel {...defaultProps}>{children}</EntityPanel>;
};
export const DefaultWithHeaderContent: Story<void> = () => {
return (
<EntityPanel {...defaultProps} headerContent={headerContent}>
{children}
</EntityPanel>
);
};
export const Expandable: Story<void> = () => {
return <EntityPanel {...defaultProps} expandable={true} />;
return (
<EntityPanel {...defaultProps} expandable={true}>
{children}
</EntityPanel>
);
};
export const ExpandableDefaultOpen: Story<void> = () => {
return <EntityPanel {...defaultProps} expandable={true} expanded={true} />;
return (
<EntityPanel {...defaultProps} expandable={true} expanded={true}>
{children}
</EntityPanel>
);
};
export const EmptyDefault: Story<void> = () => {
return <EntityPanel {...defaultProps} content={null} />;
return <EntityPanel {...defaultProps} />;
};
export const EmptyDefaultExpanded: Story<void> = () => {
return <EntityPanel {...defaultProps} expandable={true} content={null} />;
return <EntityPanel {...defaultProps} expandable={true} />;
};

View file

@ -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 = <p>{'test content'}</p>;
describe('<EntityPanel />', () => {
describe('panel is not expandable by default', () => {
it('should render non-expandable panel by default', () => {
const { getByTestId, queryByTestId } = render(<EntityPanel {...defaultProps} />);
expect(getByTestId(ENTITY_PANEL_TEST_ID)).toBeInTheDocument();
expect(getByTestId(ENTITY_PANEL_HEADER_TEST_ID)).toHaveTextContent('test');
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'
const { getByTestId, queryByTestId } = render(
<ThemeProvider theme={mockTheme}>
<EntityPanel {...defaultProps}>{children}</EntityPanel>
</ThemeProvider>
);
expect(getByTestId(ENTITY_PANEL_TEST_ID)).toBeInTheDocument();
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();
});
it('should only render left section of panel header when headerContent is not passed', () => {
const { getByTestId, queryByTestId } = render(
<ThemeProvider theme={mockTheme}>
<EntityPanel {...defaultProps}>{children}</EntityPanel>
</ThemeProvider>
);
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(
<ThemeProvider theme={mockTheme}>
<EntityPanel {...defaultProps} headerContent={<>{'test header content'}</>}>
{children}
</EntityPanel>
</ThemeProvider>
);
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(<EntityPanel {...defaultProps} content={null} />);
const { queryByTestId } = render(
<ThemeProvider theme={mockTheme}>
<EntityPanel {...defaultProps} />
</ThemeProvider>
);
expect(queryByTestId(ENTITY_PANEL_CONTENT_TEST_ID)).not.toBeInTheDocument();
expect(queryByTestId(ENTITY_PANEL_TOGGLE_BUTTON_TEST_ID)).not.toBeInTheDocument();
@ -49,7 +78,11 @@ describe('<EntityPanel />', () => {
describe('panel is expandable', () => {
it('should render panel with toggle and collapsed by default', () => {
const { getByTestId, queryByTestId } = render(
<EntityPanel {...defaultProps} expandable={true} />
<ThemeProvider theme={mockTheme}>
<EntityPanel {...defaultProps} expandable={true}>
{children}
</EntityPanel>
</ThemeProvider>
);
expect(getByTestId(ENTITY_PANEL_TEST_ID)).toBeInTheDocument();
expect(getByTestId(ENTITY_PANEL_HEADER_TEST_ID)).toHaveTextContent('test');
@ -57,7 +90,13 @@ describe('<EntityPanel />', () => {
});
it('click toggle button should expand the panel', () => {
const { getByTestId } = render(<EntityPanel {...defaultProps} expandable={true} />);
const { getByTestId } = render(
<ThemeProvider theme={mockTheme}>
<EntityPanel {...defaultProps} expandable={true}>
{children}
</EntityPanel>
</ThemeProvider>
);
const toggle = getByTestId(ENTITY_PANEL_TOGGLE_BUTTON_TEST_ID);
expect(toggle.firstChild).toHaveAttribute('data-euiicon-type', 'arrowRight');
@ -69,7 +108,9 @@ describe('<EntityPanel />', () => {
it('should not render toggle or content when content is null', () => {
const { queryByTestId } = render(
<EntityPanel {...defaultProps} content={null} expandable={true} />
<ThemeProvider theme={mockTheme}>
<EntityPanel {...defaultProps} expandable={true} />
</ThemeProvider>
);
expect(queryByTestId(ENTITY_PANEL_TOGGLE_BUTTON_TEST_ID)).not.toBeInTheDocument();
expect(queryByTestId(ENTITY_PANEL_CONTENT_TEST_ID)).not.toBeInTheDocument();
@ -79,7 +120,11 @@ describe('<EntityPanel />', () => {
describe('panel is expandable and expanded by default', () => {
it('should render header and content', () => {
const { getByTestId } = render(
<EntityPanel {...defaultProps} expandable={true} expanded={true} />
<ThemeProvider theme={mockTheme}>
<EntityPanel {...defaultProps} expandable={true} expanded={true}>
{children}
</EntityPanel>
</ThemeProvider>
);
expect(getByTestId(ENTITY_PANEL_TEST_ID)).toBeInTheDocument();
expect(getByTestId(ENTITY_PANEL_HEADER_TEST_ID)).toHaveTextContent('test');
@ -89,7 +134,11 @@ describe('<EntityPanel />', () => {
it('click toggle button should collapse the panel', () => {
const { getByTestId, queryByTestId } = render(
<EntityPanel {...defaultProps} expandable={true} expanded={true} />
<ThemeProvider theme={mockTheme}>
<EntityPanel {...defaultProps} expandable={true} expanded={true}>
{children}
</EntityPanel>
</ThemeProvider>
);
const toggle = getByTestId(ENTITY_PANEL_TOGGLE_BUTTON_TEST_ID);
@ -103,7 +152,9 @@ describe('<EntityPanel />', () => {
it('should not render content when content is null', () => {
const { queryByTestId } = render(
<EntityPanel {...defaultProps} content={null} expandable={true} />
<ThemeProvider theme={mockTheme}>
<EntityPanel {...defaultProps} expandable={true} />
</ThemeProvider>
);
expect(queryByTestId(ENTITY_PANEL_TOGGLE_BUTTON_TEST_ID)).not.toBeInTheDocument();
expect(queryByTestId(ENTITY_PANEL_CONTENT_TEST_ID)).not.toBeInTheDocument();

View file

@ -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<EntityPanelProps> = ({
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<EntityPanelProps> = ({
const toggleIcon = useMemo(
() => (
<EuiFlexItem grow={false}>
<EuiButtonIcon
data-test-subj={ENTITY_PANEL_TOGGLE_BUTTON_TEST_ID}
aria-label={`entity-toggle`}
color="text"
display="empty"
iconType={toggleStatus ? 'arrowDown' : 'arrowRight'}
onClick={toggleQuery}
size="s"
/>
</EuiFlexItem>
<EuiButtonIcon
data-test-subj={ENTITY_PANEL_TOGGLE_BUTTON_TEST_ID}
aria-label={`entity-toggle`}
color="text"
display="empty"
iconType={toggleStatus ? 'arrowDown' : 'arrowRight'}
onClick={toggleQuery}
size="s"
/>
),
[toggleStatus, toggleQuery]
);
const icon = useMemo(() => {
return (
<EuiButtonIcon
data-test-subj={ENTITY_PANEL_ICON_TEST_ID}
aria-label={'entity-icon'}
color="text"
display="empty"
iconType={iconType}
size="s"
/>
);
}, [iconType]);
const headerLeftSection = useMemo(
() => (
<EuiFlexItem>
<EuiFlexGroup
alignItems="center"
gutterSize="s"
data-test-subj={ENTITY_PANEL_HEADER_LEFT_SECTION_TEST_ID}
>
<EuiFlexItem grow={false}>{expandable && children && toggleIcon}</EuiFlexItem>
<EuiFlexItem grow={false}>
<IconWrapper type={iconType} />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiTitle size="xxxs">
<EuiText>{title}</EuiText>
</EuiTitle>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
),
[title, children, toggleIcon, expandable, iconType]
);
const headerRightSection = useMemo(
() =>
headerContent && (
<PanelHeaderRightSectionWrapper
grow={false}
data-test-subj={ENTITY_PANEL_HEADER_RIGHT_SECTION_TEST_ID}
>
{headerContent}
</PanelHeaderRightSectionWrapper>
),
[headerContent]
);
const showContent = useMemo(() => {
if (!content) {
if (!children) {
return false;
}
return !expandable || (expandable && toggleStatus);
}, [content, expandable, toggleStatus]);
const panelHeader = useMemo(() => {
return (
<EuiFlexGroup
alignItems="center"
gutterSize="xs"
data-test-subj={ENTITY_PANEL_HEADER_TEST_ID}
>
{expandable && content && toggleIcon}
<EuiFlexItem grow={false}>{icon}</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiTitle size="xxxs">
<EuiText>{title}</EuiText>
</EuiTitle>
</EuiFlexItem>
</EuiFlexGroup>
);
}, [title, icon, content, toggleIcon, expandable]);
}, [children, expandable, toggleStatus]);
return (
<EuiSplitPanel.Outer grow hasBorder data-test-subj={ENTITY_PANEL_TEST_ID}>
<EuiSplitPanel.Inner grow={false} color="subdued" paddingSize="xs">
{panelHeader}
<EuiSplitPanel.Outer grow hasBorder data-test-subj={dataTestSub}>
<EuiSplitPanel.Inner
grow={false}
color="subdued"
paddingSize={'xs'}
data-test-subj={ENTITY_PANEL_HEADER_TEST_ID}
>
<EuiFlexGroup justifyContent="spaceBetween" alignItems="center">
{headerLeftSection}
{headerRightSection}
</EuiFlexGroup>
</EuiSplitPanel.Inner>
{showContent && (
<EuiSplitPanel.Inner paddingSize="none">
<EuiPanel data-test-subj={ENTITY_PANEL_CONTENT_TEST_ID}>{content}</EuiPanel>
<EuiPanel data-test-subj={ENTITY_PANEL_CONTENT_TEST_ID}>{children}</EuiPanel>
</EuiSplitPanel.Inner>
)}
</EuiSplitPanel.Outer>

View file

@ -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 =

View file

@ -110,7 +110,7 @@ async function enhanceEdges(
: edges;
}
async function getHostRiskData(
export async function getHostRiskData(
esClient: IScopedClusterClient,
spaceId: string,
hostNames: string[]

View file

@ -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,
};

View file

@ -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<FactoryQueryTypes>
> = {
[RelatedEntitiesQueries.relatedHosts]: usersRelatedHosts,
[RelatedEntitiesQueries.relatedUsers]: hostsRelatedUsers,
};

View file

@ -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<unknown> = {
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'],
},
];

View file

@ -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();
});
});
});

View file

@ -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<RelatedEntitiesQueries.relatedHosts> = {
buildDsl: (options: UsersRelatedHostsRequestOptions) => buildRelatedHostsQuery(options),
parse: async (
options: UsersRelatedHostsRequestOptions,
response: IEsSearchResponse<unknown>,
deps?: {
esClient: IScopedClusterClient;
spaceId?: string;
endpointContext: EndpointAppContext;
}
): Promise<UsersRelatedHostsStrategyResponse> => {
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<RelatedHost[]> {
const hostNames = relatedHosts.map((item) => item.host);
const hostRiskData = await getHostRiskData(esClient, spaceId, hostNames);
const hostsRiskByHostName: Record<string, RiskSeverity> | 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;
}

View file

@ -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);
});
});

View file

@ -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;
};

View file

@ -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<unknown> = {
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'],
},
];

View file

@ -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();
});
});
});

View file

@ -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<RelatedEntitiesQueries.relatedUsers> = {
buildDsl: (options: HostsRelatedUsersRequestOptions) => buildRelatedUsersQuery(options),
parse: async (
options: HostsRelatedUsersRequestOptions,
response: IEsSearchResponse<unknown>,
deps?: {
esClient: IScopedClusterClient;
spaceId?: string;
}
): Promise<HostsRelatedUsersStrategyResponse> => {
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<RelatedUser[]> {
const userNames = relatedUsers.map((item) => item.user);
const userRiskData = await getUserRiskData(esClient, spaceId, userNames);
const usersRiskByUserName: Record<string, RiskSeverity> | 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;
}

View file

@ -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);
});
});

View file

@ -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;
};

View file

@ -116,7 +116,7 @@ async function enhanceEdges(
: edges;
}
async function getUserRiskData(
export async function getUserRiskData(
esClient: IScopedClusterClient,
spaceId: string,
userNames: string[]

View file

@ -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",

View file

@ -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": "データ",

View file

@ -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": "数据",