mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 17:28:26 -04:00
[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  ### 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:
parent
c247899225
commit
123e535754
46 changed files with 2887 additions and 123 deletions
|
@ -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
|
||||
|
|
|
@ -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',
|
||||
}
|
|
@ -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>;
|
||||
}
|
|
@ -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>;
|
||||
}
|
|
@ -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)
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
);
|
|
@ -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 =
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
};
|
|
@ -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`,
|
||||
}
|
||||
);
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
};
|
|
@ -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`,
|
||||
}
|
||||
);
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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';
|
||||
|
|
|
@ -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'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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';
|
|
@ -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;
|
||||
|
|
|
@ -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.',
|
||||
}
|
||||
);
|
||||
|
|
|
@ -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'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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';
|
|
@ -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 {
|
||||
/**
|
||||
|
|
|
@ -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,
|
||||
};
|
|
@ -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', () => {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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} />;
|
||||
};
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 =
|
||||
|
|
|
@ -110,7 +110,7 @@ async function enhanceEdges(
|
|||
: edges;
|
||||
}
|
||||
|
||||
async function getHostRiskData(
|
||||
export async function getHostRiskData(
|
||||
esClient: IScopedClusterClient,
|
||||
spaceId: string,
|
||||
hostNames: string[]
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -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,
|
||||
};
|
|
@ -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'],
|
||||
},
|
||||
];
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
}
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
};
|
|
@ -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'],
|
||||
},
|
||||
];
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
}
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
};
|
|
@ -116,7 +116,7 @@ async function enhanceEdges(
|
|||
: edges;
|
||||
}
|
||||
|
||||
async function getUserRiskData(
|
||||
export async function getUserRiskData(
|
||||
esClient: IScopedClusterClient,
|
||||
spaceId: string,
|
||||
userNames: string[]
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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": "データ",
|
||||
|
|
|
@ -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": "数据",
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue