From 1d89be62375f9697099202e90229a7c9a1b867b4 Mon Sep 17 00:00:00 2001 From: Pablo Machado Date: Thu, 6 Oct 2022 15:25:02 +0200 Subject: [PATCH] [Security Solution] Add risk score column to all users table (#142610) * Add risk score column to all users table * [CI] Auto-commit changed files from 'node scripts/build_plugin_list_docs' Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- .../security_solution/users/all/index.ts | 2 + .../users/components/all_users/index.test.tsx | 76 +++++++- .../users/components/all_users/index.tsx | 164 +++++++++++++----- .../components/all_users/translations.ts | 12 ++ .../factory/users/all/__mocks__/index.ts | 59 ++++++- .../all/__snapshots__/index.test.ts.snap | 13 +- .../factory/users/all/index.test.ts | 83 ++++++++- .../factory/users/all/index.ts | 70 +++++++- 8 files changed, 421 insertions(+), 58 deletions(-) diff --git a/x-pack/plugins/security_solution/common/search_strategy/security_solution/users/all/index.ts b/x-pack/plugins/security_solution/common/search_strategy/security_solution/users/all/index.ts index 7bed3ffe95c0d..8cd20ad8837f0 100644 --- a/x-pack/plugins/security_solution/common/search_strategy/security_solution/users/all/index.ts +++ b/x-pack/plugins/security_solution/common/search_strategy/security_solution/users/all/index.ts @@ -10,11 +10,13 @@ import type { IEsSearchResponse } from '@kbn/data-plugin/common'; import type { Inspect, Maybe, PageInfoPaginated } from '../../../common'; import type { RequestOptionsPaginated } from '../..'; import type { SortableUsersFields } from '../common'; +import type { RiskSeverity } from '../../risk_score'; export interface User { name: string; lastSeen: string; domain: string; + risk?: RiskSeverity; } export interface UsersStrategyResponse extends IEsSearchResponse { diff --git a/x-pack/plugins/security_solution/public/users/components/all_users/index.test.tsx b/x-pack/plugins/security_solution/public/users/components/all_users/index.test.tsx index 95cc6ceef4e62..0696dada85693 100644 --- a/x-pack/plugins/security_solution/public/users/components/all_users/index.test.tsx +++ b/x-pack/plugins/security_solution/public/users/components/all_users/index.test.tsx @@ -12,10 +12,16 @@ import { TestProviders } from '../../../common/mock'; import { UsersTable } from '.'; import { usersModel } from '../../store'; -import { Direction } from '../../../../common/search_strategy'; +import { Direction, RiskSeverity } from '../../../../common/search_strategy'; import { UsersFields } from '../../../../common/search_strategy/security_solution/users/common'; import { render } from '@testing-library/react'; +const mockUseMlCapabilities = jest.fn().mockReturnValue({ isPlatinumOrTrialLicense: false }); + +jest.mock('../../../common/components/ml/hooks/use_ml_capabilities', () => ({ + useMlCapabilities: () => mockUseMlCapabilities(), +})); + describe('Users Table Component', () => { const loadPage = jest.fn(); @@ -72,5 +78,73 @@ describe('Users Table Component', () => { expect(getByTestId('table-allUsers-loading-false')).toHaveTextContent('(Empty string)'); }); + + test('it renders "Host Risk classfication" column when "isPlatinumOrTrialLicense" is truthy', () => { + mockUseMlCapabilities.mockReturnValue({ isPlatinumOrTrialLicense: true }); + + const { getAllByRole, getByText } = render( + + {}} + /> + + ); + + expect(getAllByRole('columnheader').length).toBe(4); + expect(getByText('Critical')).toBeInTheDocument(); + }); + + test("it doesn't renders 'Host Risk classfication' column when 'isPlatinumOrTrialLicense' is falsy", () => { + mockUseMlCapabilities.mockReturnValue({ isPlatinumOrTrialLicense: false }); + + const { getAllByRole, queryByText } = render( + + {}} + /> + + ); + + expect(getAllByRole('columnheader').length).toBe(3); + expect(queryByText('Critical')).not.toBeInTheDocument(); + }); }); }); diff --git a/x-pack/plugins/security_solution/public/users/components/all_users/index.tsx b/x-pack/plugins/security_solution/public/users/components/all_users/index.tsx index 6d30abce0f506..d8ad4b168adb4 100644 --- a/x-pack/plugins/security_solution/public/users/components/all_users/index.tsx +++ b/x-pack/plugins/security_solution/public/users/components/all_users/index.tsx @@ -8,9 +8,10 @@ import React, { useCallback, useMemo } from 'react'; import { useDispatch } from 'react-redux'; +import { EuiIcon, EuiLink, EuiText, EuiToolTip } from '@elastic/eui'; import { FormattedRelativePreferenceDate } from '../../../common/components/formatted_date'; import { UserDetailsLink } from '../../../common/components/links'; -import { getOrEmptyTagFromValue } from '../../../common/components/empty_value'; +import { getEmptyTagValue, getOrEmptyTagFromValue } from '../../../common/components/empty_value'; import type { Columns, Criteria, ItemsPerRow } from '../../../common/components/paginated_table'; import { PaginatedTable } from '../../../common/components/paginated_table'; @@ -22,6 +23,13 @@ import * as i18n from './translations'; import { usersActions, usersModel, usersSelectors } from '../../store'; import type { User } from '../../../../common/search_strategy/security_solution/users/all'; import type { SortUsersField } from '../../../../common/search_strategy/security_solution/users/common'; +import type { RiskSeverity } from '../../../../common/search_strategy'; +import { RiskScore } from '../../../common/components/severity/common'; +import { useMlCapabilities } from '../../../common/components/ml/hooks/use_ml_capabilities'; +import { VIEW_USERS_BY_SEVERITY } from '../user_risk_score_table/translations'; +import { SecurityPageName } from '../../../app/types'; +import { UsersTableType } from '../../store/model'; +import { useNavigateTo } from '../../../common/lib/kibana'; const tableType = usersModel.UsersTableType.allUsers; @@ -41,7 +49,8 @@ interface UsersTableProps { export type UsersTableColumns = [ Columns, Columns, - Columns + Columns, + Columns? ]; const rowItems: ItemsPerRow[] = [ @@ -55,51 +64,89 @@ const rowItems: ItemsPerRow[] = [ }, ]; -const getUsersColumns = (): UsersTableColumns => [ - { - field: 'name', - name: i18n.USER_NAME, - truncateText: false, - sortable: true, - mobileOptions: { show: true }, - render: (name) => - name != null && name.length > 0 - ? getRowItemDraggables({ - rowItems: [name], - attrName: 'user.name', - idPrefix: `users-table-${name}-name`, - render: (item) => , - isAggregatable: true, - fieldType: 'keyword', - }) - : getOrEmptyTagFromValue(name), - }, - { - field: 'lastSeen', - name: i18n.LAST_SEEN, - sortable: true, - truncateText: false, - mobileOptions: { show: true }, - render: (lastSeen) => , - }, - { - field: 'domain', - name: i18n.DOMAIN, - sortable: false, - truncateText: false, - mobileOptions: { show: true }, - render: (domain) => - domain != null && domain.length > 0 - ? getRowItemDraggables({ - rowItems: [domain], - attrName: 'user.domain', - idPrefix: `users-table-${domain}-domain`, - isAggregatable: true, - fieldType: 'keyword', - }) - : getOrEmptyTagFromValue(domain), - }, -]; +const getUsersColumns = ( + showRiskColumn: boolean, + dispatchSeverityUpdate: (s: RiskSeverity) => void +): UsersTableColumns => { + const columns: UsersTableColumns = [ + { + field: 'name', + name: i18n.USER_NAME, + truncateText: false, + sortable: true, + mobileOptions: { show: true }, + render: (name) => + name != null && name.length > 0 + ? getRowItemDraggables({ + rowItems: [name], + attrName: 'user.name', + idPrefix: `users-table-${name}-name`, + render: (item) => , + isAggregatable: true, + fieldType: 'keyword', + }) + : getOrEmptyTagFromValue(name), + }, + { + field: 'lastSeen', + name: i18n.LAST_SEEN, + sortable: true, + truncateText: false, + mobileOptions: { show: true }, + render: (lastSeen) => , + }, + { + field: 'domain', + name: i18n.DOMAIN, + sortable: false, + truncateText: false, + mobileOptions: { show: true }, + render: (domain) => + domain != null && domain.length > 0 + ? getRowItemDraggables({ + rowItems: [domain], + attrName: 'user.domain', + idPrefix: `users-table-${domain}-domain`, + isAggregatable: true, + fieldType: 'keyword', + }) + : getOrEmptyTagFromValue(domain), + }, + ]; + + if (showRiskColumn) { + columns.push({ + field: 'risk', + name: ( + + <> + {i18n.USER_RISK} + + + ), + truncateText: false, + mobileOptions: { show: true }, + sortable: false, + render: (riskScore: RiskSeverity) => { + if (riskScore != null) { + return ( + dispatchSeverityUpdate(riskScore)}> + {VIEW_USERS_BY_SEVERITY(riskScore.toLowerCase())} + + } + severity={riskScore} + /> + ); + } + return getEmptyTagValue(); + }, + }); + } + + return columns; +}; const UsersTableComponent: React.FC = ({ users, @@ -116,6 +163,8 @@ const UsersTableComponent: React.FC = ({ const dispatch = useDispatch(); const getUsersSelector = useMemo(() => usersSelectors.allUsersSelector(), []); const { activePage, limit } = useDeepEqualSelector((state) => getUsersSelector(state)); + const isPlatinumOrTrialLicense = useMlCapabilities().isPlatinumOrTrialLicense; + const { navigateTo } = useNavigateTo(); const updateLimitPagination = useCallback( (newLimit) => { @@ -159,7 +208,26 @@ const UsersTableComponent: React.FC = ({ }, [dispatch, sort] ); - const columns = useMemo(() => getUsersColumns(), []); + + const dispatchSeverityUpdate = useCallback( + (s: RiskSeverity) => { + dispatch( + usersActions.updateUserRiskScoreSeverityFilter({ + severitySelection: [s], + }) + ); + navigateTo({ + deepLinkId: SecurityPageName.users, + path: UsersTableType.risk, + }); + }, + [dispatch, navigateTo] + ); + + const columns = useMemo( + () => getUsersColumns(isPlatinumOrTrialLicense, dispatchSeverityUpdate), + [isPlatinumOrTrialLicense, dispatchSeverityUpdate] + ); return ( values: { totalCount }, defaultMessage: `{totalCount, plural, =1 {user} other {users}}`, }); + +export const USER_RISK_TOOLTIP = i18n.translate( + 'xpack.securitySolution.usersTable.userRiskToolTip', + { + defaultMessage: + 'User risk classification is determined by user risk score. Users classified as Critical or High are indicated as risky.', + } +); + +export const USER_RISK = i18n.translate('xpack.securitySolution.usersTable.riskTitle', { + defaultMessage: 'User risk classification', +}); diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/users/all/__mocks__/index.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/users/all/__mocks__/index.ts index 48c58a031db4a..f808f0fdf7a5f 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/users/all/__mocks__/index.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/users/all/__mocks__/index.ts @@ -5,11 +5,17 @@ * 2.0. */ +import type { KibanaRequest } from '@kbn/core-http-server'; import type { IEsSearchResponse } from '@kbn/data-plugin/common'; +import { allowedExperimentalValues } from '../../../../../../../common/experimental_features'; import { Direction } from '../../../../../../../common/search_strategy'; import { UsersQueries } from '../../../../../../../common/search_strategy/security_solution/users'; import type { UsersRequestOptions } from '../../../../../../../common/search_strategy/security_solution/users/all'; import { UsersFields } from '../../../../../../../common/search_strategy/security_solution/users/common'; +import { elasticsearchServiceMock } from '@kbn/core/server/mocks'; +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'; export const mockOptions: UsersRequestOptions = { defaultIndex: ['test_indices*'], @@ -47,14 +53,14 @@ export const mockSearchStrategyResponse: IEsSearchResponse = { }, aggregations: { user_count: { - value: 1, + value: 2, }, user_data: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [ { - key: 'vagrant', + key: 'jose52', doc_count: 780, lastSeen: { value: 1644837532000, @@ -85,6 +91,38 @@ export const mockSearchStrategyResponse: IEsSearchResponse = { }, }, }, + { + key: 'danny', + doc_count: 781, + lastSeen: { + value: 1644837532000, + value_as_string: '2022-02-14T11:18:52.000Z', + }, + domain: { + hits: { + total: { + value: 100, + relation: 'eq', + }, + max_score: null, + hits: [ + { + _index: 'endgame-00001', + _id: 'inT0934BjUd1_U2597Vv', + _score: null, + fields: { + 'user.name': ['danny'], + '@timestamp': ['2022-04-13T17:16:34.540Z'], + 'user.id': ['18'], + 'user.email': ['danny@barrett.com'], + 'user.domain': ['ENDPOINT-W-8-04'], + }, + sort: [1644837532000], + }, + ], + }, + }, + }, ], }, }, @@ -94,3 +132,20 @@ export const mockSearchStrategyResponse: IEsSearchResponse = { total: 2, loaded: 2, }; + +export const mockDeps = () => ({ + esClient: elasticsearchServiceMock.createScopedClusterClient(), + savedObjectsClient: {} as SavedObjectsClientContract, + endpointContext: { + logFactory: { + get: jest.fn(), + }, + config: jest.fn().mockResolvedValue({}), + experimentalFeatures: { + ...allowedExperimentalValues, + }, + service: {} as EndpointAppContextService, + } as EndpointAppContext, + request: {} as KibanaRequest, + spaceId: 'test-space', +}); diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/users/all/__snapshots__/index.test.ts.snap b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/users/all/__snapshots__/index.test.ts.snap index 6383808293a9d..138cc45011c8c 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/users/all/__snapshots__/index.test.ts.snap +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/users/all/__snapshots__/index.test.ts.snap @@ -7,17 +7,24 @@ Array [ "ENDPOINT-W-8-03", ], "lastSeen": "2022-02-14T11:18:52.000Z", - "name": "vagrant", + "name": "jose52", + }, + Object { + "domain": Array [ + "ENDPOINT-W-8-04", + ], + "lastSeen": "2022-02-14T11:18:52.000Z", + "name": "danny", }, ] `; -exports[`allHosts search strategy parse should parse data correctly 2`] = `1`; +exports[`allHosts search strategy parse should parse data correctly 2`] = `2`; exports[`allHosts search strategy parse should parse data correctly 3`] = ` Object { "activePage": 0, - "fakeTotalCount": 1, + "fakeTotalCount": 2, "showMorePagesIndicator": false, } `; diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/users/all/index.test.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/users/all/index.test.ts index 7ac81811ff903..1f17bbfc870fc 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/users/all/index.test.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/users/all/index.test.ts @@ -9,8 +9,20 @@ import { DEFAULT_MAX_TABLE_QUERY_SIZE } from '../../../../../../common/constants import * as buildQuery from './query.all_users.dsl'; import { allUsers } from '.'; -import { mockOptions, mockSearchStrategyResponse } from './__mocks__'; +import { mockDeps, mockOptions, mockSearchStrategyResponse } from './__mocks__'; import type { UsersRequestOptions } from '../../../../../../common/search_strategy/security_solution/users/all'; +import * as buildRiskQuery from '../../risk_score/all/query.risk_score.dsl'; + +import { get } from 'lodash/fp'; + +class IndexNotFoundException extends Error { + meta: { body: { error: { type: string } } }; + + constructor() { + super(); + this.meta = { body: { error: { type: 'index_not_found_exception' } } }; + } +} describe('allHosts search strategy', () => { const buildAllHostsQuery = jest.spyOn(buildQuery, 'buildUsersQuery'); @@ -47,5 +59,74 @@ describe('allHosts search strategy', () => { expect(result.totalCount).toMatchSnapshot(); expect(result.pageInfo).toMatchSnapshot(); }); + + test('should enhance data with risk score', async () => { + const risk = 'TEST_RISK_SCORE'; + const userName: string = get( + `aggregations.user_data.buckets[0].domain.hits.hits[0].fields['user.name']`, + mockSearchStrategyResponse.rawResponse + ); + + const mockedDeps = mockDeps(); + + mockedDeps.esClient.asCurrentUser.search.mockResponse({ + hits: { + hits: [ + // @ts-expect-error incomplete type + { + _source: { + risk, + user: { + name: userName, + risk: { + multipliers: [], + calculated_score_norm: 9999, + calculated_level: risk, + rule_risks: [], + }, + }, + }, + }, + ], + }, + }); + + const result = await allUsers.parse(mockOptions, mockSearchStrategyResponse, mockedDeps); + + expect(result.users[0].risk).toBe(risk); + }); + + test('should query host risk only for hostNames in the current page', async () => { + const buildHostsRiskQuery = jest.spyOn(buildRiskQuery, 'buildRiskScoreQuery'); + const mockedDeps = mockDeps(); + // @ts-expect-error incomplete type + mockedDeps.esClient.asCurrentUser.search.mockResponse({ hits: { hits: [] } }); + + const userName: string = get( + `aggregations.user_data.buckets[1].domain.hits.hits[0].fields['user.name']`, + mockSearchStrategyResponse.rawResponse + ); + + // 2 pages with one item on each + const pagination = { activePage: 1, cursorStart: 1, fakePossibleCount: 5, querySize: 2 }; + + await allUsers.parse({ ...mockOptions, pagination }, mockSearchStrategyResponse, mockedDeps); + + expect(buildHostsRiskQuery).toHaveBeenCalledWith({ + defaultIndex: ['ml_user_risk_score_latest_test-space'], + filterQuery: { terms: { 'user.name': userName } }, + }); + }); + + test("should not enhance data when index doesn't exist", async () => { + const mockedDeps = mockDeps(); + mockedDeps.esClient.asCurrentUser.search.mockImplementation(() => { + throw new IndexNotFoundException(); + }); + + const result = await allUsers.parse(mockOptions, mockSearchStrategyResponse, mockedDeps); + + expect(result.users[0].risk).toBeUndefined(); + }); }); }); diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/users/all/index.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/users/all/index.ts index 254cc9f06879f..d5c13e0d2b52e 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/users/all/index.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/users/all/index.ts @@ -8,6 +8,7 @@ import { getOr } from 'lodash/fp'; import type { IEsSearchResponse } from '@kbn/data-plugin/common'; +import type { IScopedClusterClient } from '@kbn/core-elasticsearch-server'; import { DEFAULT_MAX_TABLE_QUERY_SIZE } from '../../../../../../common/constants'; import { inspectStringifyObject } from '../../../../../utils/build_query'; @@ -15,10 +16,14 @@ import type { SecuritySolutionFactory } from '../../types'; import { buildUsersQuery } from './query.all_users.dsl'; import type { UsersQueries } from '../../../../../../common/search_strategy/security_solution/users'; import type { + User, UsersRequestOptions, UsersStrategyResponse, } from '../../../../../../common/search_strategy/security_solution/users/all'; import type { AllUsersAggEsItem } from '../../../../../../common/search_strategy/security_solution/users/common'; +import { buildRiskScoreQuery } from '../../risk_score/all/query.risk_score.dsl'; +import type { RiskSeverity, UserRiskScore } from '../../../../../../common/search_strategy'; +import { buildUserNamesFilter, getUserRiskIndex } from '../../../../../../common/search_strategy'; export const allUsers: SecuritySolutionFactory = { buildDsl: (options: UsersRequestOptions) => { @@ -29,7 +34,12 @@ export const allUsers: SecuritySolutionFactory = { }, parse: async ( options: UsersRequestOptions, - response: IEsSearchResponse + response: IEsSearchResponse, + deps?: { + esClient: IScopedClusterClient; + spaceId?: string; + // endpointContext: EndpointAppContext; + } ): Promise => { const { activePage, cursorStart, fakePossibleCount, querySize } = options.pagination; const inspect = { @@ -46,7 +56,7 @@ export const allUsers: SecuritySolutionFactory = { const fakeTotalCount = fakePossibleCount <= totalCount ? fakePossibleCount : totalCount; - const users = buckets.map( + const users: User[] = buckets.map( (bucket: AllUsersAggEsItem) => ({ name: bucket.key, lastSeen: getOr(null, `lastSeen.value_as_string`, bucket), @@ -56,11 +66,18 @@ export const allUsers: SecuritySolutionFactory = { ); const showMorePagesIndicator = totalCount > fakeTotalCount; + + const edges = users.splice(cursorStart, querySize - cursorStart); + const userNames = edges.map(({ name }) => name); + const enhancedEdges = deps?.spaceId + ? await enhanceEdges(edges, userNames, deps.spaceId, deps.esClient) + : edges; + return { ...response, inspect, totalCount, - users: users.splice(cursorStart, querySize - cursorStart), + users: enhancedEdges, pageInfo: { activePage: activePage ?? 0, fakeTotalCount, @@ -69,3 +86,50 @@ export const allUsers: SecuritySolutionFactory = { }; }, }; + +async function enhanceEdges( + edges: User[], + userNames: string[], + spaceId: string, + esClient: IScopedClusterClient +): Promise { + const userRiskData = await getUserRiskData(esClient, spaceId, userNames); + const usersRiskByUserName: Record | undefined = + userRiskData?.hits.hits.reduce( + (acc, hit) => ({ + ...acc, + [hit._source?.user.name ?? '']: hit._source?.user?.risk?.calculated_level, + }), + {} + ); + + return usersRiskByUserName + ? edges.map(({ name, lastSeen, domain }) => ({ + name, + lastSeen, + domain, + risk: usersRiskByUserName[name ?? ''], + })) + : edges; +} + +async function getUserRiskData( + esClient: IScopedClusterClient, + spaceId: string, + userNames: string[] +) { + try { + const userRiskResponse = await esClient.asCurrentUser.search( + buildRiskScoreQuery({ + defaultIndex: [getUserRiskIndex(spaceId)], + filterQuery: buildUserNamesFilter(userNames), + }) + ); + return userRiskResponse; + } catch (error) { + if (error?.meta?.body?.error?.type !== 'index_not_found_exception') { + throw error; + } + return undefined; + } +}