From da96f61330c0888180f5fa3cddc88dd16cda1afa Mon Sep 17 00:00:00 2001 From: Pablo Machado Date: Mon, 13 Dec 2021 12:16:20 +0100 Subject: [PATCH] Hosts Risk Step 2 - Hosts Page - Risk Column #119734 (#120487) * Add Host risk classification column to All hosts table * Add cypress test to risk column on all hosts table * Fix unit test * Add unit test * Add tooltip to host risk column Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../security_solution/hosts/common/index.ts | 1 + .../hosts/risk_score/index.test.ts | 14 ++ .../hosts/risk_score/index.ts | 7 +- .../integration/hosts/hosts_risk_column.ts | 33 +++ .../integration/hosts/risky_hosts_kpi.spec.ts | 5 - .../hosts_risk/use_hosts_risk_score.ts | 6 +- .../use_hosts_risk_score_complete.ts | 4 +- .../security_solution/public/helpers.test.tsx | 7 - .../security_solution/public/helpers.tsx | 5 - .../common/host_risk_score.test.tsx | 102 +++++++++ .../components/common/host_risk_score.tsx | 44 ++++ .../hosts/components/hosts_table/columns.tsx | 214 ++++++++++-------- .../components/hosts_table/index.test.tsx | 48 ++++ .../hosts/components/hosts_table/index.tsx | 11 +- .../components/hosts_table/translations.ts | 12 + .../kpi_hosts/risky_hosts/index.tsx | 38 +--- .../kpi_hosts/risky_hosts/index.tsx | 7 +- .../security_solution/server/plugin.ts | 4 +- .../factory/hosts/all/__mocks__/index.ts | 24 ++ .../factory/hosts/all/index.test.ts | 96 +++++++- .../factory/hosts/all/index.ts | 73 +++++- .../hosts/risk_score/query.hosts_risk.dsl.ts | 6 +- .../security_solution/factory/types.ts | 1 + .../security_solution/index.ts | 5 +- .../es_archives/risky_hosts/data.json | 2 +- .../es_archives/risky_hosts/mappings.json | 8 +- 26 files changed, 606 insertions(+), 171 deletions(-) create mode 100644 x-pack/plugins/security_solution/common/search_strategy/security_solution/hosts/risk_score/index.test.ts create mode 100644 x-pack/plugins/security_solution/cypress/integration/hosts/hosts_risk_column.ts create mode 100644 x-pack/plugins/security_solution/public/hosts/components/common/host_risk_score.test.tsx create mode 100644 x-pack/plugins/security_solution/public/hosts/components/common/host_risk_score.tsx diff --git a/x-pack/plugins/security_solution/common/search_strategy/security_solution/hosts/common/index.ts b/x-pack/plugins/security_solution/common/search_strategy/security_solution/hosts/common/index.ts index f6f5ad4cd23f16..8a9a047aab3fdb 100644 --- a/x-pack/plugins/security_solution/common/search_strategy/security_solution/hosts/common/index.ts +++ b/x-pack/plugins/security_solution/common/search_strategy/security_solution/hosts/common/index.ts @@ -45,6 +45,7 @@ export interface HostItem { endpoint?: Maybe; host?: Maybe; lastSeen?: Maybe; + risk?: string; } export interface HostValue { diff --git a/x-pack/plugins/security_solution/common/search_strategy/security_solution/hosts/risk_score/index.test.ts b/x-pack/plugins/security_solution/common/search_strategy/security_solution/hosts/risk_score/index.test.ts new file mode 100644 index 00000000000000..8c58ccaabe8df8 --- /dev/null +++ b/x-pack/plugins/security_solution/common/search_strategy/security_solution/hosts/risk_score/index.test.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { getHostRiskIndex } from '.'; + +describe('hosts risk search_strategy getHostRiskIndex', () => { + it('should properly return index if space is specified', () => { + expect(getHostRiskIndex('testName')).toEqual('ml_host_risk_score_latest_testName'); + }); +}); diff --git a/x-pack/plugins/security_solution/common/search_strategy/security_solution/hosts/risk_score/index.ts b/x-pack/plugins/security_solution/common/search_strategy/security_solution/hosts/risk_score/index.ts index 23cda0b68f0382..4273c08c638f3f 100644 --- a/x-pack/plugins/security_solution/common/search_strategy/security_solution/hosts/risk_score/index.ts +++ b/x-pack/plugins/security_solution/common/search_strategy/security_solution/hosts/risk_score/index.ts @@ -10,12 +10,13 @@ import type { IEsSearchRequest, IEsSearchResponse, } from '../../../../../../../../src/plugins/data/common'; +import { RISKY_HOSTS_INDEX_PREFIX } from '../../../../constants'; import { Inspect, Maybe, TimerangeInput } from '../../../common'; export interface HostsRiskScoreRequestOptions extends IEsSearchRequest { defaultIndex: string[]; factoryQueryType?: FactoryQueryTypes; - hostName?: string; + hostNames?: string[]; timerange?: TimerangeInput; } @@ -38,3 +39,7 @@ export interface RuleRisk { rule_name: string; rule_risk: string; } + +export const getHostRiskIndex = (spaceId: string): string => { + return `${RISKY_HOSTS_INDEX_PREFIX}${spaceId}`; +}; diff --git a/x-pack/plugins/security_solution/cypress/integration/hosts/hosts_risk_column.ts b/x-pack/plugins/security_solution/cypress/integration/hosts/hosts_risk_column.ts new file mode 100644 index 00000000000000..bb57a8973c8e6d --- /dev/null +++ b/x-pack/plugins/security_solution/cypress/integration/hosts/hosts_risk_column.ts @@ -0,0 +1,33 @@ +/* + * 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 { loginAndWaitForPage } from '../../tasks/login'; + +import { HOSTS_URL } from '../../urls/navigation'; +import { cleanKibana } from '../../tasks/common'; +import { esArchiverLoad, esArchiverUnload } from '../../tasks/es_archiver'; +import { TABLE_CELL } from '../../screens/alerts_details'; +import { kqlSearch } from '../../tasks/security_header'; + +describe('All hosts table', () => { + before(() => { + cleanKibana(); + esArchiverLoad('risky_hosts'); + }); + + after(() => { + esArchiverUnload('risky_hosts'); + }); + + it('it renders risk column', () => { + loginAndWaitForPage(HOSTS_URL); + kqlSearch('host.name: "siem-kibana" {enter}'); + + cy.get('[data-test-subj="tableHeaderCell_node.risk_4"]').should('exist'); + cy.get(`${TABLE_CELL} .euiTableCellContent`).eq(4).should('have.text', 'Low'); + }); +}); diff --git a/x-pack/plugins/security_solution/cypress/integration/hosts/risky_hosts_kpi.spec.ts b/x-pack/plugins/security_solution/cypress/integration/hosts/risky_hosts_kpi.spec.ts index 4f282e1e69d5cc..602a9118128b5b 100644 --- a/x-pack/plugins/security_solution/cypress/integration/hosts/risky_hosts_kpi.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/hosts/risky_hosts_kpi.spec.ts @@ -8,13 +8,8 @@ import { loginAndWaitForPage } from '../../tasks/login'; import { HOSTS_URL } from '../../urls/navigation'; -import { cleanKibana } from '../../tasks/common'; describe('RiskyHosts KPI', () => { - before(() => { - cleanKibana(); - }); - it('it renders', () => { loginAndWaitForPage(HOSTS_URL); diff --git a/x-pack/plugins/security_solution/public/common/containers/hosts_risk/use_hosts_risk_score.ts b/x-pack/plugins/security_solution/public/common/containers/hosts_risk/use_hosts_risk_score.ts index 41fcd29191da25..debdacb570ad05 100644 --- a/x-pack/plugins/security_solution/public/common/containers/hosts_risk/use_hosts_risk_score.ts +++ b/x-pack/plugins/security_solution/public/common/containers/hosts_risk/use_hosts_risk_score.ts @@ -13,10 +13,10 @@ import { useAppToasts } from '../../hooks/use_app_toasts'; import { useKibana } from '../../lib/kibana'; import { inputsActions } from '../../store/actions'; import { isIndexNotFoundError } from '../../utils/exceptions'; -import { HostsRiskScore } from '../../../../common/search_strategy'; +import { getHostRiskIndex, HostsRiskScore } from '../../../../common/search_strategy'; + import { useHostsRiskScoreComplete } from './use_hosts_risk_score_complete'; import { useIsExperimentalFeatureEnabled } from '../../hooks/use_experimental_features'; -import { getHostRiskIndex } from '../../../helpers'; export const QUERY_ID = 'host_risk_score'; const noop = () => {}; @@ -104,7 +104,7 @@ export const useHostsRiskScore = ({ timerange: timerange ? { to: timerange.to, from: timerange.from, interval: '' } : undefined, - hostName, + hostNames: hostName ? [hostName] : undefined, defaultIndex: [getHostRiskIndex(space.id)], }); }); diff --git a/x-pack/plugins/security_solution/public/common/containers/hosts_risk/use_hosts_risk_score_complete.ts b/x-pack/plugins/security_solution/public/common/containers/hosts_risk/use_hosts_risk_score_complete.ts index 934cb88ee0d869..6faaa3c8f08dbf 100644 --- a/x-pack/plugins/security_solution/public/common/containers/hosts_risk/use_hosts_risk_score_complete.ts +++ b/x-pack/plugins/security_solution/public/common/containers/hosts_risk/use_hosts_risk_score_complete.ts @@ -28,7 +28,7 @@ export const getHostsRiskScore = ({ data, defaultIndex, timerange, - hostName, + hostNames, signal, }: GetHostsRiskScoreProps): Observable => data.search.search( @@ -36,7 +36,7 @@ export const getHostsRiskScore = ({ defaultIndex, factoryQueryType: HostsQueries.hostsRiskScore, timerange, - hostName, + hostNames, }, { strategy: 'securitySolutionSearchStrategy', diff --git a/x-pack/plugins/security_solution/public/helpers.test.tsx b/x-pack/plugins/security_solution/public/helpers.test.tsx index 3475ac7c28f7ae..5ba5d882c16d00 100644 --- a/x-pack/plugins/security_solution/public/helpers.test.tsx +++ b/x-pack/plugins/security_solution/public/helpers.test.tsx @@ -10,7 +10,6 @@ import { Capabilities } from '../../../../src/core/public'; import { CASES_FEATURE_ID, SERVER_APP_ID } from '../common/constants'; import { parseRoute, - getHostRiskIndex, isSubPluginAvailable, getSubPluginRoutesByCapabilities, RedirectRoute, @@ -65,12 +64,6 @@ describe('public helpers parseRoute', () => { }); }); -describe('public helpers export getHostRiskIndex', () => { - it('should properly return index if space is specified', () => { - expect(getHostRiskIndex('testName')).toEqual('ml_host_risk_score_latest_testName'); - }); -}); - describe('#getSubPluginRoutesByCapabilities', () => { const mockRender = () => null; const mockSubPlugins = { diff --git a/x-pack/plugins/security_solution/public/helpers.tsx b/x-pack/plugins/security_solution/public/helpers.tsx index d330da94e779cf..09f955a53cd0ad 100644 --- a/x-pack/plugins/security_solution/public/helpers.tsx +++ b/x-pack/plugins/security_solution/public/helpers.tsx @@ -17,7 +17,6 @@ import { EXCEPTIONS_PATH, RULES_PATH, UEBA_PATH, - RISKY_HOSTS_INDEX_PREFIX, SERVER_APP_ID, CASES_FEATURE_ID, OVERVIEW_PATH, @@ -164,10 +163,6 @@ export const isDetectionsPath = (pathname: string): boolean => { }); }; -export const getHostRiskIndex = (spaceId: string): string => { - return `${RISKY_HOSTS_INDEX_PREFIX}${spaceId}`; -}; - export const getSubPluginRoutesByCapabilities = ( subPlugins: StartedSubPlugins, capabilities: Capabilities diff --git a/x-pack/plugins/security_solution/public/hosts/components/common/host_risk_score.test.tsx b/x-pack/plugins/security_solution/public/hosts/components/common/host_risk_score.test.tsx new file mode 100644 index 00000000000000..4f70dce3c11609 --- /dev/null +++ b/x-pack/plugins/security_solution/public/hosts/components/common/host_risk_score.test.tsx @@ -0,0 +1,102 @@ +/* + * 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 { render } from '@testing-library/react'; +import React from 'react'; +import { HostRiskSeverity } from '../../../../common/search_strategy'; +import { TestProviders } from '../../../common/mock'; +import { HostRiskScore } from './host_risk_score'; + +import { EuiHealth, EuiHealthProps } from '@elastic/eui'; + +import { euiThemeVars } from '@kbn/ui-shared-deps-src/theme'; + +jest.mock('@elastic/eui', () => { + const original = jest.requireActual('@elastic/eui'); + return { + ...jest.requireActual('@elastic/eui'), + EuiHealth: jest.fn((props: EuiHealthProps) => ), + }; +}); + +describe('HostRiskScore', () => { + const context = {}; + it('renders critical severity risk score', () => { + const { container } = render( + + + + ); + + expect(container).toHaveTextContent(HostRiskSeverity.critical); + + expect(EuiHealth as jest.Mock).toHaveBeenLastCalledWith( + expect.objectContaining({ color: euiThemeVars.euiColorDanger }), + context + ); + }); + + it('renders hight severity risk score', () => { + const { container } = render( + + + + ); + + expect(container).toHaveTextContent(HostRiskSeverity.high); + + expect(EuiHealth as jest.Mock).toHaveBeenLastCalledWith( + expect.objectContaining({ color: euiThemeVars.euiColorVis9_behindText }), + context + ); + }); + + it('renders moderate severity risk score', () => { + const { container } = render( + + + + ); + + expect(container).toHaveTextContent(HostRiskSeverity.moderate); + + expect(EuiHealth as jest.Mock).toHaveBeenLastCalledWith( + expect.objectContaining({ color: euiThemeVars.euiColorWarning }), + context + ); + }); + + it('renders low severity risk score', () => { + const { container } = render( + + + + ); + + expect(container).toHaveTextContent(HostRiskSeverity.low); + + expect(EuiHealth as jest.Mock).toHaveBeenLastCalledWith( + expect.objectContaining({ color: euiThemeVars.euiColorVis0 }), + context + ); + }); + + it('renders unknown severity risk score', () => { + const { container } = render( + + + + ); + + expect(container).toHaveTextContent(HostRiskSeverity.unknown); + + expect(EuiHealth as jest.Mock).toHaveBeenLastCalledWith( + expect.objectContaining({ color: euiThemeVars.euiColorMediumShade }), + context + ); + }); +}); diff --git a/x-pack/plugins/security_solution/public/hosts/components/common/host_risk_score.tsx b/x-pack/plugins/security_solution/public/hosts/components/common/host_risk_score.tsx new file mode 100644 index 00000000000000..94f344b54036f0 --- /dev/null +++ b/x-pack/plugins/security_solution/public/hosts/components/common/host_risk_score.tsx @@ -0,0 +1,44 @@ +/* + * 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 { EuiHealth, transparentize } from '@elastic/eui'; + +import styled, { css } from 'styled-components'; +import { euiLightVars } from '@kbn/ui-shared-deps-src/theme'; +import { HostRiskSeverity } from '../../../../common/search_strategy'; + +const HOST_RISK_SEVERITY_COLOUR = { + Unknown: euiLightVars.euiColorMediumShade, + Low: euiLightVars.euiColorVis0, + Moderate: euiLightVars.euiColorWarning, + High: euiLightVars.euiColorVis9_behindText, + Critical: euiLightVars.euiColorDanger, +}; + +const HostRiskBadge = styled.div<{ $severity: HostRiskSeverity }>` + ${({ theme, $severity }) => css` + width: fit-content; + padding-right: ${theme.eui.paddingSizes.s}; + padding-left: ${theme.eui.paddingSizes.xs}; + + ${($severity === 'Critical' || $severity === 'High') && + css` + background-color: ${transparentize(theme.eui.euiColorDanger, 0.2)}; + border-radius: 999px; // pill shaped + `}; + `} +`; + +export const HostRiskScore: React.FC<{ severity: HostRiskSeverity }> = ({ severity }) => ( + + + {severity} + + +); diff --git a/x-pack/plugins/security_solution/public/hosts/components/hosts_table/columns.tsx b/x-pack/plugins/security_solution/public/hosts/components/hosts_table/columns.tsx index 95f88da0a24ac1..2aff7124990a65 100644 --- a/x-pack/plugins/security_solution/public/hosts/components/hosts_table/columns.tsx +++ b/x-pack/plugins/security_solution/public/hosts/components/hosts_table/columns.tsx @@ -24,102 +24,128 @@ import { import { HostsTableColumns } from './'; import * as i18n from './translations'; -import { Maybe } from '../../../../common/search_strategy'; +import { HostRiskSeverity, Maybe } from '../../../../common/search_strategy'; +import { HostRiskScore } from '../common/host_risk_score'; -export const getHostsColumns = (): HostsTableColumns => [ - { - field: 'node.host.name', - name: i18n.NAME, - truncateText: false, - mobileOptions: { show: true }, - sortable: true, - render: (hostName) => { - if (hostName != null && hostName.length > 0) { - const id = escapeDataProviderId(`hosts-table-hostName-${hostName[0]}`); - return ( - - snapshot.isDragging ? ( - - - - ) : ( - - ) - } - /> - ); - } - return getEmptyTagValue(); +export const getHostsColumns = (showRiskColumn: boolean): HostsTableColumns => { + const columns: HostsTableColumns = [ + { + field: 'node.host.name', + name: i18n.NAME, + truncateText: false, + mobileOptions: { show: true }, + sortable: true, + render: (hostName) => { + if (hostName != null && hostName.length > 0) { + const id = escapeDataProviderId(`hosts-table-hostName-${hostName[0]}`); + return ( + + snapshot.isDragging ? ( + + + + ) : ( + + ) + } + /> + ); + } + return getEmptyTagValue(); + }, + width: '35%', }, - width: '35%', - }, - { - field: 'node.lastSeen', - name: ( - - <> - {i18n.LAST_SEEN}{' '} - - - - ), - truncateText: false, - mobileOptions: { show: true }, - sortable: true, - render: (lastSeen: Maybe | undefined) => { - if (lastSeen != null && lastSeen.length > 0) { - return ( - - ); - } - return getEmptyTagValue(); + { + field: 'node.lastSeen', + name: ( + + <> + {i18n.LAST_SEEN} + + + ), + truncateText: false, + mobileOptions: { show: true }, + sortable: true, + render: (lastSeen: Maybe | undefined) => { + if (lastSeen != null && lastSeen.length > 0) { + return ( + + ); + } + return getEmptyTagValue(); + }, }, - }, - { - field: 'node.host.os.name', - name: i18n.OS, - truncateText: false, - mobileOptions: { show: true }, - sortable: false, - render: (hostOsName) => { - if (hostOsName != null) { - return ( - - <>{hostOsName} - - ); - } - return getEmptyTagValue(); + { + field: 'node.host.os.name', + name: i18n.OS, + truncateText: false, + mobileOptions: { show: true }, + sortable: false, + render: (hostOsName) => { + if (hostOsName != null) { + return ( + + <>{hostOsName} + + ); + } + return getEmptyTagValue(); + }, }, - }, - { - field: 'node.host.os.version', - name: i18n.VERSION, - truncateText: false, - mobileOptions: { show: true }, - sortable: false, - render: (hostOsVersion) => { - if (hostOsVersion != null) { - return ( - - <>{hostOsVersion} - - ); - } - return getEmptyTagValue(); + { + field: 'node.host.os.version', + name: i18n.VERSION, + truncateText: false, + mobileOptions: { show: true }, + sortable: false, + render: (hostOsVersion) => { + if (hostOsVersion != null) { + return ( + + <>{hostOsVersion} + + ); + } + return getEmptyTagValue(); + }, }, - }, -]; + ]; + + if (showRiskColumn) { + columns.push({ + field: 'node.risk', + name: ( + + <> + {i18n.HOST_RISK} + + + ), + truncateText: false, + mobileOptions: { show: true }, + sortable: false, + render: (riskScore: HostRiskSeverity) => { + if (riskScore != null) { + return ; + } + return getEmptyTagValue(); + }, + }); + } + + return columns; +}; diff --git a/x-pack/plugins/security_solution/public/hosts/components/hosts_table/index.test.tsx b/x-pack/plugins/security_solution/public/hosts/components/hosts_table/index.test.tsx index 413b8cda9b6abd..e30e87ffcb8fb3 100644 --- a/x-pack/plugins/security_solution/public/hosts/components/hosts_table/index.test.tsx +++ b/x-pack/plugins/security_solution/public/hosts/components/hosts_table/index.test.tsx @@ -22,6 +22,8 @@ import { hostsModel } from '../../../hosts/store'; import { HostsTableType } from '../../../hosts/store/model'; import { HostsTable } from './index'; import { mockData } from './mock'; +import { render } from '@testing-library/react'; +import { useIsExperimentalFeatureEnabled } from '../../../common/hooks/use_experimental_features'; jest.mock('../../../common/lib/kibana'); @@ -36,6 +38,8 @@ jest.mock('../../../common/components/query_bar', () => ({ jest.mock('../../../common/components/link_to'); +jest.mock('../../../common/hooks/use_experimental_features'); + describe('Hosts Table', () => { const loadPage = jest.fn(); const state: State = mockGlobalState; @@ -69,6 +73,50 @@ describe('Hosts Table', () => { expect(wrapper.find('HostsTable')).toMatchSnapshot(); }); + test('it renders "Host Risk classfication" column when "riskyHostsEnabled" feature flag is enabled', () => { + (useIsExperimentalFeatureEnabled as jest.Mock).mockReturnValue(true); + + const { queryByTestId } = render( + + + + ); + + expect(queryByTestId('tableHeaderCell_node.risk_4')).toBeInTheDocument(); + }); + + test("it doesn't renders 'Host Risk classfication' column when 'riskyHostsEnabled' feature flag is disabled", () => { + (useIsExperimentalFeatureEnabled as jest.Mock).mockReturnValue(false); + + const { queryByTestId } = render( + + + + ); + + expect(queryByTestId('tableHeaderCell_node.riskScore_4')).not.toBeInTheDocument(); + }); + describe('Sorting on Table', () => { let wrapper: ReturnType; diff --git a/x-pack/plugins/security_solution/public/hosts/components/hosts_table/index.tsx b/x-pack/plugins/security_solution/public/hosts/components/hosts_table/index.tsx index d20333d2105596..dc9312b1ad4c41 100644 --- a/x-pack/plugins/security_solution/public/hosts/components/hosts_table/index.tsx +++ b/x-pack/plugins/security_solution/public/hosts/components/hosts_table/index.tsx @@ -25,9 +25,11 @@ import { HostItem, HostsSortField, HostsFields, + HostRiskSeverity, } from '../../../../common/search_strategy/security_solution/hosts'; import { Direction } from '../../../../common/search_strategy'; import { HostEcs, OsEcs } from '../../../../common/ecs/host'; +import { useIsExperimentalFeatureEnabled } from '../../../common/hooks/use_experimental_features'; const tableType = hostsModel.HostsTableType.hosts; @@ -47,7 +49,8 @@ export type HostsTableColumns = [ Columns, Columns, Columns, - Columns + Columns, + Columns? ]; const rowItems: ItemsPerRow[] = [ @@ -124,8 +127,12 @@ const HostsTableComponent: React.FC = ({ }, [direction, sortField, type, dispatch] ); + const riskyHostsFeatureEnabled = useIsExperimentalFeatureEnabled('riskyHostsEnabled'); - const hostsColumns = useMemo(() => getHostsColumns(), []); + const hostsColumns = useMemo( + () => getHostsColumns(riskyHostsFeatureEnabled), + [riskyHostsFeatureEnabled] + ); const sorting = useMemo(() => getSorting(sortField, direction), [sortField, direction]); diff --git a/x-pack/plugins/security_solution/public/hosts/components/hosts_table/translations.ts b/x-pack/plugins/security_solution/public/hosts/components/hosts_table/translations.ts index 773a052dc71d0a..88c01f695b940f 100644 --- a/x-pack/plugins/security_solution/public/hosts/components/hosts_table/translations.ts +++ b/x-pack/plugins/security_solution/public/hosts/components/hosts_table/translations.ts @@ -32,6 +32,14 @@ export const FIRST_LAST_SEEN_TOOLTIP = i18n.translate( } ); +export const HOST_RISK_TOOLTIP = i18n.translate( + 'xpack.securitySolution.hostsTable.hostRiskToolTip', + { + defaultMessage: + 'Host risk classifcation is determined by host risk score. Hosts classified as Critical or High are indicated as risky.', + } +); + export const OS = i18n.translate('xpack.securitySolution.hostsTable.osTitle', { defaultMessage: 'Operating system', }); @@ -40,6 +48,10 @@ export const VERSION = i18n.translate('xpack.securitySolution.hostsTable.version defaultMessage: 'Version', }); +export const HOST_RISK = i18n.translate('xpack.securitySolution.hostsTable.hostRiskTitle', { + defaultMessage: 'Host risk classification', +}); + export const ROWS_5 = i18n.translate('xpack.securitySolution.hostsTable.rows', { values: { numRows: 5 }, defaultMessage: '{numRows} {numRows, plural, =0 {rows} =1 {row} other {rows}}', diff --git a/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/risky_hosts/index.tsx b/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/risky_hosts/index.tsx index 1030ea4c5e65b5..d5f0b16fbb7b6f 100644 --- a/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/risky_hosts/index.tsx +++ b/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/risky_hosts/index.tsx @@ -8,19 +8,16 @@ import { EuiFlexGroup, EuiFlexItem, - EuiHealth, EuiHorizontalRule, EuiIcon, EuiPanel, EuiTitle, EuiText, - transparentize, } from '@elastic/eui'; import React from 'react'; -import styled, { css } from 'styled-components'; +import styled from 'styled-components'; import { euiLightVars } from '@kbn/ui-shared-deps-src/theme'; import { InspectButtonContainer, InspectButton } from '../../../../common/components/inspect'; - import { HostsKpiBaseComponentLoader } from '../common'; import * as i18n from './translations'; @@ -31,37 +28,10 @@ import { import { useInspectQuery } from '../../../../common/hooks/use_inspect_query'; import { useErrorToast } from '../../../../common/hooks/use_error_toast'; +import { HostRiskScore } from '../../common/host_risk_score'; const QUERY_ID = 'hostsKpiRiskyHostsQuery'; -const HOST_RISK_SEVERITY_COLOUR = { - Unknown: euiLightVars.euiColorMediumShade, - Low: euiLightVars.euiColorVis0, - Moderate: euiLightVars.euiColorWarning, - High: euiLightVars.euiColorVis9_behindText, - Critical: euiLightVars.euiColorDanger, -}; - -const HostRiskBadge = styled.div<{ $severity: HostRiskSeverity }>` - ${({ theme, $severity }) => css` - width: fit-content; - padding-right: ${theme.eui.paddingSizes.s}; - padding-left: ${theme.eui.paddingSizes.xs}; - - ${($severity === 'Critical' || $severity === 'High') && - css` - background-color: ${transparentize(theme.eui.euiColorDanger, 0.2)}; - border-radius: 999px; // pill shaped - `}; - `} -`; - -const HostRisk: React.FC<{ severity: HostRiskSeverity }> = ({ severity }) => ( - - {severity} - -); - const HostCount = styled(EuiText)` font-weight: bold; `; @@ -124,7 +94,7 @@ const RiskyHostsComponent: React.FC<{ - + @@ -136,7 +106,7 @@ const RiskyHostsComponent: React.FC<{ - + diff --git a/x-pack/plugins/security_solution/public/hosts/containers/kpi_hosts/risky_hosts/index.tsx b/x-pack/plugins/security_solution/public/hosts/containers/kpi_hosts/risky_hosts/index.tsx index cd9f01e2fd67c2..d3785c842af902 100644 --- a/x-pack/plugins/security_solution/public/hosts/containers/kpi_hosts/risky_hosts/index.tsx +++ b/x-pack/plugins/security_solution/public/hosts/containers/kpi_hosts/risky_hosts/index.tsx @@ -11,7 +11,11 @@ import { useEffect, useState } from 'react'; import { useObservable, withOptionalSignal } from '@kbn/securitysolution-hook-utils'; import { createFilter } from '../../../../common/containers/helpers'; -import { HostsKpiQueries, RequestBasicOptions } from '../../../../../common/search_strategy'; +import { + getHostRiskIndex, + HostsKpiQueries, + RequestBasicOptions, +} from '../../../../../common/search_strategy'; import { isCompleteResponse, @@ -21,7 +25,6 @@ import type { DataPublicPluginStart } from '../../../../../../../../src/plugins/ import type { HostsKpiRiskyHostsStrategyResponse } from '../../../../../common/search_strategy/security_solution/hosts/kpi/risky_hosts'; import { useKibana } from '../../../../common/lib/kibana'; import { isIndexNotFoundError } from '../../../../common/utils/exceptions'; -import { getHostRiskIndex } from '../../../../helpers'; export type RiskyHostsScoreRequestOptions = RequestBasicOptions; diff --git a/x-pack/plugins/security_solution/server/plugin.ts b/x-pack/plugins/security_solution/server/plugin.ts index a676ca8779f6a9..98d63e1917f733 100644 --- a/x-pack/plugins/security_solution/server/plugin.ts +++ b/x-pack/plugins/security_solution/server/plugin.ts @@ -324,8 +324,10 @@ export class Plugin implements ISecuritySolutionPlugin { const securitySolutionSearchStrategy = securitySolutionSearchStrategyProvider( depsStart.data, - endpointContext + endpointContext, + depsStart.spaces?.spacesService?.getSpaceId ); + plugins.data.search.registerSearchStrategy( 'securitySolutionSearchStrategy', securitySolutionSearchStrategy diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/all/__mocks__/index.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/all/__mocks__/index.ts index ce640f7d367d66..e039eff1602417 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/all/__mocks__/index.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/all/__mocks__/index.ts @@ -5,7 +5,13 @@ * 2.0. */ +import { + KibanaRequest, + SavedObjectsClientContract, +} from '../../../../../../../../../../src/core/server'; +import { elasticsearchServiceMock } from '../../../../../../../../../../src/core/server/mocks'; import type { IEsSearchResponse } from '../../../../../../../../../../src/plugins/data/common'; +import { allowedExperimentalValues } from '../../../../../../../common/experimental_features'; import { Direction, @@ -14,6 +20,8 @@ import { HostsQueries, HostsRequestOptions, } from '../../../../../../../common/search_strategy'; +import { EndpointAppContextService } from '../../../../../../endpoint/endpoint_app_context_services'; +import { EndpointAppContext } from '../../../../../../endpoint/types'; export const mockOptions: HostsRequestOptions = { defaultIndex: [ @@ -833,3 +841,19 @@ export const expectedDsl = { 'winlogbeat-*', ], }; + +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, +}; diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/all/index.test.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/all/index.test.ts index 7fc43be9b800e9..2739b912c42db9 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/all/index.test.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/all/index.test.ts @@ -14,7 +14,30 @@ import { mockOptions, mockSearchStrategyResponse, formattedSearchStrategyResponse, + mockDeps as defaultMockDeps, } from './__mocks__'; +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' } } }; + } +} + +const mockDeps = (riskyHostsEnabled = true) => ({ + ...defaultMockDeps, + spaceId: 'test-space', + endpointContext: { + ...defaultMockDeps.endpointContext, + experimentalFeatures: { + ...defaultMockDeps.endpointContext.experimentalFeatures, + riskyHostsEnabled, + }, + }, +}); describe('allHosts search strategy', () => { const buildAllHostsQuery = jest.spyOn(buildQuery, 'buildHostsQuery'); @@ -46,8 +69,79 @@ describe('allHosts search strategy', () => { describe('parse', () => { test('should parse data correctly', async () => { - const result = await allHosts.parse(mockOptions, mockSearchStrategyResponse); + const result = await allHosts.parse(mockOptions, mockSearchStrategyResponse, mockDeps(false)); expect(result).toMatchObject(formattedSearchStrategyResponse); }); + + 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 as jest.Mock).mockResolvedValue({ + body: { + hits: { + hits: [ + { + _source: { + risk, + host: { + name: hostName, + }, + }, + }, + ], + }, + }, + }); + + const result = await allHosts.parse(mockOptions, mockSearchStrategyResponse, mockedDeps); + + expect(result.edges[0].node.risk).toBe(risk); + }); + + test('should not enhance data when feature flag is disabled', async () => { + const risk = 'TEST_RISK_SCORE'; + const hostName: string = get( + 'aggregations.host_data.buckets[0].key', + mockSearchStrategyResponse.rawResponse + ); + const mockedDeps = mockDeps(false); + + (mockedDeps.esClient.asCurrentUser.search as jest.Mock).mockResolvedValue({ + body: { + hits: { + hits: [ + { + _source: { + risk, + host: { + name: hostName, + }, + }, + }, + ], + }, + }, + }); + + const result = await allHosts.parse(mockOptions, mockSearchStrategyResponse, mockedDeps); + + expect(result.edges[0].node.risk).toBeUndefined(); + }); + + test("should not enhance data when index doesn't exist", async () => { + const mockedDeps = mockDeps(); + (mockedDeps.esClient.asCurrentUser.search as jest.Mock).mockImplementation(() => { + throw new IndexNotFoundException(); + }); + + const result = await allHosts.parse(mockOptions, mockSearchStrategyResponse, mockedDeps); + + expect(result.edges[0].node.risk).toBeUndefined(); + }); }); }); diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/all/index.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/all/index.ts index 987420f4bf4bd2..9e6abfe49d9499 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/all/index.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/all/index.ts @@ -14,13 +14,22 @@ import { HostsStrategyResponse, HostsQueries, HostsRequestOptions, + HostsRiskScore, + HostsEdges, } from '../../../../../../common/search_strategy/security_solution/hosts'; +import { getHostRiskIndex } from '../../../../../../common/search_strategy'; + import { inspectStringifyObject } from '../../../../../utils/build_query'; import { SecuritySolutionFactory } from '../../types'; import { buildHostsQuery } from './query.all_hosts.dsl'; import { formatHostEdgesData, HOSTS_FIELDS } from './helpers'; +import { IScopedClusterClient } from '../../../../../../../../../src/core/server'; + +import { buildHostsRiskScoreQuery } from '../risk_score/query.hosts_risk.dsl'; + import { buildHostsQueryEntities } from './query.all_hosts_entities.dsl'; +import { EndpointAppContext } from '../../../../../endpoint/types'; export const allHosts: SecuritySolutionFactory = { buildDsl: (options: HostsRequestOptions) => { @@ -31,7 +40,12 @@ export const allHosts: SecuritySolutionFactory = { }, parse: async ( options: HostsRequestOptions, - response: IEsSearchResponse + response: IEsSearchResponse, + deps?: { + esClient: IScopedClusterClient; + spaceId?: string; + endpointContext: EndpointAppContext; + } ): Promise => { const { activePage, cursorStart, fakePossibleCount, querySize } = options.pagination; const totalCount = getOr(0, 'aggregations.host_count.value', response.rawResponse); @@ -48,10 +62,17 @@ export const allHosts: SecuritySolutionFactory = { }; const showMorePagesIndicator = totalCount > fakeTotalCount; + const hostNames = buckets.map(getOr('', 'key')); + + const enhancedEdges = + deps?.spaceId && deps?.endpointContext.experimentalFeatures.riskyHostsEnabled + ? await enhanceEdges(edges, hostNames, deps.spaceId, deps.esClient) + : edges; + return { ...response, inspect, - edges, + edges: enhancedEdges, totalCount, pageInfo: { activePage: activePage ?? 0, @@ -62,6 +83,54 @@ export const allHosts: SecuritySolutionFactory = { }, }; +async function enhanceEdges( + edges: HostsEdges[], + hostNames: string[], + spaceId: string, + esClient: IScopedClusterClient +): Promise { + const hostRiskData = await getHostRiskData(esClient, spaceId, hostNames); + + const hostsRiskByHostName: Record | undefined = hostRiskData?.hits.hits.reduce( + (acc, hit) => ({ + ...acc, + [hit._source?.host.name ?? '']: hit._source?.risk, + }), + {} + ); + + return hostsRiskByHostName + ? edges.map(({ node, cursor }) => ({ + node: { + ...node, + risk: hostsRiskByHostName[node._id ?? ''], + }, + cursor, + })) + : edges; +} + +async function getHostRiskData( + esClient: IScopedClusterClient, + spaceId: string, + hostNames: string[] +) { + try { + const hostRiskResponse = await esClient.asCurrentUser.search( + buildHostsRiskScoreQuery({ + defaultIndex: [getHostRiskIndex(spaceId)], + hostNames, + }) + ); + return hostRiskResponse.body; + } catch (error) { + if (error?.meta?.body?.error?.type !== 'index_not_found_exception') { + throw error; + } + return undefined; + } +} + export const allHostsEntities: SecuritySolutionFactory = { buildDsl: (options: HostsRequestOptions) => { if (options.pagination && options.pagination.querySize >= DEFAULT_MAX_TABLE_QUERY_SIZE) { diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/risk_score/query.hosts_risk.dsl.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/risk_score/query.hosts_risk.dsl.ts index 05bb496a7444e1..182ad7892204f8 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/risk_score/query.hosts_risk.dsl.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/risk_score/query.hosts_risk.dsl.ts @@ -9,7 +9,7 @@ import { HostsRiskScoreRequestOptions } from '../../../../../../common/search_st export const buildHostsRiskScoreQuery = ({ timerange, - hostName, + hostNames, defaultIndex, }: HostsRiskScoreRequestOptions) => { const filter = []; @@ -26,8 +26,8 @@ export const buildHostsRiskScoreQuery = ({ }); } - if (hostName) { - filter.push({ term: { 'host.name': hostName } }); + if (hostNames) { + filter.push({ terms: { 'host.name': hostNames } }); } const dslQuery = { diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/types.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/types.ts index 4fe65b7e219f34..83a6096e51abf7 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/types.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/types.ts @@ -31,6 +31,7 @@ export interface SecuritySolutionFactory { savedObjectsClient: SavedObjectsClientContract; endpointContext: EndpointAppContext; request: KibanaRequest; + spaceId?: string; } ) => Promise>; } diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/index.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/index.ts index c10c63db62ee38..7786962d860835 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/security_solution/index.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/index.ts @@ -20,6 +20,7 @@ import { import { securitySolutionFactory } from './factory'; import { SecuritySolutionFactory } from './factory/types'; import { EndpointAppContext } from '../../endpoint/types'; +import { KibanaRequest } from '../../../../../../src/core/server'; function isObj(req: unknown): req is Record { return typeof req === 'object' && req !== null; @@ -34,7 +35,8 @@ function assertValidRequestType( export const securitySolutionSearchStrategyProvider = ( data: PluginStart, - endpointContext: EndpointAppContext + endpointContext: EndpointAppContext, + getSpaceId?: (request: KibanaRequest) => string ): ISearchStrategy, StrategyResponseType> => { const es = data.search.getSearchStrategy(ENHANCED_ES_SEARCH_STRATEGY); @@ -60,6 +62,7 @@ export const securitySolutionSearchStrategyProvider =