diff --git a/x-pack/plugins/security_solution/common/search_strategy/security_solution/hosts/kpi/index.ts b/x-pack/plugins/security_solution/common/search_strategy/security_solution/hosts/kpi/index.ts index d48172bebee4c..2acbce2c88653 100644 --- a/x-pack/plugins/security_solution/common/search_strategy/security_solution/hosts/kpi/index.ts +++ b/x-pack/plugins/security_solution/common/search_strategy/security_solution/hosts/kpi/index.ts @@ -9,6 +9,7 @@ export * from './authentications'; export * from './common'; export * from './hosts'; export * from './unique_ips'; +export * from './risky_hosts'; import { HostsKpiAuthenticationsStrategyResponse } from './authentications'; import { HostsKpiHostsStrategyResponse } from './hosts'; @@ -20,6 +21,7 @@ export enum HostsKpiQueries { kpiHosts = 'hostsKpiHosts', kpiHostsEntities = 'hostsKpiHostsEntities', kpiUniqueIps = 'hostsKpiUniqueIps', + kpiRiskyHosts = 'hostsKpiRiskyHosts', kpiUniqueIpsEntities = 'hostsKpiUniqueIpsEntities', } diff --git a/x-pack/plugins/security_solution/common/search_strategy/security_solution/hosts/kpi/risky_hosts/index.ts b/x-pack/plugins/security_solution/common/search_strategy/security_solution/hosts/kpi/risky_hosts/index.ts new file mode 100644 index 0000000000000..5216052b1a6b1 --- /dev/null +++ b/x-pack/plugins/security_solution/common/search_strategy/security_solution/hosts/kpi/risky_hosts/index.ts @@ -0,0 +1,27 @@ +/* + * 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 '../../../../../../../../../src/plugins/data/common'; +import type { Inspect, Maybe } from '../../../../common'; +import type { RequestBasicOptions } from '../../..'; + +export type HostsKpiRiskyHostsRequestOptions = RequestBasicOptions; + +export interface HostsKpiRiskyHostsStrategyResponse extends IEsSearchResponse { + inspect?: Maybe; + riskyHosts: { + [key in HostRiskSeverity]: number; + }; +} + +export enum HostRiskSeverity { + unknown = 'Unknown', + low = 'Low', + moderate = 'Moderate', + high = 'High', + critical = 'Critical', +} 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 f931694a4e229..23cda0b68f038 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 @@ -27,6 +27,14 @@ export interface HostsRiskScore { host: { name: string; }; - risk_score: number; risk: string; + risk_stats: { + rule_risks: RuleRisk[]; + risk_score: number; + }; +} + +export interface RuleRisk { + rule_name: string; + rule_risk: string; } diff --git a/x-pack/plugins/security_solution/common/search_strategy/security_solution/index.ts b/x-pack/plugins/security_solution/common/search_strategy/security_solution/index.ts index 00cbdb941c11b..3362a004423d3 100644 --- a/x-pack/plugins/security_solution/common/search_strategy/security_solution/index.ts +++ b/x-pack/plugins/security_solution/common/search_strategy/security_solution/index.ts @@ -84,6 +84,10 @@ import { UserRulesRequestOptions, UserRulesStrategyResponse, } from './ueba'; +import { + HostsKpiRiskyHostsRequestOptions, + HostsKpiRiskyHostsStrategyResponse, +} from './hosts/kpi/risky_hosts'; export * from './hosts'; export * from './matrix_histogram'; @@ -146,6 +150,8 @@ export type StrategyResponseType = T extends HostsQ ? HostsKpiAuthenticationsStrategyResponse : T extends HostsKpiQueries.kpiHosts ? HostsKpiHostsStrategyResponse + : T extends HostsKpiQueries.kpiRiskyHosts + ? HostsKpiRiskyHostsStrategyResponse : T extends HostsKpiQueries.kpiUniqueIps ? HostsKpiUniqueIpsStrategyResponse : T extends NetworkQueries.details @@ -200,6 +206,8 @@ export type StrategyRequestType = T extends HostsQu ? HostsKpiHostsRequestOptions : T extends HostsKpiQueries.kpiUniqueIps ? HostsKpiUniqueIpsRequestOptions + : T extends HostsKpiQueries.kpiRiskyHosts + ? HostsKpiRiskyHostsRequestOptions : T extends NetworkQueries.details ? NetworkDetailsRequestOptions : T extends NetworkQueries.dns 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 new file mode 100644 index 0000000000000..4f282e1e69d5c --- /dev/null +++ b/x-pack/plugins/security_solution/cypress/integration/hosts/risky_hosts_kpi.spec.ts @@ -0,0 +1,25 @@ +/* + * 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'; + +describe('RiskyHosts KPI', () => { + before(() => { + cleanKibana(); + }); + + it('it renders', () => { + loginAndWaitForPage(HOSTS_URL); + + cy.get('[data-test-subj="riskyHostsTotal"]').should('have.text', '0 Risky Hosts'); + cy.get('[data-test-subj="riskyHostsCriticalQuantity"]').should('have.text', '0 hosts'); + cy.get('[data-test-subj="riskyHostsHighQuantity"]').should('have.text', '0 hosts'); + }); +}); diff --git a/x-pack/plugins/security_solution/cypress/screens/inspect.ts b/x-pack/plugins/security_solution/cypress/screens/inspect.ts index ee9a3ad8dbbc6..f2b332b1772b2 100644 --- a/x-pack/plugins/security_solution/cypress/screens/inspect.ts +++ b/x-pack/plugins/security_solution/cypress/screens/inspect.ts @@ -20,10 +20,6 @@ export const INSPECT_HOSTS_BUTTONS_IN_SECURITY: InspectButtonMetadata[] = [ id: '[data-test-subj="stat-hosts"]', title: 'Hosts Stat', }, - { - id: '[data-test-subj="stat-authentication"]', - title: 'User Authentications Stat', - }, { id: '[data-test-subj="stat-uniqueIps"]', title: 'Unique IPs Stat', diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/cti_details/host_risk_summary.test.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/cti_details/host_risk_summary.test.tsx index 21b86fc1740b7..9d60fbc496d8d 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/cti_details/host_risk_summary.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/cti_details/host_risk_summary.test.tsx @@ -23,8 +23,11 @@ describe('HostRiskSummary', () => { host: { name: 'test-host-name', }, - risk_score: 9999, risk: riskKeyword, + risk_stats: { + risk_score: 9999, + rule_risks: [], + }, }, ], }; @@ -63,8 +66,11 @@ describe('HostRiskSummary', () => { host: { name: 'test-host-name', }, - risk_score: 9999, risk: 'test-risk', + risk_stats: { + risk_score: 9999, + rule_risks: [], + }, }, ], }; diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/cti_details/host_risk_summary.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/cti_details/host_risk_summary.tsx index dd7d10014022f..8b15ed4b250b8 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/cti_details/host_risk_summary.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/cti_details/host_risk_summary.tsx @@ -10,7 +10,7 @@ import { EuiLoadingSpinner, EuiPanel, EuiSpacer, EuiLink, EuiText } from '@elast import { FormattedMessage } from '@kbn/i18n-react'; import * as i18n from './translations'; import { RISKY_HOSTS_DOC_LINK } from '../../../../overview/components/overview_risky_host_links/risky_hosts_disabled_module'; -import { HostRisk } from '../../../../overview/containers/overview_risky_host_links/use_hosts_risk_score'; +import type { HostRisk } from '../../../containers/hosts_risk/use_hosts_risk_score'; import { EnrichedDataRow, ThreatSummaryPanelHeader } from './threat_summary_view'; const HostRiskSummaryComponent: React.FC<{ diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/cti_details/threat_summary_view.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/cti_details/threat_summary_view.tsx index c4d7902e151b4..5382fb5a9bcc1 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/cti_details/threat_summary_view.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/cti_details/threat_summary_view.tsx @@ -28,7 +28,7 @@ import { BrowserFields, TimelineEventsDetailsItem, } from '../../../../../common/search_strategy'; -import { HostRisk } from '../../../../overview/containers/overview_risky_host_links/use_hosts_risk_score'; +import { HostRisk } from '../../../containers/hosts_risk/use_hosts_risk_score'; import { HostRiskSummary } from './host_risk_summary'; import { EnrichmentSummary } from './enrichment_summary'; diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/event_details.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/event_details.tsx index a8305a635f157..0fe48d5a998ea 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/event_details.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/event_details.tsx @@ -39,7 +39,7 @@ import { EnrichmentRangePicker } from './cti_details/enrichment_range_picker'; import { Reason } from './reason'; import { InvestigationGuideView } from './investigation_guide_view'; -import { HostRisk } from '../../../overview/containers/overview_risky_host_links/use_hosts_risk_score'; +import { HostRisk } from '../../containers/hosts_risk/use_hosts_risk_score'; type EventViewTab = EuiTabbedContentTab; diff --git a/x-pack/plugins/security_solution/public/overview/containers/overview_risky_host_links/use_hosts_risk_score.ts b/x-pack/plugins/security_solution/public/common/containers/hosts_risk/use_hosts_risk_score.ts similarity index 88% rename from x-pack/plugins/security_solution/public/overview/containers/overview_risky_host_links/use_hosts_risk_score.ts rename to x-pack/plugins/security_solution/public/common/containers/hosts_risk/use_hosts_risk_score.ts index eb363f4f77067..41fcd29191da2 100644 --- a/x-pack/plugins/security_solution/public/overview/containers/overview_risky_host_links/use_hosts_risk_score.ts +++ b/x-pack/plugins/security_solution/public/common/containers/hosts_risk/use_hosts_risk_score.ts @@ -9,13 +9,13 @@ import { i18n } from '@kbn/i18n'; import { useCallback, useEffect, useState } from 'react'; import { useDispatch } from 'react-redux'; -import { useAppToasts } from '../../../common/hooks/use_app_toasts'; -import { useKibana } from '../../../common/lib/kibana'; -import { inputsActions } from '../../../common/store/actions'; -import { isIndexNotFoundError } from '../../../common/utils/exceptions'; +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 { useHostsRiskScoreComplete } from './use_hosts_risk_score_complete'; -import { useIsExperimentalFeatureEnabled } from '../../../common/hooks/use_experimental_features'; +import { useIsExperimentalFeatureEnabled } from '../../hooks/use_experimental_features'; import { getHostRiskIndex } from '../../../helpers'; export const QUERY_ID = 'host_risk_score'; @@ -24,11 +24,11 @@ const noop = () => {}; const isRecord = (item: unknown): item is Record => typeof item === 'object' && !!item; -const isHostsRiskScoreHit = (item: unknown): item is HostsRiskScore => +const isHostsRiskScoreHit = (item: Partial): item is HostsRiskScore => isRecord(item) && isRecord(item.host) && typeof item.host.name === 'string' && - typeof item.risk_score === 'number' && + typeof item.risk_stats?.risk_score === 'number' && typeof item.risk === 'string'; export interface HostRisk { diff --git a/x-pack/plugins/security_solution/public/overview/containers/overview_risky_host_links/use_hosts_risk_score_complete.ts b/x-pack/plugins/security_solution/public/common/containers/hosts_risk/use_hosts_risk_score_complete.ts similarity index 87% rename from x-pack/plugins/security_solution/public/overview/containers/overview_risky_host_links/use_hosts_risk_score_complete.ts rename to x-pack/plugins/security_solution/public/common/containers/hosts_risk/use_hosts_risk_score_complete.ts index 959fb94c5bbd7..934cb88ee0d86 100644 --- a/x-pack/plugins/security_solution/public/overview/containers/overview_risky_host_links/use_hosts_risk_score_complete.ts +++ b/x-pack/plugins/security_solution/public/common/containers/hosts_risk/use_hosts_risk_score_complete.ts @@ -4,14 +4,15 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import { Observable } from 'rxjs'; +import type { Observable } from 'rxjs'; import { filter } from 'rxjs/operators'; import { useObservable, withOptionalSignal } from '@kbn/securitysolution-hook-utils'; -import type { DataPublicPluginStart } from '../../../../../../../src/plugins/data/public'; - -import { isCompleteResponse, isErrorResponse } from '../../../../../../../src/plugins/data/common'; - +import { + DataPublicPluginStart, + isCompleteResponse, + isErrorResponse, +} from '../../../../../../../src/plugins/data/public'; import { HostsQueries, HostsRiskScoreRequestOptions, diff --git a/x-pack/plugins/security_solution/public/common/hooks/use_error_toast.test.ts b/x-pack/plugins/security_solution/public/common/hooks/use_error_toast.test.ts new file mode 100644 index 0000000000000..993326d906a18 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/hooks/use_error_toast.test.ts @@ -0,0 +1,39 @@ +/* + * 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 { renderHook } from '@testing-library/react-hooks'; +import { useErrorToast } from './use_error_toast'; + +jest.mock('./use_app_toasts'); + +import { useAppToasts } from './use_app_toasts'; + +describe('useErrorToast', () => { + let addErrorMock: jest.Mock; + + beforeEach(() => { + addErrorMock = jest.fn(); + (useAppToasts as jest.Mock).mockImplementation(() => ({ + addError: addErrorMock, + })); + }); + + it('calls useAppToasts error when an error param is provided', () => { + const title = 'testErrorTitle'; + const error = new Error(); + renderHook(() => useErrorToast(title, error)); + + expect(addErrorMock).toHaveBeenCalledWith(error, { title }); + }); + + it("doesn't call useAppToasts error when an error param is undefined", () => { + const title = 'testErrorTitle'; + const error = undefined; + renderHook(() => useErrorToast(title, error)); + + expect(addErrorMock).not.toHaveBeenCalled(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/hooks/use_error_toast.ts b/x-pack/plugins/security_solution/public/common/hooks/use_error_toast.ts new file mode 100644 index 0000000000000..f459827f6cc9a --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/hooks/use_error_toast.ts @@ -0,0 +1,22 @@ +/* + * 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 } from 'react'; +import { useAppToasts } from './use_app_toasts'; + +/** + * Display App error toast when error is defined. + */ +export const useErrorToast = (title: string, error: unknown) => { + const { addError } = useAppToasts(); + + useEffect(() => { + if (error) { + addError(error, { title }); + } + }, [error, title, addError]); +}; diff --git a/x-pack/plugins/security_solution/public/common/hooks/use_inspect_query.test.tsx b/x-pack/plugins/security_solution/public/common/hooks/use_inspect_query.test.tsx new file mode 100644 index 0000000000000..1bf2de3242ac7 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/hooks/use_inspect_query.test.tsx @@ -0,0 +1,85 @@ +/* + * 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 { renderHook } from '@testing-library/react-hooks'; +import { useInspectQuery } from './use_inspect_query'; + +import { useGlobalTime } from '../containers/use_global_time'; + +jest.mock('../containers/use_global_time'); + +const QUERY_ID = 'tes_query_id'; + +const RESPONSE = { + inspect: { dsl: [], response: [] }, + isPartial: false, + isRunning: false, + total: 0, + loaded: 0, + rawResponse: { + took: 0, + timed_out: false, + _shards: { + total: 0, + successful: 0, + failed: 0, + skipped: 0, + }, + results: { + hits: { + total: 0, + }, + }, + hits: { + total: 0, + max_score: 0, + hits: [], + }, + }, + totalCount: 0, + enrichments: [], +}; + +describe('useInspectQuery', () => { + let deleteQuery: jest.Mock; + let setQuery: jest.Mock; + + beforeEach(() => { + deleteQuery = jest.fn(); + setQuery = jest.fn(); + (useGlobalTime as jest.Mock).mockImplementation(() => ({ + deleteQuery, + setQuery, + isInitializing: false, + })); + }); + + it('it calls setQuery', () => { + renderHook(() => useInspectQuery(QUERY_ID, false, RESPONSE)); + + expect(setQuery).toHaveBeenCalledTimes(1); + expect(setQuery.mock.calls[0][0].id).toBe(QUERY_ID); + }); + + it("doesn't call setQuery when response is undefined", () => { + renderHook(() => useInspectQuery(QUERY_ID, false, undefined)); + + expect(setQuery).not.toHaveBeenCalled(); + }); + + it("doesn't call setQuery when loading", () => { + renderHook(() => useInspectQuery(QUERY_ID, true)); + + expect(setQuery).not.toHaveBeenCalled(); + }); + + it('calls deleteQuery when unmouting', () => { + const result = renderHook(() => useInspectQuery(QUERY_ID, false, RESPONSE)); + result.unmount(); + + expect(deleteQuery).toHaveBeenCalledWith({ id: QUERY_ID }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/hooks/use_inspect_query.ts b/x-pack/plugins/security_solution/public/common/hooks/use_inspect_query.ts new file mode 100644 index 0000000000000..4c0cb1c4fcdca --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/hooks/use_inspect_query.ts @@ -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 { noop } from 'lodash'; +import { useEffect } from 'react'; +import type { FactoryQueryTypes, StrategyResponseType } from '../../../common/search_strategy'; +import { getInspectResponse } from '../../helpers'; +import { useGlobalTime } from '../containers/use_global_time'; +import type { Refetch, RefetchKql } from '../store/inputs/model'; + +/** + * Add and remove query response from global input store. + */ +export const useInspectQuery = ( + id: string, + loading: boolean, + response?: StrategyResponseType, + refetch: Refetch | RefetchKql = noop +) => { + const { deleteQuery, setQuery, isInitializing } = useGlobalTime(); + + useEffect(() => { + if (!loading && !isInitializing && response?.inspect) { + setQuery({ + id, + inspect: getInspectResponse(response, { + dsl: [], + response: [], + }), + loading, + refetch, + }); + } + + return () => { + if (deleteQuery) { + deleteQuery({ id }); + } + }; + }, [deleteQuery, setQuery, loading, response, isInitializing, id, refetch]); +}; diff --git a/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/common/index.tsx b/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/common/index.tsx index 067c5e8cd698d..206b452d83898 100644 --- a/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/common/index.tsx +++ b/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/common/index.tsx @@ -50,13 +50,7 @@ export const HostsKpiBaseComponent = React.memo( ); if (loading) { - return ( - - - - - - ); + return ; } return ( @@ -80,3 +74,11 @@ export const HostsKpiBaseComponent = React.memo( HostsKpiBaseComponent.displayName = 'HostsKpiBaseComponent'; export const HostsKpiBaseComponentManage = manageQuery(HostsKpiBaseComponent); + +export const HostsKpiBaseComponentLoader: React.FC = () => ( + + + + + +); diff --git a/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/index.tsx b/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/index.tsx index 26d908cba4d0d..1f854b1328aad 100644 --- a/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/index.tsx +++ b/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/index.tsx @@ -6,51 +6,96 @@ */ import React from 'react'; -import { EuiFlexItem, EuiFlexGroup } from '@elastic/eui'; +import { EuiFlexItem, EuiFlexGroup, EuiSpacer, EuiLink } from '@elastic/eui'; import { HostsKpiAuthentications } from './authentications'; import { HostsKpiHosts } from './hosts'; import { HostsKpiUniqueIps } from './unique_ips'; import { HostsKpiProps } from './types'; +import { RiskyHosts } from './risky_hosts'; +import { useIsExperimentalFeatureEnabled } from '../../../common/hooks/use_experimental_features'; +import { useRiskyHosts } from '../../containers/kpi_hosts/risky_hosts'; +import { CallOutSwitcher } from '../../../common/components/callouts'; +import { RISKY_HOSTS_DOC_LINK } from '../../../overview/components/overview_risky_host_links/risky_hosts_disabled_module'; +import * as i18n from './translations'; export const HostsKpiComponent = React.memo( - ({ filterQuery, from, indexNames, to, setQuery, skip, narrowDateRange }) => ( - - - - - - - - - - - - ) + ({ filterQuery, from, indexNames, to, setQuery, skip, narrowDateRange }) => { + const riskyHostsExperimentEnabled = useIsExperimentalFeatureEnabled('riskyHostsEnabled'); + const { + error, + response, + loading, + isModuleDisabled: isRiskHostsModuleDisabled, + } = useRiskyHosts({ + filterQuery, + from, + to, + skip: skip || !riskyHostsExperimentEnabled, + }); + + return ( + <> + {isRiskHostsModuleDisabled && ( + <> + + {i18n.LEARN_MORE}{' '} + + {i18n.HOST_RISK_DATA} + + + + ), + }} + /> + + + )} + + + + + + {riskyHostsExperimentEnabled && ( + + + + )} + + + + + + ); + } ); HostsKpiComponent.displayName = 'HostsKpiComponent'; diff --git a/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/risky_hosts/index.test.tsx b/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/risky_hosts/index.test.tsx new file mode 100644 index 0000000000000..f0e3dcfb69c6e --- /dev/null +++ b/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/risky_hosts/index.test.tsx @@ -0,0 +1,77 @@ +/* + * 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 { RiskyHosts } from './'; +import { TestProviders } from '../../../../common/mock'; +import { HostsKpiRiskyHostsStrategyResponse } from '../../../../../common/search_strategy'; + +jest.mock('../../../containers/kpi_hosts/risky_hosts'); + +describe('RiskyHosts', () => { + const defaultProps = { + error: undefined, + loading: false, + }; + + test('it renders', () => { + const { queryByText } = render( + + + + ); + + expect(queryByText('Risky Hosts')).toBeInTheDocument(); + }); + + test('it displays loader while API is loading', () => { + const { getByTestId } = render( + + + + ); + + expect(getByTestId('hostsKpiLoader')).toBeInTheDocument(); + }); + + test('it displays 0 risky hosts when initializing', () => { + const { getByTestId } = render( + + + + ); + + expect(getByTestId('riskyHostsTotal').textContent).toEqual('0 Risky Hosts'); + expect(getByTestId('riskyHostsCriticalQuantity').textContent).toEqual('0 hosts'); + expect(getByTestId('riskyHostsHighQuantity').textContent).toEqual('0 hosts'); + }); + + test('it displays risky hosts quantity returned by the API', () => { + const data: HostsKpiRiskyHostsStrategyResponse = { + rawResponse: {} as HostsKpiRiskyHostsStrategyResponse['rawResponse'], + riskyHosts: { + Critical: 1, + High: 1, + Unknown: 0, + Low: 0, + Moderate: 0, + }, + }; + const { getByTestId } = render( + + + + ); + + expect(getByTestId('riskyHostsTotal').textContent).toEqual('2 Risky Hosts'); + expect(getByTestId('riskyHostsCriticalQuantity').textContent).toEqual('1 host'); + expect(getByTestId('riskyHostsHighQuantity').textContent).toEqual('1 host'); + }); +}); 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 new file mode 100644 index 0000000000000..1030ea4c5e65b --- /dev/null +++ b/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/risky_hosts/index.tsx @@ -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 { + EuiFlexGroup, + EuiFlexItem, + EuiHealth, + EuiHorizontalRule, + EuiIcon, + EuiPanel, + EuiTitle, + EuiText, + transparentize, +} from '@elastic/eui'; +import React from 'react'; +import styled, { css } 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'; + +import { + HostRiskSeverity, + HostsKpiRiskyHostsStrategyResponse, +} from '../../../../../common/search_strategy/security_solution/hosts/kpi/risky_hosts'; + +import { useInspectQuery } from '../../../../common/hooks/use_inspect_query'; +import { useErrorToast } from '../../../../common/hooks/use_error_toast'; + +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; +`; +HostCount.displayName = 'HostCount'; + +const StatusTitle = styled(EuiTitle)` + text-transform: lowercase; +`; + +const RiskScoreContainer = styled(EuiFlexItem)` + min-width: 80px; +`; + +const RiskyHostsComponent: React.FC<{ + error: unknown; + loading: boolean; + data?: HostsKpiRiskyHostsStrategyResponse; +}> = ({ error, loading, data }) => { + useInspectQuery(QUERY_ID, loading, data); + useErrorToast(i18n.ERROR_TITLE, error); + + if (loading) { + return ; + } + + const criticalRiskCount = data?.riskyHosts.Critical ?? 0; + const hightlRiskCount = data?.riskyHosts.High ?? 0; + + const totalCount = criticalRiskCount + hightlRiskCount; + + return ( + + + + + +
{i18n.RISKY_HOSTS_TITLE}
+
+
+ + {data?.inspect && } + +
+ + + + + + + + +

{i18n.RISKY_HOSTS_DESCRIPTION(totalCount, totalCount.toLocaleString())}

+
+
+
+
+
+ + + + + + + + + + {i18n.HOSTS_COUNT(criticalRiskCount)} + + + + + + + + + + + + {i18n.HOSTS_COUNT(hightlRiskCount)} + + + + + +
+
+ ); +}; + +export const RiskyHosts = React.memo(RiskyHostsComponent); diff --git a/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/risky_hosts/translations.ts b/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/risky_hosts/translations.ts new file mode 100644 index 0000000000000..f97dc80fd9679 --- /dev/null +++ b/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/risky_hosts/translations.ts @@ -0,0 +1,46 @@ +/* + * 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 HOSTS_COUNT = (quantity: number) => + i18n.translate('xpack.securitySolution.kpiHosts.riskyHosts.hostsCount', { + defaultMessage: '{quantity} {quantity, plural, =1 {host} other {hosts}}', + values: { + quantity, + }, + }); + +export const RISKY_HOSTS_DESCRIPTION = (quantity: number, formattedQuantity: string) => + i18n.translate('xpack.securitySolution.kpiHosts.riskyHosts.description', { + defaultMessage: '{formattedQuantity} Risky {quantity, plural, =1 {Host} other {Hosts}}', + values: { + formattedQuantity, + quantity, + }, + }); + +export const RISKY_HOSTS_TITLE = i18n.translate( + 'xpack.securitySolution.kpiHosts.riskyHosts.title', + { + defaultMessage: 'Risky Hosts', + } +); + +export const INSPECT_RISKY_HOSTS = i18n.translate( + 'xpack.securitySolution.kpiHosts.riskyHosts.inspectTitle', + { + defaultMessage: 'KPI Risky Hosts', + } +); + +export const ERROR_TITLE = i18n.translate( + 'xpack.securitySolution.kpiHosts.riskyHosts.errorMessage', + { + defaultMessage: 'Error Fetching Risky Hosts API', + } +); diff --git a/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/translations.ts b/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/translations.ts new file mode 100644 index 0000000000000..cc706ed6e68e8 --- /dev/null +++ b/x-pack/plugins/security_solution/public/hosts/components/kpi_hosts/translations.ts @@ -0,0 +1,23 @@ +/* + * 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 LEARN_MORE = i18n.translate('xpack.securitySolution.kpiHost.learnMore', { + defaultMessage: 'Learn more about', +}); + +export const HOST_RISK_DATA = i18n.translate('xpack.securitySolution.kpiHost.hostRiskData', { + defaultMessage: 'host risk data', +}); + +export const ENABLE_HOST_RISK_TEXT = i18n.translate( + 'xpack.securitySolution.kpiHost.enableHostRiskText', + { + defaultMessage: 'Enable host risk module to see more data', + } +); 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 new file mode 100644 index 0000000000000..cd9f01e2fd67c --- /dev/null +++ b/x-pack/plugins/security_solution/public/hosts/containers/kpi_hosts/risky_hosts/index.tsx @@ -0,0 +1,98 @@ +/* + * 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 { Observable } from 'rxjs'; +import { filter } from 'rxjs/operators'; +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 { + isCompleteResponse, + isErrorResponse, +} from '../../../../../../../../src/plugins/data/common'; +import type { DataPublicPluginStart } from '../../../../../../../../src/plugins/data/public'; +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; + +type GetHostsRiskScoreProps = RiskyHostsScoreRequestOptions & { + data: DataPublicPluginStart; + signal: AbortSignal; +}; + +export const getRiskyHosts = ({ + data, + defaultIndex, + timerange, + signal, + filterQuery, +}: GetHostsRiskScoreProps): Observable => + data.search.search( + { + defaultIndex, + factoryQueryType: HostsKpiQueries.kpiRiskyHosts, + filterQuery: createFilter(filterQuery), + timerange, + }, + { + strategy: 'securitySolutionSearchStrategy', + abortSignal: signal, + } + ); + +export const getRiskyHostsComplete = ( + props: GetHostsRiskScoreProps +): Observable => { + return getRiskyHosts(props).pipe( + filter((response) => { + return isErrorResponse(response) || isCompleteResponse(response); + }) + ); +}; + +const getRiskyHostsWithOptionalSignal = withOptionalSignal(getRiskyHostsComplete); + +const useRiskyHostsComplete = () => useObservable(getRiskyHostsWithOptionalSignal); + +interface UseRiskyHostProps { + filterQuery?: string; + from: string; + to: string; + skip: boolean; +} + +export const useRiskyHosts = ({ filterQuery, from, to, skip }: UseRiskyHostProps) => { + const { error, result: response, start, loading } = useRiskyHostsComplete(); + const { data, spaces } = useKibana().services; + const isModuleDisabled = error && isIndexNotFoundError(error); + const [spaceId, setSpaceId] = useState(); + + useEffect(() => { + if (spaces) { + spaces.getActiveSpace().then((space) => setSpaceId(space.id)); + } + }, [spaces]); + + useEffect(() => { + if (!skip && spaceId) { + start({ + data, + timerange: { to, from, interval: '' }, + filterQuery, + defaultIndex: [getHostRiskIndex(spaceId)], + }); + } + }, [data, spaceId, start, filterQuery, to, from, skip]); + + return { error, response, loading, isModuleDisabled }; +}; diff --git a/x-pack/plugins/security_solution/public/overview/components/overview_risky_host_links/index.test.tsx b/x-pack/plugins/security_solution/public/overview/components/overview_risky_host_links/index.test.tsx index 2476b4d07c3c7..95ebe9be77019 100644 --- a/x-pack/plugins/security_solution/public/overview/components/overview_risky_host_links/index.test.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/overview_risky_host_links/index.test.tsx @@ -22,11 +22,11 @@ import { } from '../../../common/mock'; import { useRiskyHostsDashboardButtonHref } from '../../containers/overview_risky_host_links/use_risky_hosts_dashboard_button_href'; import { useRiskyHostsDashboardLinks } from '../../containers/overview_risky_host_links/use_risky_hosts_dashboard_links'; -import { useHostsRiskScore } from '../../containers/overview_risky_host_links/use_hosts_risk_score'; +import { useHostsRiskScore } from '../../../common/containers/hosts_risk/use_hosts_risk_score'; jest.mock('../../../common/lib/kibana'); -jest.mock('../../containers/overview_risky_host_links/use_hosts_risk_score'); +jest.mock('../../../common/containers/hosts_risk/use_hosts_risk_score'); const useHostsRiskScoreMock = useHostsRiskScore as jest.Mock; jest.mock('../../containers/overview_risky_host_links/use_risky_hosts_dashboard_button_href'); diff --git a/x-pack/plugins/security_solution/public/overview/components/overview_risky_host_links/index.tsx b/x-pack/plugins/security_solution/public/overview/components/overview_risky_host_links/index.tsx index 57bcff45a6348..64829aab7776d 100644 --- a/x-pack/plugins/security_solution/public/overview/components/overview_risky_host_links/index.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/overview_risky_host_links/index.tsx @@ -9,7 +9,7 @@ import React from 'react'; import { RiskyHostsEnabledModule } from './risky_hosts_enabled_module'; import { RiskyHostsDisabledModule } from './risky_hosts_disabled_module'; -import { useHostsRiskScore } from '../../containers/overview_risky_host_links/use_hosts_risk_score'; +import { useHostsRiskScore } from '../../../common/containers/hosts_risk/use_hosts_risk_score'; export interface RiskyHostLinksProps { timerange: { to: string; from: string }; } diff --git a/x-pack/plugins/security_solution/public/overview/components/overview_risky_host_links/risky_hosts_enabled_module.test.tsx b/x-pack/plugins/security_solution/public/overview/components/overview_risky_host_links/risky_hosts_enabled_module.test.tsx index 912945549be8c..364b608c6086d 100644 --- a/x-pack/plugins/security_solution/public/overview/components/overview_risky_host_links/risky_hosts_enabled_module.test.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/overview_risky_host_links/risky_hosts_enabled_module.test.tsx @@ -60,7 +60,10 @@ describe('RiskyHostsEnabledModule', () => { host: { name: 'a', }, - risk_score: 1, + risk_stats: { + risk_score: 1, + rule_risks: [], + }, risk: '', }, ], diff --git a/x-pack/plugins/security_solution/public/overview/components/overview_risky_host_links/risky_hosts_enabled_module.tsx b/x-pack/plugins/security_solution/public/overview/components/overview_risky_host_links/risky_hosts_enabled_module.tsx index 412c4a69ec2f5..875b7c206d793 100644 --- a/x-pack/plugins/security_solution/public/overview/components/overview_risky_host_links/risky_hosts_enabled_module.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/overview_risky_host_links/risky_hosts_enabled_module.tsx @@ -10,13 +10,13 @@ import { RiskyHostsPanelView } from './risky_hosts_panel_view'; import { LinkPanelListItem } from '../link_panel'; import { useRiskyHostsDashboardButtonHref } from '../../containers/overview_risky_host_links/use_risky_hosts_dashboard_button_href'; import { useRiskyHostsDashboardLinks } from '../../containers/overview_risky_host_links/use_risky_hosts_dashboard_links'; -import { HostRisk } from '../../containers/overview_risky_host_links/use_hosts_risk_score'; +import { HostRisk } from '../../../common/containers/hosts_risk/use_hosts_risk_score'; import { HostsRiskScore } from '../../../../common/search_strategy'; const getListItemsFromHits = (items: HostsRiskScore[]): LinkPanelListItem[] => { - return items.map(({ host, risk_score: count, risk: copy }) => ({ + return items.map(({ host, risk_stats: riskStats, risk: copy }) => ({ title: host.name, - count, + count: riskStats.risk_score, copy, path: '', })); diff --git a/x-pack/plugins/security_solution/public/overview/components/overview_risky_host_links/risky_hosts_panel_view.tsx b/x-pack/plugins/security_solution/public/overview/components/overview_risky_host_links/risky_hosts_panel_view.tsx index 87a5710ab0372..8a42cedc3be46 100644 --- a/x-pack/plugins/security_solution/public/overview/components/overview_risky_host_links/risky_hosts_panel_view.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/overview_risky_host_links/risky_hosts_panel_view.tsx @@ -14,7 +14,7 @@ import { LinkPanelViewProps } from '../link_panel/types'; import { Link } from '../link_panel/link'; import * as i18n from './translations'; import { VIEW_DASHBOARD } from '../overview_cti_links/translations'; -import { QUERY_ID as RiskyHostsQueryId } from '../../containers/overview_risky_host_links/use_hosts_risk_score'; +import { QUERY_ID as RiskyHostsQueryId } from '../../../common/containers/hosts_risk/use_hosts_risk_score'; import { NavigateToHost } from './navigate_to_host'; const columns: Array> = [ diff --git a/x-pack/plugins/security_solution/public/overview/pages/overview.test.tsx b/x-pack/plugins/security_solution/public/overview/pages/overview.test.tsx index 33fd1918dad59..e8b6e9d7c9e56 100644 --- a/x-pack/plugins/security_solution/public/overview/pages/overview.test.tsx +++ b/x-pack/plugins/security_solution/public/overview/pages/overview.test.tsx @@ -28,9 +28,9 @@ import { } from '../components/overview_cti_links/mock'; import { useCtiDashboardLinks } from '../containers/overview_cti_links'; import { useIsExperimentalFeatureEnabled } from '../../common/hooks/use_experimental_features'; -import { useHostsRiskScore } from '../containers/overview_risky_host_links/use_hosts_risk_score'; import { initialUserPrivilegesState } from '../../common/components/user_privileges/user_privileges_context'; import { EndpointPrivileges } from '../../../common/endpoint/types'; +import { useHostsRiskScore } from '../../common/containers/hosts_risk/use_hosts_risk_score'; jest.mock('../../common/lib/kibana'); jest.mock('../../common/containers/source'); @@ -84,7 +84,7 @@ jest.mock('../containers/overview_cti_links/use_is_threat_intel_module_enabled') const useIsThreatIntelModuleEnabledMock = useIsThreatIntelModuleEnabled as jest.Mock; useIsThreatIntelModuleEnabledMock.mockReturnValue(true); -jest.mock('../containers/overview_risky_host_links/use_hosts_risk_score'); +jest.mock('../../common/containers/hosts_risk/use_hosts_risk_score'); const useHostsRiskScoreMock = useHostsRiskScore as jest.Mock; useHostsRiskScoreMock.mockReturnValue({ loading: false, diff --git a/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/expandable_event.tsx b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/expandable_event.tsx index 6a7f0602c3675..145edafe38318 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/expandable_event.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/expandable_event.tsx @@ -22,7 +22,7 @@ import { BrowserFields } from '../../../../common/containers/source'; import { EventDetails } from '../../../../common/components/event_details/event_details'; import { TimelineEventsDetailsItem } from '../../../../../common/search_strategy/timeline'; import * as i18n from './translations'; -import { HostRisk } from '../../../../overview/containers/overview_risky_host_links/use_hosts_risk_score'; +import { HostRisk } from '../../../../common/containers/hosts_risk/use_hosts_risk_score'; export type HandleOnEventClosed = () => void; interface Props { diff --git a/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/index.tsx index 224662f0fd6ab..1d68356fc0bb7 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/side_panel/event_details/index.tsx @@ -35,7 +35,7 @@ import { TimelineNonEcsData } from '../../../../../common/search_strategy'; import { Ecs } from '../../../../../common/ecs'; import { EventDetailsFooter } from './footer'; import { EntityType } from '../../../../../../timelines/common'; -import { useHostsRiskScore } from '../../../../overview/containers/overview_risky_host_links/use_hosts_risk_score'; +import { useHostsRiskScore } from '../../../../common/containers/hosts_risk/use_hosts_risk_score'; const StyledEuiFlyoutBody = styled(EuiFlyoutBody)` .euiFlyoutBody__overflow { diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/index.test.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/index.test.ts index 9aef01d953c82..36add5af0ed25 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/index.test.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/index.test.ts @@ -18,6 +18,7 @@ import { authentications, authenticationsEntities } from './authentications'; import { hostsKpiAuthentications, hostsKpiAuthenticationsEntities } from './kpi/authentications'; import { hostsKpiHosts, hostsKpiHostsEntities } from './kpi/hosts'; import { hostsKpiUniqueIps, hostsKpiUniqueIpsEntities } from './kpi/unique_ips'; +import { hostsKpiRiskyHosts } from './kpi/risky_hosts'; jest.mock('./all'); jest.mock('./details'); @@ -45,6 +46,7 @@ describe('hostsFactory', () => { [HostsKpiQueries.kpiAuthenticationsEntities]: hostsKpiAuthenticationsEntities, [HostsKpiQueries.kpiHosts]: hostsKpiHosts, [HostsKpiQueries.kpiHostsEntities]: hostsKpiHostsEntities, + [HostsKpiQueries.kpiRiskyHosts]: hostsKpiRiskyHosts, [HostsKpiQueries.kpiUniqueIpsEntities]: hostsKpiUniqueIpsEntities, [HostsKpiQueries.kpiUniqueIps]: hostsKpiUniqueIps, }; diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/index.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/index.ts index 5b501099a21ed..f182280667e13 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/index.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/index.ts @@ -22,6 +22,7 @@ import { hostsKpiAuthentications, hostsKpiAuthenticationsEntities } from './kpi/ import { hostsKpiHosts, hostsKpiHostsEntities } from './kpi/hosts'; import { hostsKpiUniqueIps, hostsKpiUniqueIpsEntities } from './kpi/unique_ips'; import { riskScore } from './risk_score'; +import { hostsKpiRiskyHosts } from './kpi/risky_hosts'; export const hostsFactory: Record< HostsQueries | HostsKpiQueries, @@ -40,6 +41,7 @@ export const hostsFactory: Record< [HostsKpiQueries.kpiAuthenticationsEntities]: hostsKpiAuthenticationsEntities, [HostsKpiQueries.kpiHosts]: hostsKpiHosts, [HostsKpiQueries.kpiHostsEntities]: hostsKpiHostsEntities, + [HostsKpiQueries.kpiRiskyHosts]: hostsKpiRiskyHosts, [HostsKpiQueries.kpiUniqueIps]: hostsKpiUniqueIps, [HostsKpiQueries.kpiUniqueIpsEntities]: hostsKpiUniqueIpsEntities, }; diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/kpi/risky_hosts/__mocks__/index.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/kpi/risky_hosts/__mocks__/index.ts new file mode 100644 index 0000000000000..c0522d61e3804 --- /dev/null +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/kpi/risky_hosts/__mocks__/index.ts @@ -0,0 +1,28 @@ +/* + * 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 { + HostsKpiQueries, + HostsKpiRiskyHostsRequestOptions, +} from '../../../../../../../../common/search_strategy'; + +export const mockOptions: HostsKpiRiskyHostsRequestOptions = { + defaultIndex: [ + 'apm-*-transaction*', + 'traces-apm*', + 'auditbeat-*', + 'endgame-*', + 'filebeat-*', + 'logs-*', + 'packetbeat-*', + 'winlogbeat-*', + ], + factoryQueryType: HostsKpiQueries.kpiRiskyHosts, + filterQuery: + '{"bool":{"must":[],"filter":[{"match_all":{}},{"bool":{"filter":[{"bool":{"should":[{"exists":{"field":"host.name"}}],"minimum_should_match":1}}]}}],"should":[],"must_not":[]}}', + timerange: { interval: '12h', from: '2020-09-07T09:47:28.606Z', to: '2020-09-08T09:47:28.606Z' }, +}; diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/kpi/risky_hosts/index.test.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/kpi/risky_hosts/index.test.ts new file mode 100644 index 0000000000000..cbfe63d86ea73 --- /dev/null +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/kpi/risky_hosts/index.test.ts @@ -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 { hostsKpiRiskyHosts } from '.'; +import * as buildQuery from './query.hosts_kpi_risky_hosts.dsl'; +import { mockOptions } from './__mocks__'; + +describe('buildHostsKpiRiskyHostsQuery search strategy', () => { + const buildHostsKpiRiskyHostsQuery = jest.spyOn(buildQuery, 'buildHostsKpiRiskyHostsQuery'); + + describe('buildDsl', () => { + test('should build dsl query', () => { + hostsKpiRiskyHosts.buildDsl(mockOptions); + expect(buildHostsKpiRiskyHostsQuery).toHaveBeenCalledWith(mockOptions); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/kpi/risky_hosts/index.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/kpi/risky_hosts/index.ts new file mode 100644 index 0000000000000..0e0f48f37efcf --- /dev/null +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/kpi/risky_hosts/index.ts @@ -0,0 +1,53 @@ +/* + * 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 { getOr } from 'lodash/fp'; + +import type { IEsSearchResponse } from '../../../../../../../../../../src/plugins/data/common'; +import type { HostsKpiQueries } from '../../../../../../../common/search_strategy'; + +import type { + HostsKpiRiskyHostsRequestOptions, + HostsKpiRiskyHostsStrategyResponse, + HostRiskSeverity, +} from '../../../../../../../common/search_strategy/security_solution/hosts/kpi/risky_hosts'; +import { inspectStringifyObject } from '../../../../../../utils/build_query'; +import type { SecuritySolutionFactory } from '../../../types'; +import { buildHostsKpiRiskyHostsQuery } from './query.hosts_kpi_risky_hosts.dsl'; + +interface AggBucket { + key: HostRiskSeverity; + doc_count: number; +} + +export const hostsKpiRiskyHosts: SecuritySolutionFactory = { + buildDsl: (options: HostsKpiRiskyHostsRequestOptions) => buildHostsKpiRiskyHostsQuery(options), + parse: async ( + options: HostsKpiRiskyHostsRequestOptions, + response: IEsSearchResponse + ): Promise => { + const inspect = { + dsl: [inspectStringifyObject(buildHostsKpiRiskyHostsQuery(options))], + }; + + const riskBuckets = getOr([], 'aggregations.risk.buckets', response.rawResponse); + + const riskyHosts: Record = riskBuckets.reduce( + (cummulative: Record, bucket: AggBucket) => ({ + ...cummulative, + [bucket.key]: bucket.doc_count, + }), + {} + ); + + return { + ...response, + riskyHosts, + inspect, + }; + }, +}; diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/kpi/risky_hosts/query.hosts_kpi_risky_hosts.dsl.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/kpi/risky_hosts/query.hosts_kpi_risky_hosts.dsl.ts new file mode 100644 index 0000000000000..201d73c4ebb18 --- /dev/null +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/kpi/risky_hosts/query.hosts_kpi_risky_hosts.dsl.ts @@ -0,0 +1,50 @@ +/* + * 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 { HostsKpiRiskyHostsRequestOptions } from '../../../../../../../common/search_strategy/security_solution/hosts/kpi/risky_hosts'; +import { createQueryFilterClauses } from '../../../../../../utils/build_query'; + +export const buildHostsKpiRiskyHostsQuery = ({ + filterQuery, + timerange: { from, to }, + defaultIndex, +}: HostsKpiRiskyHostsRequestOptions) => { + const filter = [ + ...createQueryFilterClauses(filterQuery), + { + range: { + '@timestamp': { + gte: from, + lte: to, + format: 'strict_date_optional_time', + }, + }, + }, + ]; + + const dslQuery = { + index: defaultIndex, + allow_no_indices: false, + ignore_unavailable: true, + track_total_hits: false, + body: { + aggs: { + risk: { + terms: { field: 'risk.keyword' }, + }, + }, + query: { + bool: { + filter, + }, + }, + size: 0, + }, + }; + + return dslQuery; +}; diff --git a/x-pack/test/security_solution_cypress/es_archives/risky_hosts/data.json b/x-pack/test/security_solution_cypress/es_archives/risky_hosts/data.json index e42a13ab8d8a8..e6187d1f7e0a6 100644 --- a/x-pack/test/security_solution_cypress/es_archives/risky_hosts/data.json +++ b/x-pack/test/security_solution_cypress/es_archives/risky_hosts/data.json @@ -5,17 +5,18 @@ "index":"ml_host_risk_score_latest_default", "source":{ "@timestamp":"2021-03-10T14:51:05.766Z", - "risk_score":21, + "risk_stats": { + "risk_score": 21, + "rule_risks": [ + { + "rule_name": "Unusual Linux Username", + "rule_risk": 42 + } + ] + }, "host":{ "name":"ip-10-10-10-121" }, - "rules":{ - "Unusual Linux Username":{ - "average_risk":21, - "rule_count":2, - "rule_risk":42 - } - }, "ingest_timestamp":"2021-03-09T18:02:08.319296053Z", "risk":"Low" } diff --git a/x-pack/test/security_solution_cypress/es_archives/risky_hosts/mappings.json b/x-pack/test/security_solution_cypress/es_archives/risky_hosts/mappings.json index f71c9cf8ed4c2..2738d85d8b3af 100644 --- a/x-pack/test/security_solution_cypress/es_archives/risky_hosts/mappings.json +++ b/x-pack/test/security_solution_cypress/es_archives/risky_hosts/mappings.json @@ -32,8 +32,12 @@ } } }, - "risk_score": { - "type": "long" + "risk_stats": { + "properties": { + "risk_score": { + "type": "long" + } + } } } },