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 7bed3ffe95c0..8cd20ad8837f 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 95cc6ceef4e6..0696dada8569 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 6d30abce0f50..d8ad4b168adb 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 48c58a031db4..f808f0fdf7a5 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 6383808293a9..138cc45011c8 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 7ac81811ff90..1f17bbfc870f 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 254cc9f06879..d5c13e0d2b52 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;
+ }
+}