From c0ceb06f4b8f67a3ceff1ccf8b8b48b3866f5c0f Mon Sep 17 00:00:00 2001 From: Steph Milovic Date: Fri, 23 Jul 2021 09:49:55 -0500 Subject: [PATCH 1/5] [Security Solution] UEBA Spacetime Project (#104973) Merging with known issues documented here: https://github.com/elastic/kibana/issues/106648 --- .../security_solution/common/constants.ts | 24 +- .../common/experimental_features.ts | 5 +- .../security_solution/index.ts | 29 +++ .../security_solution/ueba/common/index.ts | 52 ++++ .../ueba/host_rules/index.ts | 48 ++++ .../ueba/host_tactics/index.ts | 52 ++++ .../security_solution/ueba/index.ts | 19 ++ .../ueba/risk_score/index.ts | 47 ++++ .../ueba/user_rules/index.ts | 78 ++++++ .../common/types/timeline/index.ts | 2 + .../public/app/deep_links/index.test.ts | 38 ++- .../public/app/deep_links/index.ts | 36 ++- .../public/app/home/home_navigations.ts | 8 + .../security_solution/public/app/index.tsx | 4 +- .../public/app/translations.ts | 4 + .../security_solution/public/app/types.ts | 8 +- .../common/components/header_page/index.tsx | 4 +- .../components/link_to/redirect_to_ueba.tsx | 24 ++ .../public/common/components/links/index.tsx | 40 ++++ .../navigation/breadcrumbs/index.ts | 24 ++ .../navigation/tab_navigation/index.tsx | 3 +- .../common/components/navigation/types.ts | 26 +- .../index.test.tsx | 14 ++ .../index.tsx | 13 +- .../use_navigation_items.tsx | 4 +- .../components/paginated_table/index.tsx | 19 +- .../common/components/url_state/constants.ts | 7 +- .../common/components/url_state/types.ts | 12 +- .../common/containers/sourcerer/index.tsx | 15 +- .../common/hooks/use_experimental_features.ts | 5 +- .../mock/endpoint/app_context_render.tsx | 2 +- .../public/common/mock/global_state.ts | 42 +++- .../public/common/mock/utils.ts | 2 + .../public/common/store/app/model.ts | 2 +- .../public/common/store/app/reducer.ts | 7 + .../public/common/store/reducer.ts | 2 + .../public/common/store/types.ts | 2 + .../public/common/utils/route/types.ts | 11 +- .../timeline_actions/alert_context_menu.tsx | 14 +- .../detection_engine/rules/details/index.tsx | 20 +- .../security_solution/public/helpers.ts | 4 +- .../public/lazy_sub_plugins.tsx | 2 + .../security_solution/public/plugin.tsx | 38 ++- .../plugins/security_solution/public/types.ts | 5 + .../components/host_rules_table/columns.tsx | 145 +++++++++++ .../components/host_rules_table/index.tsx | 173 ++++++++++++++ .../host_rules_table/translations.ts | 33 +++ .../components/host_tactics_table/columns.tsx | 153 ++++++++++++ .../components/host_tactics_table/index.tsx | 161 +++++++++++++ .../host_tactics_table/translations.ts | 33 +++ .../components/risk_score_table/columns.tsx | 79 ++++++ .../components/risk_score_table/index.tsx | 157 ++++++++++++ .../risk_score_table/translations.ts | 29 +++ .../public/ueba/components/translations.ts | 18 ++ .../public/ueba/components/utils.ts | 20 ++ .../ueba/containers/host_rules/index.tsx | 220 +++++++++++++++++ .../containers/host_rules/translations.ts | 22 ++ .../ueba/containers/host_tactics/index.tsx | 225 ++++++++++++++++++ .../containers/host_tactics/translations.ts | 22 ++ .../ueba/containers/risk_score/index.tsx | 216 +++++++++++++++++ .../containers/risk_score/translations.ts | 22 ++ .../ueba/containers/user_rules/index.tsx | 209 ++++++++++++++++ .../containers/user_rules/translations.ts | 22 ++ .../security_solution/public/ueba/index.ts | 30 +++ .../ueba/pages/details/details_tabs.tsx | 95 ++++++++ .../public/ueba/pages/details/helpers.ts | 50 ++++ .../public/ueba/pages/details/index.tsx | 150 ++++++++++++ .../public/ueba/pages/details/nav_tabs.tsx | 37 +++ .../public/ueba/pages/details/types.ts | 65 +++++ .../public/ueba/pages/details/utils.ts | 71 ++++++ .../public/ueba/pages/display.tsx | 14 ++ .../public/ueba/pages/index.tsx | 65 +++++ .../public/ueba/pages/nav_tabs.tsx | 22 ++ .../navigation/host_rules_query_tab_body.tsx | 64 +++++ .../host_tactics_query_tab_body.tsx | 63 +++++ .../public/ueba/pages/navigation/index.ts | 11 + .../navigation/risk_score_query_tab_body.tsx | 52 ++++ .../public/ueba/pages/navigation/types.ts | 39 +++ .../navigation/user_rules_query_tab_body.tsx | 70 ++++++ .../public/ueba/pages/translations.ts | 27 +++ .../public/ueba/pages/types.ts | 29 +++ .../public/ueba/pages/ueba.tsx | 184 ++++++++++++++ .../public/ueba/pages/ueba_tabs.tsx | 82 +++++++ .../security_solution/public/ueba/routes.tsx | 26 ++ .../public/ueba/store/actions.ts | 35 +++ .../public/ueba/store/helpers.ts | 45 ++++ .../public/ueba/store/index.ts | 22 ++ .../public/ueba/store/model.ts | 78 ++++++ .../public/ueba/store/reducer.ts | 136 +++++++++++ .../public/ueba/store/selectors.ts | 27 +++ .../signals/executors/eql.test.ts | 2 + .../detection_engine/signals/executors/eql.ts | 10 +- .../signals/executors/query.ts | 10 +- .../signals/executors/threat_match.ts | 10 +- .../signals/executors/threshold.test.ts | 2 + .../signals/executors/threshold.ts | 10 +- .../signals/get_input_output_index.test.ts | 64 ++++- .../signals/get_input_output_index.ts | 33 ++- .../signals/signal_rule_alert_type.test.ts | 2 + .../signals/signal_rule_alert_type.ts | 14 +- .../security_solution/server/plugin.ts | 1 + .../factory/hosts/details/index.test.tsx | 1 + .../security_solution/factory/index.ts | 2 + .../factory/ueba/host_rules/helpers.ts | 24 ++ .../factory/ueba/host_rules/index.ts | 59 +++++ .../ueba/host_rules/query.host_rules.dsl.ts | 86 +++++++ .../factory/ueba/host_tactics/helpers.ts | 47 ++++ .../factory/ueba/host_tactics/index.ts | 59 +++++ .../host_tactics/query.host_tactics.dsl.ts | 90 +++++++ .../security_solution/factory/ueba/index.ts | 23 ++ .../factory/ueba/risk_score/helpers.ts | 23 ++ .../factory/ueba/risk_score/index.ts | 59 +++++ .../ueba/risk_score/query.risk_score.dsl.ts | 71 ++++++ .../factory/ueba/user_rules/helpers.ts | 19 ++ .../factory/ueba/user_rules/index.ts | 67 ++++++ .../ueba/user_rules/query.user_rules.dsl.ts | 97 ++++++++ .../security_solution/server/ui_settings.ts | 5 +- .../timeline/events/last_event_time/index.ts | 1 + .../timelines/common/types/timeline/index.ts | 2 + .../timelines/public/store/t_grid/types.ts | 1 + .../query.events_last_event_time.dsl.ts | 1 + 121 files changed, 5143 insertions(+), 117 deletions(-) create mode 100644 x-pack/plugins/security_solution/common/search_strategy/security_solution/ueba/common/index.ts create mode 100644 x-pack/plugins/security_solution/common/search_strategy/security_solution/ueba/host_rules/index.ts create mode 100644 x-pack/plugins/security_solution/common/search_strategy/security_solution/ueba/host_tactics/index.ts create mode 100644 x-pack/plugins/security_solution/common/search_strategy/security_solution/ueba/index.ts create mode 100644 x-pack/plugins/security_solution/common/search_strategy/security_solution/ueba/risk_score/index.ts create mode 100644 x-pack/plugins/security_solution/common/search_strategy/security_solution/ueba/user_rules/index.ts create mode 100644 x-pack/plugins/security_solution/public/common/components/link_to/redirect_to_ueba.tsx create mode 100644 x-pack/plugins/security_solution/public/ueba/components/host_rules_table/columns.tsx create mode 100644 x-pack/plugins/security_solution/public/ueba/components/host_rules_table/index.tsx create mode 100644 x-pack/plugins/security_solution/public/ueba/components/host_rules_table/translations.ts create mode 100644 x-pack/plugins/security_solution/public/ueba/components/host_tactics_table/columns.tsx create mode 100644 x-pack/plugins/security_solution/public/ueba/components/host_tactics_table/index.tsx create mode 100644 x-pack/plugins/security_solution/public/ueba/components/host_tactics_table/translations.ts create mode 100644 x-pack/plugins/security_solution/public/ueba/components/risk_score_table/columns.tsx create mode 100644 x-pack/plugins/security_solution/public/ueba/components/risk_score_table/index.tsx create mode 100644 x-pack/plugins/security_solution/public/ueba/components/risk_score_table/translations.ts create mode 100644 x-pack/plugins/security_solution/public/ueba/components/translations.ts create mode 100644 x-pack/plugins/security_solution/public/ueba/components/utils.ts create mode 100644 x-pack/plugins/security_solution/public/ueba/containers/host_rules/index.tsx create mode 100644 x-pack/plugins/security_solution/public/ueba/containers/host_rules/translations.ts create mode 100644 x-pack/plugins/security_solution/public/ueba/containers/host_tactics/index.tsx create mode 100644 x-pack/plugins/security_solution/public/ueba/containers/host_tactics/translations.ts create mode 100644 x-pack/plugins/security_solution/public/ueba/containers/risk_score/index.tsx create mode 100644 x-pack/plugins/security_solution/public/ueba/containers/risk_score/translations.ts create mode 100644 x-pack/plugins/security_solution/public/ueba/containers/user_rules/index.tsx create mode 100644 x-pack/plugins/security_solution/public/ueba/containers/user_rules/translations.ts create mode 100644 x-pack/plugins/security_solution/public/ueba/index.ts create mode 100644 x-pack/plugins/security_solution/public/ueba/pages/details/details_tabs.tsx create mode 100644 x-pack/plugins/security_solution/public/ueba/pages/details/helpers.ts create mode 100644 x-pack/plugins/security_solution/public/ueba/pages/details/index.tsx create mode 100644 x-pack/plugins/security_solution/public/ueba/pages/details/nav_tabs.tsx create mode 100644 x-pack/plugins/security_solution/public/ueba/pages/details/types.ts create mode 100644 x-pack/plugins/security_solution/public/ueba/pages/details/utils.ts create mode 100644 x-pack/plugins/security_solution/public/ueba/pages/display.tsx create mode 100644 x-pack/plugins/security_solution/public/ueba/pages/index.tsx create mode 100644 x-pack/plugins/security_solution/public/ueba/pages/nav_tabs.tsx create mode 100644 x-pack/plugins/security_solution/public/ueba/pages/navigation/host_rules_query_tab_body.tsx create mode 100644 x-pack/plugins/security_solution/public/ueba/pages/navigation/host_tactics_query_tab_body.tsx create mode 100644 x-pack/plugins/security_solution/public/ueba/pages/navigation/index.ts create mode 100644 x-pack/plugins/security_solution/public/ueba/pages/navigation/risk_score_query_tab_body.tsx create mode 100644 x-pack/plugins/security_solution/public/ueba/pages/navigation/types.ts create mode 100644 x-pack/plugins/security_solution/public/ueba/pages/navigation/user_rules_query_tab_body.tsx create mode 100644 x-pack/plugins/security_solution/public/ueba/pages/translations.ts create mode 100644 x-pack/plugins/security_solution/public/ueba/pages/types.ts create mode 100644 x-pack/plugins/security_solution/public/ueba/pages/ueba.tsx create mode 100644 x-pack/plugins/security_solution/public/ueba/pages/ueba_tabs.tsx create mode 100644 x-pack/plugins/security_solution/public/ueba/routes.tsx create mode 100644 x-pack/plugins/security_solution/public/ueba/store/actions.ts create mode 100644 x-pack/plugins/security_solution/public/ueba/store/helpers.ts create mode 100644 x-pack/plugins/security_solution/public/ueba/store/index.ts create mode 100644 x-pack/plugins/security_solution/public/ueba/store/model.ts create mode 100644 x-pack/plugins/security_solution/public/ueba/store/reducer.ts create mode 100644 x-pack/plugins/security_solution/public/ueba/store/selectors.ts create mode 100644 x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/ueba/host_rules/helpers.ts create mode 100644 x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/ueba/host_rules/index.ts create mode 100644 x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/ueba/host_rules/query.host_rules.dsl.ts create mode 100644 x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/ueba/host_tactics/helpers.ts create mode 100644 x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/ueba/host_tactics/index.ts create mode 100644 x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/ueba/host_tactics/query.host_tactics.dsl.ts create mode 100644 x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/ueba/index.ts create mode 100644 x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/ueba/risk_score/helpers.ts create mode 100644 x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/ueba/risk_score/index.ts create mode 100644 x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/ueba/risk_score/query.risk_score.dsl.ts create mode 100644 x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/ueba/user_rules/helpers.ts create mode 100644 x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/ueba/user_rules/index.ts create mode 100644 x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/ueba/user_rules/query.user_rules.dsl.ts diff --git a/x-pack/plugins/security_solution/common/constants.ts b/x-pack/plugins/security_solution/common/constants.ts index 27d4a5c9fd399..48a23a967059e 100644 --- a/x-pack/plugins/security_solution/common/constants.ts +++ b/x-pack/plugins/security_solution/common/constants.ts @@ -62,20 +62,21 @@ export const DEFAULT_INDICATOR_SOURCE_PATH = 'threatintel.indicator'; export const INDICATOR_DESTINATION_PATH = 'threat.indicator'; export enum SecurityPageName { - overview = 'overview', - detections = 'detections', + administration = 'administration', alerts = 'alerts', - rules = 'rules', + case = 'case', + detections = 'detections', + endpoints = 'endpoints', + eventFilters = 'event_filters', exceptions = 'exceptions', hosts = 'hosts', network = 'network', - timelines = 'timelines', - case = 'case', - administration = 'administration', - endpoints = 'endpoints', + overview = 'overview', policies = 'policies', + rules = 'rules', + timelines = 'timelines', trustedApps = 'trusted_apps', - eventFilters = 'event_filters', + ueba = 'ueba', } export const TIMELINES_PATH = '/timelines'; @@ -86,6 +87,7 @@ export const ALERTS_PATH = '/alerts'; export const RULES_PATH = '/rules'; export const EXCEPTIONS_PATH = '/exceptions'; export const HOSTS_PATH = '/hosts'; +export const UEBA_PATH = '/ueba'; export const NETWORK_PATH = '/network'; export const MANAGEMENT_PATH = '/administration'; export const ENDPOINTS_PATH = `${MANAGEMENT_PATH}/endpoints`; @@ -100,6 +102,7 @@ export const APP_RULES_PATH = `${APP_PATH}${RULES_PATH}`; export const APP_EXCEPTIONS_PATH = `${APP_PATH}${EXCEPTIONS_PATH}`; export const APP_HOSTS_PATH = `${APP_PATH}${HOSTS_PATH}`; +export const APP_UEBA_PATH = `${APP_PATH}${UEBA_PATH}`; export const APP_NETWORK_PATH = `${APP_PATH}${NETWORK_PATH}`; export const APP_TIMELINES_PATH = `${APP_PATH}${TIMELINES_PATH}`; export const APP_CASES_PATH = `${APP_PATH}${CASES_PATH}`; @@ -119,6 +122,11 @@ export const DEFAULT_INDEX_PATTERN = [ 'winlogbeat-*', ]; +export const DEFAULT_INDEX_PATTERN_EXPERIMENTAL = [ + // TODO: Steph/ueba TEMP for testing UEBA data + 'ml_host_risk_score_*', +]; + /** This Kibana Advanced Setting enables the `Security news` feed widget */ export const ENABLE_NEWS_FEED_SETTING = 'securitySolution:enableNewsFeed'; diff --git a/x-pack/plugins/security_solution/common/experimental_features.ts b/x-pack/plugins/security_solution/common/experimental_features.ts index a9a81aa285af7..6d4a2b78840ea 100644 --- a/x-pack/plugins/security_solution/common/experimental_features.ts +++ b/x-pack/plugins/security_solution/common/experimental_features.ts @@ -11,11 +11,12 @@ export type ExperimentalFeatures = typeof allowedExperimentalValues; * A list of allowed values that can be used in `xpack.securitySolution.enableExperimental`. * This object is then used to validate and parse the value entered. */ -const allowedExperimentalValues = Object.freeze({ - trustedAppsByPolicyEnabled: false, +export const allowedExperimentalValues = Object.freeze({ metricsEntitiesEnabled: false, ruleRegistryEnabled: false, tGridEnabled: false, + trustedAppsByPolicyEnabled: false, + uebaEnabled: false, }); type ExperimentalConfigKeys = Array; 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 06d4a16699b8f..208579ffacabe 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 @@ -71,14 +71,27 @@ import { CtiEventEnrichmentStrategyResponse, CtiQueries, } from './cti'; +import { + HostRulesRequestOptions, + HostRulesStrategyResponse, + HostTacticsRequestOptions, + HostTacticsStrategyResponse, + RiskScoreRequestOptions, + RiskScoreStrategyResponse, + UebaQueries, + UserRulesRequestOptions, + UserRulesStrategyResponse, +} from './ueba'; export * from './hosts'; export * from './matrix_histogram'; export * from './network'; +export * from './ueba'; export type FactoryQueryTypes = | HostsQueries | HostsKpiQueries + | UebaQueries | NetworkQueries | NetworkKpiQueries | CtiQueries @@ -109,6 +122,14 @@ export type StrategyResponseType = T extends HostsQ ? HostsStrategyResponse : T extends HostsQueries.details ? HostDetailsStrategyResponse + : T extends UebaQueries.riskScore + ? RiskScoreStrategyResponse + : T extends UebaQueries.hostRules + ? HostRulesStrategyResponse + : T extends UebaQueries.userRules + ? UserRulesStrategyResponse + : T extends UebaQueries.hostTactics + ? HostTacticsStrategyResponse : T extends HostsQueries.overview ? HostsOverviewStrategyResponse : T extends HostsQueries.authentications @@ -199,6 +220,14 @@ export type StrategyRequestType = T extends HostsQu ? NetworkKpiUniqueFlowsRequestOptions : T extends NetworkKpiQueries.uniquePrivateIps ? NetworkKpiUniquePrivateIpsRequestOptions + : T extends UebaQueries.riskScore + ? RiskScoreRequestOptions + : T extends UebaQueries.hostRules + ? HostRulesRequestOptions + : T extends UebaQueries.userRules + ? UserRulesRequestOptions + : T extends UebaQueries.hostTactics + ? HostTacticsRequestOptions : T extends typeof MatrixHistogramQuery ? MatrixHistogramRequestOptions : T extends CtiQueries.eventEnrichment diff --git a/x-pack/plugins/security_solution/common/search_strategy/security_solution/ueba/common/index.ts b/x-pack/plugins/security_solution/common/search_strategy/security_solution/ueba/common/index.ts new file mode 100644 index 0000000000000..f7406e32d1869 --- /dev/null +++ b/x-pack/plugins/security_solution/common/search_strategy/security_solution/ueba/common/index.ts @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { Maybe } from '../../../common'; + +export enum RiskScoreFields { + hostName = 'host_name', + riskKeyword = 'risk_keyword', + riskScore = 'risk_score', +} +export interface RiskScoreItem { + _id?: Maybe; + [RiskScoreFields.hostName]: Maybe; + [RiskScoreFields.riskKeyword]: Maybe; + [RiskScoreFields.riskScore]: Maybe; +} +export enum HostRulesFields { + hits = 'hits', + riskScore = 'risk_score', + ruleName = 'rule_name', + ruleType = 'rule_type', +} +export interface HostRulesItem { + _id?: Maybe; + [HostRulesFields.hits]: Maybe; + [HostRulesFields.riskScore]: Maybe; + [HostRulesFields.ruleName]: Maybe; + [HostRulesFields.ruleType]: Maybe; +} +export enum UserRulesFields { + userName = 'user_name', + riskScore = 'risk_score', + rules = 'rules', + ruleCount = 'rule_count', +} +export enum HostTacticsFields { + hits = 'hits', + riskScore = 'risk_score', + tactic = 'tactic', + technique = 'technique', +} +export interface HostTacticsItem { + _id?: Maybe; + [HostTacticsFields.hits]: Maybe; + [HostTacticsFields.riskScore]: Maybe; + [HostTacticsFields.tactic]: Maybe; + [HostTacticsFields.technique]: Maybe; +} diff --git a/x-pack/plugins/security_solution/common/search_strategy/security_solution/ueba/host_rules/index.ts b/x-pack/plugins/security_solution/common/search_strategy/security_solution/ueba/host_rules/index.ts new file mode 100644 index 0000000000000..cb6469c6209a6 --- /dev/null +++ b/x-pack/plugins/security_solution/common/search_strategy/security_solution/ueba/host_rules/index.ts @@ -0,0 +1,48 @@ +/* + * 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 { IEsSearchResponse } from '../../../../../../../../src/plugins/data/common'; + +import { HostRulesItem, HostRulesFields } from '../common'; +import { CursorType, Hit, Inspect, Maybe, PageInfoPaginated, SortField } from '../../../common'; +import { RequestOptionsPaginated } from '../..'; + +export interface HostRulesHit extends Hit { + key: string; + doc_count: number; + risk_score: { + value?: number; + }; + rule_type: { + buckets?: Array<{ + key: string; + doc_count: number; + }>; + }; + rule_count: { + value: number; + }; +} + +export interface HostRulesEdges { + node: HostRulesItem; + cursor: CursorType; +} + +export interface HostRulesStrategyResponse extends IEsSearchResponse { + edges: HostRulesEdges[]; + totalCount: number; + pageInfo: PageInfoPaginated; + inspect?: Maybe; +} + +export interface HostRulesRequestOptions extends RequestOptionsPaginated { + defaultIndex: string[]; + hostName: string; +} + +export type HostRulesSortField = SortField; diff --git a/x-pack/plugins/security_solution/common/search_strategy/security_solution/ueba/host_tactics/index.ts b/x-pack/plugins/security_solution/common/search_strategy/security_solution/ueba/host_tactics/index.ts new file mode 100644 index 0000000000000..c55058dc6be04 --- /dev/null +++ b/x-pack/plugins/security_solution/common/search_strategy/security_solution/ueba/host_tactics/index.ts @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { IEsSearchResponse } from '../../../../../../../../src/plugins/data/common'; + +import { HostTacticsItem, HostTacticsFields } from '../common'; +import { CursorType, Hit, Inspect, Maybe, PageInfoPaginated, SortField } from '../../../common'; +import { RequestOptionsPaginated } from '../..'; +export interface HostTechniqueHit { + key: string; + doc_count: number; + risk_score: { + value?: number; + }; +} +export interface HostTacticsHit extends Hit { + key: string; + doc_count: number; + risk_score: { + value?: number; + }; + technique: { + buckets?: HostTechniqueHit[]; + }; + tactic_count: { + value: number; + }; +} + +export interface HostTacticsEdges { + node: HostTacticsItem; + cursor: CursorType; +} + +export interface HostTacticsStrategyResponse extends IEsSearchResponse { + edges: HostTacticsEdges[]; + techniqueCount: number; + totalCount: number; + pageInfo: PageInfoPaginated; + inspect?: Maybe; +} + +export interface HostTacticsRequestOptions extends RequestOptionsPaginated { + defaultIndex: string[]; + hostName: string; +} + +export type HostTacticsSortField = SortField; diff --git a/x-pack/plugins/security_solution/common/search_strategy/security_solution/ueba/index.ts b/x-pack/plugins/security_solution/common/search_strategy/security_solution/ueba/index.ts new file mode 100644 index 0000000000000..1d166e36f6973 --- /dev/null +++ b/x-pack/plugins/security_solution/common/search_strategy/security_solution/ueba/index.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export * from './common'; +export * from './host_rules'; +export * from './host_tactics'; +export * from './risk_score'; +export * from './user_rules'; + +export enum UebaQueries { + hostRules = 'hostRules', + hostTactics = 'hostTactics', + riskScore = 'riskScore', + userRules = 'userRules', +} diff --git a/x-pack/plugins/security_solution/common/search_strategy/security_solution/ueba/risk_score/index.ts b/x-pack/plugins/security_solution/common/search_strategy/security_solution/ueba/risk_score/index.ts new file mode 100644 index 0000000000000..14c1533755056 --- /dev/null +++ b/x-pack/plugins/security_solution/common/search_strategy/security_solution/ueba/risk_score/index.ts @@ -0,0 +1,47 @@ +/* + * 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 { IEsSearchResponse } from '../../../../../../../../src/plugins/data/common'; + +import { RiskScoreItem, RiskScoreFields } from '../common'; +import { CursorType, Hit, Inspect, Maybe, PageInfoPaginated, SortField } from '../../../common'; +import { RequestOptionsPaginated } from '../..'; + +export interface RiskScoreHit extends Hit { + _source: { + '@timestamp': string; + }; + key: string; + doc_count: number; + risk_score: { + value?: number; + }; + risk_keyword: { + buckets?: Array<{ + key: string; + doc_count: number; + }>; + }; +} + +export interface RiskScoreEdges { + node: RiskScoreItem; + cursor: CursorType; +} + +export interface RiskScoreStrategyResponse extends IEsSearchResponse { + edges: RiskScoreEdges[]; + totalCount: number; + pageInfo: PageInfoPaginated; + inspect?: Maybe; +} + +export interface RiskScoreRequestOptions extends RequestOptionsPaginated { + defaultIndex: string[]; +} + +export type RiskScoreSortField = SortField; diff --git a/x-pack/plugins/security_solution/common/search_strategy/security_solution/ueba/user_rules/index.ts b/x-pack/plugins/security_solution/common/search_strategy/security_solution/ueba/user_rules/index.ts new file mode 100644 index 0000000000000..c7302c10fab3b --- /dev/null +++ b/x-pack/plugins/security_solution/common/search_strategy/security_solution/ueba/user_rules/index.ts @@ -0,0 +1,78 @@ +/* + * 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 { IEsSearchResponse } from '../../../../../../../../src/plugins/data/common'; + +import { HostRulesFields, UserRulesFields } from '../common'; +import { Hit, Inspect, Maybe, PageInfoPaginated, SearchHit, SortField } from '../../../common'; +import { HostRulesEdges, RequestOptionsPaginated } from '../..'; + +export interface RuleNameHit extends Hit { + key: string; + doc_count: number; + risk_score: { + value: number; + }; + rule_type: { + buckets?: Array<{ + key: string; + doc_count: number; + }>; + }; +} +export interface UserRulesHit extends Hit { + _source: { + '@timestamp': string; + }; + key: string; + doc_count: number; + risk_score: { + value: number; + }; + rule_count: { + value: number; + }; + rule_name: { + buckets?: RuleNameHit[]; + }; +} + +export interface UserRulesByUser { + _id?: Maybe; + [UserRulesFields.userName]: string; + [UserRulesFields.riskScore]: number; + [UserRulesFields.ruleCount]: number; + [UserRulesFields.rules]: HostRulesEdges[]; +} + +export interface UserRulesStrategyUserResponse { + [UserRulesFields.userName]: string; + [UserRulesFields.riskScore]: number; + edges: HostRulesEdges[]; + totalCount: number; + pageInfo: PageInfoPaginated; +} + +export interface UserRulesStrategyResponse extends IEsSearchResponse { + inspect?: Maybe; + data: UserRulesStrategyUserResponse[]; +} + +export interface UserRulesRequestOptions extends RequestOptionsPaginated { + defaultIndex: string[]; + hostName: string; +} + +export type UserRulesSortField = SortField; + +export interface UsersRulesHit extends SearchHit { + aggregations: { + user_data: { + buckets: UserRulesHit[]; + }; + }; +} diff --git a/x-pack/plugins/security_solution/common/types/timeline/index.ts b/x-pack/plugins/security_solution/common/types/timeline/index.ts index 05cf99195774b..e7c6464bc1546 100644 --- a/x-pack/plugins/security_solution/common/types/timeline/index.ts +++ b/x-pack/plugins/security_solution/common/types/timeline/index.ts @@ -308,6 +308,7 @@ export enum TimelineId { detectionsRulesDetailsPage = 'detections-rules-details-page', detectionsPage = 'detections-page', networkPageExternalAlerts = 'network-page-external-alerts', + uebaPageExternalAlerts = 'ueba-page-external-alerts', active = 'timeline-1', casePage = 'timeline-case', test = 'test', // Reserved for testing purposes @@ -320,6 +321,7 @@ export const TimelineIdLiteralRt = runtimeTypes.union([ runtimeTypes.literal(TimelineId.detectionsRulesDetailsPage), runtimeTypes.literal(TimelineId.detectionsPage), runtimeTypes.literal(TimelineId.networkPageExternalAlerts), + runtimeTypes.literal(TimelineId.uebaPageExternalAlerts), runtimeTypes.literal(TimelineId.active), runtimeTypes.literal(TimelineId.test), ]); diff --git a/x-pack/plugins/security_solution/public/app/deep_links/index.test.ts b/x-pack/plugins/security_solution/public/app/deep_links/index.test.ts index f125218b68c09..59af6737e495f 100644 --- a/x-pack/plugins/security_solution/public/app/deep_links/index.test.ts +++ b/x-pack/plugins/security_solution/public/app/deep_links/index.test.ts @@ -7,13 +7,14 @@ import { getDeepLinks } from '.'; import { Capabilities } from '../../../../../../src/core/public'; import { SecurityPageName } from '../types'; +import { mockGlobalState } from '../../common/mock'; describe('public search functions', () => { it('returns a subset of links for basic license, full set for platinum', () => { const basicLicense = 'basic'; const platinumLicense = 'platinum'; - const basicLinks = getDeepLinks(basicLicense); - const platinumLinks = getDeepLinks(platinumLicense); + const basicLinks = getDeepLinks(mockGlobalState.app.enableExperimental, basicLicense); + const platinumLinks = getDeepLinks(mockGlobalState.app.enableExperimental, platinumLicense); basicLinks.forEach((basicLink, index) => { const platinumLink = platinumLinks[index]; @@ -26,7 +27,7 @@ describe('public search functions', () => { it('returns case links for basic license with only read_cases capabilities', () => { const basicLicense = 'basic'; - const basicLinks = getDeepLinks(basicLicense, ({ + const basicLinks = getDeepLinks(mockGlobalState.app.enableExperimental, basicLicense, ({ siem: { read_cases: true, crud_cases: false }, } as unknown) as Capabilities); @@ -35,7 +36,7 @@ describe('public search functions', () => { it('returns case links with NO deepLinks for basic license with only read_cases capabilities', () => { const basicLicense = 'basic'; - const basicLinks = getDeepLinks(basicLicense, ({ + const basicLinks = getDeepLinks(mockGlobalState.app.enableExperimental, basicLicense, ({ siem: { read_cases: true, crud_cases: false }, } as unknown) as Capabilities); @@ -46,7 +47,7 @@ describe('public search functions', () => { it('returns case links with deepLinks for basic license with crud_cases capabilities', () => { const basicLicense = 'basic'; - const basicLinks = getDeepLinks(basicLicense, ({ + const basicLinks = getDeepLinks(mockGlobalState.app.enableExperimental, basicLicense, ({ siem: { read_cases: true, crud_cases: true }, } as unknown) as Capabilities); @@ -57,7 +58,7 @@ describe('public search functions', () => { it('returns NO case links for basic license with NO read_cases capabilities', () => { const basicLicense = 'basic'; - const basicLinks = getDeepLinks(basicLicense, ({ + const basicLinks = getDeepLinks(mockGlobalState.app.enableExperimental, basicLicense, ({ siem: { read_cases: false, crud_cases: false }, } as unknown) as Capabilities); @@ -66,17 +67,38 @@ describe('public search functions', () => { it('returns case links for basic license with undefined capabilities', () => { const basicLicense = 'basic'; - const basicLinks = getDeepLinks(basicLicense, undefined); + const basicLinks = getDeepLinks( + mockGlobalState.app.enableExperimental, + basicLicense, + undefined + ); expect(basicLinks.some((l) => l.id === SecurityPageName.case)).toBeTruthy(); }); it('returns case deepLinks for basic license with undefined capabilities', () => { const basicLicense = 'basic'; - const basicLinks = getDeepLinks(basicLicense, undefined); + const basicLinks = getDeepLinks( + mockGlobalState.app.enableExperimental, + basicLicense, + undefined + ); expect( (basicLinks.find((l) => l.id === SecurityPageName.case)?.deepLinks?.length ?? 0) > 0 ).toBeTruthy(); }); + + it('returns NO ueba link when enableExperimental.uebaEnabled === false', () => { + const deepLinks = getDeepLinks(mockGlobalState.app.enableExperimental); + expect(deepLinks.some((l) => l.id === SecurityPageName.ueba)).toBeFalsy(); + }); + + it('returns ueba link when enableExperimental.uebaEnabled === true', () => { + const deepLinks = getDeepLinks({ + ...mockGlobalState.app.enableExperimental, + uebaEnabled: true, + }); + expect(deepLinks.some((l) => l.id === SecurityPageName.ueba)).toBeTruthy(); + }); }); diff --git a/x-pack/plugins/security_solution/public/app/deep_links/index.ts b/x-pack/plugins/security_solution/public/app/deep_links/index.ts index f5cec592c7abf..871f1a01e3de0 100644 --- a/x-pack/plugins/security_solution/public/app/deep_links/index.ts +++ b/x-pack/plugins/security_solution/public/app/deep_links/index.ts @@ -27,6 +27,7 @@ import { TIMELINES, CASE, MANAGE, + UEBA, } from '../translations'; import { OVERVIEW_PATH, @@ -40,7 +41,9 @@ import { ENDPOINTS_PATH, TRUSTED_APPS_PATH, EVENT_FILTERS_PATH, + UEBA_PATH, } from '../../../common/constants'; +import { ExperimentalFeatures } from '../../../common/experimental_features'; export const topDeepLinks: AppDeepLink[] = [ { @@ -90,6 +93,18 @@ export const topDeepLinks: AppDeepLink[] = [ ], order: 9003, }, + { + id: SecurityPageName.ueba, + title: UEBA, + path: UEBA_PATH, + navLinkStatus: AppNavLinkStatus.visible, + keywords: [ + i18n.translate('xpack.securitySolution.search.ueba', { + defaultMessage: 'Users & Entities', + }), + ], + order: 9004, + }, { id: SecurityPageName.timelines, title: TIMELINES, @@ -100,7 +115,7 @@ export const topDeepLinks: AppDeepLink[] = [ defaultMessage: 'Timelines', }), ], - order: 9004, + order: 9005, }, { id: SecurityPageName.case, @@ -112,7 +127,7 @@ export const topDeepLinks: AppDeepLink[] = [ defaultMessage: 'Cases', }), ], - order: 9005, + order: 9006, }, { id: SecurityPageName.administration, @@ -254,6 +269,9 @@ const nestedDeepLinks: SecurityDeepLinks = { }, ], }, + [SecurityPageName.ueba]: { + base: [], + }, [SecurityPageName.timelines]: { base: [ { @@ -316,18 +334,22 @@ const nestedDeepLinks: SecurityDeepLinks = { /** * A function that generates the plugin deepLinks + * @param enableExperimental ExperimentalFeatures arg * @param licenseType optional string for license level, if not provided basic is assumed. + * @param capabilities optional arg for app start capabilities */ export function getDeepLinks( + enableExperimental: ExperimentalFeatures, licenseType?: LicenseType, capabilities?: ApplicationStart['capabilities'] ): AppDeepLink[] { return topDeepLinks .filter( (deepLink) => - deepLink.id !== SecurityPageName.case || - capabilities == null || - (deepLink.id === SecurityPageName.case && capabilities.siem.read_cases === true) + (deepLink.id !== SecurityPageName.case && deepLink.id !== SecurityPageName.ueba) || // is not cases or ueba + (deepLink.id === SecurityPageName.case && + (capabilities == null || capabilities.siem.read_cases === true)) || // is cases with at least read only caps + (deepLink.id === SecurityPageName.ueba && enableExperimental.uebaEnabled) // is ueba with ueba feature flag enabled ) .map((deepLink) => { const deepLinkId = deepLink.id as SecurityDeepLinkName; @@ -370,11 +392,13 @@ export function isPremiumLicense(licenseType?: LicenseType): boolean { export function updateGlobalNavigation({ capabilities, updater$, + enableExperimental, }: { capabilities: ApplicationStart['capabilities']; updater$: Subject; + enableExperimental: ExperimentalFeatures; }) { - const deepLinks = getDeepLinks(undefined, capabilities); + const deepLinks = getDeepLinks(enableExperimental, undefined, capabilities); const updatedDeepLinks = deepLinks.map((link) => { switch (link.id) { case SecurityPageName.case: diff --git a/x-pack/plugins/security_solution/public/app/home/home_navigations.ts b/x-pack/plugins/security_solution/public/app/home/home_navigations.ts index d6f8516d43a72..686dafca76d99 100644 --- a/x-pack/plugins/security_solution/public/app/home/home_navigations.ts +++ b/x-pack/plugins/security_solution/public/app/home/home_navigations.ts @@ -24,6 +24,7 @@ import { APP_ENDPOINTS_PATH, APP_TRUSTED_APPS_PATH, APP_EVENT_FILTERS_PATH, + APP_UEBA_PATH, SecurityPageName, } from '../../../common/constants'; @@ -70,6 +71,13 @@ export const navTabs: SecurityNav = { disabled: false, urlKey: 'network', }, + [SecurityPageName.ueba]: { + id: SecurityPageName.ueba, + name: i18n.UEBA, + href: APP_UEBA_PATH, + disabled: false, + urlKey: 'ueba', + }, [SecurityPageName.timelines]: { id: SecurityPageName.timelines, name: i18n.TIMELINES, diff --git a/x-pack/plugins/security_solution/public/app/index.tsx b/x-pack/plugins/security_solution/public/app/index.tsx index 81437ec9ec6f6..e880da57cf374 100644 --- a/x-pack/plugins/security_solution/public/app/index.tsx +++ b/x-pack/plugins/security_solution/public/app/index.tsx @@ -10,7 +10,7 @@ import { render, unmountComponentAtNode } from 'react-dom'; import { Redirect, Route, Switch } from 'react-router-dom'; import { OVERVIEW_PATH } from '../../common/constants'; -import { NotFoundPage } from '../app/404'; +import { NotFoundPage } from './404'; import { SecurityApp } from './app'; import { RenderAppProps } from './types'; @@ -43,6 +43,8 @@ export const renderApp = ({ ...subPlugins.exceptions.routes, ...subPlugins.hosts.routes, ...subPlugins.network.routes, + // will be undefined if enabledExperimental.uebaEnabled === false + ...(subPlugins.ueba != null ? subPlugins.ueba.routes : []), ...subPlugins.timelines.routes, ...subPlugins.cases.routes, ...subPlugins.management.routes, diff --git a/x-pack/plugins/security_solution/public/app/translations.ts b/x-pack/plugins/security_solution/public/app/translations.ts index 027789713a2ae..c3cf11f35211e 100644 --- a/x-pack/plugins/security_solution/public/app/translations.ts +++ b/x-pack/plugins/security_solution/public/app/translations.ts @@ -19,6 +19,10 @@ export const NETWORK = i18n.translate('xpack.securitySolution.navigation.network defaultMessage: 'Network', }); +export const UEBA = i18n.translate('xpack.securitySolution.navigation.ueba', { + defaultMessage: 'Users & Entities', +}); + export const RULES = i18n.translate('xpack.securitySolution.navigation.rules', { defaultMessage: 'Rules', }); diff --git a/x-pack/plugins/security_solution/public/app/types.ts b/x-pack/plugins/security_solution/public/app/types.ts index 8056c4092091c..490ff8936c18c 100644 --- a/x-pack/plugins/security_solution/public/app/types.ts +++ b/x-pack/plugins/security_solution/public/app/types.ts @@ -54,19 +54,21 @@ export interface SecuritySubPlugin { export type SecuritySubPluginKeyStore = | 'hosts' | 'network' + | 'ueba' | 'timeline' | 'hostList' | 'alertList' | 'management'; export type SecurityDeepLinkName = - | SecurityPageName.overview + | SecurityPageName.administration + | SecurityPageName.case | SecurityPageName.detections | SecurityPageName.hosts | SecurityPageName.network + | SecurityPageName.overview | SecurityPageName.timelines - | SecurityPageName.case - | SecurityPageName.administration; + | SecurityPageName.ueba; interface SecurityDeepLink { base: AppDeepLink[]; diff --git a/x-pack/plugins/security_solution/public/common/components/header_page/index.tsx b/x-pack/plugins/security_solution/public/common/components/header_page/index.tsx index dea19e1366875..46d05d9712227 100644 --- a/x-pack/plugins/security_solution/public/common/components/header_page/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/header_page/index.tsx @@ -77,6 +77,7 @@ export interface HeaderPageProps extends HeaderProps { children?: React.ReactNode; draggableArguments?: DraggableArguments; hideSourcerer?: boolean; + sourcererScope?: SourcererScopeName; subtitle?: SubtitleProps['items']; subtitle2?: SubtitleProps['items']; title: TitleProp; @@ -115,6 +116,7 @@ const HeaderPageComponent: React.FC = ({ draggableArguments, hideSourcerer = false, isLoading, + sourcererScope = SourcererScopeName.default, subtitle, subtitle2, title, @@ -145,7 +147,7 @@ const HeaderPageComponent: React.FC = ({ {children} )} - {!hideSourcerer && } + {!hideSourcerer && } {/* Manually add a 'padding-bottom' to header */} diff --git a/x-pack/plugins/security_solution/public/common/components/link_to/redirect_to_ueba.tsx b/x-pack/plugins/security_solution/public/common/components/link_to/redirect_to_ueba.tsx new file mode 100644 index 0000000000000..614ddf698d6b7 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/link_to/redirect_to_ueba.tsx @@ -0,0 +1,24 @@ +/* + * 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 { UebaTableType } from '../../../ueba/store/model'; +import { UEBA_PATH } from '../../../../common/constants'; +import { appendSearch } from './helpers'; + +export const getUebaUrl = (search?: string) => `${UEBA_PATH}${appendSearch(search)}`; + +export const getTabsOnUebaUrl = (tabName: UebaTableType, search?: string) => + `/${tabName}${appendSearch(search)}`; + +export const getUebaDetailsUrl = (detailName: string, search?: string) => + `/${detailName}${appendSearch(search)}`; + +export const getTabsOnUebaDetailsUrl = ( + detailName: string, + tabName: UebaTableType, + search?: string +) => `/${detailName}/${tabName}${appendSearch(search)}`; diff --git a/x-pack/plugins/security_solution/public/common/components/links/index.tsx b/x-pack/plugins/security_solution/public/common/components/links/index.tsx index 0b6b77aab00e4..cc0fdb3923dce 100644 --- a/x-pack/plugins/security_solution/public/common/components/links/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/links/index.tsx @@ -42,6 +42,7 @@ import { isUrlInvalid } from '../../utils/validators'; import * as i18n from './translations'; import { SecurityPageName } from '../../../app/types'; +import { getUebaDetailsUrl } from '../link_to/redirect_to_ueba'; export const DEFAULT_NUMBER_OF_LINK = 5; @@ -61,6 +62,45 @@ export const PortContainer = styled.div` `; // Internal Links +const UebaDetailsLinkComponent: React.FC<{ + children?: React.ReactNode; + hostName: string; + isButton?: boolean; +}> = ({ children, hostName, isButton }) => { + const { formatUrl, search } = useFormatUrl(SecurityPageName.ueba); + const { navigateToApp } = useKibana().services.application; + const goToUebaDetails = useCallback( + (ev) => { + ev.preventDefault(); + navigateToApp(APP_ID, { + deepLinkId: SecurityPageName.ueba, + path: getUebaDetailsUrl(encodeURIComponent(hostName), search), + }); + }, + [hostName, navigateToApp, search] + ); + + return isButton ? ( + + {children ? children : hostName} + + ) : ( + + {children ? children : hostName} + + ); +}; + +export const UebaDetailsLink = React.memo(UebaDetailsLinkComponent); + const HostDetailsLinkComponent: React.FC<{ children?: React.ReactNode; hostName: string; diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/breadcrumbs/index.ts b/x-pack/plugins/security_solution/public/common/components/navigation/breadcrumbs/index.ts index 4ad26533cb58c..aae97d90cb4b8 100644 --- a/x-pack/plugins/security_solution/public/common/components/navigation/breadcrumbs/index.ts +++ b/x-pack/plugins/security_solution/public/common/components/navigation/breadcrumbs/index.ts @@ -15,6 +15,7 @@ import { getBreadcrumbs as getIPDetailsBreadcrumbs } from '../../../../network/p import { getBreadcrumbs as getCaseDetailsBreadcrumbs } from '../../../../cases/pages/utils'; import { getBreadcrumbs as getDetectionRulesBreadcrumbs } from '../../../../detections/pages/detection_engine/rules/utils'; import { getBreadcrumbs as getTimelinesBreadcrumbs } from '../../../../timelines/pages'; +import { getBreadcrumbs as getUebaBreadcrumbs } from '../../../../ueba/pages/details/utils'; import { getBreadcrumbs as getAdminBreadcrumbs } from '../../../../management/common/breadcrumbs'; import { SecurityPageName } from '../../../../app/types'; import { @@ -23,6 +24,7 @@ import { NetworkRouteSpyState, TimelineRouteSpyState, AdministrationRouteSpyState, + UebaRouteSpyState, } from '../../../utils/route/types'; import { getAppOverviewUrl } from '../../link_to'; @@ -60,6 +62,9 @@ const isNetworkRoutes = (spyState: RouteSpyState): spyState is NetworkRouteSpySt const isHostsRoutes = (spyState: RouteSpyState): spyState is HostRouteSpyState => spyState != null && spyState.pageName === SecurityPageName.hosts; +const isUebaRoutes = (spyState: RouteSpyState): spyState is UebaRouteSpyState => + spyState != null && spyState.pageName === SecurityPageName.ueba; + const isTimelinesRoutes = (spyState: RouteSpyState): spyState is TimelineRouteSpyState => spyState != null && spyState.pageName === SecurityPageName.timelines; @@ -124,6 +129,25 @@ export const getBreadcrumbsForRoute = ( ), ]; } + if (isUebaRoutes(spyState) && object.navTabs) { + const tempNav: SearchNavTab = { urlKey: 'ueba', isDetailPage: false }; + let urlStateKeys = [getOr(tempNav, spyState.pageName, object.navTabs)]; + if (spyState.tabName != null) { + urlStateKeys = [...urlStateKeys, getOr(tempNav, spyState.tabName, object.navTabs)]; + } + + return [ + siemRootBreadcrumb, + ...getUebaBreadcrumbs( + spyState, + urlStateKeys.reduce( + (acc: string[], item: SearchNavTab) => [...acc, getSearch(item, object)], + [] + ), + getUrlForApp + ), + ]; + } if (isRulesRoutes(spyState) && object.navTabs) { const tempNav: SearchNavTab = { urlKey: SecurityPageName.rules, isDetailPage: false }; let urlStateKeys = [getOr(tempNav, spyState.pageName, object.navTabs)]; diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/tab_navigation/index.tsx b/x-pack/plugins/security_solution/public/common/components/navigation/tab_navigation/index.tsx index 2ca0d878078aa..4d9a8a704dde5 100644 --- a/x-pack/plugins/security_solution/public/common/components/navigation/tab_navigation/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/navigation/tab_navigation/index.tsx @@ -11,7 +11,7 @@ import React, { useEffect, useState, useCallback, useMemo } from 'react'; import { useLocation } from 'react-router-dom'; import deepEqual from 'fast-deep-equal'; -import { useNavigation } from '../../../lib/kibana/hooks'; +import { useNavigation } from '../../../lib/kibana'; import { track, METRIC_TYPE, TELEMETRY_EVENT } from '../../../lib/telemetry'; import { TabNavigationProps, TabNavigationItemProps } from './types'; @@ -84,7 +84,6 @@ export const TabNavigationComponent: React.FC = ({ () => Object.values(navTabs).map((tab) => { const isSelected = selectedTabId === tab.id; - return ( ; -} export interface TabNavigationComponentProps { pageName: string; tabName: SiemRouteType | undefined; @@ -43,22 +39,30 @@ export interface NavTab { urlKey?: UrlStateType; pageId?: SecurityPageName; } + export type SecurityNavKey = - | SecurityPageName.overview + | SecurityPageName.administration + | SecurityPageName.alerts + | SecurityPageName.case + | SecurityPageName.endpoints + | SecurityPageName.eventFilters + | SecurityPageName.exceptions | SecurityPageName.hosts | SecurityPageName.network - | SecurityPageName.alerts + | SecurityPageName.overview | SecurityPageName.rules - | SecurityPageName.exceptions | SecurityPageName.timelines - | SecurityPageName.case - | SecurityPageName.administration - | SecurityPageName.endpoints | SecurityPageName.trustedApps - | SecurityPageName.eventFilters; + | SecurityPageName.ueba; export type SecurityNav = Record; +export type GenericNavRecord = Record; + +export interface SecuritySolutionTabNavigationProps { + display?: 'default' | 'condensed'; + navTabs: GenericNavRecord; +} export type GetUrlForApp = ( appId: string, options?: { deepLinkId?: string; path?: string; absolute?: boolean } diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/index.test.tsx index af88aacb7602a..4bd5a43684792 100644 --- a/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/index.test.tsx @@ -16,10 +16,12 @@ import { TimelineTabs } from '../../../../../common/types/timeline'; import { useDeepEqualSelector } from '../../../hooks/use_selector'; import { UrlInputsModel } from '../../../store/inputs/model'; import { useRouteSpy } from '../../../utils/route/use_route_spy'; +import { useIsExperimentalFeatureEnabled } from '../../../hooks/use_experimental_features'; jest.mock('../../../lib/kibana/kibana_react'); jest.mock('../../../lib/kibana'); jest.mock('../../../hooks/use_selector'); +jest.mock('../../../hooks/use_experimental_features'); jest.mock('../../../utils/route/use_route_spy'); describe('useSecuritySolutionNavigation', () => { @@ -70,6 +72,7 @@ describe('useSecuritySolutionNavigation', () => { ]; beforeEach(() => { + (useIsExperimentalFeatureEnabled as jest.Mock).mockReturnValue(false); (useDeepEqualSelector as jest.Mock).mockReturnValue({ urlState: mockUrlState }); (useRouteSpy as jest.Mock).mockReturnValue(mockRouteSpy); (useKibana as jest.Mock).mockReturnValue({ @@ -231,6 +234,17 @@ describe('useSecuritySolutionNavigation', () => { `); }); + // TODO: Steph/ueba remove when no longer experimental + it('should include ueba when feature flag is on', async () => { + (useIsExperimentalFeatureEnabled as jest.Mock).mockReturnValue(true); + const { result } = renderHook<{}, KibanaPageTemplateProps['solutionNav']>(() => + useSecuritySolutionNavigation() + ); + + // @ts-ignore possibly undefined, but if undefined we want this test to fail + expect(result.current.items[2].items[2].id).toEqual(SecurityPageName.ueba); + }); + describe('Permission gated routes', () => { describe('cases', () => { it('should display the cases navigation item when the user has read permissions', () => { diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/index.tsx b/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/index.tsx index 39c6885e8dff5..5165a903bbde1 100644 --- a/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/index.tsx @@ -13,6 +13,8 @@ import { makeMapStateToProps } from '../../url_state/helpers'; import { useRouteSpy } from '../../../utils/route/use_route_spy'; import { navTabs } from '../../../../app/home/home_navigations'; import { useDeepEqualSelector } from '../../../hooks/use_selector'; +import { useIsExperimentalFeatureEnabled } from '../../../hooks/use_experimental_features'; +import { GenericNavRecord } from '../types'; /** * @description - This hook provides the structure necessary by the KibanaPageTemplate for rendering the primary security_solution side navigation. @@ -29,6 +31,12 @@ export const useSecuritySolutionNavigation = () => { const { detailName, flowTarget, pageName, pathName, search, state, tabName } = routeProps; + const uebaEnabled = useIsExperimentalFeatureEnabled('uebaEnabled'); + let enabledNavTabs: GenericNavRecord = (navTabs as unknown) as GenericNavRecord; + if (!uebaEnabled) { + const { ueba, ...rest } = enabledNavTabs; + enabledNavTabs = rest; + } useEffect(() => { if (pathName || pageName) { setBreadcrumbs( @@ -36,7 +44,7 @@ export const useSecuritySolutionNavigation = () => { detailName, filters: urlState.filters, flowTarget, - navTabs, + navTabs: enabledNavTabs, pageName, pathName, query: urlState.query, @@ -65,12 +73,13 @@ export const useSecuritySolutionNavigation = () => { tabName, getUrlForApp, navigateToUrl, + enabledNavTabs, ]); return usePrimaryNavigation({ query: urlState.query, filters: urlState.filters, - navTabs, + navTabs: enabledNavTabs, pageName, sourcerer: urlState.sourcerer, savedQuery: urlState.savedQuery, diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/use_navigation_items.tsx b/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/use_navigation_items.tsx index fffe59fceff41..feeeacf6124e8 100644 --- a/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/use_navigation_items.tsx +++ b/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/use_navigation_items.tsx @@ -20,7 +20,6 @@ export const usePrimaryNavigationItems = ({ ...urlStateProps }: PrimaryNavigationItemsProps): Array> => { const { navigateTo, getAppUrl } = useNavigation(); - const getSideNav = useCallback( (tab: NavTab) => { const { id, name, disabled } = tab; @@ -62,7 +61,6 @@ export const usePrimaryNavigationItems = ({ function usePrimaryNavigationItemsToDisplay(navTabs: Record) { const hasCasesReadPermissions = useGetUserCasesPermissions()?.read; - return useMemo( () => [ { @@ -76,7 +74,7 @@ function usePrimaryNavigationItemsToDisplay(navTabs: Record) { }, { ...securityNavGroup.explore, - items: [navTabs.hosts, navTabs.network], + items: [navTabs.hosts, navTabs.network, ...(navTabs.ueba != null ? [navTabs.ueba] : [])], }, { ...securityNavGroup.investigate, diff --git a/x-pack/plugins/security_solution/public/common/components/paginated_table/index.tsx b/x-pack/plugins/security_solution/public/common/components/paginated_table/index.tsx index 3d0be80e3d58c..f5828c9f65db9 100644 --- a/x-pack/plugins/security_solution/public/common/components/paginated_table/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/paginated_table/index.tsx @@ -46,6 +46,9 @@ import { useStateToaster } from '../toasters'; import * as i18n from './translations'; import { Panel } from '../panel'; import { InspectButtonContainer } from '../inspect'; +import { RiskScoreColumns } from '../../../ueba/components/risk_score_table'; +import { HostRulesColumns } from '../../../ueba/components/host_rules_table'; +import { HostTacticsColumns } from '../../../ueba/components/host_tactics_table'; const DEFAULT_DATA_TEST_SUBJ = 'paginated-table'; @@ -74,6 +77,8 @@ declare type HostsTableColumnsTest = [ declare type BasicTableColumns = | AuthTableColumns + | HostRulesColumns + | HostTacticsColumns | HostsTableColumns | HostsTableColumnsTest | NetworkDnsColumns @@ -82,6 +87,8 @@ declare type BasicTableColumns = | NetworkTopCountriesColumnsNetworkDetails | NetworkTopNFlowColumns | NetworkTopNFlowColumnsNetworkDetails + | NetworkHttpColumns + | RiskScoreColumns | TlsColumns | UncommonProcessTableColumns | UsersColumns; @@ -97,7 +104,8 @@ export interface BasicTableProps { headerSupplement?: React.ReactElement; headerTitle: string | React.ReactElement; headerTooltip?: string; - headerUnit: string | React.ReactElement; + headerUnit?: string | React.ReactElement; + headerSubtitle?: string | React.ReactElement; id?: string; itemsPerRow?: ItemsPerRow[]; isInspect?: boolean; @@ -136,6 +144,7 @@ const PaginatedTableComponent: FC = ({ headerTitle, headerTooltip, headerUnit, + headerSubtitle, id, isInspect, itemsPerRow, @@ -248,8 +257,12 @@ const PaginatedTableComponent: FC = ({ = 0 ? headerCount.toLocaleString() : 0} ${headerUnit}` + !loadingInitial && headerSubtitle + ? `${i18n.SHOWING}: ${headerSubtitle}` + : headerUnit && + `${i18n.SHOWING}: ${ + headerCount >= 0 ? headerCount.toLocaleString() : 0 + } ${headerUnit}` } title={headerTitle} tooltip={headerTooltip} diff --git a/x-pack/plugins/security_solution/public/common/components/url_state/constants.ts b/x-pack/plugins/security_solution/public/common/components/url_state/constants.ts index 6107b61638888..edf09a52006fd 100644 --- a/x-pack/plugins/security_solution/public/common/components/url_state/constants.ts +++ b/x-pack/plugins/security_solution/public/common/components/url_state/constants.ts @@ -26,12 +26,13 @@ export enum CONSTANTS { } export type UrlStateType = - | 'case' + | 'administration' | 'alerts' - | 'rules' + | 'case' | 'exceptions' | 'host' | 'network' | 'overview' + | 'rules' | 'timeline' - | 'administration'; + | 'ueba'; diff --git a/x-pack/plugins/security_solution/public/common/components/url_state/types.ts b/x-pack/plugins/security_solution/public/common/components/url_state/types.ts index 63511c54d28db..e6f79d3d24ae0 100644 --- a/x-pack/plugins/security_solution/public/common/components/url_state/types.ts +++ b/x-pack/plugins/security_solution/public/common/components/url_state/types.ts @@ -19,7 +19,7 @@ import { UrlInputsModel } from '../../store/inputs/model'; import { TimelineUrl } from '../../../timelines/store/timeline/model'; import { RouteSpyState } from '../../utils/route/types'; import { DispatchUpdateTimeline } from '../../../timelines/components/open_timeline/types'; -import { NavTab } from '../navigation/types'; +import { SecurityNav } from '../navigation/types'; import { CONSTANTS, UrlStateType } from './constants'; import { SourcererScopePatterns } from '../../store/sourcerer/model'; @@ -66,6 +66,14 @@ export const URL_STATE_KEYS: Record = { CONSTANTS.timerange, CONSTANTS.timeline, ], + ueba: [ + CONSTANTS.appQuery, + CONSTANTS.filters, + CONSTANTS.savedQuery, + CONSTANTS.sourcerer, + CONSTANTS.timerange, + CONSTANTS.timeline, + ], administration: [], network: [ CONSTANTS.appQuery, @@ -124,7 +132,7 @@ export interface UrlState { export type KeyUrlState = keyof UrlState; export interface UrlStateProps { - navTabs: Record; + navTabs: SecurityNav; indexPattern?: IIndexPattern; mapToUrlState?: (value: string) => UrlState; onChange?: (urlState: UrlState, previousUrlState: UrlState) => void; diff --git a/x-pack/plugins/security_solution/public/common/containers/sourcerer/index.tsx b/x-pack/plugins/security_solution/public/common/containers/sourcerer/index.tsx index 002c40fc9d428..d804f350a7f79 100644 --- a/x-pack/plugins/security_solution/public/common/containers/sourcerer/index.tsx +++ b/x-pack/plugins/security_solution/public/common/containers/sourcerer/index.tsx @@ -14,8 +14,8 @@ import { SourcererScopeName } from '../../store/sourcerer/model'; import { useIndexFields } from '../source'; import { useUserInfo } from '../../../detections/components/user_info'; import { timelineSelectors } from '../../../timelines/store/timeline'; -import { ALERTS_PATH, RULES_PATH } from '../../../../common/constants'; -import { TimelineId } from '../../../../common/types/timeline'; +import { ALERTS_PATH, RULES_PATH, UEBA_PATH } from '../../../../common/constants'; +import { TimelineId } from '../../../../common'; import { useDeepEqualSelector } from '../../hooks/use_selector'; export const useInitSourcerer = ( @@ -57,8 +57,7 @@ export const useInitSourcerer = ( !loadingSignalIndex && signalIndexName != null && signalIndexNameSelector == null && - (activeTimeline == null || - (activeTimeline != null && activeTimeline.savedObjectId == null)) && + (activeTimeline == null || activeTimeline.savedObjectId == null) && initialTimelineSourcerer.current ) { initialTimelineSourcerer.current = false; @@ -70,8 +69,7 @@ export const useInitSourcerer = ( ); } else if ( signalIndexNameSelector != null && - (activeTimeline == null || - (activeTimeline != null && activeTimeline.savedObjectId == null)) && + (activeTimeline == null || activeTimeline.savedObjectId == null) && initialTimelineSourcerer.current ) { initialTimelineSourcerer.current = false; @@ -124,15 +122,14 @@ export const useInitSourcerer = ( export const useSourcererScope = (scope: SourcererScopeName = SourcererScopeName.default) => { const sourcererScopeSelector = useMemo(() => sourcererSelectors.getSourcererScopeSelector(), []); - const SourcererScope = useDeepEqualSelector((state) => sourcererScopeSelector(state, scope)); - return SourcererScope; + return useDeepEqualSelector((state) => sourcererScopeSelector(state, scope)); }; export const getScopeFromPath = ( pathname: string ): SourcererScopeName.default | SourcererScopeName.detections => { return matchPath(pathname, { - path: [ALERTS_PATH, `${RULES_PATH}/id/:id`], + path: [ALERTS_PATH, `${RULES_PATH}/id/:id`, `${UEBA_PATH}/:id`], strict: false, }) == null ? SourcererScopeName.default diff --git a/x-pack/plugins/security_solution/public/common/hooks/use_experimental_features.ts b/x-pack/plugins/security_solution/public/common/hooks/use_experimental_features.ts index 247b7624914cf..9a6b8c54f2bc6 100644 --- a/x-pack/plugins/security_solution/public/common/hooks/use_experimental_features.ts +++ b/x-pack/plugins/security_solution/public/common/hooks/use_experimental_features.ts @@ -14,8 +14,8 @@ import { const allowedExperimentalValues = getExperimentalAllowedValues(); -export const useIsExperimentalFeatureEnabled = (feature: keyof ExperimentalFeatures): boolean => { - return useSelector(({ app: { enableExperimental } }: State) => { +export const useIsExperimentalFeatureEnabled = (feature: keyof ExperimentalFeatures): boolean => + useSelector(({ app: { enableExperimental } }: State) => { if (!enableExperimental || !(feature in enableExperimental)) { throw new Error( `Invalid enable value ${feature}. Allowed values are: ${allowedExperimentalValues.join( @@ -25,4 +25,3 @@ export const useIsExperimentalFeatureEnabled = (feature: keyof ExperimentalFeatu } return enableExperimental[feature]; }); -}; diff --git a/x-pack/plugins/security_solution/public/common/mock/endpoint/app_context_render.tsx b/x-pack/plugins/security_solution/public/common/mock/endpoint/app_context_render.tsx index 44a100e27e95b..f8a77d97b8700 100644 --- a/x-pack/plugins/security_solution/public/common/mock/endpoint/app_context_render.tsx +++ b/x-pack/plugins/security_solution/public/common/mock/endpoint/app_context_render.tsx @@ -172,7 +172,7 @@ const createCoreStartMock = ( ): ReturnType => { const coreStart = coreMock.createStart({ basePath: '/mock' }); - const deepLinkPaths = getDeepLinkPaths(getDeepLinks()); + const deepLinkPaths = getDeepLinkPaths(getDeepLinks(mockGlobalState.app.enableExperimental)); // Mock the certain APP Ids returned by `application.getUrlForApp()` coreStart.application.getUrlForApp.mockImplementation((appId, { deepLinkId, path } = {}) => { diff --git a/x-pack/plugins/security_solution/public/common/mock/global_state.ts b/x-pack/plugins/security_solution/public/common/mock/global_state.ts index ffbfd1a5123ad..8130a7058700d 100644 --- a/x-pack/plugins/security_solution/public/common/mock/global_state.ts +++ b/x-pack/plugins/security_solution/public/common/mock/global_state.ts @@ -13,6 +13,9 @@ import { NetworkTopTablesFields, NetworkTlsFields, NetworkUsersFields, + RiskScoreFields, + HostRulesFields, + HostTacticsFields, } from '../../../common/search_strategy'; import { State } from '../store'; @@ -25,12 +28,14 @@ import { DEFAULT_INDEX_PATTERN, } from '../../../common/constants'; import { networkModel } from '../../network/store'; +import { uebaModel } from '../../ueba/store'; import { TimelineType, TimelineStatus, TimelineTabs } from '../../../common/types/timeline'; import { mockManagementState } from '../../management/store/reducer'; import { ManagementState } from '../../management/types'; import { initialSourcererState, SourcererScopeName } from '../store/sourcerer/model'; import { mockBrowserFields, mockDocValueFields } from '../containers/source/mock'; import { mockIndexPattern } from './index_pattern'; +import { allowedExperimentalValues } from '../../../common/experimental_features'; export const mockGlobalState: State = { app: { @@ -39,12 +44,7 @@ export const mockGlobalState: State = { { id: 'error-id-1', title: 'title-1', message: ['error-message-1'] }, { id: 'error-id-2', title: 'title-2', message: ['error-message-2'] }, ], - enableExperimental: { - trustedAppsByPolicyEnabled: false, - metricsEntitiesEnabled: false, - ruleRegistryEnabled: false, - tGridEnabled: false, - }, + enableExperimental: allowedExperimentalValues, }, hosts: { page: { @@ -164,6 +164,36 @@ export const mockGlobalState: State = { }, }, }, + ueba: { + page: { + queries: { + [uebaModel.UebaTableType.riskScore]: { + activePage: 0, + limit: 10, + sort: { field: RiskScoreFields.riskScore, direction: Direction.desc }, + }, + }, + }, + details: { + queries: { + [uebaModel.UebaTableType.hostRules]: { + activePage: 0, + limit: 10, + sort: { field: HostRulesFields.riskScore, direction: Direction.desc }, + }, + [uebaModel.UebaTableType.hostTactics]: { + activePage: 0, + limit: 10, + sort: { field: HostTacticsFields.riskScore, direction: Direction.desc }, + }, + [uebaModel.UebaTableType.userRules]: { + activePage: 0, + limit: 10, + sort: { field: HostRulesFields.riskScore, direction: Direction.desc }, + }, + }, + }, + }, inputs: { global: { timerange: { diff --git a/x-pack/plugins/security_solution/public/common/mock/utils.ts b/x-pack/plugins/security_solution/public/common/mock/utils.ts index e0f8e651a5821..0d9e2f4f367ec 100644 --- a/x-pack/plugins/security_solution/public/common/mock/utils.ts +++ b/x-pack/plugins/security_solution/public/common/mock/utils.ts @@ -12,6 +12,7 @@ import { tGridReducer } from '../../../../timelines/public'; import { hostsReducer } from '../../hosts/store'; import { networkReducer } from '../../network/store'; +import { uebaReducer } from '../../ueba/store'; import { timelineReducer } from '../../timelines/store/timeline/reducer'; import { managementReducer } from '../../management/store/reducer'; import { ManagementPluginReducer } from '../../management'; @@ -52,6 +53,7 @@ const combineTimelineReducer = reduceReducers( export const SUB_PLUGINS_REDUCER: SubPluginsInitReducer = { hosts: hostsReducer, network: networkReducer, + ueba: uebaReducer, timeline: combineTimelineReducer, /** * These state's are wrapped in `Immutable`, but for compatibility with the overall app architecture, diff --git a/x-pack/plugins/security_solution/public/common/store/app/model.ts b/x-pack/plugins/security_solution/public/common/store/app/model.ts index 2888867167c14..2c4ddb703f6a0 100644 --- a/x-pack/plugins/security_solution/public/common/store/app/model.ts +++ b/x-pack/plugins/security_solution/public/common/store/app/model.ts @@ -27,5 +27,5 @@ export type ErrorModel = Error[]; export interface AppModel { notesById: NotesById; errors: ErrorState; - enableExperimental?: ExperimentalFeatures; + enableExperimental: ExperimentalFeatures; } diff --git a/x-pack/plugins/security_solution/public/common/store/app/reducer.ts b/x-pack/plugins/security_solution/public/common/store/app/reducer.ts index 20c9b0e14dbd9..5b0a2330a408d 100644 --- a/x-pack/plugins/security_solution/public/common/store/app/reducer.ts +++ b/x-pack/plugins/security_solution/public/common/store/app/reducer.ts @@ -17,6 +17,13 @@ export type AppState = AppModel; export const initialAppState: AppState = { notesById: {}, errors: [], + enableExperimental: { + trustedAppsByPolicyEnabled: false, + metricsEntitiesEnabled: false, + ruleRegistryEnabled: false, + tGridEnabled: false, + uebaEnabled: false, + }, }; interface UpdateNotesByIdParams { diff --git a/x-pack/plugins/security_solution/public/common/store/reducer.ts b/x-pack/plugins/security_solution/public/common/store/reducer.ts index c2ef2563fe63e..d5633ee84d6d4 100644 --- a/x-pack/plugins/security_solution/public/common/store/reducer.ts +++ b/x-pack/plugins/security_solution/public/common/store/reducer.ts @@ -14,6 +14,7 @@ import { sourcererReducer, sourcererModel } from './sourcerer'; import { HostsPluginReducer } from '../../hosts/store'; import { NetworkPluginReducer } from '../../network/store'; +import { UebaPluginReducer } from '../../ueba/store'; import { TimelinePluginReducer } from '../../timelines/store/timeline'; import { SecuritySubPlugins } from '../../app/types'; @@ -24,6 +25,7 @@ import { KibanaIndexPatterns } from './sourcerer/model'; import { ExperimentalFeatures } from '../../../common/experimental_features'; export type SubPluginsInitReducer = HostsPluginReducer & + UebaPluginReducer & NetworkPluginReducer & TimelinePluginReducer & ManagementPluginReducer; diff --git a/x-pack/plugins/security_solution/public/common/store/types.ts b/x-pack/plugins/security_solution/public/common/store/types.ts index 21e833abe1f9b..6943b4cf73117 100644 --- a/x-pack/plugins/security_solution/public/common/store/types.ts +++ b/x-pack/plugins/security_solution/public/common/store/types.ts @@ -18,10 +18,12 @@ import { HostsPluginState } from '../../hosts/store'; import { DragAndDropState } from './drag_and_drop/reducer'; import { TimelinePluginState } from '../../timelines/store/timeline'; import { NetworkPluginState } from '../../network/store'; +import { UebaPluginState } from '../../ueba/store'; import { ManagementPluginState } from '../../management'; export type StoreState = HostsPluginState & NetworkPluginState & + UebaPluginState & TimelinePluginState & ManagementPluginState & { app: AppState; diff --git a/x-pack/plugins/security_solution/public/common/utils/route/types.ts b/x-pack/plugins/security_solution/public/common/utils/route/types.ts index 189e68d1c55bb..c6d5852881850 100644 --- a/x-pack/plugins/security_solution/public/common/utils/route/types.ts +++ b/x-pack/plugins/security_solution/public/common/utils/route/types.ts @@ -15,8 +15,14 @@ import { HostsTableType } from '../../../hosts/store/model'; import { NetworkRouteType } from '../../../network/pages/navigation/types'; import { AdministrationSubTab as AdministrationType } from '../../../management/types'; import { FlowTarget } from '../../../../common/search_strategy'; +import { UebaTableType } from '../../../ueba/store/model'; -export type SiemRouteType = HostsTableType | NetworkRouteType | TimelineType | AdministrationType; +export type SiemRouteType = + | HostsTableType + | NetworkRouteType + | TimelineType + | AdministrationType + | UebaTableType; export interface RouteSpyState { pageName: string; detailName: string | undefined; @@ -32,6 +38,9 @@ export interface HostRouteSpyState extends RouteSpyState { tabName: HostsTableType | undefined; } +export interface UebaRouteSpyState extends RouteSpyState { + tabName: UebaTableType | undefined; +} export interface NetworkRouteSpyState extends RouteSpyState { tabName: NetworkRouteType | undefined; } diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.tsx index 9f59e3763ffbc..b1881d29ec10d 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.tsx @@ -23,7 +23,10 @@ import type { ExceptionListType } from '@kbn/securitysolution-io-ts-list-types'; import { buildGetAlertByIdQuery } from '../../../../common/components/exceptions/helpers'; import { useAppToasts } from '../../../../common/hooks/use_app_toasts'; import { TimelineId } from '../../../../../common/types/timeline'; -import { DEFAULT_INDEX_PATTERN } from '../../../../../common/constants'; +import { + DEFAULT_INDEX_PATTERN, + DEFAULT_INDEX_PATTERN_EXPERIMENTAL, +} from '../../../../../common/constants'; import { Status } from '../../../../../common/detection_engine/schemas/common/schemas'; import { timelineActions } from '../../../../timelines/store/timeline'; import { EventsTdContent } from '../../../../timelines/components/timeline/styles'; @@ -49,6 +52,7 @@ import { AlertData, EcsHit } from '../../../../common/components/exceptions/type import { useQueryAlerts } from '../../../containers/detection_engine/alerts/use_query'; import { useSignalIndex } from '../../../containers/detection_engine/alerts/use_signal_index'; import { EventFiltersModal } from '../../../../management/pages/event_filters/view/components/modal'; +import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features'; interface AlertContextMenuProps { ariaLabel?: string; @@ -84,6 +88,8 @@ const AlertContextMenuComponent: React.FC = ({ [ecsRowData] ); + // TODO: Steph/ueba remove when past experimental + const uebaEnabled = useIsExperimentalFeatureEnabled('uebaEnabled'); const isEvent = useMemo(() => indexOf(ecsRowData.event?.kind, 'event') !== -1, [ecsRowData]); const ruleIndices = useMemo((): string[] => { if ( @@ -93,9 +99,11 @@ const AlertContextMenuComponent: React.FC = ({ ) { return ecsRowData.signal.rule.index; } else { - return DEFAULT_INDEX_PATTERN; + return uebaEnabled + ? [...DEFAULT_INDEX_PATTERN, ...DEFAULT_INDEX_PATTERN_EXPERIMENTAL] + : DEFAULT_INDEX_PATTERN; } - }, [ecsRowData]); + }, [ecsRowData.signal?.rule, uebaEnabled]); const { addWarning } = useAppToasts(); diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/index.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/index.tsx index 66f62ad3ebeab..8770e59e0c178 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/index.tsx @@ -86,7 +86,11 @@ import { SecurityPageName } from '../../../../../app/types'; import { LinkButton } from '../../../../../common/components/links'; import { useFormatUrl } from '../../../../../common/components/link_to'; import { ExceptionsViewer } from '../../../../../common/components/exceptions/viewer'; -import { APP_ID, DEFAULT_INDEX_PATTERN } from '../../../../../../common/constants'; +import { + APP_ID, + DEFAULT_INDEX_PATTERN, + DEFAULT_INDEX_PATTERN_EXPERIMENTAL, +} from '../../../../../../common/constants'; import { useGlobalFullScreen } from '../../../../../common/containers/use_full_screen'; import { Display } from '../../../../../hosts/pages/display'; @@ -227,6 +231,9 @@ const RuleDetailsPageComponent = () => { // TODO: Once we are past experimental phase this code should be removed const ruleRegistryEnabled = useIsExperimentalFeatureEnabled('ruleRegistryEnabled'); + // TODO: Steph/ueba remove when past experimental + const uebaEnabled = useIsExperimentalFeatureEnabled('uebaEnabled'); + // TODO: Refactor license check + hasMlAdminPermissions to common check const hasMlPermissions = hasMlLicense(mlCapabilities) && hasMlAdminPermissions(mlCapabilities); const { @@ -348,7 +355,14 @@ const RuleDetailsPageComponent = () => { ), [ruleDetailTab, setRuleDetailTab] ); - + const ruleIndices = useMemo( + () => + rule?.index ?? + (uebaEnabled + ? [...DEFAULT_INDEX_PATTERN, ...DEFAULT_INDEX_PATTERN_EXPERIMENTAL] + : DEFAULT_INDEX_PATTERN), + [rule?.index, uebaEnabled] + ); const handleRefresh = useCallback(() => { if (fetchRuleStatus != null && ruleId != null) { fetchRuleStatus(ruleId); @@ -732,7 +746,7 @@ const RuleDetailsPageComponent = () => { ( export const isDetectionsPath = (pathname: string): boolean => { return !!matchPath(pathname, { - path: `(${ALERTS_PATH}|${RULES_PATH}|${EXCEPTIONS_PATH})`, + path: `(${ALERTS_PATH}|${RULES_PATH}|${UEBA_PATH}|${EXCEPTIONS_PATH})`, strict: false, }); }; diff --git a/x-pack/plugins/security_solution/public/lazy_sub_plugins.tsx b/x-pack/plugins/security_solution/public/lazy_sub_plugins.tsx index 47026cbec49ad..430c77b9422d8 100644 --- a/x-pack/plugins/security_solution/public/lazy_sub_plugins.tsx +++ b/x-pack/plugins/security_solution/public/lazy_sub_plugins.tsx @@ -16,6 +16,7 @@ import { Exceptions } from './exceptions'; import { Hosts } from './hosts'; import { Network } from './network'; +import { Ueba } from './ueba'; import { Overview } from './overview'; import { Rules } from './rules'; @@ -31,6 +32,7 @@ const subPluginClasses = { Exceptions, Hosts, Network, + Ueba, Overview, Rules, Timelines, diff --git a/x-pack/plugins/security_solution/public/plugin.tsx b/x-pack/plugins/security_solution/public/plugin.tsx index 137fef1641501..ee5ca84c6e13f 100644 --- a/x-pack/plugins/security_solution/public/plugin.tsx +++ b/x-pack/plugins/security_solution/public/plugin.tsx @@ -58,16 +58,21 @@ import { SecuritySolutionUiConfigType } from './common/types'; import { getLazyEndpointPolicyEditExtension } from './management/pages/policy/view/ingest_manager_integration/lazy_endpoint_policy_edit_extension'; import { LazyEndpointPolicyCreateExtension } from './management/pages/policy/view/ingest_manager_integration/lazy_endpoint_policy_create_extension'; import { getLazyEndpointPackageCustomExtension } from './management/pages/policy/view/ingest_manager_integration/lazy_endpoint_package_custom_extension'; -import { parseExperimentalConfigValue } from '../common/experimental_features'; +import { + ExperimentalFeatures, + parseExperimentalConfigValue, +} from '../common/experimental_features'; import type { TimelineState } from '../../timelines/public'; import { LazyEndpointCustomAssetsExtension } from './management/pages/policy/view/ingest_manager_integration/lazy_endpoint_custom_assets_extension'; export class Plugin implements IPlugin { - private kibanaVersion: string; + readonly kibanaVersion: string; private config: SecuritySolutionUiConfigType; + readonly experimentalFeatures: ExperimentalFeatures; constructor(private readonly initializerContext: PluginInitializerContext) { this.config = this.initializerContext.config.get(); + this.experimentalFeatures = parseExperimentalConfigValue(this.config.enableExperimental || []); this.kibanaVersion = initializerContext.env.packageInfo.version; } private appUpdater$ = new Subject(); @@ -151,7 +156,7 @@ export class Plugin implements IPlugin { const [coreStart, startPlugins] = await core.getStartServices(); const subPlugins = await this.startSubPlugins(this.storage, coreStart, startPlugins); @@ -231,7 +236,11 @@ export class Plugin implements IPlugin ({ navLinkStatus: AppNavLinkStatus.hidden, // workaround to prevent main navLink to switch to visible after update. should not be needed - deepLinks: getDeepLinks(currentLicense.type, core.application.capabilities), + deepLinks: getDeepLinks( + this.experimentalFeatures, + currentLicense.type, + core.application.capabilities + ), })); } }); @@ -239,6 +248,7 @@ export class Plugin implements IPlugin { if (!this._store) { - const experimentalFeatures = parseExperimentalConfigValue( - this.config.enableExperimental || [] - ); const defaultIndicesName = coreStart.uiSettings.get(DEFAULT_INDEX_KEY); const [ { createStore, createInitialState }, @@ -359,7 +370,7 @@ export class Plugin implements IPlugin; hosts: ReturnType; network: ReturnType; + // TODO: Steph/ueba require ueba once no longer experimental + ueba?: ReturnType; overview: ReturnType; timelines: ReturnType; management: ReturnType; diff --git a/x-pack/plugins/security_solution/public/ueba/components/host_rules_table/columns.tsx b/x-pack/plugins/security_solution/public/ueba/components/host_rules_table/columns.tsx new file mode 100644 index 0000000000000..4289b7d2c62da --- /dev/null +++ b/x-pack/plugins/security_solution/public/ueba/components/host_rules_table/columns.tsx @@ -0,0 +1,145 @@ +/* + * 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 { + DragEffects, + DraggableWrapper, +} from '../../../common/components/drag_and_drop/draggable_wrapper'; +import { escapeDataProviderId } from '../../../common/components/drag_and_drop/helpers'; +import { getEmptyTagValue } from '../../../common/components/empty_value'; +import { IS_OPERATOR } from '../../../timelines/components/timeline/data_providers/data_provider'; +import { Provider } from '../../../timelines/components/timeline/data_providers/provider'; +import { HostRulesColumns } from './'; + +import * as i18n from './translations'; +import { HostRulesFields } from '../../../../common'; + +export const getHostRulesColumns = (): HostRulesColumns => [ + { + field: `node.${HostRulesFields.ruleName}`, + name: i18n.NAME, + truncateText: false, + hideForMobile: false, + render: (ruleName) => { + if (ruleName != null && ruleName.length > 0) { + const id = escapeDataProviderId(`ueba-table-ruleName-${ruleName}`); + return ( + + snapshot.isDragging ? ( + + + + ) : ( + ruleName + ) + } + /> + ); + } + return getEmptyTagValue(); + }, + }, + { + field: `node.${HostRulesFields.ruleType}`, + name: i18n.RULE_TYPE, + truncateText: false, + hideForMobile: false, + render: (ruleType) => { + if (ruleType != null && ruleType.length > 0) { + const id = escapeDataProviderId(`ueba-table-ruleType-${ruleType}`); + return ( + + snapshot.isDragging ? ( + + + + ) : ( + ruleType + ) + } + /> + ); + } + return getEmptyTagValue(); + }, + }, + { + field: `node.${HostRulesFields.riskScore}`, + name: i18n.RISK_SCORE, + truncateText: false, + hideForMobile: false, + render: (riskScore) => { + if (riskScore != null) { + const id = escapeDataProviderId(`ueba-table-riskScore-${riskScore}`); + return ( + + snapshot.isDragging ? ( + + + + ) : ( + riskScore + ) + } + /> + ); + } + return getEmptyTagValue(); + }, + }, + { + field: `node.${HostRulesFields.hits}`, + name: i18n.HITS, + truncateText: false, + hideForMobile: false, + sortable: false, + render: (hits) => { + if (hits != null) { + return hits; + } + return getEmptyTagValue(); + }, + }, +]; diff --git a/x-pack/plugins/security_solution/public/ueba/components/host_rules_table/index.tsx b/x-pack/plugins/security_solution/public/ueba/components/host_rules_table/index.tsx new file mode 100644 index 0000000000000..3d369a56a7bc0 --- /dev/null +++ b/x-pack/plugins/security_solution/public/ueba/components/host_rules_table/index.tsx @@ -0,0 +1,173 @@ +/* + * 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, { useMemo, useCallback } from 'react'; +import { useDispatch } from 'react-redux'; + +import { + Columns, + Criteria, + PaginatedTable, + SortingBasicTable, +} from '../../../common/components/paginated_table'; +import { useDeepEqualSelector } from '../../../common/hooks/use_selector'; +import { uebaActions, uebaModel, uebaSelectors } from '../../store'; +import { getHostRulesColumns } from './columns'; +import * as i18n from './translations'; +import { + HostRulesEdges, + HostRulesItem, + HostRulesSortField, + HostRulesFields, +} from '../../../../common'; +import { Direction } from '../../../../common/search_strategy'; +import { HOST_RULES } from '../../pages/translations'; +import { rowItems } from '../utils'; + +interface HostRulesTableProps { + data: HostRulesEdges[]; + fakeTotalCount: number; + headerTitle?: string; + headerSupplement?: React.ReactElement; + id: string; + isInspect: boolean; + loading: boolean; + loadPage: (newActivePage: number) => void; + showMorePagesIndicator: boolean; + totalCount: number; + type: uebaModel.UebaType; + tableType: uebaModel.UebaTableType.hostRules | uebaModel.UebaTableType.userRules; +} + +export type HostRulesColumns = [ + Columns, + Columns, + Columns, + Columns +]; + +const getSorting = (sortField: HostRulesFields, direction: Direction): SortingBasicTable => ({ + field: getNodeField(sortField), + direction, +}); + +const HostRulesTableComponent: React.FC = ({ + data, + fakeTotalCount, + headerTitle, + headerSupplement, + id, + isInspect, + loading, + loadPage, + showMorePagesIndicator, + tableType, + totalCount, + type, +}) => { + const dispatch = useDispatch(); + const { activePage, limit, sort } = useDeepEqualSelector(uebaSelectors.hostRulesSelector()); + const updateLimitPagination = useCallback( + (newLimit) => + dispatch( + uebaActions.updateTableLimit({ + uebaType: type, + limit: newLimit, + tableType, + }) + ), + [tableType, type, dispatch] + ); + + const updateActivePage = useCallback( + (newPage) => + dispatch( + uebaActions.updateTableActivePage({ + activePage: newPage, + uebaType: type, + tableType, // this will need to become unique for each user table in the group + }) + ), + [tableType, type, dispatch] + ); + + const onChange = useCallback( + (criteria: Criteria) => { + if (criteria.sort != null) { + const newSort: HostRulesSortField = { + field: getSortField(criteria.sort.field), + direction: criteria.sort.direction as Direction, + }; + if (newSort.direction !== sort.direction || newSort.field !== sort.field) { + // dispatch( + // uebaActions.updateHostRulesSort({ + // sort, + // uebaType: type, + // }) + // ); TODO: Steph/ueba implement sorting + } + } + }, + [sort] + ); + + const columns = useMemo(() => getHostRulesColumns(), []); + + const sorting = useMemo(() => getSorting(sort.field, sort.direction), [sort]); + const headerProps = useMemo( + () => + tableType === uebaModel.UebaTableType.userRules && headerTitle && headerSupplement + ? { + headerTitle, + headerSupplement, + } + : { headerTitle: HOST_RULES }, + [headerSupplement, headerTitle, tableType] + ); + return ( + + ); +}; + +HostRulesTableComponent.displayName = 'HostRulesTableComponent'; + +const getSortField = (field: string): HostRulesFields => { + switch (field) { + case `node.${HostRulesFields.ruleName}`: + return HostRulesFields.ruleName; + case `node.${HostRulesFields.riskScore}`: + return HostRulesFields.riskScore; + default: + return HostRulesFields.riskScore; + } +}; + +const getNodeField = (field: HostRulesFields): string => `node.${field}`; + +export const HostRulesTable = React.memo(HostRulesTableComponent); + +HostRulesTable.displayName = 'HostRulesTable'; diff --git a/x-pack/plugins/security_solution/public/ueba/components/host_rules_table/translations.ts b/x-pack/plugins/security_solution/public/ueba/components/host_rules_table/translations.ts new file mode 100644 index 0000000000000..f029910b9714b --- /dev/null +++ b/x-pack/plugins/security_solution/public/ueba/components/host_rules_table/translations.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 { i18n } from '@kbn/i18n'; + +export const UNIT = (totalCount: number) => + i18n.translate('xpack.securitySolution.uebaTableHostRules.unit', { + values: { totalCount }, + defaultMessage: `{totalCount, plural, =1 {rule} other {rules}}`, + }); + +export const NAME = i18n.translate('xpack.securitySolution.uebaTableHostRules.ruleName', { + defaultMessage: 'Rule name', +}); + +export const RISK_SCORE = i18n.translate( + 'xpack.securitySolution.uebaTableHostRules.totalRiskScore', + { + defaultMessage: 'Total risk score', + } +); + +export const RULE_TYPE = i18n.translate('xpack.securitySolution.uebaTableHostRules.ruleType', { + defaultMessage: 'Rule type', +}); + +export const HITS = i18n.translate('xpack.securitySolution.uebaTableHostRules.hits', { + defaultMessage: 'Number of hits', +}); diff --git a/x-pack/plugins/security_solution/public/ueba/components/host_tactics_table/columns.tsx b/x-pack/plugins/security_solution/public/ueba/components/host_tactics_table/columns.tsx new file mode 100644 index 0000000000000..19516ad6fcafa --- /dev/null +++ b/x-pack/plugins/security_solution/public/ueba/components/host_tactics_table/columns.tsx @@ -0,0 +1,153 @@ +/* + * 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 { + DragEffects, + DraggableWrapper, +} from '../../../common/components/drag_and_drop/draggable_wrapper'; +import { escapeDataProviderId } from '../../../common/components/drag_and_drop/helpers'; +import { getEmptyTagValue } from '../../../common/components/empty_value'; +import { IS_OPERATOR } from '../../../timelines/components/timeline/data_providers/data_provider'; +import { Provider } from '../../../timelines/components/timeline/data_providers/provider'; +import { HostTacticsColumns } from './'; + +import * as i18n from './translations'; +import { HostTacticsFields } from '../../../../common'; + +export const getHostTacticsColumns = (): HostTacticsColumns => [ + { + field: `node.${HostTacticsFields.tactic}`, + name: i18n.TACTIC, + truncateText: false, + hideForMobile: false, + render: (tactic) => { + if (tactic != null && tactic.length > 0) { + const id = escapeDataProviderId(`ueba-table-tactic-${tactic}`); + return ( + + snapshot.isDragging ? ( + + + + ) : ( + tactic + ) + } + /> + ); + } + return getEmptyTagValue(); + }, + }, + { + field: `node.${HostTacticsFields.technique}`, + name: i18n.TECHNIQUE, + truncateText: false, + hideForMobile: false, + render: (technique) => { + if (technique != null && technique.length > 0) { + const id = escapeDataProviderId(`ueba-table-technique-${technique}`); + return ( + + snapshot.isDragging ? ( + + + + ) : ( + technique + ) + } + /> + ); + } + return getEmptyTagValue(); + }, + }, + { + field: `node.${HostTacticsFields.riskScore}`, + name: i18n.RISK_SCORE, + truncateText: false, + hideForMobile: false, + render: (riskScore) => { + if (riskScore != null) { + const id = escapeDataProviderId(`ueba-table-riskScore-${riskScore}`); + return ( + + snapshot.isDragging ? ( + + + + ) : ( + riskScore + ) + } + /> + ); + } + return getEmptyTagValue(); + }, + }, + { + field: `node.${HostTacticsFields.hits}`, + name: i18n.HITS, + truncateText: false, + hideForMobile: false, + sortable: false, + render: (hits) => { + if (hits != null) { + return hits; + } + return getEmptyTagValue(); + }, + }, +]; diff --git a/x-pack/plugins/security_solution/public/ueba/components/host_tactics_table/index.tsx b/x-pack/plugins/security_solution/public/ueba/components/host_tactics_table/index.tsx new file mode 100644 index 0000000000000..28bd3d6ad43a0 --- /dev/null +++ b/x-pack/plugins/security_solution/public/ueba/components/host_tactics_table/index.tsx @@ -0,0 +1,161 @@ +/* + * 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, { useMemo, useCallback } from 'react'; +import { useDispatch } from 'react-redux'; + +import { + Columns, + Criteria, + PaginatedTable, + SortingBasicTable, +} from '../../../common/components/paginated_table'; +import { useDeepEqualSelector } from '../../../common/hooks/use_selector'; +import { uebaActions, uebaModel, uebaSelectors } from '../../store'; +import { getHostTacticsColumns } from './columns'; +import * as i18n from './translations'; +import { + HostTacticsEdges, + HostTacticsItem, + HostTacticsSortField, + HostTacticsFields, +} from '../../../../common'; +import { Direction } from '../../../../common/search_strategy'; +import { HOST_TACTICS } from '../../pages/translations'; +import { rowItems } from '../utils'; + +const tableType = uebaModel.UebaTableType.hostTactics; + +interface HostTacticsTableProps { + data: HostTacticsEdges[]; + fakeTotalCount: number; + id: string; + isInspect: boolean; + loading: boolean; + loadPage: (newActivePage: number) => void; + showMorePagesIndicator: boolean; + techniqueCount: number; + totalCount: number; + type: uebaModel.UebaType; +} + +export type HostTacticsColumns = [ + Columns, + Columns, + Columns, + Columns +]; + +const getSorting = (sortField: HostTacticsFields, direction: Direction): SortingBasicTable => ({ + field: getNodeField(sortField), + direction, +}); + +const HostTacticsTableComponent: React.FC = ({ + data, + fakeTotalCount, + id, + isInspect, + loading, + loadPage, + showMorePagesIndicator, + techniqueCount, + totalCount, + type, +}) => { + const dispatch = useDispatch(); + const { activePage, limit, sort } = useDeepEqualSelector(uebaSelectors.hostTacticsSelector()); + const updateLimitPagination = useCallback( + (newLimit) => + dispatch( + uebaActions.updateTableLimit({ + uebaType: type, + limit: newLimit, + tableType, + }) + ), + [type, dispatch] + ); + + const updateActivePage = useCallback( + (newPage) => + dispatch( + uebaActions.updateTableActivePage({ + activePage: newPage, + uebaType: type, + tableType, // this will need to become unique for each user table in the group + }) + ), + [type, dispatch] + ); + + const onChange = useCallback( + (criteria: Criteria) => { + if (criteria.sort != null) { + const newSort: HostTacticsSortField = { + field: getSortField(criteria.sort.field), + direction: criteria.sort.direction as Direction, + }; + if (newSort.direction !== sort.direction || newSort.field !== sort.field) { + // dispatch( + // uebaActions.updateHostTacticsSort({ + // sort, + // uebaType: type, + // }) + // ); TODO: Steph/ueba implement sorting + } + } + }, + [sort] + ); + + const columns = useMemo(() => getHostTacticsColumns(), []); + + const sorting = useMemo(() => getSorting(sort.field, sort.direction), [sort]); + return ( + + ); +}; + +HostTacticsTableComponent.displayName = 'HostTacticsTableComponent'; + +const getSortField = (field: string): HostTacticsFields => { + switch (field) { + case `node.${HostTacticsFields.tactic}`: + return HostTacticsFields.tactic; + case `node.${HostTacticsFields.riskScore}`: + return HostTacticsFields.riskScore; + default: + return HostTacticsFields.riskScore; + } +}; + +const getNodeField = (field: HostTacticsFields): string => `node.${field}`; + +export const HostTacticsTable = React.memo(HostTacticsTableComponent); + +HostTacticsTable.displayName = 'HostTacticsTable'; diff --git a/x-pack/plugins/security_solution/public/ueba/components/host_tactics_table/translations.ts b/x-pack/plugins/security_solution/public/ueba/components/host_tactics_table/translations.ts new file mode 100644 index 0000000000000..98cd53a59e5f3 --- /dev/null +++ b/x-pack/plugins/security_solution/public/ueba/components/host_tactics_table/translations.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 { i18n } from '@kbn/i18n'; + +export const COUNT = (totalCount: number, techniqueCount: number) => + i18n.translate('xpack.securitySolution.uebaTableHostTactics.tacticTechnique', { + values: { techniqueCount, totalCount }, + defaultMessage: `{totalCount} {totalCount, plural, =1 {tactic} other {tactics}} with {techniqueCount} {techniqueCount, plural, =1 {technique} other {techniques}}`, + }); + +export const TACTIC = i18n.translate('xpack.securitySolution.uebaTableHostTactics.tactic', { + defaultMessage: 'Tactic', +}); + +export const RISK_SCORE = i18n.translate( + 'xpack.securitySolution.uebaTableHostTactics.totalRiskScore', + { + defaultMessage: 'Total risk score', + } +); + +export const TECHNIQUE = i18n.translate('xpack.securitySolution.uebaTableHostTactics.technique', { + defaultMessage: 'Technique', +}); + +export const HITS = i18n.translate('xpack.securitySolution.uebaTableHostTactics.hits', { + defaultMessage: 'Number of hits', +}); diff --git a/x-pack/plugins/security_solution/public/ueba/components/risk_score_table/columns.tsx b/x-pack/plugins/security_solution/public/ueba/components/risk_score_table/columns.tsx new file mode 100644 index 0000000000000..b751521001fe5 --- /dev/null +++ b/x-pack/plugins/security_solution/public/ueba/components/risk_score_table/columns.tsx @@ -0,0 +1,79 @@ +/* + * 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 { + DragEffects, + DraggableWrapper, +} from '../../../common/components/drag_and_drop/draggable_wrapper'; +import { escapeDataProviderId } from '../../../common/components/drag_and_drop/helpers'; +import { getEmptyTagValue } from '../../../common/components/empty_value'; +import { UebaDetailsLink } from '../../../common/components/links'; +import { IS_OPERATOR } from '../../../timelines/components/timeline/data_providers/data_provider'; +import { Provider } from '../../../timelines/components/timeline/data_providers/provider'; +import { + AddFilterToGlobalSearchBar, + createFilter, +} from '../../../common/components/add_filter_to_global_search_bar'; +import { RiskScoreColumns } from './'; + +import * as i18n from './translations'; +export const getRiskScoreColumns = (): RiskScoreColumns => [ + { + field: 'node.host_name', + name: i18n.NAME, + truncateText: false, + hideForMobile: false, + sortable: true, + render: (hostName) => { + if (hostName != null && hostName.length > 0) { + const id = escapeDataProviderId(`ueba-table-hostName-${hostName}`); + return ( + + snapshot.isDragging ? ( + + + + ) : ( + + ) + } + /> + ); + } + return getEmptyTagValue(); + }, + }, + { + field: 'node.risk_keyword', + name: i18n.CURRENT_RISK, + truncateText: false, + hideForMobile: false, + sortable: false, + render: (riskKeyword) => { + if (riskKeyword != null) { + return ( + + <>{riskKeyword} + + ); + } + return getEmptyTagValue(); + }, + }, +]; diff --git a/x-pack/plugins/security_solution/public/ueba/components/risk_score_table/index.tsx b/x-pack/plugins/security_solution/public/ueba/components/risk_score_table/index.tsx new file mode 100644 index 0000000000000..9e9c6f81a43bb --- /dev/null +++ b/x-pack/plugins/security_solution/public/ueba/components/risk_score_table/index.tsx @@ -0,0 +1,157 @@ +/* + * 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, { useMemo, useCallback } from 'react'; +import { useDispatch } from 'react-redux'; + +import { + Columns, + Criteria, + PaginatedTable, + SortingBasicTable, +} from '../../../common/components/paginated_table'; +import { useDeepEqualSelector } from '../../../common/hooks/use_selector'; +import { uebaActions, uebaModel, uebaSelectors } from '../../store'; +import { getRiskScoreColumns } from './columns'; +import * as i18n from './translations'; +import { + RiskScoreEdges, + RiskScoreItem, + RiskScoreSortField, + RiskScoreFields, +} from '../../../../common'; +import { Direction } from '../../../../common/search_strategy'; +import { rowItems } from '../utils'; + +const tableType = uebaModel.UebaTableType.riskScore; + +interface RiskScoreTableProps { + data: RiskScoreEdges[]; + fakeTotalCount: number; + id: string; + isInspect: boolean; + loading: boolean; + loadPage: (newActivePage: number) => void; + showMorePagesIndicator: boolean; + totalCount: number; + type: uebaModel.UebaType; +} + +export type RiskScoreColumns = [ + Columns, + Columns +]; + +const getSorting = (sortField: RiskScoreFields, direction: Direction): SortingBasicTable => ({ + field: getNodeField(sortField), + direction, +}); + +const RiskScoreTableComponent: React.FC = ({ + data, + fakeTotalCount, + id, + isInspect, + loading, + loadPage, + showMorePagesIndicator, + totalCount, + type, +}) => { + const dispatch = useDispatch(); + const { activePage, limit, sort } = useDeepEqualSelector(uebaSelectors.riskScoreSelector()); + const updateLimitPagination = useCallback( + (newLimit) => + dispatch( + uebaActions.updateTableLimit({ + uebaType: type, + limit: newLimit, + tableType, + }) + ), + [type, dispatch] + ); + + const updateActivePage = useCallback( + (newPage) => + dispatch( + uebaActions.updateTableActivePage({ + activePage: newPage, + uebaType: type, + tableType, + }) + ), + [type, dispatch] + ); + + const onChange = useCallback( + (criteria: Criteria) => { + if (criteria.sort != null) { + const newSort: RiskScoreSortField = { + field: getSortField(criteria.sort.field), + direction: criteria.sort.direction as Direction, + }; + if (newSort.direction !== sort.direction || newSort.field !== sort.field) { + // dispatch( + // uebaActions.updateRiskScoreSort({ + // sort, + // uebaType: type, + // }) + // ); TODO: Steph/ueba implement sorting + } + } + }, + [sort] + ); + + const columns = useMemo(() => getRiskScoreColumns(), []); + + const sorting = useMemo(() => getSorting(sort.field, sort.direction), [sort]); + + return ( + + ); +}; + +RiskScoreTableComponent.displayName = 'RiskScoreTableComponent'; + +const getSortField = (field: string): RiskScoreFields => { + switch (field) { + case `node.${RiskScoreFields.hostName}`: + return RiskScoreFields.hostName; + case `node.${RiskScoreFields.riskScore}`: + return RiskScoreFields.riskScore; + default: + return RiskScoreFields.riskScore; + } +}; + +const getNodeField = (field: RiskScoreFields): string => `node.${field}`; + +export const RiskScoreTable = React.memo(RiskScoreTableComponent); + +RiskScoreTable.displayName = 'RiskScoreTable'; diff --git a/x-pack/plugins/security_solution/public/ueba/components/risk_score_table/translations.ts b/x-pack/plugins/security_solution/public/ueba/components/risk_score_table/translations.ts new file mode 100644 index 0000000000000..a4e7a3271d152 --- /dev/null +++ b/x-pack/plugins/security_solution/public/ueba/components/risk_score_table/translations.ts @@ -0,0 +1,29 @@ +/* + * 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 UNIT = (totalCount: number) => + i18n.translate('xpack.securitySolution.uebaTableRiskScore.unit', { + values: { totalCount }, + defaultMessage: `{totalCount, plural, =1 {user} other {users}}`, + }); + +export const NAME = i18n.translate('xpack.securitySolution.uebaTableRiskScore.nameTitle', { + defaultMessage: 'Host name', +}); + +export const RISK_SCORE = i18n.translate('xpack.securitySolution.uebaTableRiskScore.riskScore', { + defaultMessage: 'Risk score', +}); + +export const CURRENT_RISK = i18n.translate( + 'xpack.securitySolution.uebaTableRiskScore.currentRisk', + { + defaultMessage: 'Current risk', + } +); diff --git a/x-pack/plugins/security_solution/public/ueba/components/translations.ts b/x-pack/plugins/security_solution/public/ueba/components/translations.ts new file mode 100644 index 0000000000000..5775871a3fe4a --- /dev/null +++ b/x-pack/plugins/security_solution/public/ueba/components/translations.ts @@ -0,0 +1,18 @@ +/* + * 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 ROWS_5 = i18n.translate('xpack.securitySolution.uebaTable.rows', { + values: { numRows: 5 }, + defaultMessage: '{numRows} {numRows, plural, =0 {rows} =1 {row} other {rows}}', +}); + +export const ROWS_10 = i18n.translate('xpack.securitySolution.uebaTable.rows', { + values: { numRows: 10 }, + defaultMessage: '{numRows} {numRows, plural, =0 {rows} =1 {row} other {rows}}', +}); diff --git a/x-pack/plugins/security_solution/public/ueba/components/utils.ts b/x-pack/plugins/security_solution/public/ueba/components/utils.ts new file mode 100644 index 0000000000000..d12e66a5f6d7b --- /dev/null +++ b/x-pack/plugins/security_solution/public/ueba/components/utils.ts @@ -0,0 +1,20 @@ +/* + * 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 { ItemsPerRow } from '../../common/components/paginated_table'; +import * as i18n from './translations'; + +export const rowItems: ItemsPerRow[] = [ + { + text: i18n.ROWS_5, + numberOfRow: 5, + }, + { + text: i18n.ROWS_10, + numberOfRow: 10, + }, +]; diff --git a/x-pack/plugins/security_solution/public/ueba/containers/host_rules/index.tsx b/x-pack/plugins/security_solution/public/ueba/containers/host_rules/index.tsx new file mode 100644 index 0000000000000..7db1a77244bbe --- /dev/null +++ b/x-pack/plugins/security_solution/public/ueba/containers/host_rules/index.tsx @@ -0,0 +1,220 @@ +/* + * 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 deepEqual from 'fast-deep-equal'; +import { noop } from 'lodash/fp'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { Subscription } from 'rxjs'; + +import { inputsModel, State } from '../../../common/store'; +import { createFilter } from '../../../common/containers/helpers'; +import { useKibana } from '../../../common/lib/kibana'; +import { useDeepEqualSelector } from '../../../common/hooks/use_selector'; +import { uebaModel, uebaSelectors } from '../../store'; +import { generateTablePaginationOptions } from '../../../common/components/paginated_table/helpers'; +import { + HostRulesEdges, + PageInfoPaginated, + DocValueFields, + UebaQueries, + HostRulesRequestOptions, + HostRulesStrategyResponse, +} from '../../../../common'; +import { ESTermQuery } from '../../../../common/typed_json'; + +import * as i18n from './translations'; +import { isCompleteResponse, isErrorResponse } from '../../../../../../../src/plugins/data/common'; +import { getInspectResponse } from '../../../helpers'; +import { InspectResponse } from '../../../types'; +import { useTransforms } from '../../../transforms/containers/use_transforms'; +import { useAppToasts } from '../../../common/hooks/use_app_toasts'; + +export const ID = 'hostRulesQuery'; + +type LoadPage = (newActivePage: number) => void; +export interface HostRulesState { + data: HostRulesEdges[]; + endDate: string; + id: string; + inspect: InspectResponse; + isInspected: boolean; + loadPage: LoadPage; + pageInfo: PageInfoPaginated; + refetch: inputsModel.Refetch; + startDate: string; + totalCount: number; +} + +interface UseHostRules { + docValueFields?: DocValueFields[]; + endDate: string; + filterQuery?: ESTermQuery | string; + hostName: string; + indexNames: string[]; + skip?: boolean; + startDate: string; + type: uebaModel.UebaType; +} + +export const useHostRules = ({ + docValueFields, + endDate, + filterQuery, + hostName, + indexNames, + skip = false, + startDate, +}: UseHostRules): [boolean, HostRulesState] => { + const getHostRulesSelector = useMemo(() => uebaSelectors.hostRulesSelector(), []); + const { activePage, limit, sort } = useDeepEqualSelector((state: State) => + getHostRulesSelector(state) + ); + const { data } = useKibana().services; + const refetch = useRef(noop); + const abortCtrl = useRef(new AbortController()); + const searchSubscription = useRef(new Subscription()); + const [loading, setLoading] = useState(false); + const [hostRulesRequest, setHostRulesRequest] = useState(null); + const { getTransformChangesIfTheyExist } = useTransforms(); + const { addError, addWarning } = useAppToasts(); + + const wrappedLoadMore = useCallback( + (newActivePage: number) => { + setHostRulesRequest((prevRequest) => { + if (!prevRequest) { + return prevRequest; + } + + return { + ...prevRequest, + pagination: generateTablePaginationOptions(newActivePage, limit), + }; + }); + }, + [limit] + ); + + const [hostRulesResponse, setHostRulesResponse] = useState({ + data: [], + endDate, + id: ID, + inspect: { + dsl: [], + response: [], + }, + isInspected: false, + loadPage: wrappedLoadMore, + pageInfo: { + activePage: 0, + fakeTotalCount: 0, + showMorePagesIndicator: false, + }, + refetch: refetch.current, + startDate, + totalCount: -1, + }); + + const hostRulesSearch = useCallback( + (request: HostRulesRequestOptions | null) => { + if (request == null || skip) { + return; + } + + const asyncSearch = async () => { + abortCtrl.current = new AbortController(); + setLoading(true); + + searchSubscription.current = data.search + .search(request, { + strategy: 'securitySolutionSearchStrategy', + abortSignal: abortCtrl.current.signal, + }) + .subscribe({ + next: (response) => { + if (isCompleteResponse(response)) { + setHostRulesResponse((prevResponse) => ({ + ...prevResponse, + data: response.edges, + inspect: getInspectResponse(response, prevResponse.inspect), + pageInfo: response.pageInfo, + refetch: refetch.current, + totalCount: response.totalCount, + })); + searchSubscription.current.unsubscribe(); + } else if (isErrorResponse(response)) { + setLoading(false); + addWarning(i18n.ERROR_HOST_RULES); + searchSubscription.current.unsubscribe(); + } + }, + error: (msg) => { + setLoading(false); + addError(msg, { title: i18n.FAIL_HOST_RULES }); + searchSubscription.current.unsubscribe(); + }, + }); + setLoading(false); + }; + searchSubscription.current.unsubscribe(); + abortCtrl.current.abort(); + asyncSearch(); + refetch.current = asyncSearch; + }, + [data.search, addError, addWarning, skip] + ); + + useEffect(() => { + setHostRulesRequest((prevRequest) => { + const { indices, factoryQueryType, timerange } = getTransformChangesIfTheyExist({ + factoryQueryType: UebaQueries.hostRules, + indices: indexNames, + filterQuery, + timerange: { + interval: '12h', + from: startDate, + to: endDate, + }, + }); + const myRequest = { + ...(prevRequest ?? {}), + hostName, + defaultIndex: indices, + docValueFields: docValueFields ?? [], + factoryQueryType, + filterQuery: createFilter(filterQuery), + pagination: generateTablePaginationOptions(activePage, limit), + timerange, + sort, + }; + if (!deepEqual(prevRequest, myRequest)) { + return myRequest; + } + return prevRequest; + }); + }, [ + activePage, + docValueFields, + endDate, + filterQuery, + indexNames, + limit, + startDate, + sort, + getTransformChangesIfTheyExist, + hostName, + ]); + + useEffect(() => { + hostRulesSearch(hostRulesRequest); + return () => { + searchSubscription.current.unsubscribe(); + abortCtrl.current.abort(); + }; + }, [hostRulesRequest, hostRulesSearch]); + + return [loading, hostRulesResponse]; +}; diff --git a/x-pack/plugins/security_solution/public/ueba/containers/host_rules/translations.ts b/x-pack/plugins/security_solution/public/ueba/containers/host_rules/translations.ts new file mode 100644 index 0000000000000..6cf5521f4eaaa --- /dev/null +++ b/x-pack/plugins/security_solution/public/ueba/containers/host_rules/translations.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 { i18n } from '@kbn/i18n'; + +export const ERROR_HOST_RULES = i18n.translate( + 'xpack.securitySolution.hostRules.errorSearchDescription', + { + defaultMessage: `An error has occurred on risk score search`, + } +); + +export const FAIL_HOST_RULES = i18n.translate( + 'xpack.securitySolution.hostRules.failSearchDescription', + { + defaultMessage: `Failed to run search on risk score`, + } +); diff --git a/x-pack/plugins/security_solution/public/ueba/containers/host_tactics/index.tsx b/x-pack/plugins/security_solution/public/ueba/containers/host_tactics/index.tsx new file mode 100644 index 0000000000000..35dd2a0b08d4e --- /dev/null +++ b/x-pack/plugins/security_solution/public/ueba/containers/host_tactics/index.tsx @@ -0,0 +1,225 @@ +/* + * 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 deepEqual from 'fast-deep-equal'; +import { noop } from 'lodash/fp'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { Subscription } from 'rxjs'; + +import { inputsModel, State } from '../../../common/store'; +import { createFilter } from '../../../common/containers/helpers'; +import { useKibana } from '../../../common/lib/kibana'; +import { useDeepEqualSelector } from '../../../common/hooks/use_selector'; +import { uebaModel, uebaSelectors } from '../../store'; +import { generateTablePaginationOptions } from '../../../common/components/paginated_table/helpers'; +import { + HostTacticsEdges, + PageInfoPaginated, + DocValueFields, + UebaQueries, + HostTacticsRequestOptions, + HostTacticsStrategyResponse, +} from '../../../../common'; +import { ESTermQuery } from '../../../../common/typed_json'; + +import * as i18n from './translations'; +import { isCompleteResponse, isErrorResponse } from '../../../../../../../src/plugins/data/common'; +import { getInspectResponse } from '../../../helpers'; +import { InspectResponse } from '../../../types'; +import { useTransforms } from '../../../transforms/containers/use_transforms'; +import { useAppToasts } from '../../../common/hooks/use_app_toasts'; + +export const ID = 'hostTacticsQuery'; + +type LoadPage = (newActivePage: number) => void; +export interface HostTacticsState { + data: HostTacticsEdges[]; + endDate: string; + id: string; + inspect: InspectResponse; + isInspected: boolean; + loadPage: LoadPage; + pageInfo: PageInfoPaginated; + refetch: inputsModel.Refetch; + startDate: string; + techniqueCount: number; + totalCount: number; +} + +interface UseHostTactics { + docValueFields?: DocValueFields[]; + endDate: string; + filterQuery?: ESTermQuery | string; + hostName: string; + indexNames: string[]; + skip?: boolean; + startDate: string; + type: uebaModel.UebaType; +} + +export const useHostTactics = ({ + docValueFields, + endDate, + filterQuery, + hostName, + indexNames, + skip = false, + startDate, +}: UseHostTactics): [boolean, HostTacticsState] => { + const getHostTacticsSelector = useMemo(() => uebaSelectors.hostTacticsSelector(), []); + const { activePage, limit, sort } = useDeepEqualSelector((state: State) => + getHostTacticsSelector(state) + ); + const { data } = useKibana().services; + const refetch = useRef(noop); + const abortCtrl = useRef(new AbortController()); + const searchSubscription = useRef(new Subscription()); + const [loading, setLoading] = useState(false); + const [hostTacticsRequest, setHostTacticsRequest] = useState( + null + ); + const { getTransformChangesIfTheyExist } = useTransforms(); + const { addError, addWarning } = useAppToasts(); + + const wrappedLoadMore = useCallback( + (newActivePage: number) => { + setHostTacticsRequest((prevRequest) => { + if (!prevRequest) { + return prevRequest; + } + + return { + ...prevRequest, + pagination: generateTablePaginationOptions(newActivePage, limit), + }; + }); + }, + [limit] + ); + + const [hostTacticsResponse, setHostTacticsResponse] = useState({ + data: [], + endDate, + id: ID, + inspect: { + dsl: [], + response: [], + }, + isInspected: false, + loadPage: wrappedLoadMore, + pageInfo: { + activePage: 0, + fakeTotalCount: 0, + showMorePagesIndicator: false, + }, + refetch: refetch.current, + startDate, + techniqueCount: -1, + totalCount: -1, + }); + + const hostTacticsSearch = useCallback( + (request: HostTacticsRequestOptions | null) => { + if (request == null || skip) { + return; + } + + const asyncSearch = async () => { + abortCtrl.current = new AbortController(); + setLoading(true); + + searchSubscription.current = data.search + .search(request, { + strategy: 'securitySolutionSearchStrategy', + abortSignal: abortCtrl.current.signal, + }) + .subscribe({ + next: (response) => { + if (isCompleteResponse(response)) { + setHostTacticsResponse((prevResponse) => ({ + ...prevResponse, + data: response.edges, + inspect: getInspectResponse(response, prevResponse.inspect), + pageInfo: response.pageInfo, + refetch: refetch.current, + totalCount: response.totalCount, + techniqueCount: response.techniqueCount, + })); + searchSubscription.current.unsubscribe(); + } else if (isErrorResponse(response)) { + setLoading(false); + addWarning(i18n.ERROR_HOST_RULES); + searchSubscription.current.unsubscribe(); + } + }, + error: (msg) => { + setLoading(false); + addError(msg, { title: i18n.FAIL_HOST_RULES }); + searchSubscription.current.unsubscribe(); + }, + }); + setLoading(false); + }; + searchSubscription.current.unsubscribe(); + abortCtrl.current.abort(); + asyncSearch(); + refetch.current = asyncSearch; + }, + [data.search, addError, addWarning, skip] + ); + + useEffect(() => { + setHostTacticsRequest((prevRequest) => { + const { indices, factoryQueryType, timerange } = getTransformChangesIfTheyExist({ + factoryQueryType: UebaQueries.hostTactics, + indices: indexNames, + filterQuery, + timerange: { + interval: '12h', + from: startDate, + to: endDate, + }, + }); + const myRequest = { + ...(prevRequest ?? {}), + hostName, + defaultIndex: indices, + docValueFields: docValueFields ?? [], + factoryQueryType, + filterQuery: createFilter(filterQuery), + pagination: generateTablePaginationOptions(activePage, limit), + timerange, + sort, + }; + if (!deepEqual(prevRequest, myRequest)) { + return myRequest; + } + return prevRequest; + }); + }, [ + activePage, + docValueFields, + endDate, + filterQuery, + indexNames, + limit, + startDate, + sort, + getTransformChangesIfTheyExist, + hostName, + ]); + + useEffect(() => { + hostTacticsSearch(hostTacticsRequest); + return () => { + searchSubscription.current.unsubscribe(); + abortCtrl.current.abort(); + }; + }, [hostTacticsRequest, hostTacticsSearch]); + + return [loading, hostTacticsResponse]; +}; diff --git a/x-pack/plugins/security_solution/public/ueba/containers/host_tactics/translations.ts b/x-pack/plugins/security_solution/public/ueba/containers/host_tactics/translations.ts new file mode 100644 index 0000000000000..6cf5521f4eaaa --- /dev/null +++ b/x-pack/plugins/security_solution/public/ueba/containers/host_tactics/translations.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 { i18n } from '@kbn/i18n'; + +export const ERROR_HOST_RULES = i18n.translate( + 'xpack.securitySolution.hostRules.errorSearchDescription', + { + defaultMessage: `An error has occurred on risk score search`, + } +); + +export const FAIL_HOST_RULES = i18n.translate( + 'xpack.securitySolution.hostRules.failSearchDescription', + { + defaultMessage: `Failed to run search on risk score`, + } +); diff --git a/x-pack/plugins/security_solution/public/ueba/containers/risk_score/index.tsx b/x-pack/plugins/security_solution/public/ueba/containers/risk_score/index.tsx new file mode 100644 index 0000000000000..f2f353ffc0cff --- /dev/null +++ b/x-pack/plugins/security_solution/public/ueba/containers/risk_score/index.tsx @@ -0,0 +1,216 @@ +/* + * 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 deepEqual from 'fast-deep-equal'; +import { noop } from 'lodash/fp'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { Subscription } from 'rxjs'; + +import { inputsModel, State } from '../../../common/store'; +import { createFilter } from '../../../common/containers/helpers'; +import { useKibana } from '../../../common/lib/kibana'; +import { useDeepEqualSelector } from '../../../common/hooks/use_selector'; +import { uebaModel, uebaSelectors } from '../../store'; +import { generateTablePaginationOptions } from '../../../common/components/paginated_table/helpers'; +import { + RiskScoreEdges, + PageInfoPaginated, + DocValueFields, + UebaQueries, + RiskScoreRequestOptions, + RiskScoreStrategyResponse, +} from '../../../../common'; +import { ESTermQuery } from '../../../../common/typed_json'; + +import * as i18n from './translations'; +import { isCompleteResponse, isErrorResponse } from '../../../../../../../src/plugins/data/common'; +import { getInspectResponse } from '../../../helpers'; +import { InspectResponse } from '../../../types'; +import { useTransforms } from '../../../transforms/containers/use_transforms'; +import { useAppToasts } from '../../../common/hooks/use_app_toasts'; + +export const ID = 'riskScoreQuery'; + +type LoadPage = (newActivePage: number) => void; +export interface RiskScoreState { + data: RiskScoreEdges[]; + endDate: string; + id: string; + inspect: InspectResponse; + isInspected: boolean; + loadPage: LoadPage; + pageInfo: PageInfoPaginated; + refetch: inputsModel.Refetch; + startDate: string; + totalCount: number; +} + +interface UseRiskScore { + docValueFields?: DocValueFields[]; + endDate: string; + filterQuery?: ESTermQuery | string; + indexNames: string[]; + skip?: boolean; + startDate: string; + type: uebaModel.UebaType; +} + +export const useRiskScore = ({ + docValueFields, + endDate, + filterQuery, + indexNames, + skip = false, + startDate, +}: UseRiskScore): [boolean, RiskScoreState] => { + const getRiskScoreSelector = useMemo(() => uebaSelectors.riskScoreSelector(), []); + const { activePage, limit, sort } = useDeepEqualSelector((state: State) => + getRiskScoreSelector(state) + ); + const { data } = useKibana().services; + const refetch = useRef(noop); + const abortCtrl = useRef(new AbortController()); + const searchSubscription = useRef(new Subscription()); + const [loading, setLoading] = useState(false); + const [riskScoreRequest, setRiskScoreRequest] = useState(null); + const { getTransformChangesIfTheyExist } = useTransforms(); + const { addError, addWarning } = useAppToasts(); + + const wrappedLoadMore = useCallback( + (newActivePage: number) => { + setRiskScoreRequest((prevRequest) => { + if (!prevRequest) { + return prevRequest; + } + + return { + ...prevRequest, + pagination: generateTablePaginationOptions(newActivePage, limit), + }; + }); + }, + [limit] + ); + + const [riskScoreResponse, setRiskScoreResponse] = useState({ + data: [], + endDate, + id: ID, + inspect: { + dsl: [], + response: [], + }, + isInspected: false, + loadPage: wrappedLoadMore, + pageInfo: { + activePage: 0, + fakeTotalCount: 0, + showMorePagesIndicator: false, + }, + refetch: refetch.current, + startDate, + totalCount: -1, + }); + + const riskScoreSearch = useCallback( + (request: RiskScoreRequestOptions | null) => { + if (request == null || skip) { + return; + } + + const asyncSearch = async () => { + abortCtrl.current = new AbortController(); + setLoading(true); + + searchSubscription.current = data.search + .search(request, { + strategy: 'securitySolutionSearchStrategy', + abortSignal: abortCtrl.current.signal, + }) + .subscribe({ + next: (response) => { + if (isCompleteResponse(response)) { + setRiskScoreResponse((prevResponse) => ({ + ...prevResponse, + data: response.edges, + inspect: getInspectResponse(response, prevResponse.inspect), + pageInfo: response.pageInfo, + refetch: refetch.current, + totalCount: response.totalCount, + })); + searchSubscription.current.unsubscribe(); + } else if (isErrorResponse(response)) { + setLoading(false); + addWarning(i18n.ERROR_RISK_SCORE); + searchSubscription.current.unsubscribe(); + } + }, + error: (msg) => { + setLoading(false); + addError(msg, { title: i18n.FAIL_RISK_SCORE }); + searchSubscription.current.unsubscribe(); + }, + }); + setLoading(false); + }; + searchSubscription.current.unsubscribe(); + abortCtrl.current.abort(); + asyncSearch(); + refetch.current = asyncSearch; + }, + [data.search, addError, addWarning, skip] + ); + + useEffect(() => { + setRiskScoreRequest((prevRequest) => { + const { indices, factoryQueryType, timerange } = getTransformChangesIfTheyExist({ + factoryQueryType: UebaQueries.riskScore, + indices: indexNames, + filterQuery, + timerange: { + interval: '12h', + from: startDate, + to: endDate, + }, + }); + const myRequest = { + ...(prevRequest ?? {}), + defaultIndex: indices, + docValueFields: docValueFields ?? [], + factoryQueryType, + filterQuery: createFilter(filterQuery), + pagination: generateTablePaginationOptions(activePage, limit), + timerange, + sort, + }; + if (!deepEqual(prevRequest, myRequest)) { + return myRequest; + } + return prevRequest; + }); + }, [ + activePage, + docValueFields, + endDate, + filterQuery, + indexNames, + limit, + startDate, + sort, + getTransformChangesIfTheyExist, + ]); + + useEffect(() => { + riskScoreSearch(riskScoreRequest); + return () => { + searchSubscription.current.unsubscribe(); + abortCtrl.current.abort(); + }; + }, [riskScoreRequest, riskScoreSearch]); + + return [loading, riskScoreResponse]; +}; diff --git a/x-pack/plugins/security_solution/public/ueba/containers/risk_score/translations.ts b/x-pack/plugins/security_solution/public/ueba/containers/risk_score/translations.ts new file mode 100644 index 0000000000000..8cc275674d4e9 --- /dev/null +++ b/x-pack/plugins/security_solution/public/ueba/containers/risk_score/translations.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 { i18n } from '@kbn/i18n'; + +export const ERROR_RISK_SCORE = i18n.translate( + 'xpack.securitySolution.riskScore.errorSearchDescription', + { + defaultMessage: `An error has occurred on risk score search`, + } +); + +export const FAIL_RISK_SCORE = i18n.translate( + 'xpack.securitySolution.riskScore.failSearchDescription', + { + defaultMessage: `Failed to run search on risk score`, + } +); diff --git a/x-pack/plugins/security_solution/public/ueba/containers/user_rules/index.tsx b/x-pack/plugins/security_solution/public/ueba/containers/user_rules/index.tsx new file mode 100644 index 0000000000000..3c4e45bd3a1e5 --- /dev/null +++ b/x-pack/plugins/security_solution/public/ueba/containers/user_rules/index.tsx @@ -0,0 +1,209 @@ +/* + * 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 deepEqual from 'fast-deep-equal'; +import { noop } from 'lodash/fp'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { Subscription } from 'rxjs'; + +import { inputsModel, State } from '../../../common/store'; +import { createFilter } from '../../../common/containers/helpers'; +import { useKibana } from '../../../common/lib/kibana'; +import { useDeepEqualSelector } from '../../../common/hooks/use_selector'; +import { uebaModel, uebaSelectors } from '../../store'; +import { generateTablePaginationOptions } from '../../../common/components/paginated_table/helpers'; +import { + DocValueFields, + UebaQueries, + UserRulesRequestOptions, + UserRulesStrategyResponse, + UserRulesStrategyUserResponse, +} from '../../../../common'; +import { ESTermQuery } from '../../../../common/typed_json'; + +import * as i18n from './translations'; +import { isCompleteResponse, isErrorResponse } from '../../../../../../../src/plugins/data/common'; +import { getInspectResponse } from '../../../helpers'; +import { InspectResponse } from '../../../types'; +import { useTransforms } from '../../../transforms/containers/use_transforms'; +import { useAppToasts } from '../../../common/hooks/use_app_toasts'; + +export const ID = 'userRulesQuery'; + +type LoadPage = (newActivePage: number) => void; +export interface UserRulesState { + data: UserRulesStrategyUserResponse[]; + endDate: string; + id: string; + inspect: InspectResponse; + isInspected: boolean; + loadPage: LoadPage; + refetch: inputsModel.Refetch; + startDate: string; +} + +interface UseUserRules { + docValueFields?: DocValueFields[]; + endDate: string; + filterQuery?: ESTermQuery | string; + hostName: string; + indexNames: string[]; + skip?: boolean; + startDate: string; + type: uebaModel.UebaType; +} + +export const useUserRules = ({ + docValueFields, + endDate, + filterQuery, + hostName, + indexNames, + skip = false, + startDate, +}: UseUserRules): [boolean, UserRulesState] => { + const getUserRulesSelector = useMemo(() => uebaSelectors.userRulesSelector(), []); + const { activePage, limit, sort } = useDeepEqualSelector((state: State) => + getUserRulesSelector(state) + ); + const { data } = useKibana().services; + const refetch = useRef(noop); + const abortCtrl = useRef(new AbortController()); + const searchSubscription = useRef(new Subscription()); + const [loading, setLoading] = useState(false); + const [userRulesRequest, setUserRulesRequest] = useState(null); + const { getTransformChangesIfTheyExist } = useTransforms(); + const { addError, addWarning } = useAppToasts(); + + const wrappedLoadMore = useCallback( + (newActivePage: number) => { + setUserRulesRequest((prevRequest) => { + if (!prevRequest) { + return prevRequest; + } + + return { + ...prevRequest, + pagination: generateTablePaginationOptions(newActivePage, limit), + }; + }); + }, + [limit] + ); + + const [userRulesResponse, setUserRulesResponse] = useState({ + data: [], + endDate, + id: ID, + inspect: { + dsl: [], + response: [], + }, + isInspected: false, + loadPage: wrappedLoadMore, + refetch: refetch.current, + startDate, + }); + + const userRulesSearch = useCallback( + (request: UserRulesRequestOptions | null) => { + if (request == null || skip) { + return; + } + + const asyncSearch = async () => { + abortCtrl.current = new AbortController(); + setLoading(true); + + searchSubscription.current = data.search + .search(request, { + strategy: 'securitySolutionSearchStrategy', + abortSignal: abortCtrl.current.signal, + }) + .subscribe({ + next: (response) => { + if (isCompleteResponse(response)) { + setUserRulesResponse((prevResponse) => ({ + ...prevResponse, + data: response.data, + inspect: getInspectResponse(response, prevResponse.inspect), + refetch: refetch.current, + })); + searchSubscription.current.unsubscribe(); + } else if (isErrorResponse(response)) { + setLoading(false); + addWarning(i18n.ERROR_HOST_RULES); + searchSubscription.current.unsubscribe(); + } + }, + error: (msg) => { + setLoading(false); + addError(msg, { title: i18n.FAIL_HOST_RULES }); + searchSubscription.current.unsubscribe(); + }, + }); + setLoading(false); + }; + searchSubscription.current.unsubscribe(); + abortCtrl.current.abort(); + asyncSearch(); + refetch.current = asyncSearch; + }, + [data.search, addError, addWarning, skip] + ); + + useEffect(() => { + setUserRulesRequest((prevRequest) => { + const { indices, factoryQueryType, timerange } = getTransformChangesIfTheyExist({ + factoryQueryType: UebaQueries.userRules, + indices: indexNames, + filterQuery, + timerange: { + interval: '12h', + from: startDate, + to: endDate, + }, + }); + const myRequest = { + ...(prevRequest ?? {}), + hostName, + defaultIndex: indices, + docValueFields: docValueFields ?? [], + factoryQueryType, + filterQuery: createFilter(filterQuery), + pagination: generateTablePaginationOptions(activePage, limit), + timerange, + sort, + }; + if (!deepEqual(prevRequest, myRequest)) { + return myRequest; + } + return prevRequest; + }); + }, [ + activePage, + docValueFields, + endDate, + filterQuery, + indexNames, + limit, + startDate, + sort, + getTransformChangesIfTheyExist, + hostName, + ]); + + useEffect(() => { + userRulesSearch(userRulesRequest); + return () => { + searchSubscription.current.unsubscribe(); + abortCtrl.current.abort(); + }; + }, [userRulesRequest, userRulesSearch]); + + return [loading, userRulesResponse]; +}; diff --git a/x-pack/plugins/security_solution/public/ueba/containers/user_rules/translations.ts b/x-pack/plugins/security_solution/public/ueba/containers/user_rules/translations.ts new file mode 100644 index 0000000000000..6cf5521f4eaaa --- /dev/null +++ b/x-pack/plugins/security_solution/public/ueba/containers/user_rules/translations.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 { i18n } from '@kbn/i18n'; + +export const ERROR_HOST_RULES = i18n.translate( + 'xpack.securitySolution.hostRules.errorSearchDescription', + { + defaultMessage: `An error has occurred on risk score search`, + } +); + +export const FAIL_HOST_RULES = i18n.translate( + 'xpack.securitySolution.hostRules.failSearchDescription', + { + defaultMessage: `Failed to run search on risk score`, + } +); diff --git a/x-pack/plugins/security_solution/public/ueba/index.ts b/x-pack/plugins/security_solution/public/ueba/index.ts new file mode 100644 index 0000000000000..030844735b0f1 --- /dev/null +++ b/x-pack/plugins/security_solution/public/ueba/index.ts @@ -0,0 +1,30 @@ +/* + * 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 { Storage } from '../../../../../src/plugins/kibana_utils/public'; +import { SecuritySubPluginWithStore } from '../app/types'; +import { routes } from './routes'; +import { initialUebaState, uebaReducer, uebaModel } from './store'; +import { TimelineId } from '../../common/types/timeline'; +import { getTimelinesInStorageByIds } from '../timelines/containers/local_storage'; + +export class Ueba { + public setup() {} + + public start(storage: Storage): SecuritySubPluginWithStore<'ueba', uebaModel.UebaModel> { + return { + routes, + storageTimelines: { + timelineById: getTimelinesInStorageByIds(storage, [TimelineId.uebaPageExternalAlerts]), + }, + store: { + initialState: { ueba: initialUebaState }, + reducer: { ueba: uebaReducer }, + }, + }; + } +} diff --git a/x-pack/plugins/security_solution/public/ueba/pages/details/details_tabs.tsx b/x-pack/plugins/security_solution/public/ueba/pages/details/details_tabs.tsx new file mode 100644 index 0000000000000..dad3277d0a7a4 --- /dev/null +++ b/x-pack/plugins/security_solution/public/ueba/pages/details/details_tabs.tsx @@ -0,0 +1,95 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useCallback } from 'react'; +import { Route, Switch } from 'react-router-dom'; + +import { UpdateDateRange } from '../../../common/components/charts/common'; +import { scoreIntervalToDateTime } from '../../../common/components/ml/score/score_interval_to_datetime'; +import { Anomaly } from '../../../common/components/ml/types'; +import { UebaTableType } from '../../store/model'; +import { useGlobalTime } from '../../../common/containers/use_global_time'; + +import { UebaDetailsTabsProps } from './types'; +import { type } from './utils'; + +import { + HostRulesQueryTabBody, + HostTacticsQueryTabBody, + UserRulesQueryTabBody, +} from '../navigation'; + +export const UebaDetailsTabs = React.memo( + ({ + detailName, + docValueFields, + filterQuery, + indexNames, + indexPattern, + pageFilters, + setAbsoluteRangeDatePicker, + uebaDetailsPagePath, + }) => { + const { from, to, isInitializing, deleteQuery, setQuery } = useGlobalTime(); + const narrowDateRange = useCallback( + (score: Anomaly, interval: string) => { + const fromTo = scoreIntervalToDateTime(score, interval); + setAbsoluteRangeDatePicker({ + id: 'global', + from: fromTo.from, + to: fromTo.to, + }); + }, + [setAbsoluteRangeDatePicker] + ); + + const updateDateRange = useCallback( + ({ x }) => { + if (!x) { + return; + } + const [min, max] = x; + setAbsoluteRangeDatePicker({ + id: 'global', + from: new Date(min).toISOString(), + to: new Date(max).toISOString(), + }); + }, + [setAbsoluteRangeDatePicker] + ); + + const tabProps = { + deleteQuery, + endDate: to, + filterQuery, + skip: isInitializing || filterQuery === undefined, + setQuery, + startDate: from, + type, + indexPattern, + indexNames, + hostName: detailName, + narrowDateRange, + updateDateRange, + }; + return ( + + + + + + + + + + + + ); + } +); + +UebaDetailsTabs.displayName = 'UebaDetailsTabs'; diff --git a/x-pack/plugins/security_solution/public/ueba/pages/details/helpers.ts b/x-pack/plugins/security_solution/public/ueba/pages/details/helpers.ts new file mode 100644 index 0000000000000..70f8027b1f55b --- /dev/null +++ b/x-pack/plugins/security_solution/public/ueba/pages/details/helpers.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 { escapeQueryValue } from '../../../common/lib/keury'; +import { Filter } from '../../../../../../../src/plugins/data/public'; + +/** Returns the kqlQueryExpression for the `Events` widget on the `Host Details` page */ +export const getUebaDetailsEventsKqlQueryExpression = ({ + filterQueryExpression, + hostName, +}: { + filterQueryExpression: string; + hostName: string; +}): string => { + if (filterQueryExpression.length) { + return `${filterQueryExpression}${ + hostName.length ? ` and host.name: ${escapeQueryValue(hostName)}` : '' + }`; + } else { + return hostName.length ? `host.name: ${escapeQueryValue(hostName)}` : ''; + } +}; + +export const getUebaDetailsPageFilters = (hostName: string): Filter[] => [ + { + meta: { + alias: null, + negate: false, + disabled: false, + type: 'phrase', + key: 'host.name', + value: hostName, + params: { + query: hostName, + }, + }, + query: { + match: { + 'host.name': { + query: hostName, + type: 'phrase', + }, + }, + }, + }, +]; diff --git a/x-pack/plugins/security_solution/public/ueba/pages/details/index.tsx b/x-pack/plugins/security_solution/public/ueba/pages/details/index.tsx new file mode 100644 index 0000000000000..5a297099f3834 --- /dev/null +++ b/x-pack/plugins/security_solution/public/ueba/pages/details/index.tsx @@ -0,0 +1,150 @@ +/* + * 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 { EuiSpacer, EuiWindowEvent } from '@elastic/eui'; +import { noop } from 'lodash/fp'; +import React, { useEffect, useMemo } from 'react'; +import { useDispatch } from 'react-redux'; + +import { LastEventIndexKey } from '../../../../common/search_strategy'; +import { SecurityPageName } from '../../../app/types'; +import { FiltersGlobal } from '../../../common/components/filters_global'; +import { HeaderPage } from '../../../common/components/header_page'; +import { LastEventTime } from '../../../common/components/last_event_time'; +import { SecuritySolutionTabNavigation } from '../../../common/components/navigation'; +import { SiemSearchBar } from '../../../common/components/search_bar'; +import { SecuritySolutionPageWrapper } from '../../../common/components/page_wrapper'; +import { useGlobalTime } from '../../../common/containers/use_global_time'; +import { useKibana } from '../../../common/lib/kibana'; +import { convertToBuildEsQuery } from '../../../common/lib/keury'; +import { inputsSelectors } from '../../../common/store'; +import { setUebaDetailsTablesActivePageToZero } from '../../store/actions'; +import { setAbsoluteRangeDatePicker } from '../../../common/store/inputs/actions'; +import { SpyRoute } from '../../../common/utils/route/spy_routes'; +import { esQuery, Filter } from '../../../../../../../src/plugins/data/public'; + +import { OverviewEmpty } from '../../../overview/components/overview_empty'; +import { UebaDetailsTabs } from './details_tabs'; +import { navTabsUebaDetails } from './nav_tabs'; +import { UebaDetailsProps } from './types'; +import { type } from './utils'; +import { getUebaDetailsPageFilters } from './helpers'; +import { showGlobalFilters } from '../../../timelines/components/timeline/helpers'; +import { useGlobalFullScreen } from '../../../common/containers/use_full_screen'; +import { Display } from '../display'; +import { timelineSelectors } from '../../../timelines/store/timeline'; +import { TimelineId } from '../../../../common/types/timeline'; +import { timelineDefaults } from '../../../timelines/store/timeline/defaults'; +import { useSourcererScope } from '../../../common/containers/sourcerer'; +import { useDeepEqualSelector, useShallowEqualSelector } from '../../../common/hooks/use_selector'; +import { useInvalidFilterQuery } from '../../../common/hooks/use_invalid_filter_query'; +import { SourcererScopeName } from '../../../common/store/sourcerer/model'; +const ID = 'UebaDetailsQueryId'; + +const UebaDetailsComponent: React.FC = ({ detailName, uebaDetailsPagePath }) => { + const dispatch = useDispatch(); + const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []); + const graphEventId = useShallowEqualSelector( + (state) => (getTimeline(state, TimelineId.hostsPageEvents) ?? timelineDefaults).graphEventId + ); + const getGlobalFiltersQuerySelector = useMemo( + () => inputsSelectors.globalFiltersQuerySelector(), + [] + ); + const getGlobalQuerySelector = useMemo(() => inputsSelectors.globalQuerySelector(), []); + const query = useDeepEqualSelector(getGlobalQuerySelector); + const filters = useDeepEqualSelector(getGlobalFiltersQuerySelector); + + const { to, from, deleteQuery, setQuery, isInitializing } = useGlobalTime(); + const { globalFullScreen } = useGlobalFullScreen(); + + const kibana = useKibana(); + const uebaDetailsPageFilters: Filter[] = useMemo(() => getUebaDetailsPageFilters(detailName), [ + detailName, + ]); + const getFilters = () => [...uebaDetailsPageFilters, ...filters]; + + const { docValueFields, indicesExist, indexPattern, selectedPatterns } = useSourcererScope( + SourcererScopeName.detections + ); + + const [filterQuery, kqlError] = convertToBuildEsQuery({ + config: esQuery.getEsQueryConfig(kibana.services.uiSettings), + indexPattern, + queries: [query], + filters: getFilters(), + }); + + useInvalidFilterQuery({ id: ID, filterQuery, kqlError, query, startDate: from, endDate: to }); + + useEffect(() => { + dispatch(setUebaDetailsTablesActivePageToZero()); + }, [dispatch, detailName]); + + return ( + <> + {indicesExist ? ( + <> + + + + + + + + + } + title={detailName} + /> + + + + + + + + + ) : ( + + + + + + )} + + + + ); +}; + +UebaDetailsComponent.displayName = 'UebaDetailsComponent'; + +export const UebaDetails = React.memo(UebaDetailsComponent); diff --git a/x-pack/plugins/security_solution/public/ueba/pages/details/nav_tabs.tsx b/x-pack/plugins/security_solution/public/ueba/pages/details/nav_tabs.tsx new file mode 100644 index 0000000000000..ba97a03bf6daf --- /dev/null +++ b/x-pack/plugins/security_solution/public/ueba/pages/details/nav_tabs.tsx @@ -0,0 +1,37 @@ +/* + * 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 * as i18n from '../translations'; +import { UebaDetailsNavTab } from './types'; +import { UebaTableType } from '../../store/model'; +import { UEBA_PATH } from '../../../../common/constants'; + +const getTabsOnUebaDetailsUrl = (hostName: string, tabName: UebaTableType) => + `${UEBA_PATH}/${hostName}/${tabName}`; + +export const navTabsUebaDetails = (hostName: string): UebaDetailsNavTab => { + return { + [UebaTableType.hostRules]: { + id: UebaTableType.hostRules, + name: i18n.HOST_RULES, + href: getTabsOnUebaDetailsUrl(hostName, UebaTableType.hostRules), + disabled: false, + }, + [UebaTableType.hostTactics]: { + id: UebaTableType.hostTactics, + name: i18n.HOST_TACTICS, + href: getTabsOnUebaDetailsUrl(hostName, UebaTableType.hostTactics), + disabled: false, + }, + [UebaTableType.userRules]: { + id: UebaTableType.userRules, + name: i18n.USER_RULES, + href: getTabsOnUebaDetailsUrl(hostName, UebaTableType.userRules), + disabled: false, + }, + }; +}; diff --git a/x-pack/plugins/security_solution/public/ueba/pages/details/types.ts b/x-pack/plugins/security_solution/public/ueba/pages/details/types.ts new file mode 100644 index 0000000000000..976b033db5f5a --- /dev/null +++ b/x-pack/plugins/security_solution/public/ueba/pages/details/types.ts @@ -0,0 +1,65 @@ +/* + * 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 { ActionCreator } from 'typescript-fsa'; +import { Query, IIndexPattern, Filter } from 'src/plugins/data/public'; +import { InputsModelId } from '../../../common/store/inputs/constants'; +import { UebaTableType } from '../../store/model'; +import { UebaQueryProps } from '../types'; +import { NavTab } from '../../../common/components/navigation/types'; +import { uebaModel } from '../../store'; +import { DocValueFields } from '../../../common/containers/source'; + +interface UebaDetailsComponentReduxProps { + query: Query; + filters: Filter[]; +} + +interface HostBodyComponentDispatchProps { + setAbsoluteRangeDatePicker: ActionCreator<{ + id: InputsModelId; + from: string; + to: string; + }>; + detailName: string; + uebaDetailsPagePath: string; +} + +interface UebaDetailsComponentDispatchProps extends HostBodyComponentDispatchProps { + setUebaDetailsTablesActivePageToZero: ActionCreator; +} + +export interface UebaDetailsProps { + detailName: string; + uebaDetailsPagePath: string; +} + +export type UebaDetailsComponentProps = UebaDetailsComponentReduxProps & + UebaDetailsComponentDispatchProps & + UebaQueryProps; + +type KeyUebaDetailsNavTab = UebaTableType.hostRules & + UebaTableType.hostTactics & + UebaTableType.userRules; + +export type UebaDetailsNavTab = Record; + +export type UebaDetailsTabsProps = HostBodyComponentDispatchProps & + UebaQueryProps & { + docValueFields?: DocValueFields[]; + indexNames: string[]; + pageFilters?: Filter[]; + filterQuery?: string; + indexPattern: IIndexPattern; + type: uebaModel.UebaType; + }; + +export type SetAbsoluteRangeDatePicker = ActionCreator<{ + id: InputsModelId; + from: string; + to: string; +}>; diff --git a/x-pack/plugins/security_solution/public/ueba/pages/details/utils.ts b/x-pack/plugins/security_solution/public/ueba/pages/details/utils.ts new file mode 100644 index 0000000000000..d5f346d3ece64 --- /dev/null +++ b/x-pack/plugins/security_solution/public/ueba/pages/details/utils.ts @@ -0,0 +1,71 @@ +/* + * 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 { get, isEmpty } from 'lodash/fp'; + +import { ChromeBreadcrumb } from '../../../../../../../src/core/public'; +import { uebaModel } from '../../store'; +import { UebaTableType } from '../../store/model'; +import { getUebaDetailsUrl } from '../../../common/components/link_to/redirect_to_ueba'; + +import * as i18n from '../translations'; +import { UebaRouteSpyState } from '../../../common/utils/route/types'; +import { GetUrlForApp } from '../../../common/components/navigation/types'; +import { APP_ID } from '../../../../common/constants'; +import { SecurityPageName } from '../../../app/types'; + +export const type = uebaModel.UebaType.details; + +const TabNameMappedToI18nKey: Record = { + [UebaTableType.hostRules]: i18n.HOST_RULES, + [UebaTableType.hostTactics]: i18n.HOST_TACTICS, + [UebaTableType.riskScore]: i18n.RISK_SCORE_TITLE, + [UebaTableType.userRules]: i18n.USER_RULES, +}; + +export const getBreadcrumbs = ( + params: UebaRouteSpyState, + search: string[], + getUrlForApp: GetUrlForApp +): ChromeBreadcrumb[] => { + let breadcrumb = [ + { + text: i18n.PAGE_TITLE, + href: getUrlForApp(APP_ID, { + path: !isEmpty(search[0]) ? search[0] : '', + deepLinkId: SecurityPageName.ueba, + }), + }, + ]; + + if (params.detailName != null) { + breadcrumb = [ + ...breadcrumb, + { + text: params.detailName, + href: getUrlForApp(APP_ID, { + path: getUebaDetailsUrl(params.detailName, !isEmpty(search[0]) ? search[0] : ''), + deepLinkId: SecurityPageName.ueba, + }), + }, + ]; + } + + if (params.tabName != null) { + const tabName = get('tabName', params); + if (!tabName) return breadcrumb; + + breadcrumb = [ + ...breadcrumb, + { + text: TabNameMappedToI18nKey[tabName], + href: '', + }, + ]; + } + return breadcrumb; +}; diff --git a/x-pack/plugins/security_solution/public/ueba/pages/display.tsx b/x-pack/plugins/security_solution/public/ueba/pages/display.tsx new file mode 100644 index 0000000000000..a907f1fdb5997 --- /dev/null +++ b/x-pack/plugins/security_solution/public/ueba/pages/display.tsx @@ -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 styled from 'styled-components'; + +export const Display = styled.div<{ show: boolean }>` + ${({ show }) => (show ? '' : 'display: none;')}; +`; + +Display.displayName = 'Display'; diff --git a/x-pack/plugins/security_solution/public/ueba/pages/index.tsx b/x-pack/plugins/security_solution/public/ueba/pages/index.tsx new file mode 100644 index 0000000000000..c4a6794b75999 --- /dev/null +++ b/x-pack/plugins/security_solution/public/ueba/pages/index.tsx @@ -0,0 +1,65 @@ +/* + * 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 { Route, Switch, Redirect } from 'react-router-dom'; +import { UEBA_PATH } from '../../../common/constants'; +import { UebaTableType } from '../store/model'; +import { Ueba } from './ueba'; +import { uebaDetailsPagePath } from './types'; +import { UebaDetails } from './details'; + +const uebaTabPath = `${UEBA_PATH}/:tabName(${UebaTableType.riskScore})`; + +const uebaDetailsTabPath = + `${uebaDetailsPagePath}/:tabName(` + + `${UebaTableType.hostRules}|` + + `${UebaTableType.hostTactics}|` + + `${UebaTableType.userRules})`; + +export const UebaContainer = React.memo(() => ( + + ( + + )} + /> + + + + + } + /> + ( + + )} + /> + +)); + +UebaContainer.displayName = 'UebaContainer'; diff --git a/x-pack/plugins/security_solution/public/ueba/pages/nav_tabs.tsx b/x-pack/plugins/security_solution/public/ueba/pages/nav_tabs.tsx new file mode 100644 index 0000000000000..5e06e5c9bf068 --- /dev/null +++ b/x-pack/plugins/security_solution/public/ueba/pages/nav_tabs.tsx @@ -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 * as i18n from './translations'; +import { UebaTableType } from '../store/model'; +import { UebaNavTab } from './navigation/types'; +import { UEBA_PATH } from '../../../common/constants'; + +const getTabsOnUebaUrl = (tabName: UebaTableType) => `${UEBA_PATH}/${tabName}`; + +export const navTabsUeba: UebaNavTab = { + [UebaTableType.riskScore]: { + id: UebaTableType.riskScore, + name: i18n.RISK_SCORE_TITLE, + href: getTabsOnUebaUrl(UebaTableType.riskScore), + disabled: false, + }, +}; diff --git a/x-pack/plugins/security_solution/public/ueba/pages/navigation/host_rules_query_tab_body.tsx b/x-pack/plugins/security_solution/public/ueba/pages/navigation/host_rules_query_tab_body.tsx new file mode 100644 index 0000000000000..bce19a9da7ab9 --- /dev/null +++ b/x-pack/plugins/security_solution/public/ueba/pages/navigation/host_rules_query_tab_body.tsx @@ -0,0 +1,64 @@ +/* + * 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 React from 'react'; +import { useHostRules } from '../../containers/host_rules'; +import { HostQueryProps } from './types'; +import { manageQuery } from '../../../common/components/page/manage_query'; +import { HostRulesTable } from '../../components/host_rules_table'; +import { uebaModel } from '../../store'; + +const HostRulesTableManage = manageQuery(HostRulesTable); + +export const HostRulesQueryTabBody = ({ + deleteQuery, + docValueFields, + endDate, + filterQuery, + hostName, + indexNames, + skip, + setQuery, + startDate, + type, +}: HostQueryProps) => { + const [ + loading, + { data, totalCount, pageInfo, loadPage, id, inspect, isInspected, refetch }, + ] = useHostRules({ + docValueFields, + endDate, + filterQuery, + hostName, + indexNames, + skip, + startDate, + type, + }); + + return ( + + ); +}; + +HostRulesQueryTabBody.displayName = 'HostRulesQueryTabBody'; diff --git a/x-pack/plugins/security_solution/public/ueba/pages/navigation/host_tactics_query_tab_body.tsx b/x-pack/plugins/security_solution/public/ueba/pages/navigation/host_tactics_query_tab_body.tsx new file mode 100644 index 0000000000000..c441eff3219d2 --- /dev/null +++ b/x-pack/plugins/security_solution/public/ueba/pages/navigation/host_tactics_query_tab_body.tsx @@ -0,0 +1,63 @@ +/* + * 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 React from 'react'; +import { useHostTactics } from '../../containers/host_tactics'; +import { HostQueryProps } from './types'; +import { manageQuery } from '../../../common/components/page/manage_query'; +import { HostTacticsTable } from '../../components/host_tactics_table'; + +const HostTacticsTableManage = manageQuery(HostTacticsTable); + +export const HostTacticsQueryTabBody = ({ + deleteQuery, + docValueFields, + endDate, + filterQuery, + hostName, + indexNames, + skip, + setQuery, + startDate, + type, +}: HostQueryProps) => { + const [ + loading, + { data, techniqueCount, totalCount, pageInfo, loadPage, id, inspect, isInspected, refetch }, + ] = useHostTactics({ + docValueFields, + endDate, + filterQuery, + hostName, + indexNames, + skip, + startDate, + type, + }); + + return ( + + ); +}; + +HostTacticsQueryTabBody.displayName = 'HostTacticsQueryTabBody'; diff --git a/x-pack/plugins/security_solution/public/ueba/pages/navigation/index.ts b/x-pack/plugins/security_solution/public/ueba/pages/navigation/index.ts new file mode 100644 index 0000000000000..dd549659a3eab --- /dev/null +++ b/x-pack/plugins/security_solution/public/ueba/pages/navigation/index.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export * from './host_rules_query_tab_body'; +export * from './host_tactics_query_tab_body'; +export * from './risk_score_query_tab_body'; +export * from './user_rules_query_tab_body'; diff --git a/x-pack/plugins/security_solution/public/ueba/pages/navigation/risk_score_query_tab_body.tsx b/x-pack/plugins/security_solution/public/ueba/pages/navigation/risk_score_query_tab_body.tsx new file mode 100644 index 0000000000000..cde972d8a66ca --- /dev/null +++ b/x-pack/plugins/security_solution/public/ueba/pages/navigation/risk_score_query_tab_body.tsx @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { getOr } from 'lodash/fp'; +import React from 'react'; +import { useRiskScore } from '../../containers/risk_score'; +import { RiskScoreQueryProps } from './types'; +import { manageQuery } from '../../../common/components/page/manage_query'; +import { RiskScoreTable } from '../../components/risk_score_table'; + +const RiskScoreTableManage = manageQuery(RiskScoreTable); + +export const RiskScoreQueryTabBody = ({ + deleteQuery, + docValueFields, + endDate, + filterQuery, + indexNames, + skip, + setQuery, + startDate, + type, +}: RiskScoreQueryProps) => { + const [ + loading, + { data, totalCount, pageInfo, loadPage, id, inspect, isInspected, refetch }, + ] = useRiskScore({ docValueFields, endDate, filterQuery, indexNames, skip, startDate, type }); + + return ( + + ); +}; + +RiskScoreQueryTabBody.displayName = 'RiskScoreQueryTabBody'; diff --git a/x-pack/plugins/security_solution/public/ueba/pages/navigation/types.ts b/x-pack/plugins/security_solution/public/ueba/pages/navigation/types.ts new file mode 100644 index 0000000000000..e24b3271cf534 --- /dev/null +++ b/x-pack/plugins/security_solution/public/ueba/pages/navigation/types.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 { UebaTableType, UebaType } from '../../store/model'; +import { GlobalTimeArgs } from '../../../common/containers/use_global_time'; +import { ESTermQuery } from '../../../../common/typed_json'; +import { DocValueFields } from '../../../../../timelines/common'; +import { Filter } from '../../../../../../../src/plugins/data/common'; +import { UpdateDateRange } from '../../../common/components/charts/common'; +import { NarrowDateRange } from '../../../common/components/ml/types'; +import { NavTab } from '../../../common/components/navigation/types'; + +type KeyUebaNavTab = UebaTableType.riskScore; + +export type UebaNavTab = Record; +export interface QueryTabBodyProps { + type: UebaType; + startDate: GlobalTimeArgs['from']; + endDate: GlobalTimeArgs['to']; + filterQuery?: string | ESTermQuery; +} + +export type RiskScoreQueryProps = QueryTabBodyProps & { + deleteQuery?: GlobalTimeArgs['deleteQuery']; + docValueFields?: DocValueFields[]; + indexNames: string[]; + pageFilters?: Filter[]; + skip: boolean; + setQuery: GlobalTimeArgs['setQuery']; + updateDateRange?: UpdateDateRange; + narrowDateRange?: NarrowDateRange; +}; +export type HostQueryProps = RiskScoreQueryProps & { + hostName: string; +}; diff --git a/x-pack/plugins/security_solution/public/ueba/pages/navigation/user_rules_query_tab_body.tsx b/x-pack/plugins/security_solution/public/ueba/pages/navigation/user_rules_query_tab_body.tsx new file mode 100644 index 0000000000000..f7542b7b4b8a6 --- /dev/null +++ b/x-pack/plugins/security_solution/public/ueba/pages/navigation/user_rules_query_tab_body.tsx @@ -0,0 +1,70 @@ +/* + * 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 React from 'react'; +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { useUserRules } from '../../containers/user_rules'; +import { HostQueryProps } from './types'; +import { manageQuery } from '../../../common/components/page/manage_query'; +import { HostRulesTable } from '../../components/host_rules_table'; +import { uebaModel } from '../../store'; +import { UserRulesFields } from '../../../../common'; + +const UserRulesTableManage = manageQuery(HostRulesTable); + +export const UserRulesQueryTabBody = ({ + deleteQuery, + docValueFields, + endDate, + filterQuery, + hostName, + indexNames, + skip, + setQuery, + startDate, + type, +}: HostQueryProps) => { + const [loading, { data, loadPage, id, inspect, isInspected, refetch }] = useUserRules({ + docValueFields, + endDate, + filterQuery, + hostName, + indexNames, + skip, + startDate, + type, + }); + return ( + + {data.map((user, i) => ( + + {`Total user risk score: ${user[UserRulesFields.riskScore]}`}

} + headerTitle={`user.name: ${user[UserRulesFields.userName]}`} + fakeTotalCount={getOr(50, 'fakeTotalCount', user.pageInfo)} + id={`${id}${i}`} + inspect={inspect} + isInspect={isInspected} + loading={loading} + loadPage={loadPage} + refetch={refetch} + setQuery={setQuery} + showMorePagesIndicator={getOr(false, 'showMorePagesIndicator', user.pageInfo)} + tableType={uebaModel.UebaTableType.userRules} // pagination will not work until this is unique + totalCount={user.totalCount} + type={type} + /> +
+ ))} +
+ ); +}; + +UserRulesQueryTabBody.displayName = 'UserRulesQueryTabBody'; diff --git a/x-pack/plugins/security_solution/public/ueba/pages/translations.ts b/x-pack/plugins/security_solution/public/ueba/pages/translations.ts new file mode 100644 index 0000000000000..0e6519d9d45ce --- /dev/null +++ b/x-pack/plugins/security_solution/public/ueba/pages/translations.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 { i18n } from '@kbn/i18n'; + +export const PAGE_TITLE = i18n.translate('xpack.securitySolution.ueba.pageTitle', { + defaultMessage: 'Users & Entities', +}); +export const RISK_SCORE_TITLE = i18n.translate('xpack.securitySolution.ueba.riskScore', { + defaultMessage: 'Risk score', +}); + +export const HOST_RULES = i18n.translate('xpack.securitySolution.ueba.hostRules', { + defaultMessage: 'Host risk score by rule', +}); + +export const HOST_TACTICS = i18n.translate('xpack.securitySolution.ueba.hostTactics', { + defaultMessage: 'Host risk score by tactic', +}); + +export const USER_RULES = i18n.translate('xpack.securitySolution.ueba.userRules', { + defaultMessage: 'User risk score by rule', +}); diff --git a/x-pack/plugins/security_solution/public/ueba/pages/types.ts b/x-pack/plugins/security_solution/public/ueba/pages/types.ts new file mode 100644 index 0000000000000..07c4d5fccd066 --- /dev/null +++ b/x-pack/plugins/security_solution/public/ueba/pages/types.ts @@ -0,0 +1,29 @@ +/* + * 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 { ActionCreator } from 'typescript-fsa'; + +import { GlobalTimeArgs } from '../../common/containers/use_global_time'; +import { UEBA_PATH } from '../../../common/constants'; +import { uebaModel } from '../../ueba/store'; +import { DocValueFields } from '../../../../timelines/common'; +import { InputsModelId } from '../../common/store/inputs/constants'; + +export const uebaDetailsPagePath = `${UEBA_PATH}/:detailName`; + +export type UebaTabsProps = GlobalTimeArgs & { + docValueFields: DocValueFields[]; + filterQuery: string; + indexNames: string[]; + type: uebaModel.UebaType; + setAbsoluteRangeDatePicker: ActionCreator<{ + id: InputsModelId; + from: string; + to: string; + }>; +}; + +export type UebaQueryProps = GlobalTimeArgs; diff --git a/x-pack/plugins/security_solution/public/ueba/pages/ueba.tsx b/x-pack/plugins/security_solution/public/ueba/pages/ueba.tsx new file mode 100644 index 0000000000000..4e0041a98454c --- /dev/null +++ b/x-pack/plugins/security_solution/public/ueba/pages/ueba.tsx @@ -0,0 +1,184 @@ +/* + * 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 { EuiSpacer, EuiWindowEvent } from '@elastic/eui'; +import styled from 'styled-components'; +import { noop } from 'lodash/fp'; +import React, { useCallback, useMemo, useRef } from 'react'; +import { isTab } from '../../../../timelines/public'; + +import { SecurityPageName } from '../../app/types'; +import { FiltersGlobal } from '../../common/components/filters_global'; +import { HeaderPage } from '../../common/components/header_page'; +import { LastEventTime } from '../../common/components/last_event_time'; +import { SecuritySolutionTabNavigation } from '../../common/components/navigation'; + +import { SiemSearchBar } from '../../common/components/search_bar'; +import { SecuritySolutionPageWrapper } from '../../common/components/page_wrapper'; +import { useGlobalFullScreen } from '../../common/containers/use_full_screen'; +import { useGlobalTime } from '../../common/containers/use_global_time'; +import { TimelineId } from '../../../common'; +import { LastEventIndexKey } from '../../../common/search_strategy'; +import { useKibana } from '../../common/lib/kibana'; +import { convertToBuildEsQuery } from '../../common/lib/keury'; +import { inputsSelectors } from '../../common/store'; +import { setAbsoluteRangeDatePicker } from '../../common/store/inputs/actions'; + +import { SpyRoute } from '../../common/utils/route/spy_routes'; +import { esQuery } from '../../../../../../src/plugins/data/public'; +import { OverviewEmpty } from '../../overview/components/overview_empty'; +import { Display } from './display'; +import { UebaTabs } from './ueba_tabs'; +import { navTabsUeba } from './nav_tabs'; +import * as i18n from './translations'; +import { uebaModel } from '../store'; +import { + onTimelineTabKeyPressed, + resetKeyboardFocus, + showGlobalFilters, +} from '../../timelines/components/timeline/helpers'; +import { timelineSelectors } from '../../timelines/store/timeline'; +import { timelineDefaults } from '../../timelines/store/timeline/defaults'; +import { useSourcererScope } from '../../common/containers/sourcerer'; +import { useDeepEqualSelector, useShallowEqualSelector } from '../../common/hooks/use_selector'; +import { useInvalidFilterQuery } from '../../common/hooks/use_invalid_filter_query'; + +const ID = 'UebaQueryId'; + +/** + * Need a 100% height here to account for the graph/analyze tool, which sets no explicit height parameters, but fills the available space. + */ +const StyledFullHeightContainer = styled.div` + display: flex; + flex-direction: column; + flex: 1 1 auto; +`; + +const UebaComponent = () => { + const containerElement = useRef(null); + const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []); + const graphEventId = useShallowEqualSelector( + (state) => + (getTimeline(state, TimelineId.uebaPageExternalAlerts) ?? timelineDefaults).graphEventId + ); + const getGlobalFiltersQuerySelector = useMemo( + () => inputsSelectors.globalFiltersQuerySelector(), + [] + ); + const getGlobalQuerySelector = useMemo(() => inputsSelectors.globalQuerySelector(), []); + const query = useDeepEqualSelector(getGlobalQuerySelector); + const filters = useDeepEqualSelector(getGlobalFiltersQuerySelector); + + const { to, from, deleteQuery, setQuery, isInitializing } = useGlobalTime(); + const { globalFullScreen } = useGlobalFullScreen(); + const { uiSettings } = useKibana().services; + const tabsFilters = filters; + + const { docValueFields, indicesExist, indexPattern, selectedPatterns } = useSourcererScope(); + const [filterQuery, kqlError] = useMemo( + () => + convertToBuildEsQuery({ + config: esQuery.getEsQueryConfig(uiSettings), + indexPattern, + queries: [query], + filters, + }), + [filters, indexPattern, uiSettings, query] + ); + const [tabsFilterQuery] = useMemo( + () => + convertToBuildEsQuery({ + config: esQuery.getEsQueryConfig(uiSettings), + indexPattern, + queries: [query], + filters: tabsFilters, + }), + [indexPattern, query, tabsFilters, uiSettings] + ); + + useInvalidFilterQuery({ id: ID, filterQuery, kqlError, query, startDate: from, endDate: to }); + + const onSkipFocusBeforeEventsTable = useCallback(() => { + containerElement.current + ?.querySelector('.inspectButtonComponent:last-of-type') + ?.focus(); + }, [containerElement]); + + const onSkipFocusAfterEventsTable = useCallback(() => { + resetKeyboardFocus(); + }, []); + + const onKeyDown = useCallback( + (keyboardEvent: React.KeyboardEvent) => { + if (isTab(keyboardEvent)) { + onTimelineTabKeyPressed({ + containerElement: containerElement.current, + keyboardEvent, + onSkipFocusBeforeEventsTable, + onSkipFocusAfterEventsTable, + }); + } + }, + [containerElement, onSkipFocusBeforeEventsTable, onSkipFocusAfterEventsTable] + ); + + return ( + <> + {indicesExist ? ( + + + + + + + + + + } + title={i18n.PAGE_TITLE} + /> + + + + + + + + + + ) : ( + + + + + + )} + + + + ); +}; +UebaComponent.displayName = 'UebaComponent'; + +export const Ueba = React.memo(UebaComponent); diff --git a/x-pack/plugins/security_solution/public/ueba/pages/ueba_tabs.tsx b/x-pack/plugins/security_solution/public/ueba/pages/ueba_tabs.tsx new file mode 100644 index 0000000000000..b6ae4419b609a --- /dev/null +++ b/x-pack/plugins/security_solution/public/ueba/pages/ueba_tabs.tsx @@ -0,0 +1,82 @@ +/* + * 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, { memo, useCallback } from 'react'; +import { Route, Switch } from 'react-router-dom'; + +import { UebaTabsProps } from './types'; +import { scoreIntervalToDateTime } from '../../common/components/ml/score/score_interval_to_datetime'; +import { Anomaly } from '../../common/components/ml/types'; +import { UebaTableType } from '../store/model'; +import { UpdateDateRange } from '../../common/components/charts/common'; +import { UEBA_PATH } from '../../../common/constants'; +import { RiskScoreQueryTabBody } from './navigation'; + +export const UebaTabs = memo( + ({ + deleteQuery, + docValueFields, + filterQuery, + from, + indexNames, + isInitializing, + setAbsoluteRangeDatePicker, + setQuery, + to, + type, + }) => { + const narrowDateRange = useCallback( + (score: Anomaly, interval: string) => { + const fromTo = scoreIntervalToDateTime(score, interval); + setAbsoluteRangeDatePicker({ + id: 'global', + from: fromTo.from, + to: fromTo.to, + }); + }, + [setAbsoluteRangeDatePicker] + ); + + const updateDateRange = useCallback( + ({ x }) => { + if (!x) { + return; + } + const [min, max] = x; + setAbsoluteRangeDatePicker({ + id: 'global', + from: new Date(min).toISOString(), + to: new Date(max).toISOString(), + }); + }, + [setAbsoluteRangeDatePicker] + ); + + const tabProps = { + deleteQuery, + endDate: to, + filterQuery, + indexNames, + skip: isInitializing || filterQuery === undefined, + setQuery, + startDate: from, + type, + narrowDateRange, + updateDateRange, + }; + + return ( + + + + + + ); + } +); + +UebaTabs.displayName = 'UebaTabs'; diff --git a/x-pack/plugins/security_solution/public/ueba/routes.tsx b/x-pack/plugins/security_solution/public/ueba/routes.tsx new file mode 100644 index 0000000000000..4d761856155e3 --- /dev/null +++ b/x-pack/plugins/security_solution/public/ueba/routes.tsx @@ -0,0 +1,26 @@ +/* + * 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 { UebaContainer } from './pages'; + +import { TrackApplicationView } from '../../../../../src/plugins/usage_collection/public'; +import { SecurityPageName, SecuritySubPluginRoutes } from '../app/types'; +import { UEBA_PATH } from '../../common/constants'; + +export const UebaRoutes = () => ( + + + +); + +export const routes: SecuritySubPluginRoutes = [ + { + path: UEBA_PATH, + render: UebaRoutes, + }, +]; diff --git a/x-pack/plugins/security_solution/public/ueba/store/actions.ts b/x-pack/plugins/security_solution/public/ueba/store/actions.ts new file mode 100644 index 0000000000000..72ec2ff425d20 --- /dev/null +++ b/x-pack/plugins/security_solution/public/ueba/store/actions.ts @@ -0,0 +1,35 @@ +/* + * 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 actionCreatorFactory from 'typescript-fsa'; +import { uebaModel } from '.'; + +const actionCreator = actionCreatorFactory('x-pack/security_solution/local/ueba'); + +export const updateUebaTable = actionCreator<{ + uebaType: uebaModel.UebaType; + tableType: uebaModel.UebaTableType | uebaModel.UebaTableType; + updates: uebaModel.TableUpdates; +}>('UPDATE_NETWORK_TABLE'); + +export const setUebaDetailsTablesActivePageToZero = actionCreator( + 'SET_UEBA_DETAILS_TABLES_ACTIVE_PAGE_TO_ZERO' +); + +export const setUebaTablesActivePageToZero = actionCreator('SET_UEBA_TABLES_ACTIVE_PAGE_TO_ZERO'); + +export const updateTableLimit = actionCreator<{ + uebaType: uebaModel.UebaType; + limit: number; + tableType: uebaModel.UebaTableType; +}>('UPDATE_UEBA_TABLE_LIMIT'); + +export const updateTableActivePage = actionCreator<{ + uebaType: uebaModel.UebaType; + activePage: number; + tableType: uebaModel.UebaTableType; +}>('UPDATE_UEBA_ACTIVE_PAGE'); diff --git a/x-pack/plugins/security_solution/public/ueba/store/helpers.ts b/x-pack/plugins/security_solution/public/ueba/store/helpers.ts new file mode 100644 index 0000000000000..653cf30fac484 --- /dev/null +++ b/x-pack/plugins/security_solution/public/ueba/store/helpers.ts @@ -0,0 +1,45 @@ +/* + * 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 { UebaModel, UebaType, UebaTableType, UebaQueries, UebaDetailsQueries } from './model'; +import { DEFAULT_TABLE_ACTIVE_PAGE } from '../../common/store/constants'; + +export const setUebaPageQueriesActivePageToZero = (state: UebaModel): UebaQueries => ({ + ...state.page.queries, + [UebaTableType.riskScore]: { + ...state.page.queries[UebaTableType.riskScore], + activePage: DEFAULT_TABLE_ACTIVE_PAGE, + }, +}); + +export const setUebaDetailsQueriesActivePageToZero = (state: UebaModel): UebaDetailsQueries => ({ + ...state.details.queries, + [UebaTableType.hostRules]: { + ...state.details.queries[UebaTableType.hostRules], + activePage: DEFAULT_TABLE_ACTIVE_PAGE, + }, + [UebaTableType.hostTactics]: { + ...state.details.queries[UebaTableType.hostTactics], + activePage: DEFAULT_TABLE_ACTIVE_PAGE, + }, + [UebaTableType.userRules]: { + ...state.details.queries[UebaTableType.userRules], + activePage: DEFAULT_TABLE_ACTIVE_PAGE, + }, +}); + +export const setUebaQueriesActivePageToZero = ( + state: UebaModel, + type: UebaType +): UebaQueries | UebaDetailsQueries => { + if (type === UebaType.page) { + return setUebaPageQueriesActivePageToZero(state); + } else if (type === UebaType.details) { + return setUebaDetailsQueriesActivePageToZero(state); + } + throw new Error(`UebaType ${type} is unknown`); +}; diff --git a/x-pack/plugins/security_solution/public/ueba/store/index.ts b/x-pack/plugins/security_solution/public/ueba/store/index.ts new file mode 100644 index 0000000000000..8538509e58d4b --- /dev/null +++ b/x-pack/plugins/security_solution/public/ueba/store/index.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 { Reducer, AnyAction } from 'redux'; +import * as uebaActions from './actions'; +import * as uebaModel from './model'; +import * as uebaSelectors from './selectors'; + +export { uebaActions, uebaModel, uebaSelectors }; +export * from './reducer'; + +export interface UebaPluginState { + ueba: uebaModel.UebaModel; +} + +export interface UebaPluginReducer { + ueba: Reducer; +} diff --git a/x-pack/plugins/security_solution/public/ueba/store/model.ts b/x-pack/plugins/security_solution/public/ueba/store/model.ts new file mode 100644 index 0000000000000..9e9f39977c8ef --- /dev/null +++ b/x-pack/plugins/security_solution/public/ueba/store/model.ts @@ -0,0 +1,78 @@ +/* + * 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 { + HostRulesSortField, + HostTacticsSortField, + RiskScoreFields, + RiskScoreSortField, + SortField, + UserRulesSortField, +} from '../../../common/search_strategy'; + +export enum UebaType { + page = 'page', + details = 'details', +} + +export enum UebaTableType { + riskScore = 'riskScore', + hostRules = 'hostRules', + hostTactics = 'hostTactics', + userRules = 'userRules', +} + +export type AllUebaTables = UebaTableType; + +export interface BasicQueryPaginated { + activePage: number; + limit: number; +} + +// Ueba Page Models +export interface RiskScoreQuery extends BasicQueryPaginated { + sort: RiskScoreSortField; +} +export interface HostRulesQuery extends BasicQueryPaginated { + sort: HostRulesSortField; +} +export interface UserRulesQuery extends BasicQueryPaginated { + sort: UserRulesSortField; +} +export interface HostTacticsQuery extends BasicQueryPaginated { + sort: HostTacticsSortField; +} + +export interface TableUpdates { + activePage?: number; + limit?: number; + isPtrIncluded?: boolean; + sort?: SortField; +} + +export interface UebaQueries { + [UebaTableType.riskScore]: RiskScoreQuery; +} + +export interface UebaPageModel { + queries: UebaQueries; +} + +export interface UebaDetailsQueries { + [UebaTableType.hostRules]: HostRulesQuery; + [UebaTableType.hostTactics]: HostTacticsQuery; + [UebaTableType.userRules]: UserRulesQuery; +} + +export interface UebaDetailsModel { + queries: UebaDetailsQueries; +} + +export interface UebaModel { + [UebaType.page]: UebaPageModel; + [UebaType.details]: UebaDetailsModel; +} diff --git a/x-pack/plugins/security_solution/public/ueba/store/reducer.ts b/x-pack/plugins/security_solution/public/ueba/store/reducer.ts new file mode 100644 index 0000000000000..f981868c21eb1 --- /dev/null +++ b/x-pack/plugins/security_solution/public/ueba/store/reducer.ts @@ -0,0 +1,136 @@ +/* + * 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 { reducerWithInitialState } from 'typescript-fsa-reducers'; +import { get } from 'lodash/fp'; +import { + Direction, + HostRulesFields, + HostTacticsFields, + RiskScoreFields, +} from '../../../common/search_strategy'; +import { DEFAULT_TABLE_ACTIVE_PAGE, DEFAULT_TABLE_LIMIT } from '../../common/store/constants'; + +import { + setUebaDetailsTablesActivePageToZero, + setUebaTablesActivePageToZero, + updateUebaTable, + updateTableActivePage, + updateTableLimit, +} from './actions'; +import { + setUebaDetailsQueriesActivePageToZero, + setUebaPageQueriesActivePageToZero, +} from './helpers'; +import { UebaTableType, UebaModel } from './model'; + +export const initialUebaState: UebaModel = { + page: { + queries: { + [UebaTableType.riskScore]: { + activePage: DEFAULT_TABLE_ACTIVE_PAGE, + limit: DEFAULT_TABLE_LIMIT, + sort: { + field: RiskScoreFields.riskScore, + direction: Direction.desc, + }, + }, + }, + }, + details: { + queries: { + [UebaTableType.hostRules]: { + activePage: DEFAULT_TABLE_ACTIVE_PAGE, + limit: DEFAULT_TABLE_LIMIT, + sort: { + field: HostRulesFields.riskScore, + direction: Direction.desc, + }, + }, + [UebaTableType.hostTactics]: { + activePage: DEFAULT_TABLE_ACTIVE_PAGE, + limit: DEFAULT_TABLE_LIMIT, + sort: { + field: HostTacticsFields.riskScore, + direction: Direction.desc, + }, + }, + [UebaTableType.userRules]: { + activePage: DEFAULT_TABLE_ACTIVE_PAGE, + limit: DEFAULT_TABLE_LIMIT, + sort: { + field: HostRulesFields.riskScore, // this looks wrong but its right, the user "table" is an array of host tables + direction: Direction.desc, + }, + }, + }, + }, +}; + +export const uebaReducer = reducerWithInitialState(initialUebaState) + .case(updateUebaTable, (state, { uebaType, tableType, updates }) => ({ + ...state, + [uebaType]: { + ...state[uebaType], + queries: { + ...state[uebaType].queries, + [tableType]: { + ...get([uebaType, 'queries', tableType], state), + ...updates, + }, + }, + }, + })) + .case(setUebaTablesActivePageToZero, (state) => ({ + ...state, + page: { + ...state.page, + queries: setUebaPageQueriesActivePageToZero(state), + }, + details: { + ...state.details, + queries: setUebaDetailsQueriesActivePageToZero(state), + }, + })) + .case(setUebaDetailsTablesActivePageToZero, (state) => ({ + ...state, + details: { + ...state.details, + queries: setUebaDetailsQueriesActivePageToZero(state), + }, + })) + .case(updateTableActivePage, (state, { activePage, uebaType, tableType }) => ({ + ...state, + [uebaType]: { + ...state[uebaType], + queries: { + ...state[uebaType].queries, + [tableType]: { + // TODO: Steph/ueba fix active page/limit on ueba tables. is broken because multiple UebaTableType.userRules tables + // @ts-ignore + ...state[uebaType].queries[tableType], + activePage, + }, + }, + }, + })) + .case(updateTableLimit, (state, { limit, uebaType, tableType }) => ({ + ...state, + [uebaType]: { + ...state[uebaType], + queries: { + ...state[uebaType].queries, + [tableType]: { + // TODO: Steph/ueba fix active page/limit on ueba tables. is broken because multiple UebaTableType.userRules tables + // @ts-ignore + ...state[uebaType].queries[tableType], + limit, + }, + }, + }, + })) + .build(); diff --git a/x-pack/plugins/security_solution/public/ueba/store/selectors.ts b/x-pack/plugins/security_solution/public/ueba/store/selectors.ts new file mode 100644 index 0000000000000..a3d7a5f8a8867 --- /dev/null +++ b/x-pack/plugins/security_solution/public/ueba/store/selectors.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 { createSelector } from 'reselect'; + +import { State } from '../../common/store/types'; + +import { UebaDetailsModel, UebaPageModel, UebaTableType } from './model'; + +const selectUebaPage = (state: State): UebaPageModel => state.ueba.page; +const selectUebaDetailsPage = (state: State): UebaDetailsModel => state.ueba.details; + +export const riskScoreSelector = () => + createSelector(selectUebaPage, (ueba) => ueba.queries[UebaTableType.riskScore]); + +export const hostRulesSelector = () => + createSelector(selectUebaDetailsPage, (ueba) => ueba.queries[UebaTableType.hostRules]); + +export const hostTacticsSelector = () => + createSelector(selectUebaDetailsPage, (ueba) => ueba.queries[UebaTableType.hostTactics]); + +export const userRulesSelector = () => + createSelector(selectUebaDetailsPage, (ueba) => ueba.queries[UebaTableType.userRules]); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/executors/eql.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/executors/eql.test.ts index a1d7d03f313db..e98e9b49b3646 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/executors/eql.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/executors/eql.test.ts @@ -16,6 +16,7 @@ import { getIndexVersion } from '../../routes/index/get_index_version'; import { SIGNALS_TEMPLATE_VERSION } from '../../routes/index/get_signals_template'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { elasticsearchClientMock } from 'src/core/server/elasticsearch/client/mocks'; +import { allowedExperimentalValues } from '../../../../../common/experimental_features'; jest.mock('../../routes/index/get_index_version'); @@ -73,6 +74,7 @@ describe('eql_executor', () => { rule: eqlSO, tuple, exceptionItems, + experimentalFeatures: allowedExperimentalValues, services: alertServices, version, logger, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/executors/eql.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/executors/eql.ts index e08f519e9761a..8d19510c63477 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/executors/eql.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/executors/eql.ts @@ -34,11 +34,13 @@ import { SimpleHit, } from '../types'; import { createSearchAfterReturnType, makeFloatString } from '../utils'; +import { ExperimentalFeatures } from '../../../../../common/experimental_features'; export const eqlExecutor = async ({ rule, tuple, exceptionItems, + experimentalFeatures, services, version, logger, @@ -50,6 +52,7 @@ export const eqlExecutor = async ({ rule: SavedObject>; tuple: RuleRangeTuple; exceptionItems: ExceptionListItemSchema[]; + experimentalFeatures: ExperimentalFeatures; services: AlertServices; version: string; logger: Logger; @@ -85,7 +88,12 @@ export const eqlExecutor = async ({ throw err; } } - const inputIndex = await getInputIndex(services, version, ruleParams.index); + const inputIndex = await getInputIndex({ + experimentalFeatures, + services, + version, + index: ruleParams.index, + }); const request = buildEqlSearchRequest( ruleParams.query, inputIndex, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/executors/query.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/executors/query.ts index 385c01c2f1cda..454cb464506a9 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/executors/query.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/executors/query.ts @@ -21,12 +21,14 @@ import { AlertAttributes, RuleRangeTuple, BulkCreate, WrapHits } from '../types' import { TelemetryEventsSender } from '../../../telemetry/sender'; import { BuildRuleMessage } from '../rule_messages'; import { QueryRuleParams, SavedQueryRuleParams } from '../../schemas/rule_schemas'; +import { ExperimentalFeatures } from '../../../../../common/experimental_features'; export const queryExecutor = async ({ rule, tuple, listClient, exceptionItems, + experimentalFeatures, services, version, searchAfterSize, @@ -40,6 +42,7 @@ export const queryExecutor = async ({ tuple: RuleRangeTuple; listClient: ListClient; exceptionItems: ExceptionListItemSchema[]; + experimentalFeatures: ExperimentalFeatures; services: AlertServices; version: string; searchAfterSize: number; @@ -50,7 +53,12 @@ export const queryExecutor = async ({ wrapHits: WrapHits; }) => { const ruleParams = rule.attributes.params; - const inputIndex = await getInputIndex(services, version, ruleParams.index); + const inputIndex = await getInputIndex({ + experimentalFeatures, + services, + version, + index: ruleParams.index, + }); const esFilter = await getFilter({ type: ruleParams.type, filters: ruleParams.filters, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/executors/threat_match.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/executors/threat_match.ts index d0e22f696b222..37b2c53636cfd 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/executors/threat_match.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/executors/threat_match.ts @@ -20,6 +20,7 @@ import { TelemetryEventsSender } from '../../../telemetry/sender'; import { BuildRuleMessage } from '../rule_messages'; import { createThreatSignals } from '../threat_mapping/create_threat_signals'; import { ThreatRuleParams } from '../../schemas/rule_schemas'; +import { ExperimentalFeatures } from '../../../../../common/experimental_features'; export const threatMatchExecutor = async ({ rule, @@ -31,6 +32,7 @@ export const threatMatchExecutor = async ({ searchAfterSize, logger, eventsTelemetry, + experimentalFeatures, buildRuleMessage, bulkCreate, wrapHits, @@ -44,12 +46,18 @@ export const threatMatchExecutor = async ({ searchAfterSize: number; logger: Logger; eventsTelemetry: TelemetryEventsSender | undefined; + experimentalFeatures: ExperimentalFeatures; buildRuleMessage: BuildRuleMessage; bulkCreate: BulkCreate; wrapHits: WrapHits; }) => { const ruleParams = rule.attributes.params; - const inputIndex = await getInputIndex(services, version, ruleParams.index); + const inputIndex = await getInputIndex({ + experimentalFeatures, + services, + version, + index: ruleParams.index, + }); return createThreatSignals({ tuple, threatMapping: ruleParams.threatMapping, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/executors/threshold.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/executors/threshold.test.ts index 3906c66922238..afcb3707591fc 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/executors/threshold.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/executors/threshold.test.ts @@ -16,6 +16,7 @@ import { getEntryListMock } from '../../../../../../lists/common/schemas/types/e import { getThresholdRuleParams } from '../../schemas/rule_schemas.mock'; import { buildRuleMessageFactory } from '../rule_messages'; import { sampleEmptyDocSearchResults } from '../__mocks__/es_results'; +import { allowedExperimentalValues } from '../../../../../common/experimental_features'; describe('threshold_executor', () => { const version = '8.0.0'; @@ -70,6 +71,7 @@ describe('threshold_executor', () => { rule: thresholdSO, tuple, exceptionItems, + experimentalFeatures: allowedExperimentalValues, services: alertServices, version, logger, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/executors/threshold.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/executors/threshold.ts index 378d68fc13d2a..ffd90f3b90b91 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/executors/threshold.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/executors/threshold.ts @@ -36,11 +36,13 @@ import { mergeReturns, } from '../utils'; import { BuildRuleMessage } from '../rule_messages'; +import { ExperimentalFeatures } from '../../../../../common/experimental_features'; export const thresholdExecutor = async ({ rule, tuple, exceptionItems, + experimentalFeatures, services, version, logger, @@ -52,6 +54,7 @@ export const thresholdExecutor = async ({ rule: SavedObject>; tuple: RuleRangeTuple; exceptionItems: ExceptionListItemSchema[]; + experimentalFeatures: ExperimentalFeatures; services: AlertServices; version: string; logger: Logger; @@ -68,7 +71,12 @@ export const thresholdExecutor = async ({ ); result.warning = true; } - const inputIndex = await getInputIndex(services, version, ruleParams.index); + const inputIndex = await getInputIndex({ + experimentalFeatures, + services, + version, + index: ruleParams.index, + }); const { thresholdSignalHistory, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/get_input_output_index.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/get_input_output_index.test.ts index 9c4bf37aca789..5058056b169a3 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/get_input_output_index.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/get_input_output_index.test.ts @@ -7,7 +7,7 @@ import { alertsMock, AlertServicesMock } from '../../../../../alerting/server/mocks'; import { DEFAULT_INDEX_KEY, DEFAULT_INDEX_PATTERN } from '../../../../common/constants'; -import { getInputIndex } from './get_input_output_index'; +import { getInputIndex, GetInputIndex } from './get_input_output_index'; describe('get_input_output_index', () => { let servicesMock: AlertServicesMock; @@ -19,7 +19,7 @@ describe('get_input_output_index', () => { afterAll(() => { jest.resetAllMocks(); }); - + let defaultProps: GetInputIndex; beforeEach(() => { servicesMock = alertsMock.createAlertServices(); servicesMock.savedObjectsClient.get.mockImplementation(async (type: string, id: string) => ({ @@ -28,6 +28,18 @@ describe('get_input_output_index', () => { references: [], attributes: {}, })); + defaultProps = { + services: servicesMock, + version: '8.0.0', + index: ['test-input-index-1'], + experimentalFeatures: { + trustedAppsByPolicyEnabled: false, + metricsEntitiesEnabled: false, + ruleRegistryEnabled: false, + tGridEnabled: false, + uebaEnabled: false, + }, + }; }); describe('getInputOutputIndex', () => { @@ -38,7 +50,7 @@ describe('get_input_output_index', () => { references: [], attributes: {}, })); - const inputIndex = await getInputIndex(servicesMock, '8.0.0', ['test-input-index-1']); + const inputIndex = await getInputIndex(defaultProps); expect(inputIndex).toEqual(['test-input-index-1']); }); @@ -51,7 +63,10 @@ describe('get_input_output_index', () => { [DEFAULT_INDEX_KEY]: ['configured-index-1', 'configured-index-2'], }, })); - const inputIndex = await getInputIndex(servicesMock, '8.0.0', undefined); + const inputIndex = await getInputIndex({ + ...defaultProps, + index: undefined, + }); expect(inputIndex).toEqual(['configured-index-1', 'configured-index-2']); }); @@ -64,7 +79,10 @@ describe('get_input_output_index', () => { [DEFAULT_INDEX_KEY]: ['configured-index-1', 'configured-index-2'], }, })); - const inputIndex = await getInputIndex(servicesMock, '8.0.0', null); + const inputIndex = await getInputIndex({ + ...defaultProps, + index: null, + }); expect(inputIndex).toEqual(['configured-index-1', 'configured-index-2']); }); @@ -77,7 +95,26 @@ describe('get_input_output_index', () => { [DEFAULT_INDEX_KEY]: null, }, })); - const inputIndex = await getInputIndex(servicesMock, '8.0.0', null); + const inputIndex = await getInputIndex({ + ...defaultProps, + index: null, + }); + expect(inputIndex).toEqual(DEFAULT_INDEX_PATTERN); + }); + + test('Returns a saved object inputIndex default along with experimental features when uebaEnabled=true', async () => { + servicesMock.savedObjectsClient.get.mockImplementation(async (type: string, id: string) => ({ + id, + type, + references: [], + attributes: { + [DEFAULT_INDEX_KEY]: null, + }, + })); + const inputIndex = await getInputIndex({ + ...defaultProps, + index: null, + }); expect(inputIndex).toEqual(DEFAULT_INDEX_PATTERN); }); @@ -90,17 +127,26 @@ describe('get_input_output_index', () => { [DEFAULT_INDEX_KEY]: null, }, })); - const inputIndex = await getInputIndex(servicesMock, '8.0.0', undefined); + const inputIndex = await getInputIndex({ + ...defaultProps, + index: undefined, + }); expect(inputIndex).toEqual(DEFAULT_INDEX_PATTERN); }); test('Returns a saved object inputIndex default from constants if both passed in inputIndex and configuration attributes are missing and the index is undefined', async () => { - const inputIndex = await getInputIndex(servicesMock, '8.0.0', undefined); + const inputIndex = await getInputIndex({ + ...defaultProps, + index: undefined, + }); expect(inputIndex).toEqual(DEFAULT_INDEX_PATTERN); }); test('Returns a saved object inputIndex default from constants if both passed in inputIndex and configuration attributes are missing and the index is null', async () => { - const inputIndex = await getInputIndex(servicesMock, '8.0.0', null); + const inputIndex = await getInputIndex({ + ...defaultProps, + index: null, + }); expect(inputIndex).toEqual(DEFAULT_INDEX_PATTERN); }); }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/get_input_output_index.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/get_input_output_index.ts index f0c62bee7aec9..d3b60f1e9a281 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/get_input_output_index.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/get_input_output_index.ts @@ -5,20 +5,33 @@ * 2.0. */ -import { DEFAULT_INDEX_KEY, DEFAULT_INDEX_PATTERN } from '../../../../common/constants'; +import { + DEFAULT_INDEX_KEY, + DEFAULT_INDEX_PATTERN, + DEFAULT_INDEX_PATTERN_EXPERIMENTAL, +} from '../../../../common/constants'; import { AlertInstanceContext, AlertInstanceState, AlertServices, } from '../../../../../alerting/server'; +import { ExperimentalFeatures } from '../../../../common/experimental_features'; + +export interface GetInputIndex { + experimentalFeatures: ExperimentalFeatures; + index: string[] | null | undefined; + services: AlertServices; + version: string; +} -export const getInputIndex = async ( - services: AlertServices, - version: string, - inputIndex: string[] | null | undefined -): Promise => { - if (inputIndex != null) { - return inputIndex; +export const getInputIndex = async ({ + experimentalFeatures, + index, + services, + version, +}: GetInputIndex): Promise => { + if (index != null) { + return index; } else { const configuration = await services.savedObjectsClient.get<{ 'securitySolution:defaultIndex': string[]; @@ -26,7 +39,9 @@ export const getInputIndex = async ( if (configuration.attributes != null && configuration.attributes[DEFAULT_INDEX_KEY] != null) { return configuration.attributes[DEFAULT_INDEX_KEY]; } else { - return DEFAULT_INDEX_PATTERN; + return experimentalFeatures.uebaEnabled + ? [...DEFAULT_INDEX_PATTERN, ...DEFAULT_INDEX_PATTERN_EXPERIMENTAL] + : DEFAULT_INDEX_PATTERN; } } }; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.test.ts index aec8b6c552b1d..a14c678d27536 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.test.ts @@ -33,6 +33,7 @@ import { queryExecutor } from './executors/query'; import { mlExecutor } from './executors/ml'; import { getMlRuleParams, getQueryRuleParams } from '../schemas/rule_schemas.mock'; import { ResponseError } from '@elastic/elasticsearch/lib/errors'; +import { allowedExperimentalValues } from '../../../../common/experimental_features'; jest.mock('./rule_status_saved_objects_client'); jest.mock('./rule_status_service'); @@ -188,6 +189,7 @@ describe('signal_rule_alert_type', () => { payload = getPayload(ruleAlert, alertServices) as jest.Mocked; alert = signalRulesAlertType({ + experimentalFeatures: allowedExperimentalValues, logger, eventsTelemetry: undefined, version, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.ts index 6eef97b05b697..d524757b7c144 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.ts @@ -69,10 +69,12 @@ import { bulkCreateFactory } from './bulk_create_factory'; import { wrapHitsFactory } from './wrap_hits_factory'; import { wrapSequencesFactory } from './wrap_sequences_factory'; import { ConfigType } from '../../../config'; +import { ExperimentalFeatures } from '../../../../common/experimental_features'; export const signalRulesAlertType = ({ logger, eventsTelemetry, + experimentalFeatures, version, ml, lists, @@ -80,6 +82,7 @@ export const signalRulesAlertType = ({ }: { logger: Logger; eventsTelemetry: TelemetryEventsSender | undefined; + experimentalFeatures: ExperimentalFeatures; version: string; ml: SetupPlugins['ml']; lists: SetupPlugins['lists'] | undefined; @@ -153,7 +156,12 @@ export const signalRulesAlertType = ({ if (!isMachineLearningParams(params)) { const index = params.index; const hasTimestampOverride = timestampOverride != null && !isEmpty(timestampOverride); - const inputIndices = await getInputIndex(services, version, index); + const inputIndices = await getInputIndex({ + services, + version, + index, + experimentalFeatures, + }); const [privileges, timestampFieldCaps] = await Promise.all([ checkPrivileges(services, inputIndices), services.scopedClusterClient.asCurrentUser.fieldCaps({ @@ -268,6 +276,7 @@ export const signalRulesAlertType = ({ rule: thresholdRuleSO, tuple, exceptionItems, + experimentalFeatures, services, version, logger, @@ -285,6 +294,7 @@ export const signalRulesAlertType = ({ tuple, listClient, exceptionItems, + experimentalFeatures, services, version, searchAfterSize, @@ -303,6 +313,7 @@ export const signalRulesAlertType = ({ tuple, listClient, exceptionItems, + experimentalFeatures, services, version, searchAfterSize, @@ -320,6 +331,7 @@ export const signalRulesAlertType = ({ rule: eqlRuleSO, tuple, exceptionItems, + experimentalFeatures, services, version, searchAfterSize, diff --git a/x-pack/plugins/security_solution/server/plugin.ts b/x-pack/plugins/security_solution/server/plugin.ts index 9d2e918d4f274..4a346581b7767 100644 --- a/x-pack/plugins/security_solution/server/plugin.ts +++ b/x-pack/plugins/security_solution/server/plugin.ts @@ -290,6 +290,7 @@ export class Plugin implements IPlugin > = { ...hostsFactory, + ...uebaFactory, ...matrixHistogramFactory, ...networkFactory, ...ctiFactoryTypes, diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/ueba/host_rules/helpers.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/ueba/host_rules/helpers.ts new file mode 100644 index 0000000000000..f9c94eea3ff29 --- /dev/null +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/ueba/host_rules/helpers.ts @@ -0,0 +1,24 @@ +/* + * 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 { HostRulesHit, HostRulesEdges, HostRulesFields } from '../../../../../../common'; + +export const formatHostRulesData = (buckets: HostRulesHit[]): HostRulesEdges[] => + buckets.map((bucket) => ({ + node: { + _id: bucket.key, + [HostRulesFields.hits]: bucket.doc_count, + [HostRulesFields.riskScore]: getOr(0, 'risk_score.value', bucket), + [HostRulesFields.ruleName]: bucket.key, + [HostRulesFields.ruleType]: getOr(0, 'rule_type.buckets[0].key', bucket), + }, + cursor: { + value: bucket.key, + tiebreaker: null, + }, + })); diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/ueba/host_rules/index.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/ueba/host_rules/index.ts new file mode 100644 index 0000000000000..39fa7193fd5d2 --- /dev/null +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/ueba/host_rules/index.ts @@ -0,0 +1,59 @@ +/* + * 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 { SecuritySolutionFactory } from '../../types'; +import { + HostRulesEdges, + HostRulesRequestOptions, + HostRulesStrategyResponse, + UebaQueries, +} from '../../../../../../common'; +import { DEFAULT_MAX_TABLE_QUERY_SIZE } from '../../../../../../common/constants'; +import { buildHostRulesQuery } from './query.host_rules.dsl'; +import { IEsSearchResponse } from '../../../../../../../../../src/plugins/data/common'; +import { formatHostRulesData } from './helpers'; +import { inspectStringifyObject } from '../../../../../utils/build_query'; + +export const hostRules: SecuritySolutionFactory = { + buildDsl: (options: HostRulesRequestOptions) => { + if (options.pagination && options.pagination.querySize >= DEFAULT_MAX_TABLE_QUERY_SIZE) { + throw new Error(`No query size above ${DEFAULT_MAX_TABLE_QUERY_SIZE}`); + } + + return buildHostRulesQuery(options); + }, + parse: async ( + options: HostRulesRequestOptions, + response: IEsSearchResponse + ): Promise => { + const { activePage, cursorStart, fakePossibleCount, querySize } = options.pagination; + const totalCount = getOr(0, 'aggregations.rule_count.value', response.rawResponse); + const fakeTotalCount = fakePossibleCount <= totalCount ? fakePossibleCount : totalCount; + + const hostRulesEdges: HostRulesEdges[] = formatHostRulesData( + getOr([], 'aggregations.rule_name.buckets', response.rawResponse) + ); + + const edges = hostRulesEdges.splice(cursorStart, querySize - cursorStart); + const inspect = { + dsl: [inspectStringifyObject(buildHostRulesQuery(options))], + }; + const showMorePagesIndicator = totalCount > fakeTotalCount; + return { + ...response, + inspect, + edges, + totalCount, + pageInfo: { + activePage: activePage ?? 0, + fakeTotalCount, + showMorePagesIndicator, + }, + }; + }, +}; diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/ueba/host_rules/query.host_rules.dsl.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/ueba/host_rules/query.host_rules.dsl.ts new file mode 100644 index 0000000000000..4c116104b3e14 --- /dev/null +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/ueba/host_rules/query.host_rules.dsl.ts @@ -0,0 +1,86 @@ +/* + * 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 { isEmpty } from 'lodash/fp'; +import { Direction, HostRulesRequestOptions } from '../../../../../../common/search_strategy'; +import { createQueryFilterClauses } from '../../../../../utils/build_query'; + +export const buildHostRulesQuery = ({ + defaultIndex, + docValueFields, + filterQuery, + hostName, + timerange: { from, to }, +}: HostRulesRequestOptions) => { + const filter = [ + ...createQueryFilterClauses(filterQuery), + { + range: { + '@timestamp': { + gte: from, + lte: to, + format: 'strict_date_optional_time', + }, + }, + }, + ]; + + return { + allowNoIndices: true, + index: defaultIndex, // can stop getting this from sourcerer and assume default detections index if we want + ignoreUnavailable: true, + track_total_hits: true, + body: { + ...(!isEmpty(docValueFields) ? { docvalue_fields: docValueFields } : {}), + aggs: { + risk_score: { + sum: { + field: 'signal.rule.risk_score', + }, + }, + rule_name: { + terms: { + field: 'signal.rule.name', + order: { + risk_score: Direction.desc, + }, + }, + aggs: { + risk_score: { + sum: { + field: 'signal.rule.risk_score', + }, + }, + rule_type: { + terms: { + field: 'signal.rule.type', + }, + }, + }, + }, + rule_count: { + cardinality: { + field: 'signal.rule.name', + }, + }, + }, + query: { + bool: { + filter, + must: [ + { + term: { + 'host.name': hostName, + }, + }, + ], + }, + }, + size: 0, + }, + }; +}; diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/ueba/host_tactics/helpers.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/ueba/host_tactics/helpers.ts new file mode 100644 index 0000000000000..b20cf4582c824 --- /dev/null +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/ueba/host_tactics/helpers.ts @@ -0,0 +1,47 @@ +/* + * 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 { + HostTacticsHit, + HostTacticsEdges, + HostTacticsFields, + HostTechniqueHit, +} from '../../../../../../common'; + +export const formatHostTacticsData = (buckets: HostTacticsHit[]): HostTacticsEdges[] => + buckets.reduce((acc: HostTacticsEdges[], bucket) => { + return [ + ...acc, + ...getOr([], 'technique.buckets', bucket).map((t: HostTechniqueHit) => ({ + node: { + _id: bucket.key + t.key, + [HostTacticsFields.hits]: t.doc_count, + [HostTacticsFields.riskScore]: getOr(0, 'risk_score.value', t), + [HostTacticsFields.tactic]: bucket.key, + [HostTacticsFields.technique]: t.key, + }, + cursor: { + value: bucket.key + t.key, + tiebreaker: null, + }, + })), + ]; + }, []); +// buckets.map((bucket) => ({ +// node: { +// _id: bucket.key, +// [HostTacticsFields.hits]: bucket.doc_count, +// [HostTacticsFields.riskScore]: getOr(0, 'risk_score.value', bucket), +// [HostTacticsFields.tactic]: bucket.key, +// [HostTacticsFields.technique]: getOr(0, 'technique.buckets[0].key', bucket), +// }, +// cursor: { +// value: bucket.key, +// tiebreaker: null, +// }, +// })); diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/ueba/host_tactics/index.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/ueba/host_tactics/index.ts new file mode 100644 index 0000000000000..0ba8cbef1d144 --- /dev/null +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/ueba/host_tactics/index.ts @@ -0,0 +1,59 @@ +/* + * 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 { SecuritySolutionFactory } from '../../types'; +import { + HostTacticsEdges, + HostTacticsRequestOptions, + HostTacticsStrategyResponse, + UebaQueries, +} from '../../../../../../common'; +import { DEFAULT_MAX_TABLE_QUERY_SIZE } from '../../../../../../common/constants'; +import { buildHostTacticsQuery } from './query.host_tactics.dsl'; +import { IEsSearchResponse } from '../../../../../../../../../src/plugins/data/common'; +import { formatHostTacticsData } from './helpers'; +import { inspectStringifyObject } from '../../../../../utils/build_query'; + +export const hostTactics: SecuritySolutionFactory = { + buildDsl: (options: HostTacticsRequestOptions) => { + if (options.pagination && options.pagination.querySize >= DEFAULT_MAX_TABLE_QUERY_SIZE) { + throw new Error(`No query size above ${DEFAULT_MAX_TABLE_QUERY_SIZE}`); + } + + return buildHostTacticsQuery(options); + }, + parse: async ( + options: HostTacticsRequestOptions, + response: IEsSearchResponse + ): Promise => { + const { activePage, cursorStart, fakePossibleCount, querySize } = options.pagination; + const totalCount = getOr(0, 'aggregations.tactic_count.value', response.rawResponse); + const techniqueCount = getOr(0, 'aggregations.technique_count.value', response.rawResponse); + const fakeTotalCount = fakePossibleCount <= totalCount ? fakePossibleCount : totalCount; + const hostTacticsEdges: HostTacticsEdges[] = formatHostTacticsData( + getOr([], 'aggregations.tactic.buckets', response.rawResponse) + ); + const edges = hostTacticsEdges.splice(cursorStart, querySize - cursorStart); + const inspect = { + dsl: [inspectStringifyObject(buildHostTacticsQuery(options))], + }; + const showMorePagesIndicator = totalCount > fakeTotalCount; + return { + ...response, + inspect, + edges, + techniqueCount, + totalCount, + pageInfo: { + activePage: activePage ?? 0, + fakeTotalCount, + showMorePagesIndicator, + }, + }; + }, +}; diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/ueba/host_tactics/query.host_tactics.dsl.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/ueba/host_tactics/query.host_tactics.dsl.ts new file mode 100644 index 0000000000000..ec1afe247011b --- /dev/null +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/ueba/host_tactics/query.host_tactics.dsl.ts @@ -0,0 +1,90 @@ +/* + * 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 { isEmpty } from 'lodash/fp'; +import { HostTacticsRequestOptions } from '../../../../../../common/search_strategy'; +import { createQueryFilterClauses } from '../../../../../utils/build_query'; + +export const buildHostTacticsQuery = ({ + defaultIndex, + docValueFields, + filterQuery, + hostName, + timerange: { from, to }, +}: HostTacticsRequestOptions) => { + const filter = [ + ...createQueryFilterClauses(filterQuery), + { + range: { + '@timestamp': { + gte: from, + lte: to, + format: 'strict_date_optional_time', + }, + }, + }, + ]; + + return { + allowNoIndices: true, + index: defaultIndex, // can stop getting this from sourcerer and assume default detections index if we want + ignoreUnavailable: true, + track_total_hits: true, + body: { + ...(!isEmpty(docValueFields) ? { docvalue_fields: docValueFields } : {}), + aggs: { + risk_score: { + sum: { + field: 'signal.rule.risk_score', + }, + }, + tactic: { + terms: { + field: 'signal.rule.threat.tactic.name', + }, + aggs: { + technique: { + terms: { + field: 'signal.rule.threat.technique.name', + }, + aggs: { + risk_score: { + sum: { + field: 'signal.rule.risk_score', + }, + }, + }, + }, + }, + }, + tactic_count: { + cardinality: { + field: 'signal.rule.threat.tactic.name', + }, + }, + technique_count: { + cardinality: { + field: 'signal.rule.threat.technique.name', + }, + }, + }, + query: { + bool: { + filter, + must: [ + { + term: { + 'host.name': hostName, + }, + }, + ], + }, + }, + size: 0, + }, + }; +}; diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/ueba/index.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/ueba/index.ts new file mode 100644 index 0000000000000..90db2ec63260a --- /dev/null +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/ueba/index.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 { + FactoryQueryTypes, + UebaQueries, +} from '../../../../../common/search_strategy/security_solution'; +import { SecuritySolutionFactory } from '../types'; +import { hostRules } from './host_rules'; +import { hostTactics } from './host_tactics'; +import { riskScore } from './risk_score'; +import { userRules } from './user_rules'; + +export const uebaFactory: Record> = { + [UebaQueries.hostRules]: hostRules, + [UebaQueries.hostTactics]: hostTactics, + [UebaQueries.riskScore]: riskScore, + [UebaQueries.userRules]: userRules, +}; diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/ueba/risk_score/helpers.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/ueba/risk_score/helpers.ts new file mode 100644 index 0000000000000..ace2faf819877 --- /dev/null +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/ueba/risk_score/helpers.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 { getOr } from 'lodash/fp'; +import { RiskScoreHit, RiskScoreEdges } from '../../../../../../common'; + +export const formatRiskScoreData = (buckets: RiskScoreHit[]): RiskScoreEdges[] => + buckets.map((bucket) => ({ + node: { + _id: bucket.key, + host_name: bucket.key, + risk_score: getOr(0, 'risk_score.value', bucket), + risk_keyword: getOr(0, 'risk_keyword.buckets[0].key', bucket), + }, + cursor: { + value: bucket.key, + tiebreaker: null, + }, + })); diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/ueba/risk_score/index.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/ueba/risk_score/index.ts new file mode 100644 index 0000000000000..6b3a956c9c1b7 --- /dev/null +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/ueba/risk_score/index.ts @@ -0,0 +1,59 @@ +/* + * 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 { SecuritySolutionFactory } from '../../types'; +import { + RiskScoreEdges, + RiskScoreRequestOptions, + RiskScoreStrategyResponse, + UebaQueries, +} from '../../../../../../common'; +import { DEFAULT_MAX_TABLE_QUERY_SIZE } from '../../../../../../common/constants'; +import { buildRiskScoreQuery } from './query.risk_score.dsl'; +import { IEsSearchResponse } from '../../../../../../../../../src/plugins/data/common'; +import { formatRiskScoreData } from './helpers'; +import { inspectStringifyObject } from '../../../../../utils/build_query'; + +export const riskScore: SecuritySolutionFactory = { + buildDsl: (options: RiskScoreRequestOptions) => { + if (options.pagination && options.pagination.querySize >= DEFAULT_MAX_TABLE_QUERY_SIZE) { + throw new Error(`No query size above ${DEFAULT_MAX_TABLE_QUERY_SIZE}`); + } + + return buildRiskScoreQuery(options); + }, + parse: async ( + options: RiskScoreRequestOptions, + response: IEsSearchResponse + ): Promise => { + const { activePage, cursorStart, fakePossibleCount, querySize } = options.pagination; + const totalCount = getOr(0, 'aggregations.host_count.value', response.rawResponse); + const fakeTotalCount = fakePossibleCount <= totalCount ? fakePossibleCount : totalCount; + + const riskScoreEdges: RiskScoreEdges[] = formatRiskScoreData( + getOr([], 'aggregations.host_data.buckets', response.rawResponse) + ); + + const edges = riskScoreEdges.splice(cursorStart, querySize - cursorStart); + const inspect = { + dsl: [inspectStringifyObject(buildRiskScoreQuery(options))], + }; + const showMorePagesIndicator = totalCount > fakeTotalCount; + return { + ...response, + inspect, + edges, + totalCount, + pageInfo: { + activePage: activePage ?? 0, + fakeTotalCount, + showMorePagesIndicator, + }, + }; + }, +}; diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/ueba/risk_score/query.risk_score.dsl.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/ueba/risk_score/query.risk_score.dsl.ts new file mode 100644 index 0000000000000..79c50d84e3c92 --- /dev/null +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/ueba/risk_score/query.risk_score.dsl.ts @@ -0,0 +1,71 @@ +/* + * 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 { isEmpty } from 'lodash/fp'; +import { Direction, RiskScoreRequestOptions } from '../../../../../../common/search_strategy'; +import { createQueryFilterClauses } from '../../../../../utils/build_query'; + +export const buildRiskScoreQuery = ({ + defaultIndex, + docValueFields, + filterQuery, + pagination: { querySize }, + sort, + timerange: { from, to }, +}: RiskScoreRequestOptions) => { + const filter = [ + ...createQueryFilterClauses(filterQuery), + { + range: { + '@timestamp': { + gte: from, + lte: to, + format: 'strict_date_optional_time', + }, + }, + }, + ]; + + return { + allowNoIndices: true, + index: defaultIndex, + ignoreUnavailable: true, + track_total_hits: true, + body: { + ...(!isEmpty(docValueFields) ? { docvalue_fields: docValueFields } : {}), + aggregations: { + host_data: { + terms: { + field: 'host.name', + order: { + risk_score: Direction.desc, + }, + }, + aggs: { + risk_score: { + sum: { + field: 'risk_score', + }, + }, + risk_keyword: { + terms: { + field: 'risk.keyword', + }, + }, + }, + }, + host_count: { + cardinality: { + field: 'host.name', + }, + }, + }, + query: { bool: { filter } }, + size: 0, + }, + }; +}; diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/ueba/user_rules/helpers.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/ueba/user_rules/helpers.ts new file mode 100644 index 0000000000000..c0f38af37c1f5 --- /dev/null +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/ueba/user_rules/helpers.ts @@ -0,0 +1,19 @@ +/* + * 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 { UserRulesHit, UserRulesFields, UserRulesByUser } from '../../../../../../common'; +import { formatHostRulesData } from '../host_rules/helpers'; + +export const formatUserRulesData = (buckets: UserRulesHit[]): UserRulesByUser[] => + buckets.map((user) => ({ + _id: user.key, + [UserRulesFields.userName]: user.key, + [UserRulesFields.riskScore]: getOr(0, 'risk_score.value', user), + [UserRulesFields.ruleCount]: getOr(0, 'rule_count.value', user), + [UserRulesFields.rules]: formatHostRulesData(getOr([], 'rule_name.buckets', user)), + })); diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/ueba/user_rules/index.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/ueba/user_rules/index.ts new file mode 100644 index 0000000000000..aa525f2c5b741 --- /dev/null +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/ueba/user_rules/index.ts @@ -0,0 +1,67 @@ +/* + * 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 { SecuritySolutionFactory } from '../../types'; +import { + UebaQueries, + UserRulesByUser, + UserRulesFields, + UserRulesRequestOptions, + UserRulesStrategyResponse, + UsersRulesHit, +} from '../../../../../../common'; +import { DEFAULT_MAX_TABLE_QUERY_SIZE } from '../../../../../../common/constants'; +import { buildUserRulesQuery } from './query.user_rules.dsl'; +import { IEsSearchResponse } from '../../../../../../../../../src/plugins/data/common'; +import { formatUserRulesData } from './helpers'; +import { inspectStringifyObject } from '../../../../../utils/build_query'; + +export const userRules: SecuritySolutionFactory = { + buildDsl: (options: UserRulesRequestOptions) => { + if (options.pagination && options.pagination.querySize >= DEFAULT_MAX_TABLE_QUERY_SIZE) { + throw new Error(`No query size above ${DEFAULT_MAX_TABLE_QUERY_SIZE}`); + } + + return buildUserRulesQuery(options); + }, + parse: async ( + options: UserRulesRequestOptions, + response: IEsSearchResponse + ): Promise => { + const { activePage, cursorStart, fakePossibleCount, querySize } = options.pagination; + + const userRulesByUser: UserRulesByUser[] = formatUserRulesData( + getOr([], 'aggregations.user_data.buckets', response.rawResponse) + ); + const inspect = { + dsl: [inspectStringifyObject(buildUserRulesQuery(options))], + }; + return { + ...response, + inspect, + data: userRulesByUser.map((user) => { + const edges = user[UserRulesFields.rules].splice(cursorStart, querySize - cursorStart); + const totalCount = user[UserRulesFields.ruleCount]; + const fakeTotalCount = fakePossibleCount <= totalCount ? fakePossibleCount : totalCount; + + const showMorePagesIndicator = totalCount > fakeTotalCount; + return { + [UserRulesFields.userName]: user[UserRulesFields.userName], + [UserRulesFields.riskScore]: user[UserRulesFields.riskScore], + edges, + totalCount, + pageInfo: { + activePage: activePage ?? 0, + fakeTotalCount, + showMorePagesIndicator, + }, + }; + }), + }; + }, +}; diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/ueba/user_rules/query.user_rules.dsl.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/ueba/user_rules/query.user_rules.dsl.ts new file mode 100644 index 0000000000000..c2242ff00a6c1 --- /dev/null +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/ueba/user_rules/query.user_rules.dsl.ts @@ -0,0 +1,97 @@ +/* + * 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 { isEmpty } from 'lodash/fp'; +import { Direction, UserRulesRequestOptions } from '../../../../../../common/search_strategy'; +import { createQueryFilterClauses } from '../../../../../utils/build_query'; + +export const buildUserRulesQuery = ({ + defaultIndex, + docValueFields, + filterQuery, + hostName, + timerange: { from, to }, +}: UserRulesRequestOptions) => { + const filter = [ + ...createQueryFilterClauses(filterQuery), + { + range: { + '@timestamp': { + gte: from, + lte: to, + format: 'strict_date_optional_time', + }, + }, + }, + ]; + + return { + allowNoIndices: true, + index: defaultIndex, // can stop getting this from sourcerer and assume default detections index if we want + ignoreUnavailable: true, + track_total_hits: true, + body: { + ...(!isEmpty(docValueFields) ? { docvalue_fields: docValueFields } : {}), + aggs: { + user_data: { + terms: { + field: 'user.name', + order: { + risk_score: Direction.desc, + }, + size: 20, + }, + aggs: { + risk_score: { + sum: { + field: 'signal.rule.risk_score', + }, + }, + rule_name: { + terms: { + field: 'signal.rule.name', + order: { + risk_score: Direction.desc, + }, + }, + aggs: { + risk_score: { + sum: { + field: 'signal.rule.risk_score', + }, + }, + rule_type: { + terms: { + field: 'signal.rule.type', + }, + }, + }, + }, + rule_count: { + cardinality: { + field: 'signal.rule.name', + }, + }, + }, + }, + }, + query: { + bool: { + filter, + must: [ + { + term: { + 'host.name': hostName, + }, + }, + ], + }, + }, + size: 0, + }, + }; +}; diff --git a/x-pack/plugins/security_solution/server/ui_settings.ts b/x-pack/plugins/security_solution/server/ui_settings.ts index 259c0f2ae2f92..611860929e25e 100644 --- a/x-pack/plugins/security_solution/server/ui_settings.ts +++ b/x-pack/plugins/security_solution/server/ui_settings.ts @@ -13,6 +13,7 @@ import { APP_ID, DEFAULT_INDEX_KEY, DEFAULT_INDEX_PATTERN, + DEFAULT_INDEX_PATTERN_EXPERIMENTAL, DEFAULT_ANOMALY_SCORE, DEFAULT_APP_TIME_RANGE, DEFAULT_APP_REFRESH_INTERVAL, @@ -88,7 +89,9 @@ export const initUiSettings = ( }), sensitive: true, - value: DEFAULT_INDEX_PATTERN, + value: experimentalFeatures.uebaEnabled + ? [...DEFAULT_INDEX_PATTERN, ...DEFAULT_INDEX_PATTERN_EXPERIMENTAL] + : DEFAULT_INDEX_PATTERN, description: i18n.translate('xpack.securitySolution.uiSettings.defaultIndexDescription', { defaultMessage: '

Comma-delimited list of Elasticsearch indices from which the Security app collects events.

', diff --git a/x-pack/plugins/timelines/common/search_strategy/timeline/events/last_event_time/index.ts b/x-pack/plugins/timelines/common/search_strategy/timeline/events/last_event_time/index.ts index f29dc4a3c7450..9a2d884af948f 100644 --- a/x-pack/plugins/timelines/common/search_strategy/timeline/events/last_event_time/index.ts +++ b/x-pack/plugins/timelines/common/search_strategy/timeline/events/last_event_time/index.ts @@ -14,6 +14,7 @@ export enum LastEventIndexKey { hosts = 'hosts', ipDetails = 'ipDetails', network = 'network', + ueba = 'ueba', // TODO: Steph/ueba implement this } export interface LastTimeDetails { diff --git a/x-pack/plugins/timelines/common/types/timeline/index.ts b/x-pack/plugins/timelines/common/types/timeline/index.ts index c0bc1c305b970..36a5d31bd6904 100644 --- a/x-pack/plugins/timelines/common/types/timeline/index.ts +++ b/x-pack/plugins/timelines/common/types/timeline/index.ts @@ -314,6 +314,7 @@ export enum TimelineId { detectionsRulesDetailsPage = 'detections-rules-details-page', detectionsPage = 'detections-page', networkPageExternalAlerts = 'network-page-external-alerts', + uebaPageExternalAlerts = 'ueba-page-external-alerts', active = 'timeline-1', casePage = 'timeline-case', test = 'test', // Reserved for testing purposes @@ -326,6 +327,7 @@ export const TimelineIdLiteralRt = runtimeTypes.union([ runtimeTypes.literal(TimelineId.detectionsRulesDetailsPage), runtimeTypes.literal(TimelineId.detectionsPage), runtimeTypes.literal(TimelineId.networkPageExternalAlerts), + runtimeTypes.literal(TimelineId.uebaPageExternalAlerts), runtimeTypes.literal(TimelineId.active), runtimeTypes.literal(TimelineId.test), ]); diff --git a/x-pack/plugins/timelines/public/store/t_grid/types.ts b/x-pack/plugins/timelines/public/store/t_grid/types.ts index c8c72e0310958..41f69b9f55d0d 100644 --- a/x-pack/plugins/timelines/public/store/t_grid/types.ts +++ b/x-pack/plugins/timelines/public/store/t_grid/types.ts @@ -45,6 +45,7 @@ export enum TimelineId { detectionsRulesDetailsPage = 'detections-rules-details-page', detectionsPage = 'detections-page', networkPageExternalAlerts = 'network-page-external-alerts', + uebaPageExternalAlerts = 'ueba-page-external-alerts', active = 'timeline-1', casePage = 'timeline-case', test = 'test', // Reserved for testing purposes diff --git a/x-pack/plugins/timelines/server/search_strategy/timeline/factory/events/last_event_time/query.events_last_event_time.dsl.ts b/x-pack/plugins/timelines/server/search_strategy/timeline/factory/events/last_event_time/query.events_last_event_time.dsl.ts index 6d3d8ac3c55aa..0fc6ce78ee982 100644 --- a/x-pack/plugins/timelines/server/search_strategy/timeline/factory/events/last_event_time/query.events_last_event_time.dsl.ts +++ b/x-pack/plugins/timelines/server/search_strategy/timeline/factory/events/last_event_time/query.events_last_event_time.dsl.ts @@ -82,6 +82,7 @@ export const buildLastEventTimeQuery = ({ throw new Error('buildLastEventTimeQuery - no hostName argument provided'); case LastEventIndexKey.hosts: case LastEventIndexKey.network: + case LastEventIndexKey.ueba: return { allowNoIndices: true, index: indicesToQuery[indexKey], From e45d25dde00ab5c1882522a78502ddf7f77c7d7f Mon Sep 17 00:00:00 2001 From: Jean-Louis Leysens Date: Sun, 25 Jul 2021 15:59:24 +0200 Subject: [PATCH 2/5] Fix vis. search filter being overridden in dashboard (#106399) * fix vis search filter context being overridden in dashboard * Revert "fix vis search filter context being overridden in dashboard" This reverts commit ead7ef6ed34d9a3acbfe4f7bdeae1063fc7ce8c0. * updated filtering order in kibana context * use buildFilter Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../search/expressions/kibana_context.test.ts | 264 ++++++++++++++++++ .../search/expressions/kibana_context.ts | 2 +- 2 files changed, 265 insertions(+), 1 deletion(-) create mode 100644 src/plugins/data/common/search/expressions/kibana_context.test.ts diff --git a/src/plugins/data/common/search/expressions/kibana_context.test.ts b/src/plugins/data/common/search/expressions/kibana_context.test.ts new file mode 100644 index 0000000000000..77d89792b63c3 --- /dev/null +++ b/src/plugins/data/common/search/expressions/kibana_context.test.ts @@ -0,0 +1,264 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { FilterStateStore, buildFilter, FILTERS } from '@kbn/es-query'; +import type { DeeplyMockedKeys } from '@kbn/utility-types/jest'; +import type { ExecutionContext } from 'src/plugins/expressions/common'; +import { KibanaContext } from './kibana_context_type'; + +import { + getKibanaContextFn, + ExpressionFunctionKibanaContext, + KibanaContextStartDependencies, +} from './kibana_context'; + +type StartServicesMock = DeeplyMockedKeys; + +const createExecutionContextMock = (): DeeplyMockedKeys => ({ + abortSignal: {} as any, + getExecutionContext: jest.fn(), + getSearchContext: jest.fn(), + getSearchSessionId: jest.fn(), + inspectorAdapters: jest.fn(), + types: {}, + variables: {}, + getKibanaRequest: jest.fn(), +}); + +const emptyArgs = { q: null, timeRange: null, savedSearchId: null }; + +describe('kibanaContextFn', () => { + let kibanaContextFn: ExpressionFunctionKibanaContext; + let startServicesMock: StartServicesMock; + + const getStartServicesMock = (): Promise => Promise.resolve(startServicesMock); + + beforeEach(async () => { + kibanaContextFn = getKibanaContextFn(getStartServicesMock); + startServicesMock = { + savedObjectsClient: { + create: jest.fn(), + delete: jest.fn(), + find: jest.fn(), + get: jest.fn(), + update: jest.fn(), + }, + }; + }); + + it('merges and deduplicates queries from different sources', async () => { + const { fn } = kibanaContextFn; + startServicesMock.savedObjectsClient.get.mockResolvedValue({ + attributes: { + kibanaSavedObjectMeta: { + searchSourceJSON: JSON.stringify({ + query: [ + { + language: 'kuery', + query: { + match_phrase: { + DUPLICATE: 'DUPLICATE', + }, + }, + }, + { + language: 'kuery', + query: { + match_phrase: { + DUPLICATE: 'DUPLICATE', + }, + }, + }, + { + language: 'kuery', + query: { + match_phrase: { + test: 'something1', + }, + }, + }, + ], + }), + }, + }, + } as any); + const args = { + ...emptyArgs, + q: { + type: 'kibana_query' as 'kibana_query', + language: 'test', + query: { + type: 'test', + match_phrase: { + test: 'something2', + }, + }, + }, + savedSearchId: 'test', + }; + const input: KibanaContext = { + type: 'kibana_context', + query: [ + { + language: 'kuery', + query: [ + // TODO: Is it expected that if we pass in an array that the values in the array are not deduplicated? + { + language: 'kuery', + query: { + match_phrase: { + DUPLICATE: 'DUPLICATE', + }, + }, + }, + { + language: 'kuery', + query: { + match_phrase: { + DUPLICATE: 'DUPLICATE', + }, + }, + }, + { + language: 'kuery', + query: { + match_phrase: { + test: 'something3', + }, + }, + }, + ], + }, + ], + timeRange: { + from: 'now-24h', + to: 'now', + }, + }; + + const { query } = await fn(input, args, createExecutionContextMock()); + + expect(query).toEqual([ + { + language: 'kuery', + query: [ + { + language: 'kuery', + query: { + match_phrase: { + DUPLICATE: 'DUPLICATE', + }, + }, + }, + { + language: 'kuery', + query: { + match_phrase: { + DUPLICATE: 'DUPLICATE', + }, + }, + }, + { + language: 'kuery', + query: { + match_phrase: { + test: 'something3', + }, + }, + }, + ], + }, + { + type: 'kibana_query', + language: 'test', + query: { + type: 'test', + match_phrase: { + test: 'something2', + }, + }, + }, + { + language: 'kuery', + query: { + match_phrase: { + DUPLICATE: 'DUPLICATE', + }, + }, + }, + { + language: 'kuery', + query: { + match_phrase: { + test: 'something1', + }, + }, + }, + ]); + }); + + it('deduplicates duplicated filters and keeps the first enabled filter', async () => { + const { fn } = kibanaContextFn; + const filter1 = buildFilter( + { fields: [] }, + { name: 'test', type: 'test' }, + FILTERS.PHRASE, + false, + true, + { + query: 'JetBeats', + }, + null, + FilterStateStore.APP_STATE + ); + const filter2 = buildFilter( + { fields: [] }, + { name: 'test', type: 'test' }, + FILTERS.PHRASE, + false, + false, + { + query: 'JetBeats', + }, + null, + FilterStateStore.APP_STATE + ); + + const filter3 = buildFilter( + { fields: [] }, + { name: 'test', type: 'test' }, + FILTERS.PHRASE, + false, + false, + { + query: 'JetBeats', + }, + null, + FilterStateStore.APP_STATE + ); + + const input: KibanaContext = { + type: 'kibana_context', + query: [ + { + language: 'kuery', + query: '', + }, + ], + filters: [filter1, filter2, filter3], + timeRange: { + from: 'now-24h', + to: 'now', + }, + }; + + const { filters } = await fn(input, emptyArgs, createExecutionContextMock()); + expect(filters!.length).toBe(1); + expect(filters![0]).toBe(filter2); + }); +}); diff --git a/src/plugins/data/common/search/expressions/kibana_context.ts b/src/plugins/data/common/search/expressions/kibana_context.ts index 9c1c78604ea83..8112777b9b0f3 100644 --- a/src/plugins/data/common/search/expressions/kibana_context.ts +++ b/src/plugins/data/common/search/expressions/kibana_context.ts @@ -146,7 +146,7 @@ export const getKibanaContextFn = ( return { type: 'kibana_context', query: queries, - filters: uniqFilters(filters).filter((f: any) => !f.meta?.disabled), + filters: uniqFilters(filters.filter((f: any) => !f.meta?.disabled)), timeRange, }; }, From f7a308859fab15cd72622900f70bfb9ed9cd8b95 Mon Sep 17 00:00:00 2001 From: Byron Hulcher Date: Sun, 25 Jul 2021 10:53:17 -0400 Subject: [PATCH 3/5] [App Search] New Add Domain Flyout for Crawler (#106102) * Added /api/app_search/engines/{name}/crawler/domains to Crawler routes * New AddDomainLogic * New AddDomainFormSubmitButton component * New AddDomainFormErrors component * New AddDomainForm component * New AddDomainFlyout component * Add AddDomainFlyout to CrawlerOverview * Use exact path for CrawlerOverview in CrawlerRouter * Clean-up AddDomainFlyout * Clean-up AddDomainForm * Clean-up AddDomainFormSubmitButton * Extract getErrorsFromHttpResponse from flashAPIErrors * Clean-up AddDomainLogic * Remove unused imports Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../add_domain/add_domain_flyout.test.tsx | 69 ++++ .../add_domain/add_domain_flyout.tsx | 98 ++++++ .../add_domain/add_domain_form.test.tsx | 122 +++++++ .../components/add_domain/add_domain_form.tsx | 105 ++++++ .../add_domain_form_errors.test.tsx | 41 +++ .../add_domain/add_domain_form_errors.tsx | 40 +++ .../add_domain_form_submit_button.test.tsx | 59 ++++ .../add_domain_form_submit_button.tsx | 30 ++ .../add_domain/add_domain_logic.test.ts | 300 ++++++++++++++++++ .../components/add_domain/add_domain_logic.ts | 166 ++++++++++ .../components/add_domain/utils.test.ts | 23 ++ .../crawler/components/add_domain/utils.ts | 21 ++ .../crawler/crawler_overview.test.tsx | 3 +- .../components/crawler/crawler_overview.tsx | 20 ++ .../components/crawler/crawler_router.tsx | 4 +- .../flash_messages/handle_api_errors.test.ts | 25 +- .../flash_messages/handle_api_errors.ts | 21 +- .../server/routes/app_search/crawler.test.ts | 56 ++++ .../server/routes/app_search/crawler.ts | 25 ++ 19 files changed, 1217 insertions(+), 11 deletions(-) create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/add_domain/add_domain_flyout.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/add_domain/add_domain_flyout.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/add_domain/add_domain_form.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/add_domain/add_domain_form.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/add_domain/add_domain_form_errors.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/add_domain/add_domain_form_errors.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/add_domain/add_domain_form_submit_button.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/add_domain/add_domain_form_submit_button.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/add_domain/add_domain_logic.test.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/add_domain/add_domain_logic.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/add_domain/utils.test.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/add_domain/utils.ts diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/add_domain/add_domain_flyout.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/add_domain/add_domain_flyout.test.tsx new file mode 100644 index 0000000000000..bdedc9357fa0e --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/add_domain/add_domain_flyout.test.tsx @@ -0,0 +1,69 @@ +/* + * 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 { shallow, ShallowWrapper } from 'enzyme'; + +import { EuiButton, EuiButtonEmpty, EuiFlyout, EuiFlyoutBody } from '@elastic/eui'; + +import { AddDomainFlyout } from './add_domain_flyout'; +import { AddDomainForm } from './add_domain_form'; +import { AddDomainFormErrors } from './add_domain_form_errors'; +import { AddDomainFormSubmitButton } from './add_domain_form_submit_button'; + +describe('AddDomainFlyout', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('is hidden by default', () => { + const wrapper = shallow(); + + expect(wrapper.find(EuiFlyout)).toHaveLength(0); + }); + + it('displays the flyout when the button is pressed', () => { + const wrapper = shallow(); + + wrapper.find(EuiButton).simulate('click'); + + expect(wrapper.find(EuiFlyout)).toHaveLength(1); + }); + + describe('flyout', () => { + let wrapper: ShallowWrapper; + + beforeEach(() => { + wrapper = shallow(); + + wrapper.find(EuiButton).simulate('click'); + }); + + it('displays form errors', () => { + expect(wrapper.find(EuiFlyoutBody).dive().find(AddDomainFormErrors)).toHaveLength(1); + }); + + it('contains a form to add domains', () => { + expect(wrapper.find(AddDomainForm)).toHaveLength(1); + }); + + it('contains a cancel buttonn', () => { + wrapper.find(EuiButtonEmpty).simulate('click'); + expect(wrapper.find(EuiFlyout)).toHaveLength(0); + }); + + it('contains a submit button', () => { + expect(wrapper.find(AddDomainFormSubmitButton)).toHaveLength(1); + }); + + it('hides the flyout on close', () => { + wrapper.find(EuiFlyout).simulate('close'); + + expect(wrapper.find(EuiFlyout)).toHaveLength(0); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/add_domain/add_domain_flyout.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/add_domain/add_domain_flyout.tsx new file mode 100644 index 0000000000000..f8511d1e2ef14 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/add_domain/add_domain_flyout.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 React, { useState } from 'react'; + +import { + EuiButton, + EuiButtonEmpty, + EuiFlexGroup, + EuiFlexItem, + EuiFlyout, + EuiFlyoutBody, + EuiFlyoutFooter, + EuiFlyoutHeader, + EuiPortal, + EuiSpacer, + EuiText, + EuiTitle, +} from '@elastic/eui'; + +import { i18n } from '@kbn/i18n'; + +import { CANCEL_BUTTON_LABEL } from '../../../../../shared/constants'; + +import { AddDomainForm } from './add_domain_form'; +import { AddDomainFormErrors } from './add_domain_form_errors'; +import { AddDomainFormSubmitButton } from './add_domain_form_submit_button'; + +export const AddDomainFlyout: React.FC = () => { + const [isFlyoutVisible, setIsFlyoutVisible] = useState(false); + + return ( + <> + setIsFlyoutVisible(true)} + > + {i18n.translate( + 'xpack.enterpriseSearch.appSearch.crawler.addDomainFlyout.openButtonLabel', + { + defaultMessage: 'Add domain', + } + )} + + + {isFlyoutVisible && ( + + setIsFlyoutVisible(false)}> + + +

+ {i18n.translate( + 'xpack.enterpriseSearch.appSearch.crawler.addDomainFlyout.title', + { + defaultMessage: 'Add a new domain', + } + )} +

+
+
+ }> + + {i18n.translate( + 'xpack.enterpriseSearch.appSearch.crawler.addDomainFlyout.description', + { + defaultMessage: + 'You can add multiple domains to this engine\'s web crawler. Add another domain here and modify the entry points and crawl rules from the "Manage" page.', + } + )} +

+ + + + + + + + setIsFlyoutVisible(false)}> + {CANCEL_BUTTON_LABEL} + + + + + + + + + + )} + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/add_domain/add_domain_form.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/add_domain/add_domain_form.test.tsx new file mode 100644 index 0000000000000..6c869d9371f6f --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/add_domain/add_domain_form.test.tsx @@ -0,0 +1,122 @@ +/* + * 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 { setMockActions, setMockValues } from '../../../../../__mocks__/kea_logic'; + +import React from 'react'; + +import { shallow, ShallowWrapper } from 'enzyme'; + +import { EuiButton, EuiFieldText, EuiForm } from '@elastic/eui'; + +import { FormattedMessage } from '@kbn/i18n/react'; + +import { rerender } from '../../../../../test_helpers'; + +import { AddDomainForm } from './add_domain_form'; + +const MOCK_VALUES = { + addDomainFormInputValue: 'https://', + entryPointValue: '/', +}; + +const MOCK_ACTIONS = { + setAddDomainFormInputValue: jest.fn(), + validateDomain: jest.fn(), +}; + +describe('AddDomainForm', () => { + let wrapper: ShallowWrapper; + + beforeEach(() => { + jest.clearAllMocks(); + setMockActions(MOCK_ACTIONS); + setMockValues(MOCK_VALUES); + wrapper = shallow(); + }); + + it('renders', () => { + expect(wrapper.find(EuiForm)).toHaveLength(1); + }); + + it('contains a submit button', () => { + expect(wrapper.find(EuiButton).prop('type')).toEqual('submit'); + }); + + it('validates domain on submit', () => { + wrapper.find(EuiForm).simulate('submit', { preventDefault: jest.fn() }); + + expect(MOCK_ACTIONS.validateDomain).toHaveBeenCalledTimes(1); + }); + + describe('url field', () => { + it('uses the value from the logic', () => { + setMockValues({ + ...MOCK_VALUES, + addDomainFormInputValue: 'test value', + }); + + rerender(wrapper); + + expect(wrapper.find(EuiFieldText).prop('value')).toEqual('test value'); + }); + + it('sets the value in the logic on change', () => { + wrapper.find(EuiFieldText).simulate('change', { target: { value: 'test value' } }); + + expect(MOCK_ACTIONS.setAddDomainFormInputValue).toHaveBeenCalledWith('test value'); + }); + }); + + describe('validate domain button', () => { + it('is enabled when the input has a value', () => { + setMockValues({ + ...MOCK_VALUES, + addDomainFormInputValue: 'https://elastic.co', + }); + + rerender(wrapper); + + expect(wrapper.find(EuiButton).prop('disabled')).toEqual(false); + }); + + it('is disabled when the input value is empty', () => { + setMockValues({ + ...MOCK_VALUES, + addDomainFormInputValue: '', + }); + + rerender(wrapper); + + expect(wrapper.find(EuiButton).prop('disabled')).toEqual(true); + }); + }); + + describe('entry point indicator', () => { + it('is hidden when the entry point is /', () => { + setMockValues({ + ...MOCK_VALUES, + entryPointValue: '/', + }); + + rerender(wrapper); + + expect(wrapper.find(FormattedMessage)).toHaveLength(0); + }); + + it('displays the entry point otherwise', () => { + setMockValues({ + ...MOCK_VALUES, + entryPointValue: '/guide', + }); + + rerender(wrapper); + + expect(wrapper.find(FormattedMessage)).toHaveLength(1); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/add_domain/add_domain_form.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/add_domain/add_domain_form.tsx new file mode 100644 index 0000000000000..de6a33403c2ed --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/add_domain/add_domain_form.tsx @@ -0,0 +1,105 @@ +/* + * 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 { useActions, useValues } from 'kea'; + +import { + EuiButton, + EuiCode, + EuiFlexGroup, + EuiFlexItem, + EuiForm, + EuiFormRow, + EuiFieldText, + EuiSpacer, + EuiText, +} from '@elastic/eui'; + +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; + +import { AddDomainLogic } from './add_domain_logic'; + +export const AddDomainForm: React.FC = () => { + const { setAddDomainFormInputValue, validateDomain } = useActions(AddDomainLogic); + + const { addDomainFormInputValue, entryPointValue } = useValues(AddDomainLogic); + + return ( + <> + { + event.preventDefault(); + validateDomain(); + }} + component="form" + > + + {i18n.translate( + 'xpack.enterpriseSearch.appSearch.crawler.addDomainForm.urlHelpText', + { + defaultMessage: 'Domain URLs require a protocol and cannot contain any paths.', + } + )} + + } + > + + + setAddDomainFormInputValue(e.target.value)} + fullWidth + /> + + + + {i18n.translate( + 'xpack.enterpriseSearch.appSearch.crawler.addDomainForm.validateButtonLabel', + { + defaultMessage: 'Validate Domain', + } + )} + + + + + + {entryPointValue !== '/' && ( + <> + + +

+ + {entryPointValue}, + }} + /> + +

+
+ + )} + + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/add_domain/add_domain_form_errors.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/add_domain/add_domain_form_errors.test.tsx new file mode 100644 index 0000000000000..d2c3ac37d58fa --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/add_domain/add_domain_form_errors.test.tsx @@ -0,0 +1,41 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { setMockValues } from '../../../../../__mocks__/kea_logic'; + +import React from 'react'; + +import { shallow } from 'enzyme'; + +import { AddDomainFormErrors } from './add_domain_form_errors'; + +describe('AddDomainFormErrors', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('is empty when there are no errors', () => { + setMockValues({ + errors: [], + }); + + const wrapper = shallow(); + + expect(wrapper.isEmptyRender()).toBe(true); + }); + + it('displays all the errors from the logic', () => { + setMockValues({ + errors: ['first error', 'second error'], + }); + + const wrapper = shallow(); + + expect(wrapper.find('p')).toHaveLength(2); + expect(wrapper.find('p').first().text()).toContain('first error'); + expect(wrapper.find('p').last().text()).toContain('second error'); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/add_domain/add_domain_form_errors.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/add_domain/add_domain_form_errors.tsx new file mode 100644 index 0000000000000..890657d4c235a --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/add_domain/add_domain_form_errors.tsx @@ -0,0 +1,40 @@ +/* + * 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 { useValues } from 'kea'; + +import { EuiCallOut } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +import { AddDomainLogic } from './add_domain_logic'; + +export const AddDomainFormErrors: React.FC = () => { + const { errors } = useValues(AddDomainLogic); + + if (errors.length > 0) { + return ( + + {errors.map((message, index) => ( +

{message}

+ ))} +
+ ); + } + + return null; +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/add_domain/add_domain_form_submit_button.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/add_domain/add_domain_form_submit_button.test.tsx new file mode 100644 index 0000000000000..a01d8c55bc87c --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/add_domain/add_domain_form_submit_button.test.tsx @@ -0,0 +1,59 @@ +/* + * 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 { setMockActions, setMockValues } from '../../../../../__mocks__/kea_logic'; + +import React from 'react'; + +import { shallow } from 'enzyme'; + +import { EuiButton } from '@elastic/eui'; + +import { AddDomainFormSubmitButton } from './add_domain_form_submit_button'; + +describe('AddDomainFormSubmitButton', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('is disabled when the domain has not been validated', () => { + setMockValues({ + hasValidationCompleted: false, + }); + + const wrapper = shallow(); + + expect(wrapper.prop('disabled')).toBe(true); + }); + + it('is enabled when the domain has been validated', () => { + setMockValues({ + hasValidationCompleted: true, + }); + + const wrapper = shallow(); + + expect(wrapper.prop('disabled')).toBe(false); + }); + + it('submits the domain on click', () => { + const submitNewDomain = jest.fn(); + + setMockActions({ + submitNewDomain, + }); + setMockValues({ + hasValidationCompleted: true, + }); + + const wrapper = shallow(); + + wrapper.find(EuiButton).simulate('click'); + + expect(submitNewDomain).toHaveBeenCalled(); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/add_domain/add_domain_form_submit_button.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/add_domain/add_domain_form_submit_button.tsx new file mode 100644 index 0000000000000..dbf5f86ca70fc --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/add_domain/add_domain_form_submit_button.tsx @@ -0,0 +1,30 @@ +/* + * 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 { useActions, useValues } from 'kea'; + +import { EuiButton } from '@elastic/eui'; + +import { i18n } from '@kbn/i18n'; + +import { AddDomainLogic } from './add_domain_logic'; + +export const AddDomainFormSubmitButton: React.FC = () => { + const { submitNewDomain } = useActions(AddDomainLogic); + + const { hasValidationCompleted } = useValues(AddDomainLogic); + + return ( + + {i18n.translate('xpack.enterpriseSearch.appSearch.crawler.addDomainForm.submitButtonLabel', { + defaultMessage: 'Add domain', + })} + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/add_domain/add_domain_logic.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/add_domain/add_domain_logic.test.ts new file mode 100644 index 0000000000000..3072796b7194f --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/add_domain/add_domain_logic.test.ts @@ -0,0 +1,300 @@ +/* + * 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 { + LogicMounter, + mockFlashMessageHelpers, + mockHttpValues, + mockKibanaValues, +} from '../../../../../__mocks__/kea_logic'; +import '../../../../__mocks__/engine_logic.mock'; + +jest.mock('../../crawler_overview_logic', () => ({ + CrawlerOverviewLogic: { + actions: { + onReceiveCrawlerData: jest.fn(), + }, + }, +})); + +import { nextTick } from '@kbn/test/jest'; + +import { CrawlerOverviewLogic } from '../../crawler_overview_logic'; +import { CrawlerDomain } from '../../types'; + +import { AddDomainLogic, AddDomainLogicValues } from './add_domain_logic'; + +const DEFAULT_VALUES: AddDomainLogicValues = { + addDomainFormInputValue: 'https://', + allowSubmit: false, + entryPointValue: '/', + hasValidationCompleted: false, + errors: [], +}; + +describe('AddDomainLogic', () => { + const { mount } = new LogicMounter(AddDomainLogic); + const { flashSuccessToast } = mockFlashMessageHelpers; + const { http } = mockHttpValues; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('has default values', () => { + mount(); + expect(AddDomainLogic.values).toEqual(DEFAULT_VALUES); + }); + + describe('actions', () => { + describe('clearDomainFormInputValue', () => { + beforeAll(() => { + mount({ + addDomainFormInputValue: 'http://elastic.co', + entryPointValue: '/foo', + hasValidationCompleted: true, + errors: ['first error', 'second error'], + }); + + AddDomainLogic.actions.clearDomainFormInputValue(); + }); + + it('should clear the input value', () => { + expect(AddDomainLogic.values.addDomainFormInputValue).toEqual('https://'); + }); + + it('should clear the entry point value', () => { + expect(AddDomainLogic.values.entryPointValue).toEqual('/'); + }); + + it('should reset validation completion', () => { + expect(AddDomainLogic.values.hasValidationCompleted).toEqual(false); + }); + + it('should clear errors', () => { + expect(AddDomainLogic.values.errors).toEqual([]); + }); + }); + + describe('onSubmitNewDomainError', () => { + it('should set errors', () => { + mount(); + + AddDomainLogic.actions.onSubmitNewDomainError(['first error', 'second error']); + + expect(AddDomainLogic.values.errors).toEqual(['first error', 'second error']); + }); + }); + + describe('onValidateDomain', () => { + beforeAll(() => { + mount({ + addDomainFormInputValue: 'https://elastic.co', + entryPointValue: '/customers', + hasValidationCompleted: true, + errors: ['first error', 'second error'], + }); + + AddDomainLogic.actions.onValidateDomain('https://swiftype.com', '/site-search'); + }); + + it('should set the input value', () => { + expect(AddDomainLogic.values.addDomainFormInputValue).toEqual('https://swiftype.com'); + }); + + it('should set the entry point value', () => { + expect(AddDomainLogic.values.entryPointValue).toEqual('/site-search'); + }); + + it('should flag validation as being completed', () => { + expect(AddDomainLogic.values.hasValidationCompleted).toEqual(true); + }); + + it('should clear errors', () => { + expect(AddDomainLogic.values.errors).toEqual([]); + }); + }); + + describe('setAddDomainFormInputValue', () => { + beforeAll(() => { + mount({ + addDomainFormInputValue: 'https://elastic.co', + entryPointValue: '/customers', + hasValidationCompleted: true, + errors: ['first error', 'second error'], + }); + + AddDomainLogic.actions.setAddDomainFormInputValue('https://swiftype.com/site-search'); + }); + + it('should set the input value', () => { + expect(AddDomainLogic.values.addDomainFormInputValue).toEqual( + 'https://swiftype.com/site-search' + ); + }); + + it('should clear the entry point value', () => { + expect(AddDomainLogic.values.entryPointValue).toEqual('/'); + }); + + it('should reset validation completion', () => { + expect(AddDomainLogic.values.hasValidationCompleted).toEqual(false); + }); + + it('should clear errors', () => { + expect(AddDomainLogic.values.errors).toEqual([]); + }); + }); + + describe('submitNewDomain', () => { + it('should clear errors', () => { + expect(AddDomainLogic.values.errors).toEqual([]); + }); + }); + }); + + describe('listeners', () => { + describe('onSubmitNewDomainSuccess', () => { + it('should flash a success toast', () => { + const { navigateToUrl } = mockKibanaValues; + mount(); + + AddDomainLogic.actions.onSubmitNewDomainSuccess({ id: 'test-domain' } as CrawlerDomain); + + expect(flashSuccessToast).toHaveBeenCalled(); + expect(navigateToUrl).toHaveBeenCalledWith( + '/engines/some-engine/crawler/domains/test-domain' + ); + }); + }); + + describe('submitNewDomain', () => { + it('calls the domains endpoint with a JSON formatted body', async () => { + mount({ + addDomainFormInputValue: 'https://elastic.co', + entryPointValue: '/guide', + }); + http.post.mockReturnValueOnce(Promise.resolve({})); + + AddDomainLogic.actions.submitNewDomain(); + await nextTick(); + + expect(http.post).toHaveBeenCalledWith( + '/api/app_search/engines/some-engine/crawler/domains', + { + query: { + respond_with: 'crawler_details', + }, + body: JSON.stringify({ + name: 'https://elastic.co', + entry_points: [{ value: '/guide' }], + }), + } + ); + }); + + describe('on success', () => { + beforeEach(() => { + mount(); + }); + + it('sets crawler data', async () => { + http.post.mockReturnValueOnce( + Promise.resolve({ + domains: [], + }) + ); + + AddDomainLogic.actions.submitNewDomain(); + await nextTick(); + + expect(CrawlerOverviewLogic.actions.onReceiveCrawlerData).toHaveBeenCalledWith({ + domains: [], + }); + }); + + it('calls the success callback with the most recent domain', async () => { + http.post.mockReturnValueOnce( + Promise.resolve({ + domains: [ + { + id: '1', + name: 'https://elastic.co/guide', + }, + { + id: '2', + name: 'https://swiftype.co/site-search', + }, + ], + }) + ); + jest.spyOn(AddDomainLogic.actions, 'onSubmitNewDomainSuccess'); + AddDomainLogic.actions.submitNewDomain(); + await nextTick(); + + expect(AddDomainLogic.actions.onSubmitNewDomainSuccess).toHaveBeenCalledWith({ + id: '2', + url: 'https://swiftype.co/site-search', + }); + }); + }); + + describe('on error', () => { + beforeEach(() => { + mount(); + jest.spyOn(AddDomainLogic.actions, 'onSubmitNewDomainError'); + }); + + it('passes error messages to the error callback', async () => { + http.post.mockReturnValueOnce( + Promise.reject({ + body: { + attributes: { + errors: ['first error', 'second error'], + }, + }, + }) + ); + + AddDomainLogic.actions.submitNewDomain(); + await nextTick(); + + expect(AddDomainLogic.actions.onSubmitNewDomainError).toHaveBeenCalledWith([ + 'first error', + 'second error', + ]); + }); + }); + }); + + describe('validateDomain', () => { + it('extracts the domain and entrypoint and passes them to the callback ', () => { + mount({ addDomainFormInputValue: 'https://swiftype.com/site-search' }); + jest.spyOn(AddDomainLogic.actions, 'onValidateDomain'); + + AddDomainLogic.actions.validateDomain(); + + expect(AddDomainLogic.actions.onValidateDomain).toHaveBeenCalledWith( + 'https://swiftype.com', + '/site-search' + ); + }); + }); + }); + + describe('selectors', () => { + describe('allowSubmit', () => { + it('gets set true when validation is completed', () => { + mount({ hasValidationCompleted: false }); + expect(AddDomainLogic.values.allowSubmit).toEqual(false); + + mount({ hasValidationCompleted: true }); + expect(AddDomainLogic.values.allowSubmit).toEqual(true); + }); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/add_domain/add_domain_logic.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/add_domain/add_domain_logic.ts new file mode 100644 index 0000000000000..b05b9454fe8f8 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/add_domain/add_domain_logic.ts @@ -0,0 +1,166 @@ +/* + * 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 { kea, MakeLogicType } from 'kea'; + +import { i18n } from '@kbn/i18n'; + +import { flashSuccessToast } from '../../../../../shared/flash_messages'; +import { getErrorsFromHttpResponse } from '../../../../../shared/flash_messages/handle_api_errors'; + +import { HttpLogic } from '../../../../../shared/http'; +import { KibanaLogic } from '../../../../../shared/kibana'; +import { ENGINE_CRAWLER_DOMAIN_PATH } from '../../../../routes'; +import { EngineLogic, generateEnginePath } from '../../../engine'; + +import { CrawlerOverviewLogic } from '../../crawler_overview_logic'; +import { CrawlerDataFromServer, CrawlerDomain } from '../../types'; +import { crawlerDataServerToClient } from '../../utils'; + +import { extractDomainAndEntryPointFromUrl } from './utils'; + +export interface AddDomainLogicValues { + addDomainFormInputValue: string; + allowSubmit: boolean; + hasValidationCompleted: boolean; + entryPointValue: string; + errors: string[]; +} + +export interface AddDomainLogicActions { + clearDomainFormInputValue(): void; + setAddDomainFormInputValue(newValue: string): string; + onSubmitNewDomainError(errors: string[]): { errors: string[] }; + onSubmitNewDomainSuccess(domain: CrawlerDomain): { domain: CrawlerDomain }; + onValidateDomain( + newValue: string, + newEntryPointValue: string + ): { newValue: string; newEntryPointValue: string }; + submitNewDomain(): void; + validateDomain(): void; +} + +const DEFAULT_SELECTOR_VALUES = { + addDomainFormInputValue: 'https://', + entryPointValue: '/', +}; + +export const AddDomainLogic = kea>({ + path: ['enterprise_search', 'app_search', 'crawler', 'add_domain'], + actions: () => ({ + clearDomainFormInputValue: true, + setAddDomainFormInputValue: (newValue) => newValue, + onSubmitNewDomainSuccess: (domain) => ({ domain }), + onSubmitNewDomainError: (errors) => ({ errors }), + onValidateDomain: (newValue, newEntryPointValue) => ({ + newValue, + newEntryPointValue, + }), + submitNewDomain: true, + validateDomain: true, + }), + reducers: () => ({ + addDomainFormInputValue: [ + DEFAULT_SELECTOR_VALUES.addDomainFormInputValue, + { + clearDomainFormInputValue: () => DEFAULT_SELECTOR_VALUES.addDomainFormInputValue, + setAddDomainFormInputValue: (_, newValue: string) => newValue, + onValidateDomain: (_, { newValue }: { newValue: string }) => newValue, + }, + ], + entryPointValue: [ + DEFAULT_SELECTOR_VALUES.entryPointValue, + { + clearDomainFormInputValue: () => DEFAULT_SELECTOR_VALUES.entryPointValue, + setAddDomainFormInputValue: () => DEFAULT_SELECTOR_VALUES.entryPointValue, + onValidateDomain: (_, { newEntryPointValue }) => newEntryPointValue, + }, + ], + // TODO When 4-step validation is added this will become a selector as + // we'll use individual step results to determine whether this is true/false + hasValidationCompleted: [ + false, + { + clearDomainFormInputValue: () => false, + setAddDomainFormInputValue: () => false, + onValidateDomain: () => true, + }, + ], + errors: [ + [], + { + clearDomainFormInputValue: () => [], + setAddDomainFormInputValue: () => [], + onValidateDomain: () => [], + submitNewDomain: () => [], + onSubmitNewDomainError: (_, { errors }) => errors, + }, + ], + }), + selectors: ({ selectors }) => ({ + // TODO include selectors.blockingFailures once 4-step validation is migrated + allowSubmit: [ + () => [selectors.hasValidationCompleted], // should eventually also contain selectors.hasBlockingFailures when that is added + (hasValidationCompleted: boolean) => hasValidationCompleted, // && !hasBlockingFailures + ], + }), + listeners: ({ actions, values }) => ({ + onSubmitNewDomainSuccess: ({ domain }) => { + flashSuccessToast( + i18n.translate( + 'xpack.enterpriseSearch.appSearch.crawler.domainsTable.action.add.successMessage', + { + defaultMessage: "Successfully added domain '{domainUrl}'", + values: { + domainUrl: domain.url, + }, + } + ) + ); + KibanaLogic.values.navigateToUrl( + generateEnginePath(ENGINE_CRAWLER_DOMAIN_PATH, { domainId: domain.id }) + ); + }, + submitNewDomain: async () => { + const { http } = HttpLogic.values; + const { engineName } = EngineLogic.values; + + const requestBody = JSON.stringify({ + name: values.addDomainFormInputValue.trim(), + entry_points: [{ value: values.entryPointValue }], + }); + + try { + const response = await http.post(`/api/app_search/engines/${engineName}/crawler/domains`, { + query: { + respond_with: 'crawler_details', + }, + body: requestBody, + }); + + const crawlerData = crawlerDataServerToClient(response as CrawlerDataFromServer); + CrawlerOverviewLogic.actions.onReceiveCrawlerData(crawlerData); + const newDomain = crawlerData.domains[crawlerData.domains.length - 1]; + if (newDomain) { + actions.onSubmitNewDomainSuccess(newDomain); + } + // If there is not a new domain, that means the server responded with a 200 but + // didn't actually persist the new domain to our BE, and we take no action + } catch (e) { + // we surface errors inside the form instead of in flash messages + const errorMessages = getErrorsFromHttpResponse(e); + actions.onSubmitNewDomainError(errorMessages); + } + }, + validateDomain: () => { + const { domain, entryPoint } = extractDomainAndEntryPointFromUrl( + values.addDomainFormInputValue.trim() + ); + actions.onValidateDomain(domain, entryPoint); + }, + }), +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/add_domain/utils.test.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/add_domain/utils.test.ts new file mode 100644 index 0000000000000..446545c28ee79 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/add_domain/utils.test.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 { extractDomainAndEntryPointFromUrl } from './utils'; + +describe('extractDomainAndEntryPointFromUrl', () => { + it('extracts a provided entry point and domain', () => { + expect(extractDomainAndEntryPointFromUrl('https://elastic.co/guide')).toEqual({ + domain: 'https://elastic.co', + entryPoint: '/guide', + }); + }); + + it('provides a default entry point if there is only a domain', () => { + expect(extractDomainAndEntryPointFromUrl('https://elastic.co')).toEqual({ + domain: 'https://elastic.co', + entryPoint: '/', + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/add_domain/utils.ts b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/add_domain/utils.ts new file mode 100644 index 0000000000000..7ba67ae61aa2b --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/add_domain/utils.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. + */ + +export const extractDomainAndEntryPointFromUrl = ( + url: string +): { domain: string; entryPoint: string } => { + let domain = url; + let entryPoint = '/'; + + const pathSlashIndex = url.search(/[^\:\/]\//); + if (pathSlashIndex !== -1) { + domain = url.substring(0, pathSlashIndex + 1); + entryPoint = url.substring(pathSlashIndex + 1); + } + + return { domain, entryPoint }; +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_overview.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_overview.test.tsx index 3804ecfe7c67d..610ad1f571699 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_overview.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_overview.test.tsx @@ -13,6 +13,7 @@ import React from 'react'; import { shallow, ShallowWrapper } from 'enzyme'; +import { AddDomainFlyout } from './components/add_domain/add_domain_flyout'; import { DomainsTable } from './components/domains_table'; import { CrawlerOverview } from './crawler_overview'; @@ -44,7 +45,7 @@ describe('CrawlerOverview', () => { // TODO test for CrawlRequestsTable after it is built in a future PR - // TODO test for AddDomainForm after it is built in a future PR + expect(wrapper.find(AddDomainFlyout)).toHaveLength(1); // TODO test for empty state after it is built in a future PR }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_overview.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_overview.tsx index 9e484df35e7a2..0daac399b7b09 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_overview.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_overview.tsx @@ -9,9 +9,14 @@ import React, { useEffect } from 'react'; import { useActions, useValues } from 'kea'; +import { EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiTitle } from '@elastic/eui'; + +import { i18n } from '@kbn/i18n'; + import { getEngineBreadcrumbs } from '../engine'; import { AppSearchPageTemplate } from '../layout'; +import { AddDomainFlyout } from './components/add_domain/add_domain_flyout'; import { DomainsTable } from './components/domains_table'; import { CRAWLER_TITLE } from './constants'; import { CrawlerOverviewLogic } from './crawler_overview_logic'; @@ -31,6 +36,21 @@ export const CrawlerOverview: React.FC = () => { pageHeader={{ pageTitle: CRAWLER_TITLE }} isLoading={dataLoading} > + + + +

+ {i18n.translate('xpack.enterpriseSearch.appSearch.crawler.domainsTitle', { + defaultMessage: 'Domains', + })} +

+
+
+ + + +
+ ); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_router.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_router.tsx index a0145cf76908a..c5dd3907c9019 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_router.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/crawler_router.tsx @@ -8,13 +8,15 @@ import React from 'react'; import { Route, Switch } from 'react-router-dom'; +import { ENGINE_CRAWLER_PATH } from '../../routes'; + import { CrawlerLanding } from './crawler_landing'; import { CrawlerOverview } from './crawler_overview'; export const CrawlerRouter: React.FC = () => { return ( - + {process.env.NODE_ENV === 'development' ? : } diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/flash_messages/handle_api_errors.test.ts b/x-pack/plugins/enterprise_search/public/applications/shared/flash_messages/handle_api_errors.test.ts index b361e796b4f43..47cbef0bfd953 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/flash_messages/handle_api_errors.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/flash_messages/handle_api_errors.test.ts @@ -9,7 +9,7 @@ import '../../__mocks__/kea_logic/kibana_logic.mock'; import { FlashMessagesLogic } from './flash_messages_logic'; -import { flashAPIErrors } from './handle_api_errors'; +import { flashAPIErrors, getErrorsFromHttpResponse } from './handle_api_errors'; describe('flashAPIErrors', () => { const mockHttpError = { @@ -68,10 +68,29 @@ describe('flashAPIErrors', () => { try { flashAPIErrors(Error('whatever') as any); } catch (e) { - expect(e.message).toEqual('whatever'); expect(FlashMessagesLogic.actions.setFlashMessages).toHaveBeenCalledWith([ - { type: 'error', message: 'An unexpected error occurred' }, + { type: 'error', message: expect.any(String) }, ]); } }); }); + +describe('getErrorsFromHttpResponse', () => { + it('should return errors from the response if present', () => { + expect( + getErrorsFromHttpResponse({ + body: { attributes: { errors: ['first error', 'second error'] } }, + } as any) + ).toEqual(['first error', 'second error']); + }); + + it('should return a message from the responnse if no errors', () => { + expect(getErrorsFromHttpResponse({ body: { message: 'test message' } } as any)).toEqual([ + 'test message', + ]); + }); + + it('should return the a default message otherwise', () => { + expect(getErrorsFromHttpResponse({} as any)).toEqual([expect.any(String)]); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/flash_messages/handle_api_errors.ts b/x-pack/plugins/enterprise_search/public/applications/shared/flash_messages/handle_api_errors.ts index 1b5dab0839663..7c82dfb971a1d 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/flash_messages/handle_api_errors.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/flash_messages/handle_api_errors.ts @@ -40,13 +40,22 @@ export const defaultErrorMessage = i18n.translate( } ); +export const getErrorsFromHttpResponse = (response: HttpResponse) => { + return Array.isArray(response?.body?.attributes?.errors) + ? response.body!.attributes.errors + : [response?.body?.message || defaultErrorMessage]; +}; + /** * Converts API/HTTP errors into user-facing Flash Messages */ -export const flashAPIErrors = (error: HttpResponse, { isQueued }: Options = {}) => { - const errorFlashMessages: IFlashMessage[] = Array.isArray(error?.body?.attributes?.errors) - ? error.body!.attributes.errors.map((message) => ({ type: 'error', message })) - : [{ type: 'error', message: error?.body?.message || defaultErrorMessage }]; +export const flashAPIErrors = ( + response: HttpResponse, + { isQueued }: Options = {} +) => { + const errorFlashMessages: IFlashMessage[] = getErrorsFromHttpResponse( + response + ).map((message) => ({ type: 'error', message })); if (isQueued) { FlashMessagesLogic.actions.setQueuedMessages(errorFlashMessages); @@ -56,7 +65,7 @@ export const flashAPIErrors = (error: HttpResponse, { isQueued }: // If this was a programming error or a failed request (such as a CORS) error, // we rethrow the error so it shows up in the developer console - if (!error?.body?.message) { - throw error; + if (!response?.body?.message) { + throw response; } }; diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/crawler.test.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/crawler.test.ts index 06a206017fbd1..fd478e35064c5 100644 --- a/x-pack/plugins/enterprise_search/server/routes/app_search/crawler.test.ts +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/crawler.test.ts @@ -43,6 +43,62 @@ describe('crawler routes', () => { }); }); + describe('POST /api/app_search/engines/{name}/crawler/domains', () => { + let mockRouter: MockRouter; + + beforeEach(() => { + jest.clearAllMocks(); + mockRouter = new MockRouter({ + method: 'post', + path: '/api/app_search/engines/{name}/crawler/domains', + }); + + registerCrawlerRoutes({ + ...mockDependencies, + router: mockRouter.router, + }); + }); + + it('creates a request to enterprise search', () => { + expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ + path: '/api/as/v0/engines/:name/crawler/domains', + }); + }); + + it('validates correctly with params and body', () => { + const request = { + params: { name: 'some-engine' }, + body: { name: 'https://elastic.co/guide', entry_points: [{ value: '/guide' }] }, + }; + mockRouter.shouldValidate(request); + }); + + it('accepts a query param', () => { + const request = { + params: { name: 'some-engine' }, + body: { name: 'https://elastic.co/guide', entry_points: [{ value: '/guide' }] }, + query: { respond_with: 'crawler_details' }, + }; + mockRouter.shouldValidate(request); + }); + + it('fails validation without a name param', () => { + const request = { + params: {}, + body: { name: 'https://elastic.co/guide', entry_points: [{ value: '/guide' }] }, + }; + mockRouter.shouldThrow(request); + }); + + it('fails validation without a body', () => { + const request = { + params: { name: 'some-engine' }, + body: {}, + }; + mockRouter.shouldThrow(request); + }); + }); + describe('DELETE /api/app_search/engines/{name}/crawler/domains/{id}', () => { let mockRouter: MockRouter; diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/crawler.ts b/x-pack/plugins/enterprise_search/server/routes/app_search/crawler.ts index 6c8ed7a49c64a..35bfae763bb9a 100644 --- a/x-pack/plugins/enterprise_search/server/routes/app_search/crawler.ts +++ b/x-pack/plugins/enterprise_search/server/routes/app_search/crawler.ts @@ -27,6 +27,31 @@ export function registerCrawlerRoutes({ }) ); + router.post( + { + path: '/api/app_search/engines/{name}/crawler/domains', + validate: { + params: schema.object({ + name: schema.string(), + }), + body: schema.object({ + name: schema.string(), + entry_points: schema.arrayOf( + schema.object({ + value: schema.string(), + }) + ), + }), + query: schema.object({ + respond_with: schema.maybe(schema.string()), + }), + }, + }, + enterpriseSearchRequestHandler.createRequest({ + path: '/api/as/v0/engines/:name/crawler/domains', + }) + ); + router.delete( { path: '/api/app_search/engines/{name}/crawler/domains/{id}', From a1134e1bcaa37353a2aacf8098ae7e3f518b1a04 Mon Sep 17 00:00:00 2001 From: Mat Schaffer Date: Mon, 26 Jul 2021 13:52:51 +0900 Subject: [PATCH 4/5] Note full cli arg for es full featured snapshot (#103045) --- docs/developer/advanced/running-elasticsearch.asciidoc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/developer/advanced/running-elasticsearch.asciidoc b/docs/developer/advanced/running-elasticsearch.asciidoc index e5c86fafd1ce7..324d2af2ed3af 100644 --- a/docs/developer/advanced/running-elasticsearch.asciidoc +++ b/docs/developer/advanced/running-elasticsearch.asciidoc @@ -25,7 +25,7 @@ See all available options, like how to specify a specific license, with the `--h yarn es snapshot --help ---- -`trial` will give you access to all capabilities. +`--license trial` will give you access to all capabilities. **Keeping data between snapshots** From 6a50aff2545167b176a31383ef491bc33e1c6409 Mon Sep 17 00:00:00 2001 From: Yaroslav Kuznietsov Date: Mon, 26 Jul 2021 09:09:37 +0300 Subject: [PATCH 5/5] [Canvas] Register `expression_functions` in `{expression}/public/plugin.ts`. (#106636) * Registered `revealImageFunction` in `public/plugin`. * Registered `shapeFunction` in `public/plugin`. --- src/plugins/expression_reveal_image/public/plugin.ts | 2 ++ src/plugins/expression_shape/public/plugin.ts | 2 ++ 2 files changed, 4 insertions(+) diff --git a/src/plugins/expression_reveal_image/public/plugin.ts b/src/plugins/expression_reveal_image/public/plugin.ts index 5f6496a25f820..c3522b43ca0ca 100755 --- a/src/plugins/expression_reveal_image/public/plugin.ts +++ b/src/plugins/expression_reveal_image/public/plugin.ts @@ -9,6 +9,7 @@ import { CoreSetup, CoreStart, Plugin } from '../../../core/public'; import { ExpressionsStart, ExpressionsSetup } from '../../expressions/public'; import { revealImageRenderer } from './expression_renderers'; +import { revealImageFunction } from '../common/expression_functions'; interface SetupDeps { expressions: ExpressionsSetup; @@ -30,6 +31,7 @@ export class ExpressionRevealImagePlugin StartDeps > { public setup(core: CoreSetup, { expressions }: SetupDeps): ExpressionRevealImagePluginSetup { + expressions.registerFunction(revealImageFunction); expressions.registerRenderer(revealImageRenderer); } diff --git a/src/plugins/expression_shape/public/plugin.ts b/src/plugins/expression_shape/public/plugin.ts index cb28f97acd697..b20f357d52a9b 100755 --- a/src/plugins/expression_shape/public/plugin.ts +++ b/src/plugins/expression_shape/public/plugin.ts @@ -9,6 +9,7 @@ import { CoreSetup, CoreStart, Plugin } from '../../../core/public'; import { ExpressionsStart, ExpressionsSetup } from '../../expressions/public'; import { shapeRenderer } from './expression_renderers'; +import { shapeFunction } from '../common/expression_functions'; interface SetupDeps { expressions: ExpressionsSetup; @@ -24,6 +25,7 @@ export type ExpressionShapePluginStart = void; export class ExpressionShapePlugin implements Plugin { public setup(core: CoreSetup, { expressions }: SetupDeps): ExpressionShapePluginSetup { + expressions.registerFunction(shapeFunction); expressions.registerRenderer(shapeRenderer); }