From c44f01979019d32500856753ca35d67e542030c5 Mon Sep 17 00:00:00 2001 From: Thomas Neirynck Date: Mon, 13 Jul 2020 11:55:36 -0400 Subject: [PATCH 01/66] [Maps] Show joins disabled message (#70826) Show feedback in the layer-settings when the scaling-method does not support Term-joins. --- .../data_request_descriptor_types.ts | 5 +- .../maps/common/descriptor_types/sources.ts | 6 +- .../blended_vector_layer.ts | 16 ++- .../maps/public/classes/layers/layer.tsx | 22 ++-- .../es_geo_grid_source/es_geo_grid_source.js | 4 +- .../es_pew_pew_source/es_pew_pew_source.js | 2 +- .../es_search_source/es_search_source.js | 11 +- .../maps/public/classes/sources/source.ts | 10 +- .../sources/vector_source/vector_source.js | 2 +- .../__snapshots__/view.test.js.snap | 13 +- .../connected_components/layer_panel/index.js | 2 +- .../__snapshots__/join_editor.test.tsx.snap | 100 ++++++++++++++ .../layer_panel/join_editor/index.js | 31 ----- .../layer_panel/join_editor/index.tsx | 31 +++++ .../join_editor/join_editor.test.tsx | 63 +++++++++ .../layer_panel/join_editor/join_editor.tsx | 124 ++++++++++++++++++ .../layer_panel/join_editor/view.js | 103 --------------- .../connected_components/layer_panel/view.js | 5 +- .../layer_panel/view.test.js | 2 +- 19 files changed, 383 insertions(+), 169 deletions(-) create mode 100644 x-pack/plugins/maps/public/connected_components/layer_panel/join_editor/__snapshots__/join_editor.test.tsx.snap delete mode 100644 x-pack/plugins/maps/public/connected_components/layer_panel/join_editor/index.js create mode 100644 x-pack/plugins/maps/public/connected_components/layer_panel/join_editor/index.tsx create mode 100644 x-pack/plugins/maps/public/connected_components/layer_panel/join_editor/join_editor.test.tsx create mode 100644 x-pack/plugins/maps/public/connected_components/layer_panel/join_editor/join_editor.tsx delete mode 100644 x-pack/plugins/maps/public/connected_components/layer_panel/join_editor/view.js diff --git a/x-pack/plugins/maps/common/descriptor_types/data_request_descriptor_types.ts b/x-pack/plugins/maps/common/descriptor_types/data_request_descriptor_types.ts index c7bfe94742bd6..1bd8c5401eb1d 100644 --- a/x-pack/plugins/maps/common/descriptor_types/data_request_descriptor_types.ts +++ b/x-pack/plugins/maps/common/descriptor_types/data_request_descriptor_types.ts @@ -5,7 +5,7 @@ */ /* eslint-disable @typescript-eslint/consistent-type-definitions */ -import { RENDER_AS, SORT_ORDER, SCALING_TYPES } from '../constants'; +import { RENDER_AS, SORT_ORDER, SCALING_TYPES, SOURCE_TYPES } from '../constants'; import { MapExtent, MapQuery } from './map_descriptor'; import { Filter, TimeRange } from '../../../../../src/plugins/data/common'; @@ -26,10 +26,12 @@ type ESSearchSourceSyncMeta = { scalingType: SCALING_TYPES; topHitsSplitField: string; topHitsSize: number; + sourceType: SOURCE_TYPES.ES_SEARCH; }; type ESGeoGridSourceSyncMeta = { requestType: RENDER_AS; + sourceType: SOURCE_TYPES.ES_GEO_GRID; }; export type VectorSourceSyncMeta = ESSearchSourceSyncMeta | ESGeoGridSourceSyncMeta | null; @@ -51,7 +53,6 @@ export type VectorStyleRequestMeta = MapFilters & { export type ESSearchSourceResponseMeta = { areResultsTrimmed?: boolean; - sourceType?: string; // top hits meta areEntitiesTrimmed?: boolean; diff --git a/x-pack/plugins/maps/common/descriptor_types/sources.ts b/x-pack/plugins/maps/common/descriptor_types/sources.ts index e32b5f44c8272..7eda37bf53351 100644 --- a/x-pack/plugins/maps/common/descriptor_types/sources.ts +++ b/x-pack/plugins/maps/common/descriptor_types/sources.ts @@ -77,8 +77,8 @@ export type ESPewPewSourceDescriptor = AbstractESAggSourceDescriptor & { }; export type ESTermSourceDescriptor = AbstractESAggSourceDescriptor & { - indexPatternTitle: string; - term: string; // term field name + indexPatternTitle?: string; + term?: string; // term field name whereQuery?: Query; }; @@ -138,7 +138,7 @@ export type GeojsonFileSourceDescriptor = { }; export type JoinDescriptor = { - leftField: string; + leftField?: string; right: ESTermSourceDescriptor; }; diff --git a/x-pack/plugins/maps/public/classes/layers/blended_vector_layer/blended_vector_layer.ts b/x-pack/plugins/maps/public/classes/layers/blended_vector_layer/blended_vector_layer.ts index 551e20fc5ceb5..26a0ffc1b1a37 100644 --- a/x-pack/plugins/maps/public/classes/layers/blended_vector_layer/blended_vector_layer.ts +++ b/x-pack/plugins/maps/public/classes/layers/blended_vector_layer/blended_vector_layer.ts @@ -126,7 +126,7 @@ function getClusterStyleDescriptor( ), } : undefined; - // @ts-ignore + // @ts-expect-error clusterStyleDescriptor.properties[styleName] = { type: STYLE_TYPE.DYNAMIC, options: { @@ -136,7 +136,7 @@ function getClusterStyleDescriptor( }; } else { // copy static styles to cluster style - // @ts-ignore + // @ts-expect-error clusterStyleDescriptor.properties[styleName] = { type: STYLE_TYPE.STATIC, options: { ...styleProperty.getOptions() }, @@ -192,8 +192,8 @@ export class BlendedVectorLayer extends VectorLayer implements IVectorLayer { const requestMeta = sourceDataRequest.getMeta(); if ( requestMeta && - requestMeta.sourceType && - requestMeta.sourceType === SOURCE_TYPES.ES_GEO_GRID + requestMeta.sourceMeta && + requestMeta.sourceMeta.sourceType === SOURCE_TYPES.ES_GEO_GRID ) { isClustered = true; } @@ -220,8 +220,12 @@ export class BlendedVectorLayer extends VectorLayer implements IVectorLayer { : displayName; } - isJoinable() { - return false; + showJoinEditor() { + return true; + } + + getJoinsDisabledReason() { + return this._documentSource.getJoinsDisabledReason(); } getJoins() { diff --git a/x-pack/plugins/maps/public/classes/layers/layer.tsx b/x-pack/plugins/maps/public/classes/layers/layer.tsx index d6f6ee8fa609b..d8def155a9185 100644 --- a/x-pack/plugins/maps/public/classes/layers/layer.tsx +++ b/x-pack/plugins/maps/public/classes/layers/layer.tsx @@ -78,6 +78,8 @@ export interface ILayer { isPreviewLayer: () => boolean; areLabelsOnTop: () => boolean; supportsLabelsOnTop: () => boolean; + showJoinEditor(): boolean; + getJoinsDisabledReason(): string | null; } export type Footnote = { icon: ReactElement; @@ -141,13 +143,12 @@ export class AbstractLayer implements ILayer { } static getBoundDataForSource(mbMap: unknown, sourceId: string): FeatureCollection { - // @ts-ignore + // @ts-expect-error const mbStyle = mbMap.getStyle(); return mbStyle.sources[sourceId].data; } async cloneDescriptor(): Promise { - // @ts-ignore const clonedDescriptor = copyPersistentState(this._descriptor); // layer id is uuid used to track styles/layers in mapbox clonedDescriptor.id = uuid(); @@ -155,14 +156,10 @@ export class AbstractLayer implements ILayer { clonedDescriptor.label = `Clone of ${displayName}`; clonedDescriptor.sourceDescriptor = this.getSource().cloneDescriptor(); - // todo: remove this - // This should not be in AbstractLayer. It relies on knowledge of VectorLayerDescriptor - // @ts-ignore if (clonedDescriptor.joins) { - // @ts-ignore + // @ts-expect-error clonedDescriptor.joins.forEach((joinDescriptor) => { // right.id is uuid used to track requests in inspector - // @ts-ignore joinDescriptor.right.id = uuid(); }); } @@ -173,8 +170,12 @@ export class AbstractLayer implements ILayer { return `${this.getId()}${MB_SOURCE_ID_LAYER_ID_PREFIX_DELIMITER}${layerNameSuffix}`; } - isJoinable(): boolean { - return this.getSource().isJoinable(); + showJoinEditor(): boolean { + return this.getSource().showJoinEditor(); + } + + getJoinsDisabledReason() { + return this.getSource().getJoinsDisabledReason(); } isPreviewLayer(): boolean { @@ -394,7 +395,6 @@ export class AbstractLayer implements ILayer { const requestTokens = this._dataRequests.map((dataRequest) => dataRequest.getRequestToken()); // Compact removes all the undefineds - // @ts-ignore return _.compact(requestTokens); } @@ -478,7 +478,7 @@ export class AbstractLayer implements ILayer { } syncVisibilityWithMb(mbMap: unknown, mbLayerId: string) { - // @ts-ignore + // @ts-expect-error mbMap.setLayoutProperty(mbLayerId, 'visibility', this.isVisible() ? 'visible' : 'none'); } diff --git a/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/es_geo_grid_source.js b/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/es_geo_grid_source.js index 9431fb55dc88b..1be74140fe1bf 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/es_geo_grid_source.js +++ b/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/es_geo_grid_source.js @@ -63,6 +63,7 @@ export class ESGeoGridSource extends AbstractESAggSource { getSyncMeta() { return { requestType: this._descriptor.requestType, + sourceType: SOURCE_TYPES.ES_GEO_GRID, }; } @@ -103,7 +104,7 @@ export class ESGeoGridSource extends AbstractESAggSource { return true; } - isJoinable() { + showJoinEditor() { return false; } @@ -307,7 +308,6 @@ export class ESGeoGridSource extends AbstractESAggSource { }, meta: { areResultsTrimmed: false, - sourceType: SOURCE_TYPES.ES_GEO_GRID, }, }; } diff --git a/x-pack/plugins/maps/public/classes/sources/es_pew_pew_source/es_pew_pew_source.js b/x-pack/plugins/maps/public/classes/sources/es_pew_pew_source/es_pew_pew_source.js index a4cff7c89a011..98db7bcdcc8a3 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_pew_pew_source/es_pew_pew_source.js +++ b/x-pack/plugins/maps/public/classes/sources/es_pew_pew_source/es_pew_pew_source.js @@ -51,7 +51,7 @@ export class ESPewPewSource extends AbstractESAggSource { return true; } - isJoinable() { + showJoinEditor() { return false; } diff --git a/x-pack/plugins/maps/public/classes/sources/es_search_source/es_search_source.js b/x-pack/plugins/maps/public/classes/sources/es_search_source/es_search_source.js index c8f14f1dc6a4b..330fa6e8318ed 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_search_source/es_search_source.js +++ b/x-pack/plugins/maps/public/classes/sources/es_search_source/es_search_source.js @@ -385,7 +385,7 @@ export class ESSearchSource extends AbstractESSource { return { data: featureCollection, - meta: { ...meta, sourceType: SOURCE_TYPES.ES_SEARCH }, + meta, }; } @@ -540,6 +540,7 @@ export class ESSearchSource extends AbstractESSource { scalingType: this._descriptor.scalingType, topHitsSplitField: this._descriptor.topHitsSplitField, topHitsSize: this._descriptor.topHitsSize, + sourceType: SOURCE_TYPES.ES_SEARCH, }; } @@ -551,6 +552,14 @@ export class ESSearchSource extends AbstractESSource { path: geoField.name, }; } + + getJoinsDisabledReason() { + return this._descriptor.scalingType === SCALING_TYPES.CLUSTERS + ? i18n.translate('xpack.maps.source.esSearch.joinsDisabledReason', { + defaultMessage: 'Joins are not supported when scaling by clusters', + }) + : null; + } } registerSource({ diff --git a/x-pack/plugins/maps/public/classes/sources/source.ts b/x-pack/plugins/maps/public/classes/sources/source.ts index c68e22ada8b0c..696c07376575b 100644 --- a/x-pack/plugins/maps/public/classes/sources/source.ts +++ b/x-pack/plugins/maps/public/classes/sources/source.ts @@ -54,7 +54,8 @@ export interface ISource { isESSource(): boolean; renderSourceSettingsEditor({ onChange }: SourceEditorArgs): ReactElement | null; supportsFitToBounds(): Promise; - isJoinable(): boolean; + showJoinEditor(): boolean; + getJoinsDisabledReason(): string | null; cloneDescriptor(): SourceDescriptor; getFieldNames(): string[]; getApplyGlobalQuery(): boolean; @@ -80,7 +81,6 @@ export class AbstractSource implements ISource { destroy(): void {} cloneDescriptor(): SourceDescriptor { - // @ts-ignore return copyPersistentState(this._descriptor); } @@ -148,10 +148,14 @@ export class AbstractSource implements ISource { return 0; } - isJoinable(): boolean { + showJoinEditor(): boolean { return false; } + getJoinsDisabledReason() { + return null; + } + isESSource(): boolean { return false; } diff --git a/x-pack/plugins/maps/public/classes/sources/vector_source/vector_source.js b/x-pack/plugins/maps/public/classes/sources/vector_source/vector_source.js index ecb13bb875721..98ed89a6ff0ad 100644 --- a/x-pack/plugins/maps/public/classes/sources/vector_source/vector_source.js +++ b/x-pack/plugins/maps/public/classes/sources/vector_source/vector_source.js @@ -122,7 +122,7 @@ export class AbstractVectorSource extends AbstractSource { return false; } - isJoinable() { + showJoinEditor() { return true; } diff --git a/x-pack/plugins/maps/public/connected_components/layer_panel/__snapshots__/view.test.js.snap b/x-pack/plugins/maps/public/connected_components/layer_panel/__snapshots__/view.test.js.snap index 1c48ed2290dce..2cf5287ae6594 100644 --- a/x-pack/plugins/maps/public/connected_components/layer_panel/__snapshots__/view.test.js.snap +++ b/x-pack/plugins/maps/public/connected_components/layer_panel/__snapshots__/view.test.js.snap @@ -96,8 +96,8 @@ exports[`LayerPanel is rendered 1`] = ` "getId": [Function], "getImmutableSourceProperties": [Function], "getLayerTypeIconName": [Function], - "isJoinable": [Function], "renderSourceSettingsEditor": [Function], + "showJoinEditor": [Function], "supportsElasticsearchFilters": [Function], } } @@ -107,6 +107,17 @@ exports[`LayerPanel is rendered 1`] = ` diff --git a/x-pack/plugins/maps/public/connected_components/layer_panel/index.js b/x-pack/plugins/maps/public/connected_components/layer_panel/index.js index 1c8dcdb43d434..17fd41d120194 100644 --- a/x-pack/plugins/maps/public/connected_components/layer_panel/index.js +++ b/x-pack/plugins/maps/public/connected_components/layer_panel/index.js @@ -12,7 +12,7 @@ import { updateSourceProp } from '../../actions'; function mapStateToProps(state = {}) { const selectedLayer = getSelectedLayer(state); return { - key: selectedLayer ? `${selectedLayer.getId()}${selectedLayer.isJoinable()}` : '', + key: selectedLayer ? `${selectedLayer.getId()}${selectedLayer.showJoinEditor()}` : '', selectedLayer, }; } diff --git a/x-pack/plugins/maps/public/connected_components/layer_panel/join_editor/__snapshots__/join_editor.test.tsx.snap b/x-pack/plugins/maps/public/connected_components/layer_panel/join_editor/__snapshots__/join_editor.test.tsx.snap new file mode 100644 index 0000000000000..00d7f44d6273f --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/layer_panel/join_editor/__snapshots__/join_editor.test.tsx.snap @@ -0,0 +1,100 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Should render callout when joins are disabled 1`] = ` +
+ +
+ + + +
+
+ + Simulated disabled reason + +
+`; + +exports[`Should render join editor 1`] = ` +
+ +
+ + + +
+
+ + + + + + + + +
+`; diff --git a/x-pack/plugins/maps/public/connected_components/layer_panel/join_editor/index.js b/x-pack/plugins/maps/public/connected_components/layer_panel/join_editor/index.js deleted file mode 100644 index cf55c16bbe0be..0000000000000 --- a/x-pack/plugins/maps/public/connected_components/layer_panel/join_editor/index.js +++ /dev/null @@ -1,31 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { connect } from 'react-redux'; -import { JoinEditor } from './view'; -import { - getSelectedLayer, - getSelectedLayerJoinDescriptors, -} from '../../../selectors/map_selectors'; -import { setJoinsForLayer } from '../../../actions'; - -function mapDispatchToProps(dispatch) { - return { - onChange: (layer, joins) => { - dispatch(setJoinsForLayer(layer, joins)); - }, - }; -} - -function mapStateToProps(state = {}) { - return { - joins: getSelectedLayerJoinDescriptors(state), - layer: getSelectedLayer(state), - }; -} - -const connectedJoinEditor = connect(mapStateToProps, mapDispatchToProps)(JoinEditor); -export { connectedJoinEditor as JoinEditor }; diff --git a/x-pack/plugins/maps/public/connected_components/layer_panel/join_editor/index.tsx b/x-pack/plugins/maps/public/connected_components/layer_panel/join_editor/index.tsx new file mode 100644 index 0000000000000..0348b38351971 --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/layer_panel/join_editor/index.tsx @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { AnyAction, Dispatch } from 'redux'; +import { connect } from 'react-redux'; +import { JoinEditor } from './join_editor'; +import { getSelectedLayerJoinDescriptors } from '../../../selectors/map_selectors'; +import { setJoinsForLayer } from '../../../actions'; +import { MapStoreState } from '../../../reducers/store'; +import { ILayer } from '../../../classes/layers/layer'; +import { JoinDescriptor } from '../../../../common/descriptor_types'; + +function mapStateToProps(state: MapStoreState) { + return { + joins: getSelectedLayerJoinDescriptors(state), + }; +} + +function mapDispatchToProps(dispatch: Dispatch) { + return { + onChange: (layer: ILayer, joins: JoinDescriptor[]) => { + dispatch(setJoinsForLayer(layer, joins)); + }, + }; +} + +const connectedJoinEditor = connect(mapStateToProps, mapDispatchToProps)(JoinEditor); +export { connectedJoinEditor as JoinEditor }; diff --git a/x-pack/plugins/maps/public/connected_components/layer_panel/join_editor/join_editor.test.tsx b/x-pack/plugins/maps/public/connected_components/layer_panel/join_editor/join_editor.test.tsx new file mode 100644 index 0000000000000..12da1c4bb9388 --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/layer_panel/join_editor/join_editor.test.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { ILayer } from '../../../classes/layers/layer'; +import { JoinEditor } from './join_editor'; +import { shallow } from 'enzyme'; +import { JoinDescriptor } from '../../../../common/descriptor_types'; + +class MockLayer { + private readonly _disableReason: string | null; + + constructor(disableReason: string | null) { + this._disableReason = disableReason; + } + + getJoinsDisabledReason() { + return this._disableReason; + } +} + +const defaultProps = { + joins: [ + { + leftField: 'iso2', + right: { + id: '673ff994-fc75-4c67-909b-69fcb0e1060e', + indexPatternTitle: 'kibana_sample_data_logs', + term: 'geo.src', + indexPatternId: 'abcde', + metrics: [ + { + type: 'count', + label: 'web logs count', + }, + ], + }, + } as JoinDescriptor, + ], + layerDisplayName: 'myLeftJoinField', + leftJoinFields: [], + onChange: () => {}, +}; + +test('Should render join editor', () => { + const component = shallow( + + ); + expect(component).toMatchSnapshot(); +}); + +test('Should render callout when joins are disabled', () => { + const component = shallow( + + ); + expect(component).toMatchSnapshot(); +}); diff --git a/x-pack/plugins/maps/public/connected_components/layer_panel/join_editor/join_editor.tsx b/x-pack/plugins/maps/public/connected_components/layer_panel/join_editor/join_editor.tsx new file mode 100644 index 0000000000000..c589604e85112 --- /dev/null +++ b/x-pack/plugins/maps/public/connected_components/layer_panel/join_editor/join_editor.tsx @@ -0,0 +1,124 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { Fragment } from 'react'; +import uuid from 'uuid/v4'; + +import { + EuiButtonEmpty, + EuiTitle, + EuiSpacer, + EuiToolTip, + EuiTextAlign, + EuiCallOut, +} from '@elastic/eui'; + +import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; +// @ts-expect-error +import { Join } from './resources/join'; +import { ILayer } from '../../../classes/layers/layer'; +import { JoinDescriptor } from '../../../../common/descriptor_types'; +import { IField } from '../../../classes/fields/field'; + +interface Props { + joins: JoinDescriptor[]; + layer: ILayer; + layerDisplayName: string; + leftJoinFields: IField[]; + onChange: (layer: ILayer, joins: JoinDescriptor[]) => void; +} + +export function JoinEditor({ joins, layer, onChange, leftJoinFields, layerDisplayName }: Props) { + const renderJoins = () => { + return joins.map((joinDescriptor: JoinDescriptor, index: number) => { + const handleOnChange = (updatedDescriptor: JoinDescriptor) => { + onChange(layer, [...joins.slice(0, index), updatedDescriptor, ...joins.slice(index + 1)]); + }; + + const handleOnRemove = () => { + onChange(layer, [...joins.slice(0, index), ...joins.slice(index + 1)]); + }; + + return ( + + + + + ); + }); + }; + + const addJoin = () => { + onChange(layer, [ + ...joins, + { + right: { + id: uuid(), + applyGlobalQuery: true, + }, + } as JoinDescriptor, + ]); + }; + + const renderContent = () => { + const disabledReason = layer.getJoinsDisabledReason(); + return disabledReason ? ( + {disabledReason} + ) : ( + + {renderJoins()} + + + + + + + + + + ); + }; + + return ( +
+ +
+ + + +
+
+ + {renderContent()} +
+ ); +} diff --git a/x-pack/plugins/maps/public/connected_components/layer_panel/join_editor/view.js b/x-pack/plugins/maps/public/connected_components/layer_panel/join_editor/view.js deleted file mode 100644 index 900f5c9ff53ea..0000000000000 --- a/x-pack/plugins/maps/public/connected_components/layer_panel/join_editor/view.js +++ /dev/null @@ -1,103 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React, { Fragment } from 'react'; -import uuid from 'uuid/v4'; - -import { - EuiFlexGroup, - EuiFlexItem, - EuiButtonIcon, - EuiTitle, - EuiSpacer, - EuiToolTip, -} from '@elastic/eui'; - -import { Join } from './resources/join'; -import { FormattedMessage } from '@kbn/i18n/react'; -import { i18n } from '@kbn/i18n'; - -export function JoinEditor({ joins, layer, onChange, leftJoinFields, layerDisplayName }) { - const renderJoins = () => { - return joins.map((joinDescriptor, index) => { - const handleOnChange = (updatedDescriptor) => { - onChange(layer, [...joins.slice(0, index), updatedDescriptor, ...joins.slice(index + 1)]); - }; - - const handleOnRemove = () => { - onChange(layer, [...joins.slice(0, index), ...joins.slice(index + 1)]); - }; - - return ( - - - - - ); - }); - }; - - const addJoin = () => { - onChange(layer, [ - ...joins, - { - right: { - id: uuid(), - applyGlobalQuery: true, - }, - }, - ]); - }; - - if (!layer.isJoinable()) { - return null; - } - - return ( -
- - - -
- - - -
-
-
- - - -
- - {renderJoins()} -
- ); -} diff --git a/x-pack/plugins/maps/public/connected_components/layer_panel/view.js b/x-pack/plugins/maps/public/connected_components/layer_panel/view.js index 71d76ff53d8a9..2e20a4492f08b 100644 --- a/x-pack/plugins/maps/public/connected_components/layer_panel/view.js +++ b/x-pack/plugins/maps/public/connected_components/layer_panel/view.js @@ -75,7 +75,7 @@ export class LayerPanel extends React.Component { }; async _loadLeftJoinFields() { - if (!this.props.selectedLayer || !this.props.selectedLayer.isJoinable()) { + if (!this.props.selectedLayer || !this.props.selectedLayer.showJoinEditor()) { return; } @@ -120,7 +120,7 @@ export class LayerPanel extends React.Component { } _renderJoinSection() { - if (!this.props.selectedLayer.isJoinable()) { + if (!this.props.selectedLayer.showJoinEditor()) { return null; } @@ -128,6 +128,7 @@ export class LayerPanel extends React.Component { diff --git a/x-pack/plugins/maps/public/connected_components/layer_panel/view.test.js b/x-pack/plugins/maps/public/connected_components/layer_panel/view.test.js index 99893c1bc5bee..33ca80b00c451 100644 --- a/x-pack/plugins/maps/public/connected_components/layer_panel/view.test.js +++ b/x-pack/plugins/maps/public/connected_components/layer_panel/view.test.js @@ -55,7 +55,7 @@ const mockLayer = { getImmutableSourceProperties: () => { return [{ label: 'source prop1', value: 'you get one chance to set me' }]; }, - isJoinable: () => { + showJoinEditor: () => { return true; }, supportsElasticsearchFilters: () => { From 94ef03dbd3ab57426fc04bbf0d6c11a8e12e11ac Mon Sep 17 00:00:00 2001 From: Jonathan Budzenski Date: Mon, 13 Jul 2020 10:56:25 -0500 Subject: [PATCH 02/66] Move kibana-keystore from data/ to config/ (#57856) * Move kibana-keystore from data/ to config/ This is a breaking change to move the location of kibana-keystore. Keystores in other stack products live in the config directory, so this updates our current path to be consistent. Closes #25746 * add breaking changes * update comment * wip * fix docs * read from both keystore locations, write priority to non-deprecated * note data directory fallback * add tests for get_keystore Co-authored-by: Elastic Machine --- docs/migration/migrate_8_0.asciidoc | 7 +++- src/cli_keystore/cli_keystore.js | 8 ++-- src/cli_keystore/get_keystore.js | 40 +++++++++++++++++++ src/cli_keystore/get_keystore.test.js | 57 +++++++++++++++++++++++++++ src/core/server/path/index.test.ts | 7 +++- src/core/server/path/index.ts | 15 ++++++- 6 files changed, 125 insertions(+), 9 deletions(-) create mode 100644 src/cli_keystore/get_keystore.js create mode 100644 src/cli_keystore/get_keystore.test.js diff --git a/docs/migration/migrate_8_0.asciidoc b/docs/migration/migrate_8_0.asciidoc index 82798e948822a..b80503750a26e 100644 --- a/docs/migration/migrate_8_0.asciidoc +++ b/docs/migration/migrate_8_0.asciidoc @@ -115,12 +115,17 @@ URL that it derived from the actual server address and `xpack.security.public` s *Impact:* Any workflow that involved manually clearing generated bundles will have to be updated with the new path. +[float]] +=== kibana.keystore has moved from the `data` folder to the `config` folder +*Details:* By default, kibana.keystore has moved from the configured `path.data` folder to `/config` for archive distributions +and `/etc/kibana` for package distributions. If a pre-existing keystore exists in the data directory that path will continue to be used. + [float] [[breaking_80_user_role_changes]] === User role changes [float] -==== `kibana_user` role has been removed and `kibana_admin` has been added. +=== `kibana_user` role has been removed and `kibana_admin` has been added. *Details:* The `kibana_user` role has been removed and `kibana_admin` has been added to better reflect its intended use. This role continues to grant all access to every diff --git a/src/cli_keystore/cli_keystore.js b/src/cli_keystore/cli_keystore.js index e1561b343ef39..d12c80b361c92 100644 --- a/src/cli_keystore/cli_keystore.js +++ b/src/cli_keystore/cli_keystore.js @@ -18,20 +18,16 @@ */ import _ from 'lodash'; -import { join } from 'path'; import { pkg } from '../core/server/utils'; import Command from '../cli/command'; -import { getDataPath } from '../core/server/path'; import { Keystore } from '../legacy/server/keystore'; -const path = join(getDataPath(), 'kibana.keystore'); -const keystore = new Keystore(path); - import { createCli } from './create'; import { listCli } from './list'; import { addCli } from './add'; import { removeCli } from './remove'; +import { getKeystore } from './get_keystore'; const argv = process.env.kbnWorkerArgv ? JSON.parse(process.env.kbnWorkerArgv) @@ -42,6 +38,8 @@ program .version(pkg.version) .description('A tool for managing settings stored in the Kibana keystore'); +const keystore = new Keystore(getKeystore()); + createCli(program, keystore); listCli(program, keystore); addCli(program, keystore); diff --git a/src/cli_keystore/get_keystore.js b/src/cli_keystore/get_keystore.js new file mode 100644 index 0000000000000..c8ff2555563ad --- /dev/null +++ b/src/cli_keystore/get_keystore.js @@ -0,0 +1,40 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { existsSync } from 'fs'; +import { join } from 'path'; + +import Logger from '../cli_plugin/lib/logger'; +import { getConfigDirectory, getDataPath } from '../core/server/path'; + +export function getKeystore() { + const configKeystore = join(getConfigDirectory(), 'kibana.keystore'); + const dataKeystore = join(getDataPath(), 'kibana.keystore'); + let keystorePath = null; + if (existsSync(dataKeystore)) { + const logger = new Logger(); + logger.log( + `kibana.keystore located in the data folder is deprecated. Future versions will use the config folder.` + ); + keystorePath = dataKeystore; + } else { + keystorePath = configKeystore; + } + return keystorePath; +} diff --git a/src/cli_keystore/get_keystore.test.js b/src/cli_keystore/get_keystore.test.js new file mode 100644 index 0000000000000..88102b8f51d57 --- /dev/null +++ b/src/cli_keystore/get_keystore.test.js @@ -0,0 +1,57 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { getKeystore } from './get_keystore'; +import Logger from '../cli_plugin/lib/logger'; +import fs from 'fs'; +import sinon from 'sinon'; + +describe('get_keystore', () => { + const sandbox = sinon.createSandbox(); + + beforeEach(() => { + sandbox.stub(Logger.prototype, 'log'); + }); + + afterEach(() => { + sandbox.restore(); + }); + + it('uses the config directory if there is no pre-existing keystore', () => { + sandbox.stub(fs, 'existsSync').returns(false); + expect(getKeystore()).toContain('config'); + expect(getKeystore()).not.toContain('data'); + }); + + it('uses the data directory if there is a pre-existing keystore in the data directory', () => { + sandbox.stub(fs, 'existsSync').returns(true); + expect(getKeystore()).toContain('data'); + expect(getKeystore()).not.toContain('config'); + }); + + it('logs a deprecation warning if the data directory is used', () => { + sandbox.stub(fs, 'existsSync').returns(true); + getKeystore(); + sandbox.assert.calledOnce(Logger.prototype.log); + sandbox.assert.calledWith( + Logger.prototype.log, + 'kibana.keystore located in the data folder is deprecated. Future versions will use the config folder.' + ); + }); +}); diff --git a/src/core/server/path/index.test.ts b/src/core/server/path/index.test.ts index 048622e1f7eab..522e100d85e5d 100644 --- a/src/core/server/path/index.test.ts +++ b/src/core/server/path/index.test.ts @@ -18,7 +18,7 @@ */ import { accessSync, constants } from 'fs'; -import { getConfigPath, getDataPath } from './'; +import { getConfigPath, getDataPath, getConfigDirectory } from './'; describe('Default path finder', () => { it('should find a kibana.yml', () => { @@ -30,4 +30,9 @@ describe('Default path finder', () => { const dataPath = getDataPath(); expect(() => accessSync(dataPath, constants.R_OK)).not.toThrow(); }); + + it('should find a config directory', () => { + const configDirectory = getConfigDirectory(); + expect(() => accessSync(configDirectory, constants.R_OK)).not.toThrow(); + }); }); diff --git a/src/core/server/path/index.ts b/src/core/server/path/index.ts index 2e05e3856bd4c..1bb650518c47a 100644 --- a/src/core/server/path/index.ts +++ b/src/core/server/path/index.ts @@ -30,6 +30,10 @@ const CONFIG_PATHS = [ fromRoot('config/kibana.yml'), ].filter(isString); +const CONFIG_DIRECTORIES = [process.env.KIBANA_PATH_CONF, fromRoot('config'), '/etc/kibana'].filter( + isString +); + const DATA_PATHS = [ process.env.DATA_PATH, // deprecated fromRoot('data'), @@ -49,12 +53,19 @@ function findFile(paths: string[]) { } /** - * Get the path where the config files are stored + * Get the path of kibana.yml * @internal */ export const getConfigPath = () => findFile(CONFIG_PATHS); + +/** + * Get the directory containing configuration files + * @internal + */ +export const getConfigDirectory = () => findFile(CONFIG_DIRECTORIES); + /** - * Get the path where the data can be stored + * Get the directory containing runtime data * @internal */ export const getDataPath = () => findFile(DATA_PATHS); From ced0023ef943fb2e20c5a53e28f1333bf9cb2dee Mon Sep 17 00:00:00 2001 From: Madison Caldwell Date: Mon, 13 Jul 2020 12:02:29 -0400 Subject: [PATCH 03/66] Mapping adjustments (#71449) --- .../server/endpoint/lib/artifacts/common.ts | 4 ++-- .../endpoint/lib/artifacts/saved_object_mappings.ts | 5 ++--- .../endpoint/artifacts/api_feature/data.json | 12 ++++++------ 3 files changed, 10 insertions(+), 11 deletions(-) diff --git a/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/common.ts b/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/common.ts index 71d14eb1226d5..77a5e85b14199 100644 --- a/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/common.ts +++ b/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/common.ts @@ -7,13 +7,13 @@ import { Logger } from 'src/core/server'; export const ArtifactConstants = { GLOBAL_ALLOWLIST_NAME: 'endpoint-exceptionlist', - SAVED_OBJECT_TYPE: 'endpoint:user-artifact:v2', + SAVED_OBJECT_TYPE: 'endpoint:user-artifact', SUPPORTED_OPERATING_SYSTEMS: ['linux', 'macos', 'windows'], SCHEMA_VERSION: 'v1', }; export const ManifestConstants = { - SAVED_OBJECT_TYPE: 'endpoint:user-artifact-manifest:v2', + SAVED_OBJECT_TYPE: 'endpoint:user-artifact-manifest', SCHEMA_VERSION: 'v1', INITIAL_VERSION: 'WzAsMF0=', }; diff --git a/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/saved_object_mappings.ts b/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/saved_object_mappings.ts index 89e974a3d5fd3..0fb433df95de3 100644 --- a/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/saved_object_mappings.ts +++ b/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/saved_object_mappings.ts @@ -45,7 +45,6 @@ export const exceptionsArtifactSavedObjectMappings: SavedObjectsType['mappings'] }, body: { type: 'binary', - index: false, }, }, }; @@ -66,14 +65,14 @@ export const manifestSavedObjectMappings: SavedObjectsType['mappings'] = { export const exceptionsArtifactType: SavedObjectsType = { name: exceptionsArtifactSavedObjectType, - hidden: false, // TODO: should these be hidden? + hidden: false, namespaceType: 'agnostic', mappings: exceptionsArtifactSavedObjectMappings, }; export const manifestType: SavedObjectsType = { name: manifestSavedObjectType, - hidden: false, // TODO: should these be hidden? + hidden: false, namespaceType: 'agnostic', mappings: manifestSavedObjectMappings, }; diff --git a/x-pack/test/functional/es_archives/endpoint/artifacts/api_feature/data.json b/x-pack/test/functional/es_archives/endpoint/artifacts/api_feature/data.json index bd1010240f86c..ab476660e3ffc 100644 --- a/x-pack/test/functional/es_archives/endpoint/artifacts/api_feature/data.json +++ b/x-pack/test/functional/es_archives/endpoint/artifacts/api_feature/data.json @@ -1,12 +1,12 @@ { "type": "doc", "value": { - "id": "endpoint:user-artifact:v2:endpoint-exceptionlist-linux-v1-d2a9c760005b08d43394e59a8701ae75c80881934ccf15a006944452b80f7f9f", + "id": "endpoint:user-artifact:endpoint-exceptionlist-linux-v1-d2a9c760005b08d43394e59a8701ae75c80881934ccf15a006944452b80f7f9f", "index": ".kibana", "source": { "references": [ ], - "endpoint:user-artifact:v2": { + "endpoint:user-artifact": { "body": "eJylkM8KwjAMxl9Fci59gN29iicvMqR02QjUbiSpKGPvbiw6ETwpuX1/fh9kBszKhALNcQa9TQgNCJ2nhOA+vJ4wdWaGqJSHPY8RRXxPCb3QkJEtP07IQUe2GOWYSoedqU8qXq16ikGqeAmpPNRtCqIU3WbnDx4WN38d/WvhQqmCXzDlIlojP9CsjLC0bqWtHwhaGN/1jHVkae3u+6N6Sg==", "created": 1593016187465, "compressionAlgorithm": "zlib", @@ -17,7 +17,7 @@ "decodedSha256": "d2a9c760005b08d43394e59a8701ae75c80881934ccf15a006944452b80f7f9f", "decodedSize": 358 }, - "type": "endpoint:user-artifact:v2", + "type": "endpoint:user-artifact", "updated_at": "2020-06-24T16:29:47.584Z" } } @@ -26,12 +26,12 @@ { "type": "doc", "value": { - "id": "endpoint:user-artifact-manifest:v2:endpoint-manifest-v1", + "id": "endpoint:user-artifact-manifest:endpoint-manifest-v1", "index": ".kibana", "source": { "references": [ ], - "endpoint:user-artifact-manifest:v2": { + "endpoint:user-artifact-manifest": { "created": 1593183699663, "ids": [ "endpoint-exceptionlist-linux-v1-d2a9c760005b08d43394e59a8701ae75c80881934ccf15a006944452b80f7f9f", @@ -39,7 +39,7 @@ "endpoint-exceptionlist-windows-v1-d801aa1fb7ddcc330a5e3173372ea6af4a3d08ec58074478e85aa5603e926658" ] }, - "type": "endpoint:user-artifact-manifest:v2", + "type": "endpoint:user-artifact-manifest", "updated_at": "2020-06-26T15:01:39.704Z" } } From eac0f8d98d5ec8dfcb0ae3d6a6176a9640a5a9ad Mon Sep 17 00:00:00 2001 From: Pierre Gayvallet Date: Mon, 13 Jul 2020 18:03:49 +0200 Subject: [PATCH 04/66] preserve 401 errors from legacy es client (#71234) * preserve 401 errors from legacy es client * use exact import to resolve mocked import issue --- .../legacy/cluster_client.test.ts | 4 +-- .../core_service.test.mocks.ts | 5 ++- .../integration_tests/core_services.test.ts | 34 +++++++++++++++++-- src/core/server/http/router/router.ts | 5 +++ 4 files changed, 41 insertions(+), 7 deletions(-) diff --git a/src/core/server/elasticsearch/legacy/cluster_client.test.ts b/src/core/server/elasticsearch/legacy/cluster_client.test.ts index 2f0f80728c707..fd57d06e61eee 100644 --- a/src/core/server/elasticsearch/legacy/cluster_client.test.ts +++ b/src/core/server/elasticsearch/legacy/cluster_client.test.ts @@ -130,7 +130,7 @@ describe('#callAsInternalUser', () => { expect(mockEsClientInstance.security.authenticate).toHaveBeenLastCalledWith(mockParams); }); - test('does not wrap errors if `wrap401Errors` is not set', async () => { + test('does not wrap errors if `wrap401Errors` is set to `false`', async () => { const mockError = { message: 'some error' }; mockEsClientInstance.ping.mockRejectedValue(mockError); @@ -146,7 +146,7 @@ describe('#callAsInternalUser', () => { ).rejects.toBe(mockAuthenticationError); }); - test('wraps only 401 errors by default or when `wrap401Errors` is set', async () => { + test('wraps 401 errors when `wrap401Errors` is set to `true` or unspecified', async () => { const mockError = { message: 'some error' }; mockEsClientInstance.ping.mockRejectedValue(mockError); diff --git a/src/core/server/http/integration_tests/core_service.test.mocks.ts b/src/core/server/http/integration_tests/core_service.test.mocks.ts index f7ebd18b9c488..c23724b7d332f 100644 --- a/src/core/server/http/integration_tests/core_service.test.mocks.ts +++ b/src/core/server/http/integration_tests/core_service.test.mocks.ts @@ -19,10 +19,9 @@ import { elasticsearchServiceMock } from '../../elasticsearch/elasticsearch_service.mock'; export const clusterClientMock = jest.fn(); +export const clusterClientInstanceMock = elasticsearchServiceMock.createLegacyScopedClusterClient(); jest.doMock('../../elasticsearch/legacy/scoped_cluster_client', () => ({ - LegacyScopedClusterClient: clusterClientMock.mockImplementation(function () { - return elasticsearchServiceMock.createLegacyScopedClusterClient(); - }), + LegacyScopedClusterClient: clusterClientMock.mockImplementation(() => clusterClientInstanceMock), })); jest.doMock('elasticsearch', () => { diff --git a/src/core/server/http/integration_tests/core_services.test.ts b/src/core/server/http/integration_tests/core_services.test.ts index ba39effa77016..0ee53a04d9f87 100644 --- a/src/core/server/http/integration_tests/core_services.test.ts +++ b/src/core/server/http/integration_tests/core_services.test.ts @@ -16,9 +16,13 @@ * specific language governing permissions and limitations * under the License. */ + +import { clusterClientMock, clusterClientInstanceMock } from './core_service.test.mocks'; + import Boom from 'boom'; import { Request } from 'hapi'; -import { clusterClientMock } from './core_service.test.mocks'; +import { errors as esErrors } from 'elasticsearch'; +import { LegacyElasticsearchErrorHelpers } from '../../elasticsearch/legacy'; import * as kbnTestServer from '../../../../test_utils/kbn_server'; @@ -352,7 +356,7 @@ describe('http service', () => { }); }); }); - describe('elasticsearch', () => { + describe('legacy elasticsearch client', () => { let root: ReturnType; beforeEach(async () => { root = kbnTestServer.createRoot({ plugins: { initialize: false } }); @@ -410,5 +414,31 @@ describe('http service', () => { const [, , clientHeaders] = client; expect(clientHeaders).toEqual({ authorization: authorizationHeader }); }); + + it('forwards 401 errors returned from elasticsearch', async () => { + const { http } = await root.setup(); + const { createRouter } = http; + + const authenticationError = LegacyElasticsearchErrorHelpers.decorateNotAuthorizedError( + new (esErrors.AuthenticationException as any)('Authentication Exception', { + body: { error: { header: { 'WWW-Authenticate': 'authenticate header' } } }, + statusCode: 401, + }) + ); + + clusterClientInstanceMock.callAsCurrentUser.mockRejectedValue(authenticationError); + + const router = createRouter('/new-platform'); + router.get({ path: '/', validate: false }, async (context, req, res) => { + await context.core.elasticsearch.legacy.client.callAsCurrentUser('ping'); + return res.ok(); + }); + + await root.start(); + + const response = await kbnTestServer.request.get(root, '/new-platform/').expect(401); + + expect(response.header['www-authenticate']).toEqual('authenticate header'); + }); }); }); diff --git a/src/core/server/http/router/router.ts b/src/core/server/http/router/router.ts index 69402a74eda5f..35eec746163ce 100644 --- a/src/core/server/http/router/router.ts +++ b/src/core/server/http/router/router.ts @@ -22,6 +22,7 @@ import Boom from 'boom'; import { isConfigSchema } from '@kbn/config-schema'; import { Logger } from '../../logging'; +import { LegacyElasticsearchErrorHelpers } from '../../elasticsearch/legacy/errors'; import { KibanaRequest } from './request'; import { KibanaResponseFactory, kibanaResponseFactory, IKibanaResponse } from './response'; import { RouteConfig, RouteConfigOptions, RouteMethod, validBodyOutput } from './route'; @@ -263,6 +264,10 @@ export class Router implements IRouter { return hapiResponseAdapter.handle(kibanaResponse); } catch (e) { this.log.error(e); + // forward 401 (boom) error from ES + if (LegacyElasticsearchErrorHelpers.isNotAuthorizedError(e)) { + return e; + } return hapiResponseAdapter.toInternalError(); } } From 105afbce3d0d0264ea7de4c901e4dd41f392e89e Mon Sep 17 00:00:00 2001 From: Alison Goryachev Date: Mon, 13 Jul 2020 12:10:01 -0400 Subject: [PATCH 05/66] [Component templates] Address feedback (#70912) --- .../component_template_serialization.test.ts | 2 + .../lib/component_template_serialization.ts | 6 +- .../index_management/common/lib/index.ts | 2 +- .../common/types/component_templates.ts | 2 + .../component_template_create.test.tsx | 2 +- .../component_template_details.test.ts | 4 +- .../component_template_edit.test.tsx | 2 +- .../component_template_list.test.ts | 2 + .../helpers/setup_environment.tsx | 2 + .../component_template_details.tsx | 38 +++++++++-- .../tab_summary.tsx | 48 ++++++++++++- .../component_template_list.tsx | 42 +++++++++--- .../component_template_list/empty_prompt.tsx | 6 +- .../component_template_list/table.tsx | 51 +++++++++----- .../component_template_form.tsx | 67 +++++++++++++------ .../steps/step_logistics.tsx | 8 +-- .../steps/step_review.tsx | 14 ++-- .../component_templates_context.tsx | 25 ++++++- .../component_templates/shared_imports.ts | 2 + .../public/application/index.tsx | 3 +- .../routes/api/component_templates/get.ts | 4 +- .../component_templates/schema_validation.ts | 1 + .../index_management/component_templates.ts | 7 ++ 23 files changed, 254 insertions(+), 86 deletions(-) diff --git a/x-pack/plugins/index_management/common/lib/component_template_serialization.test.ts b/x-pack/plugins/index_management/common/lib/component_template_serialization.test.ts index 83682f45918e3..16c45991d1f32 100644 --- a/x-pack/plugins/index_management/common/lib/component_template_serialization.test.ts +++ b/x-pack/plugins/index_management/common/lib/component_template_serialization.test.ts @@ -92,6 +92,7 @@ describe('Component template serialization', () => { }, _kbnMeta: { usedBy: ['my_index_template'], + isManaged: false, }, }); }); @@ -105,6 +106,7 @@ describe('Component template serialization', () => { version: 1, _kbnMeta: { usedBy: [], + isManaged: false, }, _meta: { serialization: { diff --git a/x-pack/plugins/index_management/common/lib/component_template_serialization.ts b/x-pack/plugins/index_management/common/lib/component_template_serialization.ts index 672b8140f79fb..3a1c2c1ca55b2 100644 --- a/x-pack/plugins/index_management/common/lib/component_template_serialization.ts +++ b/x-pack/plugins/index_management/common/lib/component_template_serialization.ts @@ -60,24 +60,26 @@ export function deserializeComponentTemplate( _meta, _kbnMeta: { usedBy: indexTemplatesToUsedBy[name] || [], + isManaged: Boolean(_meta?.managed === true), }, }; return deserializedComponentTemplate; } -export function deserializeComponenTemplateList( +export function deserializeComponentTemplateList( componentTemplateEs: ComponentTemplateFromEs, indexTemplatesEs: TemplateFromEs[] ) { const { name, component_template: componentTemplate } = componentTemplateEs; - const { template } = componentTemplate; + const { template, _meta } = componentTemplate; const indexTemplatesToUsedBy = getIndexTemplatesToUsedBy(indexTemplatesEs); const componentTemplateListItem: ComponentTemplateListItem = { name, usedBy: indexTemplatesToUsedBy[name] || [], + isManaged: Boolean(_meta?.managed === true), hasSettings: hasEntries(template.settings), hasMappings: hasEntries(template.mappings), hasAliases: hasEntries(template.aliases), diff --git a/x-pack/plugins/index_management/common/lib/index.ts b/x-pack/plugins/index_management/common/lib/index.ts index f39cc063ba731..9e87e87b0eee0 100644 --- a/x-pack/plugins/index_management/common/lib/index.ts +++ b/x-pack/plugins/index_management/common/lib/index.ts @@ -19,6 +19,6 @@ export { getTemplateParameter } from './utils'; export { deserializeComponentTemplate, - deserializeComponenTemplateList, + deserializeComponentTemplateList, serializeComponentTemplate, } from './component_template_serialization'; diff --git a/x-pack/plugins/index_management/common/types/component_templates.ts b/x-pack/plugins/index_management/common/types/component_templates.ts index bc7ebdc2753dd..c8dec40d061bd 100644 --- a/x-pack/plugins/index_management/common/types/component_templates.ts +++ b/x-pack/plugins/index_management/common/types/component_templates.ts @@ -22,6 +22,7 @@ export interface ComponentTemplateDeserialized extends ComponentTemplateSerializ name: string; _kbnMeta: { usedBy: string[]; + isManaged: boolean; }; } @@ -36,4 +37,5 @@ export interface ComponentTemplateListItem { hasMappings: boolean; hasAliases: boolean; hasSettings: boolean; + isManaged: boolean; } diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_create.test.tsx b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_create.test.tsx index 75eb419d56a5c..4462a42758878 100644 --- a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_create.test.tsx +++ b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_create.test.tsx @@ -185,7 +185,7 @@ describe('', () => { }, aliases: ALIASES, }, - _kbnMeta: { usedBy: [] }, + _kbnMeta: { usedBy: [], isManaged: false }, }; expect(JSON.parse(JSON.parse(latestRequest.requestBody).body)).toEqual(expected); diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_details.test.ts b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_details.test.ts index 7c17dde119c42..3d496d68cc66e 100644 --- a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_details.test.ts +++ b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_details.test.ts @@ -26,13 +26,13 @@ const COMPONENT_TEMPLATE: ComponentTemplateDeserialized = { }, version: 1, _meta: { description: 'component template test' }, - _kbnMeta: { usedBy: ['template_1'] }, + _kbnMeta: { usedBy: ['template_1'], isManaged: false }, }; const COMPONENT_TEMPLATE_ONLY_REQUIRED_FIELDS: ComponentTemplateDeserialized = { name: 'comp-base', template: {}, - _kbnMeta: { usedBy: [] }, + _kbnMeta: { usedBy: [], isManaged: false }, }; describe('', () => { diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_edit.test.tsx b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_edit.test.tsx index 115fdf032da8f..114cafe9defde 100644 --- a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_edit.test.tsx +++ b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_edit.test.tsx @@ -52,7 +52,7 @@ describe('', () => { template: { settings: { number_of_shards: 1 }, }, - _kbnMeta: { usedBy: [] }, + _kbnMeta: { usedBy: [], isManaged: false }, }; beforeEach(async () => { diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_list.test.ts b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_list.test.ts index 6f09e51255f3b..bd6ac27375836 100644 --- a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_list.test.ts +++ b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_list.test.ts @@ -42,6 +42,7 @@ describe('', () => { hasAliases: true, hasSettings: true, usedBy: [], + isManaged: false, }; const componentTemplate2: ComponentTemplateListItem = { @@ -50,6 +51,7 @@ describe('', () => { hasAliases: true, hasSettings: true, usedBy: ['test_index_template_1'], + isManaged: false, }; const componentTemplates = [componentTemplate1, componentTemplate2]; diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/setup_environment.tsx b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/setup_environment.tsx index 70634a226c67b..7e460d3855cb0 100644 --- a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/setup_environment.tsx +++ b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/setup_environment.tsx @@ -12,6 +12,7 @@ import { HttpSetup } from 'kibana/public'; import { notificationServiceMock, docLinksServiceMock, + applicationServiceMock, } from '../../../../../../../../../../src/core/public/mocks'; import { ComponentTemplatesProvider } from '../../../component_templates_context'; @@ -28,6 +29,7 @@ const appDependencies = { docLinks: docLinksServiceMock.createStartContract(), toasts: notificationServiceMock.createSetupContract().toasts, setBreadcrumbs: () => {}, + getUrlForApp: applicationServiceMock.createStartContract().getUrlForApp, }; export const setupEnvironment = () => { diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_details/component_template_details.tsx b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_details/component_template_details.tsx index f94c5c38f23dd..60f1fff3cc9de 100644 --- a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_details/component_template_details.tsx +++ b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_details/component_template_details.tsx @@ -6,6 +6,7 @@ import React, { useState } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; + import { EuiFlyout, EuiFlyoutHeader, @@ -17,6 +18,7 @@ import { EuiButtonEmpty, EuiSpacer, EuiCallOut, + EuiBadge, } from '@elastic/eui'; import { SectionLoading, TabSettings, TabAliases, TabMappings } from '../shared_imports'; @@ -29,14 +31,15 @@ import { attemptToDecodeURI } from '../lib'; interface Props { componentTemplateName: string; onClose: () => void; - showFooter?: boolean; actions?: ManageAction[]; + showSummaryCallToAction?: boolean; } export const ComponentTemplateDetailsFlyout: React.FunctionComponent = ({ componentTemplateName, onClose, actions, + showSummaryCallToAction, }) => { const { api } = useComponentTemplatesContext(); @@ -81,7 +84,12 @@ export const ComponentTemplateDetailsFlyout: React.FunctionComponent = ({ } = componentTemplateDetails; const tabToComponentMap: Record = { - summary: , + summary: ( + + ), settings: , mappings: , aliases: , @@ -109,11 +117,27 @@ export const ComponentTemplateDetailsFlyout: React.FunctionComponent = ({ maxWidth={500} > - -

- {decodedComponentTemplateName} -

-
+ + + +

+ {decodedComponentTemplateName} +

+
+
+ + {componentTemplateDetails?._kbnMeta.isManaged ? ( + + {' '} + + + + + ) : null} +
{content} diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_details/tab_summary.tsx b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_details/tab_summary.tsx index 80f28f23c9f91..8d054b97cb4f6 100644 --- a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_details/tab_summary.tsx +++ b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_details/tab_summary.tsx @@ -6,6 +6,7 @@ import React from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; + import { EuiDescriptionList, EuiDescriptionListTitle, @@ -14,15 +15,23 @@ import { EuiTitle, EuiCallOut, EuiSpacer, + EuiLink, } from '@elastic/eui'; import { ComponentTemplateDeserialized } from '../shared_imports'; +import { useComponentTemplatesContext } from '../component_templates_context'; interface Props { componentTemplateDetails: ComponentTemplateDeserialized; + showCallToAction?: boolean; } -export const TabSummary: React.FunctionComponent = ({ componentTemplateDetails }) => { +export const TabSummary: React.FunctionComponent = ({ + componentTemplateDetails, + showCallToAction, +}) => { + const { getUrlForApp } = useComponentTemplatesContext(); + const { version, _meta, _kbnMeta } = componentTemplateDetails; const { usedBy } = _kbnMeta; @@ -43,7 +52,42 @@ export const TabSummary: React.FunctionComponent = ({ componentTemplateDe iconType="pin" data-test-subj="notInUseCallout" size="s" - /> + > + {showCallToAction && ( +

+ + + + ), + editLink: ( + + + + ), + }} + /> +

+ )} + )} diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_list/component_template_list.tsx b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_list/component_template_list.tsx index d356eabc7997d..efc8b649ef872 100644 --- a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_list/component_template_list.tsx +++ b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_list/component_template_list.tsx @@ -9,6 +9,7 @@ import { RouteComponentProps } from 'react-router-dom'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { ScopedHistory } from 'kibana/public'; +import { EuiLink, EuiText, EuiSpacer } from '@elastic/eui'; import { SectionLoading, ComponentTemplateDeserialized } from '../shared_imports'; import { UIM_COMPONENT_TEMPLATE_LIST_LOAD } from '../constants'; @@ -29,7 +30,7 @@ export const ComponentTemplateList: React.FunctionComponent = ({ componentTemplateName, history, }) => { - const { api, trackMetric } = useComponentTemplatesContext(); + const { api, trackMetric, documentation } = useComponentTemplatesContext(); const { data, isLoading, error, sendRequest } = api.useLoadComponentTemplates(); @@ -65,20 +66,40 @@ export const ComponentTemplateList: React.FunctionComponent = ({ ); } else if (data?.length) { content = ( - + <> + + + {i18n.translate('xpack.idxMgmt.componentTemplates.list.learnMoreLinkText', { + defaultMessage: 'Learn more.', + })} + + ), + }} + /> + + + + + + ); } else if (data && data.length === 0) { content = ; @@ -111,6 +132,7 @@ export const ComponentTemplateList: React.FunctionComponent = ({ = ({ history }) => {


{i18n.translate('xpack.idxMgmt.home.componentTemplates.emptyPromptDocumentionLink', { - defaultMessage: 'Learn more', + defaultMessage: 'Learn more.', })}

diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_list/table.tsx b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_list/table.tsx index 089c2f889e726..fc86609f1217d 100644 --- a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_list/table.tsx +++ b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_list/table.tsx @@ -13,11 +13,11 @@ import { EuiTextColor, EuiIcon, EuiLink, + EuiBadge, } from '@elastic/eui'; import { ScopedHistory } from 'kibana/public'; -import { reactRouterNavigate } from '../../../../../../../../src/plugins/kibana_react/public'; -import { ComponentTemplateListItem } from '../shared_imports'; +import { ComponentTemplateListItem, reactRouterNavigate } from '../shared_imports'; import { UIM_COMPONENT_TEMPLATE_DETAILS } from '../constants'; import { useComponentTemplatesContext } from '../component_templates_context'; @@ -105,6 +105,13 @@ export const ComponentTable: FunctionComponent = ({ incremental: true, }, filters: [ + { + type: 'is', + field: 'isManaged', + name: i18n.translate('xpack.idxMgmt.componentTemplatesList.table.isManagedFilterLabel', { + defaultMessage: 'Managed', + }), + }, { type: 'field_value_toggle_group', field: 'usedBy.length', @@ -144,26 +151,38 @@ export const ComponentTable: FunctionComponent = ({ defaultMessage: 'Name', }), sortable: true, - render: (name: string) => ( - /* eslint-disable-next-line @elastic/eui/href-or-on-click */ - trackMetric('click', UIM_COMPONENT_TEMPLATE_DETAILS) + width: '20%', + render: (name: string, item: ComponentTemplateListItem) => ( + <> + trackMetric('click', UIM_COMPONENT_TEMPLATE_DETAILS) + )} + data-test-subj="templateDetailsLink" + > + {name} + + {item.isManaged && ( + <> +   + + {i18n.translate('xpack.idxMgmt.componentTemplatesList.table.managedBadgeLabel', { + defaultMessage: 'Managed', + })} + + )} - data-test-subj="templateDetailsLink" - > - {name} - + ), }, { field: 'usedBy', name: i18n.translate('xpack.idxMgmt.componentTemplatesList.table.isInUseColumnTitle', { - defaultMessage: 'Index templates', + defaultMessage: 'Usage count', }), sortable: true, render: (usedBy: string[]) => { diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_wizard/component_template_form/component_template_form.tsx b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_wizard/component_template_form/component_template_form.tsx index 6e35fbad31d4e..134b8b5eda93d 100644 --- a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_wizard/component_template_form/component_template_form.tsx +++ b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_wizard/component_template_form/component_template_form.tsx @@ -74,14 +74,11 @@ const wizardSections: { [id: string]: { id: WizardSection; label: string } } = { export const ComponentTemplateForm = ({ defaultValue = { name: '', - template: { - settings: {}, - mappings: {}, - aliases: {}, - }, + template: {}, _meta: {}, _kbnMeta: { usedBy: [], + isManaged: false, }, }, isEditing, @@ -137,23 +134,49 @@ export const ComponentTemplateForm = ({ ) : null; - const buildComponentTemplateObject = (initialTemplate: ComponentTemplateDeserialized) => ( - wizardData: WizardContent - ): ComponentTemplateDeserialized => { - const componentTemplate = { - ...initialTemplate, - name: wizardData.logistics.name, - version: wizardData.logistics.version, - _meta: wizardData.logistics._meta, - template: { - settings: wizardData.settings, - mappings: wizardData.mappings, - aliases: wizardData.aliases, - }, - }; - return componentTemplate; + /** + * If no mappings, settings or aliases are defined, it is better to not send an empty + * object for those values. + * @param componentTemplate The component template object to clean up + */ + const cleanupComponentTemplateObject = (componentTemplate: ComponentTemplateDeserialized) => { + const outputTemplate = { ...componentTemplate }; + + if (outputTemplate.template.settings === undefined) { + delete outputTemplate.template.settings; + } + + if (outputTemplate.template.mappings === undefined) { + delete outputTemplate.template.mappings; + } + + if (outputTemplate.template.aliases === undefined) { + delete outputTemplate.template.aliases; + } + + return outputTemplate; }; + const buildComponentTemplateObject = useCallback( + (initialTemplate: ComponentTemplateDeserialized) => ( + wizardData: WizardContent + ): ComponentTemplateDeserialized => { + const outputComponentTemplate = { + ...initialTemplate, + name: wizardData.logistics.name, + version: wizardData.logistics.version, + _meta: wizardData.logistics._meta, + template: { + settings: wizardData.settings, + mappings: wizardData.mappings, + aliases: wizardData.aliases, + }, + }; + return cleanupComponentTemplateObject(outputComponentTemplate); + }, + [] + ); + const onSaveComponentTemplate = useCallback( async (wizardData: WizardContent) => { const componentTemplate = buildComponentTemplateObject(defaultValue)(wizardData); @@ -161,13 +184,13 @@ export const ComponentTemplateForm = ({ // This will strip an empty string if "version" is not set, as well as an empty "_meta" object onSave( stripEmptyFields(componentTemplate, { - types: ['string', 'object'], + types: ['string'], }) as ComponentTemplateDeserialized ); clearSaveError(); }, - [defaultValue, onSave, clearSaveError] + [buildComponentTemplateObject, defaultValue, onSave, clearSaveError] ); return ( diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_wizard/component_template_form/steps/step_logistics.tsx b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_wizard/component_template_form/steps/step_logistics.tsx index 18988fa125a06..c48a23226a371 100644 --- a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_wizard/component_template_form/steps/step_logistics.tsx +++ b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_wizard/component_template_form/steps/step_logistics.tsx @@ -117,7 +117,7 @@ export const StepLogistics: React.FunctionComponent = React.memo( description={ } > @@ -141,7 +141,7 @@ export const StepLogistics: React.FunctionComponent = React.memo( description={ } > @@ -165,7 +165,7 @@ export const StepLogistics: React.FunctionComponent = React.memo( <> = React.memo( {i18n.translate( 'xpack.idxMgmt.componentTemplateForm.stepLogistics.metaDocumentionLink', { - defaultMessage: 'Learn more', + defaultMessage: 'Learn more.', } )} diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_wizard/component_template_form/steps/step_review.tsx b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_wizard/component_template_form/steps/step_review.tsx index ce85854dc79ab..67246f2e10c3b 100644 --- a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_wizard/component_template_form/steps/step_review.tsx +++ b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_wizard/component_template_form/steps/step_review.tsx @@ -52,16 +52,12 @@ export const StepReview: React.FunctionComponent = React.memo(({ componen const serializedComponentTemplate = serializeComponentTemplate( stripEmptyFields(componentTemplate, { - types: ['string', 'object'], + types: ['string'], }) as ComponentTemplateDeserialized ); const { - template: { - mappings: serializedMappings, - settings: serializedSettings, - aliases: serializedAliases, - }, + template: serializedTemplate, _meta: serializedMeta, version: serializedVersion, } = serializedComponentTemplate; @@ -94,7 +90,7 @@ export const StepReview: React.FunctionComponent = React.memo(({ componen /> - {getDescriptionText(serializedSettings)} + {getDescriptionText(serializedTemplate?.settings)} {/* Mappings */} @@ -105,7 +101,7 @@ export const StepReview: React.FunctionComponent = React.memo(({ componen /> - {getDescriptionText(serializedMappings)} + {getDescriptionText(serializedTemplate?.mappings)} {/* Aliases */} @@ -116,7 +112,7 @@ export const StepReview: React.FunctionComponent = React.memo(({ componen /> - {getDescriptionText(serializedAliases)} + {getDescriptionText(serializedTemplate?.aliases)} diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/component_templates_context.tsx b/x-pack/plugins/index_management/public/application/components/component_templates/component_templates_context.tsx index ce9e28d0feefe..7be0618481a69 100644 --- a/x-pack/plugins/index_management/public/application/components/component_templates/component_templates_context.tsx +++ b/x-pack/plugins/index_management/public/application/components/component_templates/component_templates_context.tsx @@ -5,7 +5,7 @@ */ import React, { createContext, useContext } from 'react'; -import { HttpSetup, DocLinksStart, NotificationsSetup } from 'src/core/public'; +import { HttpSetup, DocLinksStart, NotificationsSetup, CoreStart } from 'src/core/public'; import { ManagementAppMountParams } from 'src/plugins/management/public'; import { getApi, getUseRequest, getSendRequest, getDocumentation, getBreadcrumbs } from './lib'; @@ -19,6 +19,7 @@ interface Props { docLinks: DocLinksStart; toasts: NotificationsSetup['toasts']; setBreadcrumbs: ManagementAppMountParams['setBreadcrumbs']; + getUrlForApp: CoreStart['application']['getUrlForApp']; } interface Context { @@ -29,6 +30,7 @@ interface Context { breadcrumbs: ReturnType; trackMetric: (type: 'loaded' | 'click' | 'count', eventName: string) => void; toasts: NotificationsSetup['toasts']; + getUrlForApp: CoreStart['application']['getUrlForApp']; } export const ComponentTemplatesProvider = ({ @@ -38,7 +40,15 @@ export const ComponentTemplatesProvider = ({ value: Props; children: React.ReactNode; }) => { - const { httpClient, apiBasePath, trackMetric, docLinks, toasts, setBreadcrumbs } = value; + const { + httpClient, + apiBasePath, + trackMetric, + docLinks, + toasts, + setBreadcrumbs, + getUrlForApp, + } = value; const useRequest = getUseRequest(httpClient); const sendRequest = getSendRequest(httpClient); @@ -49,7 +59,16 @@ export const ComponentTemplatesProvider = ({ return ( {children} diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/shared_imports.ts b/x-pack/plugins/index_management/public/application/components/component_templates/shared_imports.ts index 80e222f4f7706..278fadcd90c8b 100644 --- a/x-pack/plugins/index_management/public/application/components/component_templates/shared_imports.ts +++ b/x-pack/plugins/index_management/public/application/components/component_templates/shared_imports.ts @@ -62,3 +62,5 @@ export { } from '../../../../common'; export { serializeComponentTemplate } from '../../../../common/lib'; + +export { reactRouterNavigate } from '../../../../../../../src/plugins/kibana_react/public'; diff --git a/x-pack/plugins/index_management/public/application/index.tsx b/x-pack/plugins/index_management/public/application/index.tsx index 7b053a15b26d0..ebc29ac86a17f 100644 --- a/x-pack/plugins/index_management/public/application/index.tsx +++ b/x-pack/plugins/index_management/public/application/index.tsx @@ -25,7 +25,7 @@ export const renderApp = ( return () => undefined; } - const { i18n, docLinks, notifications } = core; + const { i18n, docLinks, notifications, application } = core; const { Context: I18nContext } = i18n; const { services, history, setBreadcrumbs } = dependencies; @@ -36,6 +36,7 @@ export const renderApp = ( docLinks, toasts: notifications.toasts, setBreadcrumbs, + getUrlForApp: application.getUrlForApp, }; render( diff --git a/x-pack/plugins/index_management/server/routes/api/component_templates/get.ts b/x-pack/plugins/index_management/server/routes/api/component_templates/get.ts index f6f8e7d63d370..16b028887f63c 100644 --- a/x-pack/plugins/index_management/server/routes/api/component_templates/get.ts +++ b/x-pack/plugins/index_management/server/routes/api/component_templates/get.ts @@ -7,7 +7,7 @@ import { schema } from '@kbn/config-schema'; import { deserializeComponentTemplate, - deserializeComponenTemplateList, + deserializeComponentTemplateList, } from '../../../../common/lib'; import { ComponentTemplateFromEs } from '../../../../common'; import { RouteDependencies } from '../../../types'; @@ -36,7 +36,7 @@ export function registerGetAllRoute({ router, license, lib: { isEsError } }: Rou ); const body = componentTemplates.map((componentTemplate) => { - const deserializedComponentTemplateListItem = deserializeComponenTemplateList( + const deserializedComponentTemplateListItem = deserializeComponentTemplateList( componentTemplate, indexTemplates ); diff --git a/x-pack/plugins/index_management/server/routes/api/component_templates/schema_validation.ts b/x-pack/plugins/index_management/server/routes/api/component_templates/schema_validation.ts index a1fc258127229..cfcb428f00501 100644 --- a/x-pack/plugins/index_management/server/routes/api/component_templates/schema_validation.ts +++ b/x-pack/plugins/index_management/server/routes/api/component_templates/schema_validation.ts @@ -16,5 +16,6 @@ export const componentTemplateSchema = schema.object({ _meta: schema.maybe(schema.object({}, { unknowns: 'allow' })), _kbnMeta: schema.object({ usedBy: schema.arrayOf(schema.string()), + isManaged: schema.boolean(), }), }); diff --git a/x-pack/test/api_integration/apis/management/index_management/component_templates.ts b/x-pack/test/api_integration/apis/management/index_management/component_templates.ts index 1a00eaba35aa1..30ec95f208c80 100644 --- a/x-pack/test/api_integration/apis/management/index_management/component_templates.ts +++ b/x-pack/test/api_integration/apis/management/index_management/component_templates.ts @@ -78,6 +78,7 @@ export default function ({ getService }: FtrProviderContext) { expect(testComponentTemplate).to.eql({ name: COMPONENT_NAME, usedBy: [], + isManaged: false, hasSettings: true, hasMappings: true, hasAliases: false, @@ -96,6 +97,7 @@ export default function ({ getService }: FtrProviderContext) { ...COMPONENT, _kbnMeta: { usedBy: [], + isManaged: false, }, }); }); @@ -148,6 +150,7 @@ export default function ({ getService }: FtrProviderContext) { }, _kbnMeta: { usedBy: [], + isManaged: false, }, }) .expect(200); @@ -167,6 +170,7 @@ export default function ({ getService }: FtrProviderContext) { template: {}, _kbnMeta: { usedBy: [], + isManaged: false, }, }) .expect(200); @@ -185,6 +189,7 @@ export default function ({ getService }: FtrProviderContext) { template: {}, _kbnMeta: { usedBy: [], + isManaged: false, }, }) .expect(409); @@ -246,6 +251,7 @@ export default function ({ getService }: FtrProviderContext) { version: 1, _kbnMeta: { usedBy: [], + isManaged: false, }, }) .expect(200); @@ -267,6 +273,7 @@ export default function ({ getService }: FtrProviderContext) { version: 1, _kbnMeta: { usedBy: [], + isManaged: false, }, }) .expect(404); From e082719870375a2188d50042e3a92ddff991ea76 Mon Sep 17 00:00:00 2001 From: Tiago Costa Date: Mon, 13 Jul 2020 17:12:25 +0100 Subject: [PATCH 06/66] skip flaky suite (#68400) --- .../apps/saved_objects_management/edit_saved_object.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/test/functional/apps/saved_objects_management/edit_saved_object.ts b/test/functional/apps/saved_objects_management/edit_saved_object.ts index 2c9200c2f8d93..0e2ff44ff62ef 100644 --- a/test/functional/apps/saved_objects_management/edit_saved_object.ts +++ b/test/functional/apps/saved_objects_management/edit_saved_object.ts @@ -66,6 +66,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { await button.click(); }; + // Flaky: https://github.com/elastic/kibana/issues/68400 describe('saved objects edition page', () => { beforeEach(async () => { await esArchiver.load('saved_objects_management/edit_saved_object'); From 56794718c7cda41824bf5172fb28930dd06622ce Mon Sep 17 00:00:00 2001 From: Liza Katz Date: Mon, 13 Jul 2020 19:21:34 +0300 Subject: [PATCH 07/66] Resolve range date filter bugs and improve usability (#71298) * improve test stability * Filter date range improvements * Make onBlur optional * i18n Co-authored-by: Elastic Machine --- .../lib/filter_editor_utils.test.ts | 18 +++++++++- .../filter_editor/lib/filter_editor_utils.ts | 5 ++- .../filter_editor/range_value_input.tsx | 35 ++++++++++--------- .../filter_editor/value_input_type.tsx | 9 +++++ .../translations/translations/ja-JP.json | 1 - .../translations/translations/zh-CN.json | 1 - 6 files changed, 49 insertions(+), 20 deletions(-) diff --git a/src/plugins/data/public/ui/filter_bar/filter_editor/lib/filter_editor_utils.test.ts b/src/plugins/data/public/ui/filter_bar/filter_editor/lib/filter_editor_utils.test.ts index 12cdf13caeb55..e2caca7895c42 100644 --- a/src/plugins/data/public/ui/filter_bar/filter_editor/lib/filter_editor_utils.test.ts +++ b/src/plugins/data/public/ui/filter_bar/filter_editor/lib/filter_editor_utils.test.ts @@ -177,11 +177,27 @@ describe('Filter editor utils', () => { it('should return true for range filter with from/to', () => { const isValid = isFilterValid(stubIndexPattern, stubFields[0], isBetweenOperator, { from: 'foo', - too: 'goo', + to: 'goo', }); expect(isValid).toBe(true); }); + it('should return false for date range filter with bad from', () => { + const isValid = isFilterValid(stubIndexPattern, stubFields[4], isBetweenOperator, { + from: 'foo', + to: 'now', + }); + expect(isValid).toBe(false); + }); + + it('should return false for date range filter with bad to', () => { + const isValid = isFilterValid(stubIndexPattern, stubFields[4], isBetweenOperator, { + from: '2020-01-01', + to: 'mau', + }); + expect(isValid).toBe(false); + }); + it('should return true for exists filter without params', () => { const isValid = isFilterValid(stubIndexPattern, stubFields[0], existsOperator); expect(isValid).toBe(true); diff --git a/src/plugins/data/public/ui/filter_bar/filter_editor/lib/filter_editor_utils.ts b/src/plugins/data/public/ui/filter_bar/filter_editor/lib/filter_editor_utils.ts index 114be67e490cf..97a59fa69f458 100644 --- a/src/plugins/data/public/ui/filter_bar/filter_editor/lib/filter_editor_utils.ts +++ b/src/plugins/data/public/ui/filter_bar/filter_editor/lib/filter_editor_utils.ts @@ -85,7 +85,10 @@ export function isFilterValid( if (typeof params !== 'object') { return false; } - return validateParams(params.from, field.type) || validateParams(params.to, field.type); + return ( + (!params.from || validateParams(params.from, field.type)) && + (!params.to || validateParams(params.to, field.type)) + ); case 'exists': return true; default: diff --git a/src/plugins/data/public/ui/filter_bar/filter_editor/range_value_input.tsx b/src/plugins/data/public/ui/filter_bar/filter_editor/range_value_input.tsx index 65b842f0bd4aa..bdfd1014625d8 100644 --- a/src/plugins/data/public/ui/filter_bar/filter_editor/range_value_input.tsx +++ b/src/plugins/data/public/ui/filter_bar/filter_editor/range_value_input.tsx @@ -17,8 +17,9 @@ * under the License. */ -import { EuiIcon, EuiLink, EuiFormHelpText, EuiFormControlLayoutDelimited } from '@elastic/eui'; -import { FormattedMessage, InjectedIntl, injectI18n } from '@kbn/i18n/react'; +import moment from 'moment'; +import { EuiFormControlLayoutDelimited } from '@elastic/eui'; +import { InjectedIntl, injectI18n } from '@kbn/i18n/react'; import { get } from 'lodash'; import React from 'react'; import { useKibana } from '../../../../../kibana_react/public'; @@ -41,8 +42,17 @@ interface Props { function RangeValueInputUI(props: Props) { const kibana = useKibana(); - const dataMathDocLink = kibana.services.docLinks!.links.date.dateMath; const type = props.field ? props.field.type : 'string'; + const tzConfig = kibana.services.uiSettings!.get('dateFormat:tz'); + + const formatDateChange = (value: string | number | boolean) => { + if (typeof value !== 'string' && typeof value !== 'number') return value; + + const momentParsedValue = moment(value).tz(tzConfig); + if (momentParsedValue.isValid()) return momentParsedValue?.format('YYYY-MM-DDTHH:mm:ss.SSSZ'); + + return value; + }; const onFromChange = (value: string | number | boolean) => { if (typeof value !== 'string' && typeof value !== 'number') { @@ -71,6 +81,9 @@ function RangeValueInputUI(props: Props) { type={type} value={props.value ? props.value.from : undefined} onChange={onFromChange} + onBlur={(value) => { + onFromChange(formatDateChange(value)); + }} placeholder={props.intl.formatMessage({ id: 'data.filter.filterEditor.rangeStartInputPlaceholder', defaultMessage: 'Start of the range', @@ -83,6 +96,9 @@ function RangeValueInputUI(props: Props) { type={type} value={props.value ? props.value.to : undefined} onChange={onToChange} + onBlur={(value) => { + onToChange(formatDateChange(value)); + }} placeholder={props.intl.formatMessage({ id: 'data.filter.filterEditor.rangeEndInputPlaceholder', defaultMessage: 'End of the range', @@ -90,19 +106,6 @@ function RangeValueInputUI(props: Props) { /> } /> - {type === 'date' ? ( - - - {' '} - - - - ) : ( - '' - )} ); } diff --git a/src/plugins/data/public/ui/filter_bar/filter_editor/value_input_type.tsx b/src/plugins/data/public/ui/filter_bar/filter_editor/value_input_type.tsx index 3737dae1bf9ef..1a165c78d4d79 100644 --- a/src/plugins/data/public/ui/filter_bar/filter_editor/value_input_type.tsx +++ b/src/plugins/data/public/ui/filter_bar/filter_editor/value_input_type.tsx @@ -27,6 +27,7 @@ interface Props { value?: string | number; type: string; onChange: (value: string | number | boolean) => void; + onBlur?: (value: string | number | boolean) => void; placeholder: string; intl: InjectedIntl; controlOnly?: boolean; @@ -66,6 +67,7 @@ class ValueInputTypeUI extends Component { placeholder={this.props.placeholder} value={value} onChange={this.onChange} + onBlur={this.onBlur} isInvalid={!isEmpty(value) && !validateParams(value, this.props.type)} controlOnly={this.props.controlOnly} className={this.props.className} @@ -126,6 +128,13 @@ class ValueInputTypeUI extends Component { const params = event.target.value; this.props.onChange(params); }; + + private onBlur = (event: React.ChangeEvent) => { + if (this.props.onBlur) { + const params = event.target.value; + this.props.onBlur(params); + } + }; } export const ValueInputType = injectI18n(ValueInputTypeUI); diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index e28ef8ff07bdd..4c83fa71a7060 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -641,7 +641,6 @@ "data.filter.filterEditor.cancelButtonLabel": "キャンセル", "data.filter.filterEditor.createCustomLabelInputLabel": "カスタムラベル", "data.filter.filterEditor.createCustomLabelSwitchLabel": "カスタムラベルを作成しますか?", - "data.filter.filterEditor.dateFormatHelpLinkLabel": "対応データフォーマット", "data.filter.filterEditor.doesNotExistOperatorOptionLabel": "存在しません", "data.filter.filterEditor.editFilterPopupTitle": "フィルターを編集", "data.filter.filterEditor.editFilterValuesButtonLabel": "フィルター値を編集", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 1df676ba7cffd..86b2480e3b314 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -641,7 +641,6 @@ "data.filter.filterEditor.cancelButtonLabel": "取消", "data.filter.filterEditor.createCustomLabelInputLabel": "定制标签", "data.filter.filterEditor.createCustomLabelSwitchLabel": "创建定制标签?", - "data.filter.filterEditor.dateFormatHelpLinkLabel": "已接受日期格式", "data.filter.filterEditor.doesNotExistOperatorOptionLabel": "不存在", "data.filter.filterEditor.editFilterPopupTitle": "编辑筛选", "data.filter.filterEditor.editFilterValuesButtonLabel": "编辑筛选值", From 3a52eaf7ee5e2b2a00fe9c40191528d8ca0ce97e Mon Sep 17 00:00:00 2001 From: Tiago Costa Date: Mon, 13 Jul 2020 17:31:08 +0100 Subject: [PATCH 08/66] skip flaky suite (#70928) --- x-pack/test/functional_embedded/tests/iframe_embedded.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/test/functional_embedded/tests/iframe_embedded.ts b/x-pack/test/functional_embedded/tests/iframe_embedded.ts index 9b5c9894a9407..f05d70b6cb3e8 100644 --- a/x-pack/test/functional_embedded/tests/iframe_embedded.ts +++ b/x-pack/test/functional_embedded/tests/iframe_embedded.ts @@ -14,7 +14,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const config = getService('config'); const testSubjects = getService('testSubjects'); - describe('in iframe', () => { + // Flaky: https://github.com/elastic/kibana/issues/70928 + describe.skip('in iframe', () => { it('should open Kibana for logged-in user', async () => { const isChromeHiddenBefore = await PageObjects.common.isChromeHidden(); expect(isChromeHiddenBefore).to.be(true); From c5729b87d6c806e5d992f038d219856cdfe08979 Mon Sep 17 00:00:00 2001 From: Michael Hirsch Date: Mon, 13 Jul 2020 12:35:04 -0400 Subject: [PATCH 09/66] [ML] Adds siem_cloudtrail Module (#71323) * adds siem_cloudtrail module * updates logo to logoSecurity Co-authored-by: Elastic Machine --- .../modules/siem_cloudtrail/logo.json | 3 + .../modules/siem_cloudtrail/manifest.json | 64 +++++++++++++++++++ ...eed_high_distinct_count_error_message.json | 16 +++++ .../ml/datafeed_rare_error_code.json | 16 +++++ .../ml/datafeed_rare_method_for_a_city.json | 16 +++++ .../datafeed_rare_method_for_a_country.json | 16 +++++ .../datafeed_rare_method_for_a_username.json | 16 +++++ .../ml/high_distinct_count_error_message.json | 33 ++++++++++ .../siem_cloudtrail/ml/rare_error_code.json | 33 ++++++++++ .../ml/rare_method_for_a_city.json | 34 ++++++++++ .../ml/rare_method_for_a_country.json | 34 ++++++++++ .../ml/rare_method_for_a_username.json | 34 ++++++++++ .../apis/ml/modules/get_module.ts | 1 + 13 files changed, 316 insertions(+) create mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/siem_cloudtrail/logo.json create mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/siem_cloudtrail/manifest.json create mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/siem_cloudtrail/ml/datafeed_high_distinct_count_error_message.json create mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/siem_cloudtrail/ml/datafeed_rare_error_code.json create mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/siem_cloudtrail/ml/datafeed_rare_method_for_a_city.json create mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/siem_cloudtrail/ml/datafeed_rare_method_for_a_country.json create mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/siem_cloudtrail/ml/datafeed_rare_method_for_a_username.json create mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/siem_cloudtrail/ml/high_distinct_count_error_message.json create mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/siem_cloudtrail/ml/rare_error_code.json create mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/siem_cloudtrail/ml/rare_method_for_a_city.json create mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/siem_cloudtrail/ml/rare_method_for_a_country.json create mode 100644 x-pack/plugins/ml/server/models/data_recognizer/modules/siem_cloudtrail/ml/rare_method_for_a_username.json diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_cloudtrail/logo.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_cloudtrail/logo.json new file mode 100644 index 0000000000000..ca61db7992083 --- /dev/null +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_cloudtrail/logo.json @@ -0,0 +1,3 @@ +{ + "icon": "logoSecurity" +} diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_cloudtrail/manifest.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_cloudtrail/manifest.json new file mode 100644 index 0000000000000..b7afe8d2b158a --- /dev/null +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_cloudtrail/manifest.json @@ -0,0 +1,64 @@ +{ + "id": "siem_cloudtrail", + "title": "SIEM Cloudtrail", + "description": "Detect suspicious activity recorded in your cloudtrail logs.", + "type": "Filebeat data", + "logoFile": "logo.json", + "defaultIndexPattern": "filebeat-*", + "query": { + "bool": { + "filter": [ + {"term": {"event.dataset": "aws.cloudtrail"}} + ] + } + }, + "jobs": [ + { + "id": "rare_method_for_a_city", + "file": "rare_method_for_a_city.json" + }, + { + "id": "rare_method_for_a_country", + "file": "rare_method_for_a_country.json" + }, + { + "id": "rare_method_for_a_username", + "file": "rare_method_for_a_username.json" + }, + { + "id": "high_distinct_count_error_message", + "file": "high_distinct_count_error_message.json" + }, + { + "id": "rare_error_code", + "file": "rare_error_code.json" + } + ], + "datafeeds": [ + { + "id": "datafeed-rare_method_for_a_city", + "file": "datafeed_rare_method_for_a_city.json", + "job_id": "rare_method_for_a_city" + }, + { + "id": "datafeed-rare_method_for_a_country", + "file": "datafeed_rare_method_for_a_country.json", + "job_id": "rare_method_for_a_country" + }, + { + "id": "datafeed-rare_method_for_a_username", + "file": "datafeed_rare_method_for_a_username.json", + "job_id": "rare_method_for_a_username" + }, + { + "id": "datafeed-high_distinct_count_error_message", + "file": "datafeed_high_distinct_count_error_message.json", + "job_id": "high_distinct_count_error_message" + }, + { + "id": "datafeed-rare_error_code", + "file": "datafeed_rare_error_code.json", + "job_id": "rare_error_code" + } + ] + } \ No newline at end of file diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_cloudtrail/ml/datafeed_high_distinct_count_error_message.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_cloudtrail/ml/datafeed_high_distinct_count_error_message.json new file mode 100644 index 0000000000000..269aac2ea72a1 --- /dev/null +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_cloudtrail/ml/datafeed_high_distinct_count_error_message.json @@ -0,0 +1,16 @@ +{ + "job_id": "JOB_ID", + "indices": [ + "INDEX_PATTERN_NAME" + ], + "max_empty_searches": 10, + "query": { + "bool": { + "filter": [ + {"term": {"event.dataset": "aws.cloudtrail"}}, + {"term": {"event.module": "aws"}}, + {"exists": {"field": "aws.cloudtrail.error_message"}} + ] + } + } +} \ No newline at end of file diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_cloudtrail/ml/datafeed_rare_error_code.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_cloudtrail/ml/datafeed_rare_error_code.json new file mode 100644 index 0000000000000..4b463a4d10991 --- /dev/null +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_cloudtrail/ml/datafeed_rare_error_code.json @@ -0,0 +1,16 @@ +{ + "job_id": "JOB_ID", + "indices": [ + "INDEX_PATTERN_NAME" + ], + "max_empty_searches": 10, + "query": { + "bool": { + "filter": [ + {"term": {"event.dataset": "aws.cloudtrail"}}, + {"term": {"event.module": "aws"}}, + {"exists": {"field": "aws.cloudtrail.error_code"}} + ] + } + } +} \ No newline at end of file diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_cloudtrail/ml/datafeed_rare_method_for_a_city.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_cloudtrail/ml/datafeed_rare_method_for_a_city.json new file mode 100644 index 0000000000000..e436273a848e7 --- /dev/null +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_cloudtrail/ml/datafeed_rare_method_for_a_city.json @@ -0,0 +1,16 @@ +{ + "job_id": "JOB_ID", + "indices": [ + "INDEX_PATTERN_NAME" + ], + "max_empty_searches": 10, + "query": { + "bool": { + "filter": [ + {"term": {"event.dataset": "aws.cloudtrail"}}, + {"term": {"event.module": "aws"}}, + {"exists": {"field": "source.geo.city_name"}} + ] + } + } +} \ No newline at end of file diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_cloudtrail/ml/datafeed_rare_method_for_a_country.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_cloudtrail/ml/datafeed_rare_method_for_a_country.json new file mode 100644 index 0000000000000..f0e80174b8791 --- /dev/null +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_cloudtrail/ml/datafeed_rare_method_for_a_country.json @@ -0,0 +1,16 @@ +{ + "job_id": "JOB_ID", + "indices": [ + "INDEX_PATTERN_NAME" + ], + "max_empty_searches": 10, + "query": { + "bool": { + "filter": [ + {"term": {"event.dataset": "aws.cloudtrail"}}, + {"term": {"event.module": "aws"}}, + {"exists": {"field": "source.geo.country_iso_code"}} + ] + } + } +} \ No newline at end of file diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_cloudtrail/ml/datafeed_rare_method_for_a_username.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_cloudtrail/ml/datafeed_rare_method_for_a_username.json new file mode 100644 index 0000000000000..2fd3622ff81ce --- /dev/null +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_cloudtrail/ml/datafeed_rare_method_for_a_username.json @@ -0,0 +1,16 @@ +{ + "job_id": "JOB_ID", + "indices": [ + "INDEX_PATTERN_NAME" + ], + "max_empty_searches": 10, + "query": { + "bool": { + "filter": [ + {"term": {"event.dataset": "aws.cloudtrail"}}, + {"term": {"event.module": "aws"}}, + {"exists": {"field": "user.name"}} + ] + } + } +} \ No newline at end of file diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_cloudtrail/ml/high_distinct_count_error_message.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_cloudtrail/ml/high_distinct_count_error_message.json new file mode 100644 index 0000000000000..fdabf66ac91b3 --- /dev/null +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_cloudtrail/ml/high_distinct_count_error_message.json @@ -0,0 +1,33 @@ +{ + "job_type": "anomaly_detector", + "description": "Looks for a spike in the rate of an error message which may simply indicate an impending service failure but these can also be byproducts of attempted or successful persistence, privilege escalation, defense evasion, discovery, lateral movement, or collection activity by a threat actor.", + "groups": [ + "siem", + "cloudtrail" + ], + "analysis_config": { + "bucket_span": "15m", + "detectors": [ + { + "detector_description": "high_distinct_count(\"aws.cloudtrail.error_message\")", + "function": "high_distinct_count", + "field_name": "aws.cloudtrail.error_message" + } + ], + "influencers": [ + "aws.cloudtrail.user_identity.arn", + "source.ip", + "source.geo.city_name" + ] + }, + "allow_lazy_open": true, + "analysis_limits": { + "model_memory_limit": "16mb" + }, + "data_description": { + "time_field": "@timestamp" + }, + "custom_settings": { + "created_by": "ml-module-siem-cloudtrail" + } + } \ No newline at end of file diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_cloudtrail/ml/rare_error_code.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_cloudtrail/ml/rare_error_code.json new file mode 100644 index 0000000000000..0f8fa814ac60a --- /dev/null +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_cloudtrail/ml/rare_error_code.json @@ -0,0 +1,33 @@ +{ + "job_type": "anomaly_detector", + "description": "Looks for unsual errors. Rare and unusual errors may simply indicate an impending service failure but they can also be byproducts of attempted or successful persistence, privilege escalation, defense evasion, discovery, lateral movement, or collection activity by a threat actor.", + "groups": [ + "siem", + "cloudtrail" + ], + "analysis_config": { + "bucket_span": "60m", + "detectors": [ + { + "detector_description": "rare by \"aws.cloudtrail.error_code\"", + "function": "rare", + "by_field_name": "aws.cloudtrail.error_code" + } + ], + "influencers": [ + "aws.cloudtrail.user_identity.arn", + "source.ip", + "source.geo.city_name" + ] + }, + "allow_lazy_open": true, + "analysis_limits": { + "model_memory_limit": "16mb" + }, + "data_description": { + "time_field": "@timestamp" + }, + "custom_settings": { + "created_by": "ml-module-siem-cloudtrail" + } + } \ No newline at end of file diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_cloudtrail/ml/rare_method_for_a_city.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_cloudtrail/ml/rare_method_for_a_city.json new file mode 100644 index 0000000000000..eff4d4cdbb889 --- /dev/null +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_cloudtrail/ml/rare_method_for_a_city.json @@ -0,0 +1,34 @@ +{ + "job_type": "anomaly_detector", + "description": "Looks for AWS API calls that, while not inherently suspicious or abnormal, are sourcing from a geolocation (city) that is unusual. This can be the result of compromised credentials or keys.", + "groups": [ + "siem", + "cloudtrail" + ], + "analysis_config": { + "bucket_span": "60m", + "detectors": [ + { + "detector_description": "rare by \"event.action\" partition by \"source.geo.city_name\"", + "function": "rare", + "by_field_name": "event.action", + "partition_field_name": "source.geo.city_name" + } + ], + "influencers": [ + "aws.cloudtrail.user_identity.arn", + "source.ip", + "source.geo.city_name" + ] + }, + "allow_lazy_open": true, + "analysis_limits": { + "model_memory_limit": "64mb" + }, + "data_description": { + "time_field": "@timestamp" + }, + "custom_settings": { + "created_by": "ml-module-siem-cloudtrail" + } + } \ No newline at end of file diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_cloudtrail/ml/rare_method_for_a_country.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_cloudtrail/ml/rare_method_for_a_country.json new file mode 100644 index 0000000000000..810822c30a5dd --- /dev/null +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_cloudtrail/ml/rare_method_for_a_country.json @@ -0,0 +1,34 @@ +{ + "job_type": "anomaly_detector", + "description": "Looks for AWS API calls that, while not inherently suspicious or abnormal, are sourcing from a geolocation (country) that is unusual. This can be the result of compromised credentials or keys.", + "groups": [ + "siem", + "cloudtrail" + ], + "analysis_config": { + "bucket_span": "60m", + "detectors": [ + { + "detector_description": "rare by \"event.action\" partition by \"source.geo.country_iso_code\"", + "function": "rare", + "by_field_name": "event.action", + "partition_field_name": "source.geo.country_iso_code" + } + ], + "influencers": [ + "aws.cloudtrail.user_identity.arn", + "source.ip", + "source.geo.country_iso_code" + ] + }, + "allow_lazy_open": true, + "analysis_limits": { + "model_memory_limit": "64mb" + }, + "data_description": { + "time_field": "@timestamp" + }, + "custom_settings": { + "created_by": "ml-module-siem-cloudtrail" + } + } \ No newline at end of file diff --git a/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_cloudtrail/ml/rare_method_for_a_username.json b/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_cloudtrail/ml/rare_method_for_a_username.json new file mode 100644 index 0000000000000..2edf52e8351ed --- /dev/null +++ b/x-pack/plugins/ml/server/models/data_recognizer/modules/siem_cloudtrail/ml/rare_method_for_a_username.json @@ -0,0 +1,34 @@ +{ + "job_type": "anomaly_detector", + "description": "Looks for AWS API calls that, while not inherently suspicious or abnormal, are sourcing from a user context that does not normally call the method. This can be the result of compromised credentials or keys as someone uses a valid account to persist, move laterally, or exfil data.", + "groups": [ + "siem", + "cloudtrail" + ], + "analysis_config": { + "bucket_span": "60m", + "detectors": [ + { + "detector_description": "rare by \"event.action\" partition by \"user.name\"", + "function": "rare", + "by_field_name": "event.action", + "partition_field_name": "user.name" + } + ], + "influencers": [ + "user.name", + "source.ip", + "source.geo.city_name" + ] + }, + "allow_lazy_open": true, + "analysis_limits": { + "model_memory_limit": "128mb" + }, + "data_description": { + "time_field": "@timestamp" + }, + "custom_settings": { + "created_by": "ml-module-siem-cloudtrail" + } + } \ No newline at end of file diff --git a/x-pack/test/api_integration/apis/ml/modules/get_module.ts b/x-pack/test/api_integration/apis/ml/modules/get_module.ts index 5ca496a7a7fe9..cfb3c17ac7f21 100644 --- a/x-pack/test/api_integration/apis/ml/modules/get_module.ts +++ b/x-pack/test/api_integration/apis/ml/modules/get_module.ts @@ -25,6 +25,7 @@ const moduleIds = [ 'sample_data_weblogs', 'siem_auditbeat', 'siem_auditbeat_auth', + 'siem_cloudtrail', 'siem_packetbeat', 'siem_winlogbeat', 'siem_winlogbeat_auth', From 1a65900e8ed45e17b621234ec64d88ce57ee075e Mon Sep 17 00:00:00 2001 From: Chris Cowan Date: Mon, 13 Jul 2020 09:51:43 -0700 Subject: [PATCH 10/66] [TSVB] Add support for histogram type (#68837) * [TSVB] Add support for histogram type * Merge branch 'master' of github.com:elastic/kibana into issue-52426-tsvb-support-for-histograms * Adding support to filter ratio; updating test * Limist aggs for filter_ratio and histogram fields; add test for AggSelect; Fixes #70984 * Ensure only compatible fields are displayed for filter ratio metric aggs Co-authored-by: Elastic Machine --- .../common/metric_types.js | 3 + .../components/aggs/agg_select.test.tsx | 184 ++++++++++++++++++ .../components/aggs/agg_select.tsx | 17 ++ .../components/aggs/filter_ratio.js | 19 +- .../components/aggs/filter_ratio.test.js | 136 +++++++++++++ .../components/aggs/histogram_support.test.js | 94 +++++++++ .../application/components/aggs/percentile.js | 2 +- .../aggs/percentile_rank/percentile_rank.tsx | 2 +- .../components/aggs/positive_rate.js | 2 +- .../application/components/aggs/std_agg.js | 6 +- .../get_supported_fields_by_metric_type.js | 34 ++++ ...et_supported_fields_by_metric_type.test.js | 44 +++++ .../public/test_utils/index.ts | 50 +++++ 13 files changed, 580 insertions(+), 13 deletions(-) create mode 100644 src/plugins/vis_type_timeseries/public/application/components/aggs/agg_select.test.tsx create mode 100644 src/plugins/vis_type_timeseries/public/application/components/aggs/filter_ratio.test.js create mode 100644 src/plugins/vis_type_timeseries/public/application/components/aggs/histogram_support.test.js create mode 100644 src/plugins/vis_type_timeseries/public/application/components/lib/get_supported_fields_by_metric_type.js create mode 100644 src/plugins/vis_type_timeseries/public/application/components/lib/get_supported_fields_by_metric_type.test.js create mode 100644 src/plugins/vis_type_timeseries/public/test_utils/index.ts diff --git a/src/plugins/vis_type_timeseries/common/metric_types.js b/src/plugins/vis_type_timeseries/common/metric_types.js index 9dc6085b080e9..05836a6df410a 100644 --- a/src/plugins/vis_type_timeseries/common/metric_types.js +++ b/src/plugins/vis_type_timeseries/common/metric_types.js @@ -27,6 +27,9 @@ export const METRIC_TYPES = { VARIANCE: 'variance', SUM_OF_SQUARES: 'sum_of_squares', CARDINALITY: 'cardinality', + VALUE_COUNT: 'value_count', + AVERAGE: 'avg', + SUM: 'sum', }; export const EXTENDED_STATS_TYPES = [ diff --git a/src/plugins/vis_type_timeseries/public/application/components/aggs/agg_select.test.tsx b/src/plugins/vis_type_timeseries/public/application/components/aggs/agg_select.test.tsx new file mode 100644 index 0000000000000..968fa5384e1d8 --- /dev/null +++ b/src/plugins/vis_type_timeseries/public/application/components/aggs/agg_select.test.tsx @@ -0,0 +1,184 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React from 'react'; +import { mountWithIntl } from 'test_utils/enzyme_helpers'; +import { AggSelect } from './agg_select'; +import { METRIC, SERIES } from '../../../test_utils'; +import { EuiComboBox } from '@elastic/eui'; + +describe('TSVB AggSelect', () => { + const setup = (panelType: string, value: string) => { + const metric = { + ...METRIC, + type: 'filter_ratio', + field: 'histogram_value', + }; + const series = { ...SERIES, metrics: [metric] }; + + const wrapper = mountWithIntl( +
+ +
+ ); + return wrapper; + }; + + it('should only display filter ratio compattible aggs', () => { + const wrapper = setup('filter_ratio', 'avg'); + expect(wrapper.find(EuiComboBox).props().options).toMatchInlineSnapshot(` + Array [ + Object { + "label": "Average", + "value": "avg", + }, + Object { + "label": "Cardinality", + "value": "cardinality", + }, + Object { + "label": "Count", + "value": "count", + }, + Object { + "label": "Positive Rate", + "value": "positive_rate", + }, + Object { + "label": "Max", + "value": "max", + }, + Object { + "label": "Min", + "value": "min", + }, + Object { + "label": "Sum", + "value": "sum", + }, + Object { + "label": "Value Count", + "value": "value_count", + }, + ] + `); + }); + + it('should only display histogram compattible aggs', () => { + const wrapper = setup('histogram', 'avg'); + expect(wrapper.find(EuiComboBox).props().options).toMatchInlineSnapshot(` + Array [ + Object { + "label": "Average", + "value": "avg", + }, + Object { + "label": "Count", + "value": "count", + }, + Object { + "label": "Sum", + "value": "sum", + }, + Object { + "label": "Value Count", + "value": "value_count", + }, + ] + `); + }); + + it('should only display metrics compattible aggs', () => { + const wrapper = setup('metrics', 'avg'); + expect(wrapper.find(EuiComboBox).props().options).toMatchInlineSnapshot(` + Array [ + Object { + "label": "Average", + "value": "avg", + }, + Object { + "label": "Cardinality", + "value": "cardinality", + }, + Object { + "label": "Count", + "value": "count", + }, + Object { + "label": "Filter Ratio", + "value": "filter_ratio", + }, + Object { + "label": "Positive Rate", + "value": "positive_rate", + }, + Object { + "label": "Max", + "value": "max", + }, + Object { + "label": "Min", + "value": "min", + }, + Object { + "label": "Percentile", + "value": "percentile", + }, + Object { + "label": "Percentile Rank", + "value": "percentile_rank", + }, + Object { + "label": "Static Value", + "value": "static", + }, + Object { + "label": "Std. Deviation", + "value": "std_deviation", + }, + Object { + "label": "Sum", + "value": "sum", + }, + Object { + "label": "Sum of Squares", + "value": "sum_of_squares", + }, + Object { + "label": "Top Hit", + "value": "top_hit", + }, + Object { + "label": "Value Count", + "value": "value_count", + }, + Object { + "label": "Variance", + "value": "variance", + }, + ] + `); + }); +}); diff --git a/src/plugins/vis_type_timeseries/public/application/components/aggs/agg_select.tsx b/src/plugins/vis_type_timeseries/public/application/components/aggs/agg_select.tsx index 6fa1a2adaa08e..7701d351e5478 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/aggs/agg_select.tsx +++ b/src/plugins/vis_type_timeseries/public/application/components/aggs/agg_select.tsx @@ -225,6 +225,19 @@ const specialAggs: AggSelectOption[] = [ }, ]; +const FILTER_RATIO_AGGS = [ + 'avg', + 'cardinality', + 'count', + 'positive_rate', + 'max', + 'min', + 'sum', + 'value_count', +]; + +const HISTOGRAM_AGGS = ['avg', 'count', 'sum', 'value_count']; + const allAggOptions = [...metricAggs, ...pipelineAggs, ...siblingAggs, ...specialAggs]; function filterByPanelType(panelType: string) { @@ -257,6 +270,10 @@ export function AggSelect(props: AggSelectUiProps) { let options: EuiComboBoxOptionOption[]; if (panelType === 'metrics') { options = metricAggs; + } else if (panelType === 'filter_ratio') { + options = metricAggs.filter((m) => FILTER_RATIO_AGGS.includes(`${m.value}`)); + } else if (panelType === 'histogram') { + options = metricAggs.filter((m) => HISTOGRAM_AGGS.includes(`${m.value}`)); } else { const disableSiblingAggs = (agg: AggSelectOption) => ({ ...agg, diff --git a/src/plugins/vis_type_timeseries/public/application/components/aggs/filter_ratio.js b/src/plugins/vis_type_timeseries/public/application/components/aggs/filter_ratio.js index b5311e3832da4..2aa994c09a2ad 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/aggs/filter_ratio.js +++ b/src/plugins/vis_type_timeseries/public/application/components/aggs/filter_ratio.js @@ -36,7 +36,15 @@ import { } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { KBN_FIELD_TYPES } from '../../../../../../plugins/data/public'; -import { METRIC_TYPES } from '../../../../../../plugins/vis_type_timeseries/common/metric_types'; +import { getSupportedFieldsByMetricType } from '../lib/get_supported_fields_by_metric_type'; + +const isFieldHistogram = (fields, indexPattern, field) => { + const indexFields = fields[indexPattern]; + if (!indexFields) return false; + const fieldObject = indexFields.find((f) => f.name === field); + if (!fieldObject) return false; + return fieldObject.type === KBN_FIELD_TYPES.HISTOGRAM; +}; export const FilterRatioAgg = (props) => { const { series, fields, panel } = props; @@ -56,9 +64,6 @@ export const FilterRatioAgg = (props) => { const model = { ...defaults, ...props.model }; const htmlId = htmlIdGenerator(); - const restrictFields = - model.metric_agg === METRIC_TYPES.CARDINALITY ? [] : [KBN_FIELD_TYPES.NUMBER]; - return ( { @@ -149,7 +156,7 @@ export const FilterRatioAgg = (props) => { { + const setup = (metric) => { + const series = { ...SERIES, metrics: [metric] }; + const panel = { ...PANEL, series }; + + const wrapper = mountWithIntl( +
+ +
+ ); + return wrapper; + }; + + describe('histogram support', () => { + it('should only display histogram compattible aggs', () => { + const metric = { + ...METRIC, + metric_agg: 'avg', + field: 'histogram_value', + }; + const wrapper = setup(metric); + expect(wrapper.find(EuiComboBox).at(1).props().options).toMatchInlineSnapshot(` + Array [ + Object { + "label": "Average", + "value": "avg", + }, + Object { + "label": "Count", + "value": "count", + }, + Object { + "label": "Sum", + "value": "sum", + }, + Object { + "label": "Value Count", + "value": "value_count", + }, + ] + `); + }); + const shouldNotHaveHistogramField = (agg) => { + it(`should not have histogram fields for ${agg}`, () => { + const metric = { + ...METRIC, + metric_agg: agg, + field: '', + }; + const wrapper = setup(metric); + expect(wrapper.find(EuiComboBox).at(2).props().options).toMatchInlineSnapshot(` + Array [ + Object { + "label": "number", + "options": Array [ + Object { + "label": "system.cpu.user.pct", + "value": "system.cpu.user.pct", + }, + ], + }, + ] + `); + }); + }; + shouldNotHaveHistogramField('max'); + shouldNotHaveHistogramField('min'); + shouldNotHaveHistogramField('positive_rate'); + + it(`should not have histogram fields for cardinality`, () => { + const metric = { + ...METRIC, + metric_agg: 'cardinality', + field: '', + }; + const wrapper = setup(metric); + expect(wrapper.find(EuiComboBox).at(2).props().options).toMatchInlineSnapshot(` + Array [ + Object { + "label": "date", + "options": Array [ + Object { + "label": "@timestamp", + "value": "@timestamp", + }, + ], + }, + Object { + "label": "number", + "options": Array [ + Object { + "label": "system.cpu.user.pct", + "value": "system.cpu.user.pct", + }, + ], + }, + ] + `); + }); + }); +}); diff --git a/src/plugins/vis_type_timeseries/public/application/components/aggs/histogram_support.test.js b/src/plugins/vis_type_timeseries/public/application/components/aggs/histogram_support.test.js new file mode 100644 index 0000000000000..7af33ba11f247 --- /dev/null +++ b/src/plugins/vis_type_timeseries/public/application/components/aggs/histogram_support.test.js @@ -0,0 +1,94 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React from 'react'; +import { mountWithIntl } from 'test_utils/enzyme_helpers'; +import { Agg } from './agg'; +import { FieldSelect } from './field_select'; +import { FIELDS, METRIC, SERIES, PANEL } from '../../../test_utils'; +const runTest = (aggType, name, test, additionalProps = {}) => { + describe(aggType, () => { + const metric = { + ...METRIC, + type: aggType, + field: 'histogram_value', + ...additionalProps, + }; + const series = { ...SERIES, metrics: [metric] }; + const panel = { ...PANEL, series }; + + it(name, () => { + const wrapper = mountWithIntl( +
+ +
+ ); + test(wrapper); + }); + }); +}; + +describe('Histogram Types', () => { + describe('supported', () => { + const shouldHaveHistogramSupport = (aggType, additionalProps = {}) => { + runTest( + aggType, + 'supports', + (wrapper) => + expect(wrapper.find(FieldSelect).at(0).props().restrict).toContain('histogram'), + additionalProps + ); + }; + shouldHaveHistogramSupport('avg'); + shouldHaveHistogramSupport('sum'); + shouldHaveHistogramSupport('value_count'); + shouldHaveHistogramSupport('percentile'); + shouldHaveHistogramSupport('percentile_rank'); + shouldHaveHistogramSupport('filter_ratio', { metric_agg: 'avg' }); + }); + describe('not supported', () => { + const shouldNotHaveHistogramSupport = (aggType, additionalProps = {}) => { + runTest( + aggType, + 'does not support', + (wrapper) => + expect(wrapper.find(FieldSelect).at(0).props().restrict).not.toContain('histogram'), + additionalProps + ); + }; + shouldNotHaveHistogramSupport('cardinality'); + shouldNotHaveHistogramSupport('max'); + shouldNotHaveHistogramSupport('min'); + shouldNotHaveHistogramSupport('variance'); + shouldNotHaveHistogramSupport('sum_of_squares'); + shouldNotHaveHistogramSupport('std_deviation'); + shouldNotHaveHistogramSupport('positive_rate'); + shouldNotHaveHistogramSupport('top_hit'); + }); +}); diff --git a/src/plugins/vis_type_timeseries/public/application/components/aggs/percentile.js b/src/plugins/vis_type_timeseries/public/application/components/aggs/percentile.js index 6a7bf1bffe83c..f12c0c8f6f465 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/aggs/percentile.js +++ b/src/plugins/vis_type_timeseries/public/application/components/aggs/percentile.js @@ -36,7 +36,7 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { KBN_FIELD_TYPES } from '../../../../../../plugins/data/public'; import { Percentiles, newPercentile } from './percentile_ui'; -const RESTRICT_FIELDS = [KBN_FIELD_TYPES.NUMBER]; +const RESTRICT_FIELDS = [KBN_FIELD_TYPES.NUMBER, KBN_FIELD_TYPES.HISTOGRAM]; const checkModel = (model) => Array.isArray(model.percentiles); diff --git a/src/plugins/vis_type_timeseries/public/application/components/aggs/percentile_rank/percentile_rank.tsx b/src/plugins/vis_type_timeseries/public/application/components/aggs/percentile_rank/percentile_rank.tsx index a16f5aeefc49c..d02a16ade2bba 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/aggs/percentile_rank/percentile_rank.tsx +++ b/src/plugins/vis_type_timeseries/public/application/components/aggs/percentile_rank/percentile_rank.tsx @@ -41,7 +41,7 @@ import { IFieldType, KBN_FIELD_TYPES } from '../../../../../../../plugins/data/p import { MetricsItemsSchema, PanelSchema, SeriesItemsSchema } from '../../../../../common/types'; import { DragHandleProps } from '../../../../types'; -const RESTRICT_FIELDS = [KBN_FIELD_TYPES.NUMBER]; +const RESTRICT_FIELDS = [KBN_FIELD_TYPES.NUMBER, KBN_FIELD_TYPES.HISTOGRAM]; interface PercentileRankAggProps { disableDelete: boolean; diff --git a/src/plugins/vis_type_timeseries/public/application/components/aggs/positive_rate.js b/src/plugins/vis_type_timeseries/public/application/components/aggs/positive_rate.js index 3ca89f7289d65..c20bcc1babc1d 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/aggs/positive_rate.js +++ b/src/plugins/vis_type_timeseries/public/application/components/aggs/positive_rate.js @@ -123,7 +123,7 @@ export const PositiveRateAgg = (props) => { t !== KBN_FIELD_TYPES.HISTOGRAM); + case METRIC_TYPES.VALUE_COUNT: + case METRIC_TYPES.AVERAGE: + case METRIC_TYPES.SUM: + return [KBN_FIELD_TYPES.NUMBER, KBN_FIELD_TYPES.HISTOGRAM]; + default: + return [KBN_FIELD_TYPES.NUMBER]; + } +} diff --git a/src/plugins/vis_type_timeseries/public/application/components/lib/get_supported_fields_by_metric_type.test.js b/src/plugins/vis_type_timeseries/public/application/components/lib/get_supported_fields_by_metric_type.test.js new file mode 100644 index 0000000000000..3cd3fac191bf1 --- /dev/null +++ b/src/plugins/vis_type_timeseries/public/application/components/lib/get_supported_fields_by_metric_type.test.js @@ -0,0 +1,44 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { getSupportedFieldsByMetricType } from './get_supported_fields_by_metric_type'; + +describe('getSupportedFieldsByMetricType', () => { + const shouldHaveHistogramAndNumbers = (type) => + it(`should return numbers and histogram for ${type}`, () => { + expect(getSupportedFieldsByMetricType(type)).toEqual(['number', 'histogram']); + }); + const shouldHaveOnlyNumbers = (type) => + it(`should return only numbers for ${type}`, () => { + expect(getSupportedFieldsByMetricType(type)).toEqual(['number']); + }); + + shouldHaveHistogramAndNumbers('value_count'); + shouldHaveHistogramAndNumbers('avg'); + shouldHaveHistogramAndNumbers('sum'); + + shouldHaveOnlyNumbers('positive_rate'); + shouldHaveOnlyNumbers('std_deviation'); + shouldHaveOnlyNumbers('max'); + shouldHaveOnlyNumbers('min'); + + it(`should return everything but histogram for cardinality`, () => { + expect(getSupportedFieldsByMetricType('cardinality')).not.toContain('histogram'); + }); +}); diff --git a/src/plugins/vis_type_timeseries/public/test_utils/index.ts b/src/plugins/vis_type_timeseries/public/test_utils/index.ts new file mode 100644 index 0000000000000..96ecc89b70c2d --- /dev/null +++ b/src/plugins/vis_type_timeseries/public/test_utils/index.ts @@ -0,0 +1,50 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export const UI_RESTRICTIONS = { '*': true }; +export const INDEX_PATTERN = 'some-pattern'; +export const FIELDS = { + [INDEX_PATTERN]: [ + { + type: 'date', + name: '@timestamp', + }, + { + type: 'number', + name: 'system.cpu.user.pct', + }, + { + type: 'histogram', + name: 'histogram_value', + }, + ], +}; +export const METRIC = { + id: 'sample_metric', + type: 'avg', + field: 'system.cpu.user.pct', +}; +export const SERIES = { + metrics: [METRIC], +}; +export const PANEL = { + type: 'timeseries', + index_pattern: INDEX_PATTERN, + series: SERIES, +}; From 4db58164597638e20f255f6450a9a29c97618a07 Mon Sep 17 00:00:00 2001 From: Kerry Gallagher Date: Mon, 13 Jul 2020 18:09:10 +0100 Subject: [PATCH 11/66] [Logs UI] Add category anomalies to anomalies page (#70982) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add category anomalies to anomalies page Co-authored-by: Felix Stürmer Co-authored-by: Elastic Machine --- .../http_api/log_analysis/results/index.ts | 3 +- .../results/log_entry_anomalies.ts | 137 ++++++ .../results/log_entry_examples.ts | 82 ++++ .../results/log_entry_rate_examples.ts | 77 ---- .../log_analysis/log_analysis_results.ts | 4 + .../log_entry_rate/page_results_content.tsx | 121 +++--- .../sections/anomalies/chart.tsx | 97 +++-- .../sections/anomalies/expanded_row.tsx | 58 ++- .../sections/anomalies/index.tsx | 162 ++++--- .../sections/anomalies/log_entry_example.tsx | 22 +- .../sections/anomalies/table.tsx | 303 +++++++------ .../sections/log_rate/bar_chart.tsx | 100 ----- .../sections/log_rate/index.tsx | 98 ----- .../service_calls/get_log_entry_anomalies.ts | 41 ++ ..._examples.ts => get_log_entry_examples.ts} | 14 +- .../use_log_entry_anomalies_results.ts | 262 ++++++++++++ .../log_entry_rate/use_log_entry_examples.ts | 65 +++ .../use_log_entry_rate_examples.ts | 63 --- x-pack/plugins/infra/server/infra_server.ts | 6 +- .../infra/server/lib/log_analysis/common.ts | 29 ++ .../infra/server/lib/log_analysis/errors.ts | 7 + .../infra/server/lib/log_analysis/index.ts | 1 + .../lib/log_analysis/log_entry_anomalies.ts | 398 ++++++++++++++++++ .../log_entry_categories_analysis.ts | 30 +- .../log_analysis/log_entry_rate_analysis.ts | 145 +------ .../server/lib/log_analysis/queries/common.ts | 8 + .../server/lib/log_analysis/queries/index.ts | 1 + .../queries/log_entry_anomalies.ts | 128 ++++++ ...rate_examples.ts => log_entry_examples.ts} | 41 +- .../routes/log_analysis/results/index.ts | 3 +- .../results/log_entry_anomalies.ts | 112 +++++ ...rate_examples.ts => log_entry_examples.ts} | 20 +- .../translations/translations/ja-JP.json | 9 - .../translations/translations/zh-CN.json | 9 - 34 files changed, 1764 insertions(+), 892 deletions(-) create mode 100644 x-pack/plugins/infra/common/http_api/log_analysis/results/log_entry_anomalies.ts create mode 100644 x-pack/plugins/infra/common/http_api/log_analysis/results/log_entry_examples.ts delete mode 100644 x-pack/plugins/infra/common/http_api/log_analysis/results/log_entry_rate_examples.ts delete mode 100644 x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/log_rate/bar_chart.tsx delete mode 100644 x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/log_rate/index.tsx create mode 100644 x-pack/plugins/infra/public/pages/logs/log_entry_rate/service_calls/get_log_entry_anomalies.ts rename x-pack/plugins/infra/public/pages/logs/log_entry_rate/service_calls/{get_log_entry_rate_examples.ts => get_log_entry_examples.ts} (77%) create mode 100644 x-pack/plugins/infra/public/pages/logs/log_entry_rate/use_log_entry_anomalies_results.ts create mode 100644 x-pack/plugins/infra/public/pages/logs/log_entry_rate/use_log_entry_examples.ts delete mode 100644 x-pack/plugins/infra/public/pages/logs/log_entry_rate/use_log_entry_rate_examples.ts create mode 100644 x-pack/plugins/infra/server/lib/log_analysis/common.ts create mode 100644 x-pack/plugins/infra/server/lib/log_analysis/log_entry_anomalies.ts create mode 100644 x-pack/plugins/infra/server/lib/log_analysis/queries/log_entry_anomalies.ts rename x-pack/plugins/infra/server/lib/log_analysis/queries/{log_entry_rate_examples.ts => log_entry_examples.ts} (59%) create mode 100644 x-pack/plugins/infra/server/routes/log_analysis/results/log_entry_anomalies.ts rename x-pack/plugins/infra/server/routes/log_analysis/results/{log_entry_rate_examples.ts => log_entry_examples.ts} (75%) diff --git a/x-pack/plugins/infra/common/http_api/log_analysis/results/index.ts b/x-pack/plugins/infra/common/http_api/log_analysis/results/index.ts index 30b6be435837b..cbd89db97236f 100644 --- a/x-pack/plugins/infra/common/http_api/log_analysis/results/index.ts +++ b/x-pack/plugins/infra/common/http_api/log_analysis/results/index.ts @@ -8,4 +8,5 @@ export * from './log_entry_categories'; export * from './log_entry_category_datasets'; export * from './log_entry_category_examples'; export * from './log_entry_rate'; -export * from './log_entry_rate_examples'; +export * from './log_entry_examples'; +export * from './log_entry_anomalies'; diff --git a/x-pack/plugins/infra/common/http_api/log_analysis/results/log_entry_anomalies.ts b/x-pack/plugins/infra/common/http_api/log_analysis/results/log_entry_anomalies.ts new file mode 100644 index 0000000000000..639ac63f9b14d --- /dev/null +++ b/x-pack/plugins/infra/common/http_api/log_analysis/results/log_entry_anomalies.ts @@ -0,0 +1,137 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as rt from 'io-ts'; + +import { timeRangeRT, routeTimingMetadataRT } from '../../shared'; + +export const LOG_ANALYSIS_GET_LOG_ENTRY_ANOMALIES_PATH = + '/api/infra/log_analysis/results/log_entry_anomalies'; + +// [Sort field value, tiebreaker value] +const paginationCursorRT = rt.tuple([ + rt.union([rt.string, rt.number]), + rt.union([rt.string, rt.number]), +]); + +export type PaginationCursor = rt.TypeOf; + +export const anomalyTypeRT = rt.keyof({ + logRate: null, + logCategory: null, +}); + +export type AnomalyType = rt.TypeOf; + +const logEntryAnomalyCommonFieldsRT = rt.type({ + id: rt.string, + anomalyScore: rt.number, + dataset: rt.string, + typical: rt.number, + actual: rt.number, + type: anomalyTypeRT, + duration: rt.number, + startTime: rt.number, + jobId: rt.string, +}); +const logEntrylogRateAnomalyRT = logEntryAnomalyCommonFieldsRT; +const logEntrylogCategoryAnomalyRT = rt.partial({ + categoryId: rt.string, +}); +const logEntryAnomalyRT = rt.intersection([ + logEntryAnomalyCommonFieldsRT, + logEntrylogRateAnomalyRT, + logEntrylogCategoryAnomalyRT, +]); + +export type LogEntryAnomaly = rt.TypeOf; + +export const getLogEntryAnomaliesSuccessReponsePayloadRT = rt.intersection([ + rt.type({ + data: rt.intersection([ + rt.type({ + anomalies: rt.array(logEntryAnomalyRT), + // Signifies there are more entries backwards or forwards. If this was a request + // for a previous page, there are more previous pages, if this was a request for a next page, + // there are more next pages. + hasMoreEntries: rt.boolean, + }), + rt.partial({ + paginationCursors: rt.type({ + // The cursor to use to fetch the previous page + previousPageCursor: paginationCursorRT, + // The cursor to use to fetch the next page + nextPageCursor: paginationCursorRT, + }), + }), + ]), + }), + rt.partial({ + timing: routeTimingMetadataRT, + }), +]); + +export type GetLogEntryAnomaliesSuccessResponsePayload = rt.TypeOf< + typeof getLogEntryAnomaliesSuccessReponsePayloadRT +>; + +const sortOptionsRT = rt.keyof({ + anomalyScore: null, + dataset: null, + startTime: null, +}); + +const sortDirectionsRT = rt.keyof({ + asc: null, + desc: null, +}); + +const paginationPreviousPageCursorRT = rt.type({ + searchBefore: paginationCursorRT, +}); + +const paginationNextPageCursorRT = rt.type({ + searchAfter: paginationCursorRT, +}); + +const paginationRT = rt.intersection([ + rt.type({ + pageSize: rt.number, + }), + rt.partial({ + cursor: rt.union([paginationPreviousPageCursorRT, paginationNextPageCursorRT]), + }), +]); + +export type Pagination = rt.TypeOf; + +const sortRT = rt.type({ + field: sortOptionsRT, + direction: sortDirectionsRT, +}); + +export type Sort = rt.TypeOf; + +export const getLogEntryAnomaliesRequestPayloadRT = rt.type({ + data: rt.intersection([ + rt.type({ + // the ID of the source configuration + sourceId: rt.string, + // the time range to fetch the log entry anomalies from + timeRange: timeRangeRT, + }), + rt.partial({ + // Pagination properties + pagination: paginationRT, + // Sort properties + sort: sortRT, + }), + ]), +}); + +export type GetLogEntryAnomaliesRequestPayload = rt.TypeOf< + typeof getLogEntryAnomaliesRequestPayloadRT +>; diff --git a/x-pack/plugins/infra/common/http_api/log_analysis/results/log_entry_examples.ts b/x-pack/plugins/infra/common/http_api/log_analysis/results/log_entry_examples.ts new file mode 100644 index 0000000000000..1eed29cd37560 --- /dev/null +++ b/x-pack/plugins/infra/common/http_api/log_analysis/results/log_entry_examples.ts @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as rt from 'io-ts'; + +import { + badRequestErrorRT, + forbiddenErrorRT, + timeRangeRT, + routeTimingMetadataRT, +} from '../../shared'; + +export const LOG_ANALYSIS_GET_LOG_ENTRY_RATE_EXAMPLES_PATH = + '/api/infra/log_analysis/results/log_entry_examples'; + +/** + * request + */ + +export const getLogEntryExamplesRequestPayloadRT = rt.type({ + data: rt.intersection([ + rt.type({ + // the dataset to fetch the log rate examples from + dataset: rt.string, + // the number of examples to fetch + exampleCount: rt.number, + // the id of the source configuration + sourceId: rt.string, + // the time range to fetch the log rate examples from + timeRange: timeRangeRT, + }), + rt.partial({ + categoryId: rt.string, + }), + ]), +}); + +export type GetLogEntryExamplesRequestPayload = rt.TypeOf< + typeof getLogEntryExamplesRequestPayloadRT +>; + +/** + * response + */ + +const logEntryExampleRT = rt.type({ + id: rt.string, + dataset: rt.string, + message: rt.string, + timestamp: rt.number, + tiebreaker: rt.number, +}); + +export type LogEntryExample = rt.TypeOf; + +export const getLogEntryExamplesSuccessReponsePayloadRT = rt.intersection([ + rt.type({ + data: rt.type({ + examples: rt.array(logEntryExampleRT), + }), + }), + rt.partial({ + timing: routeTimingMetadataRT, + }), +]); + +export type GetLogEntryExamplesSuccessReponsePayload = rt.TypeOf< + typeof getLogEntryExamplesSuccessReponsePayloadRT +>; + +export const getLogEntryExamplesResponsePayloadRT = rt.union([ + getLogEntryExamplesSuccessReponsePayloadRT, + badRequestErrorRT, + forbiddenErrorRT, +]); + +export type GetLogEntryExamplesResponsePayload = rt.TypeOf< + typeof getLogEntryExamplesResponsePayloadRT +>; diff --git a/x-pack/plugins/infra/common/http_api/log_analysis/results/log_entry_rate_examples.ts b/x-pack/plugins/infra/common/http_api/log_analysis/results/log_entry_rate_examples.ts deleted file mode 100644 index 700f87ec3beb1..0000000000000 --- a/x-pack/plugins/infra/common/http_api/log_analysis/results/log_entry_rate_examples.ts +++ /dev/null @@ -1,77 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import * as rt from 'io-ts'; - -import { - badRequestErrorRT, - forbiddenErrorRT, - timeRangeRT, - routeTimingMetadataRT, -} from '../../shared'; - -export const LOG_ANALYSIS_GET_LOG_ENTRY_RATE_EXAMPLES_PATH = - '/api/infra/log_analysis/results/log_entry_rate_examples'; - -/** - * request - */ - -export const getLogEntryRateExamplesRequestPayloadRT = rt.type({ - data: rt.type({ - // the dataset to fetch the log rate examples from - dataset: rt.string, - // the number of examples to fetch - exampleCount: rt.number, - // the id of the source configuration - sourceId: rt.string, - // the time range to fetch the log rate examples from - timeRange: timeRangeRT, - }), -}); - -export type GetLogEntryRateExamplesRequestPayload = rt.TypeOf< - typeof getLogEntryRateExamplesRequestPayloadRT ->; - -/** - * response - */ - -const logEntryRateExampleRT = rt.type({ - id: rt.string, - dataset: rt.string, - message: rt.string, - timestamp: rt.number, - tiebreaker: rt.number, -}); - -export type LogEntryRateExample = rt.TypeOf; - -export const getLogEntryRateExamplesSuccessReponsePayloadRT = rt.intersection([ - rt.type({ - data: rt.type({ - examples: rt.array(logEntryRateExampleRT), - }), - }), - rt.partial({ - timing: routeTimingMetadataRT, - }), -]); - -export type GetLogEntryRateExamplesSuccessReponsePayload = rt.TypeOf< - typeof getLogEntryRateExamplesSuccessReponsePayloadRT ->; - -export const getLogEntryRateExamplesResponsePayloadRT = rt.union([ - getLogEntryRateExamplesSuccessReponsePayloadRT, - badRequestErrorRT, - forbiddenErrorRT, -]); - -export type GetLogEntryRateExamplesResponsePayload = rt.TypeOf< - typeof getLogEntryRateExamplesResponsePayloadRT ->; diff --git a/x-pack/plugins/infra/common/log_analysis/log_analysis_results.ts b/x-pack/plugins/infra/common/log_analysis/log_analysis_results.ts index 19c92cb381104..f4497dbba5056 100644 --- a/x-pack/plugins/infra/common/log_analysis/log_analysis_results.ts +++ b/x-pack/plugins/infra/common/log_analysis/log_analysis_results.ts @@ -41,6 +41,10 @@ export const formatAnomalyScore = (score: number) => { return Math.round(score); }; +export const formatOneDecimalPlace = (number: number) => { + return Math.round(number * 10) / 10; +}; + export const getFriendlyNameForPartitionId = (partitionId: string) => { return partitionId !== '' ? partitionId : 'unknown'; }; diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/page_results_content.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/page_results_content.tsx index bf4dbcd87cc41..21c3e3ec70029 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/page_results_content.tsx +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/page_results_content.tsx @@ -5,30 +5,18 @@ */ import datemath from '@elastic/datemath'; -import { - EuiBadge, - EuiFlexGroup, - EuiFlexItem, - EuiPage, - EuiPanel, - EuiSuperDatePicker, - EuiText, -} from '@elastic/eui'; -import numeral from '@elastic/numeral'; -import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiFlexGroup, EuiFlexItem, EuiPage, EuiPanel, EuiSuperDatePicker } from '@elastic/eui'; import moment from 'moment'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { euiStyled, useTrackPageview } from '../../../../../observability/public'; import { TimeRange } from '../../../../common/http_api/shared/time_range'; import { bucketSpan } from '../../../../common/log_analysis'; -import { LoadingOverlayWrapper } from '../../../components/loading_overlay_wrapper'; import { LogAnalysisJobProblemIndicator } from '../../../components/logging/log_analysis_job_status'; import { useInterval } from '../../../hooks/use_interval'; -import { useKibanaUiSetting } from '../../../utils/use_kibana_ui_setting'; import { AnomaliesResults } from './sections/anomalies'; -import { LogRateResults } from './sections/log_rate'; import { useLogEntryRateModuleContext } from './use_log_entry_rate_module'; import { useLogEntryRateResults } from './use_log_entry_rate_results'; +import { useLogEntryAnomaliesResults } from './use_log_entry_anomalies_results'; import { StringTimeRange, useLogAnalysisResultsUrlState, @@ -36,6 +24,15 @@ import { const JOB_STATUS_POLLING_INTERVAL = 30000; +export const SORT_DEFAULTS = { + direction: 'desc' as const, + field: 'anomalyScore' as const, +}; + +export const PAGINATION_DEFAULTS = { + pageSize: 25, +}; + interface LogEntryRateResultsContentProps { onOpenSetup: () => void; } @@ -46,8 +43,6 @@ export const LogEntryRateResultsContent: React.FunctionComponent { setQueryTimeRange({ @@ -182,45 +194,18 @@ export const LogEntryRateResultsContent: React.FunctionComponent - - - - {logEntryRate ? ( - - - - - {numeral(logEntryRate.totalNumberOfLogEntries).format('0.00a')} - - - ), - startTime: ( - {moment(queryTimeRange.value.startTime).format(dateFormat)} - ), - endTime: {moment(queryTimeRange.value.endTime).format(dateFormat)}, - }} - /> - - - ) : null} - - - - - - + + + + + - - - - - diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/chart.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/chart.tsx index 79ab4475ee5a3..ae5c3b5b93b47 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/chart.tsx +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/chart.tsx @@ -3,7 +3,7 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ - +import { EuiEmptyPrompt } from '@elastic/eui'; import { RectAnnotationDatum, AnnotationId } from '@elastic/charts'; import { Axis, @@ -21,6 +21,7 @@ import numeral from '@elastic/numeral'; import { i18n } from '@kbn/i18n'; import moment from 'moment'; import React, { useCallback, useMemo } from 'react'; +import { LoadingOverlayWrapper } from '../../../../../components/loading_overlay_wrapper'; import { TimeRange } from '../../../../../../common/http_api/shared/time_range'; import { @@ -36,7 +37,16 @@ export const AnomaliesChart: React.FunctionComponent<{ series: Array<{ time: number; value: number }>; annotations: Record; renderAnnotationTooltip?: (details?: string) => JSX.Element; -}> = ({ chartId, series, annotations, setTimeRange, timeRange, renderAnnotationTooltip }) => { + isLoading: boolean; +}> = ({ + chartId, + series, + annotations, + setTimeRange, + timeRange, + renderAnnotationTooltip, + isLoading, +}) => { const [dateFormat] = useKibanaUiSetting('dateFormat', 'Y-MM-DD HH:mm:ss.SSS'); const [isDarkMode] = useKibanaUiSetting('theme:darkMode'); @@ -68,41 +78,56 @@ export const AnomaliesChart: React.FunctionComponent<{ [setTimeRange] ); - return ( -
- - - numeral(value.toPrecision(3)).format('0[.][00]a')} // https://github.com/adamwdraper/Numeral-js/issues/194 - /> - + {i18n.translate('xpack.infra.logs.analysis.anomalySectionLogRateChartNoData', { + defaultMessage: 'There is no log rate data to display.', })} - xScaleType="time" - yScaleType="linear" - xAccessor={'time'} - yAccessors={['value']} - data={series} - barSeriesStyle={barSeriesStyle} - /> - {renderAnnotations(annotations, chartId, renderAnnotationTooltip)} - - -
+ + } + titleSize="m" + /> + ) : ( + +
+ {series.length ? ( + + + numeral(value.toPrecision(3)).format('0[.][00]a')} // https://github.com/adamwdraper/Numeral-js/issues/194 + /> + + {renderAnnotations(annotations, chartId, renderAnnotationTooltip)} + + + ) : null} +
+
); }; diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/expanded_row.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/expanded_row.tsx index c527b8c49d099..e4b12e199a048 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/expanded_row.tsx +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/expanded_row.tsx @@ -10,12 +10,12 @@ import { i18n } from '@kbn/i18n'; import React from 'react'; import { useMount } from 'react-use'; import { TimeRange } from '../../../../../../common/http_api/shared/time_range'; -import { AnomalyRecord } from '../../use_log_entry_rate_results'; -import { useLogEntryRateModuleContext } from '../../use_log_entry_rate_module'; -import { useLogEntryRateExamples } from '../../use_log_entry_rate_examples'; +import { LogEntryAnomaly } from '../../../../../../common/http_api'; +import { useLogEntryExamples } from '../../use_log_entry_examples'; import { LogEntryExampleMessages } from '../../../../../components/logging/log_entry_examples/log_entry_examples'; -import { LogEntryRateExampleMessage, LogEntryRateExampleMessageHeaders } from './log_entry_example'; +import { LogEntryExampleMessage, LogEntryExampleMessageHeaders } from './log_entry_example'; import { euiStyled } from '../../../../../../../observability/public'; +import { useLogSourceContext } from '../../../../../containers/logs/log_source'; const EXAMPLE_COUNT = 5; @@ -24,29 +24,27 @@ const examplesTitle = i18n.translate('xpack.infra.logs.analysis.anomaliesTableEx }); export const AnomaliesTableExpandedRow: React.FunctionComponent<{ - anomaly: AnomalyRecord; + anomaly: LogEntryAnomaly; timeRange: TimeRange; - jobId: string; -}> = ({ anomaly, timeRange, jobId }) => { - const { - sourceConfiguration: { sourceId }, - } = useLogEntryRateModuleContext(); +}> = ({ anomaly, timeRange }) => { + const { sourceId } = useLogSourceContext(); const { - getLogEntryRateExamples, - hasFailedLoadingLogEntryRateExamples, - isLoadingLogEntryRateExamples, - logEntryRateExamples, - } = useLogEntryRateExamples({ - dataset: anomaly.partitionId, + getLogEntryExamples, + hasFailedLoadingLogEntryExamples, + isLoadingLogEntryExamples, + logEntryExamples, + } = useLogEntryExamples({ + dataset: anomaly.dataset, endTime: anomaly.startTime + anomaly.duration, exampleCount: EXAMPLE_COUNT, sourceId, startTime: anomaly.startTime, + categoryId: anomaly.categoryId, }); useMount(() => { - getLogEntryRateExamples(); + getLogEntryExamples(); }); return ( @@ -57,17 +55,17 @@ export const AnomaliesTableExpandedRow: React.FunctionComponent<{

{examplesTitle}

0} + isLoading={isLoadingLogEntryExamples} + hasFailedLoading={hasFailedLoadingLogEntryExamples} + hasResults={logEntryExamples.length > 0} exampleCount={EXAMPLE_COUNT} - onReload={getLogEntryRateExamples} + onReload={getLogEntryExamples} > - {logEntryRateExamples.length > 0 ? ( + {logEntryExamples.length > 0 ? ( <> - - {logEntryRateExamples.map((example, exampleIndex) => ( - + {logEntryExamples.map((example, exampleIndex) => ( + ))} @@ -87,11 +85,11 @@ export const AnomaliesTableExpandedRow: React.FunctionComponent<{ void; timeRange: TimeRange; viewSetupForReconfiguration: () => void; - jobId: string; -}> = ({ isLoading, results, setTimeRange, timeRange, viewSetupForReconfiguration, jobId }) => { - const hasAnomalies = useMemo(() => { - return results && results.histogramBuckets - ? results.histogramBuckets.some((bucket) => { - return bucket.partitions.some((partition) => { - return partition.anomalies.length > 0; - }); - }) - : false; - }, [results]); - + page: Page; + fetchNextPage?: FetchNextPage; + fetchPreviousPage?: FetchPreviousPage; + changeSortOptions: ChangeSortOptions; + changePaginationOptions: ChangePaginationOptions; + sortOptions: SortOptions; + paginationOptions: PaginationOptions; +}> = ({ + isLoadingLogRateResults, + isLoadingAnomaliesResults, + logEntryRateResults, + setTimeRange, + timeRange, + viewSetupForReconfiguration, + anomalies, + changeSortOptions, + sortOptions, + changePaginationOptions, + paginationOptions, + fetchNextPage, + fetchPreviousPage, + page, +}) => { const logEntryRateSeries = useMemo( - () => (results && results.histogramBuckets ? getLogEntryRateCombinedSeries(results) : []), - [results] + () => + logEntryRateResults && logEntryRateResults.histogramBuckets + ? getLogEntryRateCombinedSeries(logEntryRateResults) + : [], + [logEntryRateResults] ); const anomalyAnnotations = useMemo( () => - results && results.histogramBuckets - ? getAnnotationsForAll(results) + logEntryRateResults && logEntryRateResults.histogramBuckets + ? getAnnotationsForAll(logEntryRateResults) : { warning: [], minor: [], major: [], critical: [], }, - [results] + [logEntryRateResults] ); return ( <> - -

{title}

+ +

{title}

- - -
- }> - {!results || (results && results.histogramBuckets && !results.histogramBuckets.length) ? ( + {(!logEntryRateResults || + (logEntryRateResults && + logEntryRateResults.histogramBuckets && + !logEntryRateResults.histogramBuckets.length)) && + (!anomalies || anomalies.length === 0) ? ( + } + > @@ -94,41 +123,38 @@ export const AnomaliesResults: React.FunctionComponent<{

} /> - ) : !hasAnomalies ? ( - - {i18n.translate('xpack.infra.logs.analysis.anomalySectionNoAnomaliesTitle', { - defaultMessage: 'No anomalies were detected.', - })} - - } - titleSize="m" +
+ ) : ( + <> + + + + + + + - ) : ( - <> - - - - - - - - - )} -
+ + )} ); }; @@ -137,13 +163,6 @@ const title = i18n.translate('xpack.infra.logs.analysis.anomaliesSectionTitle', defaultMessage: 'Anomalies', }); -const loadingAriaLabel = i18n.translate( - 'xpack.infra.logs.analysis.anomaliesSectionLoadingAriaLabel', - { defaultMessage: 'Loading anomalies' } -); - -const LoadingOverlayContent = () => ; - interface ParsedAnnotationDetails { anomalyScoresByPartition: Array<{ partitionName: string; maximumAnomalyScore: number }>; } @@ -189,3 +208,10 @@ const renderAnnotationTooltip = (details?: string) => { const TooltipWrapper = euiStyled('div')` white-space: nowrap; `; + +const loadingAriaLabel = i18n.translate( + 'xpack.infra.logs.analysis.anomaliesSectionLoadingAriaLabel', + { defaultMessage: 'Loading anomalies' } +); + +const LoadingOverlayContent = () => ; diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/log_entry_example.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/log_entry_example.tsx index 96f665b3693ca..2965e1fede822 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/log_entry_example.tsx +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/log_entry_example.tsx @@ -28,7 +28,7 @@ import { useLinkProps } from '../../../../../hooks/use_link_props'; import { TimeRange } from '../../../../../../common/http_api/shared/time_range'; import { partitionField } from '../../../../../../common/log_analysis/job_parameters'; import { getEntitySpecificSingleMetricViewerLink } from '../../../../../components/logging/log_analysis_results/analyze_in_ml_button'; -import { LogEntryRateExample } from '../../../../../../common/http_api/log_analysis/results'; +import { LogEntryExample } from '../../../../../../common/http_api/log_analysis/results'; import { LogColumnConfiguration, isTimestampLogColumnConfiguration, @@ -36,6 +36,7 @@ import { isMessageLogColumnConfiguration, } from '../../../../../utils/source_configuration'; import { localizedDate } from '../../../../../../common/formatters/datetime'; +import { LogEntryAnomaly } from '../../../../../../common/http_api'; export const exampleMessageScale = 'medium' as const; export const exampleTimestampFormat = 'time' as const; @@ -58,19 +59,19 @@ const VIEW_ANOMALY_IN_ML_LABEL = i18n.translate( } ); -type Props = LogEntryRateExample & { +type Props = LogEntryExample & { timeRange: TimeRange; - jobId: string; + anomaly: LogEntryAnomaly; }; -export const LogEntryRateExampleMessage: React.FunctionComponent = ({ +export const LogEntryExampleMessage: React.FunctionComponent = ({ id, dataset, message, timestamp, tiebreaker, timeRange, - jobId, + anomaly, }) => { const [isHovered, setIsHovered] = useState(false); const [isMenuOpen, setIsMenuOpen] = useState(false); @@ -107,8 +108,9 @@ export const LogEntryRateExampleMessage: React.FunctionComponent = ({ }); const viewAnomalyInMachineLearningLinkProps = useLinkProps( - getEntitySpecificSingleMetricViewerLink(jobId, timeRange, { + getEntitySpecificSingleMetricViewerLink(anomaly.jobId, timeRange, { [partitionField]: dataset, + ...(anomaly.categoryId ? { mlcategory: anomaly.categoryId } : {}), }) ); @@ -233,11 +235,11 @@ export const exampleMessageColumnConfigurations: LogColumnConfiguration[] = [ }, ]; -export const LogEntryRateExampleMessageHeaders: React.FunctionComponent<{ +export const LogEntryExampleMessageHeaders: React.FunctionComponent<{ dateTime: number; }> = ({ dateTime }) => { return ( - + <> {exampleMessageColumnConfigurations.map((columnConfiguration) => { if (isTimestampLogColumnConfiguration(columnConfiguration)) { @@ -280,11 +282,11 @@ export const LogEntryRateExampleMessageHeaders: React.FunctionComponent<{ {null} - + ); }; -const LogEntryRateExampleMessageHeadersWrapper = euiStyled(LogColumnHeadersWrapper)` +const LogEntryExampleMessageHeadersWrapper = euiStyled(LogColumnHeadersWrapper)` border-bottom: none; box-shadow: none; padding-right: 0; diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/table.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/table.tsx index c70a456bfe06a..e0a3b6fb91db0 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/table.tsx +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/table.tsx @@ -4,45 +4,52 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiBasicTable, EuiBasicTableColumn } from '@elastic/eui'; +import { + EuiBasicTable, + EuiBasicTableColumn, + EuiIcon, + EuiFlexGroup, + EuiFlexItem, + EuiButtonIcon, + EuiSpacer, +} from '@elastic/eui'; import { RIGHT_ALIGNMENT } from '@elastic/eui/lib/services'; import moment from 'moment'; import { i18n } from '@kbn/i18n'; -import React, { useCallback, useMemo, useState } from 'react'; +import React, { useCallback, useMemo } from 'react'; import { useSet } from 'react-use'; import { TimeRange } from '../../../../../../common/http_api/shared/time_range'; import { formatAnomalyScore, getFriendlyNameForPartitionId, + formatOneDecimalPlace, } from '../../../../../../common/log_analysis'; +import { AnomalyType } from '../../../../../../common/http_api/log_analysis'; import { RowExpansionButton } from '../../../../../components/basic_table'; -import { LogEntryRateResults } from '../../use_log_entry_rate_results'; import { AnomaliesTableExpandedRow } from './expanded_row'; import { AnomalySeverityIndicator } from '../../../../../components/logging/log_analysis_results/anomaly_severity_indicator'; import { useKibanaUiSetting } from '../../../../../utils/use_kibana_ui_setting'; +import { + Page, + FetchNextPage, + FetchPreviousPage, + ChangeSortOptions, + ChangePaginationOptions, + SortOptions, + PaginationOptions, + LogEntryAnomalies, +} from '../../use_log_entry_anomalies_results'; +import { LoadingOverlayWrapper } from '../../../../../components/loading_overlay_wrapper'; interface TableItem { id: string; dataset: string; datasetName: string; anomalyScore: number; - anomalyMessage: string; startTime: number; -} - -interface SortingOptions { - sort: { - field: keyof TableItem; - direction: 'asc' | 'desc'; - }; -} - -interface PaginationOptions { - pageIndex: number; - pageSize: number; - totalItemCount: number; - pageSizeOptions: number[]; - hidePerPageOptions: boolean; + typical: number; + actual: number; + type: AnomalyType; } const anomalyScoreColumnName = i18n.translate( @@ -73,125 +80,78 @@ const datasetColumnName = i18n.translate( } ); -const moreThanExpectedAnomalyMessage = i18n.translate( - 'xpack.infra.logs.analysis.anomaliesTableMoreThanExpectedAnomalyMessage', - { - defaultMessage: 'More log messages in this dataset than expected', - } -); - -const fewerThanExpectedAnomalyMessage = i18n.translate( - 'xpack.infra.logs.analysis.anomaliesTableFewerThanExpectedAnomalyMessage', - { - defaultMessage: 'Fewer log messages in this dataset than expected', - } -); - -const getAnomalyMessage = (actualRate: number, typicalRate: number): string => { - return actualRate < typicalRate - ? fewerThanExpectedAnomalyMessage - : moreThanExpectedAnomalyMessage; -}; - export const AnomaliesTable: React.FunctionComponent<{ - results: LogEntryRateResults; + results: LogEntryAnomalies; setTimeRange: (timeRange: TimeRange) => void; timeRange: TimeRange; - jobId: string; -}> = ({ results, timeRange, setTimeRange, jobId }) => { + changeSortOptions: ChangeSortOptions; + changePaginationOptions: ChangePaginationOptions; + sortOptions: SortOptions; + paginationOptions: PaginationOptions; + page: Page; + fetchNextPage?: FetchNextPage; + fetchPreviousPage?: FetchPreviousPage; + isLoading: boolean; +}> = ({ + results, + timeRange, + setTimeRange, + changeSortOptions, + sortOptions, + changePaginationOptions, + paginationOptions, + fetchNextPage, + fetchPreviousPage, + page, + isLoading, +}) => { const [dateFormat] = useKibanaUiSetting('dateFormat', 'Y-MM-DD HH:mm:ss'); + const tableSortOptions = useMemo(() => { + return { + sort: sortOptions, + }; + }, [sortOptions]); + const tableItems: TableItem[] = useMemo(() => { - return results.anomalies.map((anomaly) => { + return results.map((anomaly) => { return { id: anomaly.id, - dataset: anomaly.partitionId, - datasetName: getFriendlyNameForPartitionId(anomaly.partitionId), + dataset: anomaly.dataset, + datasetName: getFriendlyNameForPartitionId(anomaly.dataset), anomalyScore: formatAnomalyScore(anomaly.anomalyScore), - anomalyMessage: getAnomalyMessage(anomaly.actualLogEntryRate, anomaly.typicalLogEntryRate), startTime: anomaly.startTime, + type: anomaly.type, + typical: anomaly.typical, + actual: anomaly.actual, }; }); }, [results]); const [expandedIds, { add: expandId, remove: collapseId }] = useSet(new Set()); - const expandedDatasetRowContents = useMemo( + const expandedIdsRowContents = useMemo( () => - [...expandedIds].reduce>((aggregatedDatasetRows, id) => { - const anomaly = results.anomalies.find((_anomaly) => _anomaly.id === id); + [...expandedIds].reduce>((aggregatedRows, id) => { + const anomaly = results.find((_anomaly) => _anomaly.id === id); return { - ...aggregatedDatasetRows, + ...aggregatedRows, [id]: anomaly ? ( - + ) : null, }; }, {}), - [expandedIds, results, timeRange, jobId] + [expandedIds, results, timeRange] ); - const [sorting, setSorting] = useState({ - sort: { - field: 'anomalyScore', - direction: 'desc', - }, - }); - - const [_pagination, setPagination] = useState({ - pageIndex: 0, - pageSize: 20, - totalItemCount: results.anomalies.length, - pageSizeOptions: [10, 20, 50], - hidePerPageOptions: false, - }); - - const paginationOptions = useMemo(() => { - return { - ..._pagination, - totalItemCount: results.anomalies.length, - }; - }, [_pagination, results]); - const handleTableChange = useCallback( - ({ page = {}, sort = {} }) => { - const { index, size } = page; - setPagination((currentPagination) => { - return { - ...currentPagination, - pageIndex: index, - pageSize: size, - }; - }); - const { field, direction } = sort; - setSorting({ - sort: { - field, - direction, - }, - }); + ({ sort = {} }) => { + changeSortOptions(sort); }, - [setSorting, setPagination] + [changeSortOptions] ); - const sortedTableItems = useMemo(() => { - let sortedItems: TableItem[] = []; - if (sorting.sort.field === 'datasetName') { - sortedItems = tableItems.sort((a, b) => (a.datasetName > b.datasetName ? 1 : -1)); - } else if (sorting.sort.field === 'anomalyScore') { - sortedItems = tableItems.sort((a, b) => a.anomalyScore - b.anomalyScore); - } else if (sorting.sort.field === 'startTime') { - sortedItems = tableItems.sort((a, b) => a.startTime - b.startTime); - } - - return sorting.sort.direction === 'asc' ? sortedItems : sortedItems.reverse(); - }, [tableItems, sorting]); - - const pageOfItems: TableItem[] = useMemo(() => { - const { pageIndex, pageSize } = paginationOptions; - return sortedTableItems.slice(pageIndex * pageSize, pageIndex * pageSize + pageSize); - }, [paginationOptions, sortedTableItems]); - const columns: Array> = useMemo( () => [ { @@ -204,10 +164,11 @@ export const AnomaliesTable: React.FunctionComponent<{ render: (anomalyScore: number) => , }, { - field: 'anomalyMessage', name: anomalyMessageColumnName, - sortable: false, truncateText: true, + render: (item: TableItem) => ( + + ), }, { field: 'startTime', @@ -240,18 +201,116 @@ export const AnomaliesTable: React.FunctionComponent<{ ], [collapseId, expandId, expandedIds, dateFormat] ); + return ( + <> + + + + + + + ); +}; + +const AnomalyMessage = ({ + actual, + typical, + type, +}: { + actual: number; + typical: number; + type: AnomalyType; +}) => { + const moreThanExpectedAnomalyMessage = i18n.translate( + 'xpack.infra.logs.analysis.anomaliesTableMoreThanExpectedAnomalyMessage', + { + defaultMessage: + 'more log messages in this {type, select, logRate {dataset} logCategory {category}} than expected', + values: { type }, + } + ); + + const fewerThanExpectedAnomalyMessage = i18n.translate( + 'xpack.infra.logs.analysis.anomaliesTableFewerThanExpectedAnomalyMessage', + { + defaultMessage: + 'fewer log messages in this {type, select, logRate {dataset} logCategory {category}} than expected', + values: { type }, + } + ); + + const isMore = actual > typical; + const message = isMore ? moreThanExpectedAnomalyMessage : fewerThanExpectedAnomalyMessage; + const ratio = isMore ? actual / typical : typical / actual; + const icon = isMore ? 'sortUp' : 'sortDown'; + // Edge case scenarios where actual and typical might sit at 0. + const useRatio = ratio !== Infinity; + const ratioMessage = useRatio ? `${formatOneDecimalPlace(ratio)}x` : ''; return ( - + + {`${ratioMessage} ${message}`} + + ); +}; + +const previousPageLabel = i18n.translate( + 'xpack.infra.logs.analysis.anomaliesTablePreviousPageLabel', + { + defaultMessage: 'Previous page', + } +); + +const nextPageLabel = i18n.translate('xpack.infra.logs.analysis.anomaliesTableNextPageLabel', { + defaultMessage: 'Next page', +}); + +const PaginationControls = ({ + fetchPreviousPage, + fetchNextPage, + page, + isLoading, +}: { + fetchPreviousPage?: () => void; + fetchNextPage?: () => void; + page: number; + isLoading: boolean; +}) => { + return ( + + + + + + {page} + + + + + ); }; diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/log_rate/bar_chart.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/log_rate/bar_chart.tsx deleted file mode 100644 index 498a9f88176f8..0000000000000 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/log_rate/bar_chart.tsx +++ /dev/null @@ -1,100 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { - Axis, - BarSeries, - Chart, - niceTimeFormatter, - Settings, - TooltipValue, - BrushEndListener, - LIGHT_THEME, - DARK_THEME, -} from '@elastic/charts'; -import { i18n } from '@kbn/i18n'; -import numeral from '@elastic/numeral'; -import moment from 'moment'; -import React, { useCallback, useMemo } from 'react'; - -import { TimeRange } from '../../../../../../common/http_api/shared/time_range'; -import { useKibanaUiSetting } from '../../../../../utils/use_kibana_ui_setting'; - -export const LogEntryRateBarChart: React.FunctionComponent<{ - setTimeRange: (timeRange: TimeRange) => void; - timeRange: TimeRange; - series: Array<{ group: string; time: number; value: number }>; -}> = ({ series, setTimeRange, timeRange }) => { - const [dateFormat] = useKibanaUiSetting('dateFormat'); - const [isDarkMode] = useKibanaUiSetting('theme:darkMode'); - - const chartDateFormatter = useMemo( - () => niceTimeFormatter([timeRange.startTime, timeRange.endTime]), - [timeRange] - ); - - const tooltipProps = useMemo( - () => ({ - headerFormatter: (tooltipData: TooltipValue) => - moment(tooltipData.value).format(dateFormat || 'Y-MM-DD HH:mm:ss.SSS'), - }), - [dateFormat] - ); - - const handleBrushEnd = useCallback( - ({ x }) => { - if (!x) { - return; - } - const [startTime, endTime] = x; - setTimeRange({ - endTime, - startTime, - }); - }, - [setTimeRange] - ); - - return ( -
- - - numeral(value.toPrecision(3)).format('0[.][00]a')} // https://github.com/adamwdraper/Numeral-js/issues/194 - /> - - - -
- ); -}; diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/log_rate/index.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/log_rate/index.tsx deleted file mode 100644 index 3da025d90119f..0000000000000 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/sections/log_rate/index.tsx +++ /dev/null @@ -1,98 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { EuiEmptyPrompt, EuiLoadingSpinner, EuiSpacer, EuiText, EuiTitle } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import React, { useMemo } from 'react'; - -import { TimeRange } from '../../../../../../common/http_api/shared/time_range'; -import { BetaBadge } from '../../../../../components/beta_badge'; -import { LoadingOverlayWrapper } from '../../../../../components/loading_overlay_wrapper'; -import { LogEntryRateResults as Results } from '../../use_log_entry_rate_results'; -import { getLogEntryRatePartitionedSeries } from '../helpers/data_formatters'; -import { LogEntryRateBarChart } from './bar_chart'; - -export const LogRateResults = ({ - isLoading, - results, - setTimeRange, - timeRange, -}: { - isLoading: boolean; - results: Results | null; - setTimeRange: (timeRange: TimeRange) => void; - timeRange: TimeRange; -}) => { - const logEntryRateSeries = useMemo( - () => (results && results.histogramBuckets ? getLogEntryRatePartitionedSeries(results) : []), - [results] - ); - - return ( - <> - -

- {title} -

-
- }> - {!results || (results && results.histogramBuckets && !results.histogramBuckets.length) ? ( - <> - - - {i18n.translate('xpack.infra.logs.analysis.logRateSectionNoDataTitle', { - defaultMessage: 'There is no data to display.', - })} - - } - titleSize="m" - body={ -

- {i18n.translate('xpack.infra.logs.analysis.logRateSectionNoDataBody', { - defaultMessage: 'You may want to adjust your time range.', - })} -

- } - /> - - ) : ( - <> - -

- - {i18n.translate('xpack.infra.logs.analysis.logRateSectionBucketSpanLabel', { - defaultMessage: 'Bucket span: ', - })} - - {i18n.translate('xpack.infra.logs.analysis.logRateSectionBucketSpanValue', { - defaultMessage: '15 minutes', - })} -

-
- - - )} -
- - ); -}; - -const title = i18n.translate('xpack.infra.logs.analysis.logRateSectionTitle', { - defaultMessage: 'Log entries', -}); - -const loadingAriaLabel = i18n.translate( - 'xpack.infra.logs.analysis.logRateSectionLoadingAriaLabel', - { defaultMessage: 'Loading log rate results' } -); - -const LoadingOverlayContent = () => ; diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/service_calls/get_log_entry_anomalies.ts b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/service_calls/get_log_entry_anomalies.ts new file mode 100644 index 0000000000000..d4a0eaae43ac0 --- /dev/null +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/service_calls/get_log_entry_anomalies.ts @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { npStart } from '../../../../legacy_singletons'; +import { + getLogEntryAnomaliesRequestPayloadRT, + getLogEntryAnomaliesSuccessReponsePayloadRT, + LOG_ANALYSIS_GET_LOG_ENTRY_ANOMALIES_PATH, +} from '../../../../../common/http_api/log_analysis'; +import { decodeOrThrow } from '../../../../../common/runtime_types'; +import { Sort, Pagination } from '../../../../../common/http_api/log_analysis'; + +export const callGetLogEntryAnomaliesAPI = async ( + sourceId: string, + startTime: number, + endTime: number, + sort: Sort, + pagination: Pagination +) => { + const response = await npStart.http.fetch(LOG_ANALYSIS_GET_LOG_ENTRY_ANOMALIES_PATH, { + method: 'POST', + body: JSON.stringify( + getLogEntryAnomaliesRequestPayloadRT.encode({ + data: { + sourceId, + timeRange: { + startTime, + endTime, + }, + sort, + pagination, + }, + }) + ), + }); + + return decodeOrThrow(getLogEntryAnomaliesSuccessReponsePayloadRT)(response); +}; diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/service_calls/get_log_entry_rate_examples.ts b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/service_calls/get_log_entry_examples.ts similarity index 77% rename from x-pack/plugins/infra/public/pages/logs/log_entry_rate/service_calls/get_log_entry_rate_examples.ts rename to x-pack/plugins/infra/public/pages/logs/log_entry_rate/service_calls/get_log_entry_examples.ts index d3b30da72af96..a125b53f9e635 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/service_calls/get_log_entry_rate_examples.ts +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/service_calls/get_log_entry_examples.ts @@ -10,23 +10,24 @@ import { identity } from 'fp-ts/lib/function'; import { npStart } from '../../../../legacy_singletons'; import { - getLogEntryRateExamplesRequestPayloadRT, - getLogEntryRateExamplesSuccessReponsePayloadRT, + getLogEntryExamplesRequestPayloadRT, + getLogEntryExamplesSuccessReponsePayloadRT, LOG_ANALYSIS_GET_LOG_ENTRY_RATE_EXAMPLES_PATH, } from '../../../../../common/http_api/log_analysis'; import { createPlainError, throwErrors } from '../../../../../common/runtime_types'; -export const callGetLogEntryRateExamplesAPI = async ( +export const callGetLogEntryExamplesAPI = async ( sourceId: string, startTime: number, endTime: number, dataset: string, - exampleCount: number + exampleCount: number, + categoryId?: string ) => { const response = await npStart.http.fetch(LOG_ANALYSIS_GET_LOG_ENTRY_RATE_EXAMPLES_PATH, { method: 'POST', body: JSON.stringify( - getLogEntryRateExamplesRequestPayloadRT.encode({ + getLogEntryExamplesRequestPayloadRT.encode({ data: { dataset, exampleCount, @@ -35,13 +36,14 @@ export const callGetLogEntryRateExamplesAPI = async ( startTime, endTime, }, + categoryId, }, }) ), }); return pipe( - getLogEntryRateExamplesSuccessReponsePayloadRT.decode(response), + getLogEntryExamplesSuccessReponsePayloadRT.decode(response), fold(throwErrors(createPlainError), identity) ); }; diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/use_log_entry_anomalies_results.ts b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/use_log_entry_anomalies_results.ts new file mode 100644 index 0000000000000..cadb4c420c133 --- /dev/null +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/use_log_entry_anomalies_results.ts @@ -0,0 +1,262 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { useMemo, useState, useCallback, useEffect, useReducer } from 'react'; + +import { LogEntryAnomaly } from '../../../../common/http_api'; +import { useTrackedPromise } from '../../../utils/use_tracked_promise'; +import { callGetLogEntryAnomaliesAPI } from './service_calls/get_log_entry_anomalies'; +import { Sort, Pagination, PaginationCursor } from '../../../../common/http_api/log_analysis'; + +export type SortOptions = Sort; +export type PaginationOptions = Pick; +export type Page = number; +export type FetchNextPage = () => void; +export type FetchPreviousPage = () => void; +export type ChangeSortOptions = (sortOptions: Sort) => void; +export type ChangePaginationOptions = (paginationOptions: PaginationOptions) => void; +export type LogEntryAnomalies = LogEntryAnomaly[]; +interface PaginationCursors { + previousPageCursor: PaginationCursor; + nextPageCursor: PaginationCursor; +} + +interface ReducerState { + page: number; + lastReceivedCursors: PaginationCursors | undefined; + paginationCursor: Pagination['cursor'] | undefined; + hasNextPage: boolean; + paginationOptions: PaginationOptions; + sortOptions: Sort; + timeRange: { + start: number; + end: number; + }; +} + +type ReducerStateDefaults = Pick< + ReducerState, + 'page' | 'lastReceivedCursors' | 'paginationCursor' | 'hasNextPage' +>; + +type ReducerAction = + | { type: 'changePaginationOptions'; payload: { paginationOptions: PaginationOptions } } + | { type: 'changeSortOptions'; payload: { sortOptions: Sort } } + | { type: 'fetchNextPage' } + | { type: 'fetchPreviousPage' } + | { type: 'changeHasNextPage'; payload: { hasNextPage: boolean } } + | { type: 'changeLastReceivedCursors'; payload: { lastReceivedCursors: PaginationCursors } } + | { type: 'changeTimeRange'; payload: { timeRange: { start: number; end: number } } }; + +const stateReducer = (state: ReducerState, action: ReducerAction): ReducerState => { + const resetPagination = { + page: 1, + paginationCursor: undefined, + }; + switch (action.type) { + case 'changePaginationOptions': + return { + ...state, + ...resetPagination, + ...action.payload, + }; + case 'changeSortOptions': + return { + ...state, + ...resetPagination, + ...action.payload, + }; + case 'changeHasNextPage': + return { + ...state, + ...action.payload, + }; + case 'changeLastReceivedCursors': + return { + ...state, + ...action.payload, + }; + case 'fetchNextPage': + return state.lastReceivedCursors + ? { + ...state, + page: state.page + 1, + paginationCursor: { searchAfter: state.lastReceivedCursors.nextPageCursor }, + } + : state; + case 'fetchPreviousPage': + return state.lastReceivedCursors + ? { + ...state, + page: state.page - 1, + paginationCursor: { searchBefore: state.lastReceivedCursors.previousPageCursor }, + } + : state; + case 'changeTimeRange': + return { + ...state, + ...resetPagination, + ...action.payload, + }; + default: + return state; + } +}; + +const STATE_DEFAULTS: ReducerStateDefaults = { + // NOTE: This piece of state is purely for the client side, it could be extracted out of the hook. + page: 1, + // Cursor from the last request + lastReceivedCursors: undefined, + // Cursor to use for the next request. For the first request, and therefore not paging, this will be undefined. + paginationCursor: undefined, + hasNextPage: false, +}; + +export const useLogEntryAnomaliesResults = ({ + endTime, + startTime, + sourceId, + defaultSortOptions, + defaultPaginationOptions, +}: { + endTime: number; + startTime: number; + sourceId: string; + defaultSortOptions: Sort; + defaultPaginationOptions: Pick; +}) => { + const initStateReducer = (stateDefaults: ReducerStateDefaults): ReducerState => { + return { + ...stateDefaults, + paginationOptions: defaultPaginationOptions, + sortOptions: defaultSortOptions, + timeRange: { + start: startTime, + end: endTime, + }, + }; + }; + + const [reducerState, dispatch] = useReducer(stateReducer, STATE_DEFAULTS, initStateReducer); + + const [logEntryAnomalies, setLogEntryAnomalies] = useState([]); + + const [getLogEntryAnomaliesRequest, getLogEntryAnomalies] = useTrackedPromise( + { + cancelPreviousOn: 'creation', + createPromise: async () => { + const { + timeRange: { start: queryStartTime, end: queryEndTime }, + sortOptions, + paginationOptions, + paginationCursor, + } = reducerState; + return await callGetLogEntryAnomaliesAPI( + sourceId, + queryStartTime, + queryEndTime, + sortOptions, + { + ...paginationOptions, + cursor: paginationCursor, + } + ); + }, + onResolve: ({ data: { anomalies, paginationCursors: requestCursors, hasMoreEntries } }) => { + const { paginationCursor } = reducerState; + if (requestCursors) { + dispatch({ + type: 'changeLastReceivedCursors', + payload: { lastReceivedCursors: requestCursors }, + }); + } + // Check if we have more "next" entries. "Page" covers the "previous" scenario, + // since we need to know the page we're on anyway. + if (!paginationCursor || (paginationCursor && 'searchAfter' in paginationCursor)) { + dispatch({ type: 'changeHasNextPage', payload: { hasNextPage: hasMoreEntries } }); + } else if (paginationCursor && 'searchBefore' in paginationCursor) { + // We've requested a previous page, therefore there is a next page. + dispatch({ type: 'changeHasNextPage', payload: { hasNextPage: true } }); + } + setLogEntryAnomalies(anomalies); + }, + }, + [ + sourceId, + dispatch, + reducerState.timeRange, + reducerState.sortOptions, + reducerState.paginationOptions, + reducerState.paginationCursor, + ] + ); + + const changeSortOptions = useCallback( + (nextSortOptions: Sort) => { + dispatch({ type: 'changeSortOptions', payload: { sortOptions: nextSortOptions } }); + }, + [dispatch] + ); + + const changePaginationOptions = useCallback( + (nextPaginationOptions: PaginationOptions) => { + dispatch({ + type: 'changePaginationOptions', + payload: { paginationOptions: nextPaginationOptions }, + }); + }, + [dispatch] + ); + + // Time range has changed + useEffect(() => { + dispatch({ + type: 'changeTimeRange', + payload: { timeRange: { start: startTime, end: endTime } }, + }); + }, [startTime, endTime]); + + useEffect(() => { + getLogEntryAnomalies(); + }, [getLogEntryAnomalies]); + + const handleFetchNextPage = useCallback(() => { + if (reducerState.lastReceivedCursors) { + dispatch({ type: 'fetchNextPage' }); + } + }, [dispatch, reducerState]); + + const handleFetchPreviousPage = useCallback(() => { + if (reducerState.lastReceivedCursors) { + dispatch({ type: 'fetchPreviousPage' }); + } + }, [dispatch, reducerState]); + + const isLoadingLogEntryAnomalies = useMemo( + () => getLogEntryAnomaliesRequest.state === 'pending', + [getLogEntryAnomaliesRequest.state] + ); + + const hasFailedLoadingLogEntryAnomalies = useMemo( + () => getLogEntryAnomaliesRequest.state === 'rejected', + [getLogEntryAnomaliesRequest.state] + ); + + return { + logEntryAnomalies, + getLogEntryAnomalies, + isLoadingLogEntryAnomalies, + hasFailedLoadingLogEntryAnomalies, + changeSortOptions, + sortOptions: reducerState.sortOptions, + changePaginationOptions, + paginationOptions: reducerState.paginationOptions, + fetchPreviousPage: reducerState.page > 1 ? handleFetchPreviousPage : undefined, + fetchNextPage: reducerState.hasNextPage ? handleFetchNextPage : undefined, + page: reducerState.page, + }; +}; diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/use_log_entry_examples.ts b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/use_log_entry_examples.ts new file mode 100644 index 0000000000000..fae5bd200a415 --- /dev/null +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/use_log_entry_examples.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { useMemo, useState } from 'react'; + +import { LogEntryExample } from '../../../../common/http_api'; +import { useTrackedPromise } from '../../../utils/use_tracked_promise'; +import { callGetLogEntryExamplesAPI } from './service_calls/get_log_entry_examples'; + +export const useLogEntryExamples = ({ + dataset, + endTime, + exampleCount, + sourceId, + startTime, + categoryId, +}: { + dataset: string; + endTime: number; + exampleCount: number; + sourceId: string; + startTime: number; + categoryId?: string; +}) => { + const [logEntryExamples, setLogEntryExamples] = useState([]); + + const [getLogEntryExamplesRequest, getLogEntryExamples] = useTrackedPromise( + { + cancelPreviousOn: 'creation', + createPromise: async () => { + return await callGetLogEntryExamplesAPI( + sourceId, + startTime, + endTime, + dataset, + exampleCount, + categoryId + ); + }, + onResolve: ({ data: { examples } }) => { + setLogEntryExamples(examples); + }, + }, + [dataset, endTime, exampleCount, sourceId, startTime] + ); + + const isLoadingLogEntryExamples = useMemo(() => getLogEntryExamplesRequest.state === 'pending', [ + getLogEntryExamplesRequest.state, + ]); + + const hasFailedLoadingLogEntryExamples = useMemo( + () => getLogEntryExamplesRequest.state === 'rejected', + [getLogEntryExamplesRequest.state] + ); + + return { + getLogEntryExamples, + hasFailedLoadingLogEntryExamples, + isLoadingLogEntryExamples, + logEntryExamples, + }; +}; diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/use_log_entry_rate_examples.ts b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/use_log_entry_rate_examples.ts deleted file mode 100644 index 12bcdb2a4b4d6..0000000000000 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/use_log_entry_rate_examples.ts +++ /dev/null @@ -1,63 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { useMemo, useState } from 'react'; - -import { LogEntryRateExample } from '../../../../common/http_api'; -import { useTrackedPromise } from '../../../utils/use_tracked_promise'; -import { callGetLogEntryRateExamplesAPI } from './service_calls/get_log_entry_rate_examples'; - -export const useLogEntryRateExamples = ({ - dataset, - endTime, - exampleCount, - sourceId, - startTime, -}: { - dataset: string; - endTime: number; - exampleCount: number; - sourceId: string; - startTime: number; -}) => { - const [logEntryRateExamples, setLogEntryRateExamples] = useState([]); - - const [getLogEntryRateExamplesRequest, getLogEntryRateExamples] = useTrackedPromise( - { - cancelPreviousOn: 'creation', - createPromise: async () => { - return await callGetLogEntryRateExamplesAPI( - sourceId, - startTime, - endTime, - dataset, - exampleCount - ); - }, - onResolve: ({ data: { examples } }) => { - setLogEntryRateExamples(examples); - }, - }, - [dataset, endTime, exampleCount, sourceId, startTime] - ); - - const isLoadingLogEntryRateExamples = useMemo( - () => getLogEntryRateExamplesRequest.state === 'pending', - [getLogEntryRateExamplesRequest.state] - ); - - const hasFailedLoadingLogEntryRateExamples = useMemo( - () => getLogEntryRateExamplesRequest.state === 'rejected', - [getLogEntryRateExamplesRequest.state] - ); - - return { - getLogEntryRateExamples, - hasFailedLoadingLogEntryRateExamples, - isLoadingLogEntryRateExamples, - logEntryRateExamples, - }; -}; diff --git a/x-pack/plugins/infra/server/infra_server.ts b/x-pack/plugins/infra/server/infra_server.ts index 8af37a36ef745..6596e07ebaca5 100644 --- a/x-pack/plugins/infra/server/infra_server.ts +++ b/x-pack/plugins/infra/server/infra_server.ts @@ -15,9 +15,10 @@ import { initGetLogEntryCategoryDatasetsRoute, initGetLogEntryCategoryExamplesRoute, initGetLogEntryRateRoute, - initGetLogEntryRateExamplesRoute, + initGetLogEntryExamplesRoute, initValidateLogAnalysisDatasetsRoute, initValidateLogAnalysisIndicesRoute, + initGetLogEntryAnomaliesRoute, } from './routes/log_analysis'; import { initMetricExplorerRoute } from './routes/metrics_explorer'; import { initMetadataRoute } from './routes/metadata'; @@ -51,13 +52,14 @@ export const initInfraServer = (libs: InfraBackendLibs) => { initGetLogEntryCategoryDatasetsRoute(libs); initGetLogEntryCategoryExamplesRoute(libs); initGetLogEntryRateRoute(libs); + initGetLogEntryAnomaliesRoute(libs); initSnapshotRoute(libs); initNodeDetailsRoute(libs); initSourceRoute(libs); initValidateLogAnalysisDatasetsRoute(libs); initValidateLogAnalysisIndicesRoute(libs); initLogEntriesRoute(libs); - initGetLogEntryRateExamplesRoute(libs); + initGetLogEntryExamplesRoute(libs); initLogEntriesHighlightsRoute(libs); initLogEntriesSummaryRoute(libs); initLogEntriesSummaryHighlightsRoute(libs); diff --git a/x-pack/plugins/infra/server/lib/log_analysis/common.ts b/x-pack/plugins/infra/server/lib/log_analysis/common.ts new file mode 100644 index 0000000000000..0c0b0a0f19982 --- /dev/null +++ b/x-pack/plugins/infra/server/lib/log_analysis/common.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import type { MlAnomalyDetectors } from '../../types'; +import { startTracingSpan } from '../../../common/performance_tracing'; +import { NoLogAnalysisMlJobError } from './errors'; + +export async function fetchMlJob(mlAnomalyDetectors: MlAnomalyDetectors, jobId: string) { + const finalizeMlGetJobSpan = startTracingSpan('Fetch ml job from ES'); + const { + jobs: [mlJob], + } = await mlAnomalyDetectors.jobs(jobId); + + const mlGetJobSpan = finalizeMlGetJobSpan(); + + if (mlJob == null) { + throw new NoLogAnalysisMlJobError(`Failed to find ml job ${jobId}.`); + } + + return { + mlJob, + timing: { + spans: [mlGetJobSpan], + }, + }; +} diff --git a/x-pack/plugins/infra/server/lib/log_analysis/errors.ts b/x-pack/plugins/infra/server/lib/log_analysis/errors.ts index e07126416f4ce..09fee8844fbc5 100644 --- a/x-pack/plugins/infra/server/lib/log_analysis/errors.ts +++ b/x-pack/plugins/infra/server/lib/log_analysis/errors.ts @@ -33,3 +33,10 @@ export class UnknownCategoryError extends Error { Object.setPrototypeOf(this, new.target.prototype); } } + +export class InsufficientAnomalyMlJobsConfigured extends Error { + constructor(message?: string) { + super(message); + Object.setPrototypeOf(this, new.target.prototype); + } +} diff --git a/x-pack/plugins/infra/server/lib/log_analysis/index.ts b/x-pack/plugins/infra/server/lib/log_analysis/index.ts index 44c2bafce4194..c9a176be0a28f 100644 --- a/x-pack/plugins/infra/server/lib/log_analysis/index.ts +++ b/x-pack/plugins/infra/server/lib/log_analysis/index.ts @@ -7,3 +7,4 @@ export * from './errors'; export * from './log_entry_categories_analysis'; export * from './log_entry_rate_analysis'; +export * from './log_entry_anomalies'; diff --git a/x-pack/plugins/infra/server/lib/log_analysis/log_entry_anomalies.ts b/x-pack/plugins/infra/server/lib/log_analysis/log_entry_anomalies.ts new file mode 100644 index 0000000000000..12ae516564d66 --- /dev/null +++ b/x-pack/plugins/infra/server/lib/log_analysis/log_entry_anomalies.ts @@ -0,0 +1,398 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { RequestHandlerContext } from 'src/core/server'; +import { InfraRequestHandlerContext } from '../../types'; +import { TracingSpan, startTracingSpan } from '../../../common/performance_tracing'; +import { fetchMlJob } from './common'; +import { + getJobId, + logEntryCategoriesJobTypes, + logEntryRateJobTypes, + jobCustomSettingsRT, +} from '../../../common/log_analysis'; +import { Sort, Pagination } from '../../../common/http_api/log_analysis'; +import type { MlSystem } from '../../types'; +import { createLogEntryAnomaliesQuery, logEntryAnomaliesResponseRT } from './queries'; +import { + InsufficientAnomalyMlJobsConfigured, + InsufficientLogAnalysisMlJobConfigurationError, + UnknownCategoryError, +} from './errors'; +import { decodeOrThrow } from '../../../common/runtime_types'; +import { + createLogEntryExamplesQuery, + logEntryExamplesResponseRT, +} from './queries/log_entry_examples'; +import { InfraSource } from '../sources'; +import { KibanaFramework } from '../adapters/framework/kibana_framework_adapter'; +import { fetchLogEntryCategories } from './log_entry_categories_analysis'; + +interface MappedAnomalyHit { + id: string; + anomalyScore: number; + dataset: string; + typical: number; + actual: number; + jobId: string; + startTime: number; + duration: number; + categoryId?: string; +} + +export async function getLogEntryAnomalies( + context: RequestHandlerContext & { infra: Required }, + sourceId: string, + startTime: number, + endTime: number, + sort: Sort, + pagination: Pagination +) { + const finalizeLogEntryAnomaliesSpan = startTracingSpan('get log entry anomalies'); + + const logRateJobId = getJobId(context.infra.spaceId, sourceId, logEntryRateJobTypes[0]); + const logCategoriesJobId = getJobId( + context.infra.spaceId, + sourceId, + logEntryCategoriesJobTypes[0] + ); + + const jobIds: string[] = []; + let jobSpans: TracingSpan[] = []; + + try { + const { + timing: { spans }, + } = await fetchMlJob(context.infra.mlAnomalyDetectors, logRateJobId); + jobIds.push(logRateJobId); + jobSpans = [...jobSpans, ...spans]; + } catch (e) { + // Job wasn't found + } + + try { + const { + timing: { spans }, + } = await fetchMlJob(context.infra.mlAnomalyDetectors, logCategoriesJobId); + jobIds.push(logCategoriesJobId); + jobSpans = [...jobSpans, ...spans]; + } catch (e) { + // Job wasn't found + } + + if (jobIds.length === 0) { + throw new InsufficientAnomalyMlJobsConfigured( + 'Log rate or categorisation ML jobs need to be configured to search anomalies' + ); + } + + const { + anomalies, + paginationCursors, + hasMoreEntries, + timing: { spans: fetchLogEntryAnomaliesSpans }, + } = await fetchLogEntryAnomalies( + context.infra.mlSystem, + jobIds, + startTime, + endTime, + sort, + pagination + ); + + const data = anomalies.map((anomaly) => { + const { jobId } = anomaly; + + if (jobId === logRateJobId) { + return parseLogRateAnomalyResult(anomaly, logRateJobId); + } else { + return parseCategoryAnomalyResult(anomaly, logCategoriesJobId); + } + }); + + const logEntryAnomaliesSpan = finalizeLogEntryAnomaliesSpan(); + + return { + data, + paginationCursors, + hasMoreEntries, + timing: { + spans: [logEntryAnomaliesSpan, ...jobSpans, ...fetchLogEntryAnomaliesSpans], + }, + }; +} + +const parseLogRateAnomalyResult = (anomaly: MappedAnomalyHit, jobId: string) => { + const { + id, + anomalyScore, + dataset, + typical, + actual, + duration, + startTime: anomalyStartTime, + } = anomaly; + + return { + id, + anomalyScore, + dataset, + typical, + actual, + duration, + startTime: anomalyStartTime, + type: 'logRate' as const, + jobId, + }; +}; + +const parseCategoryAnomalyResult = (anomaly: MappedAnomalyHit, jobId: string) => { + const { + id, + anomalyScore, + dataset, + typical, + actual, + duration, + startTime: anomalyStartTime, + categoryId, + } = anomaly; + + return { + id, + anomalyScore, + dataset, + typical, + actual, + duration, + startTime: anomalyStartTime, + categoryId, + type: 'logCategory' as const, + jobId, + }; +}; + +async function fetchLogEntryAnomalies( + mlSystem: MlSystem, + jobIds: string[], + startTime: number, + endTime: number, + sort: Sort, + pagination: Pagination +) { + // We'll request 1 extra entry on top of our pageSize to determine if there are + // more entries to be fetched. This avoids scenarios where the client side can't + // determine if entries.length === pageSize actually means there are more entries / next page + // or not. + const expandedPagination = { ...pagination, pageSize: pagination.pageSize + 1 }; + + const finalizeFetchLogEntryAnomaliesSpan = startTracingSpan('fetch log entry anomalies'); + + const results = decodeOrThrow(logEntryAnomaliesResponseRT)( + await mlSystem.mlAnomalySearch( + createLogEntryAnomaliesQuery(jobIds, startTime, endTime, sort, expandedPagination) + ) + ); + + const { + hits: { hits }, + } = results; + const hasMoreEntries = hits.length > pagination.pageSize; + + // An extra entry was found and hasMoreEntries has been determined, the extra entry can be removed. + if (hasMoreEntries) { + hits.pop(); + } + + // To "search_before" the sort order will have been reversed for ES. + // The results are now reversed back, to match the requested sort. + if (pagination.cursor && 'searchBefore' in pagination.cursor) { + hits.reverse(); + } + + const paginationCursors = + hits.length > 0 + ? { + previousPageCursor: hits[0].sort, + nextPageCursor: hits[hits.length - 1].sort, + } + : undefined; + + const anomalies = hits.map((result) => { + const { + job_id, + record_score: anomalyScore, + typical, + actual, + partition_field_value: dataset, + bucket_span: duration, + timestamp: anomalyStartTime, + by_field_value: categoryId, + } = result._source; + + return { + id: result._id, + anomalyScore, + dataset, + typical: typical[0], + actual: actual[0], + jobId: job_id, + startTime: anomalyStartTime, + duration: duration * 1000, + categoryId, + }; + }); + + const fetchLogEntryAnomaliesSpan = finalizeFetchLogEntryAnomaliesSpan(); + + return { + anomalies, + paginationCursors, + hasMoreEntries, + timing: { + spans: [fetchLogEntryAnomaliesSpan], + }, + }; +} + +export async function getLogEntryExamples( + context: RequestHandlerContext & { infra: Required }, + sourceId: string, + startTime: number, + endTime: number, + dataset: string, + exampleCount: number, + sourceConfiguration: InfraSource, + callWithRequest: KibanaFramework['callWithRequest'], + categoryId?: string +) { + const finalizeLogEntryExamplesSpan = startTracingSpan('get log entry rate example log entries'); + + const jobId = getJobId( + context.infra.spaceId, + sourceId, + categoryId != null ? logEntryCategoriesJobTypes[0] : logEntryRateJobTypes[0] + ); + + const { + mlJob, + timing: { spans: fetchMlJobSpans }, + } = await fetchMlJob(context.infra.mlAnomalyDetectors, jobId); + + const customSettings = decodeOrThrow(jobCustomSettingsRT)(mlJob.custom_settings); + const indices = customSettings?.logs_source_config?.indexPattern; + const timestampField = customSettings?.logs_source_config?.timestampField; + const tiebreakerField = sourceConfiguration.configuration.fields.tiebreaker; + + if (indices == null || timestampField == null) { + throw new InsufficientLogAnalysisMlJobConfigurationError( + `Failed to find index configuration for ml job ${jobId}` + ); + } + + const { + examples, + timing: { spans: fetchLogEntryExamplesSpans }, + } = await fetchLogEntryExamples( + context, + sourceId, + indices, + timestampField, + tiebreakerField, + startTime, + endTime, + dataset, + exampleCount, + callWithRequest, + categoryId + ); + + const logEntryExamplesSpan = finalizeLogEntryExamplesSpan(); + + return { + data: examples, + timing: { + spans: [logEntryExamplesSpan, ...fetchMlJobSpans, ...fetchLogEntryExamplesSpans], + }, + }; +} + +export async function fetchLogEntryExamples( + context: RequestHandlerContext & { infra: Required }, + sourceId: string, + indices: string, + timestampField: string, + tiebreakerField: string, + startTime: number, + endTime: number, + dataset: string, + exampleCount: number, + callWithRequest: KibanaFramework['callWithRequest'], + categoryId?: string +) { + const finalizeEsSearchSpan = startTracingSpan('Fetch log rate examples from ES'); + + let categoryQuery: string | undefined; + + // Examples should be further scoped to a specific ML category + if (categoryId) { + const parsedCategoryId = parseInt(categoryId, 10); + + const logEntryCategoriesCountJobId = getJobId( + context.infra.spaceId, + sourceId, + logEntryCategoriesJobTypes[0] + ); + + const { logEntryCategoriesById } = await fetchLogEntryCategories( + context, + logEntryCategoriesCountJobId, + [parsedCategoryId] + ); + + const category = logEntryCategoriesById[parsedCategoryId]; + + if (category == null) { + throw new UnknownCategoryError(parsedCategoryId); + } + + categoryQuery = category._source.terms; + } + + const { + hits: { hits }, + } = decodeOrThrow(logEntryExamplesResponseRT)( + await callWithRequest( + context, + 'search', + createLogEntryExamplesQuery( + indices, + timestampField, + tiebreakerField, + startTime, + endTime, + dataset, + exampleCount, + categoryQuery + ) + ) + ); + + const esSearchSpan = finalizeEsSearchSpan(); + + return { + examples: hits.map((hit) => ({ + id: hit._id, + dataset: hit._source.event?.dataset ?? '', + message: hit._source.message ?? '', + timestamp: hit.sort[0], + tiebreaker: hit.sort[1], + })), + timing: { + spans: [esSearchSpan], + }, + }; +} diff --git a/x-pack/plugins/infra/server/lib/log_analysis/log_entry_categories_analysis.ts b/x-pack/plugins/infra/server/lib/log_analysis/log_entry_categories_analysis.ts index 4f244d724405e..6d00ba56e0e66 100644 --- a/x-pack/plugins/infra/server/lib/log_analysis/log_entry_categories_analysis.ts +++ b/x-pack/plugins/infra/server/lib/log_analysis/log_entry_categories_analysis.ts @@ -17,7 +17,6 @@ import { decodeOrThrow } from '../../../common/runtime_types'; import type { MlAnomalyDetectors, MlSystem } from '../../types'; import { InsufficientLogAnalysisMlJobConfigurationError, - NoLogAnalysisMlJobError, NoLogAnalysisResultsIndexError, UnknownCategoryError, } from './errors'; @@ -45,6 +44,7 @@ import { topLogEntryCategoriesResponseRT, } from './queries/top_log_entry_categories'; import { InfraSource } from '../sources'; +import { fetchMlJob } from './common'; const COMPOSITE_AGGREGATION_BATCH_SIZE = 1000; @@ -213,7 +213,7 @@ export async function getLogEntryCategoryExamples( const { mlJob, timing: { spans: fetchMlJobSpans }, - } = await fetchMlJob(context, logEntryCategoriesCountJobId); + } = await fetchMlJob(context.infra.mlAnomalyDetectors, logEntryCategoriesCountJobId); const customSettings = decodeOrThrow(jobCustomSettingsRT)(mlJob.custom_settings); const indices = customSettings?.logs_source_config?.indexPattern; @@ -330,7 +330,7 @@ async function fetchTopLogEntryCategories( }; } -async function fetchLogEntryCategories( +export async function fetchLogEntryCategories( context: { infra: { mlSystem: MlSystem } }, logEntryCategoriesCountJobId: string, categoryIds: number[] @@ -452,30 +452,6 @@ async function fetchTopLogEntryCategoryHistograms( }; } -async function fetchMlJob( - context: { infra: { mlAnomalyDetectors: MlAnomalyDetectors } }, - logEntryCategoriesCountJobId: string -) { - const finalizeMlGetJobSpan = startTracingSpan('Fetch ml job from ES'); - - const { - jobs: [mlJob], - } = await context.infra.mlAnomalyDetectors.jobs(logEntryCategoriesCountJobId); - - const mlGetJobSpan = finalizeMlGetJobSpan(); - - if (mlJob == null) { - throw new NoLogAnalysisMlJobError(`Failed to find ml job ${logEntryCategoriesCountJobId}.`); - } - - return { - mlJob, - timing: { - spans: [mlGetJobSpan], - }, - }; -} - async function fetchLogEntryCategoryExamples( requestContext: { core: { elasticsearch: { legacy: { client: ILegacyScopedClusterClient } } } }, indices: string, diff --git a/x-pack/plugins/infra/server/lib/log_analysis/log_entry_rate_analysis.ts b/x-pack/plugins/infra/server/lib/log_analysis/log_entry_rate_analysis.ts index 290cf03b67365..0323980dcd013 100644 --- a/x-pack/plugins/infra/server/lib/log_analysis/log_entry_rate_analysis.ts +++ b/x-pack/plugins/infra/server/lib/log_analysis/log_entry_rate_analysis.ts @@ -7,7 +7,6 @@ import { pipe } from 'fp-ts/lib/pipeable'; import { map, fold } from 'fp-ts/lib/Either'; import { identity } from 'fp-ts/lib/function'; -import { RequestHandlerContext } from 'src/core/server'; import { throwErrors, createPlainError } from '../../../common/runtime_types'; import { logRateModelPlotResponseRT, @@ -15,22 +14,9 @@ import { LogRateModelPlotBucket, CompositeTimestampPartitionKey, } from './queries'; -import { startTracingSpan } from '../../../common/performance_tracing'; -import { decodeOrThrow } from '../../../common/runtime_types'; -import { getJobId, jobCustomSettingsRT } from '../../../common/log_analysis'; -import { - createLogEntryRateExamplesQuery, - logEntryRateExamplesResponseRT, -} from './queries/log_entry_rate_examples'; -import { - InsufficientLogAnalysisMlJobConfigurationError, - NoLogAnalysisMlJobError, - NoLogAnalysisResultsIndexError, -} from './errors'; -import { InfraSource } from '../sources'; +import { getJobId } from '../../../common/log_analysis'; +import { NoLogAnalysisResultsIndexError } from './errors'; import type { MlSystem } from '../../types'; -import { InfraRequestHandlerContext } from '../../types'; -import { KibanaFramework } from '../adapters/framework/kibana_framework_adapter'; const COMPOSITE_AGGREGATION_BATCH_SIZE = 1000; @@ -143,130 +129,3 @@ export async function getLogEntryRateBuckets( } }, []); } - -export async function getLogEntryRateExamples( - context: RequestHandlerContext & { infra: Required }, - sourceId: string, - startTime: number, - endTime: number, - dataset: string, - exampleCount: number, - sourceConfiguration: InfraSource, - callWithRequest: KibanaFramework['callWithRequest'] -) { - const finalizeLogEntryRateExamplesSpan = startTracingSpan( - 'get log entry rate example log entries' - ); - - const jobId = getJobId(context.infra.spaceId, sourceId, 'log-entry-rate'); - - const { - mlJob, - timing: { spans: fetchMlJobSpans }, - } = await fetchMlJob(context, jobId); - - const customSettings = decodeOrThrow(jobCustomSettingsRT)(mlJob.custom_settings); - const indices = customSettings?.logs_source_config?.indexPattern; - const timestampField = customSettings?.logs_source_config?.timestampField; - const tiebreakerField = sourceConfiguration.configuration.fields.tiebreaker; - - if (indices == null || timestampField == null) { - throw new InsufficientLogAnalysisMlJobConfigurationError( - `Failed to find index configuration for ml job ${jobId}` - ); - } - - const { - examples, - timing: { spans: fetchLogEntryRateExamplesSpans }, - } = await fetchLogEntryRateExamples( - context, - indices, - timestampField, - tiebreakerField, - startTime, - endTime, - dataset, - exampleCount, - callWithRequest - ); - - const logEntryRateExamplesSpan = finalizeLogEntryRateExamplesSpan(); - - return { - data: examples, - timing: { - spans: [logEntryRateExamplesSpan, ...fetchMlJobSpans, ...fetchLogEntryRateExamplesSpans], - }, - }; -} - -export async function fetchLogEntryRateExamples( - context: RequestHandlerContext & { infra: Required }, - indices: string, - timestampField: string, - tiebreakerField: string, - startTime: number, - endTime: number, - dataset: string, - exampleCount: number, - callWithRequest: KibanaFramework['callWithRequest'] -) { - const finalizeEsSearchSpan = startTracingSpan('Fetch log rate examples from ES'); - - const { - hits: { hits }, - } = decodeOrThrow(logEntryRateExamplesResponseRT)( - await callWithRequest( - context, - 'search', - createLogEntryRateExamplesQuery( - indices, - timestampField, - tiebreakerField, - startTime, - endTime, - dataset, - exampleCount - ) - ) - ); - - const esSearchSpan = finalizeEsSearchSpan(); - - return { - examples: hits.map((hit) => ({ - id: hit._id, - dataset, - message: hit._source.message ?? '', - timestamp: hit.sort[0], - tiebreaker: hit.sort[1], - })), - timing: { - spans: [esSearchSpan], - }, - }; -} - -async function fetchMlJob( - context: RequestHandlerContext & { infra: Required }, - logEntryRateJobId: string -) { - const finalizeMlGetJobSpan = startTracingSpan('Fetch ml job from ES'); - const { - jobs: [mlJob], - } = await context.infra.mlAnomalyDetectors.jobs(logEntryRateJobId); - - const mlGetJobSpan = finalizeMlGetJobSpan(); - - if (mlJob == null) { - throw new NoLogAnalysisMlJobError(`Failed to find ml job ${logEntryRateJobId}.`); - } - - return { - mlJob, - timing: { - spans: [mlGetJobSpan], - }, - }; -} diff --git a/x-pack/plugins/infra/server/lib/log_analysis/queries/common.ts b/x-pack/plugins/infra/server/lib/log_analysis/queries/common.ts index eacf29b303db0..87394028095de 100644 --- a/x-pack/plugins/infra/server/lib/log_analysis/queries/common.ts +++ b/x-pack/plugins/infra/server/lib/log_analysis/queries/common.ts @@ -21,6 +21,14 @@ export const createJobIdFilters = (jobId: string) => [ }, ]; +export const createJobIdsFilters = (jobIds: string[]) => [ + { + terms: { + job_id: jobIds, + }, + }, +]; + export const createTimeRangeFilters = (startTime: number, endTime: number) => [ { range: { diff --git a/x-pack/plugins/infra/server/lib/log_analysis/queries/index.ts b/x-pack/plugins/infra/server/lib/log_analysis/queries/index.ts index 8c470acbf02fb..792c5bf98b538 100644 --- a/x-pack/plugins/infra/server/lib/log_analysis/queries/index.ts +++ b/x-pack/plugins/infra/server/lib/log_analysis/queries/index.ts @@ -6,3 +6,4 @@ export * from './log_entry_rate'; export * from './top_log_entry_categories'; +export * from './log_entry_anomalies'; diff --git a/x-pack/plugins/infra/server/lib/log_analysis/queries/log_entry_anomalies.ts b/x-pack/plugins/infra/server/lib/log_analysis/queries/log_entry_anomalies.ts new file mode 100644 index 0000000000000..fc72776ea5cac --- /dev/null +++ b/x-pack/plugins/infra/server/lib/log_analysis/queries/log_entry_anomalies.ts @@ -0,0 +1,128 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as rt from 'io-ts'; +import { commonSearchSuccessResponseFieldsRT } from '../../../utils/elasticsearch_runtime_types'; +import { + createJobIdsFilters, + createTimeRangeFilters, + createResultTypeFilters, + defaultRequestParameters, +} from './common'; +import { Sort, Pagination } from '../../../../common/http_api/log_analysis'; + +// TODO: Reassess validity of this against ML docs +const TIEBREAKER_FIELD = '_doc'; + +const sortToMlFieldMap = { + dataset: 'partition_field_value', + anomalyScore: 'record_score', + startTime: 'timestamp', +}; + +export const createLogEntryAnomaliesQuery = ( + jobIds: string[], + startTime: number, + endTime: number, + sort: Sort, + pagination: Pagination +) => { + const { field } = sort; + const { pageSize } = pagination; + + const filters = [ + ...createJobIdsFilters(jobIds), + ...createTimeRangeFilters(startTime, endTime), + ...createResultTypeFilters(['record']), + ]; + + const sourceFields = [ + 'job_id', + 'record_score', + 'typical', + 'actual', + 'partition_field_value', + 'timestamp', + 'bucket_span', + 'by_field_value', + ]; + + const { querySortDirection, queryCursor } = parsePaginationCursor(sort, pagination); + + const sortOptions = [ + { [sortToMlFieldMap[field]]: querySortDirection }, + { [TIEBREAKER_FIELD]: querySortDirection }, // Tiebreaker + ]; + + const resultsQuery = { + ...defaultRequestParameters, + body: { + query: { + bool: { + filter: filters, + }, + }, + search_after: queryCursor, + sort: sortOptions, + size: pageSize, + _source: sourceFields, + }, + }; + + return resultsQuery; +}; + +export const logEntryAnomalyHitRT = rt.type({ + _id: rt.string, + _source: rt.intersection([ + rt.type({ + job_id: rt.string, + record_score: rt.number, + typical: rt.array(rt.number), + actual: rt.array(rt.number), + partition_field_value: rt.string, + bucket_span: rt.number, + timestamp: rt.number, + }), + rt.partial({ + by_field_value: rt.string, + }), + ]), + sort: rt.tuple([rt.union([rt.string, rt.number]), rt.union([rt.string, rt.number])]), +}); + +export type LogEntryAnomalyHit = rt.TypeOf; + +export const logEntryAnomaliesResponseRT = rt.intersection([ + commonSearchSuccessResponseFieldsRT, + rt.type({ + hits: rt.type({ + hits: rt.array(logEntryAnomalyHitRT), + }), + }), +]); + +export type LogEntryAnomaliesResponseRT = rt.TypeOf; + +const parsePaginationCursor = (sort: Sort, pagination: Pagination) => { + const { cursor } = pagination; + const { direction } = sort; + + if (!cursor) { + return { querySortDirection: direction, queryCursor: undefined }; + } + + // We will always use ES's search_after to paginate, to mimic "search_before" behaviour we + // need to reverse the user's chosen search direction for the ES query. + if ('searchBefore' in cursor) { + return { + querySortDirection: direction === 'desc' ? 'asc' : 'desc', + queryCursor: cursor.searchBefore, + }; + } else { + return { querySortDirection: direction, queryCursor: cursor.searchAfter }; + } +}; diff --git a/x-pack/plugins/infra/server/lib/log_analysis/queries/log_entry_rate_examples.ts b/x-pack/plugins/infra/server/lib/log_analysis/queries/log_entry_examples.ts similarity index 59% rename from x-pack/plugins/infra/server/lib/log_analysis/queries/log_entry_rate_examples.ts rename to x-pack/plugins/infra/server/lib/log_analysis/queries/log_entry_examples.ts index ef06641caf797..74a664e78dcd6 100644 --- a/x-pack/plugins/infra/server/lib/log_analysis/queries/log_entry_rate_examples.ts +++ b/x-pack/plugins/infra/server/lib/log_analysis/queries/log_entry_examples.ts @@ -10,14 +10,15 @@ import { commonSearchSuccessResponseFieldsRT } from '../../../utils/elasticsearc import { defaultRequestParameters } from './common'; import { partitionField } from '../../../../common/log_analysis'; -export const createLogEntryRateExamplesQuery = ( +export const createLogEntryExamplesQuery = ( indices: string, timestampField: string, tiebreakerField: string, startTime: number, endTime: number, dataset: string, - exampleCount: number + exampleCount: number, + categoryQuery?: string ) => ({ ...defaultRequestParameters, body: { @@ -32,11 +33,27 @@ export const createLogEntryRateExamplesQuery = ( }, }, }, - { - term: { - [partitionField]: dataset, - }, - }, + ...(!!dataset + ? [ + { + term: { + [partitionField]: dataset, + }, + }, + ] + : []), + ...(categoryQuery + ? [ + { + match: { + message: { + query: categoryQuery, + operator: 'AND', + }, + }, + }, + ] + : []), ], }, }, @@ -47,7 +64,7 @@ export const createLogEntryRateExamplesQuery = ( size: exampleCount, }); -export const logEntryRateExampleHitRT = rt.type({ +export const logEntryExampleHitRT = rt.type({ _id: rt.string, _source: rt.partial({ event: rt.partial({ @@ -58,15 +75,15 @@ export const logEntryRateExampleHitRT = rt.type({ sort: rt.tuple([rt.number, rt.number]), }); -export type LogEntryRateExampleHit = rt.TypeOf; +export type LogEntryExampleHit = rt.TypeOf; -export const logEntryRateExamplesResponseRT = rt.intersection([ +export const logEntryExamplesResponseRT = rt.intersection([ commonSearchSuccessResponseFieldsRT, rt.type({ hits: rt.type({ - hits: rt.array(logEntryRateExampleHitRT), + hits: rt.array(logEntryExampleHitRT), }), }), ]); -export type LogEntryRateExamplesResponse = rt.TypeOf; +export type LogEntryExamplesResponse = rt.TypeOf; diff --git a/x-pack/plugins/infra/server/routes/log_analysis/results/index.ts b/x-pack/plugins/infra/server/routes/log_analysis/results/index.ts index 30b6be435837b..cbd89db97236f 100644 --- a/x-pack/plugins/infra/server/routes/log_analysis/results/index.ts +++ b/x-pack/plugins/infra/server/routes/log_analysis/results/index.ts @@ -8,4 +8,5 @@ export * from './log_entry_categories'; export * from './log_entry_category_datasets'; export * from './log_entry_category_examples'; export * from './log_entry_rate'; -export * from './log_entry_rate_examples'; +export * from './log_entry_examples'; +export * from './log_entry_anomalies'; diff --git a/x-pack/plugins/infra/server/routes/log_analysis/results/log_entry_anomalies.ts b/x-pack/plugins/infra/server/routes/log_analysis/results/log_entry_anomalies.ts new file mode 100644 index 0000000000000..f4911658ea496 --- /dev/null +++ b/x-pack/plugins/infra/server/routes/log_analysis/results/log_entry_anomalies.ts @@ -0,0 +1,112 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import Boom from 'boom'; +import { InfraBackendLibs } from '../../../lib/infra_types'; +import { + LOG_ANALYSIS_GET_LOG_ENTRY_ANOMALIES_PATH, + getLogEntryAnomaliesSuccessReponsePayloadRT, + getLogEntryAnomaliesRequestPayloadRT, + GetLogEntryAnomaliesRequestPayload, + Sort, + Pagination, +} from '../../../../common/http_api/log_analysis'; +import { createValidationFunction } from '../../../../common/runtime_types'; +import { assertHasInfraMlPlugins } from '../../../utils/request_context'; +import { getLogEntryAnomalies } from '../../../lib/log_analysis'; + +export const initGetLogEntryAnomaliesRoute = ({ framework }: InfraBackendLibs) => { + framework.registerRoute( + { + method: 'post', + path: LOG_ANALYSIS_GET_LOG_ENTRY_ANOMALIES_PATH, + validate: { + body: createValidationFunction(getLogEntryAnomaliesRequestPayloadRT), + }, + }, + framework.router.handleLegacyErrors(async (requestContext, request, response) => { + const { + data: { + sourceId, + timeRange: { startTime, endTime }, + sort: sortParam, + pagination: paginationParam, + }, + } = request.body; + + const { sort, pagination } = getSortAndPagination(sortParam, paginationParam); + + try { + assertHasInfraMlPlugins(requestContext); + + const { + data: logEntryAnomalies, + paginationCursors, + hasMoreEntries, + timing, + } = await getLogEntryAnomalies( + requestContext, + sourceId, + startTime, + endTime, + sort, + pagination + ); + + return response.ok({ + body: getLogEntryAnomaliesSuccessReponsePayloadRT.encode({ + data: { + anomalies: logEntryAnomalies, + hasMoreEntries, + paginationCursors, + }, + timing, + }), + }); + } catch (error) { + if (Boom.isBoom(error)) { + throw error; + } + + return response.customError({ + statusCode: error.statusCode ?? 500, + body: { + message: error.message ?? 'An unexpected error occurred', + }, + }); + } + }) + ); +}; + +const getSortAndPagination = ( + sort: Partial = {}, + pagination: Partial = {} +): { + sort: Sort; + pagination: Pagination; +} => { + const sortDefaults = { + field: 'anomalyScore' as const, + direction: 'desc' as const, + }; + + const sortWithDefaults = { + ...sortDefaults, + ...sort, + }; + + const paginationDefaults = { + pageSize: 50, + }; + + const paginationWithDefaults = { + ...paginationDefaults, + ...pagination, + }; + + return { sort: sortWithDefaults, pagination: paginationWithDefaults }; +}; diff --git a/x-pack/plugins/infra/server/routes/log_analysis/results/log_entry_rate_examples.ts b/x-pack/plugins/infra/server/routes/log_analysis/results/log_entry_examples.ts similarity index 75% rename from x-pack/plugins/infra/server/routes/log_analysis/results/log_entry_rate_examples.ts rename to x-pack/plugins/infra/server/routes/log_analysis/results/log_entry_examples.ts index b8ebcc66911dc..be4caee769506 100644 --- a/x-pack/plugins/infra/server/routes/log_analysis/results/log_entry_rate_examples.ts +++ b/x-pack/plugins/infra/server/routes/log_analysis/results/log_entry_examples.ts @@ -7,21 +7,21 @@ import Boom from 'boom'; import { createValidationFunction } from '../../../../common/runtime_types'; import { InfraBackendLibs } from '../../../lib/infra_types'; -import { NoLogAnalysisResultsIndexError, getLogEntryRateExamples } from '../../../lib/log_analysis'; +import { NoLogAnalysisResultsIndexError, getLogEntryExamples } from '../../../lib/log_analysis'; import { assertHasInfraMlPlugins } from '../../../utils/request_context'; import { - getLogEntryRateExamplesRequestPayloadRT, - getLogEntryRateExamplesSuccessReponsePayloadRT, + getLogEntryExamplesRequestPayloadRT, + getLogEntryExamplesSuccessReponsePayloadRT, LOG_ANALYSIS_GET_LOG_ENTRY_RATE_EXAMPLES_PATH, } from '../../../../common/http_api/log_analysis'; -export const initGetLogEntryRateExamplesRoute = ({ framework, sources }: InfraBackendLibs) => { +export const initGetLogEntryExamplesRoute = ({ framework, sources }: InfraBackendLibs) => { framework.registerRoute( { method: 'post', path: LOG_ANALYSIS_GET_LOG_ENTRY_RATE_EXAMPLES_PATH, validate: { - body: createValidationFunction(getLogEntryRateExamplesRequestPayloadRT), + body: createValidationFunction(getLogEntryExamplesRequestPayloadRT), }, }, framework.router.handleLegacyErrors(async (requestContext, request, response) => { @@ -31,6 +31,7 @@ export const initGetLogEntryRateExamplesRoute = ({ framework, sources }: InfraBa exampleCount, sourceId, timeRange: { startTime, endTime }, + categoryId, }, } = request.body; @@ -42,7 +43,7 @@ export const initGetLogEntryRateExamplesRoute = ({ framework, sources }: InfraBa try { assertHasInfraMlPlugins(requestContext); - const { data: logEntryRateExamples, timing } = await getLogEntryRateExamples( + const { data: logEntryExamples, timing } = await getLogEntryExamples( requestContext, sourceId, startTime, @@ -50,13 +51,14 @@ export const initGetLogEntryRateExamplesRoute = ({ framework, sources }: InfraBa dataset, exampleCount, sourceConfiguration, - framework.callWithRequest + framework.callWithRequest, + categoryId ); return response.ok({ - body: getLogEntryRateExamplesSuccessReponsePayloadRT.encode({ + body: getLogEntryExamplesSuccessReponsePayloadRT.encode({ data: { - examples: logEntryRateExamples, + examples: logEntryExamples, }, timing, }), diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 4c83fa71a7060..c1f36372ec94e 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -7471,7 +7471,6 @@ "xpack.infra.logs.analysis.anomaliesSectionLineSeriesName": "15 分ごとのログエントリー (平均)", "xpack.infra.logs.analysis.anomaliesSectionLoadingAriaLabel": "異常を読み込み中", "xpack.infra.logs.analysis.anomaliesSectionTitle": "異常", - "xpack.infra.logs.analysis.anomalySectionNoAnomaliesTitle": "異常が検出されませんでした。", "xpack.infra.logs.analysis.anomalySectionNoDataBody": "時間範囲を調整する必要があるかもしれません。", "xpack.infra.logs.analysis.anomalySectionNoDataTitle": "表示するデータがありません。", "xpack.infra.logs.analysis.jobConfigurationOutdatedCalloutMessage": "異なるソース構成を使用して ML ジョブが作成されました。現在の構成を適用するにはジョブを再作成してください。これにより以前検出された異常が削除されます。", @@ -7480,14 +7479,6 @@ "xpack.infra.logs.analysis.jobDefinitionOutdatedCalloutTitle": "古い ML ジョブ定義", "xpack.infra.logs.analysis.jobStoppedCalloutMessage": "ML ジョブが手動またはリソース不足により停止しました。新しいログエントリーはジョブが再起動するまで処理されません。", "xpack.infra.logs.analysis.jobStoppedCalloutTitle": "ML ジョブが停止しました", - "xpack.infra.logs.analysis.logRateResultsToolbarText": "{startTime} から {endTime} までの {numberOfLogs} 件のログエントリーを分析しました", - "xpack.infra.logs.analysis.logRateSectionBucketSpanLabel": "バケットスパン: ", - "xpack.infra.logs.analysis.logRateSectionBucketSpanValue": "15 分", - "xpack.infra.logs.analysis.logRateSectionLineSeriesName": "15 分ごとのログエントリー (平均)", - "xpack.infra.logs.analysis.logRateSectionLoadingAriaLabel": "ログレートの結果を読み込み中", - "xpack.infra.logs.analysis.logRateSectionNoDataBody": "時間範囲を調整する必要があるかもしれません。", - "xpack.infra.logs.analysis.logRateSectionNoDataTitle": "表示するデータがありません。", - "xpack.infra.logs.analysis.logRateSectionTitle": "ログレート", "xpack.infra.logs.analysis.missingMlResultsPrivilegesBody": "本機能は機械学習ジョブを利用し、そのステータスと結果にアクセスするためには、少なくとも{machineLearningUserRole}ロールが必要です。", "xpack.infra.logs.analysis.missingMlResultsPrivilegesTitle": "追加の機械学習の権限が必要です", "xpack.infra.logs.analysis.missingMlSetupPrivilegesBody": "本機能は機械学習ジョブを利用し、設定には{machineLearningAdminRole}ロールが必要です。", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 86b2480e3b314..7e36d5676585c 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -7476,7 +7476,6 @@ "xpack.infra.logs.analysis.anomaliesSectionLineSeriesName": "每 15 分钟日志条目数(平均值)", "xpack.infra.logs.analysis.anomaliesSectionLoadingAriaLabel": "正在加载异常", "xpack.infra.logs.analysis.anomaliesSectionTitle": "异常", - "xpack.infra.logs.analysis.anomalySectionNoAnomaliesTitle": "未检测到任何异常。", "xpack.infra.logs.analysis.anomalySectionNoDataBody": "您可能想调整时间范围。", "xpack.infra.logs.analysis.anomalySectionNoDataTitle": "没有可显示的数据。", "xpack.infra.logs.analysis.jobConfigurationOutdatedCalloutMessage": "创建 ML 作业时所使用的源配置不同。重新创建作业以应用当前配置。这将移除以前检测到的异常。", @@ -7485,14 +7484,6 @@ "xpack.infra.logs.analysis.jobDefinitionOutdatedCalloutTitle": "ML 作业定义已过期", "xpack.infra.logs.analysis.jobStoppedCalloutMessage": "ML 作业已手动停止或由于缺乏资源而停止。作业重新启动后,才会处理新的日志条目。", "xpack.infra.logs.analysis.jobStoppedCalloutTitle": "ML 作业已停止", - "xpack.infra.logs.analysis.logRateResultsToolbarText": "从 {startTime} 到 {endTime} 已分析 {numberOfLogs} 个日志条目", - "xpack.infra.logs.analysis.logRateSectionBucketSpanLabel": "存储桶跨度: ", - "xpack.infra.logs.analysis.logRateSectionBucketSpanValue": "15 分钟", - "xpack.infra.logs.analysis.logRateSectionLineSeriesName": "每 15 分钟日志条目数(平均值)", - "xpack.infra.logs.analysis.logRateSectionLoadingAriaLabel": "正在加载日志速率结果", - "xpack.infra.logs.analysis.logRateSectionNoDataBody": "您可能想调整时间范围。", - "xpack.infra.logs.analysis.logRateSectionNoDataTitle": "没有可显示的数据。", - "xpack.infra.logs.analysis.logRateSectionTitle": "日志速率", "xpack.infra.logs.analysis.missingMlResultsPrivilegesBody": "此功能使用 Machine Learning 作业,要访问这些作业的状态和结果,至少需要 {machineLearningUserRole} 角色。", "xpack.infra.logs.analysis.missingMlResultsPrivilegesTitle": "需要额外的 Machine Learning 权限", "xpack.infra.logs.analysis.missingMlSetupPrivilegesBody": "此功能使用 Machine Learning 作业,这需要 {machineLearningAdminRole} 角色才能设置。", From 6eeff6bfb4d7e458384d04af239272071b87ab53 Mon Sep 17 00:00:00 2001 From: Brent Kimmel Date: Mon, 13 Jul 2020 13:36:24 -0400 Subject: [PATCH 12/66] [Security_Solution][GTV] Add lineage limit warnings to graph (#70097) * [Security Solution][GTV] Add lineage limit warnings to graph Co-authored-by: Elastic Machine Co-authored-by: oatkiller --- .../common/endpoint/models/event.ts | 15 +- .../public/resolver/models/resolver_tree.ts | 5 +- .../resolver/store/data/reducer.test.ts | 281 +++++++++++++++++- .../public/resolver/store/data/selectors.ts | 114 ++++++- .../public/resolver/store/selectors.ts | 20 ++ .../public/resolver/view/limit_warnings.tsx | 126 ++++++++ .../panels/panel_content_process_list.tsx | 27 ++ .../panels/panel_content_related_list.tsx | 48 ++- 8 files changed, 617 insertions(+), 19 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/resolver/view/limit_warnings.tsx diff --git a/x-pack/plugins/security_solution/common/endpoint/models/event.ts b/x-pack/plugins/security_solution/common/endpoint/models/event.ts index 86cccff957211..9b4550f52ff22 100644 --- a/x-pack/plugins/security_solution/common/endpoint/models/event.ts +++ b/x-pack/plugins/security_solution/common/endpoint/models/event.ts @@ -82,7 +82,6 @@ export function getAncestryAsArray(event: ResolverEvent | undefined): string[] { * @param event The event to get the category for */ export function primaryEventCategory(event: ResolverEvent): string | undefined { - // Returning "Process" as a catch-all here because it seems pretty general if (isLegacyEvent(event)) { const legacyFullType = event.endgame.event_type_full; if (legacyFullType) { @@ -96,6 +95,20 @@ export function primaryEventCategory(event: ResolverEvent): string | undefined { } } +/** + * @param event The event to get the full ECS category for + */ +export function allEventCategories(event: ResolverEvent): string | string[] | undefined { + if (isLegacyEvent(event)) { + const legacyFullType = event.endgame.event_type_full; + if (legacyFullType) { + return legacyFullType; + } + } else { + return event.event.category; + } +} + /** * ECS event type will be things like 'creation', 'deletion', 'access', etc. * see: https://www.elastic.co/guide/en/ecs/current/ecs-event.html diff --git a/x-pack/plugins/security_solution/public/resolver/models/resolver_tree.ts b/x-pack/plugins/security_solution/public/resolver/models/resolver_tree.ts index cf32988a856b2..446e371832d38 100644 --- a/x-pack/plugins/security_solution/public/resolver/models/resolver_tree.ts +++ b/x-pack/plugins/security_solution/public/resolver/models/resolver_tree.ts @@ -9,6 +9,7 @@ import { ResolverEvent, ResolverNodeStats, ResolverLifecycleNode, + ResolverChildNode, } from '../../../common/endpoint/types'; import { uniquePidForProcess } from './process_event'; @@ -60,11 +61,13 @@ export function relatedEventsStats(tree: ResolverTree): Map { let store: Store; + let dispatchTree: (tree: ResolverTree) => void; beforeEach(() => { store = createStore(dataReducer, undefined); + dispatchTree = (tree) => { + const action: DataAction = { + type: 'serverReturnedResolverData', + payload: { + result: tree, + databaseDocumentID: '', + }, + }; + store.dispatch(action); + }; }); describe('when data was received and the ancestry and children edges had cursors', () => { beforeEach(() => { - const generator = new EndpointDocGenerator('seed'); + // Generate a 'tree' using the Resolver generator code. This structure isn't the same as what the API returns. + const baseTree = generateBaseTree(); const tree = mockResolverTree({ - events: generator.generateTree({ ancestors: 1, generations: 2, children: 2 }).allEvents, + events: baseTree.allEvents, cursors: { - childrenNextChild: 'aValidChildursor', + childrenNextChild: 'aValidChildCursor', ancestryNextAncestor: 'aValidAncestorCursor', }, - }); - if (tree) { - const action: DataAction = { - type: 'serverReturnedResolverData', - payload: { - result: tree, - databaseDocumentID: '', - }, - }; - store.dispatch(action); - } + })!; + dispatchTree(tree); }); it('should indicate there are additional ancestor', () => { expect(selectors.hasMoreAncestors(store.getState())).toBe(true); @@ -49,4 +53,251 @@ describe('Resolver Data Middleware', () => { expect(selectors.hasMoreChildren(store.getState())).toBe(true); }); }); + + describe('when data was received with stats mocked for the first child node', () => { + let firstChildNodeInTree: TreeNode; + let eventStatsForFirstChildNode: { total: number; byCategory: Record }; + let categoryToOverCount: string; + let tree: ResolverTree; + + /** + * Compiling stats to use for checking limit warnings and counts of missing events + * e.g. Limit warnings should show when number of related events actually displayed + * is lower than the estimated count from stats. + */ + + beforeEach(() => { + ({ + tree, + firstChildNodeInTree, + eventStatsForFirstChildNode, + categoryToOverCount, + } = mockedTree()); + if (tree) { + dispatchTree(tree); + } + }); + + describe('and when related events were returned with totals equalling what stat counts indicate they should be', () => { + beforeEach(() => { + // Return related events for the first child node + const relatedAction: DataAction = { + type: 'serverReturnedRelatedEventData', + payload: { + entityID: firstChildNodeInTree.id, + events: firstChildNodeInTree.relatedEvents, + nextEvent: null, + }, + }; + store.dispatch(relatedAction); + }); + it('should have the correct related events', () => { + const selectedEventsByEntityId = selectors.relatedEventsByEntityId(store.getState()); + const selectedEventsForFirstChildNode = selectedEventsByEntityId.get( + firstChildNodeInTree.id + )!.events; + + expect(selectedEventsForFirstChildNode).toBe(firstChildNodeInTree.relatedEvents); + }); + it('should indicate the correct related event count for each category', () => { + const selectedRelatedInfo = selectors.relatedEventInfoByEntityId(store.getState()); + const displayCountsForCategory = selectedRelatedInfo(firstChildNodeInTree.id) + ?.numberActuallyDisplayedForCategory!; + + const eventCategoriesForNode: string[] = Object.keys( + eventStatsForFirstChildNode.byCategory + ); + + for (const eventCategory of eventCategoriesForNode) { + expect(`${eventCategory}:${displayCountsForCategory(eventCategory)}`).toBe( + `${eventCategory}:${eventStatsForFirstChildNode.byCategory[eventCategory]}` + ); + } + }); + /** + * The general approach reflected here is to _avoid_ showing a limit warning - even if we hit + * the overall related event limit - as long as the number in our category matches what the stats + * say we have. E.g. If the stats say you have 20 dns events, and we receive 20 dns events, we + * don't need to display a limit warning for that, even if we hit some overall event limit of e.g. 100 + * while we were fetching the 20. + */ + it('should not indicate the limit has been exceeded because the number of related events received for the category is greater or equal to the stats count', () => { + const selectedRelatedInfo = selectors.relatedEventInfoByEntityId(store.getState()); + const shouldShowLimit = selectedRelatedInfo(firstChildNodeInTree.id) + ?.shouldShowLimitForCategory!; + for (const typeCounted of Object.keys(eventStatsForFirstChildNode.byCategory)) { + expect(shouldShowLimit(typeCounted)).toBe(false); + } + }); + it('should not indicate that there are any related events missing because the number of related events received for the category is greater or equal to the stats count', () => { + const selectedRelatedInfo = selectors.relatedEventInfoByEntityId(store.getState()); + const notDisplayed = selectedRelatedInfo(firstChildNodeInTree.id) + ?.numberNotDisplayedForCategory!; + for (const typeCounted of Object.keys(eventStatsForFirstChildNode.byCategory)) { + expect(notDisplayed(typeCounted)).toBe(0); + } + }); + }); + describe('when data was received and stats show more related events than the API can provide', () => { + beforeEach(() => { + // Add 1 to the stats for an event category so that the selectors think we are missing data. + // This mutates `tree`, and then we re-dispatch it + eventStatsForFirstChildNode.byCategory[categoryToOverCount] = + eventStatsForFirstChildNode.byCategory[categoryToOverCount] + 1; + + if (tree) { + dispatchTree(tree); + const relatedAction: DataAction = { + type: 'serverReturnedRelatedEventData', + payload: { + entityID: firstChildNodeInTree.id, + events: firstChildNodeInTree.relatedEvents, + nextEvent: 'aValidNextEventCursor', + }, + }; + store.dispatch(relatedAction); + } + }); + it('should have the correct related events', () => { + const selectedEventsByEntityId = selectors.relatedEventsByEntityId(store.getState()); + const selectedEventsForFirstChildNode = selectedEventsByEntityId.get( + firstChildNodeInTree.id + )!.events; + + expect(selectedEventsForFirstChildNode).toBe(firstChildNodeInTree.relatedEvents); + }); + it('should indicate the limit has been exceeded because the number of related events received for the category is less than what the stats count said it would be', () => { + const selectedRelatedInfo = selectors.relatedEventInfoByEntityId(store.getState()); + const shouldShowLimit = selectedRelatedInfo(firstChildNodeInTree.id) + ?.shouldShowLimitForCategory!; + expect(shouldShowLimit(categoryToOverCount)).toBe(true); + }); + it('should indicate that there are related events missing because the number of related events received for the category is less than what the stats count said it would be', () => { + const selectedRelatedInfo = selectors.relatedEventInfoByEntityId(store.getState()); + const notDisplayed = selectedRelatedInfo(firstChildNodeInTree.id) + ?.numberNotDisplayedForCategory!; + expect(notDisplayed(categoryToOverCount)).toBe(1); + }); + }); + }); }); + +function mockedTree() { + // Generate a 'tree' using the Resolver generator code. This structure isn't the same as what the API returns. + const baseTree = generateBaseTree(); + + const { children } = baseTree; + const firstChildNodeInTree = [...children.values()][0]; + + // The `generateBaseTree` mock doesn't calculate stats (the actual data has them.) + // So calculate some stats for just the node that we'll test. + const statsResults = compileStatsForChild(firstChildNodeInTree); + + const tree = mockResolverTree({ + events: baseTree.allEvents, + /** + * Calculate children from the ResolverTree response using the children of the `Tree` we generated using the Resolver data generator code. + * Compile (and attach) stats to the first child node. + * + * The purpose of `children` here is to set the `actual` + * value that the stats values will be compared with + * to derive things like the number of missing events and if + * related event limits should be shown. + */ + children: [...baseTree.children.values()].map((node: TreeNode) => { + // Treat each `TreeNode` as a `ResolverChildNode`. + // These types are almost close enough to be used interchangably (for the purposes of this test.) + const childNode: Partial = node; + + // `TreeNode` has `id` which is the same as `entityID`. + // The `ResolverChildNode` calls the entityID as `entityID`. + // Set `entityID` on `childNode` since the code in test relies on it. + childNode.entityID = (childNode as TreeNode).id; + + // This should only be true for the first child. + if (node.id === firstChildNodeInTree.id) { + // attach stats + childNode.stats = { + events: statsResults.eventStats, + totalAlerts: 0, + }; + } + return childNode; + }) as ResolverChildNode[] /** + Cast to ResolverChildNode[] array is needed because incoming + TreeNodes from the generator cannot be assigned cleanly to the + tree model's expected ResolverChildNode type. + */, + }); + + return { + tree: tree!, + firstChildNodeInTree, + eventStatsForFirstChildNode: statsResults.eventStats, + categoryToOverCount: statsResults.firstCategory, + }; +} + +function generateBaseTree() { + const generator = new EndpointDocGenerator('seed'); + return generator.generateTree({ + ancestors: 1, + generations: 2, + children: 3, + percentWithRelated: 100, + alwaysGenMaxChildrenPerNode: true, + }); +} + +function compileStatsForChild( + node: TreeNode +): { + eventStats: { + /** The total number of related events. */ + total: number; + /** A record with the categories of events as keys, and the count of events per category as values. */ + byCategory: Record; + }; + /** The category of the first event. */ + firstCategory: string; +} { + const totalRelatedEvents = node.relatedEvents.length; + // For the purposes of testing, we pick one category to fake an extra event for + // so we can test if the event limit selectors do the right thing. + + let firstCategory: string | undefined; + + const compiledStats = node.relatedEvents.reduce( + (counts: Record, relatedEvent) => { + // `relatedEvent.event.category` is `string | string[]`. + // Wrap it in an array and flatten that array to get a `string[] | [string]` + // which we can loop over. + const categories: string[] = [relatedEvent.event.category].flat(); + + for (const category of categories) { + // Set the first category as 'categoryToOverCount' + if (firstCategory === undefined) { + firstCategory = category; + } + + // Increment the count of events with this category + counts[category] = counts[category] ? counts[category] + 1 : 1; + } + return counts; + }, + {} + ); + if (firstCategory === undefined) { + throw new Error('there were no related events for the node.'); + } + return { + /** + * Object to use for the first child nodes stats `events` object? + */ + eventStats: { + total: totalRelatedEvents, + byCategory: compiledStats, + }, + firstCategory, + }; +} diff --git a/x-pack/plugins/security_solution/public/resolver/store/data/selectors.ts b/x-pack/plugins/security_solution/public/resolver/store/data/selectors.ts index 9c47c765457e3..990b911e5dbd0 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/data/selectors.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/data/selectors.ts @@ -5,7 +5,7 @@ */ import rbush from 'rbush'; -import { createSelector } from 'reselect'; +import { createSelector, defaultMemoize } from 'reselect'; import { DataState, AdjacentProcessMap, @@ -32,6 +32,7 @@ import { } from '../../../../common/endpoint/types'; import * as resolverTreeModel from '../../models/resolver_tree'; import { isometricTaxiLayout } from '../../models/indexed_process_tree/isometric_taxi_layout'; +import { allEventCategories } from '../../../../common/endpoint/models/event'; /** * If there is currently a request. @@ -167,6 +168,116 @@ export function hasMoreAncestors(state: DataState): boolean { return tree ? resolverTreeModel.hasMoreAncestors(tree) : false; } +interface RelatedInfoFunctions { + shouldShowLimitForCategory: (category: string) => boolean; + numberNotDisplayedForCategory: (category: string) => number; + numberActuallyDisplayedForCategory: (category: string) => number; +} +/** + * A map of `entity_id`s to functions that provide information about + * related events by ECS `.category` Primarily to avoid having business logic + * in UI components. + */ +export const relatedEventInfoByEntityId: ( + state: DataState +) => (entityID: string) => RelatedInfoFunctions | null = createSelector( + relatedEventsByEntityId, + relatedEventsStats, + function selectLineageLimitInfo( + /* eslint-disable no-shadow */ + relatedEventsByEntityId, + relatedEventsStats + /* eslint-enable no-shadow */ + ) { + if (!relatedEventsStats) { + // If there are no related event stats, there are no related event info objects + return (entityId: string) => null; + } + return (entityId) => { + const stats = relatedEventsStats.get(entityId); + if (!stats) { + return null; + } + const eventsResponseForThisEntry = relatedEventsByEntityId.get(entityId); + const hasMoreEvents = + eventsResponseForThisEntry && eventsResponseForThisEntry.nextEvent !== null; + /** + * Get the "aggregate" total for the event category (i.e. _all_ events that would qualify as being "in category") + * For a set like `[DNS,File][File,DNS][Registry]` The first and second events would contribute to the aggregate total for DNS being 2. + * This is currently aligned with how the backed provides this information. + * + * @param eventCategory {string} The ECS category like 'file','dns',etc. + */ + const aggregateTotalForCategory = (eventCategory: string): number => { + return stats.events.byCategory[eventCategory] || 0; + }; + + /** + * Get all the related events in the category provided. + * + * @param eventCategory {string} The ECS category like 'file','dns',etc. + */ + const unmemoizedMatchingEventsForCategory = (eventCategory: string): ResolverEvent[] => { + if (!eventsResponseForThisEntry) { + return []; + } + return eventsResponseForThisEntry.events.filter((resolverEvent) => { + for (const category of [allEventCategories(resolverEvent)].flat()) { + if (category === eventCategory) { + return true; + } + } + return false; + }); + }; + + const matchingEventsForCategory = defaultMemoize(unmemoizedMatchingEventsForCategory); + + /** + * The number of events that occurred before the API limit was reached. + * The number of events that came back form the API that have `eventCategory` in their list of categories. + * + * @param eventCategory {string} The ECS category like 'file','dns',etc. + */ + const numberActuallyDisplayedForCategory = (eventCategory: string): number => { + return matchingEventsForCategory(eventCategory)?.length || 0; + }; + + /** + * The total number counted by the backend - the number displayed + * + * @param eventCategory {string} The ECS category like 'file','dns',etc. + */ + const numberNotDisplayedForCategory = (eventCategory: string): number => { + return ( + aggregateTotalForCategory(eventCategory) - + numberActuallyDisplayedForCategory(eventCategory) + ); + }; + + /** + * `true` when the `nextEvent` cursor appeared in the results and we are short on the number needed to + * fullfill the aggregate count. + * + * @param eventCategory {string} The ECS category like 'file','dns',etc. + */ + const shouldShowLimitForCategory = (eventCategory: string): boolean => { + if (hasMoreEvents && numberNotDisplayedForCategory(eventCategory) > 0) { + return true; + } + return false; + }; + + const entryValue = { + shouldShowLimitForCategory, + numberNotDisplayedForCategory, + numberActuallyDisplayedForCategory, + }; + return entryValue; + }; + } +); + /** * If we need to fetch, this is the ID to fetch. */ @@ -285,6 +396,7 @@ export const visibleProcessNodePositionsAndEdgeLineSegments = createSelector( }; } ); + /** * If there is a pending request that's for a entity ID that doesn't matche the `entityID`, then we should cancel it. */ diff --git a/x-pack/plugins/security_solution/public/resolver/store/selectors.ts b/x-pack/plugins/security_solution/public/resolver/store/selectors.ts index 2bc254d118d33..6e512cfe13f62 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/selectors.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/selectors.ts @@ -103,6 +103,16 @@ export const relatedEventsReady = composeSelectors( dataSelectors.relatedEventsReady ); +/** + * Business logic lookup functions by ECS category by entity id. + * Example usage: + * const numberOfFileEvents = infoByEntityId.get(`someEntityId`)?.getAggregateTotalForCategory(`file`); + */ +export const relatedEventInfoByEntityId = composeSelectors( + dataStateSelector, + dataSelectors.relatedEventInfoByEntityId +); + /** * Returns the id of the "current" tree node (fake-focused) */ @@ -158,6 +168,16 @@ export const isLoading = composeSelectors(dataStateSelector, dataSelectors.isLoa */ export const hasError = composeSelectors(dataStateSelector, dataSelectors.hasError); +/** + * True if the children cursor is not null + */ +export const hasMoreChildren = composeSelectors(dataStateSelector, dataSelectors.hasMoreChildren); + +/** + * True if the ancestor cursor is not null + */ +export const hasMoreAncestors = composeSelectors(dataStateSelector, dataSelectors.hasMoreAncestors); + /** * An array containing all the processes currently in the Resolver than can be graphed */ diff --git a/x-pack/plugins/security_solution/public/resolver/view/limit_warnings.tsx b/x-pack/plugins/security_solution/public/resolver/view/limit_warnings.tsx new file mode 100644 index 0000000000000..e3bad8ee2e574 --- /dev/null +++ b/x-pack/plugins/security_solution/public/resolver/view/limit_warnings.tsx @@ -0,0 +1,126 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { EuiCallOut } from '@elastic/eui'; +import { FormattedMessage } from 'react-intl'; + +const lineageLimitMessage = ( + <> + + +); + +const LineageTitleMessage = React.memo(function LineageTitleMessage({ + numberOfEntries, +}: { + numberOfEntries: number; +}) { + return ( + <> + + + ); +}); + +const RelatedEventsLimitMessage = React.memo(function RelatedEventsLimitMessage({ + category, + numberOfEventsMissing, +}: { + numberOfEventsMissing: number; + category: string; +}) { + return ( + <> + + + ); +}); + +const RelatedLimitTitleMessage = React.memo(function RelatedLimitTitleMessage({ + category, + numberOfEventsDisplayed, +}: { + numberOfEventsDisplayed: number; + category: string; +}) { + return ( + <> + + + ); +}); + +/** + * Limit warning for hitting the /events API limit + */ +export const RelatedEventLimitWarning = React.memo(function RelatedEventLimitWarning({ + className, + eventType, + numberActuallyDisplayed, + numberMissing, +}: { + className?: string; + eventType: string; + numberActuallyDisplayed: number; + numberMissing: number; +}) { + /** + * Based on API limits, all related events may not be displayed. + */ + return ( + + } + > +

+ +

+
+ ); +}); + +/** + * Limit warning for hitting a limit of nodes in the tree + */ +export const LimitWarning = React.memo(function LimitWarning({ + className, + numberDisplayed, +}: { + className?: string; + numberDisplayed: number; +}) { + return ( + } + > +

{lineageLimitMessage}

+
+ ); +}); diff --git a/x-pack/plugins/security_solution/public/resolver/view/panels/panel_content_process_list.tsx b/x-pack/plugins/security_solution/public/resolver/view/panels/panel_content_process_list.tsx index 9152649c07abf..0ed677885775f 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/panels/panel_content_process_list.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/panels/panel_content_process_list.tsx @@ -13,6 +13,7 @@ import { } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { useSelector } from 'react-redux'; +import styled from 'styled-components'; import * as event from '../../../../common/endpoint/models/event'; import * as selectors from '../../store/selectors'; import { CrumbInfo, formatter, StyledBreadcrumbs } from './panel_content_utilities'; @@ -20,6 +21,27 @@ import { useResolverDispatch } from '../use_resolver_dispatch'; import { SideEffectContext } from '../side_effect_context'; import { CubeForProcess } from './process_cube_icon'; import { ResolverEvent } from '../../../../common/endpoint/types'; +import { LimitWarning } from '../limit_warnings'; + +const StyledLimitWarning = styled(LimitWarning)` + flex-flow: row wrap; + display: block; + align-items: baseline; + margin-top: 1em; + + & .euiCallOutHeader { + display: inline; + margin-right: 0.25em; + } + + & .euiText { + display: inline; + } + + & .euiText p { + display: inline; + } +`; /** * The "default" view for the panel: A list of all the processes currently in the graph. @@ -145,6 +167,7 @@ export const ProcessListWithCounts = memo(function ProcessListWithCounts({ }), [processNodePositions] ); + const numberOfProcesses = processTableView.length; const crumbs = useMemo(() => { return [ @@ -160,9 +183,13 @@ export const ProcessListWithCounts = memo(function ProcessListWithCounts({ ]; }, []); + const children = useSelector(selectors.hasMoreChildren); + const ancestors = useSelector(selectors.hasMoreAncestors); + const showWarning = children === true || ancestors === true; return ( <> + {showWarning && } items={processTableView} columns={columns} sorting /> diff --git a/x-pack/plugins/security_solution/public/resolver/view/panels/panel_content_related_list.tsx b/x-pack/plugins/security_solution/public/resolver/view/panels/panel_content_related_list.tsx index 1c17cf7e6ce34..591432e1f9f9f 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/panels/panel_content_related_list.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/panels/panel_content_related_list.tsx @@ -9,6 +9,7 @@ import { i18n } from '@kbn/i18n'; import { EuiTitle, EuiSpacer, EuiText, EuiButtonEmpty, EuiHorizontalRule } from '@elastic/eui'; import { useSelector } from 'react-redux'; import { FormattedMessage } from 'react-intl'; +import styled from 'styled-components'; import { CrumbInfo, formatDate, @@ -20,6 +21,7 @@ import * as event from '../../../../common/endpoint/models/event'; import { ResolverEvent, ResolverNodeStats } from '../../../../common/endpoint/types'; import * as selectors from '../../store/selectors'; import { useResolverDispatch } from '../use_resolver_dispatch'; +import { RelatedEventLimitWarning } from '../limit_warnings'; /** * This view presents a list of related events of a given type for a given process. @@ -40,16 +42,53 @@ interface MatchingEventEntry { setQueryParams: () => void; } +const StyledRelatedLimitWarning = styled(RelatedEventLimitWarning)` + flex-flow: row wrap; + display: block; + align-items: baseline; + margin-top: 1em; + + & .euiCallOutHeader { + display: inline; + margin-right: 0.25em; + } + + & .euiText { + display: inline; + } + + & .euiText p { + display: inline; + } +`; + const DisplayList = memo(function DisplayList({ crumbs, matchingEventEntries, + eventType, + processEntityId, }: { crumbs: Array<{ text: string | JSX.Element; onClick: () => void }>; matchingEventEntries: MatchingEventEntry[]; + eventType: string; + processEntityId: string; }) { + const relatedLookupsByCategory = useSelector(selectors.relatedEventInfoByEntityId); + const lookupsForThisNode = relatedLookupsByCategory(processEntityId); + const shouldShowLimitWarning = lookupsForThisNode?.shouldShowLimitForCategory(eventType); + const numberDisplayed = lookupsForThisNode?.numberActuallyDisplayedForCategory(eventType); + const numberMissing = lookupsForThisNode?.numberNotDisplayedForCategory(eventType); + return ( <> + {shouldShowLimitWarning && typeof numberDisplayed !== 'undefined' && numberMissing ? ( + + ) : null} <> {matchingEventEntries.map((eventView, index) => { @@ -250,6 +289,13 @@ export const ProcessEventListNarrowedByType = memo(function ProcessEventListNarr ); } - return ; + return ( + + ); }); ProcessEventListNarrowedByType.displayName = 'ProcessEventListNarrowedByType'; From c82ccfedc6852179e8404c1100adc13ecef6ae6f Mon Sep 17 00:00:00 2001 From: Paul Tavares <56442535+paul-tavares@users.noreply.github.com> Date: Mon, 13 Jul 2020 13:41:49 -0400 Subject: [PATCH 13/66] [SECURITY_SOLUTION][ENDPOINT] Sync up i18n of Policy Response action names to the latest from Endpoint (#71472) * Added updated Policy Response action names to translation file * `formatResponse` to generate a user friendly value for action name if no i18n * test case to cover formatting unknown actions --- .../details/policy_response_friendly_names.ts | 352 +++++++++++------- .../pages/endpoint_hosts/view/index.test.tsx | 17 +- 2 files changed, 228 insertions(+), 141 deletions(-) diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/policy_response_friendly_names.ts b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/policy_response_friendly_names.ts index 28e91331b428d..020e8c9e38ad5 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/policy_response_friendly_names.ts +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/policy_response_friendly_names.ts @@ -6,7 +6,209 @@ import { i18n } from '@kbn/i18n'; -const responseMap = new Map(); +const policyResponses: Array<[string, string]> = [ + [ + 'configure_dns_events', + i18n.translate( + 'xpack.securitySolution.endpoint.hostDetails.policyResponse.configure_dns_events', + { defaultMessage: 'Configure DNS Events' } + ), + ], + [ + 'configure_elasticsearch_connection', + i18n.translate( + 'xpack.securitySolution.endpoint.hostDetails.policyResponse.configure_elasticsearch_connection', + { defaultMessage: 'Configure Elastic Search Connection' } + ), + ], + [ + 'configure_file_events', + i18n.translate( + 'xpack.securitySolution.endpoint.hostDetails.policyResponse.configure_file_events', + { defaultMessage: 'Configure File Events' } + ), + ], + [ + 'configure_imageload_events', + i18n.translate( + 'xpack.securitySolution.endpoint.hostDetails.policyResponse.configure_imageload_events', + { defaultMessage: 'Configure Image Load Events' } + ), + ], + [ + 'configure_kernel', + i18n.translate('xpack.securitySolution.endpoint.hostDetails.policyResponse.configure_kernel', { + defaultMessage: 'Configure Kernel', + }), + ], + [ + 'configure_logging', + i18n.translate('xpack.securitySolution.endpoint.hostDetails.policyResponse.configure_logging', { + defaultMessage: 'Configure Logging', + }), + ], + [ + 'configure_malware', + i18n.translate('xpack.securitySolution.endpoint.hostDetails.policyResponse.configure_malware', { + defaultMessage: 'Configure Malware', + }), + ], + [ + 'configure_network_events', + i18n.translate( + 'xpack.securitySolution.endpoint.hostDetails.policyResponse.configure_network_events', + { defaultMessage: 'Configure Network Events' } + ), + ], + [ + 'configure_process_events', + i18n.translate( + 'xpack.securitySolution.endpoint.hostDetails.policyResponse.configure_process_events', + { defaultMessage: 'Configure Process Events' } + ), + ], + [ + 'configure_registry_events', + i18n.translate( + 'xpack.securitySolution.endpoint.hostDetails.policyResponse.configure_registry_events', + { defaultMessage: 'Configure Registry Events' } + ), + ], + [ + 'configure_security_events', + i18n.translate( + 'xpack.securitySolution.endpoint.hostDetails.policyResponse.configure_security_events', + { defaultMessage: 'Configure Security Events' } + ), + ], + [ + 'connect_kernel', + i18n.translate('xpack.securitySolution.endpoint.hostDetails.policyResponse.connect_kernel', { + defaultMessage: 'Connect Kernel', + }), + ], + [ + 'detect_async_image_load_events', + i18n.translate( + 'xpack.securitySolution.endpoint.hostDetails.policyResponse.detect_async_image_load_events', + { defaultMessage: 'Detect Async Image Load Events' } + ), + ], + [ + 'detect_file_open_events', + i18n.translate( + 'xpack.securitySolution.endpoint.hostDetails.policyResponse.detect_file_open_events', + { defaultMessage: 'Detect File Open Events' } + ), + ], + [ + 'detect_file_write_events', + i18n.translate( + 'xpack.securitySolution.endpoint.hostDetails.policyResponse.detect_file_write_events', + { defaultMessage: 'Detect File Write Events' } + ), + ], + [ + 'detect_network_events', + i18n.translate( + 'xpack.securitySolution.endpoint.hostDetails.policyResponse.detect_network_events', + { defaultMessage: 'Detect Network Events' } + ), + ], + [ + 'detect_process_events', + i18n.translate( + 'xpack.securitySolution.endpoint.hostDetails.policyResponse.detect_process_events', + { defaultMessage: 'Detect Process Events' } + ), + ], + [ + 'detect_registry_events', + i18n.translate( + 'xpack.securitySolution.endpoint.hostDetails.policyResponse.detect_registry_events', + { defaultMessage: 'Detect Registry Events' } + ), + ], + [ + 'detect_sync_image_load_events', + i18n.translate( + 'xpack.securitySolution.endpoint.hostDetails.policyResponse.detect_sync_image_load_events', + { defaultMessage: 'Detect Sync Image Load Events' } + ), + ], + [ + 'download_global_artifacts', + i18n.translate( + 'xpack.securitySolution.endpoint.hostDetails.policyResponse.download_global_artifacts', + { defaultMessage: 'Download Global Artifacts' } + ), + ], + [ + 'download_user_artifacts', + i18n.translate( + 'xpack.securitySolution.endpoint.hostDetails.policyResponse.download_user_artifacts', + { defaultMessage: 'Download User Artifacts' } + ), + ], + [ + 'load_config', + i18n.translate('xpack.securitySolution.endpoint.hostDetails.policyResponse.load_config', { + defaultMessage: 'Load Config', + }), + ], + [ + 'load_malware_model', + i18n.translate( + 'xpack.securitySolution.endpoint.hostDetails.policyResponse.load_malware_model', + { defaultMessage: 'Load Malware Model' } + ), + ], + [ + 'read_elasticsearch_config', + i18n.translate( + 'xpack.securitySolution.endpoint.hostDetails.policyResponse.read_elasticsearch_config', + { defaultMessage: 'Read ElasticSearch Config' } + ), + ], + [ + 'read_events_config', + i18n.translate( + 'xpack.securitySolution.endpoint.hostDetails.policyResponse.read_events_config', + { defaultMessage: 'Read Events Config' } + ), + ], + [ + 'read_kernel_config', + i18n.translate( + 'xpack.securitySolution.endpoint.hostDetails.policyResponse.read_kernel_config', + { defaultMessage: 'Read Kernel Config' } + ), + ], + [ + 'read_logging_config', + i18n.translate( + 'xpack.securitySolution.endpoint.hostDetails.policyResponse.read_logging_config', + { defaultMessage: 'Read Logging Config' } + ), + ], + [ + 'read_malware_config', + i18n.translate( + 'xpack.securitySolution.endpoint.hostDetails.policyResponse.read_malware_config', + { defaultMessage: 'Read Malware Config' } + ), + ], + [ + 'workflow', + i18n.translate('xpack.securitySolution.endpoint.hostDetails.policyResponse.workflow', { + defaultMessage: 'Workflow', + }), + ], +]; + +const responseMap = new Map(policyResponses); + +// Additional values used in the Policy Response UI responseMap.set( 'success', i18n.translate('xpack.securitySolution.endpoint.hostDetails.policyResponse.success', { @@ -49,144 +251,6 @@ responseMap.set( defaultMessage: 'Events', }) ); -responseMap.set( - 'configure_elasticsearch_connection', - i18n.translate( - 'xpack.securitySolution.endpoint.hostDetails.policyResponse.configureElasticSearchConnection', - { - defaultMessage: 'Configure Elastic Search Connection', - } - ) -); -responseMap.set( - 'configure_logging', - i18n.translate('xpack.securitySolution.endpoint.hostDetails.policyResponse.configureLogging', { - defaultMessage: 'Configure Logging', - }) -); -responseMap.set( - 'configure_kernel', - i18n.translate('xpack.securitySolution.endpoint.hostDetails.policyResponse.configureKernel', { - defaultMessage: 'Configure Kernel', - }) -); -responseMap.set( - 'configure_malware', - i18n.translate('xpack.securitySolution.endpoint.hostDetails.policyResponse.configureMalware', { - defaultMessage: 'Configure Malware', - }) -); -responseMap.set( - 'connect_kernel', - i18n.translate('xpack.securitySolution.endpoint.hostDetails.policyResponse.connectKernel', { - defaultMessage: 'Connect Kernel', - }) -); -responseMap.set( - 'detect_file_open_events', - i18n.translate( - 'xpack.securitySolution.endpoint.hostDetails.policyResponse.detectFileOpenEvents', - { - defaultMessage: 'Detect File Open Events', - } - ) -); -responseMap.set( - 'detect_file_write_events', - i18n.translate( - 'xpack.securitySolution.endpoint.hostDetails.policyResponse.detectFileWriteEvents', - { - defaultMessage: 'Detect File Write Events', - } - ) -); -responseMap.set( - 'detect_image_load_events', - i18n.translate( - 'xpack.securitySolution.endpoint.hostDetails.policyResponse.detectImageLoadEvents', - { - defaultMessage: 'Detect Image Load Events', - } - ) -); -responseMap.set( - 'detect_process_events', - i18n.translate('xpack.securitySolution.endpoint.hostDetails.policyResponse.detectProcessEvents', { - defaultMessage: 'Detect Process Events', - }) -); -responseMap.set( - 'download_global_artifacts', - i18n.translate( - 'xpack.securitySolution.endpoint.hostDetails.policyResponse.downloadGlobalArtifacts', - { - defaultMessage: 'Download Global Artifacts', - } - ) -); -responseMap.set( - 'load_config', - i18n.translate('xpack.securitySolution.endpoint.hostDetails.policyResponse.loadConfig', { - defaultMessage: 'Load Config', - }) -); -responseMap.set( - 'load_malware_model', - i18n.translate('xpack.securitySolution.endpoint.hostDetails.policyResponse.loadMalwareModel', { - defaultMessage: 'Load Malware Model', - }) -); -responseMap.set( - 'read_elasticsearch_config', - i18n.translate( - 'xpack.securitySolution.endpoint.hostDetails.policyResponse.readElasticSearchConfig', - { - defaultMessage: 'Read ElasticSearch Config', - } - ) -); -responseMap.set( - 'read_events_config', - i18n.translate('xpack.securitySolution.endpoint.hostDetails.policyResponse.readEventsConfig', { - defaultMessage: 'Read Events Config', - }) -); -responseMap.set( - 'read_kernel_config', - i18n.translate('xpack.securitySolution.endpoint.hostDetails.policyResponse.readKernelConfig', { - defaultMessage: 'Read Kernel Config', - }) -); -responseMap.set( - 'read_logging_config', - i18n.translate('xpack.securitySolution.endpoint.hostDetails.policyResponse.readLoggingConfig', { - defaultMessage: 'Read Logging Config', - }) -); -responseMap.set( - 'read_malware_config', - i18n.translate('xpack.securitySolution.endpoint.hostDetails.policyResponse.readMalwareConfig', { - defaultMessage: 'Read Malware Config', - }) -); -responseMap.set( - 'workflow', - i18n.translate('xpack.securitySolution.endpoint.hostDetails.policyResponse.workflow', { - defaultMessage: 'Workflow', - }) -); -responseMap.set( - 'download_model', - i18n.translate('xpack.securitySolution.endpoint.hostDetails.policyResponse.downloadModel', { - defaultMessage: 'Download Model', - }) -); -responseMap.set( - 'ingest_events_config', - i18n.translate('xpack.securitySolution.endpoint.hostDetails.policyResponse.injestEventsConfig', { - defaultMessage: 'Injest Events Config', - }) -); /** * Maps a server provided value to corresponding i18n'd string. @@ -195,5 +259,13 @@ export function formatResponse(responseString: string) { if (responseMap.has(responseString)) { return responseMap.get(responseString); } - return responseString; + + // Its possible for the UI to receive an Action name that it does not yet have a translation, + // thus we generate a label for it here by making it more user fiendly + responseMap.set( + responseString, + responseString.replace(/_/g, ' ').replace(/\b(\w)/g, (m) => m.toUpperCase()) + ); + + return responseMap.get(responseString); } diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.test.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.test.tsx index 996b987ea2be3..a61088e2edd29 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.test.tsx @@ -13,8 +13,9 @@ import { mockPolicyResultList } from '../../policy/store/policy_list/mock_policy import { AppContextTestRender, createAppRootMockRenderer } from '../../../../common/mock/endpoint'; import { HostInfo, - HostStatus, HostPolicyResponseActionStatus, + HostPolicyResponseAppliedAction, + HostStatus, } from '../../../../../common/endpoint/types'; import { EndpointDocGenerator } from '../../../../../common/endpoint/generate_data'; import { AppAction } from '../../../../common/store/actions'; @@ -251,6 +252,16 @@ describe('when on the hosts page', () => { ) { malwareResponseConfigurations.concerned_actions.push(downloadModelAction.name); } + + // Add an unknown Action Name - to ensure we handle the format of it on the UI + const unknownAction: HostPolicyResponseAppliedAction = { + status: HostPolicyResponseActionStatus.success, + message: 'test message', + name: 'a_new_unknown_action', + }; + policyResponse.Endpoint.policy.applied.actions.push(unknownAction); + malwareResponseConfigurations.concerned_actions.push(unknownAction.name); + reactTestingLibrary.act(() => { store.dispatch({ type: 'serverReturnedHostPolicyResponse', @@ -564,6 +575,10 @@ describe('when on the hosts page', () => { '?page_index=0&page_size=10&selected_host=1' ); }); + + it('should format unknown policy action names', async () => { + expect(renderResult.getByText('A New Unknown Action')).not.toBeNull(); + }); }); }); }); From 41c4f18b8961dcfe537c727c8547d28de7c8c501 Mon Sep 17 00:00:00 2001 From: Scotty Bollinger Date: Mon, 13 Jul 2020 13:10:35 -0500 Subject: [PATCH 14/66] Workplace Search in Kibana MVP (#70979) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add Workplace Search plugin to app - Adds telemetry for Workplace Search - Adds routing for telemetry and overview - Registers plugin * Add breadcrumbs for Workplace Search * Add Workplace Search index * Add route paths, types and shared assets * Add shared Workplace Search components * Add setup guide to Workplace Search * Add error state to Workplace Search * Add Workplace Search overview This is the functional MVP for Workplace Search * Update telemetry per recent changes - Remove saved objects indexing - add schema definition - remove no_ws_account - minor cleanup * Fix pluralization syntax - Still not working but fixed the syntax nonetheless * Change pluralization method - Was unable to get the `FormattedMessage` to work using the syntax in the docs. Always added ‘more’, even when there were zero (or one for users). This commit uses an alternative approach that works * Update readme * Fix duplicate i18n label * Fix failing test from previous commit :facepalm: * Update link for image in Setup Guide * Remove need for hash in routes Because of a change in the Workplace Search rails code, we can now use non-hash routes that will be redirected by rails so that we don’t have users stuck on the overview page in Workplace Search when logging in * Directly link to source details from activity feed Previously the dashboard in legacy Workplace Search linked to the sources page and this was replicated in the Kibana MVP. This PR aligns with the legacy dashboard directly linking to the source details https://github.com/elastic/ent-search/pull/1688 * Add warn logging to Workplace Search telemetry collector * Change casing to camel to match App Search * Misc security feedback for Workplace Search * Update licence mocks to match App Search * PR feedback from App Search PR * REmove duplicate code from merge conflict * Fix tests * Move varible declaration inside map for TypeScript There was no other way :facepalm: * Refactor last commit * Add punctuation Smallest commit ever. * Fix actionPath type errors * Update rebase feedback * Fix failing test * Update telemetry test after AS PR feedback * DRY out error state prompt copy * DRY out telemetry endpoint into a single route + DRY out DRY out endpoint - Instead of /api/app_search/telemetry & /api/workplace_search/telemetry, just have a single /api/enterprise_search/telemetry endpoint that takes a product param - Update public/send_telemetry accordingly (+ write tests for SendWorkplaceSearchTelemetry) DRY out helpers - Pull out certain reusable helper functions into a shared lib/ folder and have them take the repo id/name as a param - Move tests over - Remove misplaced comment block +BONUS - pull out content type header that's been giving us grief in Chrome into a constant * Remove unused telemetry type * Minor server cleanup - DRY out mockLogger * Setup Guide cleanup * Clean up Loading component - use EUI vars per feedback - remove unnecessary wrapper - adjust vh for Kibana layout - Actually apply loadingSpinner styles * Misc i18n fixes + minor newline reduction, because prettier lets me * Refactor Recent Activity component/styles - Remove table markup/styles - not semantically correct or accessible in this case - replace w flex - Fix link colors not inheriting - Add EuiPanel, error colors looked odd against page background - Fix prop/type definition - CSS cleanup - EUI vars, correct BEM, don't target generic selectors * [Opinionated] Refactor RecentActivity component - Pull out iterated activity items into a child subcomponent - Move constants/strings closer to where they're being used, instead of having to jump around the file - Move IActivityFeed definition to this file, since that's primarily where it's used @scottybollinger - if you're not a fan of this commit no worries, just let me know and we can discuss/roll back as needed * Refactor ViewContentHeader - remove unused CSS - fallback cleanup - refactor tests * Refactor ContentSection - Remove unused CSS classes - Refactor tests to include all props/more specific assertions * Refactor StatisticCard - Prefer using EuiTextColor to spans / custom classes - Prefer using EuiCard's native `href` behavior over using our own wrapping link/--isClickablec class - Note that when we port the link/destination over to React Router, we should instead opt to use React Router history, which will involve creating a EuiCard helper - Make test a bit more specific * Minor OrganizationStats cleanup - Use EuiFlexGrid * Refactor OnboardingSteps - i18n - Compact i18n newlines (nit) - Convert FormattedMessage to i18n.translate for easier test assertions - Org Name CTA - Move to separate child subcomponent to make it easier to quickly skim the parent container - Remove unused CSS class - Fix/add responsive behavior - Tests refactor - Use describe() blocks to break up tests by card/section - Make sure each card has tests for each state - zero, some/complete, and disabled/no access - Assert by plain text now that we're using i18n.translate() - Remove ContentSection/EuiPanel assertions - they're not terribly useful, and we have more specific elements to check - Add accounts={0} test to satisfy yellow branch line * Clean up OnboardingCard - Remove unused CSS class - Remove unnecessary template literal Tests - Swap out check for EuiFlexItem - it's not really the content we're concerned about displaying, EuiEmptyPrompt is the primary component - Remove need for mount() by dive()ing into EuiEmptyPrompt (this also removes the need to specify a[data-test-subj] instead of just [data-test-subj]) - Simplify empty button test - previous test has already checked for href/telemetry - Cover uncovered actionPath branch line * Minor Overview cleanup - Remove unused telemetry type - Remove unused CSS class - finally - Remove unused license context from tests * Feedback: UI fixes - Fix setup guide CSS class casing - Remove border transparent (UX > UI) * Fix Workplace Search not being hidden on feature control - Whoops, totally missed this :facepalm: * Add very basic functional Workplace Search test - Has to be without_host_configured, since with host requires Enterprise Search - Just checks for basic Setup Guide redirect for now - TODO: Add more in-depth feature/privilege functional tests for both plugins at later date * Pay down test render/loading tech debt - Turns out you don't need render(), shallow() skips useEffect already :facepalm: - Fix outdated comment import example * DRY out repeated mountWithApiMock into mountWithAsyncContext + Minor engines_overview test refactors: - Prefer to define `const wrapper` at the start of each test rather than a `let wrapper` - this better for sandboxing / not leaking state between tests - Move Platinum license tests above pagination, so the contrast between the two tests are easier to grok * Design feedback - README copy tweak + linting - Remove unused euiCard classes from onboarding card Co-authored-by: Constance Chen --- x-pack/plugins/enterprise_search/README.md | 5 +- .../enterprise_search/common/constants.ts | 2 + .../public/applications/__mocks__/index.ts | 6 +- .../__mocks__/mount_with_context.mock.tsx | 33 +++- .../__mocks__/shallow_usecontext.mock.ts | 2 +- .../empty_states/empty_states.test.tsx | 3 +- .../components/empty_states/error_state.tsx | 74 +------- .../engine_overview/engine_overview.test.tsx | 145 ++++++-------- .../public/applications/index.test.tsx | 10 +- .../error_state/error_state_prompt.test.tsx | 21 ++ .../shared/error_state/error_state_prompt.tsx | 79 ++++++++ .../applications/shared/error_state/index.ts | 7 + .../generate_breadcrumbs.test.ts | 85 ++++++++- .../generate_breadcrumbs.ts | 3 + .../shared/kibana_breadcrumbs/index.ts | 9 +- .../kibana_breadcrumbs/set_breadcrumbs.tsx | 29 ++- .../applications/shared/telemetry/index.ts | 1 + .../shared/telemetry/send_telemetry.test.tsx | 25 ++- .../shared/telemetry/send_telemetry.tsx | 19 +- .../public/applications/shared/types.ts | 14 ++ .../assets/getting_started.png | Bin 0 -> 487510 bytes .../workplace_search/assets/logo.svg | 5 + .../error_state/error_state.test.tsx | 21 ++ .../components/error_state/error_state.tsx | 34 ++++ .../components/error_state/index.ts | 7 + .../components/overview/index.ts | 7 + .../overview/onboarding_card.test.tsx | 54 ++++++ .../components/overview/onboarding_card.tsx | 92 +++++++++ .../overview/onboarding_steps.test.tsx | 136 +++++++++++++ .../components/overview/onboarding_steps.tsx | 179 ++++++++++++++++++ .../overview/organization_stats.test.tsx | 31 +++ .../overview/organization_stats.tsx | 74 ++++++++ .../components/overview/overview.test.tsx | 77 ++++++++ .../components/overview/overview.tsx | 151 +++++++++++++++ .../components/overview/recent_activity.scss | 37 ++++ .../overview/recent_activity.test.tsx | 61 ++++++ .../components/overview/recent_activity.tsx | 131 +++++++++++++ .../overview/statistic_card.test.tsx | 32 ++++ .../components/overview/statistic_card.tsx | 46 +++++ .../components/setup_guide/index.ts | 7 + .../setup_guide/setup_guide.test.tsx | 21 ++ .../components/setup_guide/setup_guide.tsx | 70 +++++++ .../components/shared/assets/share_circle.svg | 3 + .../content_section/content_section.test.tsx | 50 +++++ .../content_section/content_section.tsx | 45 +++++ .../shared/content_section/index.ts | 7 + .../components/shared/loading/index.ts | 7 + .../components/shared/loading/loading.scss | 14 ++ .../shared/loading/loading.test.tsx | 21 ++ .../components/shared/loading/loading.tsx | 17 ++ .../components/shared/product_button/index.ts | 7 + .../product_button/product_button.test.tsx | 38 ++++ .../shared/product_button/product_button.tsx | 41 ++++ .../components/shared/use_routes/index.ts | 7 + .../shared/use_routes/use_routes.tsx | 15 ++ .../shared/view_content_header/index.ts | 7 + .../view_content_header.test.tsx | 39 ++++ .../view_content_header.tsx | 42 ++++ .../workplace_search/index.test.tsx | 46 +++++ .../applications/workplace_search/index.tsx | 29 +++ .../applications/workplace_search/routes.ts | 12 ++ .../applications/workplace_search/types.ts | 16 ++ .../enterprise_search/public/plugin.ts | 29 ++- .../collectors/app_search/telemetry.test.ts | 49 +---- .../server/collectors/app_search/telemetry.ts | 55 +----- .../server/collectors/lib/telemetry.test.ts | 69 +++++++ .../server/collectors/lib/telemetry.ts | 62 ++++++ .../workplace_search/telemetry.test.ts | 101 ++++++++++ .../collectors/workplace_search/telemetry.ts | 115 +++++++++++ .../enterprise_search/server/plugin.ts | 31 +-- .../telemetry.test.ts | 81 ++++++-- .../telemetry.ts | 26 ++- .../routes/workplace_search/overview.test.ts | 127 +++++++++++++ .../routes/workplace_search/overview.ts | 46 +++++ .../workplace_search/telemetry.ts | 19 ++ .../schema/xpack_plugins.json | 37 ++++ .../app_search/setup_guide.ts | 2 +- .../without_host_configured/index.ts | 1 + .../workplace_search/setup_guide.ts | 36 ++++ .../page_objects/index.ts | 2 + .../page_objects/workplace_search.ts | 17 ++ .../security_and_spaces/tests/catalogue.ts | 3 +- .../security_and_spaces/tests/nav_links.ts | 8 +- .../security_only/tests/catalogue.ts | 3 +- .../security_only/tests/nav_links.ts | 2 +- 85 files changed, 2908 insertions(+), 321 deletions(-) create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/error_state/error_state_prompt.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/error_state/error_state_prompt.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/error_state/index.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/shared/types.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/assets/getting_started.png create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/assets/logo.svg create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/components/error_state/error_state.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/components/error_state/error_state.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/components/error_state/index.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/index.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/onboarding_card.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/onboarding_card.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/onboarding_steps.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/onboarding_steps.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/organization_stats.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/organization_stats.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/overview.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/overview.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/recent_activity.scss create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/recent_activity.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/recent_activity.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/statistic_card.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/statistic_card.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/components/setup_guide/index.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/components/setup_guide/setup_guide.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/components/setup_guide/setup_guide.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/assets/share_circle.svg create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/content_section/content_section.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/content_section/content_section.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/content_section/index.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/loading/index.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/loading/loading.scss create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/loading/loading.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/loading/loading.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/product_button/index.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/product_button/product_button.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/product_button/product_button.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/use_routes/index.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/use_routes/use_routes.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/view_content_header/index.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/view_content_header/view_content_header.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/view_content_header/view_content_header.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/index.test.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/index.tsx create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/routes.ts create mode 100644 x-pack/plugins/enterprise_search/public/applications/workplace_search/types.ts create mode 100644 x-pack/plugins/enterprise_search/server/collectors/lib/telemetry.test.ts create mode 100644 x-pack/plugins/enterprise_search/server/collectors/lib/telemetry.ts create mode 100644 x-pack/plugins/enterprise_search/server/collectors/workplace_search/telemetry.test.ts create mode 100644 x-pack/plugins/enterprise_search/server/collectors/workplace_search/telemetry.ts rename x-pack/plugins/enterprise_search/server/routes/{app_search => enterprise_search}/telemetry.test.ts (56%) rename x-pack/plugins/enterprise_search/server/routes/{app_search => enterprise_search}/telemetry.ts (55%) create mode 100644 x-pack/plugins/enterprise_search/server/routes/workplace_search/overview.test.ts create mode 100644 x-pack/plugins/enterprise_search/server/routes/workplace_search/overview.ts create mode 100644 x-pack/plugins/enterprise_search/server/saved_objects/workplace_search/telemetry.ts create mode 100644 x-pack/test/functional_enterprise_search/apps/enterprise_search/without_host_configured/workplace_search/setup_guide.ts create mode 100644 x-pack/test/functional_enterprise_search/page_objects/workplace_search.ts diff --git a/x-pack/plugins/enterprise_search/README.md b/x-pack/plugins/enterprise_search/README.md index 8c316c848184b..31ee304fe2247 100644 --- a/x-pack/plugins/enterprise_search/README.md +++ b/x-pack/plugins/enterprise_search/README.md @@ -2,7 +2,10 @@ ## Overview -This plugin's goal is to provide a Kibana user interface to the Enterprise Search solution's products (App Search and Workplace Search). In its current MVP state, the plugin provides a basic engines overview from App Search with the goal of gathering user feedback and raising product awareness. +This plugin's goal is to provide a Kibana user interface to the Enterprise Search solution's products (App Search and Workplace Search). In it's current MVP state, the plugin provides the following with the goal of gathering user feedback and raising product awareness: + +- **App Search:** A basic engines overview with links into the product. +- **Workplace Search:** A simple app overview with basic statistics, links to the sources, users (if standard auth), and product settings. ## Development diff --git a/x-pack/plugins/enterprise_search/common/constants.ts b/x-pack/plugins/enterprise_search/common/constants.ts index c134131caba75..fc9a47717871b 100644 --- a/x-pack/plugins/enterprise_search/common/constants.ts +++ b/x-pack/plugins/enterprise_search/common/constants.ts @@ -4,4 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ +export const JSON_HEADER = { 'Content-Type': 'application/json' }; // This needs specific casing or Chrome throws a 415 error + export const ENGINES_PAGE_SIZE = 10; diff --git a/x-pack/plugins/enterprise_search/public/applications/__mocks__/index.ts b/x-pack/plugins/enterprise_search/public/applications/__mocks__/index.ts index 14fde357a980a..6f82946c0ea14 100644 --- a/x-pack/plugins/enterprise_search/public/applications/__mocks__/index.ts +++ b/x-pack/plugins/enterprise_search/public/applications/__mocks__/index.ts @@ -7,7 +7,11 @@ export { mockHistory } from './react_router_history.mock'; export { mockKibanaContext } from './kibana_context.mock'; export { mockLicenseContext } from './license_context.mock'; -export { mountWithContext, mountWithKibanaContext } from './mount_with_context.mock'; +export { + mountWithContext, + mountWithKibanaContext, + mountWithAsyncContext, +} from './mount_with_context.mock'; export { shallowWithIntl } from './shallow_with_i18n.mock'; // Note: shallow_usecontext must be imported directly as a file diff --git a/x-pack/plugins/enterprise_search/public/applications/__mocks__/mount_with_context.mock.tsx b/x-pack/plugins/enterprise_search/public/applications/__mocks__/mount_with_context.mock.tsx index dfcda544459d4..1e0df1326c177 100644 --- a/x-pack/plugins/enterprise_search/public/applications/__mocks__/mount_with_context.mock.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/__mocks__/mount_with_context.mock.tsx @@ -5,7 +5,8 @@ */ import React from 'react'; -import { mount } from 'enzyme'; +import { act } from 'react-dom/test-utils'; +import { mount, ReactWrapper } from 'enzyme'; import { I18nProvider } from '@kbn/i18n/react'; import { KibanaContext } from '../'; @@ -47,3 +48,33 @@ export const mountWithKibanaContext = (children: React.ReactNode, context?: obje ); }; + +/** + * This helper is intended for components that have async effects + * (e.g. http fetches) on mount. It mostly adds act/update boilerplate + * that's needed for the wrapper to play nice with Enzyme/Jest + * + * Example usage: + * + * const wrapper = mountWithAsyncContext(, { http: { get: () => someData } }); + */ +export const mountWithAsyncContext = async ( + children: React.ReactNode, + context: object +): Promise => { + let wrapper: ReactWrapper | undefined; + + // We get a lot of act() warning/errors in the terminal without this. + // TBH, I don't fully understand why since Enzyme's mount is supposed to + // have act() baked in - could be because of the wrapping context provider? + await act(async () => { + wrapper = mountWithContext(children, context); + }); + if (wrapper) { + wrapper.update(); // This seems to be required for the DOM to actually update + + return wrapper; + } else { + throw new Error('Could not mount wrapper'); + } +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/__mocks__/shallow_usecontext.mock.ts b/x-pack/plugins/enterprise_search/public/applications/__mocks__/shallow_usecontext.mock.ts index 767a52a75d1fb..2bcdd42c38055 100644 --- a/x-pack/plugins/enterprise_search/public/applications/__mocks__/shallow_usecontext.mock.ts +++ b/x-pack/plugins/enterprise_search/public/applications/__mocks__/shallow_usecontext.mock.ts @@ -19,7 +19,7 @@ jest.mock('react', () => ({ /** * Example usage within a component test using shallow(): * - * import '../../../test_utils/mock_shallow_usecontext'; // Must come before React's import, adjust relative path as needed + * import '../../../__mocks__/shallow_usecontext'; // Must come before React's import, adjust relative path as needed * * import React from 'react'; * import { shallow } from 'enzyme'; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/empty_states.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/empty_states.test.tsx index 12bf003564103..25a9fa7430c40 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/empty_states.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/empty_states.test.tsx @@ -9,6 +9,7 @@ import '../../../__mocks__/shallow_usecontext.mock'; import React from 'react'; import { shallow } from 'enzyme'; import { EuiEmptyPrompt, EuiButton, EuiLoadingContent } from '@elastic/eui'; +import { ErrorStatePrompt } from '../../../shared/error_state'; jest.mock('../../../shared/telemetry', () => ({ sendTelemetry: jest.fn(), @@ -22,7 +23,7 @@ describe('ErrorState', () => { it('renders', () => { const wrapper = shallow(); - expect(wrapper.find(EuiEmptyPrompt)).toHaveLength(1); + expect(wrapper.find(ErrorStatePrompt)).toHaveLength(1); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/error_state.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/error_state.tsx index d8eeff2aba1c6..7ac02082ee75c 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/error_state.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/empty_states/error_state.tsx @@ -4,21 +4,17 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useContext } from 'react'; -import { EuiPage, EuiPageBody, EuiPageContent, EuiEmptyPrompt, EuiCode } from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n/react'; +import React from 'react'; +import { EuiPage, EuiPageBody, EuiPageContent } from '@elastic/eui'; -import { EuiButton } from '../../../shared/react_router_helpers'; +import { ErrorStatePrompt } from '../../../shared/error_state'; import { SetAppSearchBreadcrumbs as SetBreadcrumbs } from '../../../shared/kibana_breadcrumbs'; import { SendAppSearchTelemetry as SendTelemetry } from '../../../shared/telemetry'; -import { KibanaContext, IKibanaContext } from '../../../index'; import { EngineOverviewHeader } from '../engine_overview_header'; import './empty_states.scss'; export const ErrorState: React.FC = () => { - const { enterpriseSearchUrl } = useContext(KibanaContext) as IKibanaContext; - return ( @@ -26,68 +22,8 @@ export const ErrorState: React.FC = () => { - - - - - } - titleSize="l" - body={ - <> -

- {enterpriseSearchUrl}, - }} - /> -

-
    -
  1. - config/kibana.yml, - }} - /> -
  2. -
  3. - -
  4. -
  5. - [enterpriseSearch][plugins], - }} - /> -
  6. -
- - } - actions={ - - - - } - /> + +
diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.test.tsx index 4d2a2ea1df9aa..45ab5dc5b9ab1 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/engine_overview/engine_overview.test.tsx @@ -8,51 +8,45 @@ import '../../../__mocks__/react_router_history.mock'; import React from 'react'; import { act } from 'react-dom/test-utils'; -import { render, ReactWrapper } from 'enzyme'; +import { shallow, ReactWrapper } from 'enzyme'; -import { I18nProvider } from '@kbn/i18n/react'; -import { KibanaContext } from '../../../'; -import { LicenseContext } from '../../../shared/licensing'; -import { mountWithContext, mockKibanaContext } from '../../../__mocks__'; +import { mountWithAsyncContext, mockKibanaContext } from '../../../__mocks__'; -import { EmptyState, ErrorState } from '../empty_states'; -import { EngineTable, IEngineTablePagination } from './engine_table'; +import { LoadingState, EmptyState, ErrorState } from '../empty_states'; +import { EngineTable } from './engine_table'; import { EngineOverview } from './'; describe('EngineOverview', () => { + const mockHttp = mockKibanaContext.http; + describe('non-happy-path states', () => { it('isLoading', () => { - // We use render() instead of mount() here to not trigger lifecycle methods (i.e., useEffect) - // TODO: Consider pulling this out to a renderWithContext mock/helper - const wrapper: Cheerio = render( - - - - - - - - ); - - // render() directly renders HTML which means we have to look for selectors instead of for LoadingState directly - expect(wrapper.find('.euiLoadingContent')).toHaveLength(2); + const wrapper = shallow(); + + expect(wrapper.find(LoadingState)).toHaveLength(1); }); it('isEmpty', async () => { - const wrapper = await mountWithApiMock({ - get: () => ({ - results: [], - meta: { page: { total_results: 0 } }, - }), + const wrapper = await mountWithAsyncContext(, { + http: { + ...mockHttp, + get: () => ({ + results: [], + meta: { page: { total_results: 0 } }, + }), + }, }); expect(wrapper.find(EmptyState)).toHaveLength(1); }); it('hasErrorConnecting', async () => { - const wrapper = await mountWithApiMock({ - get: () => ({ invalidPayload: true }), + const wrapper = await mountWithAsyncContext(, { + http: { + ...mockHttp, + get: () => ({ invalidPayload: true }), + }, }); expect(wrapper.find(ErrorState)).toHaveLength(1); }); @@ -78,17 +72,17 @@ describe('EngineOverview', () => { }, }; const mockApi = jest.fn(() => mockedApiResponse); - let wrapper: ReactWrapper; - beforeAll(async () => { - wrapper = await mountWithApiMock({ get: mockApi }); + beforeEach(() => { + jest.clearAllMocks(); }); - it('renders', () => { - expect(wrapper.find(EngineTable)).toHaveLength(1); - }); + it('renders and calls the engines API', async () => { + const wrapper = await mountWithAsyncContext(, { + http: { ...mockHttp, get: mockApi }, + }); - it('calls the engines API', () => { + expect(wrapper.find(EngineTable)).toHaveLength(1); expect(mockApi).toHaveBeenNthCalledWith(1, '/api/app_search/engines', { query: { type: 'indexed', @@ -97,19 +91,42 @@ describe('EngineOverview', () => { }); }); + describe('when on a platinum license', () => { + it('renders a 2nd meta engines table & makes a 2nd meta engines API call', async () => { + const wrapper = await mountWithAsyncContext(, { + http: { ...mockHttp, get: mockApi }, + license: { type: 'platinum', isActive: true }, + }); + + expect(wrapper.find(EngineTable)).toHaveLength(2); + expect(mockApi).toHaveBeenNthCalledWith(2, '/api/app_search/engines', { + query: { + type: 'meta', + pageIndex: 1, + }, + }); + }); + }); + describe('pagination', () => { - const getTablePagination: () => IEngineTablePagination = () => - wrapper.find(EngineTable).first().prop('pagination'); + const getTablePagination = (wrapper: ReactWrapper) => + wrapper.find(EngineTable).prop('pagination'); - it('passes down page data from the API', () => { - const pagination = getTablePagination(); + it('passes down page data from the API', async () => { + const wrapper = await mountWithAsyncContext(, { + http: { ...mockHttp, get: mockApi }, + }); + const pagination = getTablePagination(wrapper); expect(pagination.totalEngines).toEqual(100); expect(pagination.pageIndex).toEqual(0); }); it('re-polls the API on page change', async () => { - await act(async () => getTablePagination().onPaginate(5)); + const wrapper = await mountWithAsyncContext(, { + http: { ...mockHttp, get: mockApi }, + }); + await act(async () => getTablePagination(wrapper).onPaginate(5)); wrapper.update(); expect(mockApi).toHaveBeenLastCalledWith('/api/app_search/engines', { @@ -118,54 +135,8 @@ describe('EngineOverview', () => { pageIndex: 5, }, }); - expect(getTablePagination().pageIndex).toEqual(4); - }); - }); - - describe('when on a platinum license', () => { - beforeAll(async () => { - mockApi.mockClear(); - wrapper = await mountWithApiMock({ - license: { type: 'platinum', isActive: true }, - get: mockApi, - }); - }); - - it('renders a 2nd meta engines table', () => { - expect(wrapper.find(EngineTable)).toHaveLength(2); - }); - - it('makes a 2nd call to the engines API with type meta', () => { - expect(mockApi).toHaveBeenNthCalledWith(2, '/api/app_search/engines', { - query: { - type: 'meta', - pageIndex: 1, - }, - }); + expect(getTablePagination(wrapper).pageIndex).toEqual(4); }); }); }); - - /** - * Test helpers - */ - - const mountWithApiMock = async ({ get, license }: { get(): any; license?: object }) => { - let wrapper: ReactWrapper | undefined; - const httpMock = { ...mockKibanaContext.http, get }; - - // We get a lot of act() warning/errors in the terminal without this. - // TBH, I don't fully understand why since Enzyme's mount is supposed to - // have act() baked in - could be because of the wrapping context provider? - await act(async () => { - wrapper = mountWithContext(, { http: httpMock, license }); - }); - if (wrapper) { - wrapper.update(); // This seems to be required for the DOM to actually update - - return wrapper; - } else { - throw new Error('Could not mount wrapper'); - } - }; }); diff --git a/x-pack/plugins/enterprise_search/public/applications/index.test.tsx b/x-pack/plugins/enterprise_search/public/applications/index.test.tsx index 1aead8468ca3b..70e16e61846b4 100644 --- a/x-pack/plugins/enterprise_search/public/applications/index.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/index.test.tsx @@ -6,14 +6,16 @@ import React from 'react'; +import { AppMountParameters } from 'src/core/public'; import { coreMock } from 'src/core/public/mocks'; import { licensingMock } from '../../../licensing/public/mocks'; import { renderApp } from './'; import { AppSearch } from './app_search'; +import { WorkplaceSearch } from './workplace_search'; describe('renderApp', () => { - const params = coreMock.createAppMountParamters(); + let params: AppMountParameters; const core = coreMock.createStart(); const config = {}; const plugins = { @@ -22,6 +24,7 @@ describe('renderApp', () => { beforeEach(() => { jest.clearAllMocks(); + params = coreMock.createAppMountParamters(); }); it('mounts and unmounts UI', () => { @@ -37,4 +40,9 @@ describe('renderApp', () => { renderApp(AppSearch, core, params, config, plugins); expect(params.element.querySelector('.setupGuide')).not.toBeNull(); }); + + it('renders WorkplaceSearch', () => { + renderApp(WorkplaceSearch, core, params, config, plugins); + expect(params.element.querySelector('.setupGuide')).not.toBeNull(); + }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/error_state/error_state_prompt.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/error_state/error_state_prompt.test.tsx new file mode 100644 index 0000000000000..29b773b80158a --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/error_state/error_state_prompt.test.tsx @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import '../../__mocks__/shallow_usecontext.mock'; + +import React from 'react'; +import { shallow } from 'enzyme'; +import { EuiEmptyPrompt } from '@elastic/eui'; + +import { ErrorStatePrompt } from './'; + +describe('ErrorState', () => { + it('renders', () => { + const wrapper = shallow(); + + expect(wrapper.find(EuiEmptyPrompt)).toHaveLength(1); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/error_state/error_state_prompt.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/error_state/error_state_prompt.tsx new file mode 100644 index 0000000000000..81455cea0b497 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/error_state/error_state_prompt.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useContext } from 'react'; +import { EuiEmptyPrompt, EuiCode } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; + +import { EuiButton } from '../react_router_helpers'; +import { KibanaContext, IKibanaContext } from '../../index'; + +export const ErrorStatePrompt: React.FC = () => { + const { enterpriseSearchUrl } = useContext(KibanaContext) as IKibanaContext; + + return ( + + + + } + titleSize="l" + body={ + <> +

+ {enterpriseSearchUrl}, + }} + /> +

+
    +
  1. + config/kibana.yml, + }} + /> +
  2. +
  3. + +
  4. +
  5. + [enterpriseSearch][plugins], + }} + /> +
  6. +
+ + } + actions={ + + + + } + /> + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/error_state/index.ts b/x-pack/plugins/enterprise_search/public/applications/shared/error_state/index.ts new file mode 100644 index 0000000000000..1012fdf4126a2 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/error_state/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { ErrorStatePrompt } from './error_state_prompt'; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/generate_breadcrumbs.test.ts b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/generate_breadcrumbs.test.ts index 7ea73577c4de6..70aa723d62601 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/generate_breadcrumbs.test.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/generate_breadcrumbs.test.ts @@ -5,7 +5,7 @@ */ import { generateBreadcrumb } from './generate_breadcrumbs'; -import { appSearchBreadcrumbs, enterpriseSearchBreadcrumbs } from './'; +import { appSearchBreadcrumbs, enterpriseSearchBreadcrumbs, workplaceSearchBreadcrumbs } from './'; import { mockHistory as mockHistoryUntyped } from '../../__mocks__'; const mockHistory = mockHistoryUntyped as any; @@ -204,3 +204,86 @@ describe('appSearchBreadcrumbs', () => { }); }); }); + +describe('workplaceSearchBreadcrumbs', () => { + const breadCrumbs = [ + { + text: 'Page 1', + path: '/page1', + }, + { + text: 'Page 2', + path: '/page2', + }, + ]; + + beforeEach(() => { + jest.clearAllMocks(); + mockHistory.createHref.mockImplementation( + ({ pathname }: any) => `/enterprise_search/workplace_search${pathname}` + ); + }); + + const subject = () => workplaceSearchBreadcrumbs(mockHistory)(breadCrumbs); + + it('Builds a chain of breadcrumbs with Enterprise Search and Workplace Search at the root', () => { + expect(subject()).toEqual([ + { + text: 'Enterprise Search', + }, + { + href: '/enterprise_search/workplace_search/', + onClick: expect.any(Function), + text: 'Workplace Search', + }, + { + href: '/enterprise_search/workplace_search/page1', + onClick: expect.any(Function), + text: 'Page 1', + }, + { + href: '/enterprise_search/workplace_search/page2', + onClick: expect.any(Function), + text: 'Page 2', + }, + ]); + }); + + it('shows just the root if breadcrumbs is empty', () => { + expect(workplaceSearchBreadcrumbs(mockHistory)()).toEqual([ + { + text: 'Enterprise Search', + }, + { + href: '/enterprise_search/workplace_search/', + onClick: expect.any(Function), + text: 'Workplace Search', + }, + ]); + }); + + describe('links', () => { + const eventMock = { + preventDefault: jest.fn(), + } as any; + + it('has Enterprise Search text first', () => { + expect(subject()[0].onClick).toBeUndefined(); + }); + + it('has a link to Workplace Search second', () => { + (subject()[1] as any).onClick(eventMock); + expect(mockHistory.push).toHaveBeenCalledWith('/'); + }); + + it('has a link to page 1 third', () => { + (subject()[2] as any).onClick(eventMock); + expect(mockHistory.push).toHaveBeenCalledWith('/page1'); + }); + + it('has a link to page 2 last', () => { + (subject()[3] as any).onClick(eventMock); + expect(mockHistory.push).toHaveBeenCalledWith('/page2'); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/generate_breadcrumbs.ts b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/generate_breadcrumbs.ts index 8f72875a32bd4..b57fdfdbb75ca 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/generate_breadcrumbs.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/generate_breadcrumbs.ts @@ -52,3 +52,6 @@ export const enterpriseSearchBreadcrumbs = (history: History) => ( export const appSearchBreadcrumbs = (history: History) => (breadcrumbs: TBreadcrumbs = []) => enterpriseSearchBreadcrumbs(history)([{ text: 'App Search', path: '/' }, ...breadcrumbs]); + +export const workplaceSearchBreadcrumbs = (history: History) => (breadcrumbs: TBreadcrumbs = []) => + enterpriseSearchBreadcrumbs(history)([{ text: 'Workplace Search', path: '/' }, ...breadcrumbs]); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/index.ts b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/index.ts index cf8bbbc593f2f..c4ef68704b7e0 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/index.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/index.ts @@ -4,6 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -export { enterpriseSearchBreadcrumbs } from './generate_breadcrumbs'; -export { appSearchBreadcrumbs } from './generate_breadcrumbs'; -export { SetAppSearchBreadcrumbs } from './set_breadcrumbs'; +export { + enterpriseSearchBreadcrumbs, + appSearchBreadcrumbs, + workplaceSearchBreadcrumbs, +} from './generate_breadcrumbs'; +export { SetAppSearchBreadcrumbs, SetWorkplaceSearchBreadcrumbs } from './set_breadcrumbs'; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/set_breadcrumbs.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/set_breadcrumbs.tsx index 530117e197616..e54f1a12b73cb 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/set_breadcrumbs.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/kibana_breadcrumbs/set_breadcrumbs.tsx @@ -8,7 +8,11 @@ import React, { useContext, useEffect } from 'react'; import { useHistory } from 'react-router-dom'; import { EuiBreadcrumb } from '@elastic/eui'; import { KibanaContext, IKibanaContext } from '../../index'; -import { appSearchBreadcrumbs, TBreadcrumbs } from './generate_breadcrumbs'; +import { + appSearchBreadcrumbs, + workplaceSearchBreadcrumbs, + TBreadcrumbs, +} from './generate_breadcrumbs'; /** * Small on-mount helper for setting Kibana's chrome breadcrumbs on any App Search view @@ -17,19 +21,17 @@ import { appSearchBreadcrumbs, TBreadcrumbs } from './generate_breadcrumbs'; export type TSetBreadcrumbs = (breadcrumbs: EuiBreadcrumb[]) => void; -interface IBreadcrumbProps { +interface IBreadcrumbsProps { text: string; isRoot?: never; } -interface IRootBreadcrumbProps { +interface IRootBreadcrumbsProps { isRoot: true; text?: never; } +type TBreadcrumbsProps = IBreadcrumbsProps | IRootBreadcrumbsProps; -export const SetAppSearchBreadcrumbs: React.FC = ({ - text, - isRoot, -}) => { +export const SetAppSearchBreadcrumbs: React.FC = ({ text, isRoot }) => { const history = useHistory(); const { setBreadcrumbs } = useContext(KibanaContext) as IKibanaContext; @@ -41,3 +43,16 @@ export const SetAppSearchBreadcrumbs: React.FC = ({ text, isRoot }) => { + const history = useHistory(); + const { setBreadcrumbs } = useContext(KibanaContext) as IKibanaContext; + + const crumb = isRoot ? [] : [{ text, path: history.location.pathname }]; + + useEffect(() => { + setBreadcrumbs(workplaceSearchBreadcrumbs(history)(crumb as TBreadcrumbs | [])); + }, []); + + return null; +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/telemetry/index.ts b/x-pack/plugins/enterprise_search/public/applications/shared/telemetry/index.ts index f871f48b17154..eadf7fa805590 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/telemetry/index.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/telemetry/index.ts @@ -6,3 +6,4 @@ export { sendTelemetry } from './send_telemetry'; export { SendAppSearchTelemetry } from './send_telemetry'; +export { SendWorkplaceSearchTelemetry } from './send_telemetry'; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/telemetry/send_telemetry.test.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/telemetry/send_telemetry.test.tsx index 9825c0d8ab889..3c873dbc25e37 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/telemetry/send_telemetry.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/telemetry/send_telemetry.test.tsx @@ -7,8 +7,10 @@ import React from 'react'; import { httpServiceMock } from 'src/core/public/mocks'; +import { JSON_HEADER as headers } from '../../../../common/constants'; import { mountWithKibanaContext } from '../../__mocks__'; -import { sendTelemetry, SendAppSearchTelemetry } from './'; + +import { sendTelemetry, SendAppSearchTelemetry, SendWorkplaceSearchTelemetry } from './'; describe('Shared Telemetry Helpers', () => { const httpMock = httpServiceMock.createSetupContract(); @@ -27,8 +29,8 @@ describe('Shared Telemetry Helpers', () => { }); expect(httpMock.put).toHaveBeenCalledWith('/api/enterprise_search/telemetry', { - headers: { 'Content-Type': 'application/json' }, - body: '{"action":"viewed","metric":"setup_guide"}', + headers, + body: '{"product":"enterprise_search","action":"viewed","metric":"setup_guide"}', }); }); @@ -47,9 +49,20 @@ describe('Shared Telemetry Helpers', () => { http: httpMock, }); - expect(httpMock.put).toHaveBeenCalledWith('/api/app_search/telemetry', { - headers: { 'Content-Type': 'application/json' }, - body: '{"action":"clicked","metric":"button"}', + expect(httpMock.put).toHaveBeenCalledWith('/api/enterprise_search/telemetry', { + headers, + body: '{"product":"app_search","action":"clicked","metric":"button"}', + }); + }); + + it('SendWorkplaceSearchTelemetry component', () => { + mountWithKibanaContext(, { + http: httpMock, + }); + + expect(httpMock.put).toHaveBeenCalledWith('/api/enterprise_search/telemetry', { + headers, + body: '{"product":"workplace_search","action":"viewed","metric":"page"}', }); }); }); diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/telemetry/send_telemetry.tsx b/x-pack/plugins/enterprise_search/public/applications/shared/telemetry/send_telemetry.tsx index 300cb18272717..715d61b31512c 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/telemetry/send_telemetry.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/shared/telemetry/send_telemetry.tsx @@ -7,6 +7,7 @@ import React, { useContext, useEffect } from 'react'; import { HttpSetup } from 'src/core/public'; +import { JSON_HEADER as headers } from '../../../../common/constants'; import { KibanaContext, IKibanaContext } from '../../index'; interface ISendTelemetryProps { @@ -25,10 +26,8 @@ interface ISendTelemetry extends ISendTelemetryProps { export const sendTelemetry = async ({ http, product, action, metric }: ISendTelemetry) => { try { - await http.put(`/api/${product}/telemetry`, { - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ action, metric }), - }); + const body = JSON.stringify({ product, action, metric }); + await http.put('/api/enterprise_search/telemetry', { headers, body }); } catch (error) { throw new Error('Unable to send telemetry'); } @@ -36,7 +35,7 @@ export const sendTelemetry = async ({ http, product, action, metric }: ISendTele /** * React component helpers - useful for on-page-load/views - * TODO: SendWorkplaceSearchTelemetry and SendEnterpriseSearchTelemetry + * TODO: SendEnterpriseSearchTelemetry */ export const SendAppSearchTelemetry: React.FC = ({ action, metric }) => { @@ -48,3 +47,13 @@ export const SendAppSearchTelemetry: React.FC = ({ action, return null; }; + +export const SendWorkplaceSearchTelemetry: React.FC = ({ action, metric }) => { + const { http } = useContext(KibanaContext) as IKibanaContext; + + useEffect(() => { + sendTelemetry({ http, action, metric, product: 'workplace_search' }); + }, [action, metric, http]); + + return null; +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/types.ts b/x-pack/plugins/enterprise_search/public/applications/shared/types.ts new file mode 100644 index 0000000000000..3f28710d92295 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/shared/types.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export interface IFlashMessagesProps { + info?: string[]; + warning?: string[]; + error?: string[]; + success?: string[]; + isWrapped?: boolean; + children?: React.ReactNode; +} diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/assets/getting_started.png b/x-pack/plugins/enterprise_search/public/applications/workplace_search/assets/getting_started.png new file mode 100644 index 0000000000000000000000000000000000000000..b6267b6e2c48e614a6376dc8d64ff8279d836670 GIT binary patch literal 487510 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�D8b` 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 z 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�-%YE+mZ!Q%COqMZMI10o8dQY0FhZx4(qg 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 uhq#GFh>9hF + + + + diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/error_state/error_state.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/error_state/error_state.test.tsx new file mode 100644 index 0000000000000..ab5cd7f0de90f --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/error_state/error_state.test.tsx @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import '../../../__mocks__/shallow_usecontext.mock'; + +import React from 'react'; +import { shallow } from 'enzyme'; + +import { ErrorStatePrompt } from '../../../shared/error_state'; +import { ErrorState } from './'; + +describe('ErrorState', () => { + it('renders', () => { + const wrapper = shallow(); + + expect(wrapper.find(ErrorStatePrompt)).toHaveLength(1); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/error_state/error_state.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/error_state/error_state.tsx new file mode 100644 index 0000000000000..9fa508d599425 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/error_state/error_state.tsx @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { EuiPage, EuiPageBody, EuiPageContent } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +import { ErrorStatePrompt } from '../../../shared/error_state'; +import { SetWorkplaceSearchBreadcrumbs as SetBreadcrumbs } from '../../../shared/kibana_breadcrumbs'; +import { SendWorkplaceSearchTelemetry as SendTelemetry } from '../../../shared/telemetry'; +import { ViewContentHeader } from '../shared/view_content_header'; + +export const ErrorState: React.FC = () => { + return ( + + + + + + + + + + + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/error_state/index.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/error_state/index.ts new file mode 100644 index 0000000000000..b4d58bab58ff1 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/error_state/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { ErrorState } from './error_state'; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/index.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/index.ts new file mode 100644 index 0000000000000..9ee1b444ee817 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { Overview } from './overview'; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/onboarding_card.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/onboarding_card.test.tsx new file mode 100644 index 0000000000000..1d7c565935e97 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/onboarding_card.test.tsx @@ -0,0 +1,54 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import '../../../__mocks__/shallow_usecontext.mock'; + +import React from 'react'; +import { shallow } from 'enzyme'; + +import { EuiEmptyPrompt, EuiButton, EuiButtonEmpty } from '@elastic/eui'; + +import { OnboardingCard } from './onboarding_card'; + +jest.mock('../../../shared/telemetry', () => ({ sendTelemetry: jest.fn() })); +import { sendTelemetry } from '../../../shared/telemetry'; + +const cardProps = { + title: 'My card', + icon: 'icon', + description: 'this is a card', + actionTitle: 'action', + testSubj: 'actionButton', +}; + +describe('OnboardingCard', () => { + it('renders', () => { + const wrapper = shallow(); + expect(wrapper.find(EuiEmptyPrompt)).toHaveLength(1); + }); + + it('renders an action button', () => { + const wrapper = shallow(); + const prompt = wrapper.find(EuiEmptyPrompt).dive(); + + expect(prompt.find(EuiButton)).toHaveLength(1); + expect(prompt.find(EuiButtonEmpty)).toHaveLength(0); + + const button = prompt.find('[data-test-subj="actionButton"]'); + expect(button.prop('href')).toBe('http://localhost:3002/ws/some_path'); + + button.simulate('click'); + expect(sendTelemetry).toHaveBeenCalled(); + }); + + it('renders an empty button when onboarding is completed', () => { + const wrapper = shallow(); + const prompt = wrapper.find(EuiEmptyPrompt).dive(); + + expect(prompt.find(EuiButton)).toHaveLength(0); + expect(prompt.find(EuiButtonEmpty)).toHaveLength(1); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/onboarding_card.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/onboarding_card.tsx new file mode 100644 index 0000000000000..288c0be84fa9a --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/onboarding_card.tsx @@ -0,0 +1,92 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useContext } from 'react'; + +import { + EuiButton, + EuiButtonEmpty, + EuiFlexItem, + EuiPanel, + EuiEmptyPrompt, + IconType, + EuiButtonProps, + EuiButtonEmptyProps, + EuiLinkProps, +} from '@elastic/eui'; +import { useRoutes } from '../shared/use_routes'; +import { sendTelemetry } from '../../../shared/telemetry'; +import { KibanaContext, IKibanaContext } from '../../../index'; + +interface IOnboardingCardProps { + title: React.ReactNode; + icon: React.ReactNode; + description: React.ReactNode; + actionTitle: React.ReactNode; + testSubj: string; + actionPath?: string; + complete?: boolean; +} + +export const OnboardingCard: React.FC = ({ + title, + icon, + description, + actionTitle, + testSubj, + actionPath, + complete, +}) => { + const { http } = useContext(KibanaContext) as IKibanaContext; + const { getWSRoute } = useRoutes(); + + const onClick = () => + sendTelemetry({ + http, + product: 'workplace_search', + action: 'clicked', + metric: 'onboarding_card_button', + }); + const buttonActionProps = actionPath + ? { + onClick, + href: getWSRoute(actionPath), + target: '_blank', + 'data-test-subj': testSubj, + } + : { + 'data-test-subj': testSubj, + }; + + const emptyButtonProps = { + ...buttonActionProps, + } as EuiButtonEmptyProps & EuiLinkProps; + const fillButtonProps = { + ...buttonActionProps, + color: 'secondary', + fill: true, + } as EuiButtonProps & EuiLinkProps; + + return ( + + + {title}} + body={description} + actions={ + complete ? ( + {actionTitle} + ) : ( + {actionTitle} + ) + } + /> + + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/onboarding_steps.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/onboarding_steps.test.tsx new file mode 100644 index 0000000000000..6174dc1c795eb --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/onboarding_steps.test.tsx @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import '../../../__mocks__/shallow_usecontext.mock'; + +import React from 'react'; +import { shallow } from 'enzyme'; + +import { ORG_SOURCES_PATH, USERS_PATH } from '../../routes'; + +jest.mock('../../../shared/telemetry', () => ({ sendTelemetry: jest.fn() })); +import { sendTelemetry } from '../../../shared/telemetry'; + +import { OnboardingSteps, OrgNameOnboarding } from './onboarding_steps'; +import { OnboardingCard } from './onboarding_card'; +import { defaultServerData } from './overview'; + +const account = { + id: '1', + isAdmin: true, + canCreatePersonalSources: true, + groups: [], + supportEligible: true, + isCurated: false, +}; + +describe('OnboardingSteps', () => { + describe('Shared Sources', () => { + it('renders 0 sources state', () => { + const wrapper = shallow(); + + expect(wrapper.find(OnboardingCard)).toHaveLength(1); + expect(wrapper.find(OnboardingCard).prop('actionPath')).toBe(ORG_SOURCES_PATH); + expect(wrapper.find(OnboardingCard).prop('description')).toBe( + 'Add shared sources for your organization to start searching.' + ); + }); + + it('renders completed sources state', () => { + const wrapper = shallow( + + ); + + expect(wrapper.find(OnboardingCard).prop('description')).toEqual( + 'You have added 2 shared sources. Happy searching.' + ); + }); + + it('disables link when the user cannot create sources', () => { + const wrapper = shallow( + + ); + + expect(wrapper.find(OnboardingCard).prop('actionPath')).toBe(undefined); + }); + }); + + describe('Users & Invitations', () => { + it('renders 0 users when not on federated auth', () => { + const wrapper = shallow( + + ); + + expect(wrapper.find(OnboardingCard)).toHaveLength(2); + expect(wrapper.find(OnboardingCard).last().prop('actionPath')).toBe(USERS_PATH); + expect(wrapper.find(OnboardingCard).last().prop('description')).toEqual( + 'Invite your colleagues into this organization to search with you.' + ); + }); + + it('renders completed users state', () => { + const wrapper = shallow( + + ); + + expect(wrapper.find(OnboardingCard).last().prop('description')).toEqual( + 'Nice, you’ve invited colleagues to search with you.' + ); + }); + + it('disables link when the user cannot create invitations', () => { + const wrapper = shallow( + + ); + + expect(wrapper.find(OnboardingCard).last().prop('actionPath')).toBe(undefined); + }); + }); + + describe('Org Name', () => { + it('renders button to change name', () => { + const wrapper = shallow(); + + const button = wrapper + .find(OrgNameOnboarding) + .dive() + .find('[data-test-subj="orgNameChangeButton"]'); + + button.simulate('click'); + expect(sendTelemetry).toHaveBeenCalled(); + }); + + it('hides card when name has been changed', () => { + const wrapper = shallow( + + ); + + expect(wrapper.find(OrgNameOnboarding)).toHaveLength(0); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/onboarding_steps.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/onboarding_steps.tsx new file mode 100644 index 0000000000000..1b00347437338 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/onboarding_steps.tsx @@ -0,0 +1,179 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useContext } from 'react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; + +import { + EuiSpacer, + EuiButtonEmpty, + EuiTitle, + EuiPanel, + EuiIcon, + EuiFlexGrid, + EuiFlexItem, + EuiFlexGroup, + EuiButtonEmptyProps, + EuiLinkProps, +} from '@elastic/eui'; +import sharedSourcesIcon from '../shared/assets/share_circle.svg'; +import { useRoutes } from '../shared/use_routes'; +import { sendTelemetry } from '../../../shared/telemetry'; +import { KibanaContext, IKibanaContext } from '../../../index'; +import { ORG_SOURCES_PATH, USERS_PATH, ORG_SETTINGS_PATH } from '../../routes'; + +import { ContentSection } from '../shared/content_section'; + +import { IAppServerData } from './overview'; + +import { OnboardingCard } from './onboarding_card'; + +const SOURCES_TITLE = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.overviewOnboardingSourcesCard.title', + { defaultMessage: 'Shared sources' } +); + +const USERS_TITLE = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.overviewOnboardingUsersCard.title', + { defaultMessage: 'Users & invitations' } +); + +const ONBOARDING_SOURCES_CARD_DESCRIPTION = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.overviewOnboardingSourcesCard.description', + { defaultMessage: 'Add shared sources for your organization to start searching.' } +); + +const USERS_CARD_DESCRIPTION = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.overviewUsersCard.title', + { defaultMessage: 'Nice, you’ve invited colleagues to search with you.' } +); + +const ONBOARDING_USERS_CARD_DESCRIPTION = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.overviewOnboardingUsersCard.description', + { defaultMessage: 'Invite your colleagues into this organization to search with you.' } +); + +export const OnboardingSteps: React.FC = ({ + hasUsers, + hasOrgSources, + canCreateContentSources, + canCreateInvitations, + accountsCount, + sourcesCount, + fpAccount: { isCurated }, + organization: { name, defaultOrgName }, + isFederatedAuth, +}) => { + const accountsPath = + !isFederatedAuth && (canCreateInvitations || isCurated) ? USERS_PATH : undefined; + const sourcesPath = canCreateContentSources || isCurated ? ORG_SOURCES_PATH : undefined; + + const SOURCES_CARD_DESCRIPTION = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.sourcesOnboardingCard.description', + { + defaultMessage: + 'You have added {sourcesCount, number} shared {sourcesCount, plural, one {source} other {sources}}. Happy searching.', + values: { sourcesCount }, + } + ); + + return ( + + + 0 ? 'more' : '' }, + } + )} + actionPath={sourcesPath} + complete={hasOrgSources} + /> + {!isFederatedAuth && ( + 0 ? 'more' : '' }, + } + )} + actionPath={accountsPath} + complete={hasUsers} + /> + )} + + {name === defaultOrgName && ( + <> + + + + )} + + ); +}; + +export const OrgNameOnboarding: React.FC = () => { + const { http } = useContext(KibanaContext) as IKibanaContext; + const { getWSRoute } = useRoutes(); + + const onClick = () => + sendTelemetry({ + http, + product: 'workplace_search', + action: 'clicked', + metric: 'org_name_change_button', + }); + + const buttonProps = { + onClick, + target: '_blank', + color: 'primary', + href: getWSRoute(ORG_SETTINGS_PATH), + 'data-test-subj': 'orgNameChangeButton', + } as EuiButtonEmptyProps & EuiLinkProps; + + return ( + + + + + + + +

+ +

+ +

t$XkYF+E-U6Mt>KtFJQYTh z&Y!>okn7WQHKe;A$n^aPbnnNy(Omy@FZ1}kFQ6+KXGc7|U5 zbiO2;ZWVR|Xrm{%^BKcM7`f3~J^#g$SWF(=x~AuTqXP#rQz2_4=y4&V_PZzC-+tx# zPmzf{VLcu zrsSh2wxj9++!^&1#fk#}+f$mcbtYa2nnV@>S}wF-Dgo7>nN*Pj!mKDu&~R}v;9ggj zs&G_a4C6y9AQl;ClTI5k+}SuVA1RJ^`zXa0ibMzB9&vp~27O41?T zT`*Dk>lm`VyXp=+LXUPR={NQ3YYJ>KVfMVzT&wrcf|W}F4$@EbZ@vdtH6kqxyhfha zzuJx6A|M2P1J5lb>DD*SJ>tVu*@;E3d?rl8nXa%+A?`E9udq*U9J7cKIhpUe+;a85 zIPT`V)QY19eGMcg{{tB|0THVqA_5CeN`HwJ_J@hZr9p^P|HzAyS@P)L@Bw{@w`J&2 zVVZZrgil@yD1V*f;ml$d1kkHuiV*MSMcujja*%+7F)4m!f{iO@+u55=uiS3YbmmPs zLejK_NpC=@cx#&-wvpikeCtwg4g!$-T&kvbg%#^iExy|^QGC-!UglHf5fgLF6w-QC zUvs^KwPC$_MKj%ui{ak;iE-xm_@Fg;Ql0%eEE;^>M_VpIZLANsPvK4a81n?nXvQ1o zbbY5Mt*2IrQqh_aH?9mON-huB;II9#EHe*Z8yjroa>zEnnkr=Ix6@ezAN;_p@RWn> z4i)k^yIv&F@yQ2SIN70RLAXGxO)$LAVX%>u#zBDdK1shKRPqG!G8~YAy1Bd|W^yzq za@T50t~niL&eQSEB3kx+6T5TX)3F9JW>;?q7gHCEnEk~4m19P!lT>=$U1sB3Tk3N> zt0KI*VcRcHB%^Ic@W0~%<9MNSVzrPn5%J#ERWoh|ZF)a2GC@xLK%;8q;4+U!1Hsp-n9XJv z@=3kiw&I95x-5+daEhE*TqoNHb3qrJ4qO!I3X+1G=~qp8#pfq7I4j`vXY*9v2PZXC zBTITclA0c^8Vlxy1v43d?LBy){*y=6^OBL97sfyd+i%1W6CEe#Frp6C#2ba-Zdev& zz%4V}R$xhiXWS?x56Zj-PF_X|Bt9|439Hf*Pb|O+}K8An~iPTP8!>3Y}+;(vyE-5aWb);eA9jQ zIp6v_GuJgU?^^58jsBWdJio@e1_}uNooa51Rv-HTdIgT{yC&aNx*Q&}XMHS^GGViZ z@x$bOiq#F~=XIn~T=V*(=CCJu~cur(sNDpz%pXYz+D1)q{= z94z)|$02G;_Sv?dDOtj`78-SE#dEcQddfU2K8G=1>z}ZeJ{#N1;+o^g_{@H1!W&tO zGpKVphVDuVi)SoSA6>#&zgkg;@jZ3JzF5k9SEQ;AAyWktJViz)V}$&RF4T`~G{pwS z;4ZgB?_hn^8EF%tTkRjuftVLfyx&X&bX%mnzNQQ?eze!a6Gm?CyPrd*6o?0yv?oUZ zzGw2W^#1CbkNV|u7s`wDP>|-0s*h#Eap7Tho;tnx1VUOZ*gf)d+W^}V zb=6ekk6!(FlH@3NFMDsgKn#IN>!uHFEQqzduhodAx zC-uR{Pp1{y8^#QswWQrWXe#q)2F=!Hcw-0Kl8)Mot(bK;kY;v-iTHwQ%H@{E_R=co zn;`=TayA!J%*aBQO6M{Vb=>bCN`~Ta^>tLh01}znAP!0}(}V)$P#hu1Xc{lJ1&K(R z%s#FL#0=y21tD;|hCES!KVFqgZcf($(^*=sVj1I}R*U2w`Bz1uwp^pew|k4ve7857 zZ0*7fj<;Ws$$Grs|Bip!xLX(;vqA=q?XKLM^AmmK`5GyBEUseoek@$&Af6nhw!S}= z7TCBw`aP$L`10>y$c$aQjg6$Ndw;n2lc^5H`G5ZtPh@9LPycO9hH`60wg+y3lOrV! z^C(+VLel_ZvW6hK;bxBX8U%Ig?kR{s`^lHh;-~tVOC&I->-Jq7q{eww3vurahT__g zxqO&%j}6jb_;C{m$mT);90kaR-v%|oM~cBBWc)Q9GC~4rxdwc@)=V@H^lRtxs`0K# z#q-_zI{iBSEA-dc;u8HL4zV$J>vRd5K@syQAdpAajb%(qDZ#$c%Z$}cEJ!Y`Pk@&c z<=HSs&TZzR=_1hAO9p$oTc8D5*aNKIzeH)CF(}ZX;f3d$LLh;s64YnSp%XF{>Z4LY zg5NCeH6rReDWweTSFofU%%galS+oS*f8eZAe!>ESSdkcPq2TWM`t>%&5;DF9l3i*p~{ z2|OeKjS3J#TpYUegfi%wREWHEc*KrwzrIy24WVt!SAm0bw1%(jO z@-QX1b+2halY`g~0>59+KI(T^y0YXb{YmzD~NEot6R#-K+y378O+I-1EP!0mqZG9b2<4ER>9X^?@~G3+qGo< z0DuRqu%xgg7!{$A{-4et>Z2xAytxKjhZ=*dRv!-T_f!!b27wQH+n)?Y%igHX7_ z0{-r3@V4k@^7xBimqJt|s73fcofmA?0us*x%d&`gt9EZZDg3>jBu9SpUeIOaZuJa?4%vB!-|lU2!w5c~>k)MwzJZuvYj`^Aysei!ZI&0w%UoA=3O&ce zCPY0iK7eDH5{KT)2#9-$uqCy}hZ1pwnsbFs|NF4lE$8Red8(cac@X#Rjla)pOY~mW zVxKM`TSB$*!v)lUqsYhH{~998T?dBlxjZ@zkWJQ5kr)fH1phszA1q_dQfH4?o~#{C zD7I7yOo*d9q{CgvVe@T{2Bw-jV5Cn^LIuGt#$krGzB=6hxk&LXN^-#i^HC-$1nJRi zLC`KmB!t=-beKLFnooB0;@-qcb1XxsI9I}yM&bi80e#i9maYUTmtc4JC-R4?Q{!-3 z@$7!Y`+p1!mluXd_Q&QU&qo5PA|JE!nD*WqP@IC;V1nv4y~@eAby!M%h`RC#?m)e* zFuX5AlCp@^W2>ljp(I6k$eD59Y8nY|4R8CeD%iK%`1|g)DT#m?9&H7m>+$D3%^XxM zUvhcxRR8(&)@tX0L0s4%K$P*#e#>Ymm)YSL^&wpdo#3o>LzAU*1-f zM55qt>v}JCe>PFMyWIPHVzo}@u|M;t<=kJXFiz(3_)QL-_gw0^&f*O7^73H90$?Ms ztEg|(FOGlmg+l5hAh(!yDEp|&`7BQ5jyS^pH8D_FnMtMpb``2In8h2M%LZZ<5e1`f zd$T#e41iNVc&2teU4I0iG7B<(8y^A(2~(m*5c=DDN@z^~ zSyhSpa^ui_)DSy$q2PBsrZqnV(HHna*6mvXXA;WU>V|J^iL0!$xVGh3zH_|wj+itQ zqswLS1yH@&X0jM+r!o{LO^F8O=?S7pOm}ycmX!E@RF9$!BlSM+l#DOiUR<2>`;T_j z@IKUNHdRzt?-w>5Qte(3l`akEY6%!0=M(Rzh(UFDkBaW%eg5#(l5(8VY`NI@e7j-4 zHJ6vB+O1)U}=gUy+l6+YM9^azu%y6r}r!~KR)Q{(B6eh?iMb0YI?AHMEF(8J?p zzrndw0bKz)!?xadvGhF*?;jpuQBRKRwEq8Rfsa%f^$OZTEFpp6G(jC;((c6 zH5jH8vig7o^d)1!NiFxI&Glk@Ci88GQj#jc%Kcs>y$)hb!b#=%K4UEOD;jbxFFz4e z2nJa(42Cr84j^k7K+4Y|1%k5>e^UM`Q=JHCS01~~pTpojfjK*poH#pO!7uXeVNkZYi%xa z9%C%U36b`cYA-Er3`1y zGeS{rp@-K32bV{$xj_QVtm3Js4KwDb-Q;-Va@L;SjT>t)P-k%PT^4+1J^UQEnaWmX z>tZ3S0K4|W=5>>5v7b8gZoK!;Lo-=8?^!&ndTGhl;0V*S4KGpP5t`APf2Tzz^w4jZi z-o~0i>B~>gQHeaA9vg%AO@Cjv?cYP0kvCZC)i#%BG-K1#)8q4kNB3Fo0=+);01%P$ z3B=RIi{<#-Ch*_vIa1V;x+YWT?sA^~-TjL9;jwJuIKA!s_lJW)WWbW$nqyl*dt=vK zZdjg9tEYvLk-m~}?ooqXClcS=g3Lh)1&C_&DERW-LSwqdU2to56%nxhh(E)Rz`fmF z_YwP=`!aZW>HAQ!==EA^=k52=>HW1~aK8(Bwt2c8 zPPY%;!YBeacQQKe7rwjCDW{y5_^MhBZu7CBWO9WGi>Ix2D=nQJUAB`$1U4L(8*Q$S z282Ex#pORfe-9CTKGcR5O~whf8sAR&f{x;Fp3cXBGws%!CB|d)yWGBDTWbiuehQmgqQcrV}1~6U7SQ6mJi0IzP?X0QO8R)bv zs??n%7sTHu@cQl+X>hWVu(LBU$kNMyW4YW*Mq-ogEjmSKdu*L~=8sD@ffDEF*1Ey+oyJ7IQ z-3Pe=Oy&dea_bKh83MW87~;D>oB~lon7m~_!ctWMeJvus<1Kex^(D0XK65jsW)Y8n zlj!43^#g~w5vyYTKZ->B?*7icgsK|NIh;!ezsQmAt)vw=Y|ppd1(H3!nUAHwOMhO8 z-WHRLe(S&aIK2qnV%zL3xXs%#a6h*)1Boa_WIhkiccS)NJG}Lofqa|&U!FDc{5}?K z7zMpG^$@jN3sq@yBnJ}VlA`og)AJ_H(HuW}BvN}C&1E=X52w=Ete*@4Z7+osf^YYE zM<7a%-(5W?p8^nba~VsZt?}K06ETOW!g9UEUb4}@$6QfF$NYRtV3RQvB_#P|Z)TiP zu-Et@{cT%t-pNo$!CS%OR!l8#WlK)(Ece~leM&n1)*m(9x)C>mpQ-Oa^$_rpRh(52xl zpLhGpOC)8{1d0+|8!Ilu?+VX7%bV+uL z5aSp0c0fRi4JWAld7XVUz=hnYI&C#;+9`G*(7#{k zd3Dx1_5ED;CYKG=bX3}1o;x&_?YT{MWYZchL5;t_U$nQ_;e2dS5)vu#5tRPu3uGls z8r;gD*Tw=t+&vINn))lV63s()FimVMU<5G%`lCVl)kA*>r*yY$}TyvC#3;7pU2_rc(m_Zl{^xW zSg*Tic1+l9Bt;ey*$5ACq+JAvVrdPC!htdhIJSn+r+eHzXW;j6yf@=-tV zBl81dS2j>5a8!N_r-`5OXX0>Ef4slq7W6@!=Bnr)iv&K#`B(ctckb2o)+<+AeV%U< zf|DM9sjNa<&eeaomB@H5@!HDJHzmXz3;t zhR}E)3GsDxpa3y)HHZ?Kv}4k9Cx)}x75}oN&ucRg7>(Cl7(h|zvYJM+94_as+%yI; zp#L?kRgbIbY4kGM-$>6wt3uFO^+T?AWqS$Zr83=LA(A0S$7eiEJi0no0XOq;ub0qw zzodm}NZ`Hd{ab(`ciQ^s(q z0xsIEFJPt7WV-%qg^Jlv+|#&us|YW35QLs+j6P@7)gc$8M;OC$C=Gpq1J~# z`{}bJj6z>~RF_i|0dBrtGa;x{ME~=$FM|!yNM@#Fi2woqoZBdA~z!w87$=1vKa>>X#|tX5HndEmxS(oqwes@ z+WDFAlrQJY{8d7zfAO_RqgzmisS{bj@wsgx6KE?oeoR=X;`MGNkmYYFHQAlXTrGnC zE>HAw$?uxt*!$PRX3ud7Cn{4x&I$!&_JywVzks43W=%bc8aL`@5GX zvg$rz{6q42oaZHKSu|rZw9-+ZFAGr-9w5)fj?|VTb2&Ug|E2xIrC1muZ=;Qo0;p#x zzzDQ3N~nWv(AVk){@3o;I9r|>7*G9)VbxIomt`1+Oua5cpPiwX9>y)F9~Qo2%jy}4 zD-Lon(AH#^Per`}ABFYY2*-<}J|5A?sot+&4^C3(fU>}Vphwg)#b7D!243m6iDfZw9z%oZ3+~`hy|Y~qN>d0vVPQm zNJ?0^gEGUGoYyqEOeH-%LP#T?q~1tNGI6%ivFM*o z-Ril^MC6^)Wph?^6?33Tkv7|9?qw2mSRK2^%4H`4=<7FER2J^vdCp(>J+$L!hCnDx2Nc z86Zo6Unk@ULW(+WtD;KARXO|;gY8}+Q?66oIwy?s9J)~QjTDc?rS)qwS;hfM$~@Ux zz3D=zQx;~lk)*`e(T%u~r6V~-A;%5;O1sT*r>{}T7YrsS(zRIck42q{4JgMMjr54x z-qRWA+zpi@f#$R7#j5Ue8#z*FKa_{XN;N}?gw(l3O?<+CRfH8Y)ZGXAZh`4XnL=;2`}gU#`}GQ{$iU_G$FTlNo84nq23q$XTx@c9T#ARhsoD4NCeM|c^AmRKL+YMXc;sS0NLd&rQgfEv)e5xQ8zwU z;44(EHT5RT9NI#e@m1Aw^U@##ema-hg2aW^FW#HVVqpSMMPM*`e+R`f6Y+ZW^BP#1 zg&Q9{r%!v$z2&B@5oxae=DtSZ+t!3JhT6K#>P)o<8Y6I1?GEsvcWuV)bntiMJ>0Vl z5EBc9KRcvP5AQXsWsZSxt)DlQG3NxEMBbkz7IfLq3%7O2$>{PqOx90-Gq+R^KP2*K zN2arw+y*vq(4d#qYjm5eR%uTe`5)N;>C9#unR2}LosSW}*;81{qTxmK^|V^N`eU2% z+(EKv*{!C8sM`%keRUrmpVi;?O6BnRLG6p$+%3>{?!HtsQ8LoEa~-@QX?}k8MCSYW zgLv6Zo&;y#eFPU;Z#lIPVG^3-KD6OYq|f8E|Mw(m7@;N!{>#FmYHqvblF4*6@h&LG zOy_qETJc-kumYXTk2Cj2x^#BWvyp;e7&V4;{^!*+bxw|E)nv@5MK;Q4oF0?M-=G`< z@E_}R-xrFZE)rKYP=5m^pv%|ifv~M$BYZ@U81-B0Op}6W)fm5cTw#&-L%+r@Xdx_I>E4d@va6mU6K+Ih6a0Q}# z_rX$I#hJ#0@WsWeS!na;1|$SQPID1l2h7|zk}BHcwfn}Fo|Bj_LL=6&qLu0b^&{c( z)Nkb8POeAQFV11~R+ z`gHoqX_%jTlSDJT+jqamw+*oXfEnBzp@}3;nb6~V@0+{yY>H`-y9vkeB}l?Y2M(AB zk26+IT|0*iF1IJGvgwx|lJ@-9~({7#B>TRSk zkra^5W3;dUSHvRBaWQ=7!=S~r7yXl!3z`2AOvu+n#en8LS7zFpZ|*B(!KEjL8HGXn zGem}MevLS&n@2^!hY%GRNvV+DW^=ifrcnp}7h!d3v-5B=li%#mKxk!tq>#>yT5U_p$BjSr;Z4I6Q=-!L_H&wYYDPRJ}R$XuQSiKmx`Gt^t?02AUO~xU4~Hn0Fz;nU>Ba_ zsq^PN$U=2T}5KJHrA7mz&k*Qk4il+70-n}^?yYY83 z*mE^l{escpk=RZ0*tc3X4HCOzFlZ`aOzHSL;c2{DDaz*v+Bsr~T~Z(e$>h{0Aopwa zy!rjzomtZ=C`^M!7wZh6oW-@kF?dYYBfHnE01f>0UsaE14rkcv7#6T+*a*~AwLjnW z5uP5{IS7&2^868*d|-ORAdrt%sC=A>x~*1xK0NaCQueM__OFYK-;eF5OxIL(`(o?& z5k^m2ZyRvI0Fy^%HWPe<4r?vEgtv^mpKRVRlSk|*eJcG&*$;%4TpmxB-rmb8SPX>9 z8uW2aAh09|3`dGoTVXV^WVaOp%PfclsE{>Zd~wCfc6}Hj>0h$nN>7sozMos*@6~AF zh0mx4M8aDC>+GV0e5Z|?s#u%}Pik~1N?pYPg!_2EkBf`gmK@W&YML-fWHHf@N|A~i zTDC;P;eIx|4ik$(3tzi*>+JU=n#3*lxMq<-1TeIx6|tSZJ>%!XN6PC2wZ5o6Atl3u z0l<8ktBkuNJ@DK%r#)sq6VsjZ^;);mA10@U9#Ecnse&pYs3781QaBjt;eeKMH)Lzw z>37Fw;kn{tm#7ku)Lx90+h`;#y9L>XQ({ZQqhb@o)zcTcOE(!&Ri&{bw>Mog{hGTm z*PemTO_bAl7*YY#GDANxhyau^c-&SSlJ80^gji92G5Ffcz26cJfmaOQM_@jNh?V&A znpJ~JfFY!Kx!^fM0KZ-HqOR(v%&2LfnqP}aVNp~1D}qZ` zYuMTvzPI-p?5%F-82eOT>x?%39Skd!=?kc5C|_S+eqa8Gwrb52X7K^<*B6k2=b*Oe za0mtG!55y`DG-XLa>nW;nuN;GvSgU}yXid_`Oah-kj?4$X3DgVV{7od*~aaoaG6NfBp`*>yLc1wYRn2^{9^30rivp!(URhUtLTZ-*<51mlsz^zCZnOFTA(?OFbnbAyY--s`;Z?IdD zlaJn@1Tg>AjcSdYHTI`;Se=F|96+PL*T-6mI^G!)b7;1zcvqkEu0`ZaYt4Fdp(V{< z3P#&d8NUw=wo%i**7Y>c$%}0lU5GopgrKqGFI+rQwhzIVw*M(5>mzor#*0JB(*#NT>Rk%nBaRm-st+FhB=O_65r{F!Y!d zoC*N(rI-*9)nwP{)rvY8>2rzA1FRtS;K^?MciYgvzNTItPYOb5vQdr1?Hw^xRM2og zFCAB5CNuauoZskm4?a6g41c(hAbdkO@s$}nivGs+jo=>upUVhk+Va|?t3JQ)vFl+< z{`uI04~hBp+k$Gv9L#P&sR9W7lLY&1plZV*(dvRq|NZ+X`WkMPAetTsE+Xj?XyMYG_pD0 z5Q(goeF4ANB?t7kaA5+wMrv2z&aVQMjUlOlMEk}XLmlyZ{v#2%`C|s*AGS;O z(`G70_SdF=EQ>275`=zR6m$&CT;~^@12Z#fGj!>)`N6@#nsYl6f4RJL<%xwqQ^Q`+ zAZI476ygy=5IBK>D-^bp93KpXodKev1V%+3&`1y{dG(j%%a@0^lRS#Qt%Zi*y5h32 zrm-|Ors9#L_sz8^;mnblD86lwK9yUQnpV$U*y+qtU@R+zgWW(?TLyh?pg$I-w%ZB| zPXiz}K%a;!dRM+c0sq40rM)M5M}paiBcdDtXUQg3<%H3a(F!V;#InJ;x1i@eU7196 zuPM)jixrA@Iit}uPyr0a$fD$Tp5Wn>=i?*bjtp*oIs3J($k*5mxw?00!?Dm}Jq!uH zAbEtV*s~NY)f67}zdvlB2vf0gebnC2MWcEa;Lp0>LJ)b3(1mL7zaYC zLd3+%cEY2ea#4|z#|lA`=DGxZc!qmEZN?#RuOxeV%1M;c0xr3xXb;(bQ!0FWnMOtu zz~Ea1g}VPNT#}{ZiP^G&=h&R;|=nD6WKMjR1rI3!JjpGDJm)0Gq3`&C~7uNnXszKM8 z$_|f=N%zK5eCa|xBFnSssnI`u_^qfXL%z&t`)%u@ZB4l2#qLXm(>%Q~UiVQir=gjTH<+HQEXsbgE&kz+zGbR$CNh$LR@ED8%a-#Kug_ z_82gNXGynAR|DcJsmQLbTyJz3G3gJ{eSCoF73{3fcmO~?UX+okkmqB8bNGF~P67#k z^l4a$R>ntX48D6>S6RjPOEJZNHIBHji9uZV)%nu2)MPr}=MpkGiM?wN4%o;qU-+g^ z&o0cvJ0CAkFHYLu-l7gCvY+Q@>wZk#&!uX|DHq10PoJSH#`~jGYJmRXGVl@YJ$0#@ zY?OnJyEU4c6x*s6+{i3|NId*{RCqu_*!{O;o;uOZatwp+#C-)OyA7~i(z zJg++1I-5?<P}?4X44d+O@$XfC@t@3M{^8M`f()6+soq0%%Ony(R3 zBM4sg$W&*7s{FpQSi3Pa-%uhNwTV_z&K7)##l&kH(ar+z1s%v!^N*sqsDw;uE(4OJ zIpg0Y#IxZcdZPJ$#D;V4ffzpu^>saE50H5aApG6GwPPp2kwu`Y#%{lG%$~);!9fr+ zymumN{uEEbN~J(ne4oWhU~fAiLk?2o)@ygW-@svYzgTtNKb|!uMV;*{Y?&@!C{^P> z`2H_DFcazs#ibGzK8GANCQc_Zk?a#<_{K1n1OZ6HSEb)z0hs6YJZ4Ob@XrU%dGrd-hSBJwzYpJFuKoNY|L&LWrove zr)!9FTB)QA{M#~DlRpnp$Uo!02xjW+%Fr46O-lfZn_@^iz^kzc?gF)=HU?(NC+Nb{ zG#dK^9h@W_3{`~$N6jZNfU7Y2J)b6A!nmSrPr>^C2(pSJ=1m}BiZa#=k+4(#p+Cgb zBjZ^te!KAX6&Mpb;~$|Cd7BzRDwQ(lbb*4wXZ8y6#c)3( zpC>2y_^ooaD?jp0<1X${N0DXr^T`bAMvqRb8X`Ee%X3(sJR`;eKm&;cWK%xT5+^Q;MBC#D+jI^6gk`>TkO#rhSG+$7% zlPDJ@7Hs)hvXaM8%0O*U1MFzK$Rb!<+$MHp1!x6ixB{`n;m7|u3b)ea98C|0-KKT z`eUAAK*e;H@xQ#eFn3h0qian*#td1Dxol^^m2Yodox|p#+&KZks;%2*X-tM~D2d1b zF>DZV`0!rPvqWBZauoZn)THmh63CF9ON=Cg$chH04;4cJf_N%WGMV2bI?$z(R!t4B z+{^~mtrE(x-qmR)4A@J0(?j}EEYqZNVHY;Rd`L}dqmWS(L(SE|QxeU0*tVuLj82+^ z;$8kB67n}FasA%dTNXL}F2CA&MP$ih^yG`C+TEQxByCH98@XbozbVZfTa`z2#^y+j z*7^s?%ykx$=_QQW=?mVWlLOgJFdIdFhf`w_-PgI4KrmAtHsS1BwC3=f|9$S;2q?_^ ze6WAoh~^c7!&g>UXXls05Y~Bl`{N-1sGhkXa4JU<1w*2>oo4Bni<9jkuB^|t?K17B9@drl)qNyd5EFjN^eoI{b}j&UkQ<{Kx-kF*xyzQN-s4x?1jgXt7wX(MXfsLN z_HIr~PP6Y&Vn7MiYj#>DF8HjeprRiA-b(q!9I$}0fHPXzUKg7HL#o-jh>v8;Nh;Z> zMxVD3SWfOju}yYBeU(rldU})97CiSb38lVGfJ%w7P0|@j2&{uIPX=$-CQZ;K8tef4 zHxocY8jkbe>v^0BG$mzjYvI_>4#zeD6>%pn9Tg?*zuH!AeMy0*r+CUb8k(9OT8Bt_& z(RB6E{Z&Pgs9+;0{mXUzh;;xp721Ku=$F^AVyJ%vj{jL*a3BH0ii(P!0;8n`D>?d_ z8oQe3QPcheGPy}P5~`gpxe}EW42)ZgNdZ;=@``2(%&2?st42biX4zz!=YMqVods|( zk6ejVPhFW)1TirVQmxFGWMSOm5ai4xT4n5=P^Sv6sD89~y6<&W?W6W9rb^1jQC0AS z_~&kryIt7rr;>jvyAOFvbaTxEDuhVI(hf)-P6hz7KHxQI+A**B-1R{IawrI)8l^I+ z#*2y!_^gg~UL9wf#}Cb-$fb~_zVGzxhFurQ6u)XE*{{(bUNRJ^VjH_F1>nXqbMFEf1eFuyjB9nNX`!*Hx+E`i% zoDhow$@{0)`KgUOy|2ZW>fY~j3z6`3YJ$Q6NFyK=K~9W&!QLHEpEx+^1SEyXOjlb# z*k=fO9}jx?Gai%J4nYlOkSHSd$vhWQS{=C+Xsxr5r$V5f03I~~405x?m?Iloom{G9 z?{3EK*geU&-YhWVq)H@#1OKgL*45|q6?W;eXfT!Cb-9|0g~LV?2*H-+qU6sFE%tsO z?RYcchTbgb1kDJ;7LOf!cvk!lnNY=SSii)R)a4~QqUfh(sx5$(gg7wT20gIAVs$Na zNZRwiJKX^{_sI3}fljBvXQ#{4BF}B;cmI7DF4E)O9n&-|HFbGa1&)A+rMJZJ+yNtK z&JFV85mImi{?_F8S^JXx!gtek0h%sZ3!%CfP{1cf^)2$)I#IBTii-E)08qe8`q-y0 zEC^u+iG+b4G>G`*L@n75mN@q*$1!fJobfNrLBv4oU|$63Lx+qj;c_5Lg)XbsdN*Ok zd>zf4@Eyc2P0InO{IGI?uftDn#zLalFc`Qa8Ga@v;g*MoqDyW?9Ys#4n&&zRa9VjY z$8R*gLS}0FgtQ#uFXrB_u4k0S{WGss73!#XLm~6uaafC)rsJLMTS?6LwGO?UnNNQ+cf8rs4`wm*6*~KqWumQTj#Q_nkaq+AREvQo|bt%r> zZaXH8GgXIQt~Q?H6y73t;$)fy-Qrn1oulY8I)#L8i_7>q+r`IdfO74#2Wo^gHQsT{ zk_aSfn7|W^Afl6)+8YbXNnnWj+JP3Ck%FtA*m8|Z5WAzBh7)YN-51bcjdRR4a>Lq( z<|~-js&@vXkZA_Cizu0sH6I$}f+aXDQHv1@Em32wP9wMq$z?063aRj-#M3od{PJ9Ev$I@A zV3vpxr&ua9zkINdR1#MiSDFUDQ`s?TLl6(e1$3>#VTWV6$#ET;8e%Sv#2}llLgeCwKClG{i zb#@hwmU}nk>#R`_pKQg2QfsGcGBWpED+&-*ZYluo zyQaSXOo9>te>gfc$OIeAR@o_p=Pya>5ZZ&c+@NcpnyGR?t+HV|oN3>8R-doW*u|AZ zD<1%pY+?X;ZEUKGp0fv1B_tV^J*2WDSR%z0m}MU>-T(g&@V(eem&KBQ;YC$9I6f5J zJBXJ{NDjQ2Zur0R6X1W?gDf<61pp;1zjavYisYco%vr%$pd<6Y;qicLfHyyNJ11C~ z(C)Z}Hgkz{7+LLb&_hmIkrl~iEI2sWkWI8kvU>f3{tR5TA3E^7jDnGbx%nRx+{?GDYWs0ac z6vjOp=Daq2qQr&1LfK=!JO@_%(|X?RdBlvN<)O+9mjqUuKJlH9yI1TdZPohCB_LVa z<5X~1SeK2T)0k~OxhdAj)rvVH>NyaRmNoGU-Y|795oitli0YF-R?7J0HJV%0N6joS2gEz#&x4V z1~g-xGFm?(?nSrAJ}JzBa#RLKkKWp<-IaLcQyU zAqa^fBg!;m8TWOTu7#G>+5;sGf!i`W$VJQ;voG%Hmr1ho&v`#?yeV%}PV z;zXuWii2LQES_JL2n8{|7Fm2$ef`z7f9Yy)>;j)S^q9;k9v*}n4cd(zQVR`4lBOZh zcCvE}!TyOHx~h%78f&5L2Ez|%rU(AwoPuKO(lemH&n&DX`?X#9-Qf(Xg|Jqc_%6dq z)qhjC9=gO^5~wZCQ{5ws5a4WFz9cGCaZq%iA@u$^dS%S+QalINYW~pN$4zcx`@`xc z=Q8bo%PC%@Z>$-JtLvd~23t+X811rSI+d^EtTPr|~)-EA+ zgB^d)V{La^`~>^uyQz?4+K19EHMASlFOvmXNLWrc)hGPzjmjfsZo9|hZ107 zqfxAp+BrX741vN@df?-W7cW=w#s1;@;ciNIefHA`d8EYsGuZjg!;o(LGaA{sl|ye~ zc4TDbfFnE7VhDXXZK-t)f0?<|N1(MYCpfthp+cNojzLSwlwI8+%xo4FkO=}Cj-P5o zICc8BDKB*gLyofBh7gLmqK_V$4ZBp(dQq-{YngiY;)a1j?OCv1p91uE(OSN-p=OM{ za56`;XT|e)gmM7*(FPsg)zsdUTA1MNQOvWW;9=9sH9_K)mW{*1;W{UDwt(vp>^6M` z2ygUnU2%e`DgaXjkjpVjMx1;86-7{^>+C8icd@9Bri3Uk09COY07`~LA_R-na5eY? zc&L*^Mftu{%8qWK~ z4|#M{xVa*fh<#%GCt$WLM>(6-fxt{6uOyo9RHWT5_Q1%G`-p16gJLb4KUHpK3eL3W zQaFM@S^(=Z1kaX~9vwlm@FLONa^3bMK?z;m7#M6AKM>xK=Dgx5sJ$m4p|@Ystp8H5 zftT@caBCz874k2udLq&5Nt5^bYvZNv2H&il}npp=ONKl-rGP9%S*=@QbM8T+iKXR-u*BbAS>qwq zyn9kK8LMt1I%7z)*Mtmp_s6(r;&|5~GtgHleXA$wVvq%1)H#bn&EQsK?L@w*RN>4H zXGFi@V^)Ea@N|8D|He7CWIL;mPf;6TzD84@|5ASYAO|Jj+E(k9pM`OM_ABQv_URy* zC<8LM6VC15x$lg(H;eY>F~S62STyifm^ZL40gWW=Zlyo+HHS|Bn5@iVK^H`+{r7U} zIyK_z^l=zdhxocysvxmXfG@9>KOf&Y~SVBAQ_>!?B}S93)fQKtEuP^#iIkw|Ju zJkl{~7YJxM#kYfL5p(>u`%W3<0-;$~4Sld3sdYrXy~Ir?Ug2QCqYPG@(U7p;Os`L~ z!BU_f^)~_STi^yKhaZ%c4QH$5fe10brP!!cjL0(H1Y8KsYw+v}{Slv-Rg2L`vF`PY z=B7S*m~`aj%Tmp>9ldYaI@LT*(II@t6bdXh*_4`=H?uoFDhduNn4=+VcGYP}-!oiQ zd>E`Ja~-FN&qsDR8cGBfBmgdtz~+8o+3fbjNy|V38Q=|K z&zyym%u~ktbwYzWAJ@xe2J$5@1J$v(98VKJS-(a+H9Tzp@|`Al6ugf>)?+pv$u~Ji z3n4KXk+;=@_@A6R3=NsBG1Wssng&fFC1}2CLv2psT7Cs||1}e9awf>b9vP$ZrVvcQ zN-|hd`EP`W8^gKBKi?=jFOJ?CV%scuATNa#LwOAn6p8BHnpg4lIJG6Mu2Sl){jn?Y zFkyh0w`DUH63Q5=aih|coKlaSf;0PP=zfG4xu z;FJ6J0;$|!cDZ%u@77TVn)w@=Aw8K1Y;TeZwqNaAWdX$}JlgCS&8Q+Ph=4&(I?gXn z#=-!{I5n)nBak7*!md&8>&RWFeI|cPM}9l&Bn%5um)log4!pPLk1`JX^(GLm#Tjeb z%G&A`c-~@I*+x@_J^IVX?$j_6G>aJ3|m`( zpfw;~D*7Q&lchgT&hzcNLd|OZo}A0v|J3k1dPuh$)lh`DBQ<%6WXQRogqDh^QaS5w zQrk8PvO>fL$943~70y{yXsBqUBk)>N@it zywLS>c9Ym7g^EH@XI(K-cERD!M{^1ck8MV#(fy9hbp4CdJ|R6nTw@zSCH2opqjE|Y zK9NVJHDYZ>CmI;*LnNn_(xOITvvy81WZTKD>`-d=BsgGXktx0~9=57mKnG+b&66Uqtx%B5w5o=w#0JR`P)2WVEBWqad{%{w4%w~lx_U%@HX}7ok`=Q>e z;AewP=#m;lUvUH3h2MUAa9Er?p&*MOqm*cjgih-vx1x?7v-v)X5hScgkB$F(LaBOQ zLj{@y7RDijkco;6ALi-tnZVi{hwn|gDc|hcRy&AxMT{^UjjgMq1_l7AYOB^8F6}!O zg@X5;S?&4zo(7+`p5=0ykOJ`S?AJktd)OgKRMC~x^w0r8Z(SKG!zt((UuSoU=t~BF zc0LkVbIq|BorUiN+W-ttFCcgy7h(ADbv4xV^nspR>wL@qtu)FIpuHZ(Jkv#A9oM{8 z+`j*ZIG7m8uEK#!YM4vfAzOJ>nT<>86z{qRK#8!)wCA+m*nXEc-#2F9Mx&JDOD%2C*y*AwA4MLB$E2F!3v2&m1 z5_=S8GAGa)MO4elyChKuEx|;fzO0|)DhaOW_$oqEP~KJfK;{mfUo-fONHo0TQFsyo z#Zib7nIlOEFlT~njxam-Gkiqe^d}!4`rPr@TOuD3K--H21L`{1&KFCz@?{aG)bFEq zvRAjGl?&%KwzJ1qN!V}X8ALq`T~H@6Rx5vn(wvE#zu%-Y%+!WsDR zw~4gBn(a_Q6 zC`bHnu`ZL4Pd+6W5%8Y{C`AegpN`nxUr?bx+xgheF30#20U{&*jUT) zum3Z3ouR|b++%sPJiSCOd&E3V$a`q^II-&pkZt}ODn^x7rP_7@ap0&^JPkiijoXeT zkCQ@8+J^DXYXvt>nQ#C_6Axz5ZN57(%l(s~z=7SD;`EOq)#V6C5?jO*je#@6s8=_s ztI$AcYkdZPo3U8uex~KPntT@nMQ;eJH}XnniMyVQPSIf;AwxNzVz;7neYE-v1;EP+ z!?{v|^@6z;hMw%k{0GVcdoBk_#eoL&lU7o@4lu@rM+OBh)6fy%qXHp9+1klepOmzm zJA=?MA$lKkR@IZQg#8X93qpIm14ibm-4v8frfU;ZUim7b_eR!w`kK_!avHAYEH%OL zOV|c@4hCM2{347N6hQcO z3#=7a`(k?8a{#58vjUnE^f`F~Z%TFj!FuzAn{I>8IyM*kAK7(91*6wp6l%E?a#x}fT!WHi?o3b(z4TyAf1UU0KQI!AY0pSn17RAg;x|b(QhYzwtG4` zwSJ~em=?Tr7VMZ^7PeS%3aCsldLv3^j2({ij%dBHsp_R>lo)?N{T!wf#ho(Ok2o`Fm%a`&oN425|h#6 zW}JIZKNBv18ul>h_Il&V(o6n6{l)7+)`rI7_RURD=jwIzvSlup?cVAxSdd${$7@Bd z#vzN}v>)e)*&?P`* zB9A+T&$RFE%?uOpC64Q|MwrcS{`Zk)mH2gVWY`d*v|)1_?JAEP1u4_&)?&$k6$W>A{}ssfaqCD$9zx zW_-?c^37d;d~7F113GC9w4ox)2jB=DJ{4@XmuY+aW|^6Ijkkau=x!cGUr?3t0lo2Y zn|eIPvSU?YG##p_0Z1Y$No&)eEVqbASmr|C5^)dN*s@Ozod-nse28yKQVv> zl-XaAS<*2!OIEKQ@GIY*gO@XFgFZY(Ml7Tqn{00~qcoU#30yxAq@+XPNwql+($A-` zY>{|N3qsLwo>_+vk(r5gaz2xe9!P|X;R3-U@KYvXH~={Dxh?e}A=S_Xb6fC;?CAY! zWlpg`zP(BGE5Fl66V}~c)l=8ldo?gNQ2G6a!3y7cLIRdETl1`CC4|m$owlB<8UqjE z9`P&78R+Ths=m<*x%O41s}4hoXlm9R)d9s|fA`0yVdJ>n-=*NXYGC!$Qc zUDtY0$O`m3JiuG3^4Waq@b`cI{819P9`riO)-*8qz$M&Xkexug3L980yYc)%A_I3d z>0qFV%x<&2hE9lKCn&p-Y3Pu;h-sbjiq~f3ed!3dO-T(s@W4;$506_GzTR)u=B@CIq_lY3^+6=IRla`w8p;%P~37D5ceY{vV@L1PA4j0cp)lAFwC^1xn1eCw~ba*t&$-Btq7mo|_knICTd7~3AN*>ZV)GGtCzfOH;*vR{9>7<*=F?i&$#>Y^WmWfHs5UB`v}Ou)!KZLHPm2JNgoyIIxTSMWdIgt9x>I_ zbMjVD4`tHOOm%Z}=jLqriRqVaH#@f^SXN)v(J}*?G9Z};{o}29%7nofGO3HgqEXD^ zQFh#{H6GZH6BaTVjMQRS4)`R4heab1@O`>kacHAg*!E}YbXNc_2wcM9y?V7VcG>@% zo?Xd>pZ+psp?5RwkHSX&wvn`()X`6P2o&x*0|pp6GLvjhl5X_%xP)_;P~0djG1i2C zKGGSk^ai-6e9o*0Q!xqN2O zA_G2Z`Q9#EjoDxk&>Zw9dOV%a8h7!Xp5ARfu5bpEQt6C^+3pKtjyN-e=5%&zE8WetU}@dUE%55dXvH9e?=?ROD|WHkZ{YLlU>C z2tc0S-}ZyPT+Ispb!kr|`@OzY}@}61c$W z`$`Z0eI&sRZYYz^g8Gw!N-6)p+u9Us^>^1>+waGlBP6sLd`N))>R;KPFKaAI+|Qbx zFM!y`P49o7{>vSXG)}VMg6&4`Q^Wn5bA+1;IGL|EA=|!})^Tw)!SKoZZzi<@BXf-^|PD||carD2Eb4O&$FT^(p)=OrsP@IoPFq< zp09=rqgJoy2_<~5#_%q^%&Z*^+nH1R@7DglqH_HrWhVmEgYlMZcA^q%b|PRklNr;T zOQ;<%s4BH)jjFyTgluus-^yNHU*WG|t~V_*@9nB9{W8dZJ891@UB0=gkiHZxX(X=d z8)Nt5TrrqLj97mQP4M2tUcua1S@b_%D9G!uF$<`7G%x%cGWek0N3hrFdS0H&6~DZ* zUpncD47%}waV%W+>z!V=PI)z+47j$owi~U^rqkr_%G(I^Xjd5KPybM>aZH0K^{?JY z_~se6o9|Uj@xq|t){Re|n$x}ir%iGM)#46jJ4PBr;hEPxDrQNU4+b{gbOX7>OIM$tYKMe$>BMOOy z)+H-ZmHi^tw6Jx_X$fGGwhKM%+_OSHUASsf!1Gq|4&ud`dHS(cZf&f5(m{8(P`@wA5u zT>RL|bm*kp(h|Ow>dEwaWLL*CbiYCq-u|p4g*54VDo>}3wV%krEWOz3*#9@6ggui* zT2o=|bG^2;{h?zrTAS`E$bNfanJ6Ca__Bz?I3wio7J}H(u@D;pbj<+n*8@lUWpZW_Mnr`Kif1RfrfTtsy4@QNgwv z$a7^|Dl8hFC0QxAB1qds1F(7YEw)=5HB#AIEO+3ME{H{SXSLON*Z z?uHT`?Xos1>#gZX{OEa@$kGbcf9ZboAvAg{M@XHaTUcTph)jqXzrZHE!0kbv@(a-J zL$ite@?Mn~oP-fi9l$8I7h}u;Vm?y_&7cE>JTj{CzqpM4tn;Fjb8I{B1z+V~z3TQ$ zTTH(re+uc$UEu{DULBgCQIIi3LufTV^%ab8qnX9*3Q(ao@1C)X6dC>aD z-s({C#v8*hu&qycI@ar#$+pL(tsl9>W(i}kI#QD}$&}N(OmYav|IgO)RVZYb>8Yhb z(CjzqfB-4UleC>Y4DKKXzy`6FUL1Nhsrb-<#}k>_cXLCw0oHVr_P=&WDUn(e%{{)g zHMh!f0oKQ8I0DbhPPV?SAw=liZU~(JYf?13MM|y*HGt@4fZ3!NC&}MGlch`PZSHcp z^QdaT^KJX-@-1sUx}*DW{1Kve=hPAOeqMt+rLE;L+y-E|8=i4^A5pqL+tlH) z*4Yv9qM{i`M?ebqaDTd17Q^t-$6L4E#rq-EfLI$w&} z^cCmGAUcM)dmlGPCie4TW?wx7VD#qc-}W*-6#EF$=sxfj=;jP$#l5{j8k(Mr3nRdP zD6c_ozwo}cUfa7p?5gDq82onKeA79B`u<_*_v(SC`1^)yreiKACIVBHYMQIRtDy%H*;;$1I_Ic+A${KjPZ58vxt9_8^*uo;_LQ@4( zPx89TG#&CQnh3AUBWAuAD*2kX^8L}UofH8a2RY!GzFp@?SyER*SOj_y5iLu9YK z=%WSK%Vm`14@JR%_Nr_@L}**Yxr$3>_T9oEE=rbNuW}>e%pU2$T72#jAw^hzx>B z5*2beO4^!;(dj4T8spxPNINtxxr(Y^a)ob2uV32bW`ulp85GsD4GNHo0-iFE&Md$$ z3O(o3x3|A~tCmOCn{0#8s0Xv{HU1CcP#%T#RR^l1-Su6C{0E%{Ra$mLLPdwSq&{)L z_ms@-8c(64yS115P|JvZWW)$Eu`a?vH$1cl`2x_8@@n&?B@!5pCxWx+|;%Ii_24H z%5Iw}K1CIe^uWZw9;Pe*SJ|hew5OGshmU=|huG5K*Osfe8B-MftAV3|OgwK*1!FY( zT|*y=GDedXdEyzLk# zCUzq%>nlsWg?(t>`s#Oro$DAC?vm2_!zL_J#dv^#r0*j{2BWBP?T#a)q}VG zpC{U&u>Hv^8Z}BlRryL7GcpNiJQp;U+sb;#zUmUXN7C&QxZR%d&?KHU=gE9Ctm)ki zw_vL+j`zM3W;En)bCR!H9mN{j%|Impbn|I_VABs^OwC3ciS{Ioa6j4c-K*N=%Z}V(B3+n}7k&`1v4NJ4H z4R{=9o9hi;`o{(A6VvThD*^tMm%TZ`*Qy!0{;gK)RrN5XSZM51Qk|V|@4~N4Y-j}g zS(Y2RKFyAZ9ei)|+JB)bk3+D%XOJAoJx#8=}3*7SPi?y70td3x*nln-he&Lj5 zB1xigxF7cJJ!st8NK4ckb!^H5*uLj~NbIYv6mlM6i9(8; zX!ZF+u>9bIDBoZ%V~Prq)|Vdwoqvn$mgXBSDBv?%gRWAQIEpziPxN{UKc|F5r_t6C zZH$S*K)c6>#|VN56A7#cq&xXesSQJe{THV0QmF3LN9lq@Gex+)-AG=&Z}z5B+`fDY zMgQ;fHTU;_I0A8h4_bP-=;&qREL#ftI}7@`dmF(NRfYkj{-bi#M418f=#SY7nPBRv z&bG$JyQY;o)0m~@WiD=RX8rcN&fDosK6jf>KaZx_)IzuAZ~u^t%y>aSTiAyUQUgjA|&j&|?M}=QW!dR^j_J%E0CMO?N{?=?r?N1 zj-Lm@$AsTp-0C>U{Iwr~RuCPTF$jEff0dDkyQ=enM7`Q{=*|xkzQ2CTA_u;}=HwF`lgLT?W#EW*D&y?<$H#(cU_`&bM2xHAg- zFwjSacn|@R_ejV@dh<)rg;=+9Iq{Q(glf}k9P=h<>O>_+@h8hnhoUyg?~ZOEA}*Ow zYSR00QKSI>kn-}AxJZLY4Z&PgX`~239-g&l7S!X1=DZwk5O8G-f!T~4U8r)mJGZ4? zWB57zu#e62MpbpO${sR&OYcGl0Qrb5*wLZOLwqE0Xw0e(Z%z@1l_28LMsGyb zx>-_w|MeJ-ysd4C7)qL~@Ju2Sz>b95(g*zTfAJb?&uw8005Q`YZ?rUAK;;6=u+6~Z zBhsLrlOUmvp@-sVMWf9NK3#*sFLsZC2qFbr)=QrCARQmECFW}EG zVPUXeyxGt&rZMgLc&^b6iW(1~35W{#w(<*4D!MG_hB00++(Q8 zAy?;~XA67m9HA-xNR5Ml0Nj}ekptl&;81@Q220*Xpvuc2VO?ea{H1+&QFs0(!oOKh z*J>+i1eyw|NHE;CIJf|ww8YShU+B#*^crU&qY>431A$ zfN%Q1@5gULQY5YH8_QqlYbx6Jx&XZ<&Ual}UVoDfKmRt(%yye-w;Kmcsn4s9EzpwX zt?2I4o*jd32R@}2QPQ1{lCE#)06Z_Ne)jZf7{nuaL?1%=tp$2jM3Sj3a5k~7*!7)z zt9#nvfpY38@n*&Be_-TJqQ(ko3}4uvp*bLr&U#A5qNLSr*gHE>hzt__nqi+T;}ZdXJ>iH)$)=6)@)~f zYe`Nsmos41B{|?C-$+65Ip_Jlk||wy0V1ZRzaRSJALeJ@sp+FXkH;=gmX_aSt^xiM zCL4JST|~VA(1r#skgVwRGqKC>oY1G=>NpGs&dP+--#h3rhZdwceKn0A5uh{B!OqeK zd&+Mezz@-T;_dyJ+W^OJ^$9=brbvx5d$ofhJ}T3axt z9vb@~e&PEYwahKzKe>r5&WYwNatz=Rb-uZO%|hAbp9#}cut18KXv+|efg$4J8Xx8& zCJ&D%4VjX9@^L^S*`PiuDuI82+>>}+|JSKokLH-L@z@e_)CcBU`)@GRDFmj6yG1Y^ z&hZ>h2ZKQ6sFU7SIp*7^Y!xQ#Y8PqV@PV9Z`YR3RSAE+bJZHxcfz$3aT*BS~ zb9d=5`!rGSR!`gdhE#Hi_jcw6U!6*^wI|xb?5g6bZ!B znF&@HMsO$u63_r(*`Rcu^1o7LO2Q6eq-XnAd)3W{{t2~vwM&$<`DLvtUTjSW`Hky~ zSj8jGdguE$Ds?d3LB(d^sJ?XNzDbVjDDg=|T4(QBFr9_@LIPq(Fv{fPNAOzhT(4LrxD1&>$~6xk5SpxbOw!6Ka4X z7^BzF&;W+@9E>J_^;~)4*1LQjf3uqU1^~2QQ$l<_*?s>aKR6d(4-34l@2^cs?Yyjk zvQM9BE|I43$8xp?*tMTZzkVzi#v@Ysq3{$zK2ze>Y`pxNM*x8n{oGFBR-l0Hb}u(c z{%1#ctXe5$pc>iVcRToU5zvDeYB@vW{ajX`-R_TvOC`7tT`O@)!TMN3w(Hp92f;4- zP4DE7SIp0M_JhkZekj9s?$xfOS8 zbyX&&FLx!vP+*HtIXqVX$M%@8m#`jdlvpr$fDF!gl>e?K1pmVJd+Xlu>nNvzlJmSJ z)!X~3IKAfwPFVsn;RoVbsdEa%r=z|xGuKoU^gjwHVnYW#b08j#Yl2Bd-saTet5u+``v+hB}~+c~=1>N!6ZgELr@I z7BHZ#4y|Ay& znLYp)6*+{3ZML0m?EzdFFb$3&XpbCJKk3j(mj57^7J#!zhEZf0gRqqMp;A!i;&qxuB1hA#=urM3`!iKseNcNrgCL7c&Xrp)-ENP8 zA8RX?iWClT)wv)W>WhMAIlK&sPHIzd*E4J0Jzx)XE+UT4GrJ- z`%u?6V3R7YrP7AN(@f}k`+30ARXQXVVk2vkEZ%jwmw42?dh|my^S{qLj`$u$Mz_kc z!#lQ?&kYg3Gx;z|AT!uQ6)O5bfbitrW6I7}I095JXC8Z=O1jg#!S{8`XSflbl zF0T2_{IkcE;qS@DlOYXK4IpHDAz8|atB5pUQUw8El6Vq-zFMQ*OrXPvsFI;~h!US| zGH#&XSSxWbJ`iw-Vr)w1EJ3@BJ%6E*p%;QxK~V^g5VzeWaJ4In4_5T1FmYC%dtx;`fkiPH z1&?9khJ0sEkIzwp0T@!Kgx@8=i5Zld54o-n;f^+Q_SmADW_HU#jOY?h-}Ube9DI|4 z?Jt5g0cs2yES=RRV_pV9lTYJ>LexFY)XJ(!!robFhFV@1UtzFH%)XpzGZ^?i`;5G8 zpxsEt!T>~4tNzgH)>0X0!vGj^q?b_{a~jCN+Y^9M7G5jqv8=GD3>^^$;r-1--k-r2 ziaVVzH>ck}Zvzfv@&CM{(=#e9qA+E5)ZPTszx7k_;&mH>N7B!xG#0uU5M?jXj|y*> zhIldA$qNBBd1ZT9NP9Ty$y3-d1j2hrhBe)KIC2)hx$V@hW1j2k5zP!hv+eVemcz2+ z008<=Y9(fTVcxTgA~ODeUEGV^mxa%i!>N-Qq(1vik3@=o=ptP3u1y>JP!!ZAA0f>4 zTeG^5qTuv7ox+>K2gJZn4ML=oPIQmX<;6tDN{K7l*wRUxvZxHE`HllrBRJLg#5kw` zey_Q-d}NjDPd7a%%}az%zRMxhi-#M{hVY$v#B#n9*vI%$I_-yo9OfwM+y5IhA}%74 zgTnftRB6@(k^NcYI{l{%!X{04Rr0MjhdAkKc01J3d}u?JlvTd`Gqxoco00VeN6>se$wY}R_CmokQ_Vf)Y$Hy(^JniP&%@H~J9dC4sTX&@9;ekc)o zP`;fk32HB`3s4Ey1Q|3aYqy3DARjpv1FZdM9!BtH`++~hcrmx-NC~(zBRdvXOyFfD zH*xXBJ?`|i7c4D-ykhYrfe&;wIx(zs1B|FFzCr>5JN3 z4JEI<>3q1xcRwtpa$GigyP9i=eIt8!ma5%Y!W2aLRVGgI?&g)#>X`k-H&NzQ(cQ!3CD@L}I*tlZ~dAPGv` zn4ecd(1jK#z*ESZm=9)Bee*FEAx4{BLLJ1Vm)vfJa>D`m{KyLy82ycG6W>TbdKqd& z;1Y;z{tKGIbdnm022yTVoq<5JFYYpz0)i;sg_q|WnTd~&4@JUnw!gr%ScTFBMn*=} zQbob#G+Pnsu~{dvTv20zn}lZ>O5nq(6=m4S6}pu-ku{>cDkO{}SAIqml-zVg8N0U+f`PgWpTV@Z4f`Z2 zo|S4hA+_S})AF(l>b$Jl_1f_~UDd(yq>EcEUgQphf=_5lxM#4>bN*G)r{^y1RyxsAYAyu8wu+&Hx%dG+sok%Ts^LUn#T8Kj4hdwK_CZ6cZTWJjm;6@o?64l+||o z=Tg@e>K6gz@X?>=g8iIS`NXB6d#9n^|Ze|su=N65#CaCQ?eLM-O%QLetoRDn} z8@Ry7X-S(jDw%LOoL;KHff$i41>!WJe7^w(X_i82)26YK!P9SH$A7Snx>8QYP*(SE ze+hTAiXFDIws}kjRmYRjKlZH6Woj&l2Ui-P`9ZIV$NCGI=us_YY*RKp2p~M-1*>Dg zEbrn6Xsgnmo9$tzWMKsr!F3i}+1ONQR90%%eBX*KBx%!gc)Zw(jg4I>RT8G{LV^by zu!THr;{F~C8Q6@Ln-&hZd6Ka;G;DFXpGdLcWJ|wAjEHk1JrVM~y4r0>%UYYAy*fi; zw%cf9|5e~7vgZ7R-SqfT@Jt8=))v9~E$bnVe)hYNOf(}z&cY;8T2sTc<$@?-r_q*w zg2)(oKPM}zAc$*!J$?wg$7H|%h)*%Y;*8ejBzwbsGL*RfEjAKMTY--4tENxXL2Z5K z5l=)-sx2u5gjnRN$K1)=_o+v|eHe+@i%SziMc5TV&ue8L-*@Pu5{{){AuU5h)q+4f zys6TXh-r^qZU=8urk+bLr&pcAuu%0K2z6Bq3q$>^Ts1)8xeQbrDiF_Tu0YZTqoeVH zl`WBqfZJhn4sjm_`adH=qac_4%O(fvwmphgXCBjGOq=*Bqe7QfEDxYNxNYM}Rq1|t z;RE1hb@>`Ok;x?Ly&IAyl^*9nj>!sqlw8f?qC2$*21&!?tCV0(!pM{Zi>S~g>*~4* zyY#-13duEe64h0Eq4Go-%!$=kHm0+CGrPTW3~;Cp`*bwW!f1MiIIA>{crCot1+=Si zh$=fXi+*E!_w>3PHryeZVCTHu;%)pgJvj|@EcT@dCX|&|q~cZ#Abh?OJO?3+-Z<$4 zl>*GFGxrb7ln!{my2bJh^r|vJZLr-@X%~i-?v)zTOxQ!PBl&e9pW{K(rl(J)!5NtQ z)cEILtP@tx6QDc26qc7;uhpBEw&OwkXTp+YYLydsOlN2RI$!%<>HoTj*J+ij6@1I! zx3$of({0k&YB%-y>@^a~b+oh6>#^Qn97~$fsV^$6Y0)b;b3KmjmioX&diR|atw4k4 zLCH_bO3|2<5Yyt!i4C0$&krG>$|E46g8!j6={mQOx^T8$GBN=FWQLcu5ClbSs-|@M z+@8>0{rcb&H?k~=9?P!J?p1abx>Dr-b<%G#l-7@Uy$1cr_d17~NsmJW3%G%o`ROn{ z#bpRcmk#e>0I|IN4!HkP@+=C3P3^DTDzsp*`CU+HF{fhUmEQI`) znhs*$z{o=13JD}|Y;byR12Nq$`hO3_e~%dCF}-rN=&-kBgrw%nb+4ip?%WCWiluOq z>v|+60mtJTnSGipR!As~QBqpI zKF3he&zbQxldjQr%0^adI$F||PvzAv&#uBg_sqO=KdsN#e}ashQyj$WxAK5GN5jR1yrPyZfOaGV zNH9u8jz^~|zMw;J>ZgjtvbQ$U2qNR$m_+C;cZUP}`~MgSp7_t7A>aI_KTY{sn@yHV ziB!*eOqeEpn{E3eF+L|@M-RlShLd>HIxdHMFqT9o7u8EPC}DfuP;lPfK9)1TwMUO^ zWzLN}TjVisJ7pi%Hp-2sp=@O(z@C_;I2)P@VL}06p-vJfO>mQ9gQCx+fG<>~p=P2& zPhGB%0YR5z=Wb^z)56xm>dEh(zUlDcH>hjLsPDS9-WTg8f94=^QL^vB{YXZMKj=m6*U?_x(BXpbT2XJ~PHL@D)1n60zH zq|860);e5lu5xJm8b&q_J4K+7&szr6u5r~*6(fw2j|}PNFZbZ7Lqv`oSX`fwFwp6pS~Qtp(spMsZC{2FxEcGa#)HlrEfFLhs>hl+ zSHA^qXV{CyW%?CdE`N24(Kp0du7qoCfBJAs>XvSYm=WqLzxB$;6HcytnL%DQ5yVJ9 zr^7c*RU{K-C|=ZTy72jbe+)Xh6EZw`!MEsr9vQPl>H9%^NoApF6jZdj13e-sWi?t& z!1U3rhLCX}w4^e_8|Qr1S;*pkNzZ^%UJtaeg-Gw*RniAnOs~8cIzk%9l#Zo24g0_k z-}{Y~)9kj?7-oa>!DWT)i-O{q>flV-@j9xx#=|CLsYgAusp#X3R6BdzPKXH67Z14xY2@wj5_S>JG|-T zOhhW+`wbI=eZ)Cy<_OH^*5&$mv3paSJTKtKr)vvqmUz~7aeV{K%aErUQJ!^#gshKO z8E=nvqk&d{g{k|SZ$N}&4NU+eAC$?n;G?yP*=mRPaNoY{orlHOBv(C zzlFazHr4i>MEcz$pzyFEr|fLlu?nIjH1N38Ms$Q}b=G_((i3Vi^4_g(I~)ZTf2Py3 zHo&kkc_Z!dJkj0TqZ4Win!rmh3=N*T+USw{=Rw_Z4hh$W@tQC2Jl3+MziV4KP0L(y zKjv{ze)gqP22u%i1(RXh~Mcf(u6#Cy7`=%x)=y%Q!%v1Z~U~iqx;cud8 zdGw&)5m~0fRHOa5K6I2F@SdW1zTVMNmv6jYM0?8ZcqZgRg*Woorm3N;u&wTRD9sR5 zYws!)?fTq#w6+MD;MeVG%k50Ze=E^9`BzpQ`<9}W4J@!srIJe5#EnDm%HoDL9OxJ@ zN|vH|DYTHmNb4X?0;L{xcWk~$bo_O}B-^3txJarYdmvNl%I%fqXD`7R@o?sL(wZfzX**u*)6n>|`QjxT&8V zj^XrJR$0&g_cs3TF%rfT4d%ppfrEV{=3((1u3F)?uTZ0NCb|T*JkFKoWC?p-#FQ_{ z7SAJ9YTB>2e~qSurAryFvR=4p+uQ|Ff`c^a(W;Pd=9wmNK9x76)UQih4eL=(huq1lLQzoerf#%-oc z|1h~+sbGzDSN#rbgoYc@A41==9>Y&y zOaVv3Eh~ea^%kLOt&;pF5F)|S=;bYTN0Ij~o_3iKjk{D;a-{zogCT#UByl-zu=$NA zKQ|mz`P(xF>JeQE19yxbcf(EUG%8V0(C}`&AXwGDknr2wO_nck*{un1aA5!Z{hkU? z>ZvzLs@pq_xaMZ2#>`J0wV{A2MuQK;9Gp^mqH!3%kui43k*Up3nM^E3hg?pq30Mk- z$2^Rf!lcTY`a%n_snd|}#ZwB;o_^!vjke##?Wk|nd%j0(@`d#pHmS(rxFRCP7!q)ZKhxpEvREoBWA6_!8v9|Kg$+0cm?`zYtkphy(5s^$W-eBcu z`xR38s}!VLzIoSseoQT2ehvL9jE79F+c-lc1<-0I5yb#vG2alAdBeaMBeAzKgJHS< zW4+FWTHnsLdIF9v^e1o`a|Hdu6J=c5vdF%Wkbv*zA+exH*%TLDxe1twnW#PBZS$Wc zofHXh&m*_BZyD#5#6thPgd2(i(N~B}MD(2qG)@Z5-VjQjZCB(0pKo1cq8FWskWZJM zWbAPzV@B<_#UG|xvv9}WQM%r@RL6fJ;&=F)Zh)JDn~E&~@zgt_68^Gj=M6r*ogdd8 zzQXZqElps_U3%PE_6${J>BH_~&Oz)*c8H4RhJ4eJFN^gwNhmdfwZ(t}?Nb4{bN)$7Em%gU`B4($KE@;4IwzjucNF3xaC6)E-zmU_@z(RtQ z7wNS|$}iWJZL~;Ld3S;#6X|F11vqUk|DMez(JeJBWjx68uQUSMq;74b5}8hE9E6zG z95?!Q!PNfU^_2pYD#<7s%&d=bPCUq+ro(d1a7Q;)03A`AHHtavk(;Nx9lndK9oGut zTf=fnO(V~HV4SIZRp}a=e~2;dbN@Xhdvl0wAU8a8$&1mbAg23BK&Fp__Cm=e`P82q z@xMQk8-muH)sSe3v!b}IxTeSPC}%5)MlplW`6lRMpyT0;1pf=Th$-an7>*;}*j)52 zDlJu`Q%dKscC*w}72n^Pjkg}w0+ZLDr6+~`jRF^Ta#jPebdGQc(T&1e%wlL7U#EL9 zsRRnU>sRFnkpgM&EQ;)xf~SftbJJe8q{jxDJF`+8-}hBE*?nqctT(h@J6bZ4I9t#B zJV_~w*cVVx+}o}wxyFm=K&35-#PrAHb=MrlUmca+X=)k+FG}!er(*bUM4KjFV$J-W zTs660(-VqXd;WOk^?&0s2hf6r2-&nKnjmvuG~xRIW1+jj^jz&p2)F-xfq<*Gy>BE; zC_S|&Uqu@qdq0{^S2#JjrZahM`=72CXq3i|ZW+uS0|JyBXKo1%*4s^A|5Ynmv^k$H zzek`BD<|7e%lc^CO+rC5t1%pLuQ4%)sWwh?=cHk>v;MN}+RmWpOlW@+X{;|mRq zPwI2N+5{L|F_#ibO--ScTvFZr;0C^0&l2naE;;xzD`}m|$0LA2UO!sFva&PfXh%n_ z=EV%xsW#j^V_8AO%l8Y;Q|8b-FCZO<5feK*hKNB5zvg}*frNaC4oLzXpVfDT`$B~O z>iho;D1LDJ+?U@slH(N}KnklqFNz=<8L5F6X& z_l_oX4aAmgM}!A zOqN!l=oVe# z?rBrS@WqZeLBfvLMF~!MBhffno@Toy`^+#$P8XMug(~4C2;v}VXT76}6>&w@fSUQ7 z!ZicLp!c*!?%tv!tWHiWJMKVqh~A_fE*+!N2CJ3D z5R@E#>1cFwH;_$oXIt9{w^(1avl!5Icjm|PQcGDk;?}{@kHGmR=^W%HDG01eP47L3 zo`JH__r`C4@R@o_KRP)6@YX8n;9@wD0J8r%%iXvGdE<>K zWTDkS>eQCtR*FlpPUSO)(7U`+mjAHaGq0*dPWC)5I787W?$vmUP|y*>WVt`zs*S)xt1)wEqUbRSnR0Ox+(hu>h{i#ChslX{dL|?IV~jD zgq{7K>>(GV;0y7+Q>obB$I*1lIPTxeF5<+-pJc}=xP$A17!5^`t<;lXPtdu9_vo-N zpabY<(^*0I-NExGV!Oj@eUD(+rzE-M3|TxhQrAZ(!U~I0=n2yIc%f0t)jL zVVTuVln_(jEJrT%C6a%d=3c$x zxBX6F49c!ZE6oiiU~9dEf>b0${~>1+Zk9wgn*1{xK+SVf<>muhWL6nNf8o!u;6|5t zR5=>nr$VhRFhDWo@vlKE*e-SB_VMl;>k?_U!!m~Q_i!YroUa1zgYcqozeOUvZR-=i z^CX$iWfg2M)1bwVRY#;Op9jrq_ElWlnUI%z++l!Tu5%XslZk+t{Knpuipe?J`UkVi z=aly2+_uvp>F(tL`Fn8X?)P@6xTnX8+Guw#9+V%-UYWHHP#;%Hb++6#QQT1@p=Jjz zT3xU`kHU3sV;i7>Ku-qDsE>Mq9G1Bbz+@zJ6ShC!cS3MlboEP&>6iWAa_-GF!J|Z< zM3RJy2>CJaDMuOn-m6Ze5p_!i*1%FYk&#`d<&ePqA@)ZTL_)m*s?y07%vEmGUP(~W z2JBez^1kvO6WKo^tRy*s*QDB}?J+wYbhqv2W08?)>{CHbhsrg6pa;L{k;TM*-KpuvLJe4)Fx-; zBl|N5)LspU`kw2n;`$v$eFWf*FbC(SBR;Z1jaXz5D1t+8L|QGj{nc!r4qNJc(7q5V zofS>;IKdk}^L;#$=9eGapBnb^T083RmM3DY(K9QMb1s3Rl&HiBB>m>m(>W8|* z0)g+nr@`^7a^9#TRL zPfB?b&UYHxN^X0w!NKY24Yyc9BgYqb-^1ikb#e79M%C4O+gkcU2{*)VL>=2mg2Np9 z$a+aj3AzunNgSWqp)#UmYAQ#$ddBbS(#wjL6d2UrwNzigsc7w7Hge#X_Ic8lePw?s z&2zbXI1)?hsboY-X~(_(qOnBJJy$iH(M}xnbxdr8ftOd2!2Z5l&R2~Lo#12UXMdDX zlPl!WKseYqL<$@k6!xCiJ{oJYpEg$GL$vZGsW56!(a_b(U4PuZt)16_FtWe0HIqDw zxIgwx@OatPbU1cJi4KKNkbf*s99&d-$|332`Q;~_*AMn3ir>$~QHsck^GTDo%pH6O zXsKxvehn2sP5;|e-&uLaY0T9?pPS$x9@V` zQ+*=%xYo{W?B#H-7qz36w6(ULi)_D37+-PUT-MWEJ*(@`p07jxWjhcsaXVc&t$jJ+ zRq}mWBh`(MpmPt{o&`y%hS3w5>lbyp_h5m^x%<3M^c-3APrt+t?VJd9?btp%i z750i36fMA0;N?#VH{T_e8Fw3R5A#d1dU5ncoi0aa&4IC2CT`w6pSy)=Wku}k)R`rW z<%l0k2u!#D%!1Ye(vPq34Z&0fivt{ zyoFqYr3M9^;ZyIet{B*jM6~}Fv(tFQueRBzN(?xWLXOdcQ@>;F@BK$Yln`Y+*F?1K zvbkv?NJ)W`VMr=al?d#ZMZi6x_1fhR=pzZ2CVBf87rS@tzC19n%~^qiPU8R_?w2R8 zROkSee(**wZVD0f5jQv|(G*GqNT5xYu znM=0l3C{~!5f;-pw62?znfZ0Tl~wyqoaocnA!>QGV#G|Lgi3pIqfQ%p+JWSH zJn{Hpe-0*3+%(u23nDFLmnzkOY~~IKIIavv*P6FT_-beEn8gp-e}lrF#sGa zuYXgDZ-T$Jtt$w<`xB|p!IH)XhwOBpr{?5e`5rE4t4nl{6V2$m4CAJ8>T%l*$3o)7 zE;mRdt|Y)c0jm$zDw>;aX?b@eLtxs@Tnvt<9<o}^z0AH2uO)LJ# z#iCK-B|6agz|P}Ee!GHzc5pjM5E=hdirnYuZ)tuo(Oy1#{+Hq@h-m*MdqnRaQ3X_x z?W6_ALJSVWr0hXB-FF%WoWXE(bQ-bgPg?#weTeqv&@bdlD~w_{^L*VG_*06*!_5&&4p97x`meB z(VPME)+-^Q6(|?cZrz8@+rKkj4)op(#*%|qe{f#Dr^Op^)}w$|%EEDtF&V`mRK-7~ zBEyLHr30Mpt`@>UCHAPf3{^dtrTj3}eBT_gADiq|q|!Ua_SidF*fyxI9Pu>XpnFnG zQ)vVcVrl#a5fiEm8V_hO>y2|IBp2&Rd|15K2|7u@&#y%ZxYbP;B$2oBFYg>di2wey zROOe4+*3;+h^zkikRwX{7^3dCrU(cckDHv%uHU2?)l_r@oQQfzXjL+TLPLkuJ8CSu zvTb%bM9qPJU2VOqEyA`^%i}TIsv<6x^^V%M8Vhio5gBZAxXXn@FaM;F}4~?iPym}@x^W=t^Bli( z_zZ7n@u`NkHz=xG))duOdATYct}&}AC5!F0VV4N8i;)UIy%A06x4sZX<>Nl%Q{8NR zzc<$Co~tgyK$JG}2l=SY(eQP7GMOfeX@Hp(+gALi*byTi6>i3ei+MT-&hdk54Nvt>C) z4xp=7LWbot$st;@bVQ$2b$y=8{WE28yLN^bJ}qF5@#s(e*0rAFgTTj4D^Vw3UC1A0 z0cw=To)zg3Mf4czsmm%=DgeP>?}yAr-S4jOc`@IylL6bTch_`$otA;!S}L5VwA*w4 zatsGCjoJC-GI7ghd>CL$C z=P2uEe?u7TW13VUS>5KQ?DL)|r};-=wzix%21lIcpVUKM8G89Tu^R^ni&Euz9%R+D z2ilI0#E@Bf+S7vsI8gS}!#uX{*Eq7Tj(tdeV53N3)i8_`)1s$G6b1hdWr%o2Nf@D- z8nYxBI37T5HPX}Zh)GVt?bU5S*wL6YpV|sj{pU9pq3W*=jtNZ$>>(oKgnR%HN7CNLtGUmwslAPih zZ;6`kr@mo9W4E_5GG>cgUx43??YcN{s8v_2{tFx6px1Y#qGhGELgujGWpxzFNcxKT z4>x_H-j92Wv!ywNfhnTp0k`jwYjcRJ@CZD5|;H&Fl!NyTo zBm$p-SigX}isRvO6Yb7>yTk97#X{loE!xZ}$3z4DU^R5XAbLMw^)I`Ef{HX=aE$A|+HQ$~my`qdB7lnWI*b{UBzFAa{)t*%Gh}zLa@*h~|NRulk;%Qk zx|5#!>%)YU8ew%Pn z=%blJ88`mx;1j-031kk})5p6aS33-B4fm9|{|Z9>Zel@~vNu4mL3{iKf9$IG^eELN z_fBfPT;#u6!BL}BF_#IEo_>Au7zP9MgUIokj0BM zVc)`lo#|8eyAhkDT(9*MV5@0={ASUAO7*nXbc}CCse_PK3j>dimRyZTl2`CK_R#ZA zcEDlbmDuvqlB6@7ew?-a>#tUijiw1u`osiOc}HbRi(SzYG^4MkT%}dk9O@xi#4N=_ zqPM@SWI^8~EN?XV+;4ivh>?#c(mv>w!k_$YG?@oh9M` z(@uAU_5Pg00TVM3dD+t*D}M`xt*>@;1gH}GofqVEck(q7KX+XH5J&y0RQbN&Vzk3u>AW}y+!0{AwkX_Z(p>1J>vZJ0D^Rs) z1VLVE7@VTFM`ks`sd&r$l1t}m9Ol~B*Uy$Sk{;maP#{TQgjv1I`O)r@qRdY+fQjdZ zpT;)D9EVwBh0NFI$l&=>Azr9oezfTNqqNp+r6Z#bMk?=G8bzX(|pS=0y0Ib;kR>AA4cTp+rX=VdlJVnZCT z^Pr2?bkJ3j=k0h21V)AzqFrXr#Oo29mg95J`>*SD5*z3mcuNGC_!52JYiZJ5nrH@~ zDOe|Hq~$_q)$MYOEHIeG5g**pG%@mGyMdgCqD}x-_(?@evy3-`_4Xa1`}g{toLv3m zRSEgEBksd_E)NrRmqE=@4ln}e@ZC4vcQ+!e4x#Y5L(Lcj!j3vZ$xZ-PAV&^(IjYdc@%js<&!9k?z;B|no8vihcR`E<7f4);-)bRICWwlkk1zPw##g(})a;0j_ zfVK51EL7#o^KF+mXgx*0SBy)wq_o(#?&TR&5N>Bd4xAtQaw#i0V&JVi8-}mmxN4dQ z4INc$*}7XuC*7Da2m33(8b(jH6?>lmWdA!>4P{l^D1lcMxZ2$B*PobgetSl2reRS3 zC{@XnKzv12bUq*TM!dcGjEfCJ(!)_RUoV;XnCtvoJ9@BMn4OjS*m!2PiG(m>y98sF z@|U|nq=H~dKuz)2;f;RD=8}%g9~~p&J-<7mIaa`LM!#n;PW3ieAI~oX_nBi@YR_O; z8ecDJz|F^i(*EY~$L)o#^WvTAER!Z5qy0E)*j+245!r|9K)m>K9O_?4p!t@Lf}QIofa`szd~cnd0uuj}Y-Bm+`Wz{cPzQ)^u^?hrz0nIXYISHC&wGg zf$H$m9{wGiLyV{Kfe{i-l)zz5hX%7j~wW`E-dXLmRi6E>4CXr4a6K}ir5%- zpRJb|qt|#oj>v9YwNjVr*tK3!U7r?=m+38l5vICU1K_mWshNca4`kQqlDophhoTP{ zOGD?Fc$7Ee@W*SQ-j2~V!ZMw>Cu1}Ac0+bK0$cXcF=k%w8#n3NcoS}`+s*MRa&k_T zc?j~ar2kQvf;UF78u{uH@5zu|7RVmjeg{OB&_0Nk27hogfSl+Gp>aSa;aDBOmu)!uIAMfy)_T;bnzXB{{Jhc>t@Lv)q= zX%q>6z-9p@wc+&1IwS7tz4gFoR8&sP#_pKY{f>f&M1A3UP!z@E0q`dhby)8!9mm2I zc^cfAIpiiL$ldTyi+ImX9;KE#mM8s34>vq?@#AcN<|ZzC3q^!~dYeMt;65ip&ax^S z^a3cH?n}}HANz1C=SDarem7V6cjp;HuXhFOANPIq!&vX{XU~8b(skl7OlVGbA;e8? z*4iwk{KBwzLzz<2km_Xk-q+XlX2C(*fN)Ky*5WSrO@U~{o|MP}7B zV_)n0^bgpU>gt7J%Tujp2V=kJ@xTY~m94FPINcjZRbARVlRE$5BM*tT z9k80N(eLbV)|AC#2xhMS>6!U>+r_v^>t+~2lFa@WK;FQmg}Ury?Ks3}xqm?6ZSfP`!&h(8OKlp6s~%RGA7Aqt6SH4#vd8k? z6%^kNsP!B<@W`|zeeMy^Y0;(jx&blGZs_jlRx^Gi^@B1n#{1wvlgBvZ@u4+v?r0XT>kv3C4|^>VJFQZ$ zA29XSFo|#(ze|QKe{_VFfcc-PM>&z9Q4tjJCgi;q{tCjCm9<4 zcY-FA^|#eWqWsF&#U6bCIFM*5z2_RoF&2YxHvo+7dqx!A4Xh*FpIS1EZ|T151RHwU z$hhXVKX(6Z)WKDQhZFB82&ClA>wDR%Y|rviI2kWC6^S-`eVNvI?sJ=nr4>$#9*d(y z(BBZ=x4B6#Fx$&(y_(Chv(i`GZsAxqeREKLZgQ-)?K6LamR|X*zjPd4te)+fO+-jKaxIeb5p;C zEsp2j;x(vsPB$~Vl2RT)g*6<+?gZ+-`lenjtuIa0q>-DtlFEy4biLr@Q@VO=J8=9Z zg#V4f#~`E77%NUs*_HnfGU|)1bG_;%v0#{oTm8dE3E&Ro?3Bm=yDdl62Z~@MI1h4J z#7_~blETJm*x=cv3PySjkEM&c4vmRpiY)2JUf|v%#N&8eb$t#>BzzwtNlYwun(K<| z`2@@QpprgIF}S!3w)fOt$Qcq&6RmPaS!)NIOkuZA6AZfw(!M(l6tfP?4Gz2QR}DMZ zW5&r*^dQ{6ZZ3aVs#{hAT=cKsO~`O+?i&M(XUpDGFR*u1ms;T`URF6@MvHb|WB89U z?#@TvlH|2+9rn3K^`QnXOaZvpgmyp7nDN5Xa`D!vm68hbtR zljN-|e>D{NHbo}<%?-0G5AyKG$#7Q8$sVtho?^KCO3TUE@P4%QPNJp$v|>;g#C25m zI7$p0)R5SA3NByOQu|ahlC)T$p1aay=~OwL6Kz8j=fH{VM)*kjyZ2a)*J{Tv0;m(hUnjhm0*UJw{@7{X*ZM7zx4d9d6PTS zv)QtN-~x2<)z^0j#KWmuW!eaP1=OID=`8f7&w}{eqis@`?cB)-I;lkXmUS0(=t}NNS3fG2KYVJyo8=Y;>#P*t>t-m%C8qq+MY{4mSz6BT!b34L z`pGyGO7@NoJ3(f^azW?_RhbZH>2#Qc-_P{JY1u;GG3^Z|0+IigfHpxf9qwJ}yOa?V;H-AwuzL77 zJb>|dlX?aBtuVjLg-u4>JUpBMEr+N`X{=YTR0Sx@w&`sD5C)cQ$!@5m37CjBa(jFg zv(vi2Oz8o#Oox~68t~&BewI1i#JN!p=%xO?WFB>kg-s7Gc4WYXDKgHOL(MkoA2sJ= z%m%3`pY->AaW?C;I18Pf{K&3j2M)_M6t>hFP@6Mao_}AVRrY`ubmP0c0B!R_v_Ekz zLEEE#1+-2?%LpYsi@?W@bNF2hl1Azm5dNxpiFaEdUESk#hvv8O^?hv{G~EvV!%`hFa9D zxH3vM?0Slnp}k|k;zfn-bB}-9vTxe?GJ&Pn{*V-;^Dl>V6}Z^j&qfY;sidH>GfFZ1 zF|c{}`Z6t?LBsMq`3tn>10rJ`x%CxZpMJywJDyhsdwOv0GFeFmn^H=%P=9EN+mAfs z{F%S`q^6zTKwz~1;f^1c4%u8#JH{%}Jrtq%pqK@HI4wOohL+(mBtdeI!}xixIhF_% z-Lv->KD$2Ac#GGuRB9b5sp0m_=8@b}W^M%9%w@mtCmtay%luyHjyVKL34`_KAx(M} z2)8gXP8+;BC@3O%OWxg|(vM5rEZe@FUS9S*@Zk!`8hhbf79VA;MPDiT{%%IpCt&mU z&MHKI&PWt!N>(V{NcuD(;kl&I^z|=B>2Ots3$Bno2c1 z52hiYrOt2cH8B$A%a>E^FTb-xJ}&=_YGvgW*>tKTUzChDVQO{V}CJ9WxLGC*UY*}t8xaUxRbwr21GiUYe^%YV3ntt z+S2y!B`zKmx#zmeDZ05_ge`w4llUipJu#W6EVZ{sU7b|aHsL9S9GyPE8|b#XB8b_H zbH&UK&&y5F%&sm5flOP5(pjo>Js%EfzV;JACLHxAuB&14` zi;?(S@J534Dkf>+EViS&veSATJ%G~;l&$DBDhKx%6xMDM$%#_MkpH3XgP zYB@f{dlp8|LER&87X99L1Q-~xW&Lwp*1Dzb+6}1xateFvNkzyVKBbMV{T|HPZ_^wV z`i&s?Q!MM#+(n({Pda8-)(=JAW>(v$qc7(IupSD20|JEn00{B9 z%fWx>K;BNIPf(s)s#6NK5O;c6aU4>=Y6rTwL)$~?A1LRH{f%qm^TYr$a!g?6Pd1q* zpgTa>ay>oVPl^m_BiN9ELpGpcc@J?;4_Lu{*p_(~BPW;Ej<)H%=E{Ij^6#qTbA<_9 z-QsL6papXOXLQaNnL7}wvVCeU8gQH>*x1OAcKv5mch!Hc2YFiA8cYBj*K^Z}n@0G- zT;?=L(D)SC%WmeJ?rVygPj?W8Vh)o{n;DV+ttYYt1(C}&Ys#F%29*7XN4Mf!9s_9r(jmF2z1u!^k9#Rb`?74=SqmKfo|I>?+V zr|mD4++R!k7|ogm6=B^19T>D9&aBof%)e3T@Kd?F@|Mia?iX{J%Adyo4} zt2O7f85(YJ1RsQ7y1*euF)z2xyn;H2RD&l%^{Q(Ln{Mm8l&kl2cSI#3bR+9ett7^? zko)l1+dvQEPv5dT{SJn+@MioUCR1HYwy5Xd-<~VnvT4`zPm?sE6WV8p8N|n;1ni?- z>OUACkz*??=?P-@c0(dY(6VT-mZU#tUb?>w-mHqk1c`c3?;Tp_aXUtPi+XjZn;SGC9mMZYfLPnv+|FZGf2jV)&mE30Tl;w8sAdRV zB~iBm7+f>4tX&J74 zLzbk@C96Kb!m$KxjNcX|ZYy19z;G0aKF>4Clq|EMe2h;#j?klv{3*>DNoPK!E_WJj zu~H>prS$V=Iv023XX^C(QaY5UjTbrFe>k5kl`ol*ENQ9+ue0-#xG}?XmwV!{)=Tuv^~=3tG+x)CPj-KW-Q_efR5B$0yyhEi@7zmWV5EB@`giqck|SEx~TYh*4T+xl;G|=o21e^NuD+-8~?58 zdSH!f;-e-O7WbLZCn@%|1k?IP&L@CW-YVbE$PJBZyc|Kt8hUZoEc7Yw%@WS51H`OI z8v@l5Fy1AS$|y@=rFp8%uDohEO_=+0B+vl5vbvy7(f9;ZAzO|5GFm9|+083DGd?T0 zp*_D9F_yEIK#?i&{tWVyj~JSA^PK$GQKa4*2c+3_|Jwy_2jU;jn)}7m=vo@Oll?gM zs@-?Gkh4D>X0y)8hASn)lnKd&Z3ZP%Ff+@%M0yUSABHOc<={r%0@=pb!3HQZV`@%|q^ztf&tu znM@{0@DvmyCd^NjDpuebVv7YNME|-MZw1(RDLn+@zLpd_0dG#>8=IJ8>CYMMY=E4M z_!=QBbD{|1x9n0iRf?|&u>i-4`11pPJNu8p*Tujl(6g_ni= zk;2O9Nn4-Y4vu8uHLq5g^rP0Fa{cRQ8iD9Dh55&p9H&b=VgF>w+-*;qm894Px8SCp z^=fX(fi&D7dgIF?sR}Di@Pr0uhL(nGDwifAw$Lf*6-#eZ_XuseZM}D~+o{YOshoA( zQ7Xern(`e6YHdPY+bO{FMNK^)`%zS+P--~0FsIyF>Fr$9!i_u}m|>yoYBl?888bKW zDa#A3--4dC)ISboM2ha~9GjEdyxSJ?ve9?RPWP zLjM%nrVLGCI|aV1MbBj!cmm4h%K{Vv5%zmp+5Zv(TEecMm8O-k=&_P@4wb%(ph(lF zh{)J|x@{dM-74Se*tXkFB4J40%EDLKtOf971%g@E3tESt4qm@({<`V_xk@I7AM8oY zF#cKur79SFvW<~HkuUy4u_a$p~lTBev@q?BqjHp&k-tPl}-2VeJJ^xyqwFp$kYorpg2NUj$uwLO<3mkV&G%#zOf6OfczOkDHZCMx z`{l1r9|~f(HFHZH-r5a%frahQw&7$?3M+$0XJ6 zOvISZvdit@i)`GOStmca)$#o+FhUhw%Jl(f7r|%d!DN1~V}X2~l2xT%6!d(@LH%W!!?^+vuJ8^cNmcwti7@a@q ztfHynF}{9Bo# z`BsIONW57GIMj*kxBv$-u0{1_4nu@B!4m$ipBc4?^3uMlM7&vm{)~( z4D)e`FAl>;yXsFf30ckb+_5P;LdURy@=1Kvd4KNrQRR!-&rlUD zQ+oG1-e0S~^7neC(0=!WTnkN9xh;6OowQG-eqOL#@S9GRVW*~_gxV>SAb5vK%SdiL zkETD#tgW1=N#-QbLlVCoo35SzOZfjTNfdxt5psH~Zi3`xGnt1t!By<$>{pvzyRw_r zV$}Yv{>q}#oA0h;0LqUOKc7B1IROUr?2LUb59L&L#$H;tnFJ;F z$b7B1G^~kvfdqZ>>22zX)mQ89d2)`Q5z}NmzSLFV3R^X9c{GZ~zc1i3N?OpjWgEXP zdAu@|m?BoEPZ6`7^he>&kh;50PQTp&wEFRt7Lw%%%ASUTvwP$|R@j$;R$CC~#{x3{ zGWYK)-HkUC0^D>cy0Q}?Rv!tJ2tLz=7xJ_Jzrnn0p?^Lr-m3ZU=bL>HYk%MS*x4ba zkkBB^V%6N`<>`6BztjpJz&d`%eJOqqewg^w#x2foRQzp5*;d5GArF3izEH8!I%Lz) znZpZ0EPOj)G>=}<2Ckamo{5cG_n8i5v@)c4qoG2q^aWf|vF=y%M!@$!RU%bcjkXQo zxjMqtg|yTogNM?_y;hH!Dq^@ks@PI{Rqt+d&%>gpl>AesQK6#2r`&XH_%aKSK{}a@ z1vyqS&>PJPaZqFnao6)~_3=jLu~zM~S8gS$9)2j$YLs8Qy#}*T)j$@s^ub!I4$7Lw z62W}i*U%T|g2h8m%1Gn#5v=ar#lg~LZ;4HuPf2ph)#Ek3|LolTzawc-KH#PBde_q| z`j`fA#%2h|Zqkb@U|?Xhh_qf&i!g+jffxHZvff(17QlM@fuS%NP+5)i)t1Lwt%>hu z;_A7v0nt_;XlC90`)wR%&u?kE#85G83yic8i46B(J_}Ie$kYp;x?~e#PidIcr;bc2 zAEZcg4(!t0C-GMMpz>guKsPDcG3e>x3;Vec$h6K5O@La{^ z<=U@~!UH9Ux4yT*6VL=1Ysu!x`4uQWN%$`@{FfY_YYg>=%F~G~uT(b3epp^zl{92^ zs|U+TbJx)f?E*$SB_uHz!32QpBa$vlwKSH$N~3jj$WeBmx$9pMjd$*&)A0etz$Rw& z>}ypaSu)-j!APP5Q{#Z@LP2w8gTBb0ozL{k7&uD^RFL8TDhEX)(gjjsw{%t3=*9e| z%H6>w%r3VgxPo0y)j#n2@^!wO2hV_~%7qQ;LF&YL>i4>v2dY}Z5ZHx+dycnU_xivS z)9Aw*-}%^qZPNH0X&=!mob!|Z1Iu95mi7RS3NQcOdv|!O(|#MkIMRH2dD~{+s%$?* z2iu$hCzr8Gs0>0UJ%Iiz9=CZ7`STdy5ds4nsbx9TMwJq^SS`S`L_Y(pe;Jm3Z2&7s zUq`n72>%T(0(mL}0;yxOu_gL@BWx_8n9e|&d?AMH#Il25C^d|K!;`Tbe)lg}{zz)4 z-5atYJEC~Ui5Rne%DfhWo$c$Sm$yA5o2qM&3d;(*C-iKm*x%1MVFOhYs>sfQ0xm2&fvuLB;T z&^Z5+yIkCD&6TwO9^8YsFd69e-1s_rdZv#S&=RH$6}dD6T~|6fvpix(SI#_UT3fJw z*^8{o{e9Kaf$@_fC@u&x&=LD6La&O&l(~VK5t~SE4 zr9q_`_1AP;ahvIQt|F)dLYkOpdu(sd}IKBZOZr~ZfE&~3aOV+M-8wK z*&^HoPxFlvlDSzSH-DvgJ!iY?){9f^7kjPQe%Ec6+7EJt_bJ2Il2E zkd^JCDX*y3b2?Ta?Ms{J&b^thu)zsr29dqCW&05)P_p}+A2GL~=ER^JX-B7sQXW4= zrTv}vVE8(EHOL( z^5RLN29Z{>qNbNPX@D3F5m0jR-=m>IDXoWy(K}!FPRC=LocvGJVJ+P-hDny6^^{;p zqw2gJ_cVF(+Ua9w|C!kSe7nX4N6}OkEOVc-OXqNQ)_ZkeTqk&r33czMZ)!yO%-?Ji zMSNUn#^7c#OKBFz?&yWYutC0h#jj>BGg48q%@)N=%GgsF!p}KQ zR_iPCf^`a;rTF)DpVTX<;hMT^W+ghRjU~=Z9x_4VJn-{R0+CI+t1DhlFQk3!Kc&ZR zJ@ZrT|6q!-VO3bde)|{C_w3SDWq6e8@Tgk-mtNyjoB3!IF6FoK-^cs4gda@e#U z;B)Ar4`ULYi`6tosz^xaT_(BYhVeh&*g$gd7jD?A!4n_JF_xe@$7$P%AUEuzMJ_1q0 zWCI@h&hD6rdVfaCJ$DhkCC$*bqR@2>n4FHyJ4VJXwK_IeK~x7OnOq0qw+)*Y=2!yB zrrju?fB80$2=K{ViMQQMXsvK4!OB3~L<2OWt% zjR*P1AW$>?a+6q0US;-pzf9HA@XHBhM!!xz}A8e=~SLJOqkoyj=9q{j5L zoL*Zn3(_#!{&hFJC!FIzD4g0SU59g#S zCKl=>sl4RWZ@5+^5LsFrlk~Z%*ypt^zMv#Lng^H1k8`DqmoOzM7x9?G>SJSL)s`Bx zSyL_&2U(b{A+qS8+zn!2tRu7Y*U(NFOYo^eIpQ#@T20%9iKhsTdulnl{9^Rn(|+jQ zq10WxhtH`p$neQC!;J=xdx7sBhQvzMp5iw=E=-+!(YHH>u=kfbcB98wjfy6bb9LL7 zFmzaOE!%9hJ=&H|U`e!pRXLLEU98rhu6ACg#|r+HP(_FA9sx(=EH*P7Y);#alHR&E z*dO!IcaEX7M3fkn|z}S;*D1J$O`- zMZOnK7@)AU+<8q@857b7R{0MB0k?q&q1fS+p~y7ja5{Z%E9l=7X9HK2=L6$n$mJF_ z3JP*^z{d-aO~E_!Wu{70o1Jfjl{oZToKM>y?i!EgpTaXFi|R5&rgmzV0~5-KfEh4n zaw%5zZaC@6t=>wnf;LGeVP+vZnG)+houd&pCv)X)Gff>#Xv5L%F~Ilk$^tS7Oh! zZU$EF5>BE0UJOmOgrXKaRCY4k7!e~3YLJRj_YRc!n;y^4B*Rx+X3*7@Rl)ZnZTy6$;b-2x;6$>f0ZhcW)K*LZ*Vr*eRm}T3w+*k}T z0(b&*&=QHdANj&=UTiB1r$r@9mS>}W&Vy;jb$sq?-}LhZ{Ng-Y-_CO5VQv4y)DRI0 z3Nc0+!BqjFZ0T8}+WSE5E-@oPbpl>u;$WVnRvd>-aFuyaFS}ce-+Nho(JY%N_YUcqF-HI&xwI{(;CAe)F132v`c zthOAArK&4#s#0Nf2%P5*Sfz8ytf6Lcgrj@uN_MJ6O1|yK&8M$Srah;cC#Pt`xDzlt?T#AKHnY|1#1RfA6&$gpm;)&Vi?Pk`n_A2k?cpXUM4 z_JQ&3CwnX2oM{5tPIVRx);%t?9e?h2#)mfrjnQ3^5KS{f6+a44jT4AJVir|jJWZ)| zNI|)3EL7SRFUIQ?m2`}zATcO$f}o)8R4vxRgW3f(2RFPxaG^U(zNkMe^jEr!Rge1e z%K{(L9)7G_CFZS&N|zP?$7`O6^ne2=CP)uyMf)j<4BT+TsX7&VU{Y=hy2fOf)C6~H zK8-)L{b}%0POZ7IY5<4@r36aWmLg;l5%~p!ec*V zu!`#O@N7?z)6MsG(BUjVoZS_5^0U|sgG5ufgp1`xh^Xxy#`|b+08@ggsgq=Yd-HXr ziaIwA2Iv~je^#BRh{1NhI|m3IB^N@tb&uA$?tgf%Ebj%FAd*6f8BR*#W~rbIOOdw? z`r)U%DnG~BCD-3RJWLHvEqQ72I_h0pdiQl=i&|)Z+DEq?DRNX0XM9}2OiXFLAd%t8 zJa4+eIW4XGd9SDo-#@zV6Sbw*V@-`NN$vgsw@*B6RA&mf`&yZ}_Oo=O_=XU0Y`_u{ z|MAfetnYU-17;T{`f2R>XWJ!6Mh4>N2XX2SPxT!ABo+{@RC$Xh0PX9OMzB)WiXDQjwvUl@qNkMeU# zc4c>pdfWO>PiKiNcdR^pU3>i6(AxnluSb5TIcEac3B2y3&nS9|9m$s#h1r#jkkKT? zACA=v?>FJ6i<=pX8Yo&<1Qfx~JAM0Q>@XGgjI+~dK8s1b z!TO#||2WUj0FXV<?e ziPUAkc0MMYiaibY#Za%dD|Xa|V!S;M%G@4E4R>IE&|cB*M-{j~mUTXNCM?_k)jeD(2V+J+iA|1znHoZHYB%h{8JzkP{2-eBh zb2X{7!RHMa1F8ZlWHZ>-yoH_kI9jFV_^~$eeit$cY#r{aNE<ud+lq0_ zw-4#wdZkI8y~ZSBjWgRThmNIH-_c7$sER&g-`H9;nl739rPNwa2tIkxpA?|GCdwMBJ7srQ3 z0y0AxDiDbXRN%=xcR)h6$%d@aeWG$m8|m1J2#2rpAIXcND6ypDBigCLD`*nf*nvIv z!lYP{+Y4i*%CaE|_SjSF*E6r`G)`Ym|CjDOyXW;hU_abp32}>#g_kZn+JeP!Qf++sm@NmEV0GnQ-3eo3^01SfK7y>k<1I zveF8{%iN6T$SPFr_WB%>i>J?aJ%-Az9eY-` z(&jG6wY@(%HgrS!v0v8vY zgdELi)u~oG#q`(}MhT)NHfNq4S749=oM zoE$ri>Q%2;pw(Xpm96CUO2D6i2gTYYf*&V@{@mKeX^vsnfmM82#ZDt()=O?bC&+Fj*qjEzUvcO=82--vRIH%Spj=x6aPB< zMRUBLoVX+*A4ZJ;pXG~5+e~_PF6eF=$@|6w5G--R!F@vNLkMq>!${0O{i8);!ClG> z+xn1Bot8o+Am0%3n{!^SQ}LPW&5hv4RZLY@rRBZB)A4+Ep~~U%n+xF|d62@v8UU3h z*9c}=v^-6B4``b7ktlpL2ybb6(8^-aHxDFf6zg=&J&1YBGv5_%JJ+eEp?m9?!wb%A zl)^8w4umZY3?Oyj?v7QNUp){=QM|M|ChNtA_Aof_j@qlTnM<;@tov~L=2U46S=!u zdwM+Sd~s@uCgUh;Z@A}Z(p{TeMKRc6JOVuHH@AM*s)xT$b2`RvG&-|4Hl{>hCqB-? z;FGeD@h*Gyh`)6&`GEbfyR7xcPA|jDRR0=WZq@1KL4^jVTCa-9l0nnoG^PAbI^=8m zrRH&5Ci>+}-NBZ8!R1Tw!xeXjHw}|hBKDYt5%(s~Gc6PG`L+h~x~jP}Ncg$o@KDIn z50zZIHjChSiMhrvIq76P#12UYzaRr<{kr$*Uck~gb5gVa2=RV| zr_bLV>Zc*%p>c%)jYPidUfjMV{roWeof){LeV;e?o2tWFL+|9>XcaurG|uj!)cURX zonC#itP%g@72M=S-xq9Xv}ffVW|Wc!PBi|7Jjs&()F@BqF(Fk!+tTPX6dOGzjh))g zaw+1ym}{-`wG3g&i@~zRJ6k+khS6*FDc%P+t;p{Gj`6u(Ep~X6T&NuJUvL?enH-<8 zg~d*}silP^oFkbSoH{+=_LK~C)GMwk9*QftCeLIF9YVuj_yHES6yz0s^ii7*?woXt8Or8}}^3->f(x z!-lKg_@~L(S}}YkUIDbazh{mk@0ZhJcB^KkJUBC7WcE+UywAdu$yzu5$)Hx1nW7G@ zAEztll(Knm0O2`I(r_{$q)Qo2&i2Bn@HX`UHt7Jjl{@667j&5IC_x3^=P>y`JpOnwqe(m%C^A}bYS)4pDzn-6fUv6G~Zd=r=+o|iY0Ojo~tl~*mI?nfB5o4j( zv0M}~$ROmcN#>z(Jp^&&7Le~%265G9MEd0N^WMXD*H5dehOn8(yG{-2HBY|# z-LmZ)&4i8%BIm9F(KojpEKn_4s2;7~RfbNDs^+Jm^+&73SqkBL-VBitE$xF11sz$? z&#ZMGXr+&z%|+y1~N_B=ax@c9UFGdlDxmQ2x78Y8$5Haq%ip2ioV za~{hx&}rY{?tn`D7v(uNR!#?BgaCn;)7`O{%c3gDdF_J}rGtO%4ruCLsXYh#} z>H0wc4w~lmuHtD-zCb`Y=W(&}+&;O}+3k9Q9PaL~bYgX9+SAmV$vNBT*R#XBK9URL zwW<@cbrCR21`nZv$J5UBIUWQjy|d;Aey{q|qtno_ME!_4sYtZK-Frd7WJS?#$gKan8dVm`|Z~|Jc5>m7 z7X3|Cmn-BU;(?*}vDrK>T2&O0#K>HdZHt=mGV}b1AI^1$PXNL3iSI4*Fe8}+1MWr{ z!Q$>orv(3^kq$J+r%UfZt&qwolzSr@w-drtXiiCY*hYoaia;v!o7=G&shTg=jaPo` zRq3Ji3I%Q3=woX^3(ZbmZ_p3#ia?baEtS!Mf<+=L_5&h1l9FfNmHv|=N0<9_)Tijq zrv(g`+Z#Bb|9*8DfXi(;3Hhj-eXm0-NfD!y+)>=GCUn{#Lct*&KkxVZar_a;q4v^p zGKTw?i)3SEk;CI;sj7ND^Hw{PC=y6X`6QwLd>ZT2@_nJF#crq`KCAKjFeObA4v4NF zpJ@w&w9ExpQvE`8$s}QYmU=pg7SyXQ*eTw~BYJ-$(!lhPEKmlqP9@_N4~0na{H4s? zanqD#iz8>*+|kD}j}lrHSg+E1*>AFfd$;|D0huK&1T2zlC;PZMceI*SG z{J1m*na32^wd*WQqJnO#lx{H2AX&2dyh=W}u~x-56o;CvqkVU&?}pN7)UcO3LYknv z`mNN2GZL)O^^DrHu|qTVL5S9XlpSd%vL`QFMvYTGKaeQcoA56$&ajyw;1 zG;ANhwT(o9c}3_95=SaXOvKuVUeR8F!qCxF)w)#h5lQ&IG$;E$LtM(*q_@YWpgF>5 zv5$!kAom=yYEykX2jOh;YaVc1zA%YYb~mFpw5r&#h7V>H zSQgp(%u(dX5p#(vWT5y7)XB^yl^UOHClQsa9TPFRg@q|xpM#gSyb1PpMkXN;2?t7$(6Acvs&03m0)jZC z+#1%NyEp=-I*)3aVn>imH%)3p={FQYCsaP`EkS;Sy=*Vu?Lp?ek0zNNHOcrR?uLgY zFuC1=;7Qkdpqa5U`^C1}Ou}`7Dw0s+o_ZX5f1Z|X-@9<$_{EZ$ENOAwh0rDalyC`! z&9z)5LPt7v_g{f~u4khx!~~n&*k&$Yti26#MWk;9<7Ke-h|HzD;2L=Ln*!rvMY~S* zY{M*`VSFK)W#Pdl$47g?9&Tj0KF)9G4L=`D%wAnl#$={htx z+VG(tx~yM5?GLXi{a1e(yR<&gGO(>{o4b7}M>yqkWWS+x;_2wLt8+}Zyw?}mI2?F; z#fdyIQaUGvsew*yc`Awk$7p#UZv&7(j6rjRygACF+jehcUnm|bFz$1NHFL%|ze_nb zKrH{xJoVcCQI9tzq}<)Nb=@(62TFY6?>g32+E^y^1o z(oGe^w=$G7+>i5Bcscnc484m@J|({Ao@8!aVee)aNX5?N`uOs)1`A4yKW8#ur`XyD zXGX>#r~L#GG-TF2f5|-r1vI&5k>I7D6wde!#BQu07>f`anhn`+6%g;%d*006Uw$H^ z`D0WzkU6hZc#Or_f*Y$`X?Dgi_TzM zRr)g)kXW1^mbgn7eg4(xccxt!G;~dN#-(INE_yy}T**&z9)h2IYU0RPk`pAP;z4KI zPUT>)$C#_DSdx|dqTF__o0M%*V#2DKtb){T;{j(NmQGs^`Dabu-L_LT)y3EG9~&wj zr%^*dmNBo@WE)WN3GfLmZN2LlO%bzF&IsnlNmsqo1{w@z>Eyn0Gyxq%n>(@3wdDOy zNuqd=?H*Yt%Pq!llZR(S>^?18=Ws;s!4tf*C>B^YweEVw*0X30N)f^g$7bjLni!I* z3nu~7(vW2EcH$C%W)G-gcAoR}Sep7puS8BI{kVcNOvCz_9XniZbG6LEV)#pGkCSwy z5>sSI8B;(AG1rcLf12^L8ZzAsaZ$Cb530>*kbl4h(OOstL-TV%3o^4raFx{Pe!X8o zm*|kQfT4_(WIjcn-1}+Zjdd#n6ZuyZDe}1VCrqxr^Lr;rFRUFxLdQRvn@{vce+|c_ z_X)TeCkylV38S6gV2AVe^f(yF5dnls;5=q{TSw~n$;5D<|B%R_{w{=T^aqw_9LOKb z*Ab_hNTOA@&wEe04phw|87F7gUFA!Al&xbl0v@2b_<%n+@Uh0>E8}6=$Z`#qMtBz$ zycdNa2v&A%SB9`fGr)Sob%gpF@u6{jQg5LC1ly5CcP&m%MPNbw3N-NY)D)6fP86#x z#rp1>y=Qw3jXUmqBx)QzYKcXeLrrg+`tw|C0}JW*_cij-6JY5FUD{^7U+O!{qkJif zW}uj$x^z?NpF=FVP|3H;7ohFa-r4l^)ay&3cQny*b)Gz!#$y+7m>n=;J7R1B&az(Z zL2~lSNvpW@Ch5)ga9svse};HFe0`b9-I6DLDVx~mT^Y4l`$-7RuZB^ByrB8cw_);RqB6T%$(;-zp=sXk=N zu|Gv%WJlK7n7b5l#Yz?iCj6o>u^^-G%YT1?Gjs{|kiwIM{bbPmzb*0$4rQTwMsLvH?i49D6{4i1!3&WI?LCR%@pE=B&H_|J;hdA?euvww; zR<};_0ejKi^Y1+!QSEpKFZ(ApKD;D@6HcV>mPBxrzEc9{XIUsoWe#_m}1Sogdt zyt)t>LQK()KJ_)t|LXG5gkMjuL89q*~=$t3@}Lf+l!A(h1J z3(hn#c8)L`jCsr5RfF}q&jYYFp8ne1tS(`GL^llYJuj;;48aKg7R&us94sKG`)HTI zQSMn;kSk)Krp1~T;=InM5OOy8Kv4K7osw`_Lt!3f6X&~7pxRYB`D+tK#{bk}cbu%o z`uHE?+b(qFc^~3??9=zDqq6AWEPi#4+giKBgvDPgfuWvDM{b7VQql{=E<2s{$AOcR zb9rfQ4G3SYQoB7C;Ivwl0R8Z`-OGUk-;$;YIxzVsm(RV6{4u+02H&R5rqh@A;b|-q1nIjfw*9C`6D@jh;KFz$?jT*AisG6 z>O6BcIn?8=zSqb56UI$_L6L_dvYC#vpv4BK?=!Z(M&)g$dB7zE8zgAb^HyBl8G~=K zqdK@(e81e_l9ZJOxvtnInXaEUiLxDlwmGnLdOC{ zDs&77KY}7cmUNb?9OjNkImZzm%RQwc@Z1QV+xe@T2TNUyu0Fwx*ePkxK8=a8?1T{f zk~`B`c-^1FcHp8f^EJqucZGMWNQUal4KM!85AIb*>Zy$EGuF1`@vC&b{PacfEIKuz z#n^t_B!fe4xi;?Q5cy@>tsI)pr~%#bnjLv{2*6+Z1fTUWgmi_e^R9QXS`XL9@ta9? zt%;iD{7TSTTghyiG(}BOCI;G>bgC3;e^|rg{UF=J@TrnES2xg0`~r1FUlu^yvYy6N zhw{Xt?DCfME_Ir`O{?)aq(`C;BN_Lt}-Jy`- zJWgIaI9y0;QlIN|CUxF68d19Ow*@lRd{o=?b(p{Ep@fgTB!;V)bzgH zw9U7kN(jHYoMm`J!H)lRIdyD>Ks7j5@UfV3*P=YAs<;H+lBneT?Q6(oYkyc+(L*tk z_S>$a^SWVeetk}~6UoCA%@f;`TV|FRwin=!(i6u!Yqq8gD zfnuCLICSJzU(VwCBUf41>Q# z;=t=!N6E{ENT=VDf@aL2CG>=hn2_aO;VoM7!vkVbl5s14f7IbqN%ia5!wBSv2!FA| zt8??LLF+`>4T=M47j&7rqilaxs@8dYIbsoX35W-DbU+15uKR@P5VA6NBNalMt6@R8yo^Tcq1D4)b52e$ua_75 zdyq(wGxr4F<)2YSpTPS491iwS`dzw!-@4rzo?EAEOFN{?eSHY4JR0HBrYd(WR zr4+l1O}-c)qW)NwA*tU=mBSGM^4`b-ywW~i4Q+mudlXMYT$8N&n5C>;dywcG44_$Z z|NG&jUXxCO*AInjH$L{KH?e&GUsH}gX-HJqL|kv*-|t$qlEs<2(atM6AN|l|rL16H z?_QS4gt7!1qU8Yt^?Fk^CJ$>A2-ir5Gg?NOrXL{PomB9>#!)iamNS>zU=w%o?6;~4)L8CLd5_2Z)`j*LuGijA2OO}tue)+QPf==BSuYG| zKgBUhgME2OMKBW=$HM&N_nwoc)%Wr(Q+`^4A8K<^a!Y9LsZzO^uPWBTrAEGeoBS9$ z{Pu_Y@Vw#&h6+WkAFG32tsISLixmAeU@qrp9^0dTDTY<_L)VW)*^t^2Xd8 zi#QL&l0i?1+BSRY!X}iNn1I;l-jr;C+hYJ7+3{L~M{~~lKBbRG7RUU7T|;0~P8@7^31{MN*$R%6iIEZ>UQX2YkLMwi(!^;f(e%Gn z4LlBz7Fa137pzh@_vs{Xx4G4v5CEb^?0eXgA}`Dw?ENqK>fbHX=04b7r=L9S-1g0l zIiy;;k>OE95e`jtwuee{eX2BFFKGHR_GSPQTaqeGl$u+!ruyj?bBP_8gTdG#qq~yb zA6LJw;y@~ozgr2-_`n?I9CI%B7C@oXtPc-B0!z*M>(eSU%cK8TrGi8gt&a`S~shLmX6b;tYh14ktE*Ou|^XIU` z@S1S?H7RM_&kC@sz%P_e9Hc}ihAnT&x0YhLJ2#fxpnXFjUQ(i4agsWGU#fU??j-5z z1A(n|N{#=^p#cw0%JKPmWr=~glnfhHz7}^j>I_%<9WlKYZj{?ujQ^M67RBy#1_Uh2 zG4xgA_pLF|mvY2fXU8h!HdGW6tnYNdm!1vLV~2F)u(?kE3$GbPcQX5&!(q(sUxx<~ z7xjnPsNB8;t-|Lb+T#ji5T##JCUJnJ7Fn0NYFa;9O)I&gp2ye@6%@trk?B(&7<%BZ z9CLGQ0ecL-cNtl$V(D_eINGtEtEDHDe$yI)lU1ZHettbmk>5o&Qh?{ys-X-$S81qP zyIh0jjw}hN`c56N@%{_b8#$DyOg=lOBSvqRlVwr$coRiGf5FMP75Z=&34$e zjo*QszBkg@P|TX6s7ft(yXDSvfeu!m*J*4%!6aC%VyrnZ-EN>?9u!|V%a6uS`%>~* zdX{Fr2PWr*MB8C4?B2wxr=;R~&^`0YK()iu?v4BYhZ@t3?VG>m9Hxkzxo)YWw6A7f zmM+58Vm{o8k`^j?Scw^NDA6F;IJ4HQft;d|R@y)lqO`Nhw+u}7awA(}?C96%Ts*jz z;kXi`ZJppw6@{{JL4J;Z2qbrouYSsU9NJHt_mkTNnAiVgR;ut#2^=c1=Y>9*76gFh za^2Z~!}Pa&K4T9xHR=bnw`QVuk;Ub3MUyGrCc~8_a)p_ANyU)|b2%VI>6Osh(DGY7 zBHu2yKFJwGZfZgKj7ryRzH1~LQ@48dmV>$&oZspPBu6yu5!ov_?4UIIYLPtVZ>cA_ z2S|?+x;7l=foKb6l;epd>6yQ!HHeyEu=g4vadzq>6YpSur|RH=H79X!XxI{jk$+#c zp;TCk*D$R9OnE0*wA-yB*)IH}{h!9`HUR1M>P1JDv@b4+^q)k| zKPr~}l0}zFOXP=&)-`Yzcu5SL4wAF=I8?zX{GjqF)Hpf!SorcMPkIGcPEf2b1M|YK zcMY8Z%yA^jH7D`+}wvew!8>_4TSrw4-X z-bUwYS)|wN&xj_X7!sCO8{kJ?>7ea3-PkXg-B4iaszQ*?C|f)x%jC;x%zb2WDJb_^ zVRO!|M?cYdF0GmG<)|#xjeZt#Fw1SNd)6Fl9s3C~_|67is5^b*qCx{wfm9td62`&HU!?ANWr~S@Bpcyr zz*J2b><@X$lxIf&$LInUZzUVHHl*02HB_Tagz7&RW!*H}*Ho~1wkD)MU@U}~F+d>Vf$(i zIfopO7!k=#G;IDo&i=xKccWT2k$XaXp!LRPA`s4GO0 zy#}SODyQQHKzRf*n!P#;!O1mPK0?0=F_rV2lb@ta_#(gt6me2?Ja}fcxg1=!eBVrK zr1a^XksuWjh^3gET*e$Isfg15pR!#WN#t5xgwQIsO2OmWgq+(K@~;U({)ENcyD&eY zOX(oaK>t+^`%`%BRM?*h%W|w!X88tKR*R20LNwpg8Z9KXdzqh|y;q?nc*?&&26L`) z9qDP2v9~rhBMY~{6Z8;A(~;#sodqA2fKzsXJ&ze(bL`%yUYSD zOPO$vB(*|Rs$Fa=7&L1>d8ORlJ525;yqW$2@vOIx8$oL3^UPoOibvqJuI`*&5lWP# z^eTS*ROLA=Ahlzp_#oB5lRe7uFWlkZ%F_6y?UADb+wOH&hr1?N{l*NKa%y#(fXjdU z0sYD?ubo>`AJ41gZe2{LLLf)`0BIgk7;ZFv%`q6|rZ7Mq1gp;+8>JqvuvG-x+6m~% z`JYyt8d)k;yXK5kxdpaq&Sdk-M^wfW>+y=-a#TKL>cOHJ&RFOxnsbbh?#;g5Rl%C; z{ocE^E3>vlUVI+1qOzeqQ+E~^@zp$`wja&4=6T}CSP1dzE)@ZhXGc^s-j+5M8q-i+ zvj-cb6cYy>WT30aP>4z_95VjvMgFsB2@XOx>cwzY3p{}b-2rUxZnr^pY~}K%3DXG! zBs+NIZBfq6rypb`SJ%TvQ#h$vozgi5p7oQ9jE`#@dN8i!FEZc z@%paOHpNS02HG~j(Zp2GQC*zV^zV%tb^o(xURsKQC}s`4r*ukmtqI8Tge5z(YNQ6v zKh>=ATbWP6jFG1JUn_E(-5L#Rh0Sl5-r6r?@4itQvreRg6!V%qJ4%$w#T9r(sd0H9 zo8+k2QXJydR+PVxmS@nN+Jj| zUp&;7t;S}1jq%H>cYN3EMERF=fn8>GH%cyHSRQO1(IwfsP%v7?V!6(n&@a6Ku0#19Kr7E#V~CkWu$ zqDri+Qg_6X9H&--4{w#z28bNq^3$h&J(4>tohq`($=U!aOWTL6?Gc0Sl&ZtcBWsdnTrv5QaYC@O+RY5d^{Ko68np)G#M%sVJV7k0<;=Y+e3MHX(DP2~Hu zJgv_LL3b>4bap*dVS!VsQQCQ95~GqxH~4;iE}pnCHLi;Rwy>6~Yg9#_i~=j_7DnXy z*No+iWrH#uQv5R?o0En6SDGQ2&gFl7q<`XD3QKGL_LOl$vk!mLCXE3kGdig-6CYT` zE@7~^zaM$4pNogPbg%x(so=KxINkcWV(AQz4rn-opw|eAbwaB>rjjMq>lN!O5-gl zJuPM3&00%TpVdg`{^S+}5Tn$owDTyu)||g9T$&bKLiA54IvUcQg;c2; zAu;}U8ylPN)}!83Rt??V-BhxlQsjW=VAlbN!t%Sd;9Ka>CjcGRJ)kKlB&ZXSujcO# ztntxrTLUpdmd^8DSD!_2%B20) z=LBAZH<#18y-InvGT$5HvO;o)g|V9&z-$I!NEH$NKs?9N5e`4e%GWWWEtq-kQ)gG` z56b)T{6aNP8XVn;;ADk!-ewlhYQb)u>(E0%ga^vHUGU<%Bwm|EO+s_Qh3otJ1<{ol zJ^{gO<9J=L@VoGjCFT%Ch(=12AN>6GAV(c$@t_EfiIF%46TC@290NPD-v?M&q+fG; zW9C%Bzf{%!a}OxKRV)WP$l)2%!^xI{0|0xV zpMSnoyQhQ2WhEI%s5Ml|91@6fXwiZiGyG^;NPbgI(%sN>3V?Ce>pUUkrU`W&+3E#9AKb-uWBTZYv#2i{RltmkCENB+I3}2wOJF0KZG*Lb7YFMjoTbWDx!j%=;e3h5O8#6cbJ+eX9ZkZI3DI6%?$GOpP!>y-wVnUmvHyM)9!y7xJ#0E&E>7UBoEweW|7?r0Y+sGpT%> z%);?ss@pj~MqHeNwwmuE701x2wAbu$#p5P(Lqp+I1ROmJ1};|!kZ(Bkpn{1o zf_2Hc5q2-8UW0?H`p+%tKNKgd({I5F4kR{6%RTk|8?(8Y% zvb*dsmRmItVpZyPDl(f;uLi$P$}hpIK%&NnA5xr!fWu=Bgf~hy>aD2DeOEyHXmxFs zjU`{okSISiE@Bsvxn#CrJQ=~EEG;FI)p8cQd@A$W)?%~iPvYz9xSp@AV*9nHc$+w;TW_l}7127EkKI0& z5wuGC;=qm4)0(QyF3F z*!Ax1&DB;E+)Ar|RAaW;%Wn!tPYJyi{*$JrX?4_9-{#4B(F5K<)0?yCy?(THJ#$MS z4WFSs(nc#gYjivUEtAcE=jK}>UDL)!dQEW;%)wdE1&90PjyL{`OQS<1mhI?+V~>dp z{$+@HqJ2CO_qEz*(@%{SmlDdTVdEIsh6-c}EJFxT)$^NUsDy7ce?;<}-7GkM+`k}^Ni2-GrJ}60LQczbsrs6i^ zs~m5@EY)`_LW>2qdY9#L_tbY$mG~z%=8l2wi$m-JwDIQXFw5U6%GXS$Gym=j^VM1` zj$deusjBwfs6~PV9zAvoO){xR4maMti8@hE8d@&r*u=7CT^G}HJ92iRT>*QrzdF>Q z#J+;@T-7Q*5m?iVjP@ZBg1>on%^**xD-TNOJT3bBVg|s7`BR0c71ZeyUM4FuPLw~q z!kfvwdxJ?u9fm3M{|&_F0qN!nnZl+Q+j)82H;s)))ouI{`46=cKfHZ+4rf>^zcCjH z9!4?%d=?hQDxBMKLuH6}{d^sr!TBPS7O>3p5 z-FOA=8{*EN;Ef=y{hQ@St(cogaJ-wogO*QOhJaT=0M{XXXixB7G;V)<>t@x}e zYQG?i+^ZV%VBvD~9ky*Z^d=Bsk~eT>lN)79VsPzIrOO^pfZ)aCwn~)0*iowlGV-03M0UUnfki+)|3i6B3En;1-@&v=R#FUL~(_2wDfq`{kL34@x58H zQ!}LEa^QW9xzZ@jMWG8>Mz{-k)lg^{A_b)e<-&saC}0DDc-H<77{7go_X&sSpN4GE!y?vL-2f^AC!YJw{yGga!E@|!-Z_M(b!8^pIlC)|6?#_ zi_YnM#j4=ZM|O+}7gVyM2n`?>Dhq*}JbtQe@@c^|is(5jT~>X8+$iNUY}{|7_M-X( zoRpODMeR_-Rb~1^>lCmXSr_IfdNC^N+7`R3SstuxtsLgL{;Ic`eAUTjc&sum11r_X z6bJA!wDWLiMlw7Y^!=p|FzvjVj+99GyzTy3jUpfFj2^@jR2MOW;6Q}yTn29FBBtvq z8y0fEill$JWjaI_7rhAwlGE1fLEmI`XjM@u=d}uWh^RAVk^Bpj;JcEh)Eq^SVJRr> ztQah>4JcP(uP22RU8LsGUO7kP)#K}i7I!PLR{4DX$}aR@D=%Bo_`}O$Uwd{OSU)^} z=}E9WaADg_g2_pX@>+u;eUlaR(cqCErwDkca8cUkv4ZogTkoMi{-Iv~hT^Z4Dt-58 zWUf(sIv98Cmgu7y3<7FenS0>Z1QfUL`k^g%Dsr^}BwTh(-J{_qDZDK*g%$Gr0`HN+ z^0%}$xqzpSG?j$B)luoG69K z>B%;tVGZaFjI=hK$IuneUz5+*MRI7ZY79UDcb{ zhI$rzGV+z@eT?KC#zUoAcBrV_)2kHOLna@KUN|!}Qdfxe*J{BAv(&DB&RNJFWxu-S z`7~1e3}a4#ovvgfU=oe!`hKGXbH9xCrqC5dXZ=&^>n-uc&2*zq?!qY|<&#-D653FL zsK3^kG96;@DMtDzx+=9&txRkqI>GpbvhrJUZXJ_buakd@M&uTZRz>o9TmjAE8I zJ<&t>yeKizxQ}z&_7OkU{i3dEr`387{n~6a*AWFV!*IOf5b8DPJEWhtq1pR9W#)8rs+p=K<~=6OSfbxHbR(8V3C@sYWjz? zFnjC+xmRV6wT%}XM>d@JBu8kTuvtA1B9MmtpJjh2CMujNGPMM^e<8v5AxuUhzXS9# zxvs|k`te;yokpF{luPrcFh0N%6i88k39qg87KDE`{T*iP??&xk+0-?1ZcAqJbjngc zAfdlP^MqwAjv0J&$NRwu^J_XNEO?v^v7WZeDIwEuX#a-NB(?Xqx)bFTd*d!3FM5+E z>v>dBo-OT2=HMB5j_0p#A!=Cf?f%}+#(W|Kk>lJxgD9vSO#gv;Q=3IW>cAE5FFHyZ z0kxgwkX^t+$=0yZhBWW-vWEj)H5T(h?7E-wSm|38Ie%HnK$&F0I5rqqO@C!+mf+I$ zd{lJIY$_$N3Gpe3x6=oBv3#%_8=tL>$p4-qCn1MwKpGn^cLug+ez6d46dyLjq zeZDO`5K=)*1}staFn}crf&5*0Dm*q-oRM8&*(`mCqU8p^VL}5;04L8y7-?Z%L*sr+ z-bBQLSfyEseu>t4t7zWi^z`>Sn{)=Z1uqM zDzIG?D=>A53rP_K%C3>rca8X@M+{D-2xD34-K3Q8nJi!SLr(ZPVQU&{+fU5Ql&zoN zY%hGowcsHj%vkN^-om30*r;_FLxvXt+u@S7@9@#RWPZ$rOjdiyA z*~+7V+c>%!u^a*YR#nan=lP)p*TXF;4Zl>`Ul{SxPGQg5dvfo0;kNlo#UsYOTV>K; z!J>erz(BOPDl&W`yw)1d=ElDk&(RCPAG#(0vb29 zQH2vdsf!}=iT(QLmf){f$mV9)eLf9bodY5Vz77Oz{s z6KnfJ^;%nA*W zMlZ*uz+Zh(9|1n=zhyrqDv{vv8UQEl>FMZDRg;(ZRqd6r3{OEJn(8|=08XgAeK(zR z#@|Lp=#6=VagD~hVj0@M5D*<@Zeo3iI(#%pH4t4Zg(lmpnx399g(w)TU42dSGY3bq{9<9AYA7>pm@q5L3?vn>8$0H{4 zC8EDd9CqY)JJ0xGLJ24QHVhcEr5!XaYtBC^c&p6iL)Afra`62LT4G>`-BqFct{LXo;*5j&t*wzH)bL^oP#! ztWJd*w))?Wx(37FYNfLYI8%p=$%pM2A_8vxNlM;lt)GMu4FLGW>)8xcY(9Rjr0b|1L*XE-NwMWNRUNgL*9fVv9+Nw%cj`W)bYYRCiJUQSzQbKz7*%`$D* zV}MJW#cMkafMq|y-2C^I^Dhd#^-7EJUe3xvgi zHwMFIC2xy?t%njA8TlzGkx+wVF31g>YDx^+UVYZ`uS)yn_+yP%d$$hU*XwCx##B{H z)>=DncleyPCAmc=QCA*XUu`oR`ntSCc$xB!x`Acs4hx4v${j%iOu9xM1y;`WmQ5kN z0JqDdXMRNjiu8z^BH8vohCGM@5R(qO_(1#((G(+s)P{A z-(e|yO%3)>T3skafr&$*{T=y|bH#N~XYjM3bYf9Q(AmV^zIkdI;30j{R^3pBOdvn+ zEY{oHjsp)a6v=8$?f#$&{}AI1?+vkoVn&?Q7kWf&hq{O0KlwfxMaGX3Qx>VIYWCHxouX2 zd_S9u8ygM>I{?hs?QfBLqgmVsYaRCmx4K@F3z2&;?lxPEOHj`{jLxfc0HiSY@;LB_ zL2PSfwc36=LA}S^a*z?}8%PgK>X3Lpy8khT|G`$bBGIzYf$z{{{IjFGfFzBt&mqV3 zkI(8^*OuU%gRNjczRk4npKn6rtE5}F*L(aq<7BTp<sEz-=#Y;OiTA649qKJiilKyrn`Sf+<19$Lx9K#;$`OobWP@q8XT&!O)b@ z^20L;6DrZB{#`plv}wI~%i%ngq~ahWi5)KZQ4k093TKLbCXNG@KEy`Nr-V84wb?5b z`R#A|>Qr=W6N5kBV;#ZGff+yN;aN%k&dPZemsS`kS~?E(aXHgL42_ygR4^h<~7 zS+%GzEyBz8prVjZib#jwEr^-a<#!g(*XsWtNoN_=R@1fN0HHvk#i6*nySux)7A;=f zAyB+fT!K3U*H9cvaV-u(OM&9W9lkv8mw(Aw>#Q|7v-ixoXYc#+Jr$bSSQT}-5WI#~ zxKS#lXyv)2#vucyd!JTS>Rq48rb9lf8a%==^)C+=y_bEs9BY2N_qSplR<|z$z1No% z4$h2lX8Ggm(_9-v{E2Yr$`3eZwWGHEeqX|y2Sm73^SU9HxE}OOS7lo^w0XTX2k-t5 zy)c1S)s(iq!ii}wDLi)5S=>b-pc`+z*qfV0Hj!J_Y>ASJwqDH`IK=pN*TcQcm?eFD zpv=QWMMuG8T*XO%QMvb_RSnKr8y`Gx3;o9#_VRB@0^Ra?kL>mO86|@)TLexB6OcIj z@8pqOp4RXB=hlbm#g{PQ@LX4B&mrsP@7S?YdnMS(Lcbb?He!NkntyF_Q!`K`J1n*7 zNB~1!6Pw*%OG;cubFV(hG;?sPk(K0?$e|qneIomY%{>yo6cVb55_)$HtbaL)VIXci zFCjxd>t6{BhkGBVgtnX&J?te4V!__8-G-es=a~7eh-P&3eet&wNSr$r5|DFUs9AKY zDJYv*>LasyUtMtdaBkNF2pPY&Xnx>u*;COAeNObLRX84vv7dl`!1~AEhh@BM<3-(H zYSi_zewatAXt?3x{E$sxAF`cIYFAm(jjna*Qz}6Ck z9be${AvY}T^Vx6)-28tWytww&t1C>;U-UZc8TStgxpv-S|Ma}P1owgSn+Nq|t5Iej z%O>is!Tlm@!0CHFX@lU}{JEUjxvf`(k(;%v71cU+p2D-vS*LFw85y(saio)Mu%zfa zbU4te*EG&MILzKjm6MHTj3W8U1GXlL^`QX{Y_eqUTo7?p1H%ua^va_PiSZ!}2 z>nS;>-u!uWj}F}~;lL4v^W$;lIEN|JZoQnOPVnq{Qe?_RxVNzg)pBn>)}AB3+2?uj zG9$kc4!G1<^T=~_91UObv{NT1XRmI%H)U5!@g`|YAc0*!jUVAKr5e|o*43ExmgUdd z&lrv@IidmV%%PuL*$D@p_^RkIwz_Q5PlNAPHeUMohN7->psyG95{3}nwv#m;+u7xP zgYFE@rK(nsvZjxhQ~%GfV!li4rRfo8RuLKt8*bkHV0tRp>D!Vp91?i^p47#3Sp4kQ zjYR%N4_!jAc zY)ifyqGCyKGXcHni6aTIn@kiMi>8{JI`MSv2a5*mUM+Cg5+={gBd85CZXz`p zP4bsh^+Gi$%Zz*NsYA-~+54c;pKzUIJOAg5Zq8AqM^r-jdmnd%fnINVjq>~XoSy>c znZunH)Dq+X`|M_#5PgF>|KO7&695Lrdw52fI$hgX)2*ciFfXkBt@G&&o~XQeXX3*U zJ29vwBO~P!UsBl)k;;~RLtV9y62!jI{U|95CwOP1412dG9y$jOsFNR`=5e_o@(jB> zXEJb3BKEDQ?1|m|M2BxmmcpJF}2kSZV88K(tQdg)E!xn_3cb`CQ zXxd}jMO4?8?p2RCa1;Kb(>u#3V;PYW5SZ=wwdg!i%(2s(-1t!;Zsb|6S)xD~?bibE z4Q=zksZ;XJISpi*aBkBd;EV1OrmCU zh0re%dO6~$tuQO^y#1jFArw#cC%1{AIz>!!GX2KN$&1PDB&oao**0e&)riIfZ-{Za z@c40xcI#9r#rf0Vc~^3kTlI>BSeQ>#S=!BG;uY!Y`c-cged_&y0JYu?x%!F(Bvi|J z$CcHa0JFD1vCVuF4`X_oba(Di^$~0yDY1*-Mun~7S4rxJf>xwrRi=1|obiQ?&ks6J2fzY9>^AW5!(vJO3V$<*38oab2V@6i1MT^?v zUakKFKEH1${!W2C0+gsP;T_(?Dg^2wuY8$lWBAveO+ruNK`1}roA>t=2WGr(c6*79 ziSQm*r`1w;5+pk~u;}>A50a*`sM4Jl^4#Fd%r$}NF0d6RO;c>v0K1U4Xo<+l>lPfh z;nIs4DBuAo2?-+V;VqV|BL5m$w5cdTL9qj0Sn&Vk8wIJGY^u{^ZQX7{{YA+HjmNJk z$R3Z~mVf+u5Ty>D#A0}@#E=-x&FeyUZ|SiWB*%t(O`77_K5PkaQ%6>B<{y{`PbaQZ zc74q%%B1mVR?VR?PkuOGJrOi7&Hr%-@w2lI{`vEjYK4vFX;fgk`jE=R*RWQ(=5^Y6 zPNQ2^{^;XW{5&)*pr)?ioq8TAXJg`o!IF>oTv~79E~@MC%>{V)V16Rr%l(;$dt8Bpq`_s7TyHHG9HGhb&7t6(Oj4CieX&0dEAM2wcDP)| zZ=zOMia8?W`Y&GXoWtD>BOoq#Fp?G8Ad5F*U_Ak$bno@U@lq+`_K&~p`>-tLv z_pN&Kf;>N9aoqiZWOCXR*Xr8>2TAU)vlFW9lTy~A?R7^%)8B5f7|?4Gu>Epjs+HW?~+SJ@Omy9e6hz7K@P7H#^(n{g{g zs*VFNAAB0>-HJ&}s0*^jO_!t9k%Z13ILB&^<;&N}qE_X83_a~7Xz!T=Ri4F<#LtKs zqe40h7&1FXtr;HrR-4Av7|A&Y7pm<5bdSY!dZNba>$GImoz*MDqmqTy`RfwXgfRMS@%+tUlC zFF0+ozXXNnLD6m4=UEGPwayE?6Egv)dDEW*KO=<)&QC@I;u+eD|NbOI;c|5|ra77j}grEj_4y?!6G_lzj;nAic~GIq6+|w#qt#al#-~> zOI1wGvASgTcT$733Qn){QtxtyrK_U3DeOezNO+j)o83DUTi@8fBy-m<|ETq*%>nb2 zeVC+}#F}PT@aZ$=ns1W{rJVqdw_c~qQ+{sJ<{vWrC7Z8uf(4}@Vt-t+td&lPTvDgW=ZcR8{oX| zffyb_ZWq(C%!!jA6_@&M!R~7A5Q$_-9H{_Y5sxkbGc(>2jO_QVH}5Nk>@5!Cx7YTN z`Zhy7{TGeHS1_&uL`(R4|(e)O4K2sU1#pgd9aHQ6p@L zwRM}5VM}-Is4cB~P15Ns7`GcXT)~P%THasQz!DUGl zHYEKZZy!TZdS(q{_zA@}&i|*XcEu#`eC<4==6sE(KUux(^=q>GLuOkFVGh)esN(mZ zgRF%Ii`rta5*W>2?&o;x6@?;`<`}6IeG<7X@zG1mf>bbJ1XfZ=G55sEjp^gkrhaTD zbs2vWm;yWu%37ZhM_c2}^k<&okIl{dW_y%9~z?Nu@W)h0OW^QR&Y7yEwiWh_ofb90;_ac4PA0edMiXc|A6xzPdi@E zt2x$ZVC(nuH)3U0A-l84fF$?!<3m*8=!3*LQ!z7%FC3$yb%sla?V&RU=c_7Bb1kWV>0 z9L23Qi#a(E{E5$VYI`?ab8Q?V^{uJG+`?JAmp=N{8z00Onl-pf8H$-@azLIh(Av*D z*3{Z6uA`F^*Z{bbHEa!S-jsVTHL5XZGhV3q=2E=vr+5xp!!Ss!_^-30bHKZUB_6$S z|1TzI-+OfSMp(}*a~f^St**=g83qShaR#tGjRUHYft`D)?$pDB*&uQy@EFQV=9YzqQQIp!_dg~hWgHadu@?_%Tb%&hS)od!7UEwngVCnOr6D9^wZLY1NQoeueOrtzZ_kz4=5Y)rc4{6QS?@}L zy78E1?q_^t#gWY3QT~yxostYEC!~;m-O*5wLN@ZnSRimpgx+QgXf{ZqHH#L2%U3iJ zMP*D-^sJ7oA;va3Z}8L_t8M8wc0d2Ykjx24hRQSyXKJ;sIhx2R4pA zoda1w9Rl}{gRN!-cr1cu%Pl&1)n#m=}5ch!))siBGrcr9w$ zOLNL=v-n?{HnFczy*9UwI1j*HpkK%Y9&PIuID(E=5oTR+*CscF8NPoU%KfwMG%V@^ z#31rr7$HWDUjS%^1MMmJ^-+M7v?+@PGwb?0XHtsNC)J<1DEY?y!O7?YzP;DiXCY2w zr90c(+wMg&Jn=OaPJ7My+YZ?&?u!Z$8B%2&dtP5tH&p$EUPO4z7WIDTxEd9Y9G?-{ zo2BE_)O&yR7$GVwF&W#TKw(fI(qhQFbHUi#6Qs%PHwDF?Rh6YHdH zOP7+?%W&raGM=~VU3K2dQZ}+>SF?Vaq!QJPMeo(!=%EMTx{k5u1Tb%1ceyZ>=U0T1 z_IdU^R~RAyt!(dByJ;8zP_4A)*;IhAPaBp`sL)4hGN%U%OhycQ5gmRGsq`I{(L7%N zQ%=FVu}_;$7SR%ja1_dkLz3KGWT`0b6uFK$-jz1S9{uT^ZTOW_<;@BW8$#7eO91n- zndgn#O^0nho!#-jnvI=%1fe5c4vx3AT)0J&^H1xvypDCmB3BFqfcdwMk$B0AP@$K& z!%!j<=z#d&%*B7w)Q>?GlbLld<(l%GVF_yVq1H6cM3M_%2MP$ka%XWi9YdqR5L}e8{k}Dn&m71@$~Y~eC|ZlK3n-f5Q#sO`&KLV zqEK9M)lR8H>c;1-tYiw>Cla+Bjk9qSAZnyy${%D?71?&vyRpRP5X;+t|GqPBl~>dj zNITK1<65dPPk5Qy&Hi;_OSV(hD?k>wR!=ESFFIf*+LtkTXXc827Vfos55d?jIM_)~ z?=jIogAtb#&$62o>wY4Elt0=9;w(&q9c}y^=-Z&N_laOf??r0u?nz<&wjahC&JQj3 zmh{wlklqvF5d578CYBTz?0PC9vphrE(5xcv+OoD2B`bnxRSY?067-2k8~e_T447W+ zu|`Rc$o{W+(bOxrJJQWtRu+5Q5$DO8N34leBQcj|?j*Fl;eoAo=~zXaWOGBey~d}P zBAhz4KQ;=!b7sRfp9S#wGCA$e-k^?CUL9+lNh(|qnz z=8CMOly&haNeyTUVX3<~?AJ0-u@m?7E#Tv}j{Z%9{BKHK~Kj zh%E`rMwlRy1&oFVQqv`xXLRpiLJ)nR2krA_RQmPS6O=-c^TEFqu^IzN7Xv3 zm4~CL=C6l(LPcategV%L+>a3<(Iz>L!{+1%5ld;N*Y}wb6?|=SWK@7#J+0eWA6a3fkVyVUnV&ED7qQ6@pS{dx zQ)Pf>z*yLb_Fh0B#45e50OJcMss=c=o;e>Et4D(3WbA(0a{|Nhi==&Q zTQ;GYNVg{iLTY=3&Uq3Iyv=x-johWqShBIBi+gUKBQ**Q22ugq3q58;0$UkZwbx*@ zCOutyyae1c41qo5GP(8n;Qbdw(f1beCmMVOOo#gSZ5uD5MoxTp5LK6xU1jD^Iox>_ z>6tB?qRH3Bx@xogSIz|QAW(xWe+ZXn<9+3G?JG4)1sKXW>sD2Weo8vts z#q-tEkrk<*Bdbf~bTQHpn{2i@6_Icf{&DW|{GrlbJ-jx*oW}CXpGB0KxEb-&?MF## z5uWL+DrRa$k-$Zd_Xq&mPnpw{%4M)rE_})q-f_!x)KUyeRb(Es`dS+B5=zXKN#9?w z34oPDm-yLvP|>96X`6YAye#E>xpRPj$3?Cb)?%MM!8leJC+~Zy*jpa)*r6{Y^pYY~ zLQ14AW$_J@_cWyzD3k8k$$iKLp7lr++ci2JxY+z%@92aatatso1tVacCjf5kw z0>ZlvJep}c zAHaP?n@>QeKw;nP$Imy%^v>J<(j-SH5Tm9D@T-zjbwVK{r#tTIsz4D(smUnSDCU&d z?v!G`-^Q^-9$~o6Wgo^Q2v;Y@)oShVM|lud5IqZjS_=yVV(#uAIkf6?@Iy|cu%0?OLlA1Fo{}=*yUQz((o?_lH83D=Cv$j zKu~u(T}7NdO&ISRDcy1FH9A@S2RjM8k&|1Z zNOiKff)JBqxi8+0jt>Uy(fH}#NiFVlA#fE;n^ag~CZfN43LJJQBC)wQ5GC81 zOs<}Y%(bsN^9q>Xqp(zcMLQ`UY$hF^=#AfLWkUxM&FJMv;d~F-5=V8YT6uF&Ut5NH z;}`Wn-SHjzvuUzP#GHupl6gq=%1uYPe00Xc#H91r^XPZ1`XXER+DwukU^;&L=j3~r z*csN$7Ws?uwcSMwfBM>ac!drWVwgkYN)<`9eBrt=L*PngY4+OQxJjPPzEgK4{<0o4QcQ*-vv4 zuwO=jXojW;KvBAXbNgdd%T;+#c+)?geNVI7o9LJ~ra~Q&SF)*(O2dKwcP;EQNde2o zC$Zq+<%tC`(J91BQ71=KS!v)6*J5H@@Q9AFc=Iw&=eRU+y-xI+c*2^n%-$`8891?K4(#{I==R|5Ct2SqAL{uq;(NgG*WPe2CR9B zq034~N@`KbseTOFManv($4QP8R0aZmV0g|4aG2ZuRfn(GAFzN@2EINyGVrHM@pJ!* zt>_0(8VgnYtgV0>LU@Z)_|3OWe8rTNPCM*xDV3bC8cTn3frjiv&7~E_oG_t{Nxv8J zc=)wJ9tjOd3o>yA{uTA54R@%~+eZq>{MIinr!l()O6Qq`+q+mbmAjmrp{fTfAGOK1 z%el$5Ga0LvcBjSr^eC^OqtKbLHgPGNO;Mbm?2(ssUBoW}x|W&;SA6fzhsS$aAib7l zhcYd2m0?x97%I$Mm=hq||JSl)rzDj@ouByH*|*|xLwA{bV^OD$qJ`eh@Nk0Y0zFLQ z%}GS|^nC0jUM*3jkdS}CXLHg)1VekR&CfLmP4e zvXnsrfUq*GV43d$rOEFm(Qr>xT=H455NHt*jRgSOm-~_$M%P2C9r<;*v6>3U^xku5 zs3W9W9C^FSj{4P3ELh%_pMy;_qlaYEVkf!;ll(ow?LYf3{S!Trs07Ec-|k|+lkdWp zE*#BxVIV z@SgZvNO?4uHo@Dy`EQDj-y5t8%h#YGHbN6r}eNIIRz?(CAH{YU4_r|iOI1-$LS@}p`<<<-S z2olMm7I->4Mp;Qg&1o0g3%X+xf#rlrP5JrLOF7NmO?tEGqD@>4*lw(b?Hzy{?|i&M%g5|5 zl?W?N#WgS>6{<@{V#W7C)=k-KekVtJm>f)*act>teE01m0_E*b)+cs0gbAUcp9KQ> zja*JB_|9c(mWa`#qUDlAyzPYJXk49}aJ3rB-?86=+eQraoKI9y%GA%7q8m|j$aATe zS9xqUhvx@?PkIZq5@Kop%+>hf)T;a`gbj!)*2y{|VM zk14oeX?Pxyq->E;Gv`B<6V|%Z%xkWhT3b?amqVX|fRI)q)VZ1QmRTWKIZ>iWDrUB` zjy`$_(uBK-0KRVjhC$Ou#jqIE$QhONm^B^hSdb)e-tqcBmFC}fzog_GN->v7T21yD zDavx@gt?v8uzc=S7iX$#ngMviV>kb){r>9q_1x|CqI%0goJY%vw}kuQuQb!Axd=Ef zu(bqtfs;Y`K9^7apH}iR0R~aZ*?re=J4m(Cz};XmkctwQ2}Sjm9i74 zv5`S|43o-}!nZ3>u)bhmxLEn^J>WCFg0(g(k6V6YNLCs8)Kli~X(-`rh9%T|Ob~9_ ztyy)wwjPh`+!C5xTl^ADq~~0k=CfzG<17XEHeF(*RnN2Zu908IlZ#2^+rq!HH0M|n zYUPx%C@?^YQmAuuZ2%hWA7OmCizW)uj7_s%4Z`eczA)B5uF63d#~FKM9OIFgE6Svr=Pb=fD88 zz8txcruPk|4txU|i7Ml1ml94s4gIqyW3CbCMr(2=TPppf>O0?n?gp+SnM-#IXPmbNIRSZwXP+DP;c7-FlArp{*C+ z2~uKF!%B65S*0u4WPm0|w(xged&LHfq3+G?G=>?D1ZH2{8SNk1J5y-Xc3koyG{=}u zB3l)|a;?fS6rWW|kWIZ;yG?Ae5$H+F-4&)aE$xM*NK$B0KL*N>-nO7L91;1wTm3Hn zoxhC<1eTvTap(Wik_h4}_#Thu;*|AE%F!2*wv8S~iTNnWOsFuI-pl#TV@)Ae2&6O` zeza2!Xl7AyI539}wV%Ud+3z|mCQSxs3IBviW~Vh~Z?tRpGSXbpg(zER^|~N#C>tOE z5N562Ib^QAp=|qg%!$vpus7mRR2@;&^HlA+bLnmZkN|(fS^7KSWNd&8$yR|$G9uZn)yTAK@3VtXGVwH+vkTURjrCCi9y-J%T!wLa^omk z-hYJ;G>dHL3(3Ns2MCpx4~34p8Be{V@PHZ2`Lx(5;S}l{vC%PG{e{0Wd)L*Nh~aam zp0Lm4N^@C#o$mY%Vs#x$&X?NM;47UoKq%cT`MwW!PsnJD!)S)Rrv^~Q+65LCGuYN} z*m8gay{_24h!X3x{9tpc^z}Ew8ftNz0gmrjha|M4d{buwLtai`q2alZy{&e#CdZ8q z7H|T(IFp0}G$>0SPvHFj z!ff@U-O;B-_GzrS4V0lvzn;&f@3A$%*Y0G=tGVKO%Q^gtOp)pLHr=-xj;Vd)aGoKu z(#)VYehiL0*T;bx6k6ZO5A9=iU1Dp>1CRQxgnDsruC>a@Sa`rE7-qEAg=^?iwc!dd zjq+0fRpOLFxq>dyNC3gwe01(O!rZnC-2V}(t^cz#HrE2k5aI3ALnu&^-=;{E`BEA?cB5jGq zk>G6c2+yK6Txj8|?$cRbbl2NGjL|R|F8s8RCf&sqc>QTf0#R*yrXDMf;ZWfm!e$)0MA1?X1{p?M5>9JDC+c~t6=J7X=K^vh623_ zVj=D*G+%~ev+Z&O;Cwl$3t4j90xlCemLeIa28fbU@*^uEDy44XUGms~H361Z$uGr} zpP}<&?2`Y`DxVD;E^-gQP(9hTXU9{s-YcyBw{*NeB6WTl^CLRiZOKM2rAfKtQzmal zLMTmhVEcggN=Hp{v;IVZ_?D)|5z3TSB9MhoVMA2uv%s)}V!3NytJGEpZ8l%tGv31V z%vR4nOhu_ig-&1_{!&S`6nfG=*}{csn0Gy; z|J*^^OBce#2eT*rOVW-&H%xUdk4RF?OGvyONP=!a$rvyuh_h6$_7bzU^nI5YzcC0B zv3uiVCcGljZz5TDIr>1GlOtR6H94M-{8V-ql|_-~J%DR#3|LFK+y z07Vh;D7HQvWmytLuaTO_Dl?Pr1Z8si5!3|98#rcAh(L~ zPWP2BCzAv_r=|kUx-fU&goL02E{GIQw~d{}qnGdpJ7gl#*wBOnltCcLZa5YR;UEbh z>ys*sh*SE6a|M(`q!LPDjzv{M#>dcU5tXqvx^^ze4k-4<1o$lKwk5=DTj4@0scX%O zR4JjJJadfH@OdIQ2My&~Us|n`f@(z=`wGzgmH%Es2*W$u0##ABr~NA<74!cHk2bYd7dG0Tb6x|1)aj%gLYa2sHhNm9g`7kORM4FOlRh zk&QXijqZ<1EHxHmKi5_KbmzlxZ5c}Krtqr>=Wkv{eiZqCa`wa{0cSaD^Glm4cD&-7 z*-b}3bTvabNd{G@OJr225CAwSm0jynD4WPBl4E+g5k77Pt0Kt>tvcO*$LGlXr=Fbo8S!3;yK%esTT0`Ma1?+t- z*&J0%@~C?n4siyp>2>HWdNQ^&F|>|Q4FJgTj5-A%s}1cy`Ty<2)@Su=hiL&T>@yc- zrZVVN4rW$9?thoMs*1WZCV@tUad@?Q>J^4|vaLk?b+`XUB#k{->52g*Bdy9#Mh+EXpD8Ps%w>WWrQ9{IEow^_>v^1icLW%J+H{XLDV=5n>pa*tZ z5grj7N@yD8ylzN1Cf%B)x;Ha_{g=5hxr^#f(u4<}P>(^JDp z3svrjke!ukOaC|z^r9;pnXzSM7tZsk6=FY~C|GF1hXGrb{uP?T2pd}7*{0UVoNHek z#iX~5sf-=pXr+`X!AY`8;gW27>HsV%1Zk|?s_+!7t71Rvh6aokm$2bVR^KXn(k1yE zq=*?_s-xfe{^)a2Z(;w^{?^({ALgFV2#BeSH1oE8eCU|6U^y!{=@$f+CZKkp*A$c* z2^Ot|&li93$+;vd0f4+2W_M9jYjv%$4ui2vv%3#KpeneMwa!WxK&P3QvO|MAX?M}} zFp%`~t z`Q{HRDTqz5n68WB%;7;aM1mNpK@bvE%LS_oF%XcZOv=!DGf*?F?r@ZJUt^+|rKL#d zxX~a22t!N|cuR$ir3g$)i9sx@VbD$iDoZL$$)+*3o8dDhq>3Oi2SAEQAG21dki5sB zvad1_JSJoHIvH&=)V@;{H(F0Z=00K`(+b1pXsXK^OdSc;1J@`U#h&WK-#cre$?akd zSjb~R)?c={_{44B(^jv4>1L?YsR`?2t!OZ#m14RKHxi9Ei9pXwTukowi4M5i!biZ4 zSJ%y+ltslevK}{@iT^XQd==hLPL;nI`QN!#y}r%QdyKaI!(f#$gF>JGe%hzVD00FI zlcXVBmAngg`260y_*RewuOu1hC>Q-+n7u4^$`1L{(1ez!xMkN&K&VLX8Lyn0mN7SG zt4GINbPQ??EpxPginMY{3S%rC+AaV9kWAT?{`MDfdF$w=QNjG!x$u}`@ng43u~+`_ zZ>+4)@WXm%LT;tpulsd`{q4Mh`~LGjK!YZqAKPm}yMNr^G7o0Wohy$GQ|-DGMT?$` z(Ztz9--hBk0U`hoQL=LZeb*kLa7zFB{aiM=_BE!NPW*|J+s6$&S)u>1pe#&e3SbbbNp%XPwRE!z+irZNa8F+&VRR(FmV2l3NVIXXn7%lrH%*G8ou}nltL~QNKntw@9pivWzoMJox(Q7Q^X|fU_H;cIhZvno>Yoi z7(Ax7&#S(GK`gcEsIo2Ec}5=9QQ9P!iLZg?N2l)Ypcaj`7031Cp{XCMMRA|3TYbCb zgqczNrLh)~NmPkH3v*#5%VL!xjq9XGjWpd)kmBS~!Tw2VOE}L0eX@dlY(!xhd#(^=HP?qIKhG zyF2muN@U~n=+MHn00s5^U!Kl~&Q5(+vwv2HJ5=F?&DBRiOFss7awP%GYu)L8`SJ`l z{In4zQ)s0?qkfY*|C_>qG#`d5S~F!-L^56#OUH|PeL*D2F`dl;YyF0IFs|UND{?nP zfTRMu!9G?yrcl|BUnaBwL_nEp3Xtgyc!X9SvdpUFBu%O8b8WGpnwcX&5R-I9otiZOkIansJ)ACX0Wx$K%9(^L7Cb4_O8^f;t>-ggY(*`Ic343Cg;$TIi zkzN1ajj@oPd{!luQoNXtA1*i}09b>u@1Pd73*%+QVtiae&tC$`S2{l0LUjM0oIaIo zzC_ZVObCk$t;5#AOqKfenQtSZ9}P;=3ZC}e_)Gpuyo^Oh@4o}|we^HL5^&D36{S;Mz@h8P92mZgO-#lkN+0fc|^y>0nbANe&+O}S&*)S6y>nAxIOGSsHxl~m7?EL6V!I=nP3D;>AlLl&eB#mUb>+gbxnQi^Duc1f z*ruu~0kw`YUx{A8%l*!1PW$w+Twgw2p+z8U{xoz|kSboZ-jfQAznJA53kD({Dew0 zKtkpH=QO;IK#3w?da^o$-I2E>^kwz7qf_{_>$Wkm=d5--69Xj-D+~b23w$5) zIEAti2vbubn1VIGjx6>1YSb=LhQrG+|LrYV_}?u@EV_2~NZ2e$p-&IDLqp|pgFiYJR7wOp z_EHk!I#`@Hy)8HUcY2>$B&;!eb~28N1;s-gZBE7pfmgL>>zyw98+K_|w?3g;d2Xb{ z$0(rspvP1HFVBhM-8)w!@WP;{kw;fEUeP=FI0JwG8G-1OXyNo;W*nj4KMC}Pp6N`V zs+Y17yrp=7m=LgKJ!-FiH$WH(Z4+a+Yhiz!5SFxt>gOjzuzduJ9 z0a*Ps8mESx9=|f)RWr8=14KJLUp)gcdma}_ByQ&1%vvl@1$uqmT8%NkY!xy1#D7zJ zo}GT4&&K~sU(O6lnvQ(LB!8`_htnS81~1<{%F7S;zN`oG*BCz}->wG?r04W{E`7li zYwYf=e_S2szq!5cI`0_I;K;Uc;w@O|Ir|CG0xSn^EuTnqy*{w;hh7D@O_3ZwUi3b} zlK@iRe$V;9H|+zNvz}deCq%&C*ptndekAvlfBK@GPOstHLg;YHasO@C^^g2!zEm6O8{|n(4Eu+eb$+XvTcWz9y$C>dWVGokqt++2`D{rO{iO> z@B-D!tqWxZxfEJ!NB&BFwREORHL33KN#UQ^bd71+bNsTBzlIu@9;VvjW|%6x^0L^J z3m`=Mv-7&eL>mbNzsDEOR=AhoHpcEz0PAwb$!WGFIx;5mm5(G>?@!FnPaJC}eE3a* zNnD(T|4hKXq0m>+TgCl@ZE$T|+q|72Cs-Vpob%b?EHFYyT#YvOM?+9LLHNzj-+-_4 zm4jB%7WtFI1`BHI51r>;562|uVz6jI%tQ>QS*@FSue5UeS)Uf*Lr&IO*>jXahK)BootzQHF|{5bGLv+=zY02OF&>p_M{@}TF+yjK~L~f zj_Y^0j8e}=aDgcbH3Rl9WV+-EEQby*dVk%U?tX2w7gipG=WVYvmH+N}bb<)_=E0ST zj!>vO;bz`uHE?LllEMD{{IIq)z_UtQ#K-E-A8_Ms&q;Z@3Zb)c+wY7eGc9&}o;(UU zrFKnc7c;5?4V?SFlIgXZA*ZE!bExCWkHoIm+PS#}xUUT|=(O$|3$(_p(W^hM485*B zXyA`-nZ6tA2lC^C(<}*I`(ID((p#(eUqi2V&eoe;cO~7Hek02;D99%k2wx()u2kbo zVc~t1b4$4W9s^?6Z$>Iezn~$~5AFZp@t??JZe9qrRzAXZUTUGHW<;tWQp;mW-M-0R z2%JC!D*hHcnVM3SI%L9OTu}E+lUxJICsd^>*(&Q-G-?f2qABH$$c{Gs5$y7$&`8gG z;IN_4;HBjm*oJ){s~`%HH*jk&pA1`!1=@ZqzD{-+=7XGA6s5MR6^dBCAS}}TdVp0d zs-*4>*br{Z_~@5b*TManDNT^;z5Sb+83tN^Eq+nhLb4^3^CBIGt@-(+6;uo;EbMDzs?Y$o$X>xqpw+Hw@q!pD z4F&43G5>fzs6Rc~KWg}=3;El>H{a$f+(`n>0I7Y*%7P*&_ST>>(N@WyO@k0=9>M1*e4P=6>Q@pmh) zFF5QE#sKvP@=F(JR7U80q|}p%?x*}EiFP4;%j2-sEz&x{X7BO^)i+)T7&dO|3Xp%Q z){q*T8?z0_h(6Kskm6S^hK<8)tH5IFi(J|_E-d~ zPf-7sm?+zj5!nZd3JZ#q6Ov685f#+=XjpCmkHMTQ%adW&TvwtB%&7~j)saUEyh%j2 zx~J^``P6$kSykYQ{NQuX@rCv)=$Y$^As+1yzEPeifVisaR{ZOphBqw6hR;JcG2-;lx<4Nu5|rP@e-m%6pq($RSyP`=Fxp+tWUp`z}!q2E) z4(nfk-%%-=t+Q0Ne?&j^G~8_qOrQ5S-o^!Wyg|A9!+MH`gWtY=twf{7W@n>+l(N=d zu@E{LB(*%FB_OL1)u=Cb@<+m83 zYzgbpxl2wFDqK}98!Ttm(<1)u(W>WNnN@?!^+k_-+(7}4r)#P@a;4PP2(d?@2l4LR9QM5_eevx)vlF)i`PzL&a zI$n9Y_I%d8RTC=*xACj&P|I_K%mTs4AHNyh?L39I<#^|?yhWdzS4wd0egYPq>!uV@ zP`Jox=&*db$@;zY*-^PWZ^L0$42d2wqGj9qMM`?zGsTj+d^!c8|Uly-oQ>^#+5@V%C+P{$Vv7EMKqVmON`-|5nNsHR$g9)UVlm zQwr3A*;+rB`@?`r;U0b6pyas#!+7X=x^B&~$94!;=qC^LAr+t^Bl{QwR}@qLM~da= zMR_%dUT|{2ccr)%$mD5Y%1r-|R3?J_t6LTPE0kXqE%-R7b+KiY;Vkrz75+LS)(W`; z!CtjuMPAiG%WqHZ_@ziBriy)Mp;t#mjC5+74_0#TI3rX&m;>c>(2g(cI2AIfI2eVX zH4WoSsA&`YT4n}rWxv@dU|Y#rRR;Nu3F+W42_k|>iJ~Ps?m(acmSZ>g&Vo3)0j%nY zARsGdDN{SIF8%297LpwW80W3o9$u+hm8l zFP%&2oIm#vZn?mKRI`lB-VhZdfi9`t?))rf$?=r^Cl=`9ez|-4s<9h8G}{RfG1a;N zjEZK~sEg_Tc}I_{zujAQJcJkehe~v~pN?LqvD@7bh8s98RtR~oYEPeY6~64)HfD9c z5UxBnaJ&qUx*G{m%vPu((=e@O%avu|;rwWKJmsx%n>gE6n{{xEtLLsio$goW$$<3& z3S)dWH#|^2buC54ibmNCjP~sd3Fj?Av`1}OV4we6+J4)cRhLvkXBF$M?)|mL7-<4F zo1-Rb7Ay7Ujr73ury*%~XLB2|G;X`AEue~~(PV3Jn28RTTd&}KtaT;y7}R|1v0AxD z*LoSfFhJ3CypCVbV$Zm{Qfhn7c|2awcto>uwQRTej`ba#_x{M6q;EQdLOP76)qQNk z|B|ItgU9OkD`v*aV7;f)>#$^gV8-%!Okn!M`E!JL-5+YH==PVRxtDvhHLX^+3FZXL zy~F*d5NY@OHsSO-B!G93#QSub@lNMw*Nj>MUHa>-R?}w4+Cvs{aJ0z%9V4eO1^Ha5 za+Trs#&t>jC}A=Q9Jm8OfS0~CH%pr;o0az|bZb>7DQk_p!2eM7+0mx>0z2bjWA$0M zyxHawkT$No?o9ztlaL~NwPEE4Of$frdDWrQUwb?H?e*h1jNFf|t+MM5Q@FUkAcFis zg5c~1q>+G9$)ST-a|Hv197-`t1_=o-$mch0a?#h)k{8{jp$W|so2UhgKf=YghLs}~ zv(@OBOcu9B?zlMPx93wy>c5CleIAVP zyv}%ds(%FzlP6>Bl+)=*vZ7gcVdFm9ShskR{^U)qi=J+AG@y)TEmyhOr(4x5W97LE zXKlM2VSUs9G=t@X6u^W{?db=~vsZs(f1 z{o=S89gpLs{pCzaJns*+^(7Dq(s>z>TV{6iVigO=*mixtQXBb>viW!y zik~kU=K=2}_ zPX)Jx=!p=~*cJCDVK*Dm)c8@FPraP0c>_*LdrS`S-q*V0mI&ryKGq&GS!bm&?Uyq2 zN}r~DG!ip6-ej^>V4U6)3uSTR|2|%*);0e9+I8xD+0y9&Ql@9RLukLR^uOFKl7HRl zIz2i*fb%k=T~Z$21<=-Wb@>I5LdPNgE+~P;@Z8>AWAro*7i)1Fc+$EXUwV9O0%=bz zzHfi{=lP}c5svq9wVtb0Z!8^F__EP?PNUK}vjP`nqjLw;(hnV*OHECKY1@;1Ja;U$ zG*8m)n_=;&6)J6abDrzZC;@L^w1)!XPy!Hc@wsM~rc^$E;Cb9W)t{Y&?cme8ZGZK@ zPmKWUiKjqv1KTBhFF^20`!cZ`U}+mvScm z-DDu+_^j4D!m7S1ORIP|0!6GV)mo6H<-R^jFE5`|vub(+d4u2r^Y`1ShrFjWHt35c z(`miuy;1xZyfDBxZ6?{bQ!*Wq#)pf?B)eZ2+KGOv3vOM$yL?f2J8i?LSjo3-e? zmF|5f&%Y+e&}ME1_Dvm)a6rHX;yIV-dV@LecOmVOY~Z%_`K{-T=ga=NQM!C=dQiI4 zL!*63yebbmh|sBTM3|O0Pf>)s{#K@J!fCFoqS|Qf>7nEU8ai5vyYqjy`%G%+8?$$W~|Uw2-}C%`yuV63aR& z@ZM;C+r7e!^*cl-?<*;YAz-eI+r;;X5MfEvS*mb8L@WVUZIW!ruXAs{*40mlGL+mi zXOi&~5*gbp1&O!K+i7FBCF!l*J6(vhwd8fQTSfY@q7`)fLhfT(0dSeOc@&!>Iopnx z*@jm?#a9+E=!Jju*!^|Vy=M#TW$Kzk82NBj3SB}Lv|YPHMv_OiB1*Eq1{L)?&oXJN znYhG+lJSGcjK9I=HY3VLhKCh234uf+t0doQ^1}(F5@ghw4aEq?U#!n7*f(h^NhZiy zVY<*#zDIH3bO9;1x>Y{5nnyjm59H4zn5(8WIvgV>G*sub*d)o@vy`gjdmwrP&4I-x zogX`kqv4Z4*)Q;{*{C4SEsp(m0d7SDANT@#!UMeS*REr1saXJAK8^$B7p&_2tA$=BX858B}ZzM+|9EBZUyV3;5#|LcIE?4OY=?&LmbA{F(O>&2)$2--#;Wm}Z{nAoDM)QmXC33l(A3|d< z`YU5@x$e3HW`>%V;Im155C~lj&jBs=Gk1cy`t$halBMfrt*L=)p5j>dB{eomNx~sr zfB(Fomb8))RV~#(0Q1lcy7$L7oxTy=C3Wga< zW2k6X8cinGl5-mQPbdMqXiv=%?YALock3C?Gracp@pKC{4?r^o#TVxr2&fXdtSI+U zues6?$3=veHcoliN1j~-@7`#XAv)8B$PH%na61r!G6+Bz5mX`M+C7Gju6&V~F5l_pDWj7X4z zpOhmfNCpwwEEG9jv)V+T*zC9}L@ijzOl%24_BUqQbeYO?))aV#52uHOd>DRQ)j;=r zJrT}k6Y}PLeOVgJV^N#^$IMJcanj$Zb4wR)kD^MTO}1Oo>R; z@SX(K*R=L!f9zG9HlbwZ0BDLo{cW~F@?{ZPhYgG%8I`j7(MNDjoHjV0$(oW$t9gW-x5_l$3#d8#~Kfb%uNxI8{38914uqIOD)T|YZqW(XrZ#C?R9c-8IM zU{0&e=~fR_=SZlS(58OU-tM zjG^b@W-Ak+^V8RbwXB@&s^;oVAo{@u-3h-wo)+Zi)97h68NUEi0Nb;Eq}pOUP`bP2 zD^Oxp^`J;hX`417hfelc>&^k+x|VJ zGsjo<`8C(0t^VLCBifY)!1RI5=BAxG><_* zUWuSOzf|RUo!jAem5N2tr80K4N0>VCARjsGMvc2r!?c@{%47bV0b7@hA}`F#W2@~G z%PH-A{1*N}-cGhvGhYGRTY^#j6}dZG?d5$e+AEBU(*Bn*J5(adjL3u`*lHS036(OE z)1DyRukDuG(LE>uK)?fDT$4^`*MPHn%eo7j%=+fdG%M$om0Mox-PVZkP^r{UIL(^~ zw`pw5rU_rjfF-`P9FuHD`MBBWPLX>(VpG$4!^SUL7l@La~Z2Zl>Z{BSs0d@UMt zH`|W(KO8wI2s2BtT5Ej;I{Jh-X>9I>PeZRZMtBssbLGF;V{!px{$~4Nw0$gt>YfL- zYo#-$gK4HclkiHKRqH5gPwP3-$9VV|Tuxiv1?^{aV@JzMpH07WOGQtS=ut?eu{+po z?FCPfX3T*%i@->uF=T6ASx^`LGcZZjYUPU(F?(>9#6OriM4x#04(P;41Hc|uv$g1I_>O2@Y(`(oI z*S~FinXy;Zr>-GntHd2{JOwD;%tGoCN-Uk_un zz9b+6A0JD(#!Dr7TbBn7P&aJ`Pw8p3K9`@aT_TE_P9GA!jxWt6icrFlj;;?~EU)@* zZpIKk6sa9WmEkI_0Y~tNM%Y|h?<(avR`L1=;bn)g{ieY4T6$$AO)n=V!BX>a{dUz9 z({pd7e!6}9=~CK5XLZ@T5Xasz{xw7E;Vkt<6NCuqf-VE)+`v?hC^7EH&cn!A)kDYWB-IeBPxdJaeL3GJQPX+gA)@WIu42{tmK&W5hfFkQ;;EzA zvTj}VpP1*}Uhr39q|R338P6a}%q-{L=X@?M)s7p=3D>EZ>u#Z1$L#FL)KKv6zF{X5 z@zCo(XGho^C2dA-eYF~Vk4J!wgNa`k4$qB+{fnYW>x%!Zhz=e?WRSiKua(4)8jZ%J zDv=Z#INg(af?E5^3bL;UOKr@CYMk_|r}}THxB45+@;DbJF)cK1M_3$MoLl^p@EF9e zEV7bPH4G4^9qbpK)~DTP@VZ__lana~GZX%bBT+(&S6eW}s-z^Qn5OZ*A;$#$m zujF!6r1RT5{xW)eh*g0MFg>?>UEAm%Y7wYqK0L2ZY58!Tt48f_nr&M^zdLZu}t)SIBF-$03} z#axfeFTi-&&Y>aGE`q!D@+zrv-PzY7Z44ccr_J?M2X48>qo;+P_T5WTC4jlTN&ptS z%FM)&`qomtnJdOO)@Et+85mC3IXbA< z8CcLxbNWIi%3A}eYhAE99kn6KFrCClDV4SqPWi zmLFm_Fc;gcJh=d%F>2m(O9F2u#Gh}0!5>nXA3cao=#T7|MXXyWex&H&LDSlI#MeD) zv%dS^> zagvSPT$3`fFM6-X;;%m&2oGc6z;FkAM&3{`pN@NfnOcxdgs@<=SCAi^p8z?37TmKt ze-@3WQx~$6lLRTk1a3eH^|w&XkKq%oRoDS{Yy?gM$VidPb&uP;UrKr-NH?!MJm7;2Wo6#`)NP4}0u2k_exvlGxQ^ zFJFvv!QMv5$UTadHOns^ED)JLE0j7{D#@)MWSq!l!f@czSb`uY78xs2^wizR_IC2Y zSG3B>zd3VxLvPfjAP$^#$Qtz%Rt2A%PE?7fjD>r#Q7fO)sTYVD0bcv`is*WpH z14nv~^QP7tRsGikFSjHa&(DkP*B#hCSGd@PguoK-0vVHt5k>CEbBJ7qvP*ZZQlF-D1O`w+a(TqU7WIN zr-s62K{&pp`yv*6Q*KBuy{n`>nk?euKra_{oN6#(=WEjmy~rk>SYJRwlWxd+W|L2D;c zT0<&vq;}O#+nT9lTT|p*q^!Ja(5o&v-OnAc_Hj_(6tDyE7^<}uS0S>g|nR$OfHRRUvJ7! zHIJ*c_TDX_v;Ml!Ab8C1)u-0Vp~O%MHcRu{3wv%pmx;7XDBufy*xoR zQD!-V*JY-)$#Hus$mp}g{?)MMpCBG-6BPcfzD8_5!?(VT<6&s}boJI35|{U6{g;Ew zJZTZCCRwwTP_W2$Q`TqQIYJPGy-!>(10y#>;Qw+!q@+kEt{oJ|ok6z`Wmi{&KLE7t z+I-?~Y)Eb`jTHI3BHFh`RBY_? z;UBcKWMG4f$v$Xsf{;a-$EvvN50-R?Y?&!tuWOi=R;agcoXINxe`1G&W}q%JlMZuA7^EydK@HqJZ1OKL+(j zbw6`%6(M38+)oqJ&Zys#hh236?ldA473lF&$iRC2P=yg3#LXDiGSy-rapPnVQOPc; z7LN-K;4vC)&>d}jumJ4pn3qy(h`A{8n{eRVm|IJ@4a*>g>%2{cAK)@?^yE(q@=nx; zSeZBn*`O?rQyi9J~j53nVi7jM5)F3_^rm$E<)lHi;G|`t`s=d*jaLfuK^Yx! zvco&bL7^@Ahm0`siuld~U!BY!b2H}>;7w(33*_(meTnKEHqe=iuJ7)qRix5DooP4IO9x3KB@Xuh}6+c9;zn%7Z1wDoj)3<}wUWZI#H? zT|$B5Cj}JsJtL%=$ftj7ed60T1G0bO?@$`;Xgd z6#QZ-NFDL1O?8r_bd@sL9>y-6a!%LI`2Yjme;2mt7E?1Uf|3-q5b*|TZf_lQiFyp zJGs20A{}~&&|Y6lwW_Inv zPY~E?*1Up8pTOzG*8>)=`ti=*@9&MAfZYmgTJU_O7fqVWjUA+pp({yW{hCL&{+J~e zqmpcw8ZEmxoS=3_#1zpkE>_K7H|*0p}*>X6|2uPP5 zW2qkAIBZF%@WOC~-Lfe`_#ki~FG_{^?~slfdn&460nKzg6%i88$%pLO#Qg{MqXLeGy2{_ zO7#h4SB?c`2xmvb0ZMWUOj9yHfSq z!b1#KbKZJ=_VYK>2!m+f2m2#-69o;-A_6;Pk`dhU3>y{J!%e2JUGJ%>V!L1MK6$?_ z2-Sq|#$UPyyJw5|UES!#(q6;}#X9uFYk{N@SCyoIw!R+p`KXbAmGDyk$etdC;t>w@Ea`g!(|1-@-~# zLgdRoB~Jek;q&sTspPNOIr{Ihh*@i>swo%=TkQD4j`Nig?!Dup7WjNp zu=k!zKavf0(R9VL9i}tX_l@P*$dd6#u>=CvmqdeX<)F1GgcwT+>5r|Pn4pW91FE-p zBPAg>HnyX3jw7jnF~qTFLpBuQdws(X=8yd%(h&;aPk%qC?*g(XfQ8<_4cQ zdh$#0T-JvxeuL21=zhOF`5SQbXMe|ze?rlQJ-#pRG}YqL3u*+o*}7`~Nkq+Hg;v0Q zsC+1Sz3#Im=YV(iPE0WY1^$9&&zGqJdu1j;Qh=_5@Vi^SYS{cYn=CU)F<>^F zls{e`&G=;bTzxGlmg@M2Jr~XhSBKDfMHN|_h1o+cWWo#Nqeh$fE4}<}qT3{~KP}f1 zY0cKc3@lCMo4;tNMX8fLX1tCpfnv?vxs3Yv1@&llo^|tI8rr?=A@l(RI*X+im?zml zHfX!v0>7f{5O449kQ}jjAwLbXmFd_Zy5b?iRbonWFMdV^#^sGvU!zfp%W^(X&;p<3KS-AM;ToMy|9DHGY9~<206c3NFYm>Us=R}^#XeG|`RMkz84f|?ckX8XB z34O}**Ugk7WHqiBQEx74#}1LhmX7$XCnpnDL#se3V`6WQxRIk{t2C|C%gljeVH=Go_=fI(x$FD7ghc{ z9BQ=MJq2;y;R;d7GUn%TlCAr)qQ37)-sV}%B#RP}BW&)t3?)dUKC9t)sR7|QbM_UyWSKJ28RC3nJJfl z2~6ThJ`pfN1z9LS|GE)`DYe-bV z{1=&6)C=q%zkJxvmg;M!LOpk;G z?YSUyl?sFuxr*2S&aXuoy7lKT8E%#xmML`GV<>w87UDzjaYPZYdBKR18L~18Q`lI~ zLdqe9sFt#Rs--7Q;Zx;3k1dvrZ&G7OkFUpw{gPoY7H&qO7ZzHOUt{h;_VZoRwl|fy zfSStcxS|1A9=Tyv0pS0F%&fF&tdH>d>ihAekHLUdnU4viQu$oJE>`@RT>fLsgk+Gw z^=fiJ_D!_$CXe<)8&$Z3R-z?5(-Ypjlz=uzW^Lb*fXmRuNG+Vo5YZ4Ed@^qSq=`zp zowh6ZdWmTuiJFw3?=Su-Hy0DI(TeRR!!jZSJo#}y@BX6-?pKV3-_DAKN(gzef~1kz zl%M_X6LQIAiBn`JKWP9HBw&QZ_g42_{Vcj6DqHatf+&+o#{Fx)Bnx9|;`ADCi4kx? z^UL!o%pm-L_ZCBjrcw!x0Am38F?=a{z1i?RbMj_rLCT;054*}a+|D0rf(iOY4PFMj z?QP4LyD{N@WA_xV9oPN!R%jjg5r?t^r}K-#h}lA0yiXFlUSY!KbeO+N3dT*3UEc3l zzi%r^DS};eL3W^5MEg7B(W>`5@is|^zDGT%Kkq$xQ@gW+IeKC1Ly$r&X{%zF8X-3q z>Ze-OJUNU%R)>4m6JA!;-OtBl7&6G9%sVcOD2W4%4?`<-ZD2V$EV7O?`2PR~E)p7U z0PPkhnaN42L4gX^%VC$z`+F^5gq|ASzMJj@^2okwP$vzN(L1JUimZ5}Fkx<$2Rabu zU+RM>=cvQL%D1>eaD226zBxr@0?#PlIGbDZa+}n%whRu>#*)LrtFsqsMlLj&kh-qE zm(S|oCNVB2(k#rKn#Hf%<_M_z)f;{N0XeL%(HZ+7EOwxDJ4U(xo8eDNYbZdIG0WQ&f|3^Pp^%fY0|12FyDnOt-o%B;xV4#bsiaor?drl^bAs|_xih*!^7^%Nm| z+V;?CaD&Yxi(HFCe?4A0@JmUT2axO=HFPgAvQG!mx{-%*;%nuH_mVVL*(TQJf`uf>kdV%|q?qDhY%xwjmGf>1tg zs@e6)STcJyh&MD(kf=p1DlqUn`D!A-h(Y67scF$DLy?sHrjn?fg)iA!2w0e$Cwr%@ zkW@vWlx3 z`lTn9hvGk@1nVeblIffUV8>eBeh1!voxn1Fi`P6#uj&kWyh&v(zq}CFcnA%R+Z$VKu=~h4bk5vv+D!O${o*s;v z=u7N<-{-5TvV98%c|m2hbNFQilUxkTB{^G{-tUJWd^#p#!q!(zfc>~=2Jf~?8-sr< zL(hbxjB~WeLf>|L1X>VS>EKfhM<`#V=0#P)HJsEILeRuv5wAY^n3({Bff0$E&`05d zm`gyGQj3;Ifhh>tAhR~V08$ttK)cz^NEi_UyIdy0?vfI<75 ztYInou_9wGHBlb&BR^@Q9{6JlJWh@`+OoE}=oU8mzvdrMzbdR#`QrME%4U9nAVmI7jxSl_{gZ?RXqj389j) z{JHf_b^fFs?GRWn2;}!5P2$ez`K_!Y1rfZ!$D_|=n*zd%s*m>?o|{Yan`D@dY}^sE()-0U8s0oc@VjfPkf$!kXJ9kC(`(89+RJj%Uh|%_;EQLkDFgjFSKLVDX^W`d^8&xUPC#YUWy^D@I1FwLoau56je z{6UDMNPI4vIscNO9z-!u{^|B7gx4vTl@dZqQL+7cGavzXlI$q|I;&GGdaPKo;(?@! zf2BYwOE=+~iXRj@nr+?4$|YaTD7*&)@dHsWQ1`^$-8$8kZNjML+Ki@#ncWTx!E9n! zhWxDDUWD%w?~E<35KeE4o2Yl%w=EB+aZ-R@*{wS_AQzAD;klVEJr?C%OSvUV%5?us z!~z$2zm*-l<6l3geb~`Wtp;l*kw$3<_tt1n)wq`pG&Rp> zM@^F1#Cd;p_{J!oWQ54rbO;i7d`M!{7p3{iYhS&aN_J7s6JFcg3j?*TN|O>OO;vzT zlO+9qC$H$MK*FFWlstB+?9NfbGWvRBdAJu8G}uhw4;54JM23py+rmdG*f%P_G5FUf zLw`fCMzHvH$MX2cL&@Ah6acRiZx9i|{)pe;yi{w`Nh@`4`5}Idzh(M~1fu!y`snV> z#pt@>qJtGe4Es6aiL)VK|60ZtygXPCC@Pmyv{>}rfFKwy2%ZR;AM&f?VhhEST-31F ztQ1q*-Jcn0v-0eVKd?yUJsbqiTXiZc+<1h5No_8C#^ndQkMm-UZQV|Ee#rnMKXX^tEtC(*nz_)yjG6{4)7FBHrR<;tUdc|&ai6gI^ zsYNB?ID8X5t@!54=iRj9u3u;ScwWi|=4V_s@dyd&Wh3=Io^|hM-Ev62K*d_8@>M^P zUcWfV75wN8Qn>rhYxs{JrXJgGE>;n)T(*jKO4I$~Ha*j|ne79C$rgzwHBksB>gB9j z{kxYvx&R9uvxr^>d#_)2_jq*7C)LTgyKUMl=^Yqo=Lv}s3+r%IkG$9#=Pbh}J!6^M zfD*mKQW%S39|mnbM(7`v9;ugjw~iyyTaRp=3>d&p9J{~E9KxOgRd~Br0DHdCpa$DI zQR>5T8M&pE!-jJa%L&;1A15YLUiUGHDPFW?2JGC@UgWIbwx$kREvs%`SYgl+{UYU> zG>(H2mKf!Dz#=}-D;5#pb&3eVN($S{O2ce>zVu16aCK7m0wx@!-i#Et5gCh)PHU@# z9=qv%8LRyM<@8F(0x+^)HsNHp#P46cSFg?H)a_SlQ)nA&O6sM1gC`U;5vToV!wp(x z3vz`R-hCt?y{A9g`x1EHlAPE#Qm9!mcMwO4AvqZGJ8~@Uj~fq!?sGQo=Ze3i>E+D5 z1*S1^s@1Dll<@uhk(V`@iG0Q~6VoViT0-rxS z7K||-DDp3(ZMYk6@@QigtbV{ND%;n%`fycw0=x*|cL9A7M{DQ95kk_Z9JY2xZ5c<) zwm)mUA(AYD)5-vdE(GMP$ZLfZ zK5ube+@HbMr{OkK`MnS=X-XAj&{eoajuZ{Ih5BBx} z>y?f1)bh?3S}Y=2-1471{4P{gGUo*|z0#IQWg$jAta%8AsMn>ddgM?q2~rS<3@UK_ z3j%@hUX2>HVIhrHRjuV`Po3w49br-DeH79O5U3yDpH-sY99<1KW^`|P-X0GaYpyn# zP^r+!sFcmjJ1-3s=qGcjdK{x>#jUqh2h3HO$4EziJ+W|Vkd?*Z{@{|2PZL+6;l`ze zt|(4nRjvGue~Pi6{5>NzGRa^K0|EJ6v`nKA`c+L8>jc3e*M1UL3sFDJsQ=yrl#X4S zJ_03T8QvOBDWt(@5w~J<6J49cFux43=7esKJ$C{*)0=jO^{bAHV#bcyR%1V6`Fe>U zP82fZPQ`f52kCheqh4gse^sgQJWN>|CemxN<_f) zW)m|fEUHM~sjAVguvz)Drm?)ZB(;Kq^^U?=9AlPm%wM>7$ z1hc{-RI~r4fjL$M4I4k+2w^Np$p@f-`RHjTxuG*OBvi600x}>nLDLZUxcUUMf7LFn zOQF@T=LA9}Uh`$wL60LK3G+fUo!K88(5S71B@*gFnLXk@J4$cu`y9qbx%KVWub}T@ z7m#0WB7CBhuaOS;kND081|U(O4w$uW<_SHAHNT% z(e3=ID60K#EyL~U+@jf$kjud)S7uK{ge65eDV@XRYKtU-=XnJnmNkxfZaaGJ&aFPp z)=NlA`H_;%WCxHgXi&b-P}-LxC&>XwMN`&znAx$@g-W<#xvExMA@GCmiL@kgb4g)h zA#)}2nY69s}JSh%N4%$NdG)fuv4pZ0y9mGO+b$Qx4 z)2FMK5`tYsU5pjkdH0p+@~zMCYcW!4kyl!u6&o)nrQ4b5me>5ZztIQ}TW=sOHgr^r z)#jUEtQwV+T0?%;nu!*(dQu>UsiIk~H-vx^2B_4;ele09Dl1y(Q=im+v6^qA`L5myog}0%aa1B~lU5cYgdv zMoN?BC+zm-bKQcia={=QN;E=F*)$JhJG;*b*gbOd-1MmCsvIcMq(yTD-kqsXX||#&Zd19r*6#<0WpXO zySd0mukya8xx_0R2#u(@Ha zMuV-b0q%(Bzuj#V#h5t)a*a89qs!~tX7%OGM~mg`D2TQsafQ}SE-M0i#|KdS&qrKc zQoglp1^HKhtQ|_g7u#2+T-?0OHw_Ki{+eB9!!BqrCIVqzvH71gtoiEZg z$A38^5ahYoY?nlw6zl5`4>E+i)qz>@8`IjsN^a@cZKmz+Hca80leN~z#;BY=VM@d< zXu8tpmESHai~g4s)KkF{F0L*<>XxRuE%l6AYz}ry-g<8_w2NfQST}c)j51s;2L`js zUq;(h^;Vz0dm|cJ2P#zPw`#%`1`rh{r9wnV(^u=v?5Y;0rsC+fsIpwjKFyiQV)^^0 zB6i6&V)=DW!l$rUYEs1HlVa+i_^Q)dvQWSX7Ad4^?0gf<)OXW;xH7KA39bwdhUFyW zZrF?>X-;Q%I1e5-UsLsEt&Ywj&GepB`V}WFE{JczjTaqq!#!3&59>1bMtT^pJO5fk5AZ%~A z^yu`;^TXb#&?4)}xkYG$dJ~17x}9*)Eh`2zl612JMu4WQJ_BOpF|h#r5Fe476W4!P z+lTPNF=U=^-+7N*oqh)We2lpzT)S0hRjlZ}{-j_U%w~7jt)EByU71gv*K<8TFT^#Z zan*(&If@R;_WtK;>gxUFF6xcL=;^-mRY&J$93q&Xn8*6!PA`I&7ItBx`m||og@enp z^T>bhP%?T(9dQsp!RuGgMVmXq%`y9Y)EHo8hJSF3cXSvVZF8@l1qqjy2un>WWZhcd z-~TQiFbi~$ta>MhvAjXhet2sE&UeQsVFQ3c`7=&mz!yY)|_u z0o=sIdPB;mEi;P!hc7e(kP;^4>+y<_d|-&Tn5~Amb4>%(zihQJA&5tu)>oe8p?*aS_6@ zP(%QMByFTKeVE(YQ6NYa1}&QfM;lF@_!+JyXD(>-)MhOG)^mWJNc(~#q!tYDOKf&2 zLx5gc&iJlN&@SqpZ2}=VogsAQ4rIaI0jv>@`9+ZJc%@(SG|0BIb95ScT`Ao%*C9HY ziI8t2=Q*o{@##t3L)&zBztdikCPcr=ob{x#NZ3wAb`J=2Ep*^~AyjlcBm__cKPp2+ zBK=x=4-XHIixzzSd|PgBBi_5U^xmP=!vg>S5HXhv_wL=bYv+^wT^%HX>SV2_)LjUA zsJR^z2$JS-Z}-N+3I-N^M^{MA2+UaWtI^V?<%5fFhsk{MuLlW#r(2kH7WG&1)Ae zTRgmM(VU=I#9Xda%*#lQkBsixb>Q#6aqsDBGhZwTwLIKRM%BwMs1Y@*LC_ThL0hd} zAT*ot-s6)a)!@vjV~^eS&0SC4KXzuMQm+6CnGi`??kdSxNSTxh`7>h^m_(}w`+IWY z@bqLeX?EsvgWa8zwc3f%sopM_dhG7Q`*zhPM^;|>#@%DZxu+W&7Ubo54>E^(YOHyJ zj#%d8PB1I%A!Yh>Yn5K{8hcz!Fn;3JQyNezg&7?wMgS1XBcFg2BQtBdvtDy96!w$Y#qDSvi>N|E*>Q<|v+VPqztpp!#;TL1Nj4)_cZT;(&Vz zq_aplA8@vBvL>>ZA^_hVo;v~9p}?96xJ++SrGxWvodk4D1>-Aek7sNex(d9`wn*br zKztNZRT8QXO#}b{2?%IrdwO-kOuG?yczAetTnwmkRZAuRh4+}(5nXW$c z!1tStddJ-1&SKQnnNLve*>ieos@{x~zJj=B)r$2imgahU0SYA&aomXFh!Arqa$%S+ z7y7$%mv32n&-T5?rW+EQ#jw0)?Uprbw}?H5#wMy`qhtA@ybvv}F$hT_8;za)sZ)oZczpZj6$^jrb?X)k&FSpx%a=<*u`J4+iIid392|t9{?1%6_aFY}7k+%# zy+8YrkJf5+yL+4vf&c=bD5=F!F$jXTZ4ZOs$i(>L2aZ)IC!YS{*Y`huchqc*)e@1z zVlJ#!n>kUS7=#eA5d~7t4{*&;@0(T+i8y-n(8-5R)~gbmQ8PeH;&`G`DFnfEW%om0 z|J&Ng{*_n0;W-){?q1c`-5JHtJNW-g3G08WHWm}c465`lMrMpAuSQE0!g$?!rQTy^ zSa)yIZZazFIROf*ARE3YIJYH0ARr{O1DefH{Wn9}T~;Zyk@9Can98cHINvrO#^l>( z+j^X`3Cwcvnd`_dF`IBI>xk;0w3UI0^)4q z+};N7OVvu#YeUl8+y|4llk|~N>B-a?`Mk}>43rWP$+|yTn~Dqk>EYqw;c@Z7>ho5O z&m_n!uve`Am9C|S$At$(Eab)GPdssIRho_YOBZh4 zd}(jboSvTPW(~$po$2fED;A5bJuUtOAd*zN} zU)(o#d@3sDbESMZ9ar-~M@PA{I(F*u?|o_N@U|WEx(0%eU4PZh=Y2Og8%|2t*|Kmu zgQE%KCxlSeQ6YqiDC%d(9%n_?Vy*;RA;{|Oiqob*URyF-ur34fovhlQo#4U z%T~rXE?BwU`qPvf%LEiBvleJ-bLy! zw=kf6Uwe3Xcz9d{$i%t_JAY+S?(Gqq@_Zo-FbK3k;Phme3l`1oearQiE?+SBf#>#awj{CnqLl5;g0! zu>{YIj?Et)%J4&yB$G%8=W=iBRjpMqi3)jv2%(T6LJ)+QE09behMWjxzL+GzzavjFcgnJ_0Sjo{(sDog~~*;9?N<&4haik zSZy{d)p{WaJ3DfH`CvdsQ4(%hx%8Gd-`G(uO^=>xPUX51x?;)HuJOrJN9#!(gaYG8 zjwiJ!j&fmGt2Lf`=E?Fwc}Ff@vtr}CdGn(vdYNzLj3CgS%_x8ngi*!Rm(@^!{Qmg3AB=!6Z`(YrS**CuX$W)RL0mZTGD23N<99%G`TyG}wMSP49 zKm_8zvFS&jJ9G7hIZKB-8qF6NCI-h zALtKqvfiwQaig3oEm}B~3*xvkF>z|NBgmI?K}Xyyi!caaYV^$L=;*+lK5UeZA3oIC zRivag(9?a@6_*2vX00isXmV=2**rtoSTKKnIzkpCNg^rEwQ-6MYSr5G^z=a2l4?Xx z9vJBvr-gBD{-RPYUrgf0bNdcI`t9(^XAPz zzx3EjeVn>Gzgz^sEamWI-s4<=>f1^{k^m8fAd;Xdn*<`a8?B1pnkT30?((!m?`kuX`+Zu^clB&dIb#oI78-=a z^ky61-bJTNJH=zVL?^s+mwV2GTg2o)z*9Vr!Z4{Sg6w_n-$t3SQ| zvNdz6_46hzdc53F6TZ`@PmYhB>FOxeYBfyaj*fD*UfJ>N(}g0fSvXuDt@d|!%kg9+PHG<`ZW`i6F?Av>?(<= zX!_9ZXC^Maq_3}^2fdb30>wfgo31KQB&nPW<_>h<{p6v2CnqYET3<2v== zcKB~iW@&Jn%UhT0r$qY}V>cuG)N%zoc@9enbj3pCf#o?%HxhkjDZAy&mR&2#4rca{ zDv0rQS3MR(&vd-&BYTDR?jg4qq2c0uX8>IdvT8q>uy$-pW;0YRG-WDN5CEVM2qZ~L z)1p*RJ>&6(+7Cqclk`f2hlhv9MT(3&ecRZ0V8(yvoPYk-($|_vy_x*{JC?s;>pT(Q zsj^gl%!dF_Z^Vg|h*&P>0Dwp$1_02Ek|>slu;b9wKm757 zZ-4#b%ht>h0+Yn=V)g2T5TaIVjE;{qlV-I6g?b*K7$wof^u(T{yMsb>-6iXHJhD9m z(bqq;sbk*!{)H2xC!6&uC0HnTcl8vvtXw(JGgj>CS+Qi{yhYuIx9!-xbm^w6woXiq z6^b4CLcS4I;-p$C!2Uf4_iTUan%CdlY`EQpi4q}XRucmNNb>po(na&iq3AD#Jxd2` zG00l|)V_U{^{cxJG*k}ijY2-DtY17>?Czb~Gb+bM#DZb&*w0s;3QSrkJ&cT;-mzmx zC+4e-#^~f^Ay>#@(j-ZMlAya-C`l#EQipy}^ z-#+72;o;F1nz{ojaUMu8rw%cVEuvlR~y508rzZ3(=E zb-A|!z6zmt9tV8{DCUD3x6Xa(LX1VsPE2x$k-1qH+i*5kd&2-HtI?P5K7@#GyLv%qDa_}BV`H`NJaF{k z?IUx03-7po(G_dwB$Doa^5oZkcmS64eD2Oe2TxQ#_{OD!y@e?DTfM#tfrwEYPfU%~ z>eJ%n=?=n@z`i6DeUO%?(OYdv~cm@ zz=F=M-nqkrL4Q!2d}6Stt0C&ACy&QLQk$+HJ$htpVyvsPW2!MdHg-xNx50swF+!sD zqIb0lf<~iS967nDoSQedcW9uzZO_RC^ZMt`S=w2fY>b^Lb{Ds9TC=K&eO>w9-h33~ z)($qhqI%SfKp@Cecy=Aaof5vcB_hVL+`E5&bviDW!fLfTR;e$VQwjnE5|lt87vus_ zZ`K!;bEl{4%Qvi@PT-NdesI;MMO|@)Mh@2|C!Rerz3s>u0H`DwAcbKN#Ze@o9>oZN zLI7e=0LqDHc0SQ)G_d=;jXHeBtmfWAdCuzTwEm4kK+8bt!{d3J9nPw5L)Yt##MNm~ zOw19~l z1J9oRrT=>WfBAQpUAnR_7Y5aO?Kl4FiApWr`46}CcNcaYnf%~?{PE*YoLIJT@aU=P zpM7c9Cw}eHe|6i6d!Ih_FFsc00d!-5s5}ou3Nh%7vv^KN0K-mJ#*&d@nc<`b9;I_qqrJ|-7(ix+OlbDp;VBK+M(Th>eb0YF}G#&ntZvFVr(F7dv<$Asnp%w5y=Qq09zrh zKtw3AYO|C{qcYiD%1NN9dZUmFDT(4BAQ?r?WWwrOtpscrl3*nP4I`JBLdGcM+XazOPaAkb8;p05T`ZFFjK zeD2`TD{xO|f@-`#bPH^C#CsDe+Kid@k~W(TdU&)2BX>%UWjmN@@@bWP0 zp3_0cr4_}UnL($?ampN^HDBZulbj^Z_%TUJKV*UjqPu z&=OrRF78}E*FBdiN;yNM`T+G|Xl)@I9eMRl$UM7Dk<01Uo&feBLF+AXBEQk?ZBN+f z^rr2uU~`MC&lUm#NWF0a5fBlt2}zbenY92yAb1<3hlhv9MTd-2y_VlIA+^W>MaBg1_M(g!Pa{C|Ov~p3`yuRWe|I@Qy z{pPOU{N>Aj=ffK(D$#%VtH(a^*=?7u>ihXyS3bORH_Bm?jo z;rH&nWM%*F{@f;!6aVR#zW>L6|JI+oWd)I}Hh)qF zU#r(*NP-YLJBuLk^z>w|Ddv-esCM+o@rkkN&T{9-$mvrf$4{RwAKq8UVeZ6{hb;^PmZ4%LC8{Ty9UMu!XOGz zKqw}rDrd&3^9N|SYoKS&;ISRgM0MG>U}?TwY)*`e0+j}as8%_BX0jN;)?!D30Rc#o znNaPB7ziPWsfMb!Mx_|y)Kp`-RvqZ>Mi7-oGYBLlQD1jwS1#-esEeYx9i0zs-!nE{ zUs>oV=9|#nALr*B7%0*vt^p(D&hSBK+hF^fPo+`|EkFmj z=Sw23!fz|U?Iql-Yg?yQ1h`!k)S?b|$~FY8`#!dIjE9GZhsT8u8xJq@!>aLoO3D*J z`%mvHSM6BRkDopj2BO|f9@=^4Z@&7RMEL19EI}0YMgk%JzaQOn>s9jsU}U2H-@mkD z^VJLf`!8M!0R3J0zxjT8ksW8Ez2pD!4{x}B<6HoE|4mE2`^fRn ze0k^DDgaOK8-MJ=1{=f3<0*WdHh$uL9!nAcN)W-_O{&`}EOjW}uD z*Vg0W2O>%WL@_ipe|lmpNfHrasUtsGovOz%G@;pSJ~wgbWbI_{vUMVAPSq+Pu-w%v za}C*?j-&e6WWA%OH|Xxzvu|(MAw*&6k!Ex6*h$QRjH3jbV-u$i9zW98KfhV4C)GF? zw}iGQ2_z})c%LLmkSolYH@Dh|b{!sBv0xZvJT-Fi;I6$B<5T^o$ID&i=IA8Vn@K$h zOQpRBPZV=`(cL40d=fV-`8;b}w**AB(F91!g?ucN(MqGY+(8nGxm;hloQvzLI%xG^ z@4ZLInp0Q`!-XprR=Xj;bW=Q8U$}HxvAcKI@$rdDT+eqz&66PpQ&F?8R0J6mgRl`d z;v^a9>}Ui~C&Zw;)JTB)lC})|I5GLdik0;Qc|1{bmcpW0RTjCGB(+$O*U37 zoDTq%S`z>VdrQlPIu4(%)|>Hjho@kE$MT^v0MzPHshGQI>)g+L?%CNZAPEkgsz7Ic z%d%bosMI0=x^nHDE7#6xG-Cj$H4+H0)=U6E`cLGm6r=+jQ@Pkb&_7(A9?$0r070W( z&4;;06irV~HzXxVErO`4m|wkm&9-ND965G$*@_iG&?zvE;^_FP(W%Px(q#*aVg5jM z?8irTO-9Xp0YVWHQD4927~iWcl#1)1yO6RzY7M)%6Z`nIL)Kh(@ZM ziAFTmOoj`^&SG64h5X^ zC5#u$8;}BTUfR2~cDg2T>%f}NK7R1@RQ*g+>nj(6FzALN#BtJyWsxF+LZS3>-psQB z`M+$t53`nDr!wwNknTgz>OIZ|G&EarIr~HjMoqOkt604`LW=8CmK~o1W=zgB|19%w zy^5u5J?jplt#Zn{J)Kk5gqX27%+kJeJwtvui(aXdq@&QG@n<(g8o>mOHyK)@p0x|K zbUOF;w{=$^3VWx>mSn!a(G6K@bQaV<#5~0QF{KKW@rjEf=b*A)gZ{v<mSwIL(l0-_q zzn%J@@L$GPJEVkCsoc{uaOluJfIv1$2q*;k>1Lx5#R5f8ASseFlV{4^a|Oz!%a%@$ zR&wRy(27NKySpY%osR0yb#;~(E$Yq%g$JgN9j=TD2oS?uKCD-x-uZnChKG}SlFuQQ z!mzWIh5ASmrS za&+(hV;!A^I!4t-bIzL8fL$^^4g7s)LxCg^BIS)A1VLv{-&idgDwJdrV_4`cwuU|Q@XW5FCE4Dzcr(UUG_dr1;bofaOL{~8!AD^7Rcxc|7t$CnY zwf^J1C!abtT1_BHs1Y?6^!Ck}(-U;Sp2=fD6qHI`=bsf5=$*d}Ik7sNmE|)`Ui~=# zHTSq^V4fxYKmj14K$4t_x?R_`{wtI!oY`+ZlQARMiqnB*(V44|$>RXT&pKiH1vby_ ziU%#Za&13TRQst%%H_O`<2{&Wf@99cYhC?8%%#9}s?L^G&+P7Q@>OS@cFqXDv&b-W zK64q+1FOD@^^EBo!{^O9{8Z=g3ypn2FRRzC0LIS%5y|bfBXqn(^B=vGLocwmF8xGA zhP)B`X${0am8>4oUAPG8Uj2%R{Fs zxiA2Lqa(G`rz^d^B}7~{+zFNVo~KX$4nRjK2LN|IcKog3bs+CMV+r`{f`Ks&Ly%~k3kI>8!eNSRtr&wa$( zsMxkRSA&*UgBuZyA56QqAKPE9ReDW{$&zfXWV5Snan=dI2;R&a%5?--$=k{tslp9W z=3Q(2`IS)57Q^$d(m5hQ8!LeO*I6fc6P(dQlX)O(Q&7o-UNMhkR#c*SB-H4I+EHKf zt2{hBJkE-#1|&qiML)&45Mk%38PT54=HbOy>0dOGgzC+t-biF3tr~9;ClVS-B1xs} z>CFG?d)EH$pFQ;RfAZk3{M4FSBmRFs_0;s4+Q0ktD*)i8OXl@nKJe>pwXRM6o0Q*toP;2>gpLJ-1wnpO`eb!Ze>stIc%b~YTbJE_$KK!i z>&M^!hNUOYR6qPj_s2vZy>&SN6mkNJ;n#n1WNu&i(pA0rP$bfSC0~U|WYXK+)miEq zpBO3T@|9Y(+}YWvN0@|xpe9K~44b*0&hFZDwX3^V#vp-;rQ*cIShF!TIM~x!f~jhS z@_1h2y0f?%R@rn9ei<;t~DI?|#LK}>=uyN?|JRHmml zZ{D#2G=*9Es7#aXL zSr(15iYQRKYCDlQ{cg0pD3tx#?tx^s0WN)8s-8+|sXe8R0C7f0;MSeDH5s%e-K%Ffj};>xYk)#v=gPrHM9M^Cy}= zdAqH30Bmt~j+J)xC)N>0H7WKfpHUwSySGofC?w1cAr8h3eE%RUOZaSx@>03MHnJNGP5cCvaVK2UU zwFsYQ#+4@g+?S%9puc=aZeG`m96GY!gELk5^0uVfghYZQ=q|v@0er&>5%|4?&H-GL z=N~w=DLr4oE2T^YdU<6b0m4w^a-wYwgbHX{K*U-t4Y9gta4?g)DSA3+I{Mjd;`m=v{1|qq1Mc@DVjVqQ7 zbtDPhxOLtYAKLKw&+qvBH+Db%g?C=Es;_ERDSNz{Ktw{o{GlaX<(^y)QBqh4MG!VA z2|~aikcq^Ydb|55u8f=-TfDFb!cc}XUkuCToTPfAS&yS?cX!Xc&U~?16NP+7K3C2a zWkSt{6#3#ry%LIG&%yn3hvp9tEs{yX#A?JK01-$T@jWS&I2=bwZ)fk8%eLJAbYd5X|$V-U;0ze`NKminkax@14Kt!Ps zso;E4LVy3j%{RW`dtdw9WHTz{!trVpgDmEA)0OH(XL;Gu1+rOBPVLK=?uUF=eg89< zD^6i)+mkywff3o)0f;a<1rWe5T@(D5TY~=b^P8;H zW0;Jg`a(I*bQ6BDQzV=Q>8z?Gd?^H-9^X;FfvBD68_qC!SB8?=&@iNSI(c)UASWX zz%^?JqBuzs`FlUR@rKQFcO9NwKHPcpWyAMBdun{D*>Q9L}*@zp=P;XAh-KXSU- zSqg95I(N;&o_aHuNxWuJ_qYDwy6?VqdA*s;>B%=Dzlq(e6GRjxux{;^Yp=TL=^YQ& zYt^x-@p4yrvOXC`*iAhEuzBU8dHn-ZasKGBkpoALEnB^++)?W2E0yy>5G0e+Cr2kI zR}3%MII!&KDLE~IYEp03Y7mI97&c^mtU9`Q!O|tmS8iH&dA?9c5tCKFlrZDg5AxryEhSdFAl-y+;q6n(FQESu%f!pb04T z%Pj~%5&=mPf&frT2!ecul*Vy9r?2~+Z+qwGKK+?fW0i$HT|3X5u7QkV5#rY$iLrVlW%qFJWjEF*n|_Xv zq4&Ih-KI;!FxQl_kS_rNhB;9vo##E?d2B%CLMx2qhQbg9#oI{*Ax}jy^zb-mXi8@s zJivpsh}B6SY~C?((CSG_4u5!m0DEr_4cqq1&r{hHO*( zKXYv=VQ3|BqgdNEkxh)IFe}ufHH)S+e|3pt&dfxo!rhtvH*T@!G`B zQ*vaQ*3HL56ZGSKvMr@=Bv5GCndo^z611!!*61z5{9f!SzNq{a@&U~4q~R|7@3)2B zMVxBDpWGe))!oTu3-DuCq#BSA1gt)p>UHGdj*sSQF_m-YI&NfgD24D(Pb8Pj7k}`k zY{!KHI`a6IHTVZ_3On;KQlbBKpZv=QlA$j6&DUK3dmvqq$guRbq+NZN_|A6cYuJxs zx?;`16>A2hl(j~}XUjw&C2d*Jzhy0bZ<+V{E%N}N8O7Ibm76J2)MmMyD?&zw5m>?jW}Ub27RfnyKf*W1_ARW20-#ZjdZO$`mr>*(s4JFxJI z?)qa-?hOL0b{3k24q$dEn?{`;U*+>T7!D4$bL_At6)?Ftyrmy+8pV zNrGIdP-_jvNV#VxX^MtlFHCQ86#7_4@z5Yy0N@@B^EAr=}+ARO}k+ z23hTy+uO5v0~{(WUr-w_$A?dhY(H>nZ*luNa-i@Fh7YkBaYeslgEK=L; z&@RokaU2j)t(PXYv3&X)ts}l=wuFb7dA_~6ZOR#~1NNK?aSwdT(AL&48?s6_J8Fe$ zbI{Q0knhTa{ERk~=#2$zpaA7ckaAvU?(qmF^q?)qf<_gJfOt z(d>bjs%?4zu^~I$Zg=SX=k*=8i|A!Wvl$xL3bkQ38Maki30I(vYyY8{f4-{M1GP0AGAc{`rrRKYDxc z;9>gCb8_Q6{P)+3i8}nZyOXghT(=be!wo@i837=c@aadBf7vc85iIG!_iYhxT{D}G z@x`a)-#(Q5&KrV<4#|7=QzGGGR|Fs0sxLV{Nq>G{^2AX}B&?r@|L$6`W-yx@5p)#c z+9leG_wTO{zW1EGYoGk^rNI}Ul6UV*Zd)6Cf44j~MSt=3aA_aj@tpjJ$K~m1D(B$s z>%_;d2tom|gx~pYOn|?0Q_xoe0QmGH$?ZF7qE3A!__<4icdQqti|8|t$?xx`GZpG7 z!mnKy+_XX*9H;-^cjDnLtTySgzi9r)H-zuqn5t(ZhJi9(wMY-Qr5t>FNAlEhefG&? z@-Oa_2gfNV;EILf*RB_HI`kd+_D=cfN99zV-mp@v9mH=un_Rw7{NmNYtKMnferx#T zQwTAOo2!~gyb_gbQ#4fTk^MdES!eo=6N-~4lM>9w&R$}W*vm5)+TZH(S|d@3UACrZ z?rHD&{Hqv5l4P)9?WIpYb?>3wyZWgsJSyUmsM(08C#rkGeM<&=MUc_AyNlwQEsHw4OGF|FP@3!!g+R62J;oA1F%og{n1s`)ib*wDNzDI z_W6;EnVt?+-90^Z?m73?|Awb^7M(5;cK2gRmh zA`!i1Mm$Bye$l5Fg1IThMF0XoT~b}T>wy43ATk0p`5*}akxLrQ_2YDHfKE;>`JBo0 z;=9uLiRvKUM&lB1DN&jaV>dY{=Xv7j(F^T7h}rTvz8T%I32hmO2|AiU_ut8X=dN(~2<{qU_ns1WpAdld8+Nhdi*)~S@he}1;R0|@AA5oh%!R$CDK-~SH(^nSi~lwHz?hvvoq_g^Ao+2`=~A77*|9}(p&SO|>4 z{io^Fa=5wAzJ3FntkD1bRCwocx_liD<>5aLh%X!#pM1M>>0l=pA&PAU0QmXO@qc<$Z0W~K2Jr6V;yeE#`1|j%U$jYO z?;}_Yx`&wvAVmE8(+UAWiu{M>=zso4I9-K1wz8QjeE9zG$O8Mr*V=;x{OCUZi=XH1 z5N_CjcOB;+e*#Y~i{9+{%NIW%=%{FI)@=quy+=7a{dsF=iWCVDHDj_Qcm7Keo7BeegglX!}k!yK$)a${TjQ;r8nf@7rHL zv9#y1t@%vW_uFb7BtT#SiF4bwomJiALI}&Y_S|^exYNwJRCL`lXC^=SUth>r%ysOI zBZHa#?Aewz>iGhFbn|(axj|4E94`)TqPB0HI+Iy^k`bS8h1)OPwe{+&rWcpmt>)m& z^xT;RrQ{MaSz}F6K`B~C+)XbqZ1Vuo!tOx*s$VQpG+1q!Anls<%A+V@S|2pAYwUQR ztPYp>5REO66!T963rNkzVK!8MDw`d9GBvk1>&zJkjoGc)5Y%kiWWq|-k0U8yoRwr) z#MJvc)7{N10PWycCq}&nGjL%xSg%j|5fsgR(6O@4$xs#lgmeK!9biC$q_IO@G?}u+ zzB%7;iWJXB+;dW#UZU6SWaDMLVI#}t`F|b~w{K-Og9Eelfv@uGH?Y6_ZhNeR`)9-t zek}a_A^wJ4Eb9O`aKi@t<(JwkZFt3B`e!Tfv$t5U*v7tmO#H+r!~Z@^KX)rUHboz} zTYS%D?61DZu^F6Mp!Qs%>od2$nOz1(ZBkZo$7i&bJM~N8al6 zWpTbvulk$dA0Fjz+{>n_@SlgorGxN~Z?s2>_`n(Qu73+Ye}rDS9Y6GXXV>rhV>GM4FB8RxD+Wa5)e@s@?BS6 zJKp!7k34j@TgJz_RXn=&`KpUVHhDVliu5K80BnO`C;7!-M%^Az#Sb+5FOC z_=V5h`_%DCVOaqNoQWkKz@jKsbA`q1yWaDjbz>WNu%=AyI1cBz!1w3or}rH=cJBjE z-Tk#kX66=mTsE+Ad}v^>uzBZ}J$otaWG+gicRie$&;>2S1%_Rz~}a*KFN% z>4+Z)ap|^=qhlLqPCo3kR|+Z~yp%nr%B59*l@#Ae-F11q6e-RD z0+22k!pIsUm!cqGk$4(aE<c~N``J} zIhyn)9Zfeid5ZU=UYVA6qbUFhA{}|ilM}beOPQ;_o+fa;8*_`1m*=RS@*L@5}c0~WIE zU{vjcfFglLSt%$!gNo=!&5MpN1f=uQr%2I_|2iZb3$_j7OpS&Mc-;o}p9lH-Znye! z__dQ_sZOujZjF}_0CtbCkG$E54civ@CCZkWo!U|okIvfOU-C+RPf-PSJ{yO{)5-D*Y03B zJMkz20NcW&^Yr?^3mL-X3N%Cdjw|qcFSh`o9gyeX-cigt0056o(diX>(FAVlLjV{n z;dku7FYXgh%+T%;WB?o>>%hII#Qqt&af1EX{la44hX4Q%ouSjqaA-lSwBU`Gvvnl| zfa}(?&;Njn02Yg*%#O8+rMWi?k4@2=|0#$jg(-RPv$t69yxIylJakr+v-tTVbpI)y zb8z1bK${NC(d{idu_XF)@QOdr`BsD&BRJzF&1vdLHMcPm$t6 z0|}AM=f3x6et7omnWIk}a7#G~ITE*RCkW{Cciua@v~c4!SBwo0#b)c* zfA8<_y8Ge0mn9IkZQp&e**{o&)8_R9!vn&}t=oF(OJB)@paono%K_WAnB8i(){hQ; z*NeCR^51`LsT%y?|9I`TOE!YT8m(HZ1tdH->z%_oPDCu^nThS;+*I&|e`CGHOg;ACksbijV)rSY9L2QSVI*lYKnw!jYK0^)rjo1O z$Q=&`>{@b;6;K&p1X-@j3K_CkWD_?i@oKy3?1hX8AfQDg{B~#MBvtxO_W)xq9q%ui z6V3=kVCT#}5oJbQCj%4q1*xCM!2%r*k<~S#*E$BF%?Ox0#42Y^FI2mX1^;9LJ_aC+ zea4;!9dMA52<`7cbLZ)^Dn*KJthC|&Q)0OVe|kUvn}>N2!eRsJ0X%euUbYRFo8Wj* z^y2l`_aweZ(PZ14AbPk(e{BmGlV9XeK*qrnOQP;mBLIeQ$5tFGVLM#a(G(*Ti5Jnh zWU4|y(4$1@tc?@Bt86O>N*TC*19KPpbt}S2J+7NIUEu*edT=WnC zR$B2v0br>KH6NB5aNj8*1OUR;e!Oy&_2-~62>>u!iu8N_O0!z2Rlo1Yf8dSp z{vVla_GxLY#jvNR|HU`F=xg`xuhi=0Qo+VxxyAqXYiFj~&TDVZT)%f)@Aw#y5UnN! zTyQ}^jEQ_t2EzP-dyXx(=;NQd>+#2qyy_L#zvSjC#zsdF3!ax@?CDa$g>V?<_r7?O z?VdSukLP)rY(`k2AMl3X&h@X)=X;RqE0twL=o=i}yk*m-iS@zC{Nn8F=boH79TvYn zxBTdX4`jzi28Q!{*Kf|0%R$}h+jv=~&__I6g(RFWM9HQUO(HJG?vi_8!r5d@IVPX2 z=`%CMT0j6ECDoW3y0U&NkG)NFxD z@>-4iZ3uLAVq-sDUrCwuPpc~E$w6uNcR3Q+UC-|Avm6_^Fpg!+{=RxIw0w?{n}L&{ zFTYvcBzn`6*D`q`g_=2&I-F4)? zC+Dxc^x*ET>#o|fb?cT5J>`D9mft3RE|=ATJvHv#}buFdk7U0Zfp<-iv)6Sx_RF;U_3E3o1LjQ90ICR15lI(_6o zsi)ThURkO?a%|Qg?S;%T6YDFVoQ3>ikGP=5ayBB;!0QUN3c!AX0tNdN*eL_!3{ zh>U`89dbQIae+ilyNEUt05XOEU@@V^a)8c?cTcOv$IH4R0_HOPmmy;ajA@ZRS%S`J zr%ae%MZ#x{At5oeDW<`y(mO^HJLnQ}=F!ol`)cT8o-U)JUfi2F$Y6Y`JF3%iV$Ll4 z5NFrN{2#Ot2+fCzIk~)C(GdJqH;waz$YiZVN|+I3(na(rV}OF9xugg%cG}|w@wiQq z;sV2`4~T^Zyzh4QroEDKFE;4A|1td15pjAMUNpfjpJ4y+D8Fn3uN-6l_(=FGUlecN z%g#;dBShc5E5_LN_4uLt#6&M%Ifh?93%`GNc-1KW-`j0md)u|Xt8coTeenqY{kwTF z13el1?YqLizmNajTb=7SBvwO!7Kf@&MfYh2DA%rMyN1{o5AzS*8@^$;^|cf9R}YFS zF2SqD^-~sVJ}g~xTpa%Yzp%P&+qSLCE!#Gh&4tUh?OI;0WqY}{%;lxEez(u}_vbzC z@wmsm-Y-30HWZmP?umZ8Tz&qC^>pj>{jc52c4H)r@1JcQ1Ge2s(I=k23xR(o)w~Yz z|Gr}F*@)uy-ARAm|slU6Osg*=*Mh>>Ms8kQ54+0SLec-TR$$sYECKLNe*V za>+66aJDw#rwn?V+Ou(yhJ6~79TpRv41QnvU2pJdj{WssV-K%?U^lSzZLRESzIV&& zsi)C%cV=xcX|Fi;E|;ua46C1a~lEV$?a2q`@ z(PLj$*TE02fGMw7<3Z?8%!;1|lkr=~!`Gj)3;zBk3%;7`J2KimCST4S}p7T{v7A?9rq4ts_4`+!jYYrRWAAgfN84bKN6wbAxbZ$sG(pFayK)5<~HQ}@l+lJ5`@phw;*=k_#z? zSTO7?N^^;Oq~p2BI;$LV>tK#So#IR^VQe8A7G^g~b8Iv+@_F0CLK~ z>H9Z`a|b}e=0FYA`yV))dy~n50$U<+Xn7^?!sEdwg69zA@P0BDM)nT++DV2sv*eWd zV{Y48!jks5sazNvozPt&92!=F-ZYRv>{|a-R*bZ7fA`zRULX4i`~j6MQ%__feu)8# z#~|Aa0YP*;0Dx`Ny7vbBd;cQ zLb%o>Pw_e%@8?T{D#5hGO#6cZUG_+Sw-lLkp`@N>x4)Z+4(~oJTzeZ!i+LFRxu*>Y z8JqaftM@0GsJ`RP;c3u+IF|Q|w~+Od&qJIp88ar>@ zgI|+dDwVMitvO786wf$ff}xX6Z+I}k^h~@z!uk4tYPmcOc3k%TMI`s3eF+#_fHW|< zbUF`3E(E`=*}OlSh-uY77o0=ESUC-acciAX#O~0Fo9NPQqeAu%;B_h}QJ6`4q7yhLX04Yt)RiWCVMqy5iK6wL%y| zv{hFrizpAeE!&_}92dS-ZIt9%kKNy+P+gA%4}@4(Rt67#i-joE)?5_&?sj?SE=a#KB7*y_VFQ#qKaE&?<#e@Y*>@T@&UpJq7=an`xs2dtSH3aMZSqtv;k*4xa^?Baa!^o3*s8x`AJ2+_BKiygz44_o@Y&8p1mq&2zQxAU6`o53XHY zJip@{p1w@Fx|(M2{{Asz@W~)JVA0y)o(z8YM!L(I*k#@P)bU*#_?U@jla9~g;lxlM&=vH-?M~f#t5cQ@eV0d> z0mro+CQTjv{TJ|(;eUvh6!C}R96J8|_!(9Bw1s!|_lj-1x3%Y+04xQbnXR7oxBxT| zGvVB)USF0`bV|*Vz9?1^8JZom!z(tCE z8*Ta^-?Op*!GBj9f0q)S9k~$8vj9xI>DC?GP(YJV zs0aM4J=y{wpyZ5$lsDGNuLXq(ou=>=83JS60T_1zSMb5-yYELJf=w-kKIm9CLCa@2 z5J)D*51PEW3SMhNWdGZ-?WiIARK}O0)uo{OWtz*}~Wxezr1} z^Lcj)BH!-TyTOQSk~V5i-ajN?CjH1afbqDsJfv5dxAYVZJ3P!H!=xv@Ui1jyDa+gsZh z0YhNPC)Y73p=3XSVEb=QJDijtv>apg$t(14JPLjQh;xN>tHgA?DSoX+6MI9g6nnhS z71r_5#yc!kxh8vgyoPURDnWQJXgsO7-_Una_e))o)d~OulQS3@Aej^MkL**Dk)dSE z+Kljhe^2^e`+{z-lh?UYC78f6R&i!cl{S_`YlnPB9d~EFLlzd#>Y-rJSL?u68`RBq z^rm&kd;zY96D;`OV!l3Z);DWS`ysr?6w&j|7oJp~yS<&e_ijm64Sn^BRmXjt#|2DQ zzz@u3>!=8}ZD(dqYI#A6g8m_M#I4E9vGqzH^$LhemVOA}Zzf>*`?X&%{=RQkiu?+= z$;&P+P*fmm7haVdpr6U&fCDS3`w`@wR@~#qPe9$LK(J2;I3n+{;qiTQH~(zJD@G_TTvFs8K}izBcthz!!eJ8Cx{W_C_Q6nIH-BtX`4iB4mpyOOW=Jey$rEBgFTS!0!CUy;V-6Eb zb}4^-aHO|aisgFa^WR^Pi+=G?az@VWLUa*(j`URJQQyZhCFnNQY~u6P=I32yQM9?+ z5)c6*Uee{ALE=eNEmdq#eL7aTv3i+Y6Cv#Jz@T~E)~f&hluP&DayE*hB9dioZ6AE- zl1u)>zsB+yP@!P>pYB$CXi^Elgnh@I9@o1Hl-=izOc>SP?^VlZ8CD3huP=VYn^3T$ zx|XI+uXF1juENNGfd|e6K83*2Kh2v-fH1GM8cPIx%Bf6$$5lyFYwmDE;=j}eq6XvC9j6=1Rfl0$!twk78MZeCZ4kO7Cw{YxWKj&Kc(l0Kzp zu4DE3JUo36ne23ndplyf+#a$unx6?kEwb`mBH1uvoJ+e|$zdxCk&VILr3VASUjZO$ z3gWR}M1=F7L3S~|tl>8+s3as}IR`*6xe5IN5=2j7XCufZ2aZ7*CZ!)J38lHhu1Lo?HU5HeG zGh+X2y+pP2mNgmFrov6`01I_Bo!ds^nL6Ko1tg*1I~0bOY%1%58w$-!ffd_O%UL)w ztxp=$8>;;h?iUA=g84ckf8 zN8zs`39*8`REnBlv`$RKf;>Exi>jK7DQ>aQ$&*xDJV2F3zT@g6^P{CAY%lt!j4GJ zcAU>IAUO?1e*9g>b=54 zg`g8K{H>zdtOE3@$4lJF2w({iF0=0ybx8(Mo0e`U-wtQlom~~>5V7iO1oG{ZOQpz4 zF)x%Bq>&E#Sg}H_)M=6Wm4Tj5ielyzT?L(^ISHu|U*)0ecszXxk9h!El`8;)jp#f)TjFbAdz!*AQ5O9bBhNKE*!yJcXP!zFdPQjWk9?_y{K*~} z2*XdWD=SzkA?P2fPac zBL5nn;>+-l&(IRnrrh!X)qNi-Qv0+a70oHd&2CgqAm2VioiK-JffNq-$Ev6#ki+?54K>i|UVSH9{t zRM`^&uF`GF63Zt}>tygz2<06Z8C>>YtctyEHYrT)0UXnG1gNIJDgEOYHXhzylTFH| zH~sH599Lzs7YJEC#Q>CiCA*|(#HE<&a~o^wLNB6x^|F)5mq8p1E7`+#9Z(VlCp*7Q zSR_F>zWDiz0&@mgNlrcKz;qVvNNssnt`cvez^~5WTNEuZKbKqe)Nfv@9!rH}jg)9M z*EX7g%9!2)IP?r`0%rvrR%4m9L@RqZ7MWKJf7uqO^gbCkoz4C_)3yzzY6lKiQe%8N zT}#%!719hWC5+mTl7$0ElPHJ7+;=YWA*MiP+w+3%LL-34$k5Mq4Q8dKEl+A zW5y_b7PUf*;pzv(rdA>?6Aeu@GHU&5i_cn)5BYJc{6T!u%TUm9c(yM!Q|ojkfEVe? zT=6j$^CbZ&Rc?+D0We4W;hD>0%2y_PSddZf`Ny(%8EFh;#)KN_vICFO+zF5jL9}Y_ z$dr6~w+0G(8}`Q^-ES#n->`0d%bc+m*GZ_C&M9sjC1EAWB|vymy^D~@xeQWObEgjQ zioG+XSNl)yBtr-@a&I9@im6uqQ9Gd=DsIf~Uxq*f)ZA025=Fn%jDa2vJBsQkE=ePQ ze!4CGcIlHS-&J*Bsm{HinK3i%X7pK%wgVxv1IlwiS4X99-ocaM{$@1Cf{ywQOoCPA z=qkWOOrLD*k}FKjWxx>e3#4IQvvu>~vMS2#)?UMfPdX@$Rd&l+Jrp4>(cwZa)%|S4 zi8E7F#!#5eE^{7_zDsa}#Vie~tm<_f0ElobARa{^39V;DF*2MwnB{+IzIPs~|D)jAm>627i zX+}3vQX+|gucN(qwHx|#yhu$9C2^6Ys9$7m|Jdt3@Q$_VsE)O)ZB>Zz zDr}}o7`Fw&0YhlQy)UkRkLlg`v2g2v&nKqX1hR9y9ud?wy4TU~+^7++J%Lv_e;rZ^ ziJe0GV7D_A?v%2v;ZBKFC6a7AB;jZ*QlV)+bDe&M6mB5$7L=8JM>3^jb+!C#as4s0 zT%))GV)_h1j+w7*k2Y(d+HZ=}RD@)<=5ZK5CYyVX8%#;k+W4hPU#9CBwDg{I2ui#P zPBi)9;Cwx&cAg(JX}cq8We^WaYCZMt4{4y;z72e2r&zeQ?CsL>uD|^thYQ30Xp0+7 zl{(A`MnSPxM`ZVKTX#=mCP1lh3W&C{95HN&k2Q6cK5||OHP;BEXkgM2rrrX&YQm(@ zlDFZnq@S9;Ms`RTz*5?XNMkuPKe2-$NOB^0$I1ypo)nstg9v2gB=WGHdva#8uAYIhUalp{6d z0BgP5a&k(um_9#fPMRfk>KR!NtUw8nhrR*fCAl} zP}oc1V|I$YEd=#KIlE{phlgmT58z9<7jpaLn&>fedxebxw621@jW7?kl}BwnVz3r! zi>$)5Dgr+o9p7i_ve&SQoeAZUbVN@RDuJMSjctsVBq8-w!ZQa2&UOh-QK=f_&lpG& zpi#{)a$>o1^v9AfRsVBc_#29I%fL14%&xYV`mH5u7@!PvJP0)5%*5e)FBcD9OvzrT?7PXVk>fzP0 zlM(>D5#&61!C_J9<6R}nb#S4TWzz${ebTVw87y!lTJ7s9V{{5%@K679)Tx<;2Xz-q)bBxVMsv&8zM{~ndVKX?zD-a-51?X#6n?<+}WFE zg1JU9?qL8&yB?j#tXWs7Lv9_-r5~j+r)PqG3QeQVvjk*_sj_MyVXufL?VlkCcTMNToBuG}U5(P-hl>=L! z2?UZT05Pk{(O z(jl5YsX+_cN`W_f%^CWG2?RcApVqdcP5_L#gX}^{5;f_egH^Sa4x~jle(6{h zTQ^4V%INRI1uNx>yeaoBvYQVWA#&^_-r{gC-z5N}Kn$Ru1K!V^OtF7f8IpdJOY4ix z@Ce-K4f^+4_^Q~jx9rYSJ~1#FASaY3$g3pv%us*}92jEvo6&3&k}u3IS$2Klf^iz- ztsv9+gWo!vlh$a(C}Jzh$hG+Kd!y4n0)42itFG>lDBIzEThj6h57T!g9_?0~H`?Av zH}n&jiP>5=&s&coQBSXBEY90SPh!;cH_-$q51KhCY*jS@lnHt;>pZ+Y&rnOz5WLY& z(9#)dl?d)OXHXuYX}IA$@{i%a&{XN-(omEVk@JozeQT0M)~^ewR78EK;`oo6|L7?D zN*^L~+rzq{^g^d{I6d|OGWz}8daVKZ&X_(a{pz4qMUK2yhH0!e57_`^soXTf{(+$Y z3OLYAwJ!p)azal69mIXVf!x=SqEg<{UrcY{7vPNiMjfm^t;MXyC0%|}TNfZc03N4= z!6jP;n-Z`SLmSEXJ(3Rb^RDaCn?b4%izVM(_<1TCMv7@nTPAY^_nU^cTy*$iewqS|dMADEpHF-VW++mQ7AyJr!pWbBkZQh{D1Z|>iKL{hg|b?g z#~9QTKZLTEYjb+IswxQ5R97Ym;U@P@@gx4I!7kw7PIB{Vgi)n8(+t+r~ zwh53mLr}IvINN*U+yDUcdshb|kTW8 zGk@BaOxsWy>q$MY2ZUoM4U>rFM{c0rv;~;rhG9Al&Zmq;O&o0F0fH+D+@rxvbVI?-GRs8zm|1uHMAjj=69nDT3_+8H=e;inF# znAHXxIW!Um)Hia#50XK~)a|K9KbxDsg$+ZIiP8k?2%mYiv?VR`!ri**TRL}GMnyP< z@701KyBg_(i&mP2)?W4FWAYI<600bg0H#VrNw)2xP+gz$FO*tQXn2z=V~{h$o8Xgb z1VZX++4#7&Q2Z)H1}rO;;-wqesD!as)WrehB0q64fzYEN0DLM;H9G+0R+8F=BzN~h z&GCP@O5HH)gg()R+M@VxJcttN$VDMav+y6np9>~ud~calOVPK~EL9ZH5+Y3`p@t~E zmc)inVpYju9R2AV7->*i{-9Z%gp)d5m<>HSO0^boghc19T3g$F*<}TT`kSo_wae3! zg?hV7>ZamD=jSWY{OH6Kmn2s)?7uNP3EJBh-%w0_kV^u|KX6`4wErH+<>sH|gLkJe zRoXu!&^D-!g99827g4v-j+}9bihFcTUwBignFJ%(Sd-K=5OlsVMq>rhw2O`7Q>PxdXc!L;GDr(cglWoiphTG1_x#o z0z$}G^m0imHqnN#XUney@-Sy&g3U=1&${Aw_1nuO327M1x)5{id_$_v0sfHY1EO3Q zsFL_eNtfS<`!K`Go-+*?X&8k?ivBvao^io@(P!qnfRX#nv{SW4avV$?9`Kl!TQnzZ zs9F@;?=zx7>_DV(%UAw>az>(Y4{{k^`6f@CyM;D!&3Q~PNQdrU+61x`I3$76cl2tH z(cvum{J%^}#OK)yYH*sgowa&_EnFmwSW^(=awR_hVncx#)Kle$1=9#{YwvbT_dg%*J!ulnqza|)^mgaNpvSfxP=rCeAzyXQdBDk@n7F;imM z;3{aK8k_F$*@v2XSOiTtsC+&0LPJSVlvHeaVm+kQ^aJIrz_IXKMcLOPalgs*vP0uJ zYe<|e4#eoEp#b@bRYHQ92tb-bq4N|~B+bZ_rB#{FGt_WC#k`9+i|6>650e=fkE zWc)PhXaC|Vwabg+&;Fg4-<0_|C(3A~uw$b>R%Xcj?vYSPZ{;r2v^3wr%c-$7`ju6 z3y+dDxCn-_3!BV-s9)h{q8~#S(PwbkTEP9En4#3T>_7;Vk><;?N>`vs4$@LLGzCkS z^S?0;p5pk^0e-+0(ow>OKCm+uX8vbPywNcM78UnE`vyOjAiKjf4w(ujL7LwT-vLnU zOagfPONd^>msyzQ^05~+_PIfuG}Yc9gsSs4`HPu;*Y4?DUgeR*RpZ`@piJ+AF0fUP zdPz|J3wiX)M1bVI3Nzr#z8zf}gE*;TA@zP1eLjXDgV8Y$buD~xjkN5fY~v*0Bo?D` z^1Nb%S+viQ5wm0uh~Ah(?@*@rV~m938;3oX78wVIi8v+HID0c{75Rrr!GJO}#!^IN zFbp86h8bT*q7KwRt(TswrY-3T`aJJe~NX`bz=+qh((45>=5DBR<&- z@))`4@LDnj!a_BT%r`L@8EVVQ(soWH%_OV()ck+UVqjIGM%*WeE?WK|*YztJoD_~8 zm;Va<&v-7tlnSa307J~U1GL|ZIPVyxl`;nh8e5dk9GJKlzGRV%nnynPBla)v-UTRXnHs97nGuYl#rE^;AT&JD-~SHC{d zu9t56`C=hs)7BVIecXdpmB#{d>*Xw681a{Np6Bn5yTAtOE^9bw`tK*{jC~41g8SHN z@gKQHLduQ`!3;%pV7iaclakeQl?kf0(k4*|B~Im?;vrk=I0OK3bZB)p*MGLfuvN8T zVWl(hF{cRlzM*#a&olT0h3?^X(Hq}$bIVI}FY=NWgxf81jc@^S-p$jlkU^3H6$q)! zI=k+m3J)cHd>Qamzf|2T`frIB%92u~Ualb|rE9t41wQ|}22f(T4`z>fYuEdFTzZ2+Q z#|3TtXZU@gB}K%evS24{tRJ^L)ixUb-Uqbuk4VF4P{~SaeUq7}P3BNOGAHuTB5Go& zUiUsdp^fL^|3lcn-#Y5xKEdex>A{$3jl-39z50g1tAU{L&SMw<%!f*f%nj;X6lQW_ zhvbV~Uf$Yph!K-?p$BS88VjAV=@TY+?>%n)z??u#-$WL7IlxDuDp92vm9=QvH)#nq z*CuhfCIlQe;tH$!PILdqHtfZR5bZDk;=3TU;PEMm7Sb=6pZpfw%B#8m$B|HRlnN-Y z9Ee!P-ngmUJ3fiN&e~5Q*&GL7$w=TF%A+1`;-E}yBTF$9Q(#X%D0|HvK6`{GLo5(U zD7s^3a0$L~mV}KxQ^T$Nmyh(=(P*)g5o3W|s&gmmhyY?)f4A$J1ZxaVaLbw1tvvEqDkV^I6wD7Ghrtwl zF4VlIL=;1eG#ZTiCq#0t9OAKx%W8H!3?g>X;z$p3oxiZ%Viq3?H@E-gt3Ci@Ii}3# zRx}i(In-Y4SEj$)_b7wndCqbe`J5j!)5@K`6(t(6@+W5@UTEeBsDQ#vXIXF zQ1z>>dJfIE`I^9c2L9=7sV)nau^8}iiB>fe_9S52lZ}PoXREU+X1w4kv_ukxzl0|A z`{M1*te`f8GRejcjW!k(=t4)F;^I-Pk+xqFc2`XRQlwYwEGqq_EoOcA=;a&`$>Af| zHXaG=hb#KF#zD7vUe^G0MG;#GubN?82)Q?=k^-NNbA6Hh2REc+kVr`wdyN+d?cstuIF(xj=vn!K7sMq&5}7R7lU+*% z|CteKR9JloimMuCWRZWFm}`^7b8IUo9xPgD`zo2AvbJw5>rWd~>Zw2Q-HF=BM^()u zy@0fGg~*UZi%CgObfZO$Sw#k&U9`-RQJxR#lvaI}B5lZ|&_7JBB{P8%Cp7w{+$%gi?Kh) z>I~%AHikNBf9tHk%9(zB(CeVRQ0xrXK=ehyFi1;u_pM%a@>!sd#adSTJbx!=q2^V> zsZ)n;%GPA~az>qiTA)7}rr)ncRfGsIjp%;V2{wB1d!M8zI`HsZr{7`S+X0xSynK~_ z>rJH1;}d5bMX#SyoecmBZj%cps13NU(EmT)qBq5MjCR=ezI%2K{41b&Dp1df_#dNP z;GT#@E8^r!X{c0l>^152H*-`)B)exmLZC@Z0+Cj;hlan-vIwIUs)EV~OX?0Sp%e3~ zTb9u0(3`~KeeZX;Mz3S9omq^Z!wrY|=fal-sR2PNz4Z#q3=7dfB{~-BRTaW1HH_2Y zU*_fxp8^l{xwAXHeqa#<=;s@h%Jk|jnmF8;YL3Jf(B=05L0}ugWoI}ZntWaq59)*3 zV+@9F2@gYxaAS$r3yEwfk6r*MGHZ76e@2BsDX|J+s+n>`{ypBf$gj3Ta98AiG$D-ak8nkE_A}56-QK(z(r=gS|%S4`x%f*Lah*-#f6f!rr=Jl`i6fIUL zB(jEFPmZcpJxDMOIm)TEzl%2Vte2@}x%lG~@09w5gMcboh&=|4UR2Q)4V#bl<6FF; z4}r|W13M1GcfAw8u&>Hu0n4x!fzas)ja!&CZ8E!-Y9H8hDnE+d%HdmCCM5?o5(BK$ zWZOQJ{vnfuCCB{`j~*J1s*8z%Kq@j0(XA#%u-vBtxHAnyTk>?y3fXcXdC96Ia0==tB`-4pkb`RT)R8XIvO!ckBAa$jqM>Prq9 z%SXLio1_F2EeOgoO}CJnDb#z_9tC!wCPo$A9En$i=j2Zf#-Qa!b-0^!SSSG1o>K|5 z3csX731BV8%hx(w{RI6;E~kZGZ*OUS#~rnE{uwyKkPgM$RSyuXhS7~m(gT+ zwe;@Jb zOX@~-YztUSnHH*_M&&RBo)4vO2=hBox-CiS5PB)gesyExk>z0kByV zff&9w(Q?$z7Rfua{~RL)QklvpWln<~NSb8hcOE<^UP?tb zf=0-eP%QWLK~o9DzMnS`b@fo}r~Z@z=f^CZ|H|InpCka_rxy)@=BZ`_`TU4Y;w^JR zdI+0Q(D{b=iAZ*UF)GZ}e~?$kzwQ{P*|15$;dcljG6Dfb$Buh*|2fDTr-+IZl6_?^ z=m=}cY}!9|cd*9T{6N-KP-IOr)AuksYwopb;Q-4Tcqywu0W{SM`YeOlnWZqx zm4RjW;J)zX(zgf9z>0_-;$%?dOs^Sgd;yMN)GXfXQ0ezuK-qIz_guY1Aefj zchnjqnmwLtXLrMAjYk2h^x>k)1X_Vvp`k&cb^`CKRKT3Df-plSw(^9(A&VB?+AEEVBIp z8vX!9$!V>ZVYS|FrFYF@x?xFfFM}VMW5EToUeBPh2}YL`?m(IEMv)?g3q|iagzwv( zJ#<@8bK3MGj^Y><2%deN#h0?oi!#HPu^|J)MgYdMNM?M^^mr|pNiqA2io!MI%VD9Y ztSmkNiYqb_W#pjOC_QODMK04G zOd+=c#(coRkYj()T%g3sf$J?_*a&oCU!86~KKzAjy0~zI?(F-CR-3p( z`t=+_LV*YK8i~TUKNh!?<_+}>jrAHj6e9wfk_hneNr?$Jn_h1(g}fhsqgrwr z+3KnjkjwJ{_`@Tu(Lk!iRznHI`r7V7^)g))Lm)f907WQxW(Tx@ea{$e@Ww==uQfXZ z9v$LRH`M9pSds7$r!aP{40UzfgHT|# zkdA7-v?a9VsfDpOKXkV>tC}=)ILvNMU-Uli-a&r?4^XCZLDUq=Y$M2bI`_Zg@ z-AnCH2iF>fuUnO5f!7lK+v5kfK?9i?=NV3g!o3ki=?|PE<`kJt+D8e~xKrzUPbZ@! zpQZL2_7Ps-go17)m~7ng;y}ar8ZZ7Ancvj|W%VKTkz;_la5M>JthIJh_3rIoicwwe zk0g{eAU6#>S^{T%BZ*)9Ta1$dhT$_sUy>|axBbD}0pOh7q+Pqj!5Vj44gp7th=(9MdJ4 z`yP+T_mn?rXvl>=rA?Ba!j;sVy5u24OX8+?X5z{7>iddmUPm3oQ|E5+ph5Ol{L@GJ zd9!vtqel)8UZ<4j$^cPF0TzTDMh=9IHET-pB!&l|7d7+q_@5z%mt?WrA0Xl$>%kj9 zp-EyO+G=a}{Vo^6tC-t*xy^h?bOc_;9s53^OcamTk^gvL_b_VA@+{!@dHM-Y#(#ZX zs&cu>$0LFRTRc+dRjOvyqwk_)VAgrSgGy3YDDr%*)Bn}%y!|S0v)j3EA7?#o$@XRL zH_;&TlC)49MW}hO%kv_O0qrPXxS4;zwvwW*;k-g`yRM$G z@q~)<-1|A?%1>zH`zll4T@>58r>AIPluOlqhLr%XP{92fp6Fv?;p=pz%EynP09K#Q zt*cJs7CrdZU)@(E&;#Zf z`9rciU;rG%hjFrn%wg$;$pJViC8=kS)zZ810LZGKGFD8FWP-9pORcY{>Hat-evfZ< zIU_4FBf~CF{=vp-$`=4BH*Sy?h;g-<_W*&tmit);O!aZN#! zU==rYkmfm#kBNSB^5f+N#3H$`o|@#J`E&CA=l>ebh&&=~C}Ru26qw@N9|C)t^;6E2 z848%5`mW%ESRpM+oaAG5U--FvkFJXc%|%m7xh^Yqox}66ik#N-BxA!PlJ4B5kRAfj zgngcIW(pRfY1yne2_ok#|5Sy<0V*nInVF|sSsbc50{qNw*xvWpe)YYQE*zQBZ#Weq zk%9-jF42AP;R^QGGOS=AvxB>jR}c{@*m^wh?zh?%3clDrG;jERP2fLjK>@P780_wU z@$Y|7q)Q;acI-%-b0#I~@-TmUS$G&JXtvTbyuKai3h)Jp=ZSbay4kzAnK2ZyDJy>@ z74y3NDYo{!Z`5Kk$E$Hq#(FIz;g{9_c5m7C;z8*yf{`$@bc_os zHf-@#hvG=su^@mv1pSLvZQ<5?7E(~{sE&4xlNfSX5>}*X#Bc<;GBMC_SiMQNz77oW)PzO@bG6jOT5ai^Lf z0M8KiDS#yCYwakz^lf0{r3UWUL4^eC=84OpNqc4 zzt1kBe;(k!$Jdjc&LpD!Hg58NeR;?g*@Q#KZ82gz(427206x`lV&GnB@MC6hucqB6R^~D5apu(-L9Z4)tS~la z=GHf_PEF`9*;abfc5Q?sQfK&yIR;FnaJs z!#eTi?Hci3!3GTfjD{ssL=ylX3*j|Kx)L z@h`=qo(}978~Y9r(1b%8bSlxT>r8){G{5{(_$IRoET`MiEwdo*%2b$yk5h?i%F$<1 zlO_w13sJ(GuT;PcszCCaEJ0zcfJBeN?=YH+TPl0XXeMzis939e>IgTucmL1D&*d^4>Znvd<6ra`OduCmu;jK#qVvr=vFd$i(xAb^U^4N4Av( zFeD5Na3YrwA9w7(6f(B9_q%zz(dcH|)pwX}GhMsuv5|Dn9zhkm8OD0tl@FS6uPzvt%Wo+UP|aiy4a#@EO7>%W)u>;AetREr24yz8XYuUohMb8OSc-Ov8k zd*x$S9PZ@m7ZhaG`VP11zC7^z87QDlM@~JuOrC=*jUoqDE*8}~)ewfk$BthvvxKdZ z7llTM5tbV#ugSGZk=I;gDK>_N>r8_`P5Wh$LfDx8Q~Z(5>~^4GQe1|F}#Bh3&r zO*?_4)NT1aW{Z%sRhCmi@NaB9GWPAK+BOAJIVS?7SNjCmB~J8RQcmhnjyEt|zzs6- zFrYL54Nz>3e>%V_9m6xoTwB?}_hzT!T0DrIM&E2FAq>G|&nBR-`&^FNy{LqQm~(x>zR%YUj-Ol^Pl7JM(T*Jjw%!RM{cc1QtG={hld3 zhAcef)9?HH3@5BkWQTTKnD19zJDpL~wtBK;zE4wR&#USBE4RWFVdgH*&-dzZYwGiL zeA4w3y**1}swZp@$Pf3phO5nH<@zSN4Xuh1Bk9B@#?H~~z;Ag&lLCqEH;?c4b1a{N z-`@HK`1yal9*=Wp}n+X1qM9lYO8D+Ia z#+D@8f8hB0QSv!Sl!!_2!GVRUO);ldtRdY`ugbHtZzZQdfsrK&-ay$c(XJ(Ab#Pft z3(VxLk3tXKfR8#4C&&}PK3;O0c|PR1m0_faIUSJhkB=qHGXb)w=WmkkDY}z4<9$7~ z4CpPh=uBV$b>4S*-ms!zg(*v1@#jz;V>D%=f$%r7tNyCnjahwF*#a#MJW>6h>${7IvkaD=T@4)8=tE z4x}iEq9nr>;fDfHO)~~cw8zI?@Cv&~cVh2yqN|}lb>?N>7;11!hIrL3*slnf*B|l` zp}>8Upl}>T-=L6%r5;^?9t}sYm!U$2*>tU-waLI^+Q*XWS^2%`X_;|e_uSLQ>~H0J zI-B_Zx)m*z9njalW#2|vx4x<0fbfZES=UiB^y1^&TX#pC3No4_{t4OT`Hao*#l=N* z3)hrWUUmB;3FKe|mx;r7W(?Fvv_|efo%WYYg>M(>I!3Rv-n~QZ`E8O$U`JQGAp2X` zj-JRbl}EL)ZNEo4f#`Z4FW#&zU37LLK zI%AxuL6v8096mTuxhKS)ANRU4J#rY#G77}$?X_4*&<>0>J^A4^Hkg(!?hA$7g(}l7 zO#3@s7dtAZw?ZrGgKLcQ=L?m?D3yT_-LWNC;$PP&0p!;D~uWx|tTf1&O-Hz`& z|NC=>#j%c+>Uh@v4;|6hQ>;prLxn@@Qs`m-m&t55mkPpZ9hGSoKK?JBZzA1%alc5& zvhmM$`RDi7Et`s#&+nUV9gURYP&57E>*!EPg#5dV>zP;1i09kueufsOmmg-IW;Zo8 zX$d8aLyPI^=2307Z9Ce1nw~v4cW3G?UvOhZk~~oK{o+J~fRW4rqD5z(l9`eew?r1_ zHy)j`?omqMIO1>E?`$|eg!=kpiYYc*Y4lRK>1H7}=xN>pEsx&|Zm?zrm+`@?cAXwK zHe;j)=Z6_MjwcsIS#>;9zi-!ZIcG+j`&1B5cwZo1rgz2OhSPVfDf2j!0T?-1`uWf- z(o5*t!L!iSS|tDdy#$s)KM{aTC8BEOQ$!q0rD5?Prx44OSO$spI*VEUkEOFm*= z{?JGc4bq_?9n#$>lF}{RF-UiJiIg;m2+}>ofYLE^4<$9ELw7uL|NhT=IOqEAz4lt4 z1uvqZ_do&7D$X%G2omc>%??Dp3HC8mWP8W4U@yhi+J~|*F(X=MIg0a?h9q@ z>8m|ZPwmu3&A=ce;G-NC0APGOrN$9p$uIUc!0z)13%sa=4RqgUaxvXniV>(iPBX(^ z8-P8Kk*a%GLSiijC^@t+x|n-DeHA{(oy8uL@})sJ%E7wWinkYqU!>3an8YhhB~uUm zeNxtc6RHPMNpF))<;9P=4_PDf3S81Zey@yT9Ch{@fKw0OT(=icAiwTG%Yt0A=-|oMj5M(DRA08X z4aP8f9N{g)Shhuw%d1={;RRt8zd1<>(9-{@@9&ldrmT!CgNd=GfISvh2L^T+g5=AF z1Yp;iVdJ>0?ESA*(r^_1-$6>&Sl+|YmgChi!IYqcVz@zUtOXkOghNX)U9`>USwYM$ zt&4N!x9{4kWQvpNGC418tjEiB$}~LG831q-$R9Sf+%)2h7l7{wGc6HoAektdntCZU zFXX<2P%QYb?$@G(gTo1^UcXzq-r2u48Tgku>q7$E&{N%aZyT)iZNHanVIg~^p$w5m zGtt}nS^{>oUI{YNRZd;GtdAeC_>^kGVsO!zM83G zE2TRHvqwj~b?eKC4!(eRH&GxTb|BAI&9_(uKxRL%I>UcwO}RkA3pAkkvuQa=2m$rW zjX4+1ZElkkZRKt5oF+))D>oUt%xEAf4QjDcyvs}ia5_hGfoKPasZv|4Fl2qN%cZ5f z+@EC-H`5oN=Sj@w%?!So8#(Nnmg@{bIcqO68xP;F?RrHV4$fba`5OrJ;`;UB>Fib) zK_kjSaZ~<$vAfQSS1mgORS&e8pMh4cQ)omlPD)dpS0w6fEdS^z148-cU4<#&cH!Q+ zoUJV)0oKo@CAv_%RqbMxzVg?0KYqX+IaFyDWe_I?lH$rr%3OoFg`!;z+E@df&X_sE zovxJGlETvBZ>0#vTy9Z31;$WOs-V3)j%6n7U{e$fgVfo2!Nw z@l?{WX=mGL#$EVJFv}5o_rq%oW5LWQTh#k(`v*R?m@~G$A%~}3xrT(HkXC!@s5!s!f2bMDR(Yoq<-mr+47Ok>#TBu)vGi8z{m0wM+SlGSxOlFL2oTAVnwkKTGIy^Nw##ZT3;BJ>g3tM(B0=t`{9gLo#LU z@6_-PVOcUg-g#lYgoA1?=elJgD21)De(}!h33Vag>ebhorJN}XrxmAiL{?=cl-hw3 zzaA>VQ7jrN`ktSiquBIjUcs=eMmlRUbCst`IXs`WtrpJdHEB4$_lYXz*o3xUo-L;x zd6EXBGpbNOcc`;B{F~DKKXJBbs<`KHXH{U@$LSKTTHeYfA-bAv?tm8R8UbLlMxuHB z1}!(21V9O&x~PKp(qq6yh>*F=M5mx%R^-V^&3ZS#HRwh}=f%yzOD%lc6VF3P(Jirf zl`J_0Z> zy`2CRAPf&9Jjz0L5hrLNj*wLMjd;WJU-^4F>X(IVs0yd8>CNBM@($CSu#WXsk}Ds|uo%|Fn*&AG+FlwvZ;JDTbQM9G4`iTq)JC&&Qdkx1}it$;LEB`rp2BXGN&zI8IHme)JtR-oSLa|Rax&a^80+|~ z)!sFBUtN2WJ%tbeu|0ubzMsNza6Z?d;vJ8#u}BK+9(nwHfcu-ZM%ub6h-rp&%i2{_ zXFNgAC!0Ad&THpi^y;`t84VdMqUgS#m^_F>2QtY6kgy01?W;u<4nW2g{UBW#NP}hm zv+@6TK2bWp{-GjEmRLWeUt4(bqOt4U{e(uqZ?Ec<6~8k=X>T^XtwkY>hgbS3fqNll z=^Tc^Y`GP|gL+GG-Y=BI>nOSB!VB-u2iuBTy8|1a(!Q6kzNk7SqMY)!Dt@UDdT7x2 z@v_kGIcUq+-#n;yZy=2cE=$uK(#~8U9|}(WdtZFG*g~e^Mc!or!27xCd=#>Mh}WC; z8)Dn2Tao7?>`0)^m`;r>|fKaK_;LCMOUReyOvy0 zP>A?}iE>PnYXP=~%}DFqgTeX5aE8BXmATzD;i42g?|NHut!|gS`S0|kCb`fB*~{ks zoc_(F&`1q4bDqeZi#SUIVGjVAc4US>e*xjLvy`p^0;i|yhtU(vho)_sN~@n z!|7m4zuvtQgZ)YnI4k=j-MYxz&w(0XK`~A4ZHe8sG86CK=u;EEk2w3+{OEk&Kl2FL}12 zJ#ex;Az7zd#114-C>P404Niq#;#n34x_gw--N;UI`d?k@n+c1FO|1)rZMCmVsBmx5hY%BmnHJW<4t2G2`fE%thrXs{bx<-vDA`l{Qd=9YffAjQ`!hO$B0&!=IfhB32xt% zAwW7QKY`?%+ky&0Y-{(CpI*t1XeiMN&qsf5h7IJ8c!h&kfxN{L%=~Zl7|<17Q`6i6 zwy)X#CA=;NabPM8QGOXK6DCwYpm=*V?UZkXNtfuiByRcQ{Wx#;;LqH5{$Z82^q0z} z!1%XUeM@{kUn$>PESOt~OG2ZUS<>=&gyZ6o?jSx)tv6Q87R;(oqga`mFR(t4kyuxp zO8w^v;8v;C`$=tK@KESc^sxo)MJis9Yn6U|OvZ=C%eZU@S(;JFo zM+ zZ=kT!qMn$jVk1Y6VtV)LdS+;R>XGnj|t-MYYWD)xY8y=Ft>HwilIipTa>ml4@S5lGGX7rV)07ft^3W?$YWZ z4R){s_@Y^+-&&wR;K}cx?RN}L{hj+c-(mX_xTA2tGWwsK=^^pfwI8>i@mm<;ToKGv1@C>eLAI>bEwsXtCD4$h5pw5eZ#&73`g zmzL6|@N*Ke`qRY`=mF00xlI5K5 z3YWT?WacVd?<_{jB2&dAxB@BkqGnz(@CKWUTvgR`+=PreTGyqf{g$j%K0#?!Sa!VC^5UqRoS>H6d$RjtZP===y=M^T&)(dW8*s75kAg9@xW3EDOx0`4Ve%tk+Dm z?L3gWl!@I7B;v9K*Ql`okbpk9f{8_&l%&gyd(1nz0VMT%eKIuNGoj!Zm4DFij9P1%@n?NxkX)?`P~9sMKAY#$!nL6}3Z<(< zKpod&$VuPW(HP<5V#?z#Pj_wk*MNPfFjG%T=N1>C)Y)R+-4y(Y{6UFG+`efkBlevi z?@Q;}ThkA>f$^RZNEm)SA07ZeCC|t@^u;X_u@u*fG zESqnC2i5{{`G=vVISQK9qD6(0hpmO}PC9P&K^XP!7qrWu3tC@9QHYBVjrvP$#91yH z1-u7-GKYOw8O-h($sd{LQxC`e#1ZXwW;ZJ*$*$zuzMFw&S}3S}(4DB#*Jp->&0Eks zj+d4F7tKqJNJY)qqKXQMBl?;S>larDvib1JoRz35`sXDcl_rZdP@YAUF&qGhW{Awo zmugs}euXO&>-odw|IdNMwBNqmEn$u}e14LEn=GHjO|m@896e0=E3nzR$jrW)RKd3P zz4>q~{&byXcDH$X7z28KDpb}x_yl1+FD`^!skOSW^n}h#Jlt;4Km8hP2+9du>hMCG zkg)2@^yJgGb>;bVBeJ?PFRSE3o|hYOlUkn#f*wUXYIy>XzwNRdZsbkGwb^q4Vp&~* zYT+q&g7)!g9U|={kYzCpe_qe^d06-P=iy=tyBSLB7k(U(j2YLN&k#sYsD{;2-X&a{ zN%Lt-@O^xSm_q^%+rx7|`ACaAKDxDi9*jP73i-P#JVh zYhYk#9~w&7jZl=niRYSn<^L>w9I{?0VZ!ORXXJzIAbLLC$=R8Y3-Pb!%6Luy$i@Dg zCIO`2m7(^a{;V0ZmN=p{!0J(rh?voUw)oi#zwDeE3uv*^(x}%wk-!3ow$h;dDfp#Z zmDrn&!DM1&cy*SOc3NMLpZmK1^meJj%j&RHGYnX7G6pni&HzVX8F1Tm8Px9J zg5|QgN-)YmD4be&0WtpkuCKS2vRtU`F{zEJ-maPN(GEAr-Bx^T^kPpo1Knmx`&^$| zDc?DB!V}0s{Oo5279f{gda-ubk{%BQ0oDCEyAOC5gy;~2Sn>u9k)KL2(3qziwCYZe z<0^oeEWc9GgxrwK#zmh-hpX8nf+*vH~AM z<;FDa+?l?FMF&v8o*)&XO)Dlp>O(*+dvzQZJ69^Z%i8(wm7+y)W=Kvb;Q#Zza(8qS@EjfJS_ z#*#XfmtsGeu;KLajDUft)5J3!Ev^TV=q3+v~lXCvb& zbFvDbfU(H)zz-Ds5s__(ip2>!i+)me(rb2>#bYx5SN9&!w2_Id&SS! zDfj07mqW=(l7zG-$lF*aB9kNf83FUoI{QZ-bhJ?$v{8&-+D{({osW8dsMZaduq=CY z>;62m29>1mI-CuVzBx$=+W0*Ic^1n36tdKTcp4Ule1a*59KI44`pSvRHQ=nupPguR zEZyY3a|M}rzN*Gj*A3o9GY?shp@TTUd&c7mLY_9`t{;bc7fkrh!hFo%h&=o;m-Igw znjW(MyUF}Qf%5d0t(%fkYs1Le&ksPr(5da=>hZ=o1`qpz!Fkq~!zA0SB^PQbH5jVv zc=;aYb_>yU0vD_j{D~@a{88Si<3uk80u>q93myb_*b*-f&`B^9d=)+Fa>$7&fndaPccMG!+eQYB#C4O6DfJi%C;(vJp!Nvk|ra zYifRX6lDI0aB_qA1@^vjt@(OsGGU2#1e|QC9@5ScIRs?BQi{gC^e%N{Tc1I2kK+!4 zV8P;eXqe%+W6a$y6MbqOLT{ISFOH51pt-N#gDWa#u+*Wm^1&-^V!28S_}g$eIlV-n zB~f1M%9A!kB;cSguB%#V^T(8l-r1NfzLtVGnsl}evY{T8#&0X{6}73@N+sA zWt?}9@&Y~ut|DV3J^Ik%@bIvJ-GqeS&7!i@Qw9C)diTlZVeJdQX?G7u5u4UofZdm0 zZF`cGZ=W92iN0+9!9tBRc{if~&}t?UO%bW^KO;>TE85FiF?(Ml1L$L8_jrwW2wv8s zuted{&OPk`P6aT~O(gpybGlm<@+ za6V1#WLecU>XDM@^I3T5>$(?1@>f||A$~uzx4-mH(?t@@mT|^Z>j(T~RO%c)!t_xJ z6y)Uj-PR4*17y!W1F)G?CR6Nj_+(<;bV>uSa3cJIodnxUiUg!)Nv`ddp-`Koyb_}zLQ=s<<~~hDfdA=rQ8W zDe(q7tkZYFD=pW$jz`jFJd>WUvr-V^EuB_Y6SYsPBuiC~W$PWxM(1v80lkt~5`8EjvW6#OELW>xG>c%f}IblaRH}0Pp_it`NyOO$WZnsP_R2WXvJg=fWZ_7y#CC zr`-uYH21kK|p}i=`Uf# z?b8JJ+@st6yC-r6|GO7Oz@GR%6xrhbp@w67KfgL2bQ8lGCI-xEy=F%NL{Azs>-aeS z1)*W&rQYG#^t6Fn@aRqKPF-q3)Xwscskn;UgSZMTx9-!IgI>nfnOlru3m!+n-xJGd zkCJ8;G$O8&KpL)?r6fHL)f~%ll*)V&FWdW`IfDE} z^jra#vMrt^@0UpI>;{BfmfBtPkx*dUQr^jrd#kI`BD@k1k1C%?5v<#0CW}4XpG`k` zl;={V8w@I%A3yw3gBDHWyf-7K{KYjscZe^%zGZXP#cY+JstWbac4$q=%%a_^_(12N zyW0?@&0&FiZnqp9@YKI?tJ=^tZX)FX@iln#Mhu zB-zrmiP(3Nk}vPZ&wNw)Z%HR8(TglQly>IXtn#pP@{-rsU_Z9)Uvn*v_`4kpPQ7%@ zRY_Nd(p@HAD=oJz*DpUh;?Jduo*ShE+eW~2bE6 z2b^|ZSTEuV3M{C-)+`^AsF6zbFyXzT%=O+5en*j-_3BVF%8&u#_*0nAhuZw(wB)PC z)AnX1fo=7%#$r7yN({NwVy5$k2|RRGrddIh?VS`?JoMfN^xn;*NeEj1^}43LNt&HKIjqfN zL56e&%iP&43oBHK!`*pTlnq@c_pe-;*U9Y2oN!|G;VAJ71)2lshob|xx4r-5Z^9P& zjS>|IiQgKWgfgW|)8fc zxCdPRuz>_Z8k4p*YbT9ZPUN*5j{ezvj6f^rw;-0S(J}ZkY1HjjVV7WEki)0;WN^=b8|VP2Al^Y9qu z>?{W?E*|}hM@(O;ZlmF4%j@xcaUau+`Zqs$qN;!AL>(N-dCd$=J*^Q_6w{pSPxoVE z6JU^&Y)6F5C3s8U4W$WO)4FpJ8V>pK&Zc+RAX=u1!;D}&JNb3mw-@=>Yd)ZMEza(% zLsx?kxcCC*8-pt z%?(wzuI6kWTY>Q`i2p>n)IIq5@*h1dHI=uC+u<%3q%xKW^mGO@Z}6Lm&d4~xdOqSY zzdq1^=r@196x2TwMO4udy}HyI2#7b7RM5~g4QJ7!(?}Yd(CGMR!lHQv%IM|_`Ld(G z!~mN9z)Hb({T2v7!^V#@9Jw{MS@|w%TtB`yMY%et@Qx{nyRYEM&dbTCYGoglfh;Q4 z*hyr{DgwN@&ORpFZVoDRH1qF$U&HDwzhu|^EK({V3Z=FeQ*Y=dJzTmQtyTYqhthXs zbbkp=^oLGw36uxv=6?~d=zif(Xe*u()@yP99W_Y-Fcga(j<-8>785HY=lxA)Xa_YG zNXd)o`-JnV?Ej*!*sJNR%5IlB-WCdTrAL}wkhH1mV@$X;>}Ebot@Ghq{WdG9&3u%S zkl9eF+#x6X%ApdO8440kVrdCAv(MK8Hlq^o6VeDIlQ99(payJ1mN!@Wu4@g&*`td| zslq#%#> z&$eL>r$?89LkKe*7s(BdICx(pJ#uL=;fz~(kx{OJ(JBB^+_GXhjh!RX8~j1kQOi%1 zmh&89bUImwa*=raOJnbw&t{*L$;bam`#UWA@VONs*^ zy9sXOB&G1f=j0o;*#`#pj57b~>7TQeqe&)^?43n3VaKHw@&L+)DmWjEyvI4Zz(zDv zhV*IVZQD3byQ+)U&OS$i;aAX=1D_;8HutJM*_7F zg6%3LzHYpYP{LmDv2<{PGy(hs;RhPEl#jXp2{}3fS@lLkbL4(k&nC$1oc{e?z!dfOWb*FNb*P$ijI1%0 z>uV(7A{QF3|8kz}2wf{S`=m>WFp0VbIjzhEpoH=ru1nq3)B6uTXH%ME0rHBL&Rj-8 zvFvOaw{5!@LfWL>>(d4!jJ_o({=M#7gxs-#hEDYgLEov($HVBPJ9HJ z-F=-v^1y(ho~~@$Q#9V3w$b&h=wayms$%d|#y9VD(|u!?Eb`>ni%%lzzwFNbm`!Qh zoe(GfB5D&P7oah6Vk%vW1PUzEWLV?Hk13ZO#gaPB%?k3*`&=>kzxzfGJF|W*m6QMr zD~>H~QlLCOrXfR7NT2+8#%vRsT8w?Gj`f(x?$cg&z}y_uW@cuxGr5=d67=d@xz45a zg5{(16$=AoxnhjLa7_{{lsL>MQ0?RVOxxHrN$4@3j%VxnkTam^gD+1(+hfKz%zqqqr^9~bod zi%1Jo)jbr{$2r%O&)48(`PZ&CcYL+7y9z4P>PNQO`#y|a{L5P1+s18vuXs{a>&CEU zotxn})ohZ541o@qU^Bq|ByTxA7{lGc^UjPo1Iu~*x7v&LA9%*xULFxXcnf8%Hi1p@ zO|ZWOC-ZHr4BH%olCiu7>;HS%q>$Ql#2}{9fPkU$Nr7_>u!&lx(}3M5Q;mhPQDQ=^ z!0)27GYbQq^(T?2@8jM^`!TZkkw-Gv+p&EF4V9i!%&;jidOTCLrE2{j7nknAsOsO@ z?Y_OmJLpzfRYDDyvy;VVPV;|6Ne-ldA-~4us%F*R_VPZk`--l0fr`U~OQK6zIl6u<@dN6@+jxC}k2dA;JAqkdp1(aH$v-m22B6b^A zwXI<`6#JT*eJ~#{3M!z3IU5M0*rcWMLJ)t#oUVR%_fVE4wU$e8(vP(ufY0vLCBBuf z5`huIN=lTv-gru+g-|BAAwYZtru-n7rTI-6`=~IaR+4CcmBtZE3 zDywU$Y+igVgr4>0!L7T`k`hF8)>rET98liB(ZLRvQ5d+V4_R$NJVte_T|NJMeDpDQ zV15|rrV$DFu`K-co;-iejTXp7hkA{cz!(Ooi~HEPgxQ4h4%?NehI*f#ib~WI^d-R^ zJe~c`w9=?*+xBHMgYyfN#eq8F*`W37l|ZPQDSx{Id|cJB+V=M-SZG#Wy7G3zfX?bC z7Hi%|4Y&24SH?yuD*~N1xWuKJ){GY*QHHUUlsbm*b$`i5s&wqd=1zZ4Yx=#-tIErf zKEAlHa4|Qs?n@!56cab%T_UsC& zmz^bt^TyE5{3U5oLn1wox=vSj8pp;?Q6FFL|E!a?0``2Pm+!ZjleFpbXp9#|@8b6s zg+R8(*Xy1Oz1OR)R)>1gzt8@d)Q8Nzxe)%AWb4u|CpXFzns~U}l3tXCU66ad;yhzs zk84;?rE^6~rC^pv$r+u?9ECMA1_fU5ROWsVcr>^bv;!shW>*$rX|D?V&~qRLh!xl8p2T3^$(%jIe%zh9 z%0>alj=wS`4S3*hI<;_0b$TbTiUjItF}PMS2K~w}Jy3V{j7_|bPiMWW?u+X?@}5pC z+<*h63ua+#;Uz(cVF6hS1>O((8fW(@l@T6a!>qf+kV%7||7@8PCpJ@6n@WnI%Jcpx zp$zD}==>&643VVhNL;B(cUBr2<~|z7|L*9)seD7^ri$7h_V*v8t`x-s5%dxlYtn%# z5W>D!;`j7-lcT1&vf0@Nf*ap+&QiKu4_83vJkr58Ezh_81z+wD4wbG=I%-Nl`(S`1 zH;!nI61uFT4B^QxOn5EC;%P$4@1_v)*|Ba=Qp#|BHY;e#22G*KgXK9uI(V-3afvy1 z?V=)|WG7wVGOvH_{%REIts}*k{;>Ci-ji`$2xtSGV;KR%fKz=9wUAF23&G9^`ls_S z>b1vPi5|j`iR53`1hn~8rk{0wdsKN_V93sZxN=fBW8c~KMFGhhv^BupAvbYBte$3j z!Y?Dr{`^a#Uf4N1FSq?RivKxpvOgjLmvwiS9V)iC8GVQ}om_7fK3wQ#9fR*iOufQb zuzOmL5=CuE^6Vr#LVQhs(V=r0eapwARLLu+x~DMnetEk?B?h!lf!K!^FBUmdT-BNWwC0mYU#GDEDAh> zt5<^%to6sBTz@UA!@fNbbFZP1|7O>7ZfCiEAH%JpRR_l(5VY;pJ?$u*50ULQZG7~u z4_R$jZLxbF#HqjeiNQ!MED`M&=eS`>z197$cVo~+PicVptee)J(Rf9AIe{8?Z6T#q zqD^CzJ@NaXWuhss&P2&3O>yG(v6^%K3A`f%X+u+^Z#kWh@v8LF0u(pLb*l})xSFS({yF|?wrGYQ>n`v`ExAVfk7<( zL*<&6l$4fL>ALoBVSnfJkGlRH{QAS|+$!I75~5~1(KW@u zz4l}!eK~;e&j46ulrO5aYV#~##k z?$G^zMGWh_CFQM}UI?+9&Wc6lEgkE`cFEgyv&iB$q_QVBE*r~*$v9>2Hg9P#CMtbi z@XKvQ66gWo-t*#lx!zR)pS~<>q4%vh|FzzIjb-LJyn1ij>c)mx)`9E}K6k9{rd2+# z2_weD$l)c~jUi9f9hjq3gO;*v=u>K0gij+KwJTi}V_B@7koFbQ}%YVC?XZ7=C*<)UU_WbRRk4cMz2#<&& z$vJ7m6;HR*gh5EA=im0DF1NKM^Up5Pasc?|tAH5whXW{VK1+_9zvj`?ZSC;~>^0UR@KDAEvKPC&SYRlIm@^`l$shFVO8228-qfE5_V1h?1d;Uwe!4P znbn^AF&VM3Sn;v5wVNmF__V*-my|Epya#UB%mW2`$3Xhy5k+nBxiz zMPc4zFB!0F+W-CtTIk-ZsFmIT$nM3UL^Gxxqd%4`IpS5Vziq!CF{$_6-F{jE3qaL^ zgPUqaXY!<*o@a-xVhE%J+l!x%Rz}dUQgj37368`OoruZet=v{u%ED|0S>z4dd*d{S zRXxwQ|CgE2;yMD$Ls6J95)Ot5CD(HD%yN)H8c^}dotka5sQ!(@m~&$>L}LuY(U#Su zHyZ7Xphh+2nF!jt6+AeKf%?E`atdC}(K3;kD0y9)EcG9Y-HX42h2Ng~Le|v!|BRa7 z5)XQu-FDj4Cx3J^b`@<6E>63g0=bNuN~?5woo%Gq($;o@X07I037vo82!{H%v*qGQu=r1t) zvN8TIm%S+~$EUZY&d)zo!2#;h<9vd}c{7-K9Pe?|&>lbioWk+P8Fza;$pC1&-Nmw@ z2Q5IU0SwzF*f+D1<%qk3oYo-3RqIMU)&Gno+wuYnYjrhWeIkL0*PZ@)@(%uUd!fQgO z>b=ZAVV#%K}K28SOF4nNm9NXz^Ez2B%++6wi!)K6?k#I&2|*7zv!&6OL%%0UR6) zan@|>v3FJ57W+(kH8-d5Pr=fiC}NH~7H*r~gnAmz4O_qe^HZ9YzY_$E61?6ztn z<9##n3GYvC9 zZ3|a!f}Lp*9npbWh-L5I^W7BvEUvNCbx?4arsYnGqV(ZO1pheCq~Z{I(KxHgt9eR4pm6UPhFj@1R? z-Kp=Jlxm@luH4{^E=}6AL#toXE zyVZJw-3MU2!cL9V@x^3e+NW0;av%<4;*eq@ku&*x)s6I*Apx7cVoBUZLDv=@imwY$R^?m?$q&MTB?kD!`Q-DCVlkG>&2Bb4YuykzXL;8+(9n=4LCP5)2}?8evP+opR2|@mdJZVc5NMwR?asC4=#;GEb!At-;9srDLgHIj{Yk zerWo-qv6t@A({2ri=&J8l0(}R-=G&LWe2G(w~nL++gj;m1TFElQKWLJ4-c> zC@%0be<=6aF6iH*e)@AT#m1%DaAsMRqv$!yo>2GYkWo|*+5D)BWR)2Lh^$xum>!nR z084f^3I88_imNO{3nA7!KqSZm(Iyt%&RPaZbgZB%h%buVR*&dhvb35*M+`Qv57ahC z$tW`DytwjxW+O4$bopRZOZquANat{94|~z#ZNZoWE6Rm0>(i9_1?#sHuBZ6}lDSnu zn<8bZ4wc}kt@)R5{L5(5QIh+_WP3`SpC1Q=g}!E)xKdXCw$4^k>%>w}7^|x1fl@I% zQpqX&s?j$59}H33q(_BifK@SoS1LXWw^+5oJdt^}g&uO-5?8Ddw4nTaxTsISc@yS4 zJjX2k@MK&ITZUc9Bel7mhj8rhRM%DUhavOMGkJRPi=~(!fQ|pAsng^n9}~e%tj&@R zyx2XkYe&wf-|<%BL-C_?%V;Um!FzrCk607qP~ld$^+4!cK*H@9o}0xPZ{Q=3nayHxj`x9EdnX*O0sDjH zc5sb+>$&$F<`QD2eiz%H*BZ?moG!jRS-gLnlm6zC!0rs~K860V_MUDF8~-mi=fb!; z5)*awidslr=o^h{fvG)42A-h7JQw_2((w9h)Vphk76A;rbk`qJbOZaWPEL-Eks?9P z?zBx^Om2Er15A9z?gT?7!d3~XoSfwGqC8f{cvPrHp`07rHXv#t*W3wP{v9Img^!#W z0$>TIXj#o6vc?=5?|yy&6IafFQp*buWu>vX(bSGAWdkhQhEZ_U)_p*vpk((Vb`;j% z?fs~VD4bZmHID9<4I>KNHYdjrlb zk^FN@DQO^n^414`rXPdwL`4k+wOwZWzf#pg8JiBpi_8hPncv+LNnH|eJHDHKaE)C? zt+70P;hHA_-0PCJ%qPI7(sY=cI4e&}?A}dK-|QSwI*?af;JbbC+Kdh1uidRb3#;<2 zrH`9FCmd-hxR_Fa4W3WDBE7PfKn>L)KyhHA$6!cAk2PT2XSkv@&7@eY5~b zvk0}oeyVhddym2IFE3{%%Yr(Ps;Tgc0f)&DdHu#Ol~I11BYW5LK5a8k=IEwN1NoAK zu}=`M=Ue}XsOa}^@Gq0~`g#GGBm&88^K@Bm?~Y>XS3(%r;j2}4_b4g5HQl_j3f10i zcAiLJ(OKoSZXjTfqrJchi;=MZHqU2+TApPVw|3IG<%(Xc1<^D7u^c|mU8Qq?f+9xc zlv;hwc4-FPZUZL@B<+&v9uYI;{do;Akw+{gy9r+v-t7zC-92)anRg0X1|8UXh5C}7z-T~n$p-qFnZBPkpcb%+L@ z{XJ}~L}Sv3YyKA$TV}b3jBA1Zg^up275~q&)4tzcUu9Y`i4~8a%`(Ch&ho5!xM)?E z77VL=x@ruT=N^N6^vQngbtPt2h%2Od5;h}=r zGe^a@s*D^13@|cXvPO5#SCOW3;=Q4=lq?`oC{T{vJ5Gu?JQlql0Mxnc__b+XCli@U zLJ{UtAJ#z8r8|FpzS`!=)UYEiM7=)>poB`Tp?f$V}Cy; zm_y(_eRqGeAlu6n>kpd+PsN_*YK)OChOLmJGN0f}Wb87iiVP%M>=bv5wlsy>UXwd4 z-c?<+p*d``W8zn|;^enfihDjtK+C>;wgZJ3Nk*WR!pd1S@bI^X36UEDGwbVLIc9eS zh=83-;q^bgi(}LHOe5Bvsx781fmiOo=GgF)Fm$Kzz)5&0VTNOtDi-23TVg|$FSvbU zy|Kellb78|`oFNPh_wP0Oi<9a&F61-q!KJ(9H-x>r>)0Qg!}72icd#Q&8H(TryN z(NvDvLsF}b)sQ#86;=9h&OOT8tCIDCMmRRZ_XQbO7IPoxiuEr` z@ogzuH*%K?>b&lAl4P*S#tt80`u_*SKs&#bBOZ@Yxsdx&RP_BKQWA zlrK0CQ9hr4#mjD~*V|vd`$5-sa#>3RBm|pVtWM1=Y#bW|z{_^;`04-ifBxz3{^iau zeQ9NRDQq|Ud&}2ffBj2e^{P_2cd1hMGTutnUp}+aXu<1VKh!@o0c3zpQh=?XA5x8cUBx!*;|>SfLolgdH(-XNb-cvb$Wd1U<&b{C32G9h^#;pMQ-S6IWYRSsV%3o$y)~7%6^M@y&|N6JT`LPEd zeE#L<<^1BoYx|Er^uYD|?tA9NTc5ji{KDC-kKfXl@TPy{hyKW;PrT!m^RxYFdedX? z>Srg{4-Ou959`pu`Fy?u6ax1YV^@i)Ki%{TTRcZ1-ko>J!?y?k(n$h=c9K#c9XwaB-6fTcICB;lJ}^Kl9Na{V#v)6Tk2?zxMom z|EFHOqNneA48_doRRZ~v}mZ=e10v+HXwfBX-9@M~9k z{&OGw?6h}uc0RX*YyaY(`UfAqxl(EO#`C4uGhkYFDPSH=RDS_iJZ&Z=If=y>$2d&h6u?lRj{CaE?7R zy>oo$+Lgomt{F%m3x=w5UZn2BBDyXt<1Y7b{jhY@EUrd-_^sobvz1}0Jub$ z1nexZ)++@)&$>XT5*nB@DqV1S%@x z>G3kkAj0*-78m|=j;;(y^2Hj%myHvUy9}_YC>D~SZ5^KSOAqE3H?mT6^7%}JK!c17 zdRT2jWO$F^8ytnxXozU6^@N6_Dmv+I5*GOq2fpiM`yFUHM%qd;9wb^Ss*I zl5KcbcIEeGi5*FKJ89^ko6u;XvcHk__g(q2l=JiT^=nuDr9b^6k3Rh1U;A%<>X~O> z+&?&cKK5Hb@V(y#I7q#9`=#Ih%%_j8-+bF!-u&dVFaPBq`x}4g`+wkF?|eH} zs}FwLx4-QjPyDNY=AZheuYdo^*~J$GD!bxv(m(#KPkh4%{*C|q$G-J%{`DXI{PXz0 zqi_D}Kl9NmtJOz;^OG-5SD*Oi@A)y?}49(YKkou8j8%GE3F3(Tp$TCI+b zuC=r2;(XFQVDIYT)$8lymoV$yyJs&R&(B|+-#WTD*grU1_w&`=<7s;HgAcspeeeC+ zZ~oxv@$FZhed_r9^etcao!|No{Nba+!{2|GDSi>f(13;5Q(z*odQt5~MKVX=4$SY$ zs}=>s0y(9X^U}necDB5mgH}{K&;p_t$8~|+1m=l~^T&un*Z3?RN`FbRbBz}_OKq_{ ztn>lj`>m|M_}&>uFq#YK4UbV&z+ZZ4?(-GGm+o~g7i2i_qnfl1WIr?NCYqC^TrqJN z%;p*PfsC3V0NF)h{hNY2v-yt$i`nFN%5Oy3RaHmw0DvZ~`;~QoH0eeN5p*%s4^evM zRTW(UOuKvOcjb*+UZbXTaB#4{zrWkRcjfm{S-^nwJJH2-*<(DMwJW>w#TOBroSg6P z@BI`1*bjdFA9(-Y{9AwTr+@yJpZ@IgAN}ZWzi{j9!J7x4{O!;DH~;m2|DnJC3%~Q} z&#n#*KJpvC^}=&6y!hgaKlk&${Ez?0pZEv9=bQiZpZ=2%KXm=ZwJZ0y*YL8Us%yoy zYgfPKX#KDMlYj6Jyyf60|MpM*_}~4=hko{_SBLFAZ+p`}{ipxAN1u5A*SzP6z4>H+ zC1+=+pL_a+i;MN?d4Il7Z+qwC?|tOv`@Z4f`)(c`pU$#W<6gaW5xI8l+B=_k=L;`9 z|H4Zzp6%@)UB7=iy}Q4@K%dur-TN$PtNqnX+e>Tt z{N2+B_nuto{J<0M``Qowz}J4~4_rCgJ3m{$vHkN=!kLHW8oFV6)i{0{VBRi+N_BmA zsD4-OQH1I70J!zYvX4X1VBolbXQDxinpQvlY<||LV}!f$xiDdP%DQK4%l>QjyYKewUHSc0O5E5GO}~h&j|kF;#@{7S*_GF&h{(mo`r-ob zf6wEOzx7Xk?{|Om-~YK^zIE&F%P+t3%FCzEy>#od&p!XD-~P=0-r@e<>f*ewro%@b zdF1_H^R6e}@#y^zT)TPSjVA5*b2Zz$+OF080BK!OMtFCW-+qBZx-^@Xg~ zz>M`vuiXCJvoGu)9=_`x@A}}keA736=RfeSZ~TsjA9-+|aejL7Re|OK2>bwS^E<;^ zYsfo4RPCOq0^Qy483%QDWmoP|>`=2gS!>8OJO{ZFdqW%Jx|>^(?Dn*DzmG_k@z|w^ zS)EFHx%zJTKd{8r6}lH!ao8bvI6dnc+Z|#5@;6!s1PRm~x~oV*^p2b(0HOY%Y8mf~ zo1WEpOnSm;iN(nv8`LI>nLxAMi0Ieh;d-ck!!D4N7{?Wf4i|WSX(kw{Hatvwfms=y zAr#tSr#Xa)1dUwHn5uUVUhr%9v`J(1##f6}-<7YD^6m#$*cY7Zjl=f72UlO|?(E90 z{H~Nu++IIyJ{Ga2yR-DW^7@wJ(~H&K-nV|^`#JKL7dWo|CH&{L<(9$3OGTdY0Ax!Iiy>g9qRKC*S|Rw?B6O>GAR5BljI$ zIXYj@>-kGv2ey)qj*bow4$m*n&d$!yFV0WTPUpVXKAQ(cT3hYy9qb?OulDx#_ahwa zOI(HadTY3P_3G94e%&LFzxyrU{0Beq$DaPoZ~f+{KmFtjFWu@Q=W{>0cKy-E-u(DG z-}%^MZ++mQhwgvyCV;OV=e!pMcvvKtP@){vN zlntXmp+Erb3Ya4U+=iiGz`A+Yn^?yCI3AChg)Z)#4^5{ew2#vY%TW1%=t)COaWI+# z#1al=$Uwes7t6VmB*(+_^loVhdM?rw=8Sr_{Ly}5rE8a*1RJ{u(CubB_@(sSIfD5K z1l`6`P7UqKuDmhIzx@ZV-9GD|etG_vzV*s|hi}-6d3I%2qTJ&Y{o)z(F23e$T)VO> zuc@rp^Ljn+?d?7C$b;wS=fE{pJvun}6My25uIJuF_V!kXhX<43V#a#?Iz!6!dc9s_ znx?ls{^rLYfAgJ__3e|hw|?MFrshn7gZ;gu{f6GJT)85^>G|t+-9tpa?C&4!?;oo6 zDE5GewANZ{H7nce(zKP{XVJd5w}0Q8-g5tAZ+ZM{zvbJ{PS@+TX~L`3>R|t1?_dHj z&scx8fq52*h}~|!ddkc^Z);!k`{L}{;rU&8O{JL6-P!Az@Svh1Q`}|;5oxe<(%`l9 z0Zlc7wTWM092`Kqo=lHx(1^1wq5i&qFBli{(%U{*8xGaOqA&G81QYNd< z@C3u=aRz4PSeGWXdmoH4QcI!#G7mt)esSuR`~#(;5@oP8fZywhbP4r0epMa%>h_2s zuRiTOLk>UokcWaSj^3jivNc%vP-N8ajI9vucCQ_p>@d%+yrIhdhwcCV!}ooqd$B9K z^1EBotXwwl)(kTUnA<)g^LrOnyGTn!beh`k*wwDQzGc0h0f;n^E%)~i_WA4i`R^{C zoagyuo&iiv?!USpeK6h1koW71r&(C50+^<0dG9HmalcE$S5@e&eKwtV|M2i&{PnBO zEx%dJBPXKt$S;6;M9LPweK;C-Uiw|RS7D_6$Rq+rO*;}7G{>4<(Hbg?xnmb9GEI|T zXeW{$u-d@(0&QD>JT@Tc=&Q#)&fmXU5pAutw(1_HfDSZ+tFsxv+b{T?7ppJT)hr{ zdH!kzBE|yYl<7h&gE+twFXFoBh>P;d}1_UWS8SMLX8>+%7KK=AAY5 zAGzcw!;(#A!936Pio8WH@A*wdnXeA-#Z6^>eQ~j;V}iEX&1754=1;xJy7zhRt2(a5 zvBL1j3;px%b@%vulCy@o3(-4c?6p_~v^vSXXS(QfMorkKddvOrgqa)s;bLZpL-pm> zdcNg}`YeCFcfP^ts1mo?sVd#}iq4ztCC+Jxh)k=0=70Z}4_D3h-D#RsyY}9uX`W{R zrq&q5A@jVp4`pyFB7N?FOslE)x%b{$v)ub4tp; zT9dL!Ya-}#Z&QoG>hdv>W>ewAoTk>h5m~0GLB%MOUbWT$Os&oH%*zOToOAbeC?e9N z_s$3vo!T__F4ilcYgg5&O)fXahsSFzBsjcHt|%1%1Y+X^H^C zHnrZhHKTwz1EtS1B7H+l6VRqfdzU7C?y<7k)NGEV$-MR#bIAY#v}uCs+&iE)WdfP| z>_?PDq#08+F=bIOwW;?$wP_~nhPka$b!wA{%=4_!y=gVO!v?+2g|H$J=SgQb%)M7K zY7l3&n&(-2Pil)m&6=hR7}DUDkeSnD7a;p`Y^`ZmOW32T(`uU6v%P4&kS0@G&3*31 z8>9TSc`=`ZwKey7VJ?;?K(c6)=2HV$IH&^iyasKXQ)`yzMIgN^4C1ceU0`_&5h}O&NXK)cULnZ4BBpL5r9zplfff^=<;# z= zD!R@#rn4FEdhp8s;a~ex*p*$`m0fvb7ZcH9`=BEU*T*CTXDZt9Yv}(wRKF{i6$Q+# z4gxHNN^6EQi?dzGU!JfW@qod~sp-7dTer^J?Fg)wP?oCV7iwSyt;n!$2>N=BKC{8{ zZZ!b-PXlhJ_ctrPhl!B-k@T}Oed(onTqgM+;(Uc_jolIekIaNY zH?>C?xav)6NAv=5y8-)}bEx#k{|uiwOygJTzx8Cr77sjWB{Aw!yF>4C$6;xa0wxb% zXcOtmwme3GPtmRrb0`As=(D!fho-L=yN?f$4i%9GbdToekpze3H54MI+*Q*#K~Lhl zHs&a>GXRv4P?a`NCJi6TS%^py8$q>|6ea`|eh#XTQ9Sz8%?heAyOCDOivoCpo<^Rk zHf|eB^V=m{OD4}pmuYrkKRFSqirHy|R+{a-r7$OVBm0MYpRFMK5Bx~h9LU*awRYCe zD)_X^AI^(66&6zeKz{Qvs`DxUZKPgq4bE@zO|cY{J|(}Y0%*2-ivnQhea1OK+yfN7 zuxRArra?%XKvg=&(qUp!D1Vf)Klm3iC_3nuh#h7UD=gg*_Ah9 zk8BqRLZ^)I(>NLo)4xE0F=h?Haj#}d3s6&OMTp?Sl! zjF^?$g9*9{CSKCPhp-qX^L!|PKF2USRA|p|sAqR|*g=jCsJ4q^RfTpRuerWOr7MYS zG*TWG&~jYBVGD6&P#Q$-l8OJ+8tpK0+o=I@-ITN@YDB2Xs)|;#G}q&CHGsm^*s8(v zKjJnbJ{ojmzzgVR)q=IyR8f(+6j-e|XWDFPIG$-mu~`fzvK=KLrOze&fXz0ORiMfG z0NWr3Mofpz0^@#+Ny@V1!YN&pq=z^;F1c5-#e@Tma1Q>bzpIKyxuUf$jF}L*F2E49kT4dOX#r<27G6XxF$7}RUf3UjK)Z@Gtwx|C@cl!h ziNvLM$j-_TL2V857-^^?((9~n&ydz&jvjAUJ(S58OM?m6F%yd|A}xPv?>UFoBWqe3 zprVXsC>BtW230{9GQMvt>gE>JE)k%mb6R%~Z(fs&?$)7Q*_B<{l{apoNVm@=)Kpxh zQ;kdP!aonyRziNia=qV`-_wQoji2pb*Lhk|u9$XFih|?3mgu(I0dxseQ(T(}1p!oy z_)JUd0nzEG1M|Vu_8fys00@4||Ax9WVu1oZQ|E&x#*1F@AU%qbq21L-ahreH9 zr(>(spbA1G0bQ?6OjQ6+;Pb@vO1!yI#$grU04mi9+kHBvyetK4ZG~|8Z23mVZ+x5@ z6I~FgagnR2a9q}VD1}W#1Ds{O_8Y{UT2kgGQB1l5jiD6^nHb6Ge#VSuL-HT#0_ zE&?^DsAE~KjS}e#iPa(mOa}X<1w&T-KEZ;xkIa5*@D!7xVlGHUYtgX#<{IM)!VJQG zK!VgNZ4v>xy(cQ#AW%_s=9+X^r3IWY#-5xK1G#5SmQNHHtWPLX_HxU^zFMTazB z7rk?N5ENTTnDKQBQ)e|uMRdQLJAlSU!4+Y?4pj@ikmmLjO58D$1zfm-AU|l%PQ#NM zCgF0yPj}E(=sc;u{}l?*MgsDuEhy=6YzNBj*ti@p#~m=7Bbe<^faqv!pplTIP1+Uc zE-G7#T@PFWKIGOUZafi7m(^)RQYc~~%959|O}dCwa59<*qXd*eP}YCd7?#cBWKjen zjrZk>%4EXd?_}=F^J2TQE4#8QZ{$K1v3QJJJ&iBJj4L}lzbmh;&@n)tn{$q#otFO2 zLG^XbI5cptt0>GsCjMp8u+|MD z0w{*S`yiDvhH)_{Rz;OOzbX#c0x1_xsbv|XZa2|5U2<9+v+UDiA_{)gstUS=-ucXcv|VLKFhvmOjEXUgnUR z?U@?$LckL|D;7dR@*%yE?6*3ShQ}*62V+n)L11G9%0;ez?~%dT=6tK1swRRdP-ozO zwrI}SN$MPiq)sL_kT0Um=PGmvVbmXCnP>oA2X+(yB9P!vC~PHG!-Oy$W}3Y$sKtqd zK*-_&;1cE*Go=YS%)aJkGP;f#vMnO+C2nDs$c;qMl=(Z%Bt%mqLlJzKzV6iGA<|bb zVe;VysAka7Vx%zh8PRo+pdLu0b&qBT{!K%qf>(Kht1;plCeDsB8bBxv1>oXnO?x-- zE&^aG7EPmx8xZ{wHIo51Ih&I%5YaHn*%hR%NuN2bWvlYVDPhuZUsA|7yN#)LNJESC zvdl1|aVn&Vs&-374iV{HMOs^}mKwsY?8>g}${Vf(es|DLDIf5{Rd0WOhev`iZIvRtS z8ru9NfWMdn94FP&0Q|mOzoLojOo^*nKAn%Q5;s9dsOYK~V^a+_^pc*+r_s?>DCq9s z6@SGj#uL4**)jeEPU22uNBZ5-5+Zum-PPt?4XEA#nBMGYSZGi|%Sd2}_<4~Q5SmE= zjrHrb%aW`kgbHp&3uGpQS;~&ZNR8ACWCqEqpf$JnBOTfRVtEO`q2<+52;zW|96?u^ zL_5Nn?XFQGX-697s1HmO&SL9{w3ted)1~dhk3pM}1-$67dePYoocK)K2aijS-H&X^ zi3Vhv1nNj$2}2CCbyZ>1u^^CMq-IECime$DOXle0Hw@^=nXC&sFU|dE>pg5xMv870 zrA}BI!(_AqVtV{cGVcO$(V+@KGlK|zM`r}LIs3(tK56Cr(^?vd8RX<+7WhezsM*-g4iNT^v~e}1(46EY;NJKxkSk^;3GQiR)?G|yKobnHIEN6vU`RAE)=Mu zd2R_P%u2$?kY^46ns^xAV8(;ySwP4}O+4d;hS5P|sj8@n{E{7JS9WDrcI6FROi6K# zK^Tm;snrw1SLb{=d$lXOa<4+)3H=WI$I}vtgg!x5OGsp+|n96*z4Qm#O}7CesvodSbw?eO+8w6Y2nP>QDF=rQ^|44eKn2a2I18DSkI zAYzI`e=T2y_B&W;0<8s*mmHNp88I2|fu!I-Oes$k)2_f{3PC24s_ihK!A-V3c6v5xQKUY+>liE33foGTu>#dg^{;N zZ6{UU65#~_(>a+8U{q27&DV?ClifzGATmr)FY1{BCVSVF2X7g4ZXuH1?uRr2~?cS!2gjhI=FfzvOyXzMY5oZRaf4t|?RNeo8g!O+dzZzo zHx+XfYhY%>eQ*}%=@*Nyj)Mb1Ny0mDL5E}@vza%wyEnYW9Tq`kbx|C^&0{wJyJuWg zU}-0a#>s*Zcfu3m(v$$CTiqm57fcHBw+GW(6;soBMzM_{PJe_cz>s!pArdk)i5e0V z<$Q^oR{&8=3{!#20Y7YlU|{8Ga-W)9nLxA1c2VWc*xEg11DbD58d>>z_Sua}W}_Jm z2fnTvV{Ibn0c!UF0NSLX8OgW}7q3&~8e;-(@D#U+qOpg5;ba4V-(&%7IL zax($Ywfj2qvt|>Zz=S@Nt!TB>roEePv4ixxvMamtRZ*zhrD>fScwqPzV1D#+?8>gZ zY8hg_-2oF12j207AomVM`B z296H1g5mSU{A{6jw3a~))aPk#acC%Xs;@s&djOW5Yw(iw5uyfz_*cBixf4YQtso*H0}W0>JcBgafZiY2m3r~9R zs1m`L6W+a@+R-M0c6YeC0mosjFAAPicTxzK3{f?CnK0E5KP@zkspOO#02*jC5j6?n zh814@>ER#J6wPfS;OPY#5<7=4!VHYz=~bExJi0_>n%Ge;SXFUjD>_s-M@=T8#V&4H zpb!F}9g*xbVQzy68NN9~3N0OO0a2bK5K(4jvz?=P(#13)Swn8w5s05A@Zh1~Pq!OV z{5Jp=tl)gdbt>JTSC|*Q74hg+DFM&>!)=Kj0NRS?Bo&@36KQGr67iUJG!f>SOtDFd z!*IKzq=FF(=>R7cifIysYDb&6?51vppO`+G)DnVvWdBQGeJe!Mn06yYhOL*GZ;uk8*pcHqR90Mo>ZnFdtq7--WYeS9awd zrI|-TL5`~0wI6cS;ARFvdQ@v9VuBwBgw^$``|}po$)mq11}4~_w3Lid~G;m3K(5h1a9XOX5{AX-jB zKt$2RgN|a4aBWX=*I_;a3JMjdOwzj>3Pk}oYbvxbpRYq6fl*KY*@>mOS~!EGA(cqb zQ0U96n+Z>Xd4nw?oX=EN4_vVb7c-y$GELfNaFWr*_8Adby-sK8TWeG67c+TkS9WDr zcI8Vewq>l^>vJQAFr2R0E}y!PS+*OM66!TYHxF~|>(Y~5dE*rYrfCI)N*w4{h3>#O zB^mZ_Iy00CQ>asyHL{HZ@A;0uxAm#5*#5F-77wKcvr+H%Z_4W$|FI8sT>03FuF(3Z zKff!fnyX%p?p)mlQXA$6?*I{dH9An8FGI{1QMBbU zA!c0U(aBI$!{46R4-jcx)X#Q00&TsU)2PwQ=Px*3gPMz&fvjK0Z}OKMOxF|wFP)mIP5Sh3u%v}L-1X*xCc?4CiAJr2I3yuupc}l~RGQ16Ls`SGR5(rLW(oE2)PGXIQzmwRDORGWL$Rz@4lNmsz1eqJe>Y6qW z2J2Y!ZCLOIdL>hu*tZeuE`CE~u(VBH^IZMrOm?QFLO>ABBiX4VsPN24OMZ(flh%5Q zJ|WSOd}b1*d>0Y%9?ZZ?2P0UC>%q)B4EZ-Ans1_|=p`QXAQ#pYAw1^xZwd$DHN{Yo zYcZo4isF5>2YZo%&8ThfH3nX^X4V7-y?bPhN)SRsy1gGZ3_As2qn?Dji$W&EdQ;ze z-D-faAY`TiU#1qsA);a21e!FpC28lK5C(xT!3m@_6CD&J@UtpVCM9XYLsZs#Dft>K zeoBznUHGvGL2Ej%X)yvVuhb)oArFX41iF#sbtwYu?@dkmy4Oqjc4b#~Wmmq)GEK6# zH?`LbNSjBaxvQX=u4W-=VPj22JycG~?{{TaE-!|108n!><;u5cNmYZm3~#F@fo31c4$BHNN(;iT~Nq%Ks&h4sD%E zVL8&N3F9RU7>je%RENFed_6jl%Cqt)wb$(mwd-+37HG8Vg2TL#X%q;22)5`S!w?YP zc;65YsOy}?@u@0;)|&PihPf0132YFjunlc16M%6TfF}m)foLs9U4S4Lk-dijlOf2O zQKbebeBfQBA>CKy_BW8}Jyci{0tgSz+5~iI%~IgU96Gjwy6X%)0&L;$iD{d;yc}?b zJx5Vfm3iWK(fH0=l2V;=jZ_GBJo8~GB3rw*gbnsR!iwy5!H*n)aqbrqJzB+{65I$$H zgy1u1+7-=@Ul{hV2m)!X88+{oaNW5b8G;R7AP+ksPrN)yCA?&dpv6OCeW%|(o3U0=_$^dhpiH?8z_ zGZXB}uI$QJa|xvVx)j@=ST$@923)-`N!VOgF9qY~H@mVcwFoe;*8r%!Be8@CJSFV_ zs+DT7iWV|E8CR6>b6AGMX4PzTD{9;?2+*FK>VAHy-mq z;2oYgs;tpvI*>#)10&%0%RZzhm^ZGy~nI)lzF%7j>fKAXtbmkH?&?fQOlytoO_8Xk^V<@LP93d z-U|!xM6U>SWl-A$rsh%oq6)PP_Ow^DBv+h2Z`FiC$Z6ILuqR1o#us>0Ld0)RzzhcJ zhWko8kNbxqg60z65;9($piAL-__&VkVO4E*^qMx5fn5wuq5D1~UDfl2sT>*pg0sFI22>Iw*^w}{g6n2`rFtFPRM6}I@yZj9^ zBLtD0t7s?@kElRv>LJUHJ(Vj(a;6~Bdw8;RH>!ZnV5K0EU@Q8aji%y&Fg9mqAP2$_ z#&sx3KVNxh;#LSdece{jSvcFi{`!yvUJ+5{9z-ErlQj1+vc#=DHH$>$Jpnwp1Mt8R zt(1b_uRv3QI5N)ZZG)(BdxB_h8WtzU!-VaIJR)ARu`#rv#kntbp@mhzNJ5*RK)P|K z(l+HW0JepZ+bPIIJ>H+G76{eFM@M@RGU=ejx5F{AnJb-#{YBT~#8^ln5?Lf@VX!H- z^At80HZ2XJO|;L+5E%t3A^%c5dZUeppDbq{BK<+`&L+cK0D4=b&F^`zRWESsj1Sb~ zTYTklf-(Sv_7cC5-fZTraW7Wp^fY6M?`!wiX}^f_`fB03vMal?E3ZpwbPL(py5>UO zWWW9B;+0lvYDUd-^lZU7yRs`(HdZUM^+Q98(@^C@U825+bhs42ORy+|r)prk#FKP= z!~_)*WrkwxJt zfIv3}lW0v48X-l9$r`V7w@_1#N+it$8g689ZPXKSXU3-R%z{mrrmmhg&q0sFTKA4| zPKYecVQks=sE7-Jk-_D_SPlym;rkSoCYHe2@s&lhi->2S_Gol~ZtLJeX7>soxH7)O z!l#u1GjQEGKE%o+x6$)Pg9}WvS3*S2mmw!hQl;>W;@yYQ)Rg#EmVszQm-r+k3}++) z1fG};p79IfXplRUC7F}pF;5ahoLTr2Vy<9MtWZcDXSo&KAdHqp#tilnmNH(cpyn85 z;6`_cve#}s3`2?5qX^zl@wZycj!!B%nePU?k4!?hHFO4;5Fu9!hTI^^)Mx<%RLum z3~NcY^-Oe#GuVw;5uAQ*771iedKdwCRh~y2P<$~1hw~&4)!UzzMUyp$Hv|gd#6|dp zwB|8EmT9I(J_?VI0NK@r568&u$?wS2vT9r$ieFV5h+~^|Gw0Ap#j#xc3y?~|; z(2}KqJM(?C!W#n0)H}T=y4-D*2CYNRHczSm$-#-Js;Ap=`I?7xio?i`@rQ~K^-}P( zhTbDt0j*|6W)+dvrq>5$fHLKygF z$s*MAL;i-oje^rQ#c4qsLxn(YGK*>#@5VQaKVx*V@jR7>@LUWJZ}v0cVJQCF3>f$5 z+hU~ge0~`k^R`heN)F_bhwDh8%O@8-sl!hVn)NLHq(foZjeB%>0`~V0yajDG)=&|} z6=Uw+cH~15h$MFKEhupq27js#Y&=X9TVQr{<=(F^sxo3FJdXMuNe^}AD?z3$OW|I> z%JBM%@HINEe2ltrhj+3nsEi_7#B#hPy~sw(@qp-4zh;_J=2WC``b{tMY>3n7Aqng!5SIa zy>+kDk%^zplCVN7ge`0ou7yjndcbvd1v6B;`O62Mgf5{P;gJ@#I|$%)+PEXYAug8_ z2(gnhUU-QWXt@peAbsg*4wix;pf!)^Hfg=j0w@{}B#WQe4~M&uTr^4K{^H5lB+;ON z^W)|cCr7YKtVV{@F_)tGOPkl=r=iP?NXC*ASkb>)W>Zy(hzwv&07QGUC=vx2C2t81 zC4_{y;4r$WL-h$X?*vo1_|E;4|5b$@kfHCuLiBBMnXB8^V=PX0f;a03Egs&LSehxF zCDwI#;7MS3S_d$4T$ryw19&>h^abT11~CL>mH^Kt$>(Wl1!`^MO z*rRr3S9WDr-e_gDTK&2IgpQcpiUeZOj=gsgZFL(0=6Rl0dr(#F zT`j#W6lkrP`LuhqtO%s{Zt(!VIjP3o#3s+1z$&q0iRLD;gOZ4ovw(J0)z-@8P_P4E z+ISFbR&9`JnmWT#EwNOan!WBJ%ofNg=)HTLklyDuO{QryX*M?A&|J=DiuB%ts{R1- z3^2a|OrZqk-Y1Jb1LN=Ds(Zw74$ubv)zl_Hd+)Z(cMtnD^*|wF)jF1e*wsSzq_8I* z#Rloxg`trmt;Ze1(dR@?Zr7RZwr0CQN!7?0nV6g)fc50+(cyb-y9fLp3iXXk9|)Ya=t>NItZMr=veAd};e@@H_u|ra?^RZ8ASw{0+Uz>$ zmd1*GEIk~xqlShNahYF3>@ouo zXmht$8X*yJ)UMUm-yARautnmmvummTP^jQWPips-zHk6U5NWR$;9R=V5-HvrQcd}hYi+BWu8&f%KCF=ChXbr@g zi-l+A9WI6!-7GE|QR2CvotTn=9+tSzLjg%}R~>{>NZlxBM~x(-Ei8=C^QbmvU6CS+ z%;M8jXFHalj{by`!3+bjNMn*))81oFo$_G9#ZF*eBb0#TELAIT$4@xDm%ss1v5}fpe>TNCAXA%B&Ql| zwnP&m@xHtIa~}zj&8(D}>wxm1Y%FDyihKT`eKwOpnIY2@CzTGJO}hA@42Ef_G?gxp zIgF>fvMal?D{s(puz&CufAP8bzPIDzggHlOGWL|ukqnkbTW;)7URh$|SoZwE&9cO% zTOJUmCt5!5+A5Q!mA(4aOKg9kzsL7+U9#6zt`DHb5}R?^m?S8J)Z<+hinqf7x>4!? z>ImaQckMBsFEm=dj-e9427xx)imGbsn>|E?po@cUOUo5W7Wh{AAc_IjXO)l2d;)12 zVeM6M9fp?Pd7f9wh-b(pJ*-lAexJ2n8RLkkc zq4nkK013{YR`E5#QXFHA1M2)c|1u1K(CMiPUNQpJT9FW56W>JgC+d9+w5W@qj>i;3 zf4hJ*gc4Xf#x4w#%(WVi36D!k6H#fdU#i-vdcaOsD$umgh6J?FZJHe2!Pr2fYMKpI z?Mid&L~CINFfG$0iuEttL!S2u^acyyW=ew+O*I_v2sf*@EJ-PPm;;yCDm2vBMJAgQ z!f+9R<$R|k=8801sMjFf$kZKHcYruNrV;8^H&LCXS^jo0zb;gJYXSEBKtMY0Ik$E> zHoM@V3hoF6+)%)iT^dNWDH+m$10sCg2+oiRAd*{yi>PvKP1II|yES6xoVmaoPbz@$ zBmBtWx(^nC_1#dY!ei`RHVXXqJcnrTV+DxR>rB_3-0SS$7zKb>-X1fd3ctfk(0$+a z-&N_EotgsZv{MKtqN~CaK-#>TnZwfxR#fCmwR0JEPxp{6%|_tMZNVJ20&=z|en)F= znQl#bH$8&u(~>&~!Z_puXp|*t3F^#0Ab@%R0xX}KaZ77c53x;WvT7H&flj50ThZqE z*#Nga*^|oALc6C-7ew@j^-P7w1bjDA4_ilIaF;D^Vdx&;4>DBm@ax&Yo99q%fIM?< zN%T!VxF^BaBMkytFgpNXT0zxUIQWxgIFOJEYB%1+3Q`Fe;9etdRY=5q+`zBDBZFM2M>&Ho9!>AcT~u#wZQkNKXl`H0G8;6z>SD_z6l8DS@5l82kan~PV@M$z+nv}OvZ6bB@{UOgDZE6D{8qgsVh}EGYjTh(& z-{z^ez>7Z1(%6-lTy`J_6okzYhLWDWWffTdw45STgupS9ZsWi=tEv+oN<=(UepQZX z@lts-Ni=i`fFSSJ)xf};9&LU((m>-EB=<#!hXD+sykcbR0~fY?;9XUVs`)PU ztL+Mwma$6xA+3lYXOT|sR)sl>Abul!b_2?p>-4}=;s9J!E}RAjDFKK_B1uk*XJ!v# zrlziCor#F)%1xPb+hv2a-ec?+uY5ofGS+yZ}2N# zpaN+v!?x276bvqY&6tf@@@TU0hQ}~)g?bg@N0=L>B!2`vi(Y^`a->D>Fc!rb+&M&& zZB>Y~41@_m`yl6f!athc2mW-NZqU&+1{d0t_y&C^&Bf)W69xS2y}QsI{<>|;IaEBE zh22{&VkV+I+1{qyQzP!(NYI)a*Vyy$@iXsyGrHMvYt;_1tJ*rc^}pva6w@GjM?hzD zJ=-imhBTKnzPK)-Aw)uvV2nMvi?MtHJECDK5meyTj4&^_F;#q`C61qGVhj6!3w*0N z1o32|0JKYMjO2l7MIJk0!&4fCXo%q=5+GT_Z66}>nRz$huKh&pA-t4p$Q}Ja#Ib~s z0aVmtJv~0B`|W!6LFWiQFqxy$R4s>P1PZmqezGgOvMamtMl2!Z?ebl*dsx~unLol# z)c3A!YDL@7-Yve;1Lb_b@tp#($4lOmODO4{^QN&&n|ayOQj4FTGWEkWE{))`i5p{M zCZmKKcCiZ-g3LP;;03N#`o5qlkjY&Ou*+^#=vXlaiG8IE%cIHxmX){7YGxWnaAJe$M4?b{6)>9@Wv`B(+P*wStpmX=xqOzGH3*0ZW)aK+sC zkn@$uYb=67n$-T2DkwBgq-razezRdPNvf323T-jKva6MTj6~gxS|~%`n78tQ)bbeL zPLW|3BeMpBkA}rG=V!2nc&~gky;WEpP1m)HySvN6-Q8i~?(XjHgy0Ur9fGqK?he7- z-Q9x+2@uH6_doAG?t|{Dx~r;d%rWL2$&Kl`oYo>I^!+J%4mpK^?I&JVLJHyFvinlc z7fNNyfN5O)8Zs8!R9|2C2W59%e&lpflkk)8x>eIrB!NoFrdH1F3h;C|yD2JVw&5N~ zzkG2$6^YV|0-F(7kWjm6&;&wHJOudSo9v&q?=L^UuDJbqWwC=wk;f&gV}s0*J7i~K zJbY|~t5SxPNzy7?(;be;jUo7q81~-2>uhro61(%NtD2-f?~LnY-?5pogc_QT>)2a- zW?@ZkV6ydVqs7s9Jas?Bn)?@qMnxVd^P10%aQ3Mzgimx-;dU1*u24n|ubW`QnJoj} zpq|FKwB6f&U-AQ)+lp&qauxnk@#x-gm!f?*e8Y$xJ)A_dFZ_qvpdOrej^&kZw={84 zmeMM=T39sfV1V34#l7=9jTQR2+%hkG*xwN~V$ivY{heu_VPmRrev{gV1TD$C^F6MA zw&WF$H9RQTCKROleQ#XCD&Jz-PWr zB?>EQY=ez#NvfQP=NHEh5G+)+Gf@-{ZH&R^W0SE(mOUXD2GRO{QYKNL760ALV7QP6^50 zjP6?EjvvzDD4A>Mw?IXN-uH6<#|ou5_U^9KKCA+Qpg+g!n?Vd`5HyMdCQydJ6{y^_(nNVGqt(W z_)#O#qCF)SYy%Glu3$wdL{InM4e1_z7yRSFFkNHZbzE!S_a}OWOps=rPJIUyiqq@c zYL`@>f{x!Sr9Q2J@4J>;RZ)e_p3+h9vk2hFkX-KNmyaBKQbIiZZ9w2TP8xf<`SAeV zpo-s)vjVH!R~8aI>|Xt;Zl)v^j1XAQ^0R3tff<|hD?&4QsD)kw0+}DQ@Ig{NI(!Qn zEL8EU@G#LO)P;kAANIu{4Th#8ZN#xm7SAYYrb_oZnH<-XOJ&aV_rqAICj31wl=2;^ zsC4-G*mwxlNWf*%+$=q_6cKKO|0k4!@0tA+9UpKmLV|m6IHj#+6&MJW3ef_X{wD6e z2a~)iLceLV?%Is(?r^vHl79-RJ&TgzDS|eMNy@?oLokV$etAX9iHRtah~3i`rVX+>!h+VRIi7uyEn^u=n8=He3xDJ#byL{+!#qa*7aB~IuQc#5xy#O}n2eiMe3OF2jjwwX4911j&`Et1>tmKEiYW_y>B&mtepBP8}UGVnlU1ba{3JrY`{v54?)7OLNU&gYj zS%fczIFq)wMU=KI>g?~2!tkqZBsP~MV(U3yIq-a5s{m}dNMVtjdS$>mX97(a7Z`s#CWh!i5mf1m6KAnNr`H~+X3Y{o{7D}ij{uZGWf(>pw zQ$Yh_CsslZ+R3TaTot4a(4$}~SqTvh^Nuo(hNB;-Y{rb9jkqpu2Uy8MZl_(Q=q}R2 zUNQT6m^lWA@COQ;l`-2r!LlK&IY;t(yj{XG8Q5il($%rZcYR3%$oVZLo38l_SQ9SqMAn6 zNM*ceV-5g{@0xw)-BdmWtt=E;v_Wu*K_{Qe6Zy-AI#OL$z$d?J0#8&fxQH{!jSc3Z zn*(K0JPp3T@h6sGObrwp5$Lo=4VrOHZdq~)gdJO0(&xuh84x=ik!8{u!F36Zwb4C~ zeBN&(6#8%tQ>ngt!h1OO+W`>YOG2WVg+qZ?{G&L8OAtT8lNrs*$QsDJGgiyyI22;{ zDfmcoFfsmjh3DfZJ%3N#H1l^P5APS$)Vpz1co$~%{zFc2X9G(d`po!g91!MW^=oeRKmQ*AMqvPz zy1M6Yx|Mik1b&EdsAri5=UjX1UqD_k*{Tojkgoc#elW3L@Dl63(vM|QVgBYrCa?%e z$XceuSQ8+VNFJX@9_G{|0AE#xH(p#JNKV0&T84S*e*G7g$__zbGOMK9)X$8b;seBP z?w+5nu&(gaE5&f8%}M^k8Y{!eEUh+V#3hPL%tCDWzOcC5^ms%D$h~X?vM8OMgb8?X zpXozh7_9dI?|a()-czj!W0?4p;L~k-pmybGULDuCser`{*hvkau5I!kkA$;kIuY@n zLOA0lyUb5VBjd)WL}GBFnuK_64A1J;l63(F18zg`veotrg=D97p|iD>NAiLX-3k3+ zDx|W~4Sc!N5IcEwUZVDq4tvt(F+M+8hkT?h56oTjSb9-|p$35qig}MaR%=RNhAcJ- zSGf7vs;C~M)KXks!83Wsg-0+x}yWW(xA4P?3C_bj3thEr=&HpG(riDBMn&U zVcyM+V_$bOVuV!?aa(X{5%P?3q*CU`(cCNR-G7Eq=rIO&&`G~tbn4@p(GisNb0=mu zB5O`oMLFfF_+#((xEVu&{cz~Q@EJi~o-;@f;Mc9M zT(_Fmtv`_P$j;P*L`1^5Gm?9TR_$jf@1v`~$Myj9mw7Q*&v!e1UR7}-OfB7ZIOcZu zbOj*X!^|aQAMJtwiH&b@;`M5Q{HZYTN9$1_H;8#XmMxJ$lzcz>f$`@oMa;-tF+IxS5v50X;41cFs&mBvb@lBXy{QjZ8c$R%%EC_su{>lj8J+D3faG z`grN@mTWSqoL^uV2s1cw5ZO;jOaHEn_HPyU3Wc6P+TVbR=VA`NuNt2M<2@lx1FXw^ zV#`AsD{&}mr9@uaqGnJEx$-Bwe7j#{^1_Z|Nzwn^H&;>T=-~kkcAA4Bxz2N4c5(WU zy<0wQH?XdR-Gs(|r+mZjq+$=J{}@*^K0$=Nx$0M69yCH-M4^R8WTu}nCg^v$`>YJ2 z?#DM;WoaIph~E@6xq(Wsk5kX0*tiQ6nCw}pG|FY{zI?Z#&9Dk{py%22XqIzwC+Pl~ zG4}6Gkns()KoyS|Q!on-I<3e+2Q&zfdJ7K`yK-Iua=E_;B*P(7(M9O+MqrIo&;*pa zg)hQ>x80$H`8-^rrdCW?6Le*m;z4wj?2Ipom&U_QZVVXXEhU`+Nm7d`3&S}_276?}1I+4x)X2!FfPkL103OQT1 zps5aeWL+xb+8G`_S3azOo0USDlxGGWGVo7hYy^T}bMwq*I|^9iFc8s_1DgxPEjUAF zm$Y+P_;fM+PDi1?NcxQZ=yQ})u;n`g`m>8z^LO8yg&2IOe3IRbwZ+3h+^ro}-=08!2GHycHlR_0RQ=gs{8<#!F zoim;RUf6=_x6oZMmWB?YWrDwBcWsHBUHEjtFg8j<)7w9AN9|D?#evAZfDCPsFY?iu z)1vnM5t^6kELca3MHoIXQ2gpwnii9L)lLDGvVCMs-j)J|7Vr$DJ3_gxGi7(i`cq>~T%B@%+*8)tC zK$cnPJrSQu=Peh!or@xI-^Iq_PR-f<-bm@s(%bo{)jw`Eh8p0y=7-pq-uUQM5S6E7 z#rzI(R3f8b4B`){IH8RsLTI!!aF+_uc&yl*#f$W?^x(O(oWH`WQgb4A;vVA1+nN!8gQ}bIj zo{l~*GY`Y@naahuOmAbx~xHeq~-YbEnvor6l=OZ3T{dSQ(D{a@;akX|zs}i1_|H@lXSb5O%ls z1+%Xl!_EFMexd`!m|JNp*v$)YSv|B+ilwYd0D6}_U6U2vX8^t@o?4MHOSLJ+Ru%+= zw61dz2?B2aAhtcBrGfIdycT3k`a2-iWq2DVq%k2GGGZx-L9CL?=s;SC&29yt@n#MMEu`0tChi16m|m7>fS&`UC!m}RPH8YN zF`tOkp^*AOU)f%B4b2)0?uA9)2=XNO_Eh`)OLyYGwqpM;Euo2mEtR{D`v|DBXg%Hf zzEj``wcXYt(TGxs2&&Q#GJi>6f@;Ztu)!H`Se&>gbCrQBnz_+{yh_po*rMm3MP25~ zUkxgJi`@GBLSFQ@uCqR#@>dkkyQFF>2Jy)9;#PFih;@&$aCQQ@Ke{aB zaDV9QvwcZ3Srvu9mJ>|JI~`-~%K7ETcCE~fBv{v$rXnHUAFF&-rnCrw{MR)+P3u5+ zM6(^uzy~;99rH{^7v#NSPV-B))K45e}P9SutTf$BpfBu1ksQ>DH&`X6(xyn(f6e-*CDST zB9+_?BAm%+`!P$GSVK5l(Eo5gwqY)^r}Keo9fr6Y_wo+xhN}$hP_?d(<m)CFm<=Qq>`;IUOa+;sMsyLvcHv(l$Pr^ImH(zx6+*Cqfo;EjR?2J z4p8U2njzFU$b}?pA`4;}s{qI+E)sQRhCFKlq%(^e4r5mTxI@NW=q4O*_)ohUWdkf) zZ62A@a(wyWS&4QYQTVJR6k$84Z!bXOs1fKfV=GWG}!dJ3o^-~}N;Sg>xFhe}0rvAj=BTh#KY?D&`l z)CH5?j$g3;-)g7#Bart|-bsCy_pKsB6XOh;UtgHsr=C#ReRg`KclO-DwRm{>(}F*l z9e_&fOK3zVT>hxE*orz7;nXYLUg`ro1~Z4xkjZih!PewOihh;gn5aE$3aDPeIMp*nEnR;jk@c_bpzbh^~}& z&K<78P9jyaqZQl~PP#UzW`U9yR*0@r)9k6#*ZbmXE4OxPb)JhG@B3qV26PxaedUC) z0;EmcCPwERAz)vGL>XnMu~Q=^?xeKkpFSufdACGb$m8B3b@IoUOjI$o!fJmtnr!tT zoxM`m`a219qmq9wRE5?~Q9^24Wm0#5p<{!>N;Gn$dKHA{r`q5!%c z>Cee5j2*){6*@1I|2u8iFd&iPst~nWJ1iY{#&c6q_h@V!4>Dnd+Ad2h%}OzTykr{B->U|VMgk$ zQtUxs!$zi=vS`%%%T)`?1i!meW3C_TawsM1RBXr;%aGgVx%%L_u z;90Z1x&=6&3s-{1X(I=7;36Z4k~VC~!|{9=S-s1zJ1ro6Ina`htl+G-{j%+6i%4zc zyy9aPuFWG>2z81CYnBxrO@T8gZs@hzA`R#7>r~4t z^nMCXUuec%naKxsGIjBm)6f8ac*5MiInPJ=SmT3A&<8)v7ha(s!rV?%9~ zGLjwb+{<=pIXywG+Tp15|;G)%po^+m2JFaWk3tX6M(+ zZ#!D?VQ0!2&X1`Y>km^=j|wZ~f0*qxcVOe>K5HztIQiR)|mjqJDVQ zbk52<`(V!NjZ3y}{xyn}l(X&zxjzQ0eUw}0m=xAY`~e?3N4_&ELaGqu_pL1;!4SpNCvUci)XJR zM~S#T9RKq>CdBNLhHglMDL1Fgu;)nu39zAE6Gg44ILBMOhX*S`f(&(jx4hMF%HfXerN7Hoam#TSg)TY^FeOpinSr z4~yj(9C;cXm)!oPlFh(VeLA#Ft?8hF7@DqI#ls|W{c5o(9W8_7WA)r^V$it{2z&1Qg7py*OR1ws!6xnAVB z>J=y~ zn~;WgC!$U@XhL*LhNGK^5()GLo5m134Zp8`=)x&1nao`$lip)&s&T>pxkOq1*xD`_ zso#?%$p>;Qy_>Rpdf);W%K(MTqi{DScdjb6jzmM)DAOi7l161yu18~=ULf=1@P|!= z6l=pdk`C+kU*BOc%Rdr#86gCv`?U>;*WT!!HYqGQb+kxSI*SEdDd0Ggq@oHJKv7t7 z;*k#dnVi{@aBIQMeJt1oE>Ha#B>df#=>oL&8jg>r=ejtuKm#<9pZLO+CkQN@2~Z$E>Gqw zOD}!cOr&Lt>PQZ0jG?w+;ZcjK&F=Yf^0#jNukqdke=E`awo_`-cT#nOsNZr{d)Iwoi`6Q}gGrH^IR)O3jHa+L_p>>~9ex@NQD z%!0)N;OO}bB6nbSVHBj`0*96DYxW|9j)b^j{=JcIG%+v@{gB5w!k%LZfA^zG_KbGb zd4fC_YYAD6IaN~{k@loVpdxl|a&>HNLGc$prI>5Q(IaKM8EK^y2=UuhK6REU zZT|M)Dm5X62;>WbFhv2D(UN7!WkWR7At~|}pg;~}f3pa08cN}tx&d;YXM6@)VoHo+ zQ{A%|Wr%O+&kCjfR5pYF-CZdO?MTOE@P2&P{3c$UOqfKX-Y||SmOSAh23?LcAu-NZ z!K0_cZTJ};B(H0ZcNfv0NM<*`8gTzJkoRCJ+F}Lb#&9oTbQ!l$5BA(BLplR!w+z#G zV{33U!aSWz({BQZGN`|KbAg$XEBYir47*5ia#Wu;)fi0=UX1$;Q8*UVc?ilfmoc`XUv)c#un+i8ThM7?x9^9# zoPe3ROZ}Qb1)^TJo%LTEsU0Slw%ZhT&>$io-8PWJ0>Xr`G*N9u_3Zxv z+5m^RNM(6qT8lYk!%h11-c(m`6(VPd@Q@} z^WJz^9d}at^7JKPA1wTR%@|?G{1I4Tp?w>Vr$VYWF>k)w4rc~eBUQ2!M0W2*kgeEl zUy!7donNWt;9wI;5HZZMS7zN4)`RaYyQf)5xZqx$Qa&uSH*|GsG4 z)j1cM<`Sxf^!qZrBV|IGJ|LPLb6SnITo$U($rLh!S6?YzKM#-?iT8E(|NMjh+QPK9 ztRQi=@`CBjiv#4^j5%+051=qwl-ZZYjBMq@vqqYi!?P~mj=_2?1IUMGySd>AFMNKYR8@7t zUu#mV$9XkP?ji)?KkF9?vfjf)3EMw3L$Q1@@SLmI!)<~Ybw6spm(zp3ISute$AX}p zU%jVRS%zM4WiB(}Zy_t$SPaC}h3a>iPnFvBQ15uYcyl;H5blpbV8)670$H}fK#x0_Z(cq!`B>zX zyKqVyd>mWYmA_*GDQHkf<$o~}3>CE2Mo|v$IMtAV zaoudZAg@cOA!%lAKXJ`>9T0FBSK7naKwt@OF+^e*iwg(-l0pW4vXRyRkdB(Zgn>X% zO2h-BibG~&n3Uxljcl`4O*N$2rDI%@6VZrnl6jmAHO2cB|(C!mTj}Y2F-q}O`I~-StW~GK(r@`k`;g;cKpR7R9U?CVK33+2 z;)H9vQi@Bp#ga&faGI#YYijvnZw7Fr%8@j4hC7sO9BvxKq4V3Jn}6dp^#PUvaw@tY ziK$9BxK8`P$5UUYv zVK~Jc(R~2Bdjt&BGCySD%_o{W^<epdK1Y@T-j`x*rym{OwG|({v3{ptrl{>POv34XCF>rwt{wr%r>y?~S z4p$;R=OTPc{r2ba+|a5Pe-pK7tUEJ!tOH^BP-P9G^2`@#+7>$oJ0=68yX)*ne7$U= zyY!t~#zxj~+su)RP!OVl^!}S|LDAuzv&9av?2-u}Av5hisoZ2+Qv#k*P1s(wITz68 zJpiuI#tk=3l51uc`hyB&b(7^Y-X9$qWcCFdT>Qgflm--fCjF(9bU_;(y_~0=JebXX z>|&6SiYN>=drhdCQyL&J&0&bhsp8xzU+}r?#NhO8!LNH>dyPGVd|8JE3cHxp@>C?)Xl20W$qG`cSGAm0dTI~wx!m_hb{WFrH5T+jC+?DL z%Kh;x7@IxQmaX&4QYOC9A$9qURNLS7GS9$fy&WjZ{wbJjHI?Qy>Qvh_pFm-$h1$dd zE7D=eI60up6SSZ+f;2?GXb4wBvWr)Sbot^G%eK&plO$ni(vLx^{E@~~9Biho;n47% zAIv**5c*uFR{Ye-_9KZ09*ScbD5BJv{a{w6E#;@G&;@rjS)55`ksk$7cH)ea!HsK| z7(}rUHm71=nsJe$ATRhJsS6K}52r4xnj8Ao1_xGZ=7C=iw#cs-_Y;UB;6PaD3Xm-= zoUi05)2M4YEH2e<9@DF;D4?bZ^MxGqR4#M-RU^U=sU7+2YYAL&^$<>5M@-Y`={u3HM+AWSDJMdfwUFL;l% z5@h8?jBH%_i3EQ-DK{lOf(U3#2H?JnjkucR5Ialg8zjF&79ANEzAWGP*1b~Pt9$c- zdf4w2XisG5!$)AhHP(zJmmhbje*x0*b-<@&H2G*F7xKY5y_Nmrwk+GEAlIr^s=|f9 zJsnYdxS1@rnNMe_SBCIVu{3&G@ng_G1F^#% zm}exx@yw3Baq>NAlt+u*!vi!cLqER@q)2-v1O(U0I|Q3L2$;^{|75NI-2ZP1I5PB_ z?z??`TikgWMx0o8U8BAgEW10Jb4PeA*;ZI=Mi+m1fX0PesBN31`f}sdO?0vkx}AO2`@X z=MRRNT_ZVL`9wlX<=t8R=J(L-fn<6KW$ZDY@SEEx?zutz<@7xzK1nt!_i?H&5=c=2?*X=l1c}&?~s*GRS!?VMkVlH$}@Y<;p z(s_fjrDg=`ti~}$S?M=w8)BNPSzV+EPFz!Ga7D=x?6@>_=KC$z)4zq&G@~uFC&X=V zGnr=Y2bV*-ORy=w#q_tGWWOKSm#cPglNK1UBI$tQtj8AHIF%wK{OYv!dMvI|m3hC% z5p}CuL&>tJjlBwF46VcV9O!^LTHzY8x@BD4@ zQ#hi|0*8F;*z3uE!Y;|2mIgK^o}bu61-_qU)|*l&2P-crl9T~r=&r85V`SN@&%Za= z*rhS=Omyz!$*uCg#E%~4^_Rl(T9fEs5}J8Ns4+IOK>L1RbQ{1NGwUg1zyb4pgsv=nio(r6z50rr}N3cbcfsW!c zfKrzkN#$dxt~-k{pSQOjehu}=qLE!Nl)=#Td;!b`w2$29$OMd+mg?l{l`7E4p(JZR zBWO~e!hzJEkB3S>_=j|HnNG<`C{2{9!wp4Z>i46mMA|YGHoeZE)*8R&sK_n>pY6Xb z*mTkhzy41_fcDM6YiTgk4!i!5HQ!z|@IXmayMuB)L#WOt*TF$w&(?*bjM9L(JP}QV zOtr?*_%wfYbhZiD_mGsDbY>Fl!4yWD%yvep2-Oa|p+}F-H99MobRlA3V&GsC(JHdQ4l?Zj(ROc%(@u;i!{?=-+Oz=ZCcNq2=KDKs<#_afLw z9HSqp{Ap?2(<;VZj^|+*TC=*;Yc!0qMK7a3;tSN`i1~74zv*KED^KcCP8SU(h{`{v zkhcy}Y~x6XN7Glz`Em{Ur|n34a8=uM&y8%}{h62AxLk>_mPu@dyE&WFP}pD@=k_y` zhf_ldT(y#-K)HG6QXb?lJq1nB7js+svjxIwr|NQj%2ovdxCs}J?CVpIP;t z#kHzCrIUeczcvjJZNuj@-IOo8wRw%6{pZ7Lyl2>s0&9_l`3LZ0*DmBSL&eYU32`E6JK|Ph&!{tiXB$PIr2t&J1yr`YhUwWXVwbPTPT#Netuk+m{Au=5>&1R*VpkdgKh3YEBZJ|0m z*$olUmtcc844ui_2e8JFTq}{3QOk<^Dt@?+d{+%73^)S|E+VS5nj+oF> zcEWUEw;4_0;a;4Oyf6JL51lsxHwV$1H2iJmRXmprCx(-L1n0PiK7ngKEj>IlPbyzW zT-&fQB|Mc5ay!3+G!RrzGe|VYf9b$3&F@)#$oAaWZTPC|n!Z-ib)}&Wn{})&^=WwG z_19BBFUVueyxg418A?Watw_9v8X96P&HHU|m0QnLIpF8N+x1GC|H(h04JmmZ7IO84 z7Lt$bGU}fsU~>izg701lDY`SOV^>ys)eyoQ!e?=i2JTL&sY@M-l#eCU;OvtT60iv^6dhc-GisBO zgbAw+9840jjqj7g!sI+ZXm&39%a>17b>wIfY9>U}Z*;?Oief_fuPWvzO7)!&28~8C zmc7dsLMM-&a$tYHl>zqC-j7AP4Pm%jbsdbol zi{o?nOfDsVDRYT#PJ|@dQ;OCf&m2UGAF0!iTU>UkP_6HF!eKG_q+?BPgx1U98l&Wf zqndEB|IlkNamKuocLug4KRjtncv!6UA)S^!DrwG%_(Q?sUMyIISEHx*Y> z@67|MX!9etzs@XRkwX)lfKXJ~x;tj=ciS zR*I$Kk?VH}Bx4AF+3_zW0u0hR$@1}gSX9r84i5GWg(-Ht`yuH0nHwMtG_D3g8RMKi z%U!(>j|pA4b@T?SZtl^J)!Jq#b@2Hwnk+|aT(D-PcV_(Yq3Hg6aV}lHQF?UbAl&ZZ z2c|lHriu-uZ|XYhXx-*fuCFGHU>Kayu>!I~R`{N^ulNni2@|=^t47$ddD^zf1u`z-=XN>4tYVVBcy!g?kNM0MRx^yHKw$oT)r z_ZWw_*%mH|M9?rK52JqJm-(<0UxONpGHbmE5DZ5!t{sRSv5Uu1NFwDtBo;VSUgQWE zSthz-=;c{d@K#ch8`mkK{Nfhvd^*M_PIOMba>qffNW{qh++3Jpj=TATd_sh6cl(d` zvm*KVzgosXocO7KRLqHhVk25Nld8uSHvVA9qv-zuR5zzS7@^penE_cnUF54Swz`^z zEZvUzyp<6qS6^XuW6En(na>zw)gpB1v`m;QPp$;vP8OmG<-~CS=ROBq9AF2VVO<|( z1#)lDH^Wl#f8fB$26E(kR@Eh2)5$&;w#}ypAd}aJ+bYHuUr zR24bn+T}!6%0skey}1fT<2U*DC8SU7GWz-`x`xwctth3DTKRtBB$aoK%g9R{q$ks& z@{cTYj%Nw}p+?fav#5gnm9NFq+d|^vT_3eT7@1X7_d{IqGp83s#j=MYEZ>p#CRh-v z2x&l%k+_g%^iq;+e6&8;NXTBHq$atCQfF|=kW8S2pqtcBC~EQETX1Z}X}|e~R7fW! z6OCMYB4;#uW*EORvjg7>9qtu31_N4N*P;HrWj3hVU{4-x{v_b*XACZlpdVMKfQCcx zijMi_FdI`m$xrX*QPy|<1J|%E{^9^FsZ8RDHJhKj=Xc}?k_&pSE+u+Xe$y(3yy!7R znK(jd_Krgty%rh1^oP+2A`z+P{0e$-Q$mDj_+IXhL(dp5_bRl94X_WoOAa9%R?91l#9&G2K=e! zYejm&-1dD!{x@0uH$Co&?8=oM4A=0ZUxO74asLXDdzq5t$XRKJX zD?}04qIIoqkDq@X`^WA2qZ-TSB=rwwocz>I7rY!*m8aQj|K8E%s8fe}c-+nesumAY za>JmyQbGq4Mx&Dv2n7M$yl?d$R>*fseCjLtP%hg11;Rp%rG`&rET!V2Y+C0NWmE8N zn3M+J*l>8j+0-lugVwB18-_q2d%n^l`wZu}r(K=Kh3% z^s`smO-W4d5lhI6h?90DbdjkFRF~GC+>beu!3@u%;8+#3P!uDDtf#g^lYvp-m=z); zPn$16f1^#c9VZhch%^hWU>*3rp8?1R4Pi= zM?1EB@%=1WH@m zP_}fXxM|f%FeKeLcx_ih7)t8Arihs`tzg1`#?tuOs>!qMsuEpR$k*xhv(3qV0t}%g z!~(EU(aTP&=l@G5!?A_AZOI@w%R1H-WkYHam3vFcm*6)xZ-bRCPobS8?W9)KBKJ#7 znySLWWFsscqDuqTaUE+ig~$@`KV{hzVlr7O@wi0eyYPPqGVmMS$hA2ye{g;(`eC`d zlIVCLY3Um8lSEE@OGCl!=zk+1rbol#~bEgkBUt%45LbdYauU$wgtj z-P>EbzV_(h$0&hUo-@w2&VSlQM|lGR*Y6 zfoX}v;d5(J)P7aIvj(ry#h~KaypS579J3IItb?ESqcvVS-;&WI^)!(2vYi$rwpK$( zsm;o5S1L(O6rKzC$En&l>UhGcV^%d#VS9_%fP?J7kdO)zM#!!cYL`@iq2-c+&T!ap zMvkWu){{)V40C#!(%u7sYW|$g1?UK?$j|7R`W#cF4RW|7o^ZqHM`u#i@d0m_@$cmi zTcf-IrMwjU+}>j%>UcEg#0K`)Nx$+M9jrLf>-E1Iy^%oDs9vk)MLx+54C^7FKwgsR zX-}oRErGqktwVE$HJrzYMx20wS_M{Zj0&yJ{+xgYVp7^;bTXDUOw<)iqD@DvjkDC( zI6F%g`2@MSG)J+##ov;hchmtwb-m{IaYuh)USnz*-zF&KNH*#RP5vBG--4lo|33iX zKpwvjB}K}{_+nRfWmk6Pja)(=)A8=guzG}CZaRxpV1njguBGpL)MUlf^de95o1(Fy zY#u;rG=ia8acy)^oT$a*2}?Gor5=rgU*#K)PAmX!D`oO?s6cF}XfiYh1oVy8H3$d+ z#@zB+>Sl$kybLE_(eh{9dzYyM>+HCWK5o(&I++7V6hz9{?%l>&V38hr@-|~hDljE* z`jWB@SKXsDGbnYPwRhF7+I#QN-aQeHe+hKeuD$1Ds%l>vTWj9^==$5DW0!Yau)Wi= z@!y>Lt(x_=M$13H(TCW3;O%8JpkZdbZ$5VBP}Ah%%EI>mvNQw5KKUv@+j zMHftlXC+1LO=@fbDxsytd7IF*LshMsJ?K!Xf+7O!kq9fh=@j%)3(a2kkk%pS#t_(0 ztRV^F_*85JDl4krPYM|VL{Kp;-wqU`0}zgxm)^ou+%-wH@G#ew%bD9snlknT+vRw( zkuJr`z@Iw2qV?Ts5K%TnzKgMIkvcKPpm~5C35HBBWdX3Xg%)$EnLWob4l4o;Ut^Rq27m_G4X}3^$0^-Pr3A6tdt>cBh8tNcu^k$FcPqDB8H)QBl622G9c)4cpDTNMzE1*?cw-N0r|rP}roJkZsSh(Z8#D0;3bX@VYp z5A4dW?8+BX>UNDmvVWOcuYZ1-TVHhG%isDJSEwPI?>A5`1DomVa!Ktwi%5&OxuW~! z-oGI{b5k#nsP0>fJ)zbxXiNE~L>I99X`|jH4y!*GU18B=e25*Xw53}=j$_IFH9{fs zozS^zElXWss0BmHG;B1q2sIz{2rcStzUUoDg`r}UaV+ttHkFN^Hp^Jy*w1+8HEgmA zI(LmkXP)@a?^gtmYG7$8FfEB=RmHRnkd;j9P#Z1mlV2`%({11D;>XO&SiT*B)W$-n zKu2oG2w-V`245>;-Bzv`|6CN(Du37Vr{pu4XPA8rXr0*~_PNf1=f+=dw zFfvP>g=OC?gkdxZF`~6l!^L+j03;=qQ_EVVz{@fn!L*Cu*qm!DO66IURtg=0VpWRQ zQbwKW=yI2eO{>scH@2M&>jhy+bipg!Xgyr;Fe7R+-3Nb#cW$XX2Rr!I?Si;&DvZ_YeG*AWGm0j7DdzV1(T~&LZ=UE^u z05DChh{@N#byaPx9qvz?zpgvh>$&&et@|iLhzfCrNz}c)z14*EjP-hr-<^TKex=y% zslNJ-TI6rhqwPaCeX-Y*%v(q4Ti!zP@s?8uWlPpZz9<{jx*E#f-a{W8{v1>a@6@7l z@jd-jW@~$wIFitqa$MB!@=2%ZW*O)-?ArGFIGlhRsGp_uXRO?J{tYE+Fv9bRt;5_p z{Ec=7ZAi+fwsFDbm0&B+6sjso&F;n~UUehcm{?t2KBtRUw0>K?)EoV|CrxztB+GAg z1%wL=%WB!taLA159yYboh4)EO)b{1zB>ySl)`Oq94tvy|w^2(H|2RCO>oUb? z(ZWLy4E>kgP|xOoWeHGPBLynwdABl--&$V^=;cyNm72=sa_q}|UF0|v;1E^I@K=5g zL`G~MbRMEDoP7(MGBSdd$Jmw82HB~>_nEcP*vT< z8Q+)AZ}WKbF)hF9Mx<9z+vLPnf#us&YzV_tj27b>Tvp8rqlqM*llo=#LT}D#358fd zI}D3B_W@sKgB0ich0+86Yn~VMUq=a6E6Fbxi#bdH!)#V@GCHEi-?%zs(;DK5>R!ef z%y5r!M^FW}@E$jOp`{rTlXFR<-EZ=2qol zI%-GarT(tkk?xB0=8K|h7msTayOt?eE$0?_Dp>&(F_w zn%Zi$YIec->x6GjHmon!UA0Z^cRu~;XPvGmv-_}e1Ec?@z zehq5XYlsEE*AoK{NJSthD#Q2Nd%mrH9iu9#ibur@vKW<;3u?Pdtx?^}31SJ1Lzht}vv-CEYE|PirqfmhG{cy~!xpOl zh^P}|xtiz zfO%NG)E2cQTT7h%c77N z7#1IvO~?ZEN#V5OH?^tquBw!X{lzSoEpnJK4IU$;DZijvAW_I-H4^N^QW#t0ENqG( zO17cqp>K7G+1xE0t>I~xp@%`h5;AkSkgL;Cu`4G_Ri#=bv_7zqLE~mlWGRcaDa(iN zrDn0|FoDU&TN~0czs$!n8T-)NvRp1Pd~K%J1tJem(xNmQZhxo_rh>(@%Z4z-^9=z$ z04KV#JyY%|hZ!*)l$#+xG0qN2Qw6OcSSeMHklyM!mWQos;iwM^*r&$Y#oS3;vUUM| z2~++1Whz`|i8eL@&+s5r2OoM9WSTE7j*pM0X?1Y0|D_{ani4Klb$@@gzrXkQ{@#cFkN=nd>+k&br*6OS z%K7=(qi=fb`pugUJowNPZ-4x4Z+q)^eCM}+@LRqCd+p?G{kj+wRJC_KJw3jA=asYb z)Ahy0`r-o6tJiMcdinW}{|~?L>%a6rKJ)o!?tkd9w>+jvzLe`7V^K!+a@xni$SzuDqfagQq_c#F8Bj zQYWj4FL>on$^t=}BP+9qkdY9p%dM(3&#h`xUIq0e7Oe70U=c1~PKl;?Ed~pA#sthI z0&iD#WmhgPE>2I+4)^!kYW^So)?fMbZ+)t-`;D8|kB{$s-~;dd$glj$?OS)AdG@&{ z-u2!;{v&_t@yDNd<@oNkD_74hF7DnvJ~-Il+uQq6T3@f%^Sr(|KRZ6Vd+XLqANiG! z{+0jo$A0;je`U3r4i5MC5B6@|dg=V^^rY#D=f3|3f2adzC+CAg_?0dSZPNs46uq_=!}J|`<}HD| z=e<{t3zUd5U#f?jD)`nvM?g1dL>ha-@RKaJJK+P%t!#<0W3QvKogn}lP3K0mn5e`w zuAdg&S?d;8dpmx)^{_x4;3YfHrrkd>NQK&hZ4|D%^k${^F11$+N-il@-?_Y6t1jUltIq6N1cv%pvWym{Ig?~ zN>6RE@*rnm z5s;D;DCG6rG~OR$l)-sAyny6tHx7&$-8h@ox|DEkIMLwirHJA&LZITS1nLO_1CcsG zurP*j@dCOq0|2$=&GnqJpB6wq6+M+YsH~?7Kt8T9aTP`L6)rj_RE-ZGc5%}ReKUIluQbO!g!pH5a}9%ODXG9f@U{-bit>A{KscXr+DL$^?MnW zQ}$a)Di!IdwZd|Ws1C7gRWQ=!RuuyOlC8=Pd77pwUAyESQnisGoKdw4x2n0%+qaKh z*_E&2qN-7V=`{_d5%tBQW>m6wl>4%g@N`Po`!{_ux? z;qKj+9)J98U;DM+@C_gQ{==h#)6(0HtrS*qn2BQD0=H(+lG}})Z*8W-8%l1YN?u*gWN>8RdsZw^gOl6UJ^D*-O-->m;rmm$~%sL%rZ(GTS(jZosbmb)4FiPK|#7tQO@oQ8>sWr+);fQKIr6;xXNdplP zG2MI-cSm-r1RF?HJ0?*X9|$a=<=1RBUKA{eKlX4z8EkyH_;a~}O69R87VXOT->u|| zp9&WRLra}&{}g*ml0>3Ei}hO|qwwux>(NgqX;dh~(7>H3 z0WkSkW$3JywiW_2{z?9k44SONRB=(foux+x&;`NA%>spJ$(Sf2CG5+=$shxiVWPnE z%~cZ|8$0Nt^$o_WF+QX`0EIRY)dncgB=>K(TiM>Z+CMt}+`sw9zj-NB-#t@$t!LKK1Ee__?3EdUfyXzv-Ly_pk1)R{Q&H?(5UDi>Wm`UH=+O?|N}@ zF|SW=+&H>*>*fFKKmXDH{QvQzpZJYWwbt5d(xyOPuXXR>;M&ax_OD*wKfH2!etvd( zeC^ux>o;!Px^?$gKk~7Ue(X2i^vENRKmN924}7iVG|o|-fB|S1{@w9-n@S0^yKcxKJqIc`Q=~OKR9~N z`#-R1)0ZBEKeaYZUjPBOmOCdG|Iy$4)%ox~^fd&nHMd}=xOQ=@l3xmdCN*vJmsCyy) ziuj;uRaI=Gub?34_DOZjuav_&J{tPLX|Nh?Ek>{3yqg$@I$H`d_7X0bVz zYO$2%$g;Vip$)iIu)&h%LW@h(KaO$ej{~b92sdI{y5>v4ixxvMVg>^?ZJQc67M9cJ1ov-4}oT zBft3ZU;D^^`IrCd$AA5kC#NUp{rvd!q-$?eoA&n3*JldR-fq2ee0FkieCMUdpLomL z-}xRzYl4IQl>q1G7a~(@uW50p>iPLupUx(rY_ucov!O@|JeCkub^J~BQ%Wr!0;rG7x-DAFy zS84suojad@<}-JXZv!H$sWlPN)~2>0JMYv1^--`<+^d6{|p3o5S@q+kC>|J|?62L|aQ zK~uG>=7yU)19i*S;5pm+?RHpu+0*6M`ar}XzS@JE?Y>H{UmMol+RUrJcli^pYZRrR zD#07xtK5V)>2S|(3_7xG{pkxILG*BA+YTLS36Ec6u_1PC`+L)sE3#TuZ~4$lft3?4u|j#+>0_z6OH9q_AwF z?z*V?KuNzh5VL${|3U(=`zWk0k^5xZc`^!~#a5MPzJ|3E9nY<*1vPlifODy86QJeT z47VQJ@a)^RT6JK{y;!@lD_>BoNM7k~ZfXP#Zp zYlR-{?*nptcDkPD)>h}I=ks2_m@goh=l0nrpMLS#r`H!Ru2v6ph)BP3v_CbudvY#v z@LEv)#rZjKes~~9hew}%>a+jV-}tZY+`4`JzMEaJK0mo4{rZ)I2d-Q>Iy}5P>+^R{ zkIyd7PVOG;?eDEtXXod)Z{O-7hlht(jt)Qh=}-TA|NDRc=*rda{hseSJAajwb!JUI zzJ2?d&wcvz^zJmRAh{>(A07PmM}FyV{na0R_Sw(Ba(uB8toB!P?+x>OcDg>ln5L=C za(w&zm4<8AuN)m7eeUzm{s;fNKX>)Y)qnO+{|o4S?)Pf_%TN^B+5~VTD3*5WX@b!m z?n2V_)bVim<@?WA#u?Y~mqiWLGQ9X{`D6(5%M_qX>Irl>0-8(900hR`^tBVAzEJDg zs_E3W%jbKvb@@-rfcEm_tA&x*j$QJlZhX0@{EIF+{@3)-_(MXO2BLm)AaD zulm?!58Rt?v5k?Izhzk^%kX6H<_k*bm#0#}Y*Ma}R%DjyWmR*R*rI4;vdw+8z&-TN zxTr`ivnqF`=Hfyh(>0Hzshax6W2v@ABR;ro#PvMM)zlQ8w#|#aM%6z*M5E+9r`v`J zplGW|H8(~D9@Wk!>eBRwg)I&uXc6Eru10%&Q+H3TSn@b)>h48}w<-(cd60JPgVbyR zi{58!avAwZ%;h?iT(xzK+h@Og*6|yWCDP;1)x_Xz;K#z<_K~s5mF6T_01FTO`v0_bIS zfL+;@ui7GVc6xTOFITVZ1MTeegveD1kM{RY&d&7wxT(DD!3Q6?|NfgdZuH*o+`aS4 z@$n0Hk5A|R;EkJ~fAQA8|G)iTKla$0f8Ymy==VYzd=2BZrnccbyZ}ALymk~%z3<}^ zyWiG19F#OyANc2}{@F&ig&6fHBhpHn3B$&nWV7A;W}y+J##-zW^%;)+9+khw*-MD9 zS^xNX(DpZ&L$(fWOAkkHiKeb33v+gPHSpdL=vJ@I_EBuiPHgNW zqtpkfj0AwMDpA$a5Rf&_Mc#9KQmWEnUc8yDfp|L+NWM}?0pl}%FQm2GzkKkLd;dVd^x+TGF2Mu-@L)a5H6U6nA%THw zj}uCBkfp6$#r%

+ + + + + +
+
+ ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/organization_stats.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/organization_stats.test.tsx new file mode 100644 index 0000000000000..112e9a910667a --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/organization_stats.test.tsx @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import '../../../__mocks__/shallow_usecontext.mock'; + +import React from 'react'; +import { shallow } from 'enzyme'; +import { EuiFlexGrid } from '@elastic/eui'; + +import { OrganizationStats } from './organization_stats'; +import { StatisticCard } from './statistic_card'; +import { defaultServerData } from './overview'; + +describe('OrganizationStats', () => { + it('renders', () => { + const wrapper = shallow(); + + expect(wrapper.find(StatisticCard)).toHaveLength(2); + expect(wrapper.find(EuiFlexGrid).prop('columns')).toEqual(2); + }); + + it('renders additional cards for federated auth', () => { + const wrapper = shallow(); + + expect(wrapper.find(StatisticCard)).toHaveLength(4); + expect(wrapper.find(EuiFlexGrid).prop('columns')).toEqual(4); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/organization_stats.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/organization_stats.tsx new file mode 100644 index 0000000000000..aa9be81f32bae --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/organization_stats.tsx @@ -0,0 +1,74 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { EuiFlexGrid } from '@elastic/eui'; + +import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; + +import { ContentSection } from '../shared/content_section'; +import { ORG_SOURCES_PATH, USERS_PATH } from '../../routes'; + +import { IAppServerData } from './overview'; + +import { StatisticCard } from './statistic_card'; + +export const OrganizationStats: React.FC = ({ + sourcesCount, + pendingInvitationsCount, + accountsCount, + personalSourcesCount, + isFederatedAuth, +}) => ( + + } + headerSpacer="m" + > + + + {!isFederatedAuth && ( + <> + + + + )} + + + +); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/overview.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/overview.test.tsx new file mode 100644 index 0000000000000..e5e5235c52368 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/overview.test.tsx @@ -0,0 +1,77 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import '../../../__mocks__/react_router_history.mock'; + +import React from 'react'; +import { shallow } from 'enzyme'; + +import { mountWithAsyncContext, mockKibanaContext } from '../../../__mocks__'; + +import { ErrorState } from '../error_state'; +import { Loading } from '../shared/loading'; +import { ViewContentHeader } from '../shared/view_content_header'; + +import { OnboardingSteps } from './onboarding_steps'; +import { OrganizationStats } from './organization_stats'; +import { RecentActivity } from './recent_activity'; +import { Overview, defaultServerData } from './overview'; + +describe('Overview', () => { + const mockHttp = mockKibanaContext.http; + + describe('non-happy-path states', () => { + it('isLoading', () => { + const wrapper = shallow(); + + expect(wrapper.find(Loading)).toHaveLength(1); + }); + + it('hasErrorConnecting', async () => { + const wrapper = await mountWithAsyncContext(, { + http: { + ...mockHttp, + get: () => Promise.reject({ invalidPayload: true }), + }, + }); + + expect(wrapper.find(ErrorState)).toHaveLength(1); + }); + }); + + describe('happy-path states', () => { + it('renders onboarding state', async () => { + const mockApi = jest.fn(() => defaultServerData); + const wrapper = await mountWithAsyncContext(, { + http: { ...mockHttp, get: mockApi }, + }); + + expect(wrapper.find(ViewContentHeader)).toHaveLength(1); + expect(wrapper.find(OnboardingSteps)).toHaveLength(1); + expect(wrapper.find(OrganizationStats)).toHaveLength(1); + expect(wrapper.find(RecentActivity)).toHaveLength(1); + }); + + it('renders when onboarding complete', async () => { + const obCompleteData = { + ...defaultServerData, + hasUsers: true, + hasOrgSources: true, + isOldAccount: true, + organization: { + name: 'foo', + defaultOrgName: 'bar', + }, + }; + const mockApi = jest.fn(() => obCompleteData); + const wrapper = await mountWithAsyncContext(, { + http: { ...mockHttp, get: mockApi }, + }); + + expect(wrapper.find(OnboardingSteps)).toHaveLength(0); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/overview.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/overview.tsx new file mode 100644 index 0000000000000..bacd65a2be75f --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/overview.tsx @@ -0,0 +1,151 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useContext, useEffect, useState } from 'react'; +import { EuiPage, EuiPageBody, EuiSpacer } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +import { SetWorkplaceSearchBreadcrumbs as SetBreadcrumbs } from '../../../shared/kibana_breadcrumbs'; +import { SendWorkplaceSearchTelemetry as SendTelemetry } from '../../../shared/telemetry'; +import { KibanaContext, IKibanaContext } from '../../../index'; + +import { IAccount } from '../../types'; + +import { ErrorState } from '../error_state'; + +import { Loading } from '../shared/loading'; +import { ProductButton } from '../shared/product_button'; +import { ViewContentHeader } from '../shared/view_content_header'; + +import { OnboardingSteps } from './onboarding_steps'; +import { OrganizationStats } from './organization_stats'; +import { RecentActivity, IFeedActivity } from './recent_activity'; + +export interface IAppServerData { + hasUsers: boolean; + hasOrgSources: boolean; + canCreateContentSources: boolean; + canCreateInvitations: boolean; + isOldAccount: boolean; + sourcesCount: number; + pendingInvitationsCount: number; + accountsCount: number; + personalSourcesCount: number; + activityFeed: IFeedActivity[]; + organization: { + name: string; + defaultOrgName: string; + }; + isFederatedAuth: boolean; + currentUser: { + firstName: string; + email: string; + name: string; + color: string; + }; + fpAccount: IAccount; +} + +export const defaultServerData = { + accountsCount: 1, + activityFeed: [], + canCreateContentSources: true, + canCreateInvitations: true, + currentUser: { + firstName: '', + email: '', + name: '', + color: '', + }, + fpAccount: {} as IAccount, + hasOrgSources: false, + hasUsers: false, + isFederatedAuth: true, + isOldAccount: false, + organization: { + name: '', + defaultOrgName: '', + }, + pendingInvitationsCount: 0, + personalSourcesCount: 0, + sourcesCount: 0, +} as IAppServerData; + +const ONBOARDING_HEADER_TITLE = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.overviewOnboardingHeader.title', + { defaultMessage: 'Get started with Workplace Search' } +); + +const HEADER_TITLE = i18n.translate('xpack.enterpriseSearch.workplaceSearch.overviewHeader.title', { + defaultMessage: 'Organization overview', +}); + +const ONBOARDING_HEADER_DESCRIPTION = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.overviewOnboardingHeader.description', + { defaultMessage: 'Complete the following to set up your organization.' } +); + +const HEADER_DESCRIPTION = i18n.translate( + 'xpack.enterpriseSearch.workplaceSearch.overviewHeader.description', + { defaultMessage: "Your organizations's statistics and activity" } +); + +export const Overview: React.FC = () => { + const { http } = useContext(KibanaContext) as IKibanaContext; + + const [isLoading, setIsLoading] = useState(true); + const [hasErrorConnecting, setHasErrorConnecting] = useState(false); + const [appData, setAppData] = useState(defaultServerData); + + const getAppData = async () => { + try { + const response = await http.get('/api/workplace_search/overview'); + setAppData(response); + } catch (error) { + setHasErrorConnecting(true); + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + getAppData(); + }, []); + + if (hasErrorConnecting) return ; + if (isLoading) return ; + + const { + hasUsers, + hasOrgSources, + isOldAccount, + organization: { name: orgName, defaultOrgName }, + } = appData as IAppServerData; + const hideOnboarding = hasUsers && hasOrgSources && isOldAccount && orgName !== defaultOrgName; + + const headerTitle = hideOnboarding ? HEADER_TITLE : ONBOARDING_HEADER_TITLE; + const headerDescription = hideOnboarding ? HEADER_DESCRIPTION : ONBOARDING_HEADER_DESCRIPTION; + + return ( + + + + + + } + /> + {!hideOnboarding && } + + + + + + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/recent_activity.scss b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/recent_activity.scss new file mode 100644 index 0000000000000..2d1e474c03faa --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/recent_activity.scss @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +.activity { + display: flex; + justify-content: space-between; + padding: $euiSizeM; + font-size: $euiFontSizeS; + + &--error { + font-weight: $euiFontWeightSemiBold; + color: $euiColorDanger; + background: rgba($euiColorDanger, 0.1); + + &__label { + margin-left: $euiSizeS * 1.75; + font-weight: $euiFontWeightRegular; + text-decoration: underline; + opacity: 0.7; + } + } + + &__message { + flex-grow: 1; + } + + &__date { + flex-grow: 0; + } + + & + & { + border-top: $euiBorderThin; + } +} diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/recent_activity.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/recent_activity.test.tsx new file mode 100644 index 0000000000000..e9bdedb199dad --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/recent_activity.test.tsx @@ -0,0 +1,61 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import '../../../__mocks__/shallow_usecontext.mock'; + +import React from 'react'; +import { shallow } from 'enzyme'; + +import { EuiEmptyPrompt, EuiLink } from '@elastic/eui'; + +import { RecentActivity, RecentActivityItem } from './recent_activity'; +import { defaultServerData } from './overview'; + +jest.mock('../../../shared/telemetry', () => ({ sendTelemetry: jest.fn() })); +import { sendTelemetry } from '../../../shared/telemetry'; + +const org = { name: 'foo', defaultOrgName: 'bar' }; + +const feed = [ + { + id: 'demo', + sourceId: 'd2d2d23d', + message: 'was successfully connected', + target: 'http://localhost:3002/ws/org/sources', + timestamp: '2020-06-24 16:34:16', + }, +]; + +describe('RecentActivity', () => { + it('renders with no feed data', () => { + const wrapper = shallow(); + + expect(wrapper.find(EuiEmptyPrompt)).toHaveLength(1); + + // Branch coverage - renders without error for custom org name + shallow(); + }); + + it('renders an activity feed with links', () => { + const wrapper = shallow(); + const activity = wrapper.find(RecentActivityItem).dive(); + + expect(activity).toHaveLength(1); + + const link = activity.find('[data-test-subj="viewSourceDetailsLink"]'); + link.simulate('click'); + expect(sendTelemetry).toHaveBeenCalled(); + }); + + it('renders activity item error state', () => { + const props = { ...feed[0], status: 'error' }; + const wrapper = shallow(); + + expect(wrapper.find('.activity--error')).toHaveLength(1); + expect(wrapper.find('.activity--error__label')).toHaveLength(1); + expect(wrapper.find(EuiLink).prop('color')).toEqual('danger'); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/recent_activity.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/recent_activity.tsx new file mode 100644 index 0000000000000..8d69582c93684 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/recent_activity.tsx @@ -0,0 +1,131 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useContext } from 'react'; + +import moment from 'moment'; + +import { EuiEmptyPrompt, EuiLink, EuiPanel, EuiSpacer, EuiLinkProps } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; + +import { ContentSection } from '../shared/content_section'; +import { useRoutes } from '../shared/use_routes'; +import { sendTelemetry } from '../../../shared/telemetry'; +import { KibanaContext, IKibanaContext } from '../../../index'; +import { getSourcePath } from '../../routes'; + +import { IAppServerData } from './overview'; + +import './recent_activity.scss'; + +export interface IFeedActivity { + status?: string; + id: string; + message: string; + timestamp: string; + sourceId: string; +} + +export const RecentActivity: React.FC = ({ + organization: { name, defaultOrgName }, + activityFeed, +}) => { + return ( + + } + headerSpacer="m" + > + + {activityFeed.length > 0 ? ( + <> + {activityFeed.map((props: IFeedActivity, index) => ( + + ))} + + ) : ( + <> + + + {name === defaultOrgName ? ( + + ) : ( + + )} + + } + /> + + + )} + + + ); +}; + +export const RecentActivityItem: React.FC = ({ + id, + status, + message, + timestamp, + sourceId, +}) => { + const { http } = useContext(KibanaContext) as IKibanaContext; + const { getWSRoute } = useRoutes(); + + const onClick = () => + sendTelemetry({ + http, + product: 'workplace_search', + action: 'clicked', + metric: 'recent_activity_source_details_link', + }); + + const linkProps = { + onClick, + target: '_blank', + href: getWSRoute(getSourcePath(sourceId)), + external: true, + color: status === 'error' ? 'danger' : 'primary', + 'data-test-subj': 'viewSourceDetailsLink', + } as EuiLinkProps; + + return ( +
+
+ + {id} {message} + {status === 'error' && ( + + {' '} + + + )} + +
+
{moment.utc(timestamp).fromNow()}
+
+ ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/statistic_card.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/statistic_card.test.tsx new file mode 100644 index 0000000000000..edf266231b39e --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/statistic_card.test.tsx @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import '../../../__mocks__/shallow_usecontext.mock'; + +import React from 'react'; +import { shallow } from 'enzyme'; + +import { EuiCard } from '@elastic/eui'; + +import { StatisticCard } from './statistic_card'; + +const props = { + title: 'foo', +}; + +describe('StatisticCard', () => { + it('renders', () => { + const wrapper = shallow(); + + expect(wrapper.find(EuiCard)).toHaveLength(1); + }); + + it('renders clickable card', () => { + const wrapper = shallow(); + + expect(wrapper.find(EuiCard).prop('href')).toBe('http://localhost:3002/ws/foo'); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/statistic_card.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/statistic_card.tsx new file mode 100644 index 0000000000000..9bc8f4f768073 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/overview/statistic_card.tsx @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; + +import { EuiCard, EuiFlexItem, EuiTitle, EuiTextColor } from '@elastic/eui'; + +import { useRoutes } from '../shared/use_routes'; + +interface IStatisticCardProps { + title: string; + count?: number; + actionPath?: string; +} + +export const StatisticCard: React.FC = ({ title, count = 0, actionPath }) => { + const { getWSRoute } = useRoutes(); + + const linkProps = actionPath + ? { + href: getWSRoute(actionPath), + target: '_blank', + rel: 'noopener', + } + : {}; + // TODO: When we port this destination to Kibana, we'll want to create a EuiReactRouterCard component (see shared/react_router_helpers/eui_link.tsx) + + return ( + + + {count} + + } + /> + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/setup_guide/index.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/setup_guide/index.ts new file mode 100644 index 0000000000000..c367424d375f9 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/setup_guide/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { SetupGuide } from './setup_guide'; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/setup_guide/setup_guide.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/setup_guide/setup_guide.test.tsx new file mode 100644 index 0000000000000..b87c35d5a5942 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/setup_guide/setup_guide.test.tsx @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { shallow } from 'enzyme'; + +import { SetWorkplaceSearchBreadcrumbs as SetBreadcrumbs } from '../../../shared/kibana_breadcrumbs'; +import { SetupGuide as SetupGuideLayout } from '../../../shared/setup_guide'; +import { SetupGuide } from './'; + +describe('SetupGuide', () => { + it('renders', () => { + const wrapper = shallow(); + + expect(wrapper.find(SetupGuideLayout)).toHaveLength(1); + expect(wrapper.find(SetBreadcrumbs)).toHaveLength(1); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/setup_guide/setup_guide.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/setup_guide/setup_guide.tsx new file mode 100644 index 0000000000000..5b5d067d23eb8 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/setup_guide/setup_guide.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { EuiSpacer, EuiTitle, EuiText, EuiButton } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; + +import { SetupGuide as SetupGuideLayout } from '../../../shared/setup_guide'; + +import { SetWorkplaceSearchBreadcrumbs as SetBreadcrumbs } from '../../../shared/kibana_breadcrumbs'; +import { SendWorkplaceSearchTelemetry as SendTelemetry } from '../../../shared/telemetry'; +import GettingStarted from '../../assets/getting_started.png'; + +const GETTING_STARTED_LINK_URL = + 'https://www.elastic.co/guide/en/workplace-search/current/workplace-search-getting-started.html'; + +export const SetupGuide: React.FC = () => { + return ( + + + + +
+ {i18n.translate('xpack.enterpriseSearch.workplaceSearch.setupGuide.imageAlt', + + + +

+ +

+
+ + + Get started with Workplace Search + + + +

+ +

+
+ + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/assets/share_circle.svg b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/assets/share_circle.svg new file mode 100644 index 0000000000000..f8d2ea1e634f6 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/assets/share_circle.svg @@ -0,0 +1,3 @@ + + + diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/content_section/content_section.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/content_section/content_section.test.tsx new file mode 100644 index 0000000000000..f406fb136f13f --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/content_section/content_section.test.tsx @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import '../../../../__mocks__/shallow_usecontext.mock'; + +import React from 'react'; +import { shallow } from 'enzyme'; +import { EuiTitle, EuiSpacer } from '@elastic/eui'; + +import { ContentSection } from './'; + +const props = { + children:
, + testSubj: 'contentSection', + className: 'test', +}; + +describe('ContentSection', () => { + it('renders', () => { + const wrapper = shallow(); + + expect(wrapper.prop('data-test-subj')).toEqual('contentSection'); + expect(wrapper.prop('className')).toEqual('test'); + expect(wrapper.find('.children')).toHaveLength(1); + }); + + it('displays title and description', () => { + const wrapper = shallow(); + + expect(wrapper.find(EuiTitle)).toHaveLength(1); + expect(wrapper.find('p').text()).toEqual('bar'); + }); + + it('displays header content', () => { + const wrapper = shallow( + } + /> + ); + + expect(wrapper.find(EuiSpacer).prop('size')).toEqual('s'); + expect(wrapper.find('.header')).toHaveLength(1); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/content_section/content_section.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/content_section/content_section.tsx new file mode 100644 index 0000000000000..b2a9eebc72e85 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/content_section/content_section.tsx @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; + +import { EuiSpacer, EuiTitle } from '@elastic/eui'; + +import { TSpacerSize } from '../../../types'; + +interface IContentSectionProps { + children: React.ReactNode; + className?: string; + title?: React.ReactNode; + description?: React.ReactNode; + headerChildren?: React.ReactNode; + headerSpacer?: TSpacerSize; + testSubj?: string; +} + +export const ContentSection: React.FC = ({ + children, + className = '', + title, + description, + headerChildren, + headerSpacer, + testSubj, +}) => ( +
+ {title && ( + <> + +

{title}

+
+ {description &&

{description}

} + {headerChildren} + {headerSpacer && } + + )} + {children} +
+); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/content_section/index.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/content_section/index.ts new file mode 100644 index 0000000000000..7dcb1b13ad1dc --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/content_section/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { ContentSection } from './content_section'; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/loading/index.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/loading/index.ts new file mode 100644 index 0000000000000..745639955dcba --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/loading/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { Loading } from './loading'; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/loading/loading.scss b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/loading/loading.scss new file mode 100644 index 0000000000000..008a8066f807b --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/loading/loading.scss @@ -0,0 +1,14 @@ +.loadingSpinnerWrapper { + width: 100%; + height: 90vh; + margin: auto; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; +} + +.loadingSpinner { + width: $euiSizeXXL * 1.25; + height: $euiSizeXXL * 1.25; +} diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/loading/loading.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/loading/loading.test.tsx new file mode 100644 index 0000000000000..8d168b436cc3b --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/loading/loading.test.tsx @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import '../../../../__mocks__/shallow_usecontext.mock'; + +import React from 'react'; +import { shallow } from 'enzyme'; +import { EuiLoadingSpinner } from '@elastic/eui'; + +import { Loading } from './'; + +describe('Loading', () => { + it('renders', () => { + const wrapper = shallow(); + + expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(1); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/loading/loading.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/loading/loading.tsx new file mode 100644 index 0000000000000..399abedf55e87 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/loading/loading.tsx @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; + +import { EuiLoadingSpinner } from '@elastic/eui'; + +import './loading.scss'; + +export const Loading: React.FC = () => ( +
+ +
+); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/product_button/index.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/product_button/index.ts new file mode 100644 index 0000000000000..c41e27bacb892 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/product_button/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { ProductButton } from './product_button'; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/product_button/product_button.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/product_button/product_button.test.tsx new file mode 100644 index 0000000000000..429a2c509813d --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/product_button/product_button.test.tsx @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import '../../../../__mocks__/shallow_usecontext.mock'; + +import React from 'react'; +import { shallow } from 'enzyme'; +import { EuiButton } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; + +import { ProductButton } from './'; + +jest.mock('../../../../shared/telemetry', () => ({ + sendTelemetry: jest.fn(), + SendAppSearchTelemetry: jest.fn(), +})); +import { sendTelemetry } from '../../../../shared/telemetry'; + +describe('ProductButton', () => { + it('renders', () => { + const wrapper = shallow(); + + expect(wrapper.find(EuiButton)).toHaveLength(1); + expect(wrapper.find(FormattedMessage)).toHaveLength(1); + }); + + it('sends telemetry on create first engine click', () => { + const wrapper = shallow(); + const button = wrapper.find(EuiButton); + + button.simulate('click'); + expect(sendTelemetry).toHaveBeenCalled(); + (sendTelemetry as jest.Mock).mockClear(); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/product_button/product_button.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/product_button/product_button.tsx new file mode 100644 index 0000000000000..5b86e14132e0f --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/product_button/product_button.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useContext } from 'react'; + +import { EuiButton, EuiButtonProps, EuiLinkProps } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; + +import { sendTelemetry } from '../../../../shared/telemetry'; +import { KibanaContext, IKibanaContext } from '../../../../index'; + +export const ProductButton: React.FC = () => { + const { enterpriseSearchUrl, http } = useContext(KibanaContext) as IKibanaContext; + + const buttonProps = { + fill: true, + iconType: 'popout', + 'data-test-subj': 'launchButton', + } as EuiButtonProps & EuiLinkProps; + buttonProps.href = `${enterpriseSearchUrl}/ws`; + buttonProps.target = '_blank'; + buttonProps.onClick = () => + sendTelemetry({ + http, + product: 'workplace_search', + action: 'clicked', + metric: 'header_launch_button', + }); + + return ( + + + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/use_routes/index.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/use_routes/index.ts new file mode 100644 index 0000000000000..cb9684408c459 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/use_routes/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { useRoutes } from './use_routes'; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/use_routes/use_routes.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/use_routes/use_routes.tsx new file mode 100644 index 0000000000000..48b8695f82b43 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/use_routes/use_routes.tsx @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { useContext } from 'react'; + +import { KibanaContext, IKibanaContext } from '../../../../index'; + +export const useRoutes = () => { + const { enterpriseSearchUrl } = useContext(KibanaContext) as IKibanaContext; + const getWSRoute = (path: string): string => `${enterpriseSearchUrl}/ws${path}`; + return { getWSRoute }; +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/view_content_header/index.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/view_content_header/index.ts new file mode 100644 index 0000000000000..774b3d85c8c85 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/view_content_header/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { ViewContentHeader } from './view_content_header'; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/view_content_header/view_content_header.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/view_content_header/view_content_header.test.tsx new file mode 100644 index 0000000000000..4680f15771caa --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/view_content_header/view_content_header.test.tsx @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import '../../../../__mocks__/shallow_usecontext.mock'; + +import React from 'react'; +import { shallow } from 'enzyme'; +import { EuiFlexGroup } from '@elastic/eui'; + +import { ViewContentHeader } from './'; + +const props = { + title: 'Header', + alignItems: 'flexStart' as any, +}; + +describe('ViewContentHeader', () => { + it('renders with title and alignItems', () => { + const wrapper = shallow(); + + expect(wrapper.find('h2').text()).toEqual('Header'); + expect(wrapper.find(EuiFlexGroup).prop('alignItems')).toEqual('flexStart'); + }); + + it('shows description, when present', () => { + const wrapper = shallow(); + + expect(wrapper.find('p').text()).toEqual('Hello World'); + }); + + it('shows action, when present', () => { + const wrapper = shallow(} />); + + expect(wrapper.find('.action')).toHaveLength(1); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/view_content_header/view_content_header.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/view_content_header/view_content_header.tsx new file mode 100644 index 0000000000000..0408517fd4ec5 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/components/shared/view_content_header/view_content_header.tsx @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; + +import { EuiFlexGroup, EuiFlexItem, EuiText, EuiTitle, EuiSpacer } from '@elastic/eui'; + +import { FlexGroupAlignItems } from '@elastic/eui/src/components/flex/flex_group'; + +interface IViewContentHeaderProps { + title: React.ReactNode; + description?: React.ReactNode; + action?: React.ReactNode; + alignItems?: FlexGroupAlignItems; +} + +export const ViewContentHeader: React.FC = ({ + title, + description, + action, + alignItems = 'center', +}) => ( + <> + + + +

{title}

+
+ {description && ( + +

{description}

+
+ )} +
+ {action && {action}} +
+ + +); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.test.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.test.tsx new file mode 100644 index 0000000000000..743080d965c36 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.test.tsx @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import '../__mocks__/shallow_usecontext.mock'; + +import React, { useContext } from 'react'; +import { Redirect } from 'react-router-dom'; +import { shallow } from 'enzyme'; + +import { SetupGuide } from './components/setup_guide'; +import { Overview } from './components/overview'; + +import { WorkplaceSearch } from './'; + +describe('Workplace Search Routes', () => { + describe('/', () => { + it('redirects to Setup Guide when enterpriseSearchUrl is not set', () => { + (useContext as jest.Mock).mockImplementationOnce(() => ({ enterpriseSearchUrl: '' })); + const wrapper = shallow(); + + expect(wrapper.find(Redirect)).toHaveLength(1); + expect(wrapper.find(Overview)).toHaveLength(0); + }); + + it('renders Engine Overview when enterpriseSearchUrl is set', () => { + (useContext as jest.Mock).mockImplementationOnce(() => ({ + enterpriseSearchUrl: 'https://foo.bar', + })); + const wrapper = shallow(); + + expect(wrapper.find(Overview)).toHaveLength(1); + expect(wrapper.find(Redirect)).toHaveLength(0); + }); + }); + + describe('/setup_guide', () => { + it('renders', () => { + const wrapper = shallow(); + + expect(wrapper.find(SetupGuide)).toHaveLength(1); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.tsx new file mode 100644 index 0000000000000..36b1a56ecba26 --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/index.tsx @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useContext } from 'react'; +import { Route, Redirect } from 'react-router-dom'; + +import { KibanaContext, IKibanaContext } from '../index'; + +import { SETUP_GUIDE_PATH } from './routes'; + +import { SetupGuide } from './components/setup_guide'; +import { Overview } from './components/overview'; + +export const WorkplaceSearch: React.FC = () => { + const { enterpriseSearchUrl } = useContext(KibanaContext) as IKibanaContext; + return ( + <> + + {!enterpriseSearchUrl ? : } + + + + + + ); +}; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/routes.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/routes.ts new file mode 100644 index 0000000000000..d9798d1f30cfc --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/routes.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const ORG_SOURCES_PATH = '/org/sources'; +export const USERS_PATH = '/org/users'; +export const ORG_SETTINGS_PATH = '/org/settings'; +export const SETUP_GUIDE_PATH = '/setup_guide'; + +export const getSourcePath = (sourceId: string): string => `${ORG_SOURCES_PATH}/${sourceId}`; diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/types.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/types.ts new file mode 100644 index 0000000000000..b448c59c52f3e --- /dev/null +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/types.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export interface IAccount { + id: string; + isCurated?: boolean; + isAdmin: boolean; + canCreatePersonalSources: boolean; + groups: string[]; + supportEligible: boolean; +} + +export type TSpacerSize = 'xs' | 's' | 'm' | 'l' | 'xl' | 'xxl'; diff --git a/x-pack/plugins/enterprise_search/public/plugin.ts b/x-pack/plugins/enterprise_search/public/plugin.ts index fbfcc303de47a..fc95828a3f4a4 100644 --- a/x-pack/plugins/enterprise_search/public/plugin.ts +++ b/x-pack/plugins/enterprise_search/public/plugin.ts @@ -22,6 +22,7 @@ import { LicensingPluginSetup } from '../../licensing/public'; import { getPublicUrl } from './applications/shared/enterprise_search_url'; import AppSearchLogo from './applications/app_search/assets/logo.svg'; +import WorkplaceSearchLogo from './applications/workplace_search/assets/logo.svg'; export interface ClientConfigType { host?: string; @@ -58,7 +59,21 @@ export class EnterpriseSearchPlugin implements Plugin { return renderApp(AppSearch, coreStart, params, config, plugins); }, }); - // TODO: Workplace Search will need to register its own plugin. + + core.application.register({ + id: 'workplaceSearch', + title: 'Workplace Search', + appRoute: '/app/enterprise_search/workplace_search', + category: DEFAULT_APP_CATEGORIES.enterpriseSearch, + mount: async (params: AppMountParameters) => { + const [coreStart] = await core.getStartServices(); + + const { renderApp } = await import('./applications'); + const { WorkplaceSearch } = await import('./applications/workplace_search'); + + return renderApp(WorkplaceSearch, coreStart, params, config, plugins); + }, + }); plugins.home.featureCatalogue.register({ id: 'appSearch', @@ -70,7 +85,17 @@ export class EnterpriseSearchPlugin implements Plugin { category: FeatureCatalogueCategory.DATA, showOnHomePage: true, }); - // TODO: Workplace Search will need to register its own feature catalogue section/card. + + plugins.home.featureCatalogue.register({ + id: 'workplaceSearch', + title: 'Workplace Search', + icon: WorkplaceSearchLogo, + description: + 'Search all documents, files, and sources available across your virtual workplace.', + path: '/app/enterprise_search/workplace_search', + category: FeatureCatalogueCategory.DATA, + showOnHomePage: true, + }); } public start(core: CoreStart) {} diff --git a/x-pack/plugins/enterprise_search/server/collectors/app_search/telemetry.test.ts b/x-pack/plugins/enterprise_search/server/collectors/app_search/telemetry.test.ts index e95056b871324..53c6dee61cd1d 100644 --- a/x-pack/plugins/enterprise_search/server/collectors/app_search/telemetry.test.ts +++ b/x-pack/plugins/enterprise_search/server/collectors/app_search/telemetry.test.ts @@ -4,20 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -import { loggingSystemMock } from 'src/core/server/mocks'; +import { mockLogger } from '../../routes/__mocks__'; -jest.mock('../../../../../../src/core/server', () => ({ - SavedObjectsErrorHelpers: { - isNotFoundError: jest.fn(), - }, -})); -import { SavedObjectsErrorHelpers } from '../../../../../../src/core/server'; - -import { registerTelemetryUsageCollector, incrementUICounter } from './telemetry'; +import { registerTelemetryUsageCollector } from './telemetry'; describe('App Search Telemetry Usage Collector', () => { - const mockLogger = loggingSystemMock.create().get(); - const makeUsageCollectorStub = jest.fn(); const registerStub = jest.fn(); const usageCollectionMock = { @@ -103,41 +94,5 @@ describe('App Search Telemetry Usage Collector', () => { }, }); }); - - it('should not throw but log a warning if saved objects errors', async () => { - const errorSavedObjectsMock = { createInternalRepository: () => ({}) } as any; - registerTelemetryUsageCollector(usageCollectionMock, errorSavedObjectsMock, mockLogger); - - // Without log warning (not found) - (SavedObjectsErrorHelpers.isNotFoundError as jest.Mock).mockImplementationOnce(() => true); - await makeUsageCollectorStub.mock.calls[0][0].fetch(); - - expect(mockLogger.warn).not.toHaveBeenCalled(); - - // With log warning - (SavedObjectsErrorHelpers.isNotFoundError as jest.Mock).mockImplementationOnce(() => false); - await makeUsageCollectorStub.mock.calls[0][0].fetch(); - - expect(mockLogger.warn).toHaveBeenCalledWith( - 'Failed to retrieve App Search telemetry data: TypeError: savedObjectsRepository.get is not a function' - ); - }); - }); - - describe('incrementUICounter', () => { - it('should increment the saved objects internal repository', async () => { - const response = await incrementUICounter({ - savedObjects: savedObjectsMock, - uiAction: 'ui_clicked', - metric: 'button', - }); - - expect(savedObjectsRepoStub.incrementCounter).toHaveBeenCalledWith( - 'app_search_telemetry', - 'app_search_telemetry', - 'ui_clicked.button' - ); - expect(response).toEqual({ success: true }); - }); }); }); diff --git a/x-pack/plugins/enterprise_search/server/collectors/app_search/telemetry.ts b/x-pack/plugins/enterprise_search/server/collectors/app_search/telemetry.ts index a10f96907ad28..f700088cb67a0 100644 --- a/x-pack/plugins/enterprise_search/server/collectors/app_search/telemetry.ts +++ b/x-pack/plugins/enterprise_search/server/collectors/app_search/telemetry.ts @@ -5,16 +5,10 @@ */ import { get } from 'lodash'; -import { - ISavedObjectsRepository, - SavedObjectsServiceStart, - SavedObjectAttributes, - Logger, -} from 'src/core/server'; +import { SavedObjectsServiceStart, Logger } from 'src/core/server'; import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; -// This throws `Error: Cannot find module 'src/core/server'` if I import it via alias ¯\_(ツ)_/¯ -import { SavedObjectsErrorHelpers } from '../../../../../../src/core/server'; +import { getSavedObjectAttributesFromRepo } from '../lib/telemetry'; interface ITelemetry { ui_viewed: { @@ -70,10 +64,11 @@ export const registerTelemetryUsageCollector = ( const fetchTelemetryMetrics = async (savedObjects: SavedObjectsServiceStart, log: Logger) => { const savedObjectsRepository = savedObjects.createInternalRepository(); - const savedObjectAttributes = (await getSavedObjectAttributesFromRepo( + const savedObjectAttributes = await getSavedObjectAttributesFromRepo( + AS_TELEMETRY_NAME, savedObjectsRepository, log - )) as SavedObjectAttributes; + ); const defaultTelemetrySavedObject: ITelemetry = { ui_viewed: { @@ -114,43 +109,3 @@ const fetchTelemetryMetrics = async (savedObjects: SavedObjectsServiceStart, log }, } as ITelemetry; }; - -/** - * Helper function - fetches saved objects attributes - */ - -const getSavedObjectAttributesFromRepo = async ( - savedObjectsRepository: ISavedObjectsRepository, - log: Logger -) => { - try { - return (await savedObjectsRepository.get(AS_TELEMETRY_NAME, AS_TELEMETRY_NAME)).attributes; - } catch (e) { - if (!SavedObjectsErrorHelpers.isNotFoundError(e)) { - log.warn(`Failed to retrieve App Search telemetry data: ${e}`); - } - return null; - } -}; - -/** - * Set saved objection attributes - used by telemetry route - */ - -interface IIncrementUICounter { - savedObjects: SavedObjectsServiceStart; - uiAction: string; - metric: string; -} - -export async function incrementUICounter({ savedObjects, uiAction, metric }: IIncrementUICounter) { - const internalRepository = savedObjects.createInternalRepository(); - - await internalRepository.incrementCounter( - AS_TELEMETRY_NAME, - AS_TELEMETRY_NAME, - `${uiAction}.${metric}` // e.g., ui_viewed.setup_guide - ); - - return { success: true }; -} diff --git a/x-pack/plugins/enterprise_search/server/collectors/lib/telemetry.test.ts b/x-pack/plugins/enterprise_search/server/collectors/lib/telemetry.test.ts new file mode 100644 index 0000000000000..3ab3b03dd7725 --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/collectors/lib/telemetry.test.ts @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { mockLogger } from '../../routes/__mocks__'; + +jest.mock('../../../../../../src/core/server', () => ({ + SavedObjectsErrorHelpers: { + isNotFoundError: jest.fn(), + }, +})); +import { SavedObjectsErrorHelpers } from '../../../../../../src/core/server'; + +import { getSavedObjectAttributesFromRepo, incrementUICounter } from './telemetry'; + +describe('App Search Telemetry Usage Collector', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('getSavedObjectAttributesFromRepo', () => { + // Note: savedObjectsRepository.get() is best tested as a whole from + // individual fetchTelemetryMetrics tests. This mostly just tests error handling + it('should not throw but log a warning if saved objects errors', async () => { + const errorSavedObjectsMock = {} as any; + + // Without log warning (not found) + (SavedObjectsErrorHelpers.isNotFoundError as jest.Mock).mockImplementationOnce(() => true); + await getSavedObjectAttributesFromRepo('some_id', errorSavedObjectsMock, mockLogger); + + expect(mockLogger.warn).not.toHaveBeenCalled(); + + // With log warning + (SavedObjectsErrorHelpers.isNotFoundError as jest.Mock).mockImplementationOnce(() => false); + await getSavedObjectAttributesFromRepo('some_id', errorSavedObjectsMock, mockLogger); + + expect(mockLogger.warn).toHaveBeenCalledWith( + 'Failed to retrieve some_id telemetry data: TypeError: savedObjectsRepository.get is not a function' + ); + }); + }); + + describe('incrementUICounter', () => { + const incrementCounterMock = jest.fn(); + const savedObjectsMock = { + createInternalRepository: jest.fn(() => ({ + incrementCounter: incrementCounterMock, + })), + } as any; + + it('should increment the saved objects internal repository', async () => { + const response = await incrementUICounter({ + id: 'app_search_telemetry', + savedObjects: savedObjectsMock, + uiAction: 'ui_clicked', + metric: 'button', + }); + + expect(incrementCounterMock).toHaveBeenCalledWith( + 'app_search_telemetry', + 'app_search_telemetry', + 'ui_clicked.button' + ); + expect(response).toEqual({ success: true }); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/server/collectors/lib/telemetry.ts b/x-pack/plugins/enterprise_search/server/collectors/lib/telemetry.ts new file mode 100644 index 0000000000000..f5f4fa368555f --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/collectors/lib/telemetry.ts @@ -0,0 +1,62 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + ISavedObjectsRepository, + SavedObjectsServiceStart, + SavedObjectAttributes, + Logger, +} from 'src/core/server'; + +// This throws `Error: Cannot find module 'src/core/server'` if I import it via alias ¯\_(ツ)_/¯ +import { SavedObjectsErrorHelpers } from '../../../../../../src/core/server'; + +/** + * Fetches saved objects attributes - used by collectors + */ + +export const getSavedObjectAttributesFromRepo = async ( + id: string, // Telemetry name + savedObjectsRepository: ISavedObjectsRepository, + log: Logger +): Promise => { + try { + return (await savedObjectsRepository.get(id, id)).attributes as SavedObjectAttributes; + } catch (e) { + if (!SavedObjectsErrorHelpers.isNotFoundError(e)) { + log.warn(`Failed to retrieve ${id} telemetry data: ${e}`); + } + return null; + } +}; + +/** + * Set saved objection attributes - used by telemetry route + */ + +interface IIncrementUICounter { + id: string; // Telemetry name + savedObjects: SavedObjectsServiceStart; + uiAction: string; + metric: string; +} + +export async function incrementUICounter({ + id, + savedObjects, + uiAction, + metric, +}: IIncrementUICounter) { + const internalRepository = savedObjects.createInternalRepository(); + + await internalRepository.incrementCounter( + id, + id, + `${uiAction}.${metric}` // e.g., ui_viewed.setup_guide + ); + + return { success: true }; +} diff --git a/x-pack/plugins/enterprise_search/server/collectors/workplace_search/telemetry.test.ts b/x-pack/plugins/enterprise_search/server/collectors/workplace_search/telemetry.test.ts new file mode 100644 index 0000000000000..496b2f254f9a6 --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/collectors/workplace_search/telemetry.test.ts @@ -0,0 +1,101 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { mockLogger } from '../../routes/__mocks__'; + +import { registerTelemetryUsageCollector } from './telemetry'; + +describe('Workplace Search Telemetry Usage Collector', () => { + const makeUsageCollectorStub = jest.fn(); + const registerStub = jest.fn(); + const usageCollectionMock = { + makeUsageCollector: makeUsageCollectorStub, + registerCollector: registerStub, + } as any; + + const savedObjectsRepoStub = { + get: () => ({ + attributes: { + 'ui_viewed.setup_guide': 10, + 'ui_viewed.overview': 20, + 'ui_error.cannot_connect': 3, + 'ui_clicked.header_launch_button': 30, + 'ui_clicked.org_name_change_button': 40, + 'ui_clicked.onboarding_card_button': 50, + 'ui_clicked.recent_activity_source_details_link': 60, + }, + }), + incrementCounter: jest.fn(), + }; + const savedObjectsMock = { + createInternalRepository: jest.fn(() => savedObjectsRepoStub), + } as any; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('registerTelemetryUsageCollector', () => { + it('should make and register the usage collector', () => { + registerTelemetryUsageCollector(usageCollectionMock, savedObjectsMock, mockLogger); + + expect(registerStub).toHaveBeenCalledTimes(1); + expect(makeUsageCollectorStub).toHaveBeenCalledTimes(1); + expect(makeUsageCollectorStub.mock.calls[0][0].type).toBe('workplace_search'); + expect(makeUsageCollectorStub.mock.calls[0][0].isReady()).toBe(true); + }); + }); + + describe('fetchTelemetryMetrics', () => { + it('should return existing saved objects data', async () => { + registerTelemetryUsageCollector(usageCollectionMock, savedObjectsMock, mockLogger); + const savedObjectsCounts = await makeUsageCollectorStub.mock.calls[0][0].fetch(); + + expect(savedObjectsCounts).toEqual({ + ui_viewed: { + setup_guide: 10, + overview: 20, + }, + ui_error: { + cannot_connect: 3, + }, + ui_clicked: { + header_launch_button: 30, + org_name_change_button: 40, + onboarding_card_button: 50, + recent_activity_source_details_link: 60, + }, + }); + }); + + it('should return a default telemetry object if no saved data exists', async () => { + const emptySavedObjectsMock = { + createInternalRepository: () => ({ + get: () => ({ attributes: null }), + }), + } as any; + + registerTelemetryUsageCollector(usageCollectionMock, emptySavedObjectsMock, mockLogger); + const savedObjectsCounts = await makeUsageCollectorStub.mock.calls[0][0].fetch(); + + expect(savedObjectsCounts).toEqual({ + ui_viewed: { + setup_guide: 0, + overview: 0, + }, + ui_error: { + cannot_connect: 0, + }, + ui_clicked: { + header_launch_button: 0, + org_name_change_button: 0, + onboarding_card_button: 0, + recent_activity_source_details_link: 0, + }, + }); + }); + }); +}); diff --git a/x-pack/plugins/enterprise_search/server/collectors/workplace_search/telemetry.ts b/x-pack/plugins/enterprise_search/server/collectors/workplace_search/telemetry.ts new file mode 100644 index 0000000000000..892de5cfee35e --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/collectors/workplace_search/telemetry.ts @@ -0,0 +1,115 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { get } from 'lodash'; +import { SavedObjectsServiceStart, Logger } from 'src/core/server'; +import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; + +import { getSavedObjectAttributesFromRepo } from '../lib/telemetry'; + +interface ITelemetry { + ui_viewed: { + setup_guide: number; + overview: number; + }; + ui_error: { + cannot_connect: number; + }; + ui_clicked: { + header_launch_button: number; + org_name_change_button: number; + onboarding_card_button: number; + recent_activity_source_details_link: number; + }; +} + +export const WS_TELEMETRY_NAME = 'workplace_search_telemetry'; + +/** + * Register the telemetry collector + */ + +export const registerTelemetryUsageCollector = ( + usageCollection: UsageCollectionSetup, + savedObjects: SavedObjectsServiceStart, + log: Logger +) => { + const telemetryUsageCollector = usageCollection.makeUsageCollector({ + type: 'workplace_search', + fetch: async () => fetchTelemetryMetrics(savedObjects, log), + isReady: () => true, + schema: { + ui_viewed: { + setup_guide: { type: 'long' }, + overview: { type: 'long' }, + }, + ui_error: { + cannot_connect: { type: 'long' }, + }, + ui_clicked: { + header_launch_button: { type: 'long' }, + org_name_change_button: { type: 'long' }, + onboarding_card_button: { type: 'long' }, + recent_activity_source_details_link: { type: 'long' }, + }, + }, + }); + usageCollection.registerCollector(telemetryUsageCollector); +}; + +/** + * Fetch the aggregated telemetry metrics from our saved objects + */ + +const fetchTelemetryMetrics = async (savedObjects: SavedObjectsServiceStart, log: Logger) => { + const savedObjectsRepository = savedObjects.createInternalRepository(); + const savedObjectAttributes = await getSavedObjectAttributesFromRepo( + WS_TELEMETRY_NAME, + savedObjectsRepository, + log + ); + + const defaultTelemetrySavedObject: ITelemetry = { + ui_viewed: { + setup_guide: 0, + overview: 0, + }, + ui_error: { + cannot_connect: 0, + }, + ui_clicked: { + header_launch_button: 0, + org_name_change_button: 0, + onboarding_card_button: 0, + recent_activity_source_details_link: 0, + }, + }; + + // If we don't have an existing/saved telemetry object, return the default + if (!savedObjectAttributes) { + return defaultTelemetrySavedObject; + } + + return { + ui_viewed: { + setup_guide: get(savedObjectAttributes, 'ui_viewed.setup_guide', 0), + overview: get(savedObjectAttributes, 'ui_viewed.overview', 0), + }, + ui_error: { + cannot_connect: get(savedObjectAttributes, 'ui_error.cannot_connect', 0), + }, + ui_clicked: { + header_launch_button: get(savedObjectAttributes, 'ui_clicked.header_launch_button', 0), + org_name_change_button: get(savedObjectAttributes, 'ui_clicked.org_name_change_button', 0), + onboarding_card_button: get(savedObjectAttributes, 'ui_clicked.onboarding_card_button', 0), + recent_activity_source_details_link: get( + savedObjectAttributes, + 'ui_clicked.recent_activity_source_details_link', + 0 + ), + }, + } as ITelemetry; +}; diff --git a/x-pack/plugins/enterprise_search/server/plugin.ts b/x-pack/plugins/enterprise_search/server/plugin.ts index 70be8600862e9..a7bd68f92f78b 100644 --- a/x-pack/plugins/enterprise_search/server/plugin.ts +++ b/x-pack/plugins/enterprise_search/server/plugin.ts @@ -22,10 +22,15 @@ import { PluginSetupContract as FeaturesPluginSetup } from '../../features/serve import { ConfigType } from './'; import { checkAccess } from './lib/check_access'; import { registerPublicUrlRoute } from './routes/enterprise_search/public_url'; -import { registerEnginesRoute } from './routes/app_search/engines'; -import { registerTelemetryRoute } from './routes/app_search/telemetry'; -import { registerTelemetryUsageCollector } from './collectors/app_search/telemetry'; +import { registerTelemetryRoute } from './routes/enterprise_search/telemetry'; + import { appSearchTelemetryType } from './saved_objects/app_search/telemetry'; +import { registerTelemetryUsageCollector as registerASTelemetryUsageCollector } from './collectors/app_search/telemetry'; +import { registerEnginesRoute } from './routes/app_search/engines'; + +import { workplaceSearchTelemetryType } from './saved_objects/workplace_search/telemetry'; +import { registerTelemetryUsageCollector as registerWSTelemetryUsageCollector } from './collectors/workplace_search/telemetry'; +import { registerWSOverviewRoute } from './routes/workplace_search/overview'; export interface PluginsSetup { usageCollection?: UsageCollectionSetup; @@ -64,8 +69,8 @@ export class EnterpriseSearchPlugin implements Plugin { order: 0, icon: 'logoEnterpriseSearch', navLinkId: 'appSearch', // TODO - remove this once functional tests no longer rely on navLinkId - app: ['kibana', 'appSearch'], // TODO: 'enterpriseSearch', 'workplaceSearch' - catalogue: ['appSearch'], // TODO: 'enterpriseSearch', 'workplaceSearch' + app: ['kibana', 'appSearch', 'workplaceSearch'], // TODO: 'enterpriseSearch' + catalogue: ['appSearch', 'workplaceSearch'], // TODO: 'enterpriseSearch' privileges: null, }); @@ -75,15 +80,16 @@ export class EnterpriseSearchPlugin implements Plugin { capabilities.registerSwitcher(async (request: KibanaRequest) => { const dependencies = { config, security, request, log: this.logger }; - const { hasAppSearchAccess } = await checkAccess(dependencies); - // TODO: hasWorkplaceSearchAccess + const { hasAppSearchAccess, hasWorkplaceSearchAccess } = await checkAccess(dependencies); return { navLinks: { appSearch: hasAppSearchAccess, + workplaceSearch: hasWorkplaceSearchAccess, }, catalogue: { appSearch: hasAppSearchAccess, + workplaceSearch: hasWorkplaceSearchAccess, }, }; }); @@ -96,23 +102,24 @@ export class EnterpriseSearchPlugin implements Plugin { registerPublicUrlRoute(dependencies); registerEnginesRoute(dependencies); + registerWSOverviewRoute(dependencies); /** * Bootstrap the routes, saved objects, and collector for telemetry */ savedObjects.registerType(appSearchTelemetryType); + savedObjects.registerType(workplaceSearchTelemetryType); let savedObjectsStarted: SavedObjectsServiceStart; getStartServices().then(([coreStart]) => { savedObjectsStarted = coreStart.savedObjects; + if (usageCollection) { - registerTelemetryUsageCollector(usageCollection, savedObjectsStarted, this.logger); + registerASTelemetryUsageCollector(usageCollection, savedObjectsStarted, this.logger); + registerWSTelemetryUsageCollector(usageCollection, savedObjectsStarted, this.logger); } }); - registerTelemetryRoute({ - ...dependencies, - getSavedObjectsService: () => savedObjectsStarted, - }); + registerTelemetryRoute({ ...dependencies, getSavedObjectsService: () => savedObjectsStarted }); } public start() {} diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/telemetry.test.ts b/x-pack/plugins/enterprise_search/server/routes/enterprise_search/telemetry.test.ts similarity index 56% rename from x-pack/plugins/enterprise_search/server/routes/app_search/telemetry.test.ts rename to x-pack/plugins/enterprise_search/server/routes/enterprise_search/telemetry.test.ts index e2d5fbcec3705..ebd84d3e0e79a 100644 --- a/x-pack/plugins/enterprise_search/server/routes/app_search/telemetry.test.ts +++ b/x-pack/plugins/enterprise_search/server/routes/enterprise_search/telemetry.test.ts @@ -7,20 +7,21 @@ import { loggingSystemMock, savedObjectsServiceMock } from 'src/core/server/mocks'; import { MockRouter, mockConfig, mockLogger } from '../__mocks__'; -import { registerTelemetryRoute } from './telemetry'; - -jest.mock('../../collectors/app_search/telemetry', () => ({ +jest.mock('../../collectors/lib/telemetry', () => ({ incrementUICounter: jest.fn(), })); -import { incrementUICounter } from '../../collectors/app_search/telemetry'; +import { incrementUICounter } from '../../collectors/lib/telemetry'; + +import { registerTelemetryRoute } from './telemetry'; /** * Since these route callbacks are so thin, these serve simply as integration tests * to ensure they're wired up to the collector functions correctly. Business logic * is tested more thoroughly in the collectors/telemetry tests. */ -describe('App Search Telemetry API', () => { +describe('Enterprise Search Telemetry API', () => { let mockRouter: MockRouter; + const successResponse = { success: true }; beforeEach(() => { jest.clearAllMocks(); @@ -34,14 +35,20 @@ describe('App Search Telemetry API', () => { }); }); - describe('PUT /api/app_search/telemetry', () => { - it('increments the saved objects counter', async () => { - const successResponse = { success: true }; + describe('PUT /api/enterprise_search/telemetry', () => { + it('increments the saved objects counter for App Search', async () => { (incrementUICounter as jest.Mock).mockImplementation(jest.fn(() => successResponse)); - await mockRouter.callRoute({ body: { action: 'viewed', metric: 'setup_guide' } }); + await mockRouter.callRoute({ + body: { + product: 'app_search', + action: 'viewed', + metric: 'setup_guide', + }, + }); expect(incrementUICounter).toHaveBeenCalledWith({ + id: 'app_search_telemetry', savedObjects: expect.any(Object), uiAction: 'ui_viewed', metric: 'setup_guide', @@ -49,10 +56,36 @@ describe('App Search Telemetry API', () => { expect(mockRouter.response.ok).toHaveBeenCalledWith({ body: successResponse }); }); + it('increments the saved objects counter for Workplace Search', async () => { + (incrementUICounter as jest.Mock).mockImplementation(jest.fn(() => successResponse)); + + await mockRouter.callRoute({ + body: { + product: 'workplace_search', + action: 'clicked', + metric: 'onboarding_card_button', + }, + }); + + expect(incrementUICounter).toHaveBeenCalledWith({ + id: 'workplace_search_telemetry', + savedObjects: expect.any(Object), + uiAction: 'ui_clicked', + metric: 'onboarding_card_button', + }); + expect(mockRouter.response.ok).toHaveBeenCalledWith({ body: successResponse }); + }); + it('throws an error when incrementing fails', async () => { (incrementUICounter as jest.Mock).mockImplementation(jest.fn(() => Promise.reject('Failed'))); - await mockRouter.callRoute({ body: { action: 'error', metric: 'error' } }); + await mockRouter.callRoute({ + body: { + product: 'enterprise_search', + action: 'error', + metric: 'error', + }, + }); expect(incrementUICounter).toHaveBeenCalled(); expect(mockLogger.error).toHaveBeenCalled(); @@ -73,34 +106,50 @@ describe('App Search Telemetry API', () => { expect(mockRouter.response.internalError).toHaveBeenCalled(); expect(loggingSystemMock.collect(mockLogger).error[0][0]).toEqual( expect.stringContaining( - 'App Search UI telemetry error: Error: Could not find Saved Objects service' + 'Enterprise Search UI telemetry error: Error: Could not find Saved Objects service' ) ); }); describe('validates', () => { it('correctly', () => { - const request = { body: { action: 'viewed', metric: 'setup_guide' } }; + const request = { + body: { product: 'workplace_search', action: 'viewed', metric: 'setup_guide' }, + }; mockRouter.shouldValidate(request); }); + it('wrong product string', () => { + const request = { + body: { product: 'workspace_space_search', action: 'viewed', metric: 'setup_guide' }, + }; + mockRouter.shouldThrow(request); + }); + it('wrong action string', () => { - const request = { body: { action: 'invalid', metric: 'setup_guide' } }; + const request = { + body: { product: 'app_search', action: 'invalid', metric: 'setup_guide' }, + }; mockRouter.shouldThrow(request); }); it('wrong metric type', () => { - const request = { body: { action: 'clicked', metric: true } }; + const request = { body: { product: 'enterprise_search', action: 'clicked', metric: true } }; + mockRouter.shouldThrow(request); + }); + + it('product is missing string', () => { + const request = { body: { action: 'viewed', metric: 'setup_guide' } }; mockRouter.shouldThrow(request); }); it('action is missing', () => { - const request = { body: { metric: 'engines_overview' } }; + const request = { body: { product: 'app_search', metric: 'engines_overview' } }; mockRouter.shouldThrow(request); }); it('metric is missing', () => { - const request = { body: { action: 'error' } }; + const request = { body: { product: 'app_search', action: 'error' } }; mockRouter.shouldThrow(request); }); }); diff --git a/x-pack/plugins/enterprise_search/server/routes/app_search/telemetry.ts b/x-pack/plugins/enterprise_search/server/routes/enterprise_search/telemetry.ts similarity index 55% rename from x-pack/plugins/enterprise_search/server/routes/app_search/telemetry.ts rename to x-pack/plugins/enterprise_search/server/routes/enterprise_search/telemetry.ts index 4cc9b64adc092..7ed1d7b17753c 100644 --- a/x-pack/plugins/enterprise_search/server/routes/app_search/telemetry.ts +++ b/x-pack/plugins/enterprise_search/server/routes/enterprise_search/telemetry.ts @@ -7,7 +7,15 @@ import { schema } from '@kbn/config-schema'; import { IRouteDependencies } from '../../plugin'; -import { incrementUICounter } from '../../collectors/app_search/telemetry'; +import { incrementUICounter } from '../../collectors/lib/telemetry'; + +import { AS_TELEMETRY_NAME } from '../../collectors/app_search/telemetry'; +import { WS_TELEMETRY_NAME } from '../../collectors/workplace_search/telemetry'; +const productToTelemetryMap = { + app_search: AS_TELEMETRY_NAME, + workplace_search: WS_TELEMETRY_NAME, + enterprise_search: 'TODO', +}; export function registerTelemetryRoute({ router, @@ -16,9 +24,14 @@ export function registerTelemetryRoute({ }: IRouteDependencies) { router.put( { - path: '/api/app_search/telemetry', + path: '/api/enterprise_search/telemetry', validate: { body: schema.object({ + product: schema.oneOf([ + schema.literal('app_search'), + schema.literal('workplace_search'), + schema.literal('enterprise_search'), + ]), action: schema.oneOf([ schema.literal('viewed'), schema.literal('clicked'), @@ -29,21 +42,24 @@ export function registerTelemetryRoute({ }, }, async (ctx, request, response) => { - const { action, metric } = request.body; + const { product, action, metric } = request.body; try { if (!getSavedObjectsService) throw new Error('Could not find Saved Objects service'); return response.ok({ body: await incrementUICounter({ + id: productToTelemetryMap[product], savedObjects: getSavedObjectsService(), uiAction: `ui_${action}`, metric, }), }); } catch (e) { - log.error(`App Search UI telemetry error: ${e instanceof Error ? e.stack : e.toString()}`); - return response.internalError({ body: 'App Search UI telemetry failed' }); + log.error( + `Enterprise Search UI telemetry error: ${e instanceof Error ? e.stack : e.toString()}` + ); + return response.internalError({ body: 'Enterprise Search UI telemetry failed' }); } } ); diff --git a/x-pack/plugins/enterprise_search/server/routes/workplace_search/overview.test.ts b/x-pack/plugins/enterprise_search/server/routes/workplace_search/overview.test.ts new file mode 100644 index 0000000000000..b1b5539795357 --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/routes/workplace_search/overview.test.ts @@ -0,0 +1,127 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { MockRouter, mockConfig, mockLogger } from '../__mocks__'; + +import { registerWSOverviewRoute } from './overview'; + +jest.mock('node-fetch'); +const fetch = jest.requireActual('node-fetch'); +const { Response } = fetch; +const fetchMock = require('node-fetch') as jest.Mocked; + +const ORG_ROUTE = 'http://localhost:3002/ws/org'; + +describe('engine routes', () => { + describe('GET /api/workplace_search/overview', () => { + const AUTH_HEADER = 'Basic 123'; + const mockRequest = { + headers: { + authorization: AUTH_HEADER, + }, + query: {}, + }; + + const mockRouter = new MockRouter({ method: 'get', payload: 'query' }); + + beforeEach(() => { + jest.clearAllMocks(); + mockRouter.createRouter(); + + registerWSOverviewRoute({ + router: mockRouter.router, + log: mockLogger, + config: mockConfig, + }); + }); + + describe('when the underlying Workplace Search API returns a 200', () => { + beforeEach(() => { + WorkplaceSearchAPI.shouldBeCalledWith(ORG_ROUTE, { + headers: { Authorization: AUTH_HEADER }, + }).andReturn({ accountsCount: 1 }); + }); + + it('should return 200 with a list of overview from the Workplace Search API', async () => { + await mockRouter.callRoute(mockRequest); + + expect(mockRouter.response.ok).toHaveBeenCalledWith({ + body: { accountsCount: 1 }, + headers: { 'content-type': 'application/json' }, + }); + }); + }); + + describe('when the Workplace Search URL is invalid', () => { + beforeEach(() => { + WorkplaceSearchAPI.shouldBeCalledWith(ORG_ROUTE, { + headers: { Authorization: AUTH_HEADER }, + }).andReturnError(); + }); + + it('should return 404 with a message', async () => { + await mockRouter.callRoute(mockRequest); + + expect(mockRouter.response.notFound).toHaveBeenCalledWith({ + body: 'cannot-connect', + }); + expect(mockLogger.error).toHaveBeenCalledWith('Cannot connect to Workplace Search: Failed'); + expect(mockLogger.debug).not.toHaveBeenCalled(); + }); + }); + + describe('when the Workplace Search API returns invalid data', () => { + beforeEach(() => { + WorkplaceSearchAPI.shouldBeCalledWith(ORG_ROUTE, { + headers: { Authorization: AUTH_HEADER }, + }).andReturnInvalidData(); + }); + + it('should return 404 with a message', async () => { + await mockRouter.callRoute(mockRequest); + + expect(mockRouter.response.notFound).toHaveBeenCalledWith({ + body: 'cannot-connect', + }); + expect(mockLogger.error).toHaveBeenCalledWith( + 'Cannot connect to Workplace Search: Error: Invalid data received from Workplace Search: {"foo":"bar"}' + ); + expect(mockLogger.debug).toHaveBeenCalled(); + }); + }); + + const WorkplaceSearchAPI = { + shouldBeCalledWith(expectedUrl: string, expectedParams: object) { + return { + andReturn(response: object) { + fetchMock.mockImplementation((url: string, params: object) => { + expect(url).toEqual(expectedUrl); + expect(params).toEqual(expectedParams); + + return Promise.resolve(new Response(JSON.stringify(response))); + }); + }, + andReturnInvalidData() { + fetchMock.mockImplementation((url: string, params: object) => { + expect(url).toEqual(expectedUrl); + expect(params).toEqual(expectedParams); + + return Promise.resolve(new Response(JSON.stringify({ foo: 'bar' }))); + }); + }, + andReturnError() { + fetchMock.mockImplementation((url: string, params: object) => { + expect(url).toEqual(expectedUrl); + expect(params).toEqual(expectedParams); + + return Promise.reject('Failed'); + }); + }, + }; + }, + }; + }); +}); diff --git a/x-pack/plugins/enterprise_search/server/routes/workplace_search/overview.ts b/x-pack/plugins/enterprise_search/server/routes/workplace_search/overview.ts new file mode 100644 index 0000000000000..d1e2f4f5f180d --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/routes/workplace_search/overview.ts @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import fetch from 'node-fetch'; + +import { IRouteDependencies } from '../../plugin'; + +export function registerWSOverviewRoute({ router, config, log }: IRouteDependencies) { + router.get( + { + path: '/api/workplace_search/overview', + validate: false, + }, + async (context, request, response) => { + try { + const entSearchUrl = config.host as string; + const url = `${encodeURI(entSearchUrl)}/ws/org`; + + const overviewResponse = await fetch(url, { + headers: { Authorization: request.headers.authorization as string }, + }); + + const body = await overviewResponse.json(); + const hasValidData = typeof body?.accountsCount === 'number'; + + if (hasValidData) { + return response.ok({ + body, + headers: { 'content-type': 'application/json' }, + }); + } else { + // Either a completely incorrect Enterprise Search host URL was configured, or Workplace Search is returning bad data + throw new Error(`Invalid data received from Workplace Search: ${JSON.stringify(body)}`); + } + } catch (e) { + log.error(`Cannot connect to Workplace Search: ${e.toString()}`); + if (e instanceof Error) log.debug(e.stack as string); + + return response.notFound({ body: 'cannot-connect' }); + } + } + ); +} diff --git a/x-pack/plugins/enterprise_search/server/saved_objects/workplace_search/telemetry.ts b/x-pack/plugins/enterprise_search/server/saved_objects/workplace_search/telemetry.ts new file mode 100644 index 0000000000000..86315a9d617e4 --- /dev/null +++ b/x-pack/plugins/enterprise_search/server/saved_objects/workplace_search/telemetry.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; + * you may not use this file except in compliance with the Elastic License. + */ +/* istanbul ignore file */ + +import { SavedObjectsType } from 'src/core/server'; +import { WS_TELEMETRY_NAME } from '../../collectors/workplace_search/telemetry'; + +export const workplaceSearchTelemetryType: SavedObjectsType = { + name: WS_TELEMETRY_NAME, + hidden: false, + namespaceType: 'agnostic', + mappings: { + dynamic: false, + properties: {}, + }, +}; diff --git a/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json b/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json index fbef75b9aa9cc..899ece7bce312 100644 --- a/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json +++ b/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json @@ -41,6 +41,43 @@ } } }, + "workplace_search": { + "properties": { + "ui_viewed": { + "properties": { + "setup_guide": { + "type": "long" + }, + "overview": { + "type": "long" + } + } + }, + "ui_error": { + "properties": { + "cannot_connect": { + "type": "long" + } + } + }, + "ui_clicked": { + "properties": { + "header_launch_button": { + "type": "long" + }, + "org_name_change_button": { + "type": "long" + }, + "onboarding_card_button": { + "type": "long" + }, + "recent_activity_source_details_link": { + "type": "long" + } + } + } + } + }, "fileUploadTelemetry": { "properties": { "filesUploadedTotalCount": { diff --git a/x-pack/test/functional_enterprise_search/apps/enterprise_search/without_host_configured/app_search/setup_guide.ts b/x-pack/test/functional_enterprise_search/apps/enterprise_search/without_host_configured/app_search/setup_guide.ts index 1d478c6baf29c..76a47cc4a7e10 100644 --- a/x-pack/test/functional_enterprise_search/apps/enterprise_search/without_host_configured/app_search/setup_guide.ts +++ b/x-pack/test/functional_enterprise_search/apps/enterprise_search/without_host_configured/app_search/setup_guide.ts @@ -24,7 +24,7 @@ export default function enterpriseSearchSetupGuideTests({ }); describe('when no enterpriseSearch.host is configured', () => { - it('navigating to the enterprise_search plugin will redirect a user to the setup guide', async () => { + it('navigating to the plugin will redirect a user to the setup guide', async () => { await PageObjects.appSearch.navigateToPage(); await retry.try(async function () { const currentUrl = await browser.getCurrentUrl(); diff --git a/x-pack/test/functional_enterprise_search/apps/enterprise_search/without_host_configured/index.ts b/x-pack/test/functional_enterprise_search/apps/enterprise_search/without_host_configured/index.ts index 31a92e752fcf4..ebfdca780c127 100644 --- a/x-pack/test/functional_enterprise_search/apps/enterprise_search/without_host_configured/index.ts +++ b/x-pack/test/functional_enterprise_search/apps/enterprise_search/without_host_configured/index.ts @@ -11,5 +11,6 @@ export default function ({ loadTestFile }: FtrProviderContext) { this.tags('ciGroup10'); loadTestFile(require.resolve('./app_search/setup_guide')); + loadTestFile(require.resolve('./workplace_search/setup_guide')); }); } diff --git a/x-pack/test/functional_enterprise_search/apps/enterprise_search/without_host_configured/workplace_search/setup_guide.ts b/x-pack/test/functional_enterprise_search/apps/enterprise_search/without_host_configured/workplace_search/setup_guide.ts new file mode 100644 index 0000000000000..20145306b21c8 --- /dev/null +++ b/x-pack/test/functional_enterprise_search/apps/enterprise_search/without_host_configured/workplace_search/setup_guide.ts @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../../../ftr_provider_context'; + +export default function enterpriseSearchSetupGuideTests({ + getService, + getPageObjects, +}: FtrProviderContext) { + const esArchiver = getService('esArchiver'); + const browser = getService('browser'); + const retry = getService('retry'); + + const PageObjects = getPageObjects(['workplaceSearch']); + + describe('Setup Guide', function () { + before(async () => await esArchiver.load('empty_kibana')); + after(async () => { + await esArchiver.unload('empty_kibana'); + }); + + describe('when no enterpriseSearch.host is configured', () => { + it('navigating to the plugin will redirect a user to the setup guide', async () => { + await PageObjects.workplaceSearch.navigateToPage(); + await retry.try(async function () { + const currentUrl = await browser.getCurrentUrl(); + expect(currentUrl).to.contain('/workplace_search/setup_guide'); + }); + }); + }); + }); +} diff --git a/x-pack/test/functional_enterprise_search/page_objects/index.ts b/x-pack/test/functional_enterprise_search/page_objects/index.ts index 009fb26482419..87de26b6feda0 100644 --- a/x-pack/test/functional_enterprise_search/page_objects/index.ts +++ b/x-pack/test/functional_enterprise_search/page_objects/index.ts @@ -6,8 +6,10 @@ import { pageObjects as basePageObjects } from '../../functional/page_objects'; import { AppSearchPageProvider } from './app_search'; +import { WorkplaceSearchPageProvider } from './workplace_search'; export const pageObjects = { ...basePageObjects, appSearch: AppSearchPageProvider, + workplaceSearch: WorkplaceSearchPageProvider, }; diff --git a/x-pack/test/functional_enterprise_search/page_objects/workplace_search.ts b/x-pack/test/functional_enterprise_search/page_objects/workplace_search.ts new file mode 100644 index 0000000000000..f97ad2af58111 --- /dev/null +++ b/x-pack/test/functional_enterprise_search/page_objects/workplace_search.ts @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { FtrProviderContext } from '../ftr_provider_context'; + +export function WorkplaceSearchPageProvider({ getPageObjects }: FtrProviderContext) { + const PageObjects = getPageObjects(['common']); + + return { + async navigateToPage(): Promise { + return await PageObjects.common.navigateToApp('enterprise_search/workplace_search'); + }, + }; +} diff --git a/x-pack/test/ui_capabilities/security_and_spaces/tests/catalogue.ts b/x-pack/test/ui_capabilities/security_and_spaces/tests/catalogue.ts index 0e0d46c6ce2cd..0d5c553a786fa 100644 --- a/x-pack/test/ui_capabilities/security_and_spaces/tests/catalogue.ts +++ b/x-pack/test/ui_capabilities/security_and_spaces/tests/catalogue.ts @@ -50,9 +50,10 @@ export default function catalogueTests({ getService }: FtrProviderContext) { expect(uiCapabilities.success).to.be(true); expect(uiCapabilities.value).to.have.property('catalogue'); // everything except ml and monitoring and enterprise search is enabled + const exceptions = ['ml', 'monitoring', 'appSearch', 'workplaceSearch']; const expected = mapValues( uiCapabilities.value!.catalogue, - (enabled, catalogueId) => !['ml', 'monitoring', 'appSearch'].includes(catalogueId) + (enabled, catalogueId) => !exceptions.includes(catalogueId) ); expect(uiCapabilities.value!.catalogue).to.eql(expected); break; diff --git a/x-pack/test/ui_capabilities/security_and_spaces/tests/nav_links.ts b/x-pack/test/ui_capabilities/security_and_spaces/tests/nav_links.ts index 08a7d789153e7..0133a2fafb129 100644 --- a/x-pack/test/ui_capabilities/security_and_spaces/tests/nav_links.ts +++ b/x-pack/test/ui_capabilities/security_and_spaces/tests/nav_links.ts @@ -51,7 +51,13 @@ export default function navLinksTests({ getService }: FtrProviderContext) { expect(uiCapabilities.success).to.be(true); expect(uiCapabilities.value).to.have.property('navLinks'); expect(uiCapabilities.value!.navLinks).to.eql( - navLinksBuilder.except('ml', 'monitoring', 'enterpriseSearch', 'appSearch') + navLinksBuilder.except( + 'ml', + 'monitoring', + 'enterpriseSearch', + 'appSearch', + 'workplaceSearch' + ) ); break; case 'superuser at nothing_space': diff --git a/x-pack/test/ui_capabilities/security_only/tests/catalogue.ts b/x-pack/test/ui_capabilities/security_only/tests/catalogue.ts index 99f91407dc1d2..9ed1c890bf57f 100644 --- a/x-pack/test/ui_capabilities/security_only/tests/catalogue.ts +++ b/x-pack/test/ui_capabilities/security_only/tests/catalogue.ts @@ -48,9 +48,10 @@ export default function catalogueTests({ getService }: FtrProviderContext) { expect(uiCapabilities.success).to.be(true); expect(uiCapabilities.value).to.have.property('catalogue'); // everything except ml and monitoring and enterprise search is enabled + const exceptions = ['ml', 'monitoring', 'appSearch', 'workplaceSearch']; const expected = mapValues( uiCapabilities.value!.catalogue, - (enabled, catalogueId) => !['ml', 'monitoring', 'appSearch'].includes(catalogueId) + (enabled, catalogueId) => !exceptions.includes(catalogueId) ); expect(uiCapabilities.value!.catalogue).to.eql(expected); break; diff --git a/x-pack/test/ui_capabilities/security_only/tests/nav_links.ts b/x-pack/test/ui_capabilities/security_only/tests/nav_links.ts index d3bd2e1afd357..18838e536cf96 100644 --- a/x-pack/test/ui_capabilities/security_only/tests/nav_links.ts +++ b/x-pack/test/ui_capabilities/security_only/tests/nav_links.ts @@ -49,7 +49,7 @@ export default function navLinksTests({ getService }: FtrProviderContext) { expect(uiCapabilities.success).to.be(true); expect(uiCapabilities.value).to.have.property('navLinks'); expect(uiCapabilities.value!.navLinks).to.eql( - navLinksBuilder.except('ml', 'monitoring', 'appSearch') + navLinksBuilder.except('ml', 'monitoring', 'appSearch', 'workplaceSearch') ); break; case 'foo_all': From fd510ca303fe44b2c67b44bd7ded1ec175892f05 Mon Sep 17 00:00:00 2001 From: Tiago Costa Date: Mon, 13 Jul 2020 19:13:38 +0100 Subject: [PATCH 15/66] skip flaky suite (#71501) --- .../functional/apps/management/_create_index_pattern_wizard.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/functional/apps/management/_create_index_pattern_wizard.js b/test/functional/apps/management/_create_index_pattern_wizard.js index cb8b5a6ddc65f..97f2641b51d13 100644 --- a/test/functional/apps/management/_create_index_pattern_wizard.js +++ b/test/functional/apps/management/_create_index_pattern_wizard.js @@ -25,7 +25,8 @@ export default function ({ getService, getPageObjects }) { const es = getService('legacyEs'); const PageObjects = getPageObjects(['settings', 'common']); - describe('"Create Index Pattern" wizard', function () { + // Flaky: https://github.com/elastic/kibana/issues/71501 + describe.skip('"Create Index Pattern" wizard', function () { before(async function () { // delete .kibana index and then wait for Kibana to re-create it await kibanaServer.uiSettings.replace({}); From 1afb0c476b158cc5509ba9f259f635bb1a7ba00b Mon Sep 17 00:00:00 2001 From: Ryland Herrick Date: Mon, 13 Jul 2020 13:18:47 -0500 Subject: [PATCH 16/66] [Security Solution][Detections] Adoption telemetry (#71102) * style: sort plugin interface * WIP: UsageCollector for Security Adoption This uses ML and raw ES calls to query our ML Jobs and Rules, and parse them into a format to be consumed by telemetry. Still to come: * initialization * tests * Initialize usage collectors during plugin setup * Rename usage key The service seems to convert colons to underscores, so let's just use an underscure. * Collector is ready if we have a kibana index * Refactor collector to generate options in a function This allows us to test our adherence to the collector API, focusing particularly on the fetch function. * Refactor usage collector in anticipation of endpoint data We're going to have our usage data under one key corresponding to the app, so this nests the existing data under a 'detections' key while allowing another fetching function to be plugged into the main collector under a separate key. * Update our collector to satisfy telemetry tooling * inlines collector options * inlines schema object * makes DetectionsUsage an interface instead of a type alias * Extracts telemetry mappings via scripts/telemetry_extract * Refactor detections usage logic to perform one loop instead of two We were previously performing two loops over each set of data: one to format it down to just the data we need, and another to convert that into usage data. We now perform both steps within a single loop. * Refactor detections telemetry to be nested * Extract new nested detections telemetry mappings Co-authored-by: Elastic Machine --- .../security_solution/server/plugin.ts | 13 +- .../server/usage/collector.ts | 54 +++++ .../server/usage/detections.mocks.ts | 162 +++++++++++++++ .../server/usage/detections.test.ts | 107 ++++++++++ .../server/usage/detections.ts | 39 ++++ .../server/usage/detections_helpers.ts | 188 ++++++++++++++++++ .../security_solution/server/usage/index.ts | 14 ++ .../security_solution/server/usage/types.ts | 12 ++ .../schema/xpack_plugins.json | 56 ++++++ 9 files changed, 643 insertions(+), 2 deletions(-) create mode 100644 x-pack/plugins/security_solution/server/usage/collector.ts create mode 100644 x-pack/plugins/security_solution/server/usage/detections.mocks.ts create mode 100644 x-pack/plugins/security_solution/server/usage/detections.test.ts create mode 100644 x-pack/plugins/security_solution/server/usage/detections.ts create mode 100644 x-pack/plugins/security_solution/server/usage/detections_helpers.ts create mode 100644 x-pack/plugins/security_solution/server/usage/index.ts create mode 100644 x-pack/plugins/security_solution/server/usage/types.ts diff --git a/x-pack/plugins/security_solution/server/plugin.ts b/x-pack/plugins/security_solution/server/plugin.ts index d4935f1aabc1c..ebd95fe79ebf5 100644 --- a/x-pack/plugins/security_solution/server/plugin.ts +++ b/x-pack/plugins/security_solution/server/plugin.ts @@ -16,6 +16,7 @@ import { PluginInitializerContext, SavedObjectsClient, } from '../../../../src/core/server'; +import { UsageCollectionSetup } from '../../../../src/plugins/usage_collection/server'; import { PluginSetupContract as AlertingSetup } from '../../alerts/server'; import { SecurityPluginSetup as SecuritySetup } from '../../security/server'; import { PluginSetupContract as FeaturesSetup } from '../../features/server'; @@ -46,17 +47,19 @@ import { ArtifactClient, ManifestManager } from './endpoint/services'; import { EndpointAppContextService } from './endpoint/endpoint_app_context_services'; import { EndpointAppContext } from './endpoint/types'; import { registerDownloadExceptionListRoute } from './endpoint/routes/artifacts'; +import { initUsageCollectors } from './usage'; export interface SetupPlugins { alerts: AlertingSetup; encryptedSavedObjects?: EncryptedSavedObjectsSetup; features: FeaturesSetup; licensing: LicensingPluginSetup; + lists?: ListPluginSetup; + ml?: MlSetup; security?: SecuritySetup; spaces?: SpacesSetup; taskManager?: TaskManagerSetupContract; - ml?: MlSetup; - lists?: ListPluginSetup; + usageCollection?: UsageCollectionSetup; } export interface StartPlugins { @@ -106,9 +109,15 @@ export class Plugin implements IPlugin void; +export interface UsageData { + detections: DetectionsUsage; +} + +export const registerCollector: RegisterCollector = ({ kibanaIndex, ml, usageCollection }) => { + if (!usageCollection) { + return; + } + + const collector = usageCollection.makeUsageCollector({ + type: 'security_solution', + schema: { + detections: { + detection_rules: { + custom: { + enabled: { type: 'long' }, + disabled: { type: 'long' }, + }, + elastic: { + enabled: { type: 'long' }, + disabled: { type: 'long' }, + }, + }, + ml_jobs: { + custom: { + enabled: { type: 'long' }, + disabled: { type: 'long' }, + }, + elastic: { + enabled: { type: 'long' }, + disabled: { type: 'long' }, + }, + }, + }, + }, + isReady: () => kibanaIndex.length > 0, + fetch: async (callCluster: LegacyAPICaller): Promise => ({ + detections: await fetchDetectionsUsage(kibanaIndex, callCluster, ml), + }), + }); + + usageCollection.registerCollector(collector); +}; diff --git a/x-pack/plugins/security_solution/server/usage/detections.mocks.ts b/x-pack/plugins/security_solution/server/usage/detections.mocks.ts new file mode 100644 index 0000000000000..c80dc6936ec7b --- /dev/null +++ b/x-pack/plugins/security_solution/server/usage/detections.mocks.ts @@ -0,0 +1,162 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { INTERNAL_IMMUTABLE_KEY } from '../../common/constants'; + +export const getMockJobSummaryResponse = () => [ + { + id: 'linux_anomalous_network_activity_ecs', + description: + 'SIEM Auditbeat: Looks for unusual processes using the network which could indicate command-and-control, lateral movement, persistence, or data exfiltration activity (beta)', + groups: ['auditbeat', 'process', 'siem'], + processed_record_count: 141889, + memory_status: 'ok', + jobState: 'opened', + hasDatafeed: true, + datafeedId: 'datafeed-linux_anomalous_network_activity_ecs', + datafeedIndices: ['auditbeat-*'], + datafeedState: 'started', + latestTimestampMs: 1594085401911, + earliestTimestampMs: 1593054845656, + latestResultsTimestampMs: 1594085401911, + isSingleMetricViewerJob: true, + nodeName: 'node', + }, + { + id: 'linux_anomalous_network_port_activity_ecs', + description: + 'SIEM Auditbeat: Looks for unusual destination port activity that could indicate command-and-control, persistence mechanism, or data exfiltration activity (beta)', + groups: ['auditbeat', 'process', 'siem'], + processed_record_count: 0, + memory_status: 'ok', + jobState: 'closed', + hasDatafeed: true, + datafeedId: 'datafeed-linux_anomalous_network_port_activity_ecs', + datafeedIndices: ['auditbeat-*'], + datafeedState: 'stopped', + isSingleMetricViewerJob: true, + }, + { + id: 'other_job', + description: 'a job that is custom', + groups: ['auditbeat', 'process'], + processed_record_count: 0, + memory_status: 'ok', + jobState: 'closed', + hasDatafeed: true, + datafeedId: 'datafeed-other', + datafeedIndices: ['auditbeat-*'], + datafeedState: 'stopped', + isSingleMetricViewerJob: true, + }, + { + id: 'another_job', + description: 'another job that is custom', + groups: ['auditbeat', 'process'], + processed_record_count: 0, + memory_status: 'ok', + jobState: 'opened', + hasDatafeed: true, + datafeedId: 'datafeed-another', + datafeedIndices: ['auditbeat-*'], + datafeedState: 'started', + isSingleMetricViewerJob: true, + }, +]; + +export const getMockListModulesResponse = () => [ + { + id: 'siem_auditbeat', + title: 'SIEM Auditbeat', + description: + 'Detect suspicious network activity and unusual processes in Auditbeat data (beta).', + type: 'Auditbeat data', + logoFile: 'logo.json', + defaultIndexPattern: 'auditbeat-*', + query: { + bool: { + filter: [ + { + term: { + 'agent.type': 'auditbeat', + }, + }, + ], + }, + }, + jobs: [ + { + id: 'linux_anomalous_network_activity_ecs', + config: { + job_type: 'anomaly_detector', + description: + 'SIEM Auditbeat: Looks for unusual processes using the network which could indicate command-and-control, lateral movement, persistence, or data exfiltration activity (beta)', + groups: ['siem', 'auditbeat', 'process'], + analysis_config: { + bucket_span: '15m', + detectors: [ + { + detector_description: 'rare by "process.name"', + function: 'rare', + by_field_name: 'process.name', + }, + ], + influencers: ['host.name', 'process.name', 'user.name', 'destination.ip'], + }, + allow_lazy_open: true, + analysis_limits: { + model_memory_limit: '64mb', + }, + data_description: { + time_field: '@timestamp', + }, + }, + }, + { + id: 'linux_anomalous_network_port_activity_ecs', + config: { + job_type: 'anomaly_detector', + description: + 'SIEM Auditbeat: Looks for unusual destination port activity that could indicate command-and-control, persistence mechanism, or data exfiltration activity (beta)', + groups: ['siem', 'auditbeat', 'network'], + analysis_config: { + bucket_span: '15m', + detectors: [ + { + detector_description: 'rare by "destination.port"', + function: 'rare', + by_field_name: 'destination.port', + }, + ], + influencers: ['host.name', 'process.name', 'user.name', 'destination.ip'], + }, + allow_lazy_open: true, + analysis_limits: { + model_memory_limit: '32mb', + }, + data_description: { + time_field: '@timestamp', + }, + }, + }, + ], + datafeeds: [], + kibana: {}, + }, +]; + +export const getMockRulesResponse = () => ({ + hits: { + hits: [ + { _source: { alert: { enabled: true, tags: [`${INTERNAL_IMMUTABLE_KEY}:true`] } } }, + { _source: { alert: { enabled: true, tags: [`${INTERNAL_IMMUTABLE_KEY}:false`] } } }, + { _source: { alert: { enabled: false, tags: [`${INTERNAL_IMMUTABLE_KEY}:true`] } } }, + { _source: { alert: { enabled: true, tags: [`${INTERNAL_IMMUTABLE_KEY}:true`] } } }, + { _source: { alert: { enabled: false, tags: [`${INTERNAL_IMMUTABLE_KEY}:false`] } } }, + { _source: { alert: { enabled: false, tags: [`${INTERNAL_IMMUTABLE_KEY}:true`] } } }, + { _source: { alert: { enabled: false, tags: [`${INTERNAL_IMMUTABLE_KEY}:true`] } } }, + ], + }, +}); diff --git a/x-pack/plugins/security_solution/server/usage/detections.test.ts b/x-pack/plugins/security_solution/server/usage/detections.test.ts new file mode 100644 index 0000000000000..7fd2d3eb9ff27 --- /dev/null +++ b/x-pack/plugins/security_solution/server/usage/detections.test.ts @@ -0,0 +1,107 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { LegacyAPICaller } from '../../../../../src/core/server'; +import { elasticsearchServiceMock } from '../../../../../src/core/server/mocks'; +import { jobServiceProvider } from '../../../ml/server/models/job_service'; +import { DataRecognizer } from '../../../ml/server/models/data_recognizer'; +import { mlServicesMock } from '../lib/machine_learning/mocks'; +import { + getMockJobSummaryResponse, + getMockListModulesResponse, + getMockRulesResponse, +} from './detections.mocks'; +import { fetchDetectionsUsage } from './detections'; + +jest.mock('../../../ml/server/models/job_service'); +jest.mock('../../../ml/server/models/data_recognizer'); + +describe('Detections Usage', () => { + describe('fetchDetectionsUsage()', () => { + let callClusterMock: jest.Mocked; + let mlMock: ReturnType; + + beforeEach(() => { + callClusterMock = elasticsearchServiceMock.createLegacyClusterClient().callAsInternalUser; + mlMock = mlServicesMock.create(); + }); + + it('returns zeroed counts if both calls are empty', async () => { + const result = await fetchDetectionsUsage('', callClusterMock, mlMock); + + expect(result).toEqual({ + detection_rules: { + custom: { + enabled: 0, + disabled: 0, + }, + elastic: { + enabled: 0, + disabled: 0, + }, + }, + ml_jobs: { + custom: { + enabled: 0, + disabled: 0, + }, + elastic: { + enabled: 0, + disabled: 0, + }, + }, + }); + }); + + it('tallies rules data given rules results', async () => { + (callClusterMock as jest.Mock).mockResolvedValue(getMockRulesResponse()); + const result = await fetchDetectionsUsage('', callClusterMock, mlMock); + + expect(result).toEqual( + expect.objectContaining({ + detection_rules: { + custom: { + enabled: 1, + disabled: 1, + }, + elastic: { + enabled: 2, + disabled: 3, + }, + }, + }) + ); + }); + + it('tallies jobs data given jobs results', async () => { + const mockJobSummary = jest.fn().mockResolvedValue(getMockJobSummaryResponse()); + const mockListModules = jest.fn().mockResolvedValue(getMockListModulesResponse()); + (jobServiceProvider as jest.Mock).mockImplementation(() => ({ + jobsSummary: mockJobSummary, + })); + (DataRecognizer as jest.Mock).mockImplementation(() => ({ + listModules: mockListModules, + })); + + const result = await fetchDetectionsUsage('', callClusterMock, mlMock); + + expect(result).toEqual( + expect.objectContaining({ + ml_jobs: { + custom: { + enabled: 1, + disabled: 1, + }, + elastic: { + enabled: 1, + disabled: 1, + }, + }, + }) + ); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/server/usage/detections.ts b/x-pack/plugins/security_solution/server/usage/detections.ts new file mode 100644 index 0000000000000..1475a8ae34625 --- /dev/null +++ b/x-pack/plugins/security_solution/server/usage/detections.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { LegacyAPICaller } from '../../../../../src/core/server'; +import { getMlJobsUsage, getRulesUsage } from './detections_helpers'; +import { MlPluginSetup } from '../../../ml/server'; + +interface FeatureUsage { + enabled: number; + disabled: number; +} + +export interface DetectionRulesUsage { + custom: FeatureUsage; + elastic: FeatureUsage; +} + +export interface MlJobsUsage { + custom: FeatureUsage; + elastic: FeatureUsage; +} + +export interface DetectionsUsage { + detection_rules: DetectionRulesUsage; + ml_jobs: MlJobsUsage; +} + +export const fetchDetectionsUsage = async ( + kibanaIndex: string, + callCluster: LegacyAPICaller, + ml: MlPluginSetup | undefined +): Promise => { + const rulesUsage = await getRulesUsage(kibanaIndex, callCluster); + const mlJobsUsage = await getMlJobsUsage(ml); + return { detection_rules: rulesUsage, ml_jobs: mlJobsUsage }; +}; diff --git a/x-pack/plugins/security_solution/server/usage/detections_helpers.ts b/x-pack/plugins/security_solution/server/usage/detections_helpers.ts new file mode 100644 index 0000000000000..18a90b12991b2 --- /dev/null +++ b/x-pack/plugins/security_solution/server/usage/detections_helpers.ts @@ -0,0 +1,188 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { SearchParams } from 'elasticsearch'; + +import { LegacyAPICaller, SavedObjectsClient } from '../../../../../src/core/server'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { jobServiceProvider } from '../../../ml/server/models/job_service'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { DataRecognizer } from '../../../ml/server/models/data_recognizer'; +import { MlPluginSetup } from '../../../ml/server'; +import { SIGNALS_ID, INTERNAL_IMMUTABLE_KEY } from '../../common/constants'; +import { DetectionRulesUsage, MlJobsUsage } from './detections'; +import { isJobStarted } from '../../common/machine_learning/helpers'; + +interface DetectionsMetric { + isElastic: boolean; + isEnabled: boolean; +} + +const isElasticRule = (tags: string[]) => tags.includes(`${INTERNAL_IMMUTABLE_KEY}:true`); + +const initialRulesUsage: DetectionRulesUsage = { + custom: { + enabled: 0, + disabled: 0, + }, + elastic: { + enabled: 0, + disabled: 0, + }, +}; + +const initialMlJobsUsage: MlJobsUsage = { + custom: { + enabled: 0, + disabled: 0, + }, + elastic: { + enabled: 0, + disabled: 0, + }, +}; + +const updateRulesUsage = ( + ruleMetric: DetectionsMetric, + usage: DetectionRulesUsage +): DetectionRulesUsage => { + const { isEnabled, isElastic } = ruleMetric; + if (isEnabled && isElastic) { + return { + ...usage, + elastic: { + ...usage.elastic, + enabled: usage.elastic.enabled + 1, + }, + }; + } else if (!isEnabled && isElastic) { + return { + ...usage, + elastic: { + ...usage.elastic, + disabled: usage.elastic.disabled + 1, + }, + }; + } else if (isEnabled && !isElastic) { + return { + ...usage, + custom: { + ...usage.custom, + enabled: usage.custom.enabled + 1, + }, + }; + } else if (!isEnabled && !isElastic) { + return { + ...usage, + custom: { + ...usage.custom, + disabled: usage.custom.disabled + 1, + }, + }; + } else { + return usage; + } +}; + +const updateMlJobsUsage = (jobMetric: DetectionsMetric, usage: MlJobsUsage): MlJobsUsage => { + const { isEnabled, isElastic } = jobMetric; + if (isEnabled && isElastic) { + return { + ...usage, + elastic: { + ...usage.elastic, + enabled: usage.elastic.enabled + 1, + }, + }; + } else if (!isEnabled && isElastic) { + return { + ...usage, + elastic: { + ...usage.elastic, + disabled: usage.elastic.disabled + 1, + }, + }; + } else if (isEnabled && !isElastic) { + return { + ...usage, + custom: { + ...usage.custom, + enabled: usage.custom.enabled + 1, + }, + }; + } else if (!isEnabled && !isElastic) { + return { + ...usage, + custom: { + ...usage.custom, + disabled: usage.custom.disabled + 1, + }, + }; + } else { + return usage; + } +}; + +export const getRulesUsage = async ( + index: string, + callCluster: LegacyAPICaller +): Promise => { + let rulesUsage: DetectionRulesUsage = initialRulesUsage; + const ruleSearchOptions: SearchParams = { + body: { query: { bool: { filter: { term: { 'alert.alertTypeId': SIGNALS_ID } } } } }, + filterPath: ['hits.hits._source.alert.enabled', 'hits.hits._source.alert.tags'], + ignoreUnavailable: true, + index, + size: 10000, // elasticsearch index.max_result_window default value + }; + + try { + const ruleResults = await callCluster<{ alert: { enabled: boolean; tags: string[] } }>( + 'search', + ruleSearchOptions + ); + + if (ruleResults.hits?.hits?.length > 0) { + rulesUsage = ruleResults.hits.hits.reduce((usage, hit) => { + const isElastic = isElasticRule(hit._source.alert.tags); + const isEnabled = hit._source.alert.enabled; + + return updateRulesUsage({ isElastic, isEnabled }, usage); + }, initialRulesUsage); + } + } catch (e) { + // ignore failure, usage will be zeroed + } + + return rulesUsage; +}; + +export const getMlJobsUsage = async (ml: MlPluginSetup | undefined): Promise => { + let jobsUsage: MlJobsUsage = initialMlJobsUsage; + + if (ml) { + try { + const mlCaller = ml.mlClient.callAsInternalUser; + const modules = await new DataRecognizer( + mlCaller, + ({} as unknown) as SavedObjectsClient + ).listModules(); + const moduleJobs = modules.flatMap((module) => module.jobs); + const jobs = await jobServiceProvider(mlCaller).jobsSummary(['siem']); + + jobsUsage = jobs.reduce((usage, job) => { + const isElastic = moduleJobs.some((moduleJob) => moduleJob.id === job.id); + const isEnabled = isJobStarted(job.jobState, job.datafeedState); + + return updateMlJobsUsage({ isElastic, isEnabled }, usage); + }, initialMlJobsUsage); + } catch (e) { + // ignore failure, usage will be zeroed + } + } + + return jobsUsage; +}; diff --git a/x-pack/plugins/security_solution/server/usage/index.ts b/x-pack/plugins/security_solution/server/usage/index.ts new file mode 100644 index 0000000000000..4d8749a83be80 --- /dev/null +++ b/x-pack/plugins/security_solution/server/usage/index.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { CollectorDependencies } from './types'; +import { registerCollector } from './collector'; + +export type InitUsageCollectors = (deps: CollectorDependencies) => void; + +export const initUsageCollectors: InitUsageCollectors = (dependencies) => { + registerCollector(dependencies); +}; diff --git a/x-pack/plugins/security_solution/server/usage/types.ts b/x-pack/plugins/security_solution/server/usage/types.ts new file mode 100644 index 0000000000000..955a4eaf4be5a --- /dev/null +++ b/x-pack/plugins/security_solution/server/usage/types.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { SetupPlugins } from '../plugin'; + +export type CollectorDependencies = { kibanaIndex: string } & Pick< + SetupPlugins, + 'ml' | 'usageCollection' +>; diff --git a/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json b/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json index 899ece7bce312..c5d528cbcce23 100644 --- a/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json +++ b/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json @@ -164,6 +164,62 @@ } } }, + "security_solution": { + "properties": { + "detections": { + "properties": { + "detection_rules": { + "properties": { + "custom": { + "properties": { + "enabled": { + "type": "long" + }, + "disabled": { + "type": "long" + } + } + }, + "elastic": { + "properties": { + "enabled": { + "type": "long" + }, + "disabled": { + "type": "long" + } + } + } + } + }, + "ml_jobs": { + "properties": { + "custom": { + "properties": { + "enabled": { + "type": "long" + }, + "disabled": { + "type": "long" + } + } + }, + "elastic": { + "properties": { + "enabled": { + "type": "long" + }, + "disabled": { + "type": "long" + } + } + } + } + } + } + } + } + }, "spaces": { "properties": { "usesFeatureControls": { From cd43bbc3654922835276063d039ab9d5b9cc45b0 Mon Sep 17 00:00:00 2001 From: Jonathan Buttner <56361221+jonathan-buttner@users.noreply.github.com> Date: Mon, 13 Jul 2020 14:22:17 -0400 Subject: [PATCH 17/66] Increasing limits for resolver (#71483) --- .../common/endpoint/schema/resolver.ts | 16 ++++++++-------- .../api_integration/apis/endpoint/resolver.ts | 12 ++++++++---- 2 files changed, 16 insertions(+), 12 deletions(-) diff --git a/x-pack/plugins/security_solution/common/endpoint/schema/resolver.ts b/x-pack/plugins/security_solution/common/endpoint/schema/resolver.ts index 42cbc2327fc28..c67ad3665d004 100644 --- a/x-pack/plugins/security_solution/common/endpoint/schema/resolver.ts +++ b/x-pack/plugins/security_solution/common/endpoint/schema/resolver.ts @@ -12,10 +12,10 @@ import { schema } from '@kbn/config-schema'; export const validateTree = { params: schema.object({ id: schema.string() }), query: schema.object({ - children: schema.number({ defaultValue: 10, min: 0, max: 100 }), - ancestors: schema.number({ defaultValue: 3, min: 0, max: 5 }), - events: schema.number({ defaultValue: 100, min: 0, max: 1000 }), - alerts: schema.number({ defaultValue: 100, min: 0, max: 1000 }), + children: schema.number({ defaultValue: 200, min: 0, max: 10000 }), + ancestors: schema.number({ defaultValue: 200, min: 0, max: 10000 }), + events: schema.number({ defaultValue: 1000, min: 0, max: 10000 }), + alerts: schema.number({ defaultValue: 1000, min: 0, max: 10000 }), afterEvent: schema.maybe(schema.string()), afterAlert: schema.maybe(schema.string()), afterChild: schema.maybe(schema.string()), @@ -29,7 +29,7 @@ export const validateTree = { export const validateEvents = { params: schema.object({ id: schema.string() }), query: schema.object({ - events: schema.number({ defaultValue: 100, min: 1, max: 1000 }), + events: schema.number({ defaultValue: 1000, min: 1, max: 10000 }), afterEvent: schema.maybe(schema.string()), legacyEndpointID: schema.maybe(schema.string()), }), @@ -41,7 +41,7 @@ export const validateEvents = { export const validateAlerts = { params: schema.object({ id: schema.string() }), query: schema.object({ - alerts: schema.number({ defaultValue: 100, min: 1, max: 1000 }), + alerts: schema.number({ defaultValue: 1000, min: 1, max: 10000 }), afterAlert: schema.maybe(schema.string()), legacyEndpointID: schema.maybe(schema.string()), }), @@ -53,7 +53,7 @@ export const validateAlerts = { export const validateAncestry = { params: schema.object({ id: schema.string() }), query: schema.object({ - ancestors: schema.number({ defaultValue: 0, min: 0, max: 10 }), + ancestors: schema.number({ defaultValue: 200, min: 0, max: 10000 }), legacyEndpointID: schema.maybe(schema.string()), }), }; @@ -64,7 +64,7 @@ export const validateAncestry = { export const validateChildren = { params: schema.object({ id: schema.string() }), query: schema.object({ - children: schema.number({ defaultValue: 10, min: 1, max: 100 }), + children: schema.number({ defaultValue: 200, min: 1, max: 10000 }), afterChild: schema.maybe(schema.string()), legacyEndpointID: schema.maybe(schema.string()), }), diff --git a/x-pack/test/api_integration/apis/endpoint/resolver.ts b/x-pack/test/api_integration/apis/endpoint/resolver.ts index ace32111005f4..c8217f2b6872a 100644 --- a/x-pack/test/api_integration/apis/endpoint/resolver.ts +++ b/x-pack/test/api_integration/apis/endpoint/resolver.ts @@ -366,7 +366,7 @@ export default function resolverAPIIntegrationTests({ getService }: FtrProviderC it('should error on invalid pagination values', async () => { await supertest.get(`/api/endpoint/resolver/${entityID}/events?events=0`).expect(400); - await supertest.get(`/api/endpoint/resolver/${entityID}/events?events=2000`).expect(400); + await supertest.get(`/api/endpoint/resolver/${entityID}/events?events=20000`).expect(400); await supertest.get(`/api/endpoint/resolver/${entityID}/events?events=-1`).expect(400); }); }); @@ -444,14 +444,18 @@ export default function resolverAPIIntegrationTests({ getService }: FtrProviderC it('should have a populated next parameter', async () => { const { body }: { body: ResolverAncestry } = await supertest - .get(`/api/endpoint/resolver/${entityID}/ancestry?legacyEndpointID=${endpointID}`) + .get( + `/api/endpoint/resolver/${entityID}/ancestry?legacyEndpointID=${endpointID}&ancestors=0` + ) .expect(200); expect(body.nextAncestor).to.eql('94041'); }); it('should handle an ancestors param request', async () => { let { body }: { body: ResolverAncestry } = await supertest - .get(`/api/endpoint/resolver/${entityID}/ancestry?legacyEndpointID=${endpointID}`) + .get( + `/api/endpoint/resolver/${entityID}/ancestry?legacyEndpointID=${endpointID}&ancestors=0` + ) .expect(200); const next = body.nextAncestor; @@ -579,7 +583,7 @@ export default function resolverAPIIntegrationTests({ getService }: FtrProviderC it('errors on invalid pagination values', async () => { await supertest.get(`/api/endpoint/resolver/${entityID}/children?children=0`).expect(400); await supertest - .get(`/api/endpoint/resolver/${entityID}/children?children=2000`) + .get(`/api/endpoint/resolver/${entityID}/children?children=20000`) .expect(400); await supertest .get(`/api/endpoint/resolver/${entityID}/children?children=-1`) From 649a16bd8813af13f1837d6207e8977c151b4346 Mon Sep 17 00:00:00 2001 From: Madison Caldwell Date: Mon, 13 Jul 2020 14:25:04 -0400 Subject: [PATCH 18/66] [Security Solution][Endpoint][Ingest Manager] Improved testing for user manifest consistency (#71381) * Test user artifacts for all OSes. Test unicode. * Test hashes and sizes pre- and post- decoding * Clean up types in ingestManager common mocks * Fix type in package config mock * Add test for conflict on dispatch * Test package config conflict resolution --- x-pack/plugins/ingest_manager/common/mocks.ts | 11 +- .../server/services/package_config.test.ts | 33 +++- .../manifest_manager/manifest_manager.mock.ts | 2 +- .../manifest_manager/manifest_manager.test.ts | 32 +++- .../apis/endpoint/artifacts/index.ts | 180 +++++++++++++++++- .../endpoint/artifacts/api_feature/data.json | 52 ++++- 6 files changed, 291 insertions(+), 19 deletions(-) diff --git a/x-pack/plugins/ingest_manager/common/mocks.ts b/x-pack/plugins/ingest_manager/common/mocks.ts index 131917af44595..e85364f2bb672 100644 --- a/x-pack/plugins/ingest_manager/common/mocks.ts +++ b/x-pack/plugins/ingest_manager/common/mocks.ts @@ -6,7 +6,7 @@ import { NewPackageConfig, PackageConfig } from './types/models/package_config'; -export const createNewPackageConfigMock = () => { +export const createNewPackageConfigMock = (): NewPackageConfig => { return { name: 'endpoint-1', description: '', @@ -20,10 +20,10 @@ export const createNewPackageConfigMock = () => { version: '0.9.0', }, inputs: [], - } as NewPackageConfig; + }; }; -export const createPackageConfigMock = () => { +export const createPackageConfigMock = (): PackageConfig => { const newPackageConfig = createNewPackageConfigMock(); return { ...newPackageConfig, @@ -37,7 +37,10 @@ export const createPackageConfigMock = () => { inputs: [ { config: {}, + enabled: true, + type: 'endpoint', + streams: [], }, ], - } as PackageConfig; + }; }; diff --git a/x-pack/plugins/ingest_manager/server/services/package_config.test.ts b/x-pack/plugins/ingest_manager/server/services/package_config.test.ts index f8dd1c65e3e72..e86e2608e252d 100644 --- a/x-pack/plugins/ingest_manager/server/services/package_config.test.ts +++ b/x-pack/plugins/ingest_manager/server/services/package_config.test.ts @@ -4,8 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ +import { savedObjectsClientMock } from 'src/core/server/mocks'; +import { createPackageConfigMock } from '../../common/mocks'; import { packageConfigService } from './package_config'; -import { PackageInfo } from '../types'; +import { PackageInfo, PackageConfigSOAttributes } from '../types'; +import { SavedObjectsUpdateResponse } from 'src/core/server'; async function mockedGetAssetsData(_a: any, _b: any, dataset: string) { if (dataset === 'dataset1') { @@ -161,4 +164,32 @@ describe('Package config service', () => { ]); }); }); + + describe('update', () => { + it('should fail to update on version conflict', async () => { + const savedObjectsClient = savedObjectsClientMock.create(); + savedObjectsClient.get.mockResolvedValue({ + id: 'test', + type: 'abcd', + references: [], + version: 'test', + attributes: createPackageConfigMock(), + }); + savedObjectsClient.update.mockImplementation( + async ( + type: string, + id: string + ): Promise> => { + throw savedObjectsClient.errors.createConflictError('abc', '123'); + } + ); + await expect( + packageConfigService.update( + savedObjectsClient, + 'the-package-config-id', + createPackageConfigMock() + ) + ).rejects.toThrow('Saved object [abc/123] conflict'); + }); + }); }); diff --git a/x-pack/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.mock.ts b/x-pack/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.mock.ts index 3bdc5dfbcbd45..3e4fee8871b8a 100644 --- a/x-pack/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.mock.ts +++ b/x-pack/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.mock.ts @@ -64,7 +64,7 @@ export const getManifestManagerMock = (opts?: { } packageConfigService.list = jest.fn().mockResolvedValue({ total: 1, - items: [createPackageConfigMock()], + items: [{ version: 'abcd', ...createPackageConfigMock() }], }); let savedObjectsClient = savedObjectsClientMock.create(); diff --git a/x-pack/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.test.ts b/x-pack/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.test.ts index d092e7060f8aa..80d325ece765c 100644 --- a/x-pack/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.test.ts @@ -77,8 +77,36 @@ describe('manifest_manager', () => { const packageConfigService = createPackageConfigServiceMock(); const manifestManager = getManifestManagerMock({ packageConfigService }); const snapshot = await manifestManager.getSnapshot(); - const dispatched = await manifestManager.dispatch(snapshot!.manifest); - expect(dispatched).toEqual([]); + const dispatchErrors = await manifestManager.dispatch(snapshot!.manifest); + expect(dispatchErrors).toEqual([]); + const entries = snapshot!.manifest.getEntries(); + const artifact = Object.values(entries)[0].getArtifact(); + expect( + packageConfigService.update.mock.calls[0][2].inputs[0].config!.artifact_manifest.value + ).toEqual({ + manifest_version: ManifestConstants.INITIAL_VERSION, + schema_version: 'v1', + artifacts: { + [artifact.identifier]: { + compression_algorithm: 'none', + encryption_algorithm: 'none', + decoded_sha256: artifact.decodedSha256, + encoded_sha256: artifact.encodedSha256, + decoded_size: artifact.decodedSize, + encoded_size: artifact.encodedSize, + relative_url: `/api/endpoint/artifacts/download/${artifact.identifier}/${artifact.decodedSha256}`, + }, + }, + }); + }); + + test('ManifestManager fails to dispatch on conflict', async () => { + const packageConfigService = createPackageConfigServiceMock(); + const manifestManager = getManifestManagerMock({ packageConfigService }); + const snapshot = await manifestManager.getSnapshot(); + packageConfigService.update.mockRejectedValue({ status: 409 }); + const dispatchErrors = await manifestManager.dispatch(snapshot!.manifest); + expect(dispatchErrors).toEqual([{ status: 409 }]); const entries = snapshot!.manifest.getEntries(); const artifact = Object.values(entries)[0].getArtifact(); expect( diff --git a/x-pack/test/api_integration/apis/endpoint/artifacts/index.ts b/x-pack/test/api_integration/apis/endpoint/artifacts/index.ts index ca59d396839ae..ba68b9b7ba6ee 100644 --- a/x-pack/test/api_integration/apis/endpoint/artifacts/index.ts +++ b/x-pack/test/api_integration/apis/endpoint/artifacts/index.ts @@ -5,6 +5,7 @@ */ import expect from '@kbn/expect'; +import { createHash } from 'crypto'; import { inflateSync } from 'zlib'; import { FtrProviderContext } from '../../../ftr_provider_context'; @@ -69,7 +70,18 @@ export default function (providerContext: FtrProviderContext) { .expect(404); }); - it('should download an artifact with correct hash', async () => { + it('should fail on invalid api key with 401', async () => { + await supertestWithoutAuth + .get( + '/api/endpoint/artifacts/download/endpoint-exceptionlist-macos-v1/1825fb19fcc6dc391cae0bc4a2e96dd7f728a0c3ae9e1469251ada67f9e1b975' + ) + .set('kbn-xsrf', 'xxx') + .set('authorization', `ApiKey iNvAlId`) + .send() + .expect(401); + }); + + it('should download an artifact with list items', async () => { await supertestWithoutAuth .get( '/api/endpoint/artifacts/download/endpoint-exceptionlist-linux-v1/d2a9c760005b08d43394e59a8701ae75c80881934ccf15a006944452b80f7f9f' @@ -79,7 +91,18 @@ export default function (providerContext: FtrProviderContext) { .send() .expect(200) .expect((response) => { - const artifactJson = JSON.parse(inflateSync(response.body).toString()); + expect(response.body.byteLength).to.equal(160); + const encodedHash = createHash('sha256').update(response.body).digest('hex'); + expect(encodedHash).to.equal( + '5caaeabcb7864d47157fc7c28d5a7398b4f6bbaaa565d789c02ee809253b7613' + ); + const decodedBody = inflateSync(response.body); + const decodedHash = createHash('sha256').update(decodedBody).digest('hex'); + expect(decodedHash).to.equal( + 'd2a9c760005b08d43394e59a8701ae75c80881934ccf15a006944452b80f7f9f' + ); + expect(decodedBody.byteLength).to.equal(358); + const artifactJson = JSON.parse(decodedBody.toString()); expect(artifactJson).to.eql({ entries: [ { @@ -116,10 +139,10 @@ export default function (providerContext: FtrProviderContext) { }); }); - it('should download an artifact with correct hash from cache', async () => { + it('should download an artifact with unicode characters', async () => { await supertestWithoutAuth .get( - '/api/endpoint/artifacts/download/endpoint-exceptionlist-linux-v1/d2a9c760005b08d43394e59a8701ae75c80881934ccf15a006944452b80f7f9f' + '/api/endpoint/artifacts/download/endpoint-exceptionlist-windows-v1/8d2bcc37e82fad5d06e2c9e4bd96793ea8905ace1d528a57d0d0579ecc8c647e' ) .set('kbn-xsrf', 'xxx') .set('authorization', `ApiKey ${agentAccessAPIKey}`) @@ -131,14 +154,25 @@ export default function (providerContext: FtrProviderContext) { .then(async () => { await supertestWithoutAuth .get( - '/api/endpoint/artifacts/download/endpoint-exceptionlist-linux-v1/d2a9c760005b08d43394e59a8701ae75c80881934ccf15a006944452b80f7f9f' + '/api/endpoint/artifacts/download/endpoint-exceptionlist-windows-v1/8d2bcc37e82fad5d06e2c9e4bd96793ea8905ace1d528a57d0d0579ecc8c647e' ) .set('kbn-xsrf', 'xxx') .set('authorization', `ApiKey ${agentAccessAPIKey}`) .send() .expect(200) .expect((response) => { - const artifactJson = JSON.parse(inflateSync(response.body).toString()); + const encodedHash = createHash('sha256').update(response.body).digest('hex'); + expect(encodedHash).to.equal( + '73015ee5131dabd1b48aa4776d3e766d836f8dd8c9fa8999c9b931f60027f07f' + ); + expect(response.body.byteLength).to.equal(191); + const decodedBody = inflateSync(response.body); + const decodedHash = createHash('sha256').update(decodedBody).digest('hex'); + expect(decodedHash).to.equal( + '8d2bcc37e82fad5d06e2c9e4bd96793ea8905ace1d528a57d0d0579ecc8c647e' + ); + expect(decodedBody.byteLength).to.equal(704); + const artifactJson = JSON.parse(decodedBody.toString()); expect(artifactJson).to.eql({ entries: [ { @@ -150,6 +184,35 @@ export default function (providerContext: FtrProviderContext) { type: 'exact_cased', value: 'Elastic, N.V.', }, + { + entries: [ + { + field: 'signer', + operator: 'included', + type: 'exact_cased', + value: '😈', + }, + { + field: 'trusted', + operator: 'included', + type: 'exact_cased', + value: 'true', + }, + ], + field: 'file.signature', + type: 'nested', + }, + ], + }, + { + type: 'simple', + entries: [ + { + field: 'actingProcess.file.signer', + operator: 'included', + type: 'exact_cased', + value: 'Another signer', + }, { entries: [ { @@ -176,15 +239,112 @@ export default function (providerContext: FtrProviderContext) { }); }); - it('should fail on invalid api key', async () => { + it('should download an artifact with empty exception list', async () => { await supertestWithoutAuth .get( - '/api/endpoint/artifacts/download/endpoint-exceptionlist-macos-v1/1825fb19fcc6dc391cae0bc4a2e96dd7f728a0c3ae9e1469251ada67f9e1b975' + '/api/endpoint/artifacts/download/endpoint-exceptionlist-macos-v1/d801aa1fb7ddcc330a5e3173372ea6af4a3d08ec58074478e85aa5603e926658' ) .set('kbn-xsrf', 'xxx') - .set('authorization', `ApiKey iNvAlId`) + .set('authorization', `ApiKey ${agentAccessAPIKey}`) .send() - .expect(401); + .expect(200) + .expect((response) => { + JSON.parse(inflateSync(response.body).toString()); + }) + .then(async () => { + await supertestWithoutAuth + .get( + '/api/endpoint/artifacts/download/endpoint-exceptionlist-macos-v1/d801aa1fb7ddcc330a5e3173372ea6af4a3d08ec58074478e85aa5603e926658' + ) + .set('kbn-xsrf', 'xxx') + .set('authorization', `ApiKey ${agentAccessAPIKey}`) + .send() + .expect(200) + .expect((response) => { + const encodedHash = createHash('sha256').update(response.body).digest('hex'); + expect(encodedHash).to.equal( + 'f8e6afa1d5662f5b37f83337af774b5785b5b7f1daee08b7b00c2d6813874cda' + ); + expect(response.body.byteLength).to.equal(22); + const decodedBody = inflateSync(response.body); + const decodedHash = createHash('sha256').update(decodedBody).digest('hex'); + expect(decodedHash).to.equal( + 'd801aa1fb7ddcc330a5e3173372ea6af4a3d08ec58074478e85aa5603e926658' + ); + expect(decodedBody.byteLength).to.equal(14); + const artifactJson = JSON.parse(decodedBody.toString()); + expect(artifactJson.entries.length).to.equal(0); + }); + }); + }); + + it('should download an artifact from cache', async () => { + await supertestWithoutAuth + .get( + '/api/endpoint/artifacts/download/endpoint-exceptionlist-linux-v1/d2a9c760005b08d43394e59a8701ae75c80881934ccf15a006944452b80f7f9f' + ) + .set('kbn-xsrf', 'xxx') + .set('authorization', `ApiKey ${agentAccessAPIKey}`) + .send() + .expect(200) + .expect((response) => { + JSON.parse(inflateSync(response.body).toString()); + }) + .then(async () => { + await supertestWithoutAuth + .get( + '/api/endpoint/artifacts/download/endpoint-exceptionlist-linux-v1/d2a9c760005b08d43394e59a8701ae75c80881934ccf15a006944452b80f7f9f' + ) + .set('kbn-xsrf', 'xxx') + .set('authorization', `ApiKey ${agentAccessAPIKey}`) + .send() + .expect(200) + .expect((response) => { + const encodedHash = createHash('sha256').update(response.body).digest('hex'); + expect(encodedHash).to.equal( + '5caaeabcb7864d47157fc7c28d5a7398b4f6bbaaa565d789c02ee809253b7613' + ); + const decodedBody = inflateSync(response.body); + const decodedHash = createHash('sha256').update(decodedBody).digest('hex'); + expect(decodedHash).to.equal( + 'd2a9c760005b08d43394e59a8701ae75c80881934ccf15a006944452b80f7f9f' + ); + const artifactJson = JSON.parse(decodedBody.toString()); + expect(artifactJson).to.eql({ + entries: [ + { + type: 'simple', + entries: [ + { + field: 'actingProcess.file.signer', + operator: 'included', + type: 'exact_cased', + value: 'Elastic, N.V.', + }, + { + entries: [ + { + field: 'signer', + operator: 'included', + type: 'exact_cased', + value: 'Evil', + }, + { + field: 'trusted', + operator: 'included', + type: 'exact_cased', + value: 'true', + }, + ], + field: 'file.signature', + type: 'nested', + }, + ], + }, + ], + }); + }); + }); }); }); } diff --git a/x-pack/test/functional/es_archives/endpoint/artifacts/api_feature/data.json b/x-pack/test/functional/es_archives/endpoint/artifacts/api_feature/data.json index ab476660e3ffc..47390f0428742 100644 --- a/x-pack/test/functional/es_archives/endpoint/artifacts/api_feature/data.json +++ b/x-pack/test/functional/es_archives/endpoint/artifacts/api_feature/data.json @@ -23,6 +23,56 @@ } } +{ + "type": "doc", + "value": { + "id": "endpoint:user-artifact:endpoint-exceptionlist-macos-v1-d801aa1fb7ddcc330a5e3173372ea6af4a3d08ec58074478e85aa5603e926658", + "index": ".kibana", + "source": { + "references": [ + ], + "endpoint:user-artifact": { + "body": "eJyrVkrNKynKTC1WsoqOrQUAJxkFKQ==", + "created": 1594402653532, + "compressionAlgorithm": "zlib", + "encryptionAlgorithm": "none", + "identifier": "endpoint-exceptionlist-macos-v1", + "encodedSha256": "f8e6afa1d5662f5b37f83337af774b5785b5b7f1daee08b7b00c2d6813874cda", + "encodedSize": 14, + "decodedSha256": "d801aa1fb7ddcc330a5e3173372ea6af4a3d08ec58074478e85aa5603e926658", + "decodedSize": 22 + }, + "type": "endpoint:user-artifact", + "updated_at": "2020-07-10T17:38:47.584Z" + } + } +} + +{ + "type": "doc", + "value": { + "id": "endpoint:user-artifact:endpoint-exceptionlist-windows-v1-8d2bcc37e82fad5d06e2c9e4bd96793ea8905ace1d528a57d0d0579ecc8c647e", + "index": ".kibana", + "source": { + "references": [ + ], + "endpoint:user-artifact": { + "body": "eJzFkL0KwjAUhV+lZA55gG4OXcXJRYqE9LZeiElJbotSsvsIbr6ij2AaakVwUqTr+fkOnIGBIYfgWb4bGJ1bYDnzeGw1MP7m1Qi6iqZUhKbZOKvAe1GjBuGxMeBi3rbgJFkXY2iU7iqoojpR4RSreyV9Enupu1EttPSEimdrsRUs8OHj6C8L99v1ksBPGLnOU4p8QYtlYKHkM21+QFLn4FU3kEZCOU4vcOzKWDqAyybGP54tetSLPluGB+Nu8h4=", + "created": 1594402653532, + "compressionAlgorithm": "zlib", + "encryptionAlgorithm": "none", + "identifier": "endpoint-exceptionlist-windows-v1", + "encodedSha256": "73015ee5131dabd1b48aa4776d3e766d836f8dd8c9fa8999c9b931f60027f07f", + "encodedSize": 191, + "decodedSha256": "8d2bcc37e82fad5d06e2c9e4bd96793ea8905ace1d528a57d0d0579ecc8c647e", + "decodedSize": 704 + }, + "type": "endpoint:user-artifact", + "updated_at": "2020-07-10T17:38:47.584Z" + } + } +} + { "type": "doc", "value": { @@ -36,7 +86,7 @@ "ids": [ "endpoint-exceptionlist-linux-v1-d2a9c760005b08d43394e59a8701ae75c80881934ccf15a006944452b80f7f9f", "endpoint-exceptionlist-macos-v1-d801aa1fb7ddcc330a5e3173372ea6af4a3d08ec58074478e85aa5603e926658", - "endpoint-exceptionlist-windows-v1-d801aa1fb7ddcc330a5e3173372ea6af4a3d08ec58074478e85aa5603e926658" + "endpoint-exceptionlist-windows-v1-8d2bcc37e82fad5d06e2c9e4bd96793ea8905ace1d528a57d0d0579ecc8c647e" ] }, "type": "endpoint:user-artifact-manifest", From 3031ff7447a33229dc487c77d079fdbea226a81e Mon Sep 17 00:00:00 2001 From: Jen Huang Date: Mon, 13 Jul 2020 11:40:21 -0700 Subject: [PATCH 19/66] Allow enrollment flyout to load well on slow networks (#71487) --- .../config_selection.tsx | 18 +++++++++++++----- .../agent_enrollment_flyout/index.tsx | 4 ++-- .../managed_instructions.tsx | 6 +++--- .../standalone_instructions.tsx | 4 ++-- .../agent_enrollment_flyout/steps.tsx | 2 +- 5 files changed, 21 insertions(+), 13 deletions(-) diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_enrollment_flyout/config_selection.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_enrollment_flyout/config_selection.tsx index 6f53a237187e5..09b00240dc127 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_enrollment_flyout/config_selection.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_enrollment_flyout/config_selection.tsx @@ -13,7 +13,7 @@ import { sendGetEnrollmentAPIKeys, useCore } from '../../../../hooks'; import { AgentConfigPackageBadges } from '../agent_config_package_badges'; type Props = { - agentConfigs: AgentConfig[]; + agentConfigs?: AgentConfig[]; onConfigChange?: (key: string) => void; } & ( | { @@ -37,9 +37,16 @@ export const EnrollmentStepAgentConfig: React.FC = (props) => { const [selectedState, setSelectedState] = useState<{ agentConfigId?: string; enrollmentAPIKeyId?: string; - }>({ - agentConfigId: agentConfigs.length ? agentConfigs[0].id : undefined, - }); + }>({}); + + useEffect(() => { + if (agentConfigs && agentConfigs.length && !selectedState.agentConfigId) { + setSelectedState({ + ...selectedState, + agentConfigId: agentConfigs[0].id, + }); + } + }, [agentConfigs, selectedState]); useEffect(() => { if (onConfigChange && selectedState.agentConfigId) { @@ -110,7 +117,8 @@ export const EnrollmentStepAgentConfig: React.FC = (props) => { /> } - options={agentConfigs.map((config) => ({ + isLoading={!agentConfigs} + options={(agentConfigs || []).map((config) => ({ value: config.id, text: config.name, }))} diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_enrollment_flyout/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_enrollment_flyout/index.tsx index 5a9d3b7efe1bb..2c66001cc8c08 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_enrollment_flyout/index.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_enrollment_flyout/index.tsx @@ -24,12 +24,12 @@ import { StandaloneInstructions } from './standalone_instructions'; interface Props { onClose: () => void; - agentConfigs: AgentConfig[]; + agentConfigs?: AgentConfig[]; } export const AgentEnrollmentFlyout: React.FunctionComponent = ({ onClose, - agentConfigs = [], + agentConfigs, }) => { const [mode, setMode] = useState<'managed' | 'standalone'>('managed'); diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_enrollment_flyout/managed_instructions.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_enrollment_flyout/managed_instructions.tsx index aabbd37e809a8..eefb7f1bb7b5f 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_enrollment_flyout/managed_instructions.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_enrollment_flyout/managed_instructions.tsx @@ -21,10 +21,10 @@ import { ManualInstructions } from '../../../../components/enrollment_instructio import { DownloadStep, AgentConfigSelectionStep } from './steps'; interface Props { - agentConfigs: AgentConfig[]; + agentConfigs?: AgentConfig[]; } -export const ManagedInstructions: React.FunctionComponent = ({ agentConfigs = [] }) => { +export const ManagedInstructions: React.FunctionComponent = ({ agentConfigs }) => { const { getHref } = useLink(); const core = useCore(); const fleetStatus = useFleetStatus(); @@ -85,7 +85,7 @@ export const ManagedInstructions: React.FunctionComponent = ({ agentConfi }} /> - )}{' '} + )} ); }; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_enrollment_flyout/standalone_instructions.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_enrollment_flyout/standalone_instructions.tsx index 27f64059deb84..d5f79563f33c4 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_enrollment_flyout/standalone_instructions.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_enrollment_flyout/standalone_instructions.tsx @@ -25,12 +25,12 @@ import { DownloadStep, AgentConfigSelectionStep } from './steps'; import { configToYaml, agentConfigRouteService } from '../../../../services'; interface Props { - agentConfigs: AgentConfig[]; + agentConfigs?: AgentConfig[]; } const RUN_INSTRUCTIONS = './elastic-agent run'; -export const StandaloneInstructions: React.FunctionComponent = ({ agentConfigs = [] }) => { +export const StandaloneInstructions: React.FunctionComponent = ({ agentConfigs }) => { const core = useCore(); const { notifications } = core; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_enrollment_flyout/steps.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_enrollment_flyout/steps.tsx index 267f9027a094a..d01e207169920 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_enrollment_flyout/steps.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_enrollment_flyout/steps.tsx @@ -46,7 +46,7 @@ export const AgentConfigSelectionStep = ({ setSelectedAPIKeyId, setSelectedConfigId, }: { - agentConfigs: AgentConfig[]; + agentConfigs?: AgentConfig[]; setSelectedAPIKeyId?: (key: string) => void; setSelectedConfigId?: (configId: string) => void; }) => { From f95ab33cbe2690474a3d32542268359ec635cdef Mon Sep 17 00:00:00 2001 From: Nathan Reese Date: Mon, 13 Jul 2020 12:53:00 -0600 Subject: [PATCH 20/66] [Maps] use EuiColorPalettePicker (#69190) * [Maps] use EuiColorPalettePicker and Eui palettes * use new ramps to create mb style * update ColorMapSelect to use EuiColorPalettePicker * move color_utils test to color_palettes * clean up heatmap constants * tslint * fix test expects * fix merge mistake * update jest expects * remove .chromium folder * another jest expect update * remove charts from kibana.json * remove unneeded jest.mock Co-authored-by: Elastic Machine --- x-pack/plugins/maps/kibana.json | 1 - .../clusters_layer_wizard.tsx | 4 +- .../point_2_point_layer_wizard.tsx | 4 +- .../maps/public/classes/styles/_index.scss | 2 +- .../classes/styles/color_palettes.test.ts | 58 ++++++ .../public/classes/styles/color_palettes.ts | 172 +++++++++++++++++ .../public/classes/styles/color_utils.test.ts | 104 ----------- .../public/classes/styles/color_utils.tsx | 174 ------------------ .../styles/components/color_gradient.tsx | 30 --- .../heatmap_style_editor.test.tsx.snap | 132 +++++++++---- .../heatmap/components/heatmap_constants.ts | 11 -- .../components/heatmap_style_editor.tsx | 29 +-- .../components/legend}/_color_gradient.scss | 0 .../components/legend/color_gradient.tsx | 19 ++ .../components/legend/heatmap_legend.js | 18 +- .../classes/styles/heatmap/heatmap_style.js | 41 +---- .../components/color/color_map_select.js | 56 +++--- .../components/color/dynamic_color_form.js | 14 +- .../extract_color_from_style_property.test.ts | 4 +- .../extract_color_from_style_property.ts | 3 +- .../vector/components/vector_style_editor.js | 2 +- .../dynamic_color_property.test.js.snap | 16 +- .../properties/dynamic_color_property.js | 14 +- .../properties/dynamic_color_property.test.js | 16 +- .../styles/vector/vector_style_defaults.ts | 10 +- .../functional/apps/maps/mapbox_styles.js | 32 ++-- 26 files changed, 446 insertions(+), 520 deletions(-) create mode 100644 x-pack/plugins/maps/public/classes/styles/color_palettes.test.ts create mode 100644 x-pack/plugins/maps/public/classes/styles/color_palettes.ts delete mode 100644 x-pack/plugins/maps/public/classes/styles/color_utils.test.ts delete mode 100644 x-pack/plugins/maps/public/classes/styles/color_utils.tsx delete mode 100644 x-pack/plugins/maps/public/classes/styles/components/color_gradient.tsx rename x-pack/plugins/maps/public/classes/styles/{components => heatmap/components/legend}/_color_gradient.scss (100%) create mode 100644 x-pack/plugins/maps/public/classes/styles/heatmap/components/legend/color_gradient.tsx diff --git a/x-pack/plugins/maps/kibana.json b/x-pack/plugins/maps/kibana.json index e422efb31cb0d..fbf45aee02125 100644 --- a/x-pack/plugins/maps/kibana.json +++ b/x-pack/plugins/maps/kibana.json @@ -21,7 +21,6 @@ "server": true, "extraPublicDirs": ["common/constants"], "requiredBundles": [ - "charts", "kibanaReact", "kibanaUtils", "savedObjects" diff --git a/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/clusters_layer_wizard.tsx b/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/clusters_layer_wizard.tsx index 715c16b22dc51..ee97fdd0a2bf6 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/clusters_layer_wizard.tsx +++ b/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/clusters_layer_wizard.tsx @@ -28,7 +28,7 @@ import { VECTOR_STYLES, STYLE_TYPE, } from '../../../../common/constants'; -import { COLOR_GRADIENTS } from '../../styles/color_utils'; +import { NUMERICAL_COLOR_PALETTES } from '../../styles/color_palettes'; export const clustersLayerWizardConfig: LayerWizard = { categories: [LAYER_WIZARD_CATEGORY.ELASTICSEARCH], @@ -57,7 +57,7 @@ export const clustersLayerWizardConfig: LayerWizard = { name: COUNT_PROP_NAME, origin: FIELD_ORIGIN.SOURCE, }, - color: COLOR_GRADIENTS[0].value, + color: NUMERICAL_COLOR_PALETTES[0].value, type: COLOR_MAP_TYPE.ORDINAL, }, }, diff --git a/x-pack/plugins/maps/public/classes/sources/es_pew_pew_source/point_2_point_layer_wizard.tsx b/x-pack/plugins/maps/public/classes/sources/es_pew_pew_source/point_2_point_layer_wizard.tsx index ae7414b827c8d..fee84d0208978 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_pew_pew_source/point_2_point_layer_wizard.tsx +++ b/x-pack/plugins/maps/public/classes/sources/es_pew_pew_source/point_2_point_layer_wizard.tsx @@ -18,7 +18,7 @@ import { VECTOR_STYLES, STYLE_TYPE, } from '../../../../common/constants'; -import { COLOR_GRADIENTS } from '../../styles/color_utils'; +import { NUMERICAL_COLOR_PALETTES } from '../../styles/color_palettes'; // @ts-ignore import { CreateSourceEditor } from './create_source_editor'; import { LayerWizard, RenderWizardArguments } from '../../layers/layer_wizard_registry'; @@ -50,7 +50,7 @@ export const point2PointLayerWizardConfig: LayerWizard = { name: COUNT_PROP_NAME, origin: FIELD_ORIGIN.SOURCE, }, - color: COLOR_GRADIENTS[0].value, + color: NUMERICAL_COLOR_PALETTES[0].value, }, }, [VECTOR_STYLES.LINE_WIDTH]: { diff --git a/x-pack/plugins/maps/public/classes/styles/_index.scss b/x-pack/plugins/maps/public/classes/styles/_index.scss index 3ee713ffc1a02..bd1467bed9d4e 100644 --- a/x-pack/plugins/maps/public/classes/styles/_index.scss +++ b/x-pack/plugins/maps/public/classes/styles/_index.scss @@ -1,4 +1,4 @@ -@import 'components/color_gradient'; +@import 'heatmap/components/legend/color_gradient'; @import 'vector/components/style_prop_editor'; @import 'vector/components/color/color_stops'; @import 'vector/components/symbol/icon_select'; diff --git a/x-pack/plugins/maps/public/classes/styles/color_palettes.test.ts b/x-pack/plugins/maps/public/classes/styles/color_palettes.test.ts new file mode 100644 index 0000000000000..b964ecf6d6b63 --- /dev/null +++ b/x-pack/plugins/maps/public/classes/styles/color_palettes.test.ts @@ -0,0 +1,58 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { + getColorRampCenterColor, + getOrdinalMbColorRampStops, + getColorPalette, +} from './color_palettes'; + +describe('getColorPalette', () => { + it('Should create RGB color ramp', () => { + expect(getColorPalette('Blues')).toEqual([ + '#ecf1f7', + '#d9e3ef', + '#c5d5e7', + '#b2c7df', + '#9eb9d8', + '#8bacd0', + '#769fc8', + '#6092c0', + ]); + }); +}); + +describe('getColorRampCenterColor', () => { + it('Should get center color from color ramp', () => { + expect(getColorRampCenterColor('Blues')).toBe('#9eb9d8'); + }); +}); + +describe('getOrdinalMbColorRampStops', () => { + it('Should create color stops for custom range', () => { + expect(getOrdinalMbColorRampStops('Blues', 0, 1000)).toEqual([ + 0, + '#ecf1f7', + 125, + '#d9e3ef', + 250, + '#c5d5e7', + 375, + '#b2c7df', + 500, + '#9eb9d8', + 625, + '#8bacd0', + 750, + '#769fc8', + 875, + '#6092c0', + ]); + }); + + it('Should snap to end of color stops for identical range', () => { + expect(getOrdinalMbColorRampStops('Blues', 23, 23)).toEqual([23, '#6092c0']); + }); +}); diff --git a/x-pack/plugins/maps/public/classes/styles/color_palettes.ts b/x-pack/plugins/maps/public/classes/styles/color_palettes.ts new file mode 100644 index 0000000000000..e7574b4e7b3e4 --- /dev/null +++ b/x-pack/plugins/maps/public/classes/styles/color_palettes.ts @@ -0,0 +1,172 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import tinycolor from 'tinycolor2'; +import { + // @ts-ignore + euiPaletteForStatus, + // @ts-ignore + euiPaletteForTemperature, + // @ts-ignore + euiPaletteCool, + // @ts-ignore + euiPaletteWarm, + // @ts-ignore + euiPaletteNegative, + // @ts-ignore + euiPalettePositive, + // @ts-ignore + euiPaletteGray, + // @ts-ignore + euiPaletteColorBlind, +} from '@elastic/eui/lib/services'; +import { EuiColorPalettePickerPaletteProps } from '@elastic/eui'; + +export const DEFAULT_HEATMAP_COLOR_RAMP_NAME = 'theclassic'; + +export const DEFAULT_FILL_COLORS: string[] = euiPaletteColorBlind(); +export const DEFAULT_LINE_COLORS: string[] = [ + ...DEFAULT_FILL_COLORS.map((color: string) => tinycolor(color).darken().toHexString()), + // Explicitly add black & white as border color options + '#000', + '#FFF', +]; + +const COLOR_PALETTES: EuiColorPalettePickerPaletteProps[] = [ + { + value: 'Blues', + palette: euiPaletteCool(8), + type: 'gradient', + }, + { + value: 'Greens', + palette: euiPalettePositive(8), + type: 'gradient', + }, + { + value: 'Greys', + palette: euiPaletteGray(8), + type: 'gradient', + }, + { + value: 'Reds', + palette: euiPaletteNegative(8), + type: 'gradient', + }, + { + value: 'Yellow to Red', + palette: euiPaletteWarm(8), + type: 'gradient', + }, + { + value: 'Green to Red', + palette: euiPaletteForStatus(8), + type: 'gradient', + }, + { + value: 'Blue to Red', + palette: euiPaletteForTemperature(8), + type: 'gradient', + }, + { + value: DEFAULT_HEATMAP_COLOR_RAMP_NAME, + palette: [ + 'rgb(65, 105, 225)', // royalblue + 'rgb(0, 256, 256)', // cyan + 'rgb(0, 256, 0)', // lime + 'rgb(256, 256, 0)', // yellow + 'rgb(256, 0, 0)', // red + ], + type: 'gradient', + }, + { + value: 'palette_0', + palette: euiPaletteColorBlind(), + type: 'fixed', + }, + { + value: 'palette_20', + palette: euiPaletteColorBlind({ rotations: 2 }), + type: 'fixed', + }, + { + value: 'palette_30', + palette: euiPaletteColorBlind({ rotations: 3 }), + type: 'fixed', + }, +]; + +export const NUMERICAL_COLOR_PALETTES = COLOR_PALETTES.filter( + (palette: EuiColorPalettePickerPaletteProps) => { + return palette.type === 'gradient'; + } +); + +export const CATEGORICAL_COLOR_PALETTES = COLOR_PALETTES.filter( + (palette: EuiColorPalettePickerPaletteProps) => { + return palette.type === 'fixed'; + } +); + +export function getColorPalette(colorPaletteId: string): string[] { + const colorPalette = COLOR_PALETTES.find(({ value }: EuiColorPalettePickerPaletteProps) => { + return value === colorPaletteId; + }); + return colorPalette ? (colorPalette.palette as string[]) : []; +} + +export function getColorRampCenterColor(colorPaletteId: string): string | null { + if (!colorPaletteId) { + return null; + } + const palette = getColorPalette(colorPaletteId); + return palette.length === 0 ? null : palette[Math.floor(palette.length / 2)]; +} + +// Returns an array of color stops +// [ stop_input_1: number, stop_output_1: color, stop_input_n: number, stop_output_n: color ] +export function getOrdinalMbColorRampStops( + colorPaletteId: string, + min: number, + max: number +): Array | null { + if (!colorPaletteId) { + return null; + } + + if (min > max) { + return null; + } + + const palette = getColorPalette(colorPaletteId); + if (palette.length === 0) { + return null; + } + + if (max === min) { + // just return single stop value + return [max, palette[palette.length - 1]]; + } + + const delta = max - min; + return palette.reduce( + (accu: Array, stopColor: string, idx: number, srcArr: string[]) => { + const stopNumber = min + (delta * idx) / srcArr.length; + return [...accu, stopNumber, stopColor]; + }, + [] + ); +} + +export function getLinearGradient(colorStrings: string[]): string { + const intervals = colorStrings.length; + let linearGradient = `linear-gradient(to right, ${colorStrings[0]} 0%,`; + for (let i = 1; i < intervals - 1; i++) { + linearGradient = `${linearGradient} ${colorStrings[i]} \ + ${Math.floor((100 * i) / (intervals - 1))}%,`; + } + return `${linearGradient} ${colorStrings[colorStrings.length - 1]} 100%)`; +} diff --git a/x-pack/plugins/maps/public/classes/styles/color_utils.test.ts b/x-pack/plugins/maps/public/classes/styles/color_utils.test.ts deleted file mode 100644 index ed7cafd53a6fc..0000000000000 --- a/x-pack/plugins/maps/public/classes/styles/color_utils.test.ts +++ /dev/null @@ -1,104 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ -import { - COLOR_GRADIENTS, - getColorRampCenterColor, - getOrdinalMbColorRampStops, - getHexColorRangeStrings, - getLinearGradient, - getRGBColorRangeStrings, -} from './color_utils'; - -jest.mock('ui/new_platform'); - -describe('COLOR_GRADIENTS', () => { - it('Should contain EuiSuperSelect options list of color ramps', () => { - expect(COLOR_GRADIENTS.length).toBe(6); - const colorGradientOption = COLOR_GRADIENTS[0]; - expect(colorGradientOption.value).toBe('Blues'); - }); -}); - -describe('getRGBColorRangeStrings', () => { - it('Should create RGB color ramp', () => { - expect(getRGBColorRangeStrings('Blues', 8)).toEqual([ - 'rgb(247,250,255)', - 'rgb(221,234,247)', - 'rgb(197,218,238)', - 'rgb(157,201,224)', - 'rgb(106,173,213)', - 'rgb(65,145,197)', - 'rgb(32,112,180)', - 'rgb(7,47,107)', - ]); - }); -}); - -describe('getHexColorRangeStrings', () => { - it('Should create HEX color ramp', () => { - expect(getHexColorRangeStrings('Blues')).toEqual([ - '#f7faff', - '#ddeaf7', - '#c5daee', - '#9dc9e0', - '#6aadd5', - '#4191c5', - '#2070b4', - '#072f6b', - ]); - }); -}); - -describe('getColorRampCenterColor', () => { - it('Should get center color from color ramp', () => { - expect(getColorRampCenterColor('Blues')).toBe('rgb(106,173,213)'); - }); -}); - -describe('getColorRampStops', () => { - it('Should create color stops for custom range', () => { - expect(getOrdinalMbColorRampStops('Blues', 0, 1000, 8)).toEqual([ - 0, - '#f7faff', - 125, - '#ddeaf7', - 250, - '#c5daee', - 375, - '#9dc9e0', - 500, - '#6aadd5', - 625, - '#4191c5', - 750, - '#2070b4', - 875, - '#072f6b', - ]); - }); - - it('Should snap to end of color stops for identical range', () => { - expect(getOrdinalMbColorRampStops('Blues', 23, 23, 8)).toEqual([23, '#072f6b']); - }); -}); - -describe('getLinearGradient', () => { - it('Should create linear gradient from color ramp', () => { - const colorRamp = [ - 'rgb(247,250,255)', - 'rgb(221,234,247)', - 'rgb(197,218,238)', - 'rgb(157,201,224)', - 'rgb(106,173,213)', - 'rgb(65,145,197)', - 'rgb(32,112,180)', - 'rgb(7,47,107)', - ]; - expect(getLinearGradient(colorRamp)).toBe( - 'linear-gradient(to right, rgb(247,250,255) 0%, rgb(221,234,247) 14%, rgb(197,218,238) 28%, rgb(157,201,224) 42%, rgb(106,173,213) 57%, rgb(65,145,197) 71%, rgb(32,112,180) 85%, rgb(7,47,107) 100%)' - ); - }); -}); diff --git a/x-pack/plugins/maps/public/classes/styles/color_utils.tsx b/x-pack/plugins/maps/public/classes/styles/color_utils.tsx deleted file mode 100644 index 0192a9d7ca68f..0000000000000 --- a/x-pack/plugins/maps/public/classes/styles/color_utils.tsx +++ /dev/null @@ -1,174 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React from 'react'; -import tinycolor from 'tinycolor2'; -import chroma from 'chroma-js'; -// @ts-ignore -import { euiPaletteColorBlind } from '@elastic/eui/lib/services'; -import { ColorGradient } from './components/color_gradient'; -import { RawColorSchema, vislibColorMaps } from '../../../../../../src/plugins/charts/public'; - -export const GRADIENT_INTERVALS = 8; - -export const DEFAULT_FILL_COLORS: string[] = euiPaletteColorBlind(); -export const DEFAULT_LINE_COLORS: string[] = [ - ...DEFAULT_FILL_COLORS.map((color: string) => tinycolor(color).darken().toHexString()), - // Explicitly add black & white as border color options - '#000', - '#FFF', -]; - -function getRGBColors(colorRamp: Array<[number, number[]]>, numLegendColors: number = 4): string[] { - const colors = []; - colors[0] = getRGBColor(colorRamp, 0); - for (let i = 1; i < numLegendColors - 1; i++) { - colors[i] = getRGBColor(colorRamp, Math.floor((colorRamp.length * i) / numLegendColors)); - } - colors[numLegendColors - 1] = getRGBColor(colorRamp, colorRamp.length - 1); - return colors; -} - -function getRGBColor(colorRamp: Array<[number, number[]]>, i: number): string { - const rgbArray = colorRamp[i][1]; - const red = Math.floor(rgbArray[0] * 255); - const green = Math.floor(rgbArray[1] * 255); - const blue = Math.floor(rgbArray[2] * 255); - return `rgb(${red},${green},${blue})`; -} - -function getColorSchema(colorRampName: string): RawColorSchema { - const colorSchema = vislibColorMaps[colorRampName]; - if (!colorSchema) { - throw new Error( - `${colorRampName} not found. Expected one of following values: ${Object.keys( - vislibColorMaps - )}` - ); - } - return colorSchema; -} - -export function getRGBColorRangeStrings( - colorRampName: string, - numberColors: number = GRADIENT_INTERVALS -): string[] { - const colorSchema = getColorSchema(colorRampName); - return getRGBColors(colorSchema.value, numberColors); -} - -export function getHexColorRangeStrings( - colorRampName: string, - numberColors: number = GRADIENT_INTERVALS -): string[] { - return getRGBColorRangeStrings(colorRampName, numberColors).map((rgbColor) => - chroma(rgbColor).hex() - ); -} - -export function getColorRampCenterColor(colorRampName: string): string | null { - if (!colorRampName) { - return null; - } - const colorSchema = getColorSchema(colorRampName); - const centerIndex = Math.floor(colorSchema.value.length / 2); - return getRGBColor(colorSchema.value, centerIndex); -} - -// Returns an array of color stops -// [ stop_input_1: number, stop_output_1: color, stop_input_n: number, stop_output_n: color ] -export function getOrdinalMbColorRampStops( - colorRampName: string, - min: number, - max: number, - numberColors: number -): Array | null { - if (!colorRampName) { - return null; - } - - if (min > max) { - return null; - } - - const hexColors = getHexColorRangeStrings(colorRampName, numberColors); - if (max === min) { - // just return single stop value - return [max, hexColors[hexColors.length - 1]]; - } - - const delta = max - min; - return hexColors.reduce( - (accu: Array, stopColor: string, idx: number, srcArr: string[]) => { - const stopNumber = min + (delta * idx) / srcArr.length; - return [...accu, stopNumber, stopColor]; - }, - [] - ); -} - -export const COLOR_GRADIENTS = Object.keys(vislibColorMaps).map((colorRampName) => ({ - value: colorRampName, - inputDisplay: , -})); - -export const COLOR_RAMP_NAMES = Object.keys(vislibColorMaps); - -export function getLinearGradient(colorStrings: string[]): string { - const intervals = colorStrings.length; - let linearGradient = `linear-gradient(to right, ${colorStrings[0]} 0%,`; - for (let i = 1; i < intervals - 1; i++) { - linearGradient = `${linearGradient} ${colorStrings[i]} \ - ${Math.floor((100 * i) / (intervals - 1))}%,`; - } - return `${linearGradient} ${colorStrings[colorStrings.length - 1]} 100%)`; -} - -export interface ColorPalette { - id: string; - colors: string[]; -} - -const COLOR_PALETTES_CONFIGS: ColorPalette[] = [ - { - id: 'palette_0', - colors: euiPaletteColorBlind(), - }, - { - id: 'palette_20', - colors: euiPaletteColorBlind({ rotations: 2 }), - }, - { - id: 'palette_30', - colors: euiPaletteColorBlind({ rotations: 3 }), - }, -]; - -export function getColorPalette(paletteId: string): string[] | null { - const palette = COLOR_PALETTES_CONFIGS.find(({ id }: ColorPalette) => id === paletteId); - return palette ? palette.colors : null; -} - -export const COLOR_PALETTES = COLOR_PALETTES_CONFIGS.map((palette) => { - const paletteDisplay = palette.colors.map((color) => { - const style: React.CSSProperties = { - backgroundColor: color, - width: `${100 / palette.colors.length}%`, - position: 'relative', - height: '100%', - display: 'inline-block', - }; - return ( -
-   -
- ); - }); - return { - value: palette.id, - inputDisplay:
{paletteDisplay}
, - }; -}); diff --git a/x-pack/plugins/maps/public/classes/styles/components/color_gradient.tsx b/x-pack/plugins/maps/public/classes/styles/components/color_gradient.tsx deleted file mode 100644 index b29146062e46d..0000000000000 --- a/x-pack/plugins/maps/public/classes/styles/components/color_gradient.tsx +++ /dev/null @@ -1,30 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React from 'react'; -import { - COLOR_RAMP_NAMES, - GRADIENT_INTERVALS, - getRGBColorRangeStrings, - getLinearGradient, -} from '../color_utils'; - -interface Props { - colorRamp?: string[]; - colorRampName?: string; -} - -export const ColorGradient = ({ colorRamp, colorRampName }: Props) => { - if (!colorRamp && (!colorRampName || !COLOR_RAMP_NAMES.includes(colorRampName))) { - return null; - } - - const rgbColorStrings = colorRampName - ? getRGBColorRangeStrings(colorRampName, GRADIENT_INTERVALS) - : colorRamp!; - const background = getLinearGradient(rgbColorStrings); - return
; -}; diff --git a/x-pack/plugins/maps/public/classes/styles/heatmap/components/__snapshots__/heatmap_style_editor.test.tsx.snap b/x-pack/plugins/maps/public/classes/styles/heatmap/components/__snapshots__/heatmap_style_editor.test.tsx.snap index 9d07b9c641e0f..7c42b78fdc552 100644 --- a/x-pack/plugins/maps/public/classes/styles/heatmap/components/__snapshots__/heatmap_style_editor.test.tsx.snap +++ b/x-pack/plugins/maps/public/classes/styles/heatmap/components/__snapshots__/heatmap_style_editor.test.tsx.snap @@ -10,66 +10,120 @@ exports[`HeatmapStyleEditor is rendered 1`] = ` label="Color range" labelType="label" > - , - "text": "theclassic", - "value": "theclassic", - }, - Object { - "inputDisplay": , + "palette": Array [ + "#ecf1f7", + "#d9e3ef", + "#c5d5e7", + "#b2c7df", + "#9eb9d8", + "#8bacd0", + "#769fc8", + "#6092c0", + ], + "type": "gradient", "value": "Blues", }, Object { - "inputDisplay": , + "palette": Array [ + "#e6f1ee", + "#cce4de", + "#b3d6cd", + "#9ac8bd", + "#80bbae", + "#65ad9e", + "#47a08f", + "#209280", + ], + "type": "gradient", "value": "Greens", }, Object { - "inputDisplay": , + "palette": Array [ + "#e0e4eb", + "#c2c9d5", + "#a6afbf", + "#8c95a5", + "#757c8b", + "#5e6471", + "#494d58", + "#343741", + ], + "type": "gradient", "value": "Greys", }, Object { - "inputDisplay": , + "palette": Array [ + "#fdeae5", + "#f9d5cc", + "#f4c0b4", + "#eeab9c", + "#e79685", + "#df816e", + "#d66c58", + "#cc5642", + ], + "type": "gradient", "value": "Reds", }, Object { - "inputDisplay": , + "palette": Array [ + "#f9eac5", + "#f6d9af", + "#f3c89a", + "#efb785", + "#eba672", + "#e89361", + "#e58053", + "#e7664c", + ], + "type": "gradient", "value": "Yellow to Red", }, Object { - "inputDisplay": , + "palette": Array [ + "#209280", + "#3aa38d", + "#54b399", + "#95b978", + "#df9352", + "#e7664c", + "#da5e47", + "#cc5642", + ], + "type": "gradient", "value": "Green to Red", }, + Object { + "palette": Array [ + "#6092c0", + "#84a9cd", + "#a8bfda", + "#cad7e8", + "#f0d3b0", + "#ecb385", + "#ea8d69", + "#e7664c", + ], + "type": "gradient", + "value": "Blue to Red", + }, + Object { + "palette": Array [ + "rgb(65, 105, 225)", + "rgb(0, 256, 256)", + "rgb(0, 256, 0)", + "rgb(256, 256, 0)", + "rgb(256, 0, 0)", + ], + "type": "gradient", + "value": "theclassic", + }, ] } valueOfSelected="Blues" diff --git a/x-pack/plugins/maps/public/classes/styles/heatmap/components/heatmap_constants.ts b/x-pack/plugins/maps/public/classes/styles/heatmap/components/heatmap_constants.ts index 583c78e56581b..b043c2791b146 100644 --- a/x-pack/plugins/maps/public/classes/styles/heatmap/components/heatmap_constants.ts +++ b/x-pack/plugins/maps/public/classes/styles/heatmap/components/heatmap_constants.ts @@ -6,17 +6,6 @@ import { i18n } from '@kbn/i18n'; -// Color stops from default Mapbox heatmap-color -export const DEFAULT_RGB_HEATMAP_COLOR_RAMP = [ - 'rgb(65, 105, 225)', // royalblue - 'rgb(0, 256, 256)', // cyan - 'rgb(0, 256, 0)', // lime - 'rgb(256, 256, 0)', // yellow - 'rgb(256, 0, 0)', // red -]; - -export const DEFAULT_HEATMAP_COLOR_RAMP_NAME = 'theclassic'; - export const HEATMAP_COLOR_RAMP_LABEL = i18n.translate('xpack.maps.heatmap.colorRampLabel', { defaultMessage: 'Color range', }); diff --git a/x-pack/plugins/maps/public/classes/styles/heatmap/components/heatmap_style_editor.tsx b/x-pack/plugins/maps/public/classes/styles/heatmap/components/heatmap_style_editor.tsx index d15fdbd79de75..48713f1ddfd4b 100644 --- a/x-pack/plugins/maps/public/classes/styles/heatmap/components/heatmap_style_editor.tsx +++ b/x-pack/plugins/maps/public/classes/styles/heatmap/components/heatmap_style_editor.tsx @@ -6,14 +6,9 @@ import React from 'react'; -import { EuiFormRow, EuiSuperSelect } from '@elastic/eui'; -import { COLOR_GRADIENTS } from '../../color_utils'; -import { ColorGradient } from '../../components/color_gradient'; -import { - DEFAULT_RGB_HEATMAP_COLOR_RAMP, - DEFAULT_HEATMAP_COLOR_RAMP_NAME, - HEATMAP_COLOR_RAMP_LABEL, -} from './heatmap_constants'; +import { EuiFormRow, EuiColorPalettePicker } from '@elastic/eui'; +import { NUMERICAL_COLOR_PALETTES } from '../../color_palettes'; +import { HEATMAP_COLOR_RAMP_LABEL } from './heatmap_constants'; interface Props { colorRampName: string; @@ -21,28 +16,18 @@ interface Props { } export function HeatmapStyleEditor({ colorRampName, onHeatmapColorChange }: Props) { - const onColorRampChange = (selectedColorRampName: string) => { + const onColorRampChange = (selectedPaletteId: string) => { onHeatmapColorChange({ - colorRampName: selectedColorRampName, + colorRampName: selectedPaletteId, }); }; - const colorRampOptions = [ - { - value: DEFAULT_HEATMAP_COLOR_RAMP_NAME, - text: DEFAULT_HEATMAP_COLOR_RAMP_NAME, - inputDisplay: , - }, - ...COLOR_GRADIENTS, - ]; - return ( - diff --git a/x-pack/plugins/maps/public/classes/styles/components/_color_gradient.scss b/x-pack/plugins/maps/public/classes/styles/heatmap/components/legend/_color_gradient.scss similarity index 100% rename from x-pack/plugins/maps/public/classes/styles/components/_color_gradient.scss rename to x-pack/plugins/maps/public/classes/styles/heatmap/components/legend/_color_gradient.scss diff --git a/x-pack/plugins/maps/public/classes/styles/heatmap/components/legend/color_gradient.tsx b/x-pack/plugins/maps/public/classes/styles/heatmap/components/legend/color_gradient.tsx new file mode 100644 index 0000000000000..b4a241f625683 --- /dev/null +++ b/x-pack/plugins/maps/public/classes/styles/heatmap/components/legend/color_gradient.tsx @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { getColorPalette, getLinearGradient } from '../../../color_palettes'; + +interface Props { + colorPaletteId: string; +} + +export const ColorGradient = ({ colorPaletteId }: Props) => { + const palette = getColorPalette(colorPaletteId); + return palette.length ? ( +
+ ) : null; +}; diff --git a/x-pack/plugins/maps/public/classes/styles/heatmap/components/legend/heatmap_legend.js b/x-pack/plugins/maps/public/classes/styles/heatmap/components/legend/heatmap_legend.js index 1d8dfe9c7bdbf..5c3600a149afe 100644 --- a/x-pack/plugins/maps/public/classes/styles/heatmap/components/legend/heatmap_legend.js +++ b/x-pack/plugins/maps/public/classes/styles/heatmap/components/legend/heatmap_legend.js @@ -7,13 +7,9 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; -import { ColorGradient } from '../../../components/color_gradient'; +import { ColorGradient } from './color_gradient'; import { RangedStyleLegendRow } from '../../../components/ranged_style_legend_row'; -import { - DEFAULT_RGB_HEATMAP_COLOR_RAMP, - DEFAULT_HEATMAP_COLOR_RAMP_NAME, - HEATMAP_COLOR_RAMP_LABEL, -} from '../heatmap_constants'; +import { HEATMAP_COLOR_RAMP_LABEL } from '../heatmap_constants'; export class HeatmapLegend extends React.Component { constructor() { @@ -41,17 +37,9 @@ export class HeatmapLegend extends React.Component { } render() { - const colorRampName = this.props.colorRampName; - const header = - colorRampName === DEFAULT_HEATMAP_COLOR_RAMP_NAME ? ( - - ) : ( - - ); - return ( } minLabel={i18n.translate('xpack.maps.heatmapLegend.coldLabel', { defaultMessage: 'cold', })} diff --git a/x-pack/plugins/maps/public/classes/styles/heatmap/heatmap_style.js b/x-pack/plugins/maps/public/classes/styles/heatmap/heatmap_style.js index 5f920d0ba52d3..55bbbc9319dfb 100644 --- a/x-pack/plugins/maps/public/classes/styles/heatmap/heatmap_style.js +++ b/x-pack/plugins/maps/public/classes/styles/heatmap/heatmap_style.js @@ -8,15 +8,15 @@ import React from 'react'; import { AbstractStyle } from '../style'; import { HeatmapStyleEditor } from './components/heatmap_style_editor'; import { HeatmapLegend } from './components/legend/heatmap_legend'; -import { DEFAULT_HEATMAP_COLOR_RAMP_NAME } from './components/heatmap_constants'; +import { DEFAULT_HEATMAP_COLOR_RAMP_NAME, getOrdinalMbColorRampStops } from '../color_palettes'; import { LAYER_STYLE_TYPE, GRID_RESOLUTION } from '../../../../common/constants'; -import { getOrdinalMbColorRampStops, GRADIENT_INTERVALS } from '../color_utils'; + import { i18n } from '@kbn/i18n'; import { EuiIcon } from '@elastic/eui'; //The heatmap range chosen hear runs from 0 to 1. It is arbitrary. //Weighting is on the raw count/sum values. -const MIN_RANGE = 0; +const MIN_RANGE = 0.1; // 0 to 0.1 is displayed as transparent color stop const MAX_RANGE = 1; export class HeatmapStyle extends AbstractStyle { @@ -83,40 +83,19 @@ export class HeatmapStyle extends AbstractStyle { property: propertyName, }); - const { colorRampName } = this._descriptor; - if (colorRampName && colorRampName !== DEFAULT_HEATMAP_COLOR_RAMP_NAME) { - const colorStops = getOrdinalMbColorRampStops( - colorRampName, - MIN_RANGE, - MAX_RANGE, - GRADIENT_INTERVALS - ); - // TODO handle null - mbMap.setPaintProperty(layerId, 'heatmap-color', [ - 'interpolate', - ['linear'], - ['heatmap-density'], - 0, - 'rgba(0, 0, 255, 0)', - ...colorStops.slice(2), // remove first stop from colorStops to avoid conflict with transparent stop at zero - ]); - } else { + const colorStops = getOrdinalMbColorRampStops( + this._descriptor.colorRampName, + MIN_RANGE, + MAX_RANGE + ); + if (colorStops) { mbMap.setPaintProperty(layerId, 'heatmap-color', [ 'interpolate', ['linear'], ['heatmap-density'], 0, 'rgba(0, 0, 255, 0)', - 0.1, - 'royalblue', - 0.3, - 'cyan', - 0.5, - 'lime', - 0.7, - 'yellow', - 1, - 'red', + ...colorStops, ]); } } diff --git a/x-pack/plugins/maps/public/classes/styles/vector/components/color/color_map_select.js b/x-pack/plugins/maps/public/classes/styles/vector/components/color/color_map_select.js index fe2f302504a15..a7d849265d815 100644 --- a/x-pack/plugins/maps/public/classes/styles/vector/components/color/color_map_select.js +++ b/x-pack/plugins/maps/public/classes/styles/vector/components/color/color_map_select.js @@ -6,10 +6,17 @@ import React, { Component, Fragment } from 'react'; -import { EuiSpacer, EuiSelect, EuiSuperSelect, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { + EuiSpacer, + EuiSelect, + EuiColorPalettePicker, + EuiFlexGroup, + EuiFlexItem, +} from '@elastic/eui'; import { ColorStopsOrdinal } from './color_stops_ordinal'; import { COLOR_MAP_TYPE } from '../../../../../../common/constants'; import { ColorStopsCategorical } from './color_stops_categorical'; +import { CATEGORICAL_COLOR_PALETTES, NUMERICAL_COLOR_PALETTES } from '../../../color_palettes'; import { i18n } from '@kbn/i18n'; const CUSTOM_COLOR_MAP = 'CUSTOM_COLOR_MAP'; @@ -65,10 +72,10 @@ export class ColorMapSelect extends Component { ); } - _onColorMapSelect = (selectedValue) => { - const useCustomColorMap = selectedValue === CUSTOM_COLOR_MAP; + _onColorPaletteSelect = (selectedPaletteId) => { + const useCustomColorMap = selectedPaletteId === CUSTOM_COLOR_MAP; this.props.onChange({ - color: useCustomColorMap ? null : selectedValue, + color: useCustomColorMap ? null : selectedPaletteId, useCustomColorMap, type: this.props.colorMapType, }); @@ -126,26 +133,28 @@ export class ColorMapSelect extends Component { return null; } - const colorMapOptionsWithCustom = [ + const palettes = + this.props.colorMapType === COLOR_MAP_TYPE.ORDINAL + ? NUMERICAL_COLOR_PALETTES + : CATEGORICAL_COLOR_PALETTES; + + const palettesWithCustom = [ { value: CUSTOM_COLOR_MAP, - inputDisplay: this.props.customOptionLabel, + title: + this.props.colorMapType === COLOR_MAP_TYPE.ORDINAL + ? i18n.translate('xpack.maps.style.customColorRampLabel', { + defaultMessage: 'Custom color ramp', + }) + : i18n.translate('xpack.maps.style.customColorPaletteLabel', { + defaultMessage: 'Custom color palette', + }), + type: 'text', 'data-test-subj': `colorMapSelectOption_${CUSTOM_COLOR_MAP}`, }, - ...this.props.colorMapOptions, + ...palettes, ]; - let valueOfSelected; - if (this.props.useCustomColorMap) { - valueOfSelected = CUSTOM_COLOR_MAP; - } else { - valueOfSelected = this.props.colorMapOptions.find( - (option) => option.value === this.props.color - ) - ? this.props.color - : ''; - } - const toggle = this.props.showColorMapTypeToggle ? ( {this._renderColorMapToggle()} ) : null; @@ -155,12 +164,13 @@ export class ColorMapSelect extends Component { {toggle} - diff --git a/x-pack/plugins/maps/public/classes/styles/vector/components/color/dynamic_color_form.js b/x-pack/plugins/maps/public/classes/styles/vector/components/color/dynamic_color_form.js index 90070343a1b48..1034e8f5d6525 100644 --- a/x-pack/plugins/maps/public/classes/styles/vector/components/color/dynamic_color_form.js +++ b/x-pack/plugins/maps/public/classes/styles/vector/components/color/dynamic_color_form.js @@ -10,8 +10,6 @@ import { FieldSelect } from '../field_select'; import { ColorMapSelect } from './color_map_select'; import { EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; import { CATEGORICAL_DATA_TYPES, COLOR_MAP_TYPE } from '../../../../../../common/constants'; -import { COLOR_GRADIENTS, COLOR_PALETTES } from '../../../color_utils'; -import { i18n } from '@kbn/i18n'; export function DynamicColorForm({ fields, @@ -91,14 +89,10 @@ export function DynamicColorForm({ return ( { fieldMetaOptions, } as ColorDynamicOptions, } as ColorDynamicStylePropertyDescriptor; - expect(extractColorFromStyleProperty(colorStyleProperty, defaultColor)).toBe( - 'rgb(106,173,213)' - ); + expect(extractColorFromStyleProperty(colorStyleProperty, defaultColor)).toBe('#9eb9d8'); }); }); diff --git a/x-pack/plugins/maps/public/classes/styles/vector/components/legend/extract_color_from_style_property.ts b/x-pack/plugins/maps/public/classes/styles/vector/components/legend/extract_color_from_style_property.ts index dadb3f201fa33..4a3f45a929fd1 100644 --- a/x-pack/plugins/maps/public/classes/styles/vector/components/legend/extract_color_from_style_property.ts +++ b/x-pack/plugins/maps/public/classes/styles/vector/components/legend/extract_color_from_style_property.ts @@ -4,8 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -// @ts-ignore -import { getColorRampCenterColor, getColorPalette } from '../../../color_utils'; +import { getColorRampCenterColor, getColorPalette } from '../../../color_palettes'; import { COLOR_MAP_TYPE, STYLE_TYPE } from '../../../../../../common/constants'; import { ColorDynamicOptions, diff --git a/x-pack/plugins/maps/public/classes/styles/vector/components/vector_style_editor.js b/x-pack/plugins/maps/public/classes/styles/vector/components/vector_style_editor.js index 6528648eff552..53a3fc95adbeb 100644 --- a/x-pack/plugins/maps/public/classes/styles/vector/components/vector_style_editor.js +++ b/x-pack/plugins/maps/public/classes/styles/vector/components/vector_style_editor.js @@ -15,7 +15,7 @@ import { VectorStyleLabelEditor } from './label/vector_style_label_editor'; import { VectorStyleLabelBorderSizeEditor } from './label/vector_style_label_border_size_editor'; import { OrientationEditor } from './orientation/orientation_editor'; import { getDefaultDynamicProperties, getDefaultStaticProperties } from '../vector_style_defaults'; -import { DEFAULT_FILL_COLORS, DEFAULT_LINE_COLORS } from '../../color_utils'; +import { DEFAULT_FILL_COLORS, DEFAULT_LINE_COLORS } from '../../color_palettes'; import { i18n } from '@kbn/i18n'; import { EuiSpacer, EuiButtonGroup, EuiFormRow, EuiSwitch } from '@elastic/eui'; diff --git a/x-pack/plugins/maps/public/classes/styles/vector/properties/__snapshots__/dynamic_color_property.test.js.snap b/x-pack/plugins/maps/public/classes/styles/vector/properties/__snapshots__/dynamic_color_property.test.js.snap index 29eb52897a50e..402eab355406b 100644 --- a/x-pack/plugins/maps/public/classes/styles/vector/properties/__snapshots__/dynamic_color_property.test.js.snap +++ b/x-pack/plugins/maps/public/classes/styles/vector/properties/__snapshots__/dynamic_color_property.test.js.snap @@ -175,7 +175,7 @@ exports[`ordinal Should render only single band of last color when delta is 0 1` key="0" > { - const rawStopValue = rangeFieldMeta.min + rangeFieldMeta.delta * (index / GRADIENT_INTERVALS); + const rawStopValue = rangeFieldMeta.min + rangeFieldMeta.delta * (index / colors.length); return { color, stop: dynamicRound(rawStopValue), diff --git a/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_color_property.test.js b/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_color_property.test.js index 1879b260da2e2..7992ee5b3aeaf 100644 --- a/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_color_property.test.js +++ b/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_color_property.test.js @@ -323,21 +323,21 @@ describe('get mapbox color expression (via internal _getMbColor)', () => { -1, 'rgba(0,0,0,0)', 0, - '#f7faff', + '#ecf1f7', 12.5, - '#ddeaf7', + '#d9e3ef', 25, - '#c5daee', + '#c5d5e7', 37.5, - '#9dc9e0', + '#b2c7df', 50, - '#6aadd5', + '#9eb9d8', 62.5, - '#4191c5', + '#8bacd0', 75, - '#2070b4', + '#769fc8', 87.5, - '#072f6b', + '#6092c0', ]); }); }); diff --git a/x-pack/plugins/maps/public/classes/styles/vector/vector_style_defaults.ts b/x-pack/plugins/maps/public/classes/styles/vector/vector_style_defaults.ts index a6878a0d760c7..a3ae80e0a5935 100644 --- a/x-pack/plugins/maps/public/classes/styles/vector/vector_style_defaults.ts +++ b/x-pack/plugins/maps/public/classes/styles/vector/vector_style_defaults.ts @@ -12,11 +12,11 @@ import { STYLE_TYPE, } from '../../../../common/constants'; import { - COLOR_GRADIENTS, - COLOR_PALETTES, DEFAULT_FILL_COLORS, DEFAULT_LINE_COLORS, -} from '../color_utils'; + NUMERICAL_COLOR_PALETTES, + CATEGORICAL_COLOR_PALETTES, +} from '../color_palettes'; import { VectorStylePropertiesDescriptor } from '../../../../common/descriptor_types'; // @ts-ignore import { getUiSettings } from '../../../kibana_services'; @@ -28,8 +28,8 @@ export const DEFAULT_MAX_SIZE = 32; export const DEFAULT_SIGMA = 3; export const DEFAULT_LABEL_SIZE = 14; export const DEFAULT_ICON_SIZE = 6; -export const DEFAULT_COLOR_RAMP = COLOR_GRADIENTS[0].value; -export const DEFAULT_COLOR_PALETTE = COLOR_PALETTES[0].value; +export const DEFAULT_COLOR_RAMP = NUMERICAL_COLOR_PALETTES[0].value; +export const DEFAULT_COLOR_PALETTE = CATEGORICAL_COLOR_PALETTES[0].value; export const LINE_STYLES = [VECTOR_STYLES.LINE_COLOR, VECTOR_STYLES.LINE_WIDTH]; export const POLYGON_STYLES = [ diff --git a/x-pack/test/functional/apps/maps/mapbox_styles.js b/x-pack/test/functional/apps/maps/mapbox_styles.js index 63bfc331d8886..744eb4ac74bf6 100644 --- a/x-pack/test/functional/apps/maps/mapbox_styles.js +++ b/x-pack/test/functional/apps/maps/mapbox_styles.js @@ -52,21 +52,21 @@ export const MAPBOX_STYLES = { 2, 'rgba(0,0,0,0)', 3, - '#f7faff', + '#ecf1f7', 4.125, - '#ddeaf7', + '#d9e3ef', 5.25, - '#c5daee', + '#c5d5e7', 6.375, - '#9dc9e0', + '#b2c7df', 7.5, - '#6aadd5', + '#9eb9d8', 8.625, - '#4191c5', + '#8bacd0', 9.75, - '#2070b4', + '#769fc8', 10.875, - '#072f6b', + '#6092c0', ], 'circle-opacity': 0.75, 'circle-stroke-color': '#41937c', @@ -122,21 +122,21 @@ export const MAPBOX_STYLES = { 2, 'rgba(0,0,0,0)', 3, - '#f7faff', + '#ecf1f7', 4.125, - '#ddeaf7', + '#d9e3ef', 5.25, - '#c5daee', + '#c5d5e7', 6.375, - '#9dc9e0', + '#b2c7df', 7.5, - '#6aadd5', + '#9eb9d8', 8.625, - '#4191c5', + '#8bacd0', 9.75, - '#2070b4', + '#769fc8', 10.875, - '#072f6b', + '#6092c0', ], 'fill-opacity': 0.75, }, From e51b92de325409818f69c1cefd91354f4be7e5dc Mon Sep 17 00:00:00 2001 From: Jen Huang Date: Mon, 13 Jul 2020 12:17:16 -0700 Subject: [PATCH 21/66] Fix fleet back link copy (#71488) --- .../ingest_manager/sections/fleet/agent_details_page/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_details_page/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_details_page/index.tsx index 15086879ce80b..ae9b1e1f6f433 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_details_page/index.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_details_page/index.tsx @@ -86,7 +86,7 @@ export const AgentDetailsPage: React.FunctionComponent = () => { > From 0ea414c13a458d521b5ac9f3b181e12396837009 Mon Sep 17 00:00:00 2001 From: Mikhail Shustov Date: Mon, 13 Jul 2020 22:26:34 +0300 Subject: [PATCH 22/66] [KP] Separate onPreAuth & onPreRouting http interceptors (#70775) Co-authored-by: Aleh Zasypkin Co-authored-by: Josh Dover --- ...ana-plugin-core-server.httpservicesetup.md | 5 +- ...ver.httpservicesetup.registeronpostauth.md | 4 +- ...rver.httpservicesetup.registeronpreauth.md | 4 +- ...r.httpservicesetup.registeronprerouting.md | 18 + .../core/server/kibana-plugin-core-server.md | 6 +- ...ana-plugin-core-server.onpreauthtoolkit.md | 1 - ...core-server.onpreauthtoolkit.rewriteurl.md | 13 - ...plugin-core-server.onpreresponsehandler.md | 2 +- ...plugin-core-server.onpreresponsetoolkit.md | 2 +- ...-plugin-core-server.onpreroutinghandler.md | 13 + ...-plugin-core-server.onpreroutingtoolkit.md | 21 ++ ...in-core-server.onpreroutingtoolkit.next.md | 13 + ...e-server.onpreroutingtoolkit.rewriteurl.md | 13 + src/core/server/http/http_server.mocks.ts | 4 +- src/core/server/http/http_server.test.ts | 10 + src/core/server/http/http_server.ts | 34 +- src/core/server/http/http_service.mock.ts | 8 +- src/core/server/http/index.ts | 3 +- .../integration_tests/core_services.test.ts | 2 +- .../http/integration_tests/lifecycle.test.ts | 318 +++++++++++++++++- src/core/server/http/lifecycle/on_pre_auth.ts | 28 +- .../server/http/lifecycle/on_pre_response.ts | 4 +- .../server/http/lifecycle/on_pre_routing.ts | 125 +++++++ src/core/server/http/types.ts | 28 +- src/core/server/index.ts | 2 + src/core/server/legacy/legacy_service.ts | 1 + src/core/server/plugins/plugin_context.ts | 1 + src/core/server/server.api.md | 13 +- .../on_request_interceptor.ts | 6 +- 29 files changed, 605 insertions(+), 97 deletions(-) create mode 100644 docs/development/core/server/kibana-plugin-core-server.httpservicesetup.registeronprerouting.md delete mode 100644 docs/development/core/server/kibana-plugin-core-server.onpreauthtoolkit.rewriteurl.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.onpreroutinghandler.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.onpreroutingtoolkit.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.onpreroutingtoolkit.next.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.onpreroutingtoolkit.rewriteurl.md create mode 100644 src/core/server/http/lifecycle/on_pre_routing.ts diff --git a/docs/development/core/server/kibana-plugin-core-server.httpservicesetup.md b/docs/development/core/server/kibana-plugin-core-server.httpservicesetup.md index b12983836d9e5..474dc6b7d6f28 100644 --- a/docs/development/core/server/kibana-plugin-core-server.httpservicesetup.md +++ b/docs/development/core/server/kibana-plugin-core-server.httpservicesetup.md @@ -88,8 +88,9 @@ async (context, request, response) => { | [csp](./kibana-plugin-core-server.httpservicesetup.csp.md) | ICspConfig | The CSP config used for Kibana. | | [getServerInfo](./kibana-plugin-core-server.httpservicesetup.getserverinfo.md) | () => HttpServerInfo | Provides common [information](./kibana-plugin-core-server.httpserverinfo.md) about the running http server. | | [registerAuth](./kibana-plugin-core-server.httpservicesetup.registerauth.md) | (handler: AuthenticationHandler) => void | To define custom authentication and/or authorization mechanism for incoming requests. | -| [registerOnPostAuth](./kibana-plugin-core-server.httpservicesetup.registeronpostauth.md) | (handler: OnPostAuthHandler) => void | To define custom logic to perform for incoming requests. | -| [registerOnPreAuth](./kibana-plugin-core-server.httpservicesetup.registeronpreauth.md) | (handler: OnPreAuthHandler) => void | To define custom logic to perform for incoming requests. | +| [registerOnPostAuth](./kibana-plugin-core-server.httpservicesetup.registeronpostauth.md) | (handler: OnPostAuthHandler) => void | To define custom logic after Auth interceptor did make sure a user has access to the requested resource. | +| [registerOnPreAuth](./kibana-plugin-core-server.httpservicesetup.registeronpreauth.md) | (handler: OnPreAuthHandler) => void | To define custom logic to perform for incoming requests before the Auth interceptor performs a check that user has access to requested resources. | | [registerOnPreResponse](./kibana-plugin-core-server.httpservicesetup.registeronpreresponse.md) | (handler: OnPreResponseHandler) => void | To define custom logic to perform for the server response. | +| [registerOnPreRouting](./kibana-plugin-core-server.httpservicesetup.registeronprerouting.md) | (handler: OnPreRoutingHandler) => void | To define custom logic to perform for incoming requests before server performs a route lookup. | | [registerRouteHandlerContext](./kibana-plugin-core-server.httpservicesetup.registerroutehandlercontext.md) | <T extends keyof RequestHandlerContext>(contextName: T, provider: RequestHandlerContextProvider<T>) => RequestHandlerContextContainer | Register a context provider for a route handler. | diff --git a/docs/development/core/server/kibana-plugin-core-server.httpservicesetup.registeronpostauth.md b/docs/development/core/server/kibana-plugin-core-server.httpservicesetup.registeronpostauth.md index 01294693e282f..eff53b7b75fa5 100644 --- a/docs/development/core/server/kibana-plugin-core-server.httpservicesetup.registeronpostauth.md +++ b/docs/development/core/server/kibana-plugin-core-server.httpservicesetup.registeronpostauth.md @@ -4,7 +4,7 @@ ## HttpServiceSetup.registerOnPostAuth property -To define custom logic to perform for incoming requests. +To define custom logic after Auth interceptor did make sure a user has access to the requested resource. Signature: @@ -14,5 +14,5 @@ registerOnPostAuth: (handler: OnPostAuthHandler) => void; ## Remarks -Runs the handler after Auth interceptor did make sure a user has access to the requested resource. The auth state is available at stage via http.auth.get(..) Can register any number of registerOnPreAuth, which are called in sequence (from the first registered to the last). See [OnPostAuthHandler](./kibana-plugin-core-server.onpostauthhandler.md). +The auth state is available at stage via http.auth.get(..) Can register any number of registerOnPreRouting, which are called in sequence (from the first registered to the last). See [OnPostAuthHandler](./kibana-plugin-core-server.onpostauthhandler.md). diff --git a/docs/development/core/server/kibana-plugin-core-server.httpservicesetup.registeronpreauth.md b/docs/development/core/server/kibana-plugin-core-server.httpservicesetup.registeronpreauth.md index f11453c8cda98..ce4cacb1c8749 100644 --- a/docs/development/core/server/kibana-plugin-core-server.httpservicesetup.registeronpreauth.md +++ b/docs/development/core/server/kibana-plugin-core-server.httpservicesetup.registeronpreauth.md @@ -4,7 +4,7 @@ ## HttpServiceSetup.registerOnPreAuth property -To define custom logic to perform for incoming requests. +To define custom logic to perform for incoming requests before the Auth interceptor performs a check that user has access to requested resources. Signature: @@ -14,5 +14,5 @@ registerOnPreAuth: (handler: OnPreAuthHandler) => void; ## Remarks -Runs the handler before Auth interceptor performs a check that user has access to requested resources, so it's the only place when you can forward a request to another URL right on the server. Can register any number of registerOnPostAuth, which are called in sequence (from the first registered to the last). See [OnPreAuthHandler](./kibana-plugin-core-server.onpreauthhandler.md). +Can register any number of registerOnPostAuth, which are called in sequence (from the first registered to the last). See [OnPreRoutingHandler](./kibana-plugin-core-server.onpreroutinghandler.md). diff --git a/docs/development/core/server/kibana-plugin-core-server.httpservicesetup.registeronprerouting.md b/docs/development/core/server/kibana-plugin-core-server.httpservicesetup.registeronprerouting.md new file mode 100644 index 0000000000000..bdf5f15828669 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.httpservicesetup.registeronprerouting.md @@ -0,0 +1,18 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [HttpServiceSetup](./kibana-plugin-core-server.httpservicesetup.md) > [registerOnPreRouting](./kibana-plugin-core-server.httpservicesetup.registeronprerouting.md) + +## HttpServiceSetup.registerOnPreRouting property + +To define custom logic to perform for incoming requests before server performs a route lookup. + +Signature: + +```typescript +registerOnPreRouting: (handler: OnPreRoutingHandler) => void; +``` + +## Remarks + +It's the only place when you can forward a request to another URL right on the server. Can register any number of registerOnPreRouting, which are called in sequence (from the first registered to the last). See [OnPreRoutingHandler](./kibana-plugin-core-server.onpreroutinghandler.md). + diff --git a/docs/development/core/server/kibana-plugin-core-server.md b/docs/development/core/server/kibana-plugin-core-server.md index 8d4c0c915437e..a665327454c1a 100644 --- a/docs/development/core/server/kibana-plugin-core-server.md +++ b/docs/development/core/server/kibana-plugin-core-server.md @@ -122,7 +122,8 @@ The plugin integrates with the core system via lifecycle events: `setup` | [OnPreAuthToolkit](./kibana-plugin-core-server.onpreauthtoolkit.md) | A tool set defining an outcome of OnPreAuth interceptor for incoming request. | | [OnPreResponseExtensions](./kibana-plugin-core-server.onpreresponseextensions.md) | Additional data to extend a response. | | [OnPreResponseInfo](./kibana-plugin-core-server.onpreresponseinfo.md) | Response status code. | -| [OnPreResponseToolkit](./kibana-plugin-core-server.onpreresponsetoolkit.md) | A tool set defining an outcome of OnPreAuth interceptor for incoming request. | +| [OnPreResponseToolkit](./kibana-plugin-core-server.onpreresponsetoolkit.md) | A tool set defining an outcome of OnPreRouting interceptor for incoming request. | +| [OnPreRoutingToolkit](./kibana-plugin-core-server.onpreroutingtoolkit.md) | A tool set defining an outcome of OnPreRouting interceptor for incoming request. | | [OpsMetrics](./kibana-plugin-core-server.opsmetrics.md) | Regroups metrics gathered by all the collectors. This contains metrics about the os/runtime, the kibana process and the http server. | | [OpsOsMetrics](./kibana-plugin-core-server.opsosmetrics.md) | OS related metrics | | [OpsProcessMetrics](./kibana-plugin-core-server.opsprocessmetrics.md) | Process related metrics | @@ -256,7 +257,8 @@ The plugin integrates with the core system via lifecycle events: `setup` | [MutatingOperationRefreshSetting](./kibana-plugin-core-server.mutatingoperationrefreshsetting.md) | Elasticsearch Refresh setting for mutating operation | | [OnPostAuthHandler](./kibana-plugin-core-server.onpostauthhandler.md) | See [OnPostAuthToolkit](./kibana-plugin-core-server.onpostauthtoolkit.md). | | [OnPreAuthHandler](./kibana-plugin-core-server.onpreauthhandler.md) | See [OnPreAuthToolkit](./kibana-plugin-core-server.onpreauthtoolkit.md). | -| [OnPreResponseHandler](./kibana-plugin-core-server.onpreresponsehandler.md) | See [OnPreAuthToolkit](./kibana-plugin-core-server.onpreauthtoolkit.md). | +| [OnPreResponseHandler](./kibana-plugin-core-server.onpreresponsehandler.md) | See [OnPreRoutingToolkit](./kibana-plugin-core-server.onpreroutingtoolkit.md). | +| [OnPreRoutingHandler](./kibana-plugin-core-server.onpreroutinghandler.md) | See [OnPreRoutingToolkit](./kibana-plugin-core-server.onpreroutingtoolkit.md). | | [PluginConfigSchema](./kibana-plugin-core-server.pluginconfigschema.md) | Dedicated type for plugin configuration schema. | | [PluginInitializer](./kibana-plugin-core-server.plugininitializer.md) | The plugin export at the root of a plugin's server directory should conform to this interface. | | [PluginName](./kibana-plugin-core-server.pluginname.md) | Dedicated type for plugin name/id that is supposed to make Map/Set/Arrays that use it as a key or value more obvious. | diff --git a/docs/development/core/server/kibana-plugin-core-server.onpreauthtoolkit.md b/docs/development/core/server/kibana-plugin-core-server.onpreauthtoolkit.md index 4097cb32c397a..8031dbc64fa6d 100644 --- a/docs/development/core/server/kibana-plugin-core-server.onpreauthtoolkit.md +++ b/docs/development/core/server/kibana-plugin-core-server.onpreauthtoolkit.md @@ -17,5 +17,4 @@ export interface OnPreAuthToolkit | Property | Type | Description | | --- | --- | --- | | [next](./kibana-plugin-core-server.onpreauthtoolkit.next.md) | () => OnPreAuthResult | To pass request to the next handler | -| [rewriteUrl](./kibana-plugin-core-server.onpreauthtoolkit.rewriteurl.md) | (url: string) => OnPreAuthResult | Rewrite requested resources url before is was authenticated and routed to a handler | diff --git a/docs/development/core/server/kibana-plugin-core-server.onpreauthtoolkit.rewriteurl.md b/docs/development/core/server/kibana-plugin-core-server.onpreauthtoolkit.rewriteurl.md deleted file mode 100644 index 7ecde62f88302..0000000000000 --- a/docs/development/core/server/kibana-plugin-core-server.onpreauthtoolkit.rewriteurl.md +++ /dev/null @@ -1,13 +0,0 @@ - - -[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [OnPreAuthToolkit](./kibana-plugin-core-server.onpreauthtoolkit.md) > [rewriteUrl](./kibana-plugin-core-server.onpreauthtoolkit.rewriteurl.md) - -## OnPreAuthToolkit.rewriteUrl property - -Rewrite requested resources url before is was authenticated and routed to a handler - -Signature: - -```typescript -rewriteUrl: (url: string) => OnPreAuthResult; -``` diff --git a/docs/development/core/server/kibana-plugin-core-server.onpreresponsehandler.md b/docs/development/core/server/kibana-plugin-core-server.onpreresponsehandler.md index e7eab8ee34d6f..10696fb79a2f6 100644 --- a/docs/development/core/server/kibana-plugin-core-server.onpreresponsehandler.md +++ b/docs/development/core/server/kibana-plugin-core-server.onpreresponsehandler.md @@ -4,7 +4,7 @@ ## OnPreResponseHandler type -See [OnPreAuthToolkit](./kibana-plugin-core-server.onpreauthtoolkit.md). +See [OnPreRoutingToolkit](./kibana-plugin-core-server.onpreroutingtoolkit.md). Signature: diff --git a/docs/development/core/server/kibana-plugin-core-server.onpreresponsetoolkit.md b/docs/development/core/server/kibana-plugin-core-server.onpreresponsetoolkit.md index 8e33e945b4ef9..306c375ba4a3c 100644 --- a/docs/development/core/server/kibana-plugin-core-server.onpreresponsetoolkit.md +++ b/docs/development/core/server/kibana-plugin-core-server.onpreresponsetoolkit.md @@ -4,7 +4,7 @@ ## OnPreResponseToolkit interface -A tool set defining an outcome of OnPreAuth interceptor for incoming request. +A tool set defining an outcome of OnPreRouting interceptor for incoming request. Signature: diff --git a/docs/development/core/server/kibana-plugin-core-server.onpreroutinghandler.md b/docs/development/core/server/kibana-plugin-core-server.onpreroutinghandler.md new file mode 100644 index 0000000000000..46016bcd5476a --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.onpreroutinghandler.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [OnPreRoutingHandler](./kibana-plugin-core-server.onpreroutinghandler.md) + +## OnPreRoutingHandler type + +See [OnPreRoutingToolkit](./kibana-plugin-core-server.onpreroutingtoolkit.md). + +Signature: + +```typescript +export declare type OnPreRoutingHandler = (request: KibanaRequest, response: LifecycleResponseFactory, toolkit: OnPreRoutingToolkit) => OnPreRoutingResult | KibanaResponse | Promise; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.onpreroutingtoolkit.md b/docs/development/core/server/kibana-plugin-core-server.onpreroutingtoolkit.md new file mode 100644 index 0000000000000..c564896b46a27 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.onpreroutingtoolkit.md @@ -0,0 +1,21 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [OnPreRoutingToolkit](./kibana-plugin-core-server.onpreroutingtoolkit.md) + +## OnPreRoutingToolkit interface + +A tool set defining an outcome of OnPreRouting interceptor for incoming request. + +Signature: + +```typescript +export interface OnPreRoutingToolkit +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [next](./kibana-plugin-core-server.onpreroutingtoolkit.next.md) | () => OnPreRoutingResult | To pass request to the next handler | +| [rewriteUrl](./kibana-plugin-core-server.onpreroutingtoolkit.rewriteurl.md) | (url: string) => OnPreRoutingResult | Rewrite requested resources url before is was authenticated and routed to a handler | + diff --git a/docs/development/core/server/kibana-plugin-core-server.onpreroutingtoolkit.next.md b/docs/development/core/server/kibana-plugin-core-server.onpreroutingtoolkit.next.md new file mode 100644 index 0000000000000..7fb0b2ce67ba5 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.onpreroutingtoolkit.next.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [OnPreRoutingToolkit](./kibana-plugin-core-server.onpreroutingtoolkit.md) > [next](./kibana-plugin-core-server.onpreroutingtoolkit.next.md) + +## OnPreRoutingToolkit.next property + +To pass request to the next handler + +Signature: + +```typescript +next: () => OnPreRoutingResult; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.onpreroutingtoolkit.rewriteurl.md b/docs/development/core/server/kibana-plugin-core-server.onpreroutingtoolkit.rewriteurl.md new file mode 100644 index 0000000000000..346a12711c723 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.onpreroutingtoolkit.rewriteurl.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [OnPreRoutingToolkit](./kibana-plugin-core-server.onpreroutingtoolkit.md) > [rewriteUrl](./kibana-plugin-core-server.onpreroutingtoolkit.rewriteurl.md) + +## OnPreRoutingToolkit.rewriteUrl property + +Rewrite requested resources url before is was authenticated and routed to a handler + +Signature: + +```typescript +rewriteUrl: (url: string) => OnPreRoutingResult; +``` diff --git a/src/core/server/http/http_server.mocks.ts b/src/core/server/http/http_server.mocks.ts index bbef0a105c089..7d37af833d4c1 100644 --- a/src/core/server/http/http_server.mocks.ts +++ b/src/core/server/http/http_server.mocks.ts @@ -33,7 +33,7 @@ import { } from './router'; import { OnPreResponseToolkit } from './lifecycle/on_pre_response'; import { OnPostAuthToolkit } from './lifecycle/on_post_auth'; -import { OnPreAuthToolkit } from './lifecycle/on_pre_auth'; +import { OnPreRoutingToolkit } from './lifecycle/on_pre_routing'; interface RequestFixtureOptions

{ auth?: { isAuthenticated: boolean }; @@ -161,7 +161,7 @@ const createLifecycleResponseFactoryMock = (): jest.Mocked; +type ToolkitMock = jest.Mocked; const createToolkitMock = (): ToolkitMock => { return { diff --git a/src/core/server/http/http_server.test.ts b/src/core/server/http/http_server.test.ts index 72cb0b2821c5c..601eba835a54e 100644 --- a/src/core/server/http/http_server.test.ts +++ b/src/core/server/http/http_server.test.ts @@ -1089,6 +1089,16 @@ describe('setup contract', () => { }); }); + describe('#registerOnPreRouting', () => { + test('does not throw if called after stop', async () => { + const { registerOnPreRouting } = await server.setup(config); + await server.stop(); + expect(() => { + registerOnPreRouting((req, res) => res.unauthorized()); + }).not.toThrow(); + }); + }); + describe('#registerOnPreAuth', () => { test('does not throw if called after stop', async () => { const { registerOnPreAuth } = await server.setup(config); diff --git a/src/core/server/http/http_server.ts b/src/core/server/http/http_server.ts index 1abf5c0c133bb..9c16162d69334 100644 --- a/src/core/server/http/http_server.ts +++ b/src/core/server/http/http_server.ts @@ -24,8 +24,9 @@ import { Logger, LoggerFactory } from '../logging'; import { HttpConfig } from './http_config'; import { createServer, getListenerOptions, getServerOptions } from './http_tools'; import { adoptToHapiAuthFormat, AuthenticationHandler } from './lifecycle/auth'; +import { adoptToHapiOnPreAuth, OnPreAuthHandler } from './lifecycle/on_pre_auth'; import { adoptToHapiOnPostAuthFormat, OnPostAuthHandler } from './lifecycle/on_post_auth'; -import { adoptToHapiOnPreAuthFormat, OnPreAuthHandler } from './lifecycle/on_pre_auth'; +import { adoptToHapiOnRequest, OnPreRoutingHandler } from './lifecycle/on_pre_routing'; import { adoptToHapiOnPreResponseFormat, OnPreResponseHandler } from './lifecycle/on_pre_response'; import { IRouter, RouteConfigOptions, KibanaRouteState, isSafeMethod } from './router'; import { @@ -49,8 +50,9 @@ export interface HttpServerSetup { basePath: HttpServiceSetup['basePath']; csp: HttpServiceSetup['csp']; createCookieSessionStorageFactory: HttpServiceSetup['createCookieSessionStorageFactory']; - registerAuth: HttpServiceSetup['registerAuth']; + registerOnPreRouting: HttpServiceSetup['registerOnPreRouting']; registerOnPreAuth: HttpServiceSetup['registerOnPreAuth']; + registerAuth: HttpServiceSetup['registerAuth']; registerOnPostAuth: HttpServiceSetup['registerOnPostAuth']; registerOnPreResponse: HttpServiceSetup['registerOnPreResponse']; getAuthHeaders: GetAuthHeaders; @@ -64,7 +66,11 @@ export interface HttpServerSetup { /** @internal */ export type LifecycleRegistrar = Pick< HttpServerSetup, - 'registerAuth' | 'registerOnPreAuth' | 'registerOnPostAuth' | 'registerOnPreResponse' + | 'registerOnPreRouting' + | 'registerOnPreAuth' + | 'registerAuth' + | 'registerOnPostAuth' + | 'registerOnPreResponse' >; export class HttpServer { @@ -113,12 +119,13 @@ export class HttpServer { return { registerRouter: this.registerRouter.bind(this), registerStaticDir: this.registerStaticDir.bind(this), + registerOnPreRouting: this.registerOnPreRouting.bind(this), registerOnPreAuth: this.registerOnPreAuth.bind(this), + registerAuth: this.registerAuth.bind(this), registerOnPostAuth: this.registerOnPostAuth.bind(this), registerOnPreResponse: this.registerOnPreResponse.bind(this), createCookieSessionStorageFactory: (cookieOptions: SessionStorageCookieOptions) => this.createCookieSessionStorageFactory(cookieOptions, config.basePath), - registerAuth: this.registerAuth.bind(this), basePath: basePathService, csp: config.csp, auth: { @@ -222,7 +229,7 @@ export class HttpServer { return; } - this.registerOnPreAuth((request, response, toolkit) => { + this.registerOnPreRouting((request, response, toolkit) => { const oldUrl = request.url.href!; const newURL = basePathService.remove(oldUrl); const shouldRedirect = newURL !== oldUrl; @@ -263,6 +270,17 @@ export class HttpServer { } } + private registerOnPreAuth(fn: OnPreAuthHandler) { + if (this.server === undefined) { + throw new Error('Server is not created yet'); + } + if (this.stopped) { + this.log.warn(`registerOnPreAuth called after stop`); + } + + this.server.ext('onPreAuth', adoptToHapiOnPreAuth(fn, this.log)); + } + private registerOnPostAuth(fn: OnPostAuthHandler) { if (this.server === undefined) { throw new Error('Server is not created yet'); @@ -274,15 +292,15 @@ export class HttpServer { this.server.ext('onPostAuth', adoptToHapiOnPostAuthFormat(fn, this.log)); } - private registerOnPreAuth(fn: OnPreAuthHandler) { + private registerOnPreRouting(fn: OnPreRoutingHandler) { if (this.server === undefined) { throw new Error('Server is not created yet'); } if (this.stopped) { - this.log.warn(`registerOnPreAuth called after stop`); + this.log.warn(`registerOnPreRouting called after stop`); } - this.server.ext('onRequest', adoptToHapiOnPreAuthFormat(fn, this.log)); + this.server.ext('onRequest', adoptToHapiOnRequest(fn, this.log)); } private registerOnPreResponse(fn: OnPreResponseHandler) { diff --git a/src/core/server/http/http_service.mock.ts b/src/core/server/http/http_service.mock.ts index 5e7ee7b658eca..51f11b15f2e09 100644 --- a/src/core/server/http/http_service.mock.ts +++ b/src/core/server/http/http_service.mock.ts @@ -29,7 +29,7 @@ import { } from './types'; import { HttpService } from './http_service'; import { AuthStatus } from './auth_state_storage'; -import { OnPreAuthToolkit } from './lifecycle/on_pre_auth'; +import { OnPreRoutingToolkit } from './lifecycle/on_pre_routing'; import { AuthToolkit } from './lifecycle/auth'; import { sessionStorageMock } from './cookie_session_storage.mocks'; import { OnPostAuthToolkit } from './lifecycle/on_post_auth'; @@ -87,6 +87,7 @@ const createInternalSetupContractMock = () => { config: jest.fn().mockReturnValue(configMock.create()), } as unknown) as jest.MockedClass, createCookieSessionStorageFactory: jest.fn(), + registerOnPreRouting: jest.fn(), registerOnPreAuth: jest.fn(), registerAuth: jest.fn(), registerOnPostAuth: jest.fn(), @@ -117,7 +118,8 @@ const createSetupContractMock = () => { const mock: HttpServiceSetupMock = { createCookieSessionStorageFactory: internalMock.createCookieSessionStorageFactory, - registerOnPreAuth: internalMock.registerOnPreAuth, + registerOnPreRouting: internalMock.registerOnPreRouting, + registerOnPreAuth: jest.fn(), registerAuth: internalMock.registerAuth, registerOnPostAuth: internalMock.registerOnPostAuth, registerOnPreResponse: internalMock.registerOnPreResponse, @@ -173,7 +175,7 @@ const createHttpServiceMock = () => { return mocked; }; -const createOnPreAuthToolkitMock = (): jest.Mocked => ({ +const createOnPreAuthToolkitMock = (): jest.Mocked => ({ next: jest.fn(), rewriteUrl: jest.fn(), }); diff --git a/src/core/server/http/index.ts b/src/core/server/http/index.ts index 65d633260a791..e91f7d9375842 100644 --- a/src/core/server/http/index.ts +++ b/src/core/server/http/index.ts @@ -64,7 +64,7 @@ export { SafeRouteMethod, } from './router'; export { BasePathProxyServer } from './base_path_proxy_server'; -export { OnPreAuthHandler, OnPreAuthToolkit } from './lifecycle/on_pre_auth'; +export { OnPreRoutingHandler, OnPreRoutingToolkit } from './lifecycle/on_pre_routing'; export { AuthenticationHandler, AuthHeaders, @@ -78,6 +78,7 @@ export { AuthResultType, } from './lifecycle/auth'; export { OnPostAuthHandler, OnPostAuthToolkit } from './lifecycle/on_post_auth'; +export { OnPreAuthHandler, OnPreAuthToolkit } from './lifecycle/on_pre_auth'; export { OnPreResponseHandler, OnPreResponseToolkit, diff --git a/src/core/server/http/integration_tests/core_services.test.ts b/src/core/server/http/integration_tests/core_services.test.ts index 0ee53a04d9f87..3c5f22500e5e0 100644 --- a/src/core/server/http/integration_tests/core_services.test.ts +++ b/src/core/server/http/integration_tests/core_services.test.ts @@ -337,7 +337,7 @@ describe('http service', () => { it('basePath information for an incoming request is available in legacy server', async () => { const reqBasePath = '/requests-specific-base-path'; const { http } = await root.setup(); - http.registerOnPreAuth((req, res, toolkit) => { + http.registerOnPreRouting((req, res, toolkit) => { http.basePath.set(req, reqBasePath); return toolkit.next(); }); diff --git a/src/core/server/http/integration_tests/lifecycle.test.ts b/src/core/server/http/integration_tests/lifecycle.test.ts index cbab14115ba6b..b9548bf7a8d70 100644 --- a/src/core/server/http/integration_tests/lifecycle.test.ts +++ b/src/core/server/http/integration_tests/lifecycle.test.ts @@ -57,20 +57,22 @@ interface StorageData { expires: number; } -describe('OnPreAuth', () => { +describe('OnPreRouting', () => { it('supports registering a request interceptor', async () => { - const { registerOnPreAuth, server: innerServer, createRouter } = await server.setup(setupDeps); + const { registerOnPreRouting, server: innerServer, createRouter } = await server.setup( + setupDeps + ); const router = createRouter('/'); router.get({ path: '/', validate: false }, (context, req, res) => res.ok({ body: 'ok' })); const callingOrder: string[] = []; - registerOnPreAuth((req, res, t) => { + registerOnPreRouting((req, res, t) => { callingOrder.push('first'); return t.next(); }); - registerOnPreAuth((req, res, t) => { + registerOnPreRouting((req, res, t) => { callingOrder.push('second'); return t.next(); }); @@ -82,7 +84,9 @@ describe('OnPreAuth', () => { }); it('supports request forwarding to specified url', async () => { - const { registerOnPreAuth, server: innerServer, createRouter } = await server.setup(setupDeps); + const { registerOnPreRouting, server: innerServer, createRouter } = await server.setup( + setupDeps + ); const router = createRouter('/'); router.get({ path: '/initial', validate: false }, (context, req, res) => @@ -93,13 +97,13 @@ describe('OnPreAuth', () => { ); let urlBeforeForwarding; - registerOnPreAuth((req, res, t) => { + registerOnPreRouting((req, res, t) => { urlBeforeForwarding = ensureRawRequest(req).raw.req.url; return t.rewriteUrl('/redirectUrl'); }); let urlAfterForwarding; - registerOnPreAuth((req, res, t) => { + registerOnPreRouting((req, res, t) => { // used by legacy platform urlAfterForwarding = ensureRawRequest(req).raw.req.url; return t.next(); @@ -113,6 +117,152 @@ describe('OnPreAuth', () => { expect(urlAfterForwarding).toBe('/redirectUrl'); }); + it('supports redirection from the interceptor', async () => { + const { registerOnPreRouting, server: innerServer, createRouter } = await server.setup( + setupDeps + ); + const router = createRouter('/'); + + const redirectUrl = '/redirectUrl'; + router.get({ path: '/initial', validate: false }, (context, req, res) => res.ok()); + + registerOnPreRouting((req, res, t) => + res.redirected({ + headers: { + location: redirectUrl, + }, + }) + ); + await server.start(); + + const result = await supertest(innerServer.listener).get('/initial').expect(302); + + expect(result.header.location).toBe(redirectUrl); + }); + + it('supports rejecting request and adjusting response headers', async () => { + const { registerOnPreRouting, server: innerServer, createRouter } = await server.setup( + setupDeps + ); + const router = createRouter('/'); + + router.get({ path: '/', validate: false }, (context, req, res) => res.ok()); + + registerOnPreRouting((req, res, t) => + res.unauthorized({ + headers: { + 'www-authenticate': 'challenge', + }, + }) + ); + await server.start(); + + const result = await supertest(innerServer.listener).get('/').expect(401); + + expect(result.header['www-authenticate']).toBe('challenge'); + }); + + it('does not expose error details if interceptor throws', async () => { + const { registerOnPreRouting, server: innerServer, createRouter } = await server.setup( + setupDeps + ); + const router = createRouter('/'); + + router.get({ path: '/', validate: false }, (context, req, res) => res.ok()); + + registerOnPreRouting((req, res, t) => { + throw new Error('reason'); + }); + await server.start(); + + const result = await supertest(innerServer.listener).get('/').expect(500); + + expect(result.body.message).toBe('An internal server error occurred.'); + expect(loggingSystemMock.collect(logger).error).toMatchInlineSnapshot(` + Array [ + Array [ + [Error: reason], + ], + ] + `); + }); + + it('returns internal error if interceptor returns unexpected result', async () => { + const { registerOnPreRouting, server: innerServer, createRouter } = await server.setup( + setupDeps + ); + const router = createRouter('/'); + + router.get({ path: '/', validate: false }, (context, req, res) => res.ok()); + + registerOnPreRouting((req, res, t) => ({} as any)); + await server.start(); + + const result = await supertest(innerServer.listener).get('/').expect(500); + + expect(result.body.message).toBe('An internal server error occurred.'); + expect(loggingSystemMock.collect(logger).error).toMatchInlineSnapshot(` + Array [ + Array [ + [Error: Unexpected result from OnPreRouting. Expected OnPreRoutingResult or KibanaResponse, but given: [object Object].], + ], + ] + `); + }); + + it(`doesn't share request object between interceptors`, async () => { + const { registerOnPreRouting, server: innerServer, createRouter } = await server.setup( + setupDeps + ); + const router = createRouter('/'); + + registerOnPreRouting((req, res, t) => { + // don't complain customField is not defined on Request type + (req as any).customField = { value: 42 }; + return t.next(); + }); + registerOnPreRouting((req, res, t) => { + // don't complain customField is not defined on Request type + if (typeof (req as any).customField !== 'undefined') { + throw new Error('Request object was mutated'); + } + return t.next(); + }); + router.get({ path: '/', validate: false }, (context, req, res) => + // don't complain customField is not defined on Request type + res.ok({ body: { customField: String((req as any).customField) } }) + ); + + await server.start(); + + await supertest(innerServer.listener).get('/').expect(200, { customField: 'undefined' }); + }); +}); + +describe('OnPreAuth', () => { + it('supports registering a request interceptor', async () => { + const { registerOnPreAuth, server: innerServer, createRouter } = await server.setup(setupDeps); + const router = createRouter('/'); + + router.get({ path: '/', validate: false }, (context, req, res) => res.ok({ body: 'ok' })); + + const callingOrder: string[] = []; + registerOnPreAuth((req, res, t) => { + callingOrder.push('first'); + return t.next(); + }); + + registerOnPreAuth((req, res, t) => { + callingOrder.push('second'); + return t.next(); + }); + await server.start(); + + await supertest(innerServer.listener).get('/').expect(200, 'ok'); + + expect(callingOrder).toEqual(['first', 'second']); + }); + it('supports redirection from the interceptor', async () => { const { registerOnPreAuth, server: innerServer, createRouter } = await server.setup(setupDeps); const router = createRouter('/'); @@ -203,20 +353,20 @@ describe('OnPreAuth', () => { const router = createRouter('/'); registerOnPreAuth((req, res, t) => { - // don't complain customField is not defined on Request type - (req as any).customField = { value: 42 }; + // @ts-expect-error customField property is not defined on request object + req.customField = { value: 42 }; return t.next(); }); registerOnPreAuth((req, res, t) => { - // don't complain customField is not defined on Request type - if (typeof (req as any).customField !== 'undefined') { + // @ts-expect-error customField property is not defined on request object + if (typeof req.customField !== 'undefined') { throw new Error('Request object was mutated'); } return t.next(); }); router.get({ path: '/', validate: false }, (context, req, res) => - // don't complain customField is not defined on Request type - res.ok({ body: { customField: String((req as any).customField) } }) + // @ts-expect-error customField property is not defined on request object + res.ok({ body: { customField: String(req.customField) } }) ); await server.start(); @@ -664,7 +814,7 @@ describe('Auth', () => { it.skip('is the only place with access to the authorization header', async () => { const { - registerOnPreAuth, + registerOnPreRouting, registerAuth, registerOnPostAuth, server: innerServer, @@ -672,9 +822,9 @@ describe('Auth', () => { } = await server.setup(setupDeps); const router = createRouter('/'); - let fromRegisterOnPreAuth; - await registerOnPreAuth((req, res, toolkit) => { - fromRegisterOnPreAuth = req.headers.authorization; + let fromregisterOnPreRouting; + await registerOnPreRouting((req, res, toolkit) => { + fromregisterOnPreRouting = req.headers.authorization; return toolkit.next(); }); @@ -701,7 +851,7 @@ describe('Auth', () => { const token = 'Basic: user:password'; await supertest(innerServer.listener).get('/').set('Authorization', token).expect(200); - expect(fromRegisterOnPreAuth).toEqual({}); + expect(fromregisterOnPreRouting).toEqual({}); expect(fromRegisterAuth).toEqual({ authorization: token }); expect(fromRegisterOnPostAuth).toEqual({}); expect(fromRouteHandler).toEqual({}); @@ -1137,3 +1287,135 @@ describe('OnPreResponse', () => { expect(requestBody).toStrictEqual({}); }); }); + +describe('run interceptors in the right order', () => { + it('with Auth registered', async () => { + const { + registerOnPreRouting, + registerOnPreAuth, + registerAuth, + registerOnPostAuth, + registerOnPreResponse, + server: innerServer, + createRouter, + } = await server.setup(setupDeps); + + const router = createRouter('/'); + + const executionOrder: string[] = []; + registerOnPreRouting((req, res, t) => { + executionOrder.push('onPreRouting'); + return t.next(); + }); + registerOnPreAuth((req, res, t) => { + executionOrder.push('onPreAuth'); + return t.next(); + }); + registerAuth((req, res, t) => { + executionOrder.push('auth'); + return t.authenticated({}); + }); + registerOnPostAuth((req, res, t) => { + executionOrder.push('onPostAuth'); + return t.next(); + }); + registerOnPreResponse((req, res, t) => { + executionOrder.push('onPreResponse'); + return t.next(); + }); + + router.get({ path: '/', validate: false }, (context, req, res) => res.ok({ body: 'ok' })); + + await server.start(); + + await supertest(innerServer.listener).get('/').expect(200); + expect(executionOrder).toEqual([ + 'onPreRouting', + 'onPreAuth', + 'auth', + 'onPostAuth', + 'onPreResponse', + ]); + }); + + it('with no Auth registered', async () => { + const { + registerOnPreRouting, + registerOnPreAuth, + registerOnPostAuth, + registerOnPreResponse, + server: innerServer, + createRouter, + } = await server.setup(setupDeps); + + const router = createRouter('/'); + + const executionOrder: string[] = []; + registerOnPreRouting((req, res, t) => { + executionOrder.push('onPreRouting'); + return t.next(); + }); + registerOnPreAuth((req, res, t) => { + executionOrder.push('onPreAuth'); + return t.next(); + }); + registerOnPostAuth((req, res, t) => { + executionOrder.push('onPostAuth'); + return t.next(); + }); + registerOnPreResponse((req, res, t) => { + executionOrder.push('onPreResponse'); + return t.next(); + }); + + router.get({ path: '/', validate: false }, (context, req, res) => res.ok({ body: 'ok' })); + + await server.start(); + + await supertest(innerServer.listener).get('/').expect(200); + expect(executionOrder).toEqual(['onPreRouting', 'onPreAuth', 'onPostAuth', 'onPreResponse']); + }); + + it('when a user failed auth', async () => { + const { + registerOnPreRouting, + registerOnPreAuth, + registerOnPostAuth, + registerAuth, + registerOnPreResponse, + server: innerServer, + createRouter, + } = await server.setup(setupDeps); + + const router = createRouter('/'); + + const executionOrder: string[] = []; + registerOnPreRouting((req, res, t) => { + executionOrder.push('onPreRouting'); + return t.next(); + }); + registerOnPreAuth((req, res, t) => { + executionOrder.push('onPreAuth'); + return t.next(); + }); + registerAuth((req, res, t) => { + executionOrder.push('auth'); + return res.forbidden(); + }); + registerOnPostAuth((req, res, t) => { + executionOrder.push('onPostAuth'); + return t.next(); + }); + registerOnPreResponse((req, res, t) => { + executionOrder.push('onPreResponse'); + return t.next(); + }); + + router.get({ path: '/', validate: false }, (context, req, res) => res.ok({ body: 'ok' })); + + await server.start(); + + await supertest(innerServer.listener).get('/').expect(403); + expect(executionOrder).toEqual(['onPreRouting', 'onPreAuth', 'auth', 'onPreResponse']); + }); +}); diff --git a/src/core/server/http/lifecycle/on_pre_auth.ts b/src/core/server/http/lifecycle/on_pre_auth.ts index dc2ae6922fb94..f76fe87fd14a3 100644 --- a/src/core/server/http/lifecycle/on_pre_auth.ts +++ b/src/core/server/http/lifecycle/on_pre_auth.ts @@ -29,33 +29,21 @@ import { enum ResultType { next = 'next', - rewriteUrl = 'rewriteUrl', } interface Next { type: ResultType.next; } -interface RewriteUrl { - type: ResultType.rewriteUrl; - url: string; -} - -type OnPreAuthResult = Next | RewriteUrl; +type OnPreAuthResult = Next; const preAuthResult = { next(): OnPreAuthResult { return { type: ResultType.next }; }, - rewriteUrl(url: string): OnPreAuthResult { - return { type: ResultType.rewriteUrl, url }; - }, isNext(result: OnPreAuthResult): result is Next { return result && result.type === ResultType.next; }, - isRewriteUrl(result: OnPreAuthResult): result is RewriteUrl { - return result && result.type === ResultType.rewriteUrl; - }, }; /** @@ -65,13 +53,10 @@ const preAuthResult = { export interface OnPreAuthToolkit { /** To pass request to the next handler */ next: () => OnPreAuthResult; - /** Rewrite requested resources url before is was authenticated and routed to a handler */ - rewriteUrl: (url: string) => OnPreAuthResult; } const toolkit: OnPreAuthToolkit = { next: preAuthResult.next, - rewriteUrl: preAuthResult.rewriteUrl, }; /** @@ -88,9 +73,9 @@ export type OnPreAuthHandler = ( * @public * Adopt custom request interceptor to Hapi lifecycle system. * @param fn - an extension point allowing to perform custom logic for - * incoming HTTP requests. + * incoming HTTP requests before a user has been authenticated. */ -export function adoptToHapiOnPreAuthFormat(fn: OnPreAuthHandler, log: Logger) { +export function adoptToHapiOnPreAuth(fn: OnPreAuthHandler, log: Logger) { return async function interceptPreAuthRequest( request: Request, responseToolkit: HapiResponseToolkit @@ -107,13 +92,6 @@ export function adoptToHapiOnPreAuthFormat(fn: OnPreAuthHandler, log: Logger) { return responseToolkit.continue; } - if (preAuthResult.isRewriteUrl(result)) { - const { url } = result; - request.setUrl(url); - // We should update raw request as well since it can be proxied to the old platform - request.raw.req.url = url; - return responseToolkit.continue; - } throw new Error( `Unexpected result from OnPreAuth. Expected OnPreAuthResult or KibanaResponse, but given: ${result}.` ); diff --git a/src/core/server/http/lifecycle/on_pre_response.ts b/src/core/server/http/lifecycle/on_pre_response.ts index 9c8c6fba690d1..4d1b53313a51f 100644 --- a/src/core/server/http/lifecycle/on_pre_response.ts +++ b/src/core/server/http/lifecycle/on_pre_response.ts @@ -64,7 +64,7 @@ const preResponseResult = { }; /** - * A tool set defining an outcome of OnPreAuth interceptor for incoming request. + * A tool set defining an outcome of OnPreResponse interceptor for incoming request. * @public */ export interface OnPreResponseToolkit { @@ -77,7 +77,7 @@ const toolkit: OnPreResponseToolkit = { }; /** - * See {@link OnPreAuthToolkit}. + * See {@link OnPreRoutingToolkit}. * @public */ export type OnPreResponseHandler = ( diff --git a/src/core/server/http/lifecycle/on_pre_routing.ts b/src/core/server/http/lifecycle/on_pre_routing.ts new file mode 100644 index 0000000000000..e62eb54f2398f --- /dev/null +++ b/src/core/server/http/lifecycle/on_pre_routing.ts @@ -0,0 +1,125 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Lifecycle, Request, ResponseToolkit as HapiResponseToolkit } from 'hapi'; +import { Logger } from '../../logging'; +import { + HapiResponseAdapter, + KibanaRequest, + KibanaResponse, + lifecycleResponseFactory, + LifecycleResponseFactory, +} from '../router'; + +enum ResultType { + next = 'next', + rewriteUrl = 'rewriteUrl', +} + +interface Next { + type: ResultType.next; +} + +interface RewriteUrl { + type: ResultType.rewriteUrl; + url: string; +} + +type OnPreRoutingResult = Next | RewriteUrl; + +const preRoutingResult = { + next(): OnPreRoutingResult { + return { type: ResultType.next }; + }, + rewriteUrl(url: string): OnPreRoutingResult { + return { type: ResultType.rewriteUrl, url }; + }, + isNext(result: OnPreRoutingResult): result is Next { + return result && result.type === ResultType.next; + }, + isRewriteUrl(result: OnPreRoutingResult): result is RewriteUrl { + return result && result.type === ResultType.rewriteUrl; + }, +}; + +/** + * @public + * A tool set defining an outcome of OnPreRouting interceptor for incoming request. + */ +export interface OnPreRoutingToolkit { + /** To pass request to the next handler */ + next: () => OnPreRoutingResult; + /** Rewrite requested resources url before is was authenticated and routed to a handler */ + rewriteUrl: (url: string) => OnPreRoutingResult; +} + +const toolkit: OnPreRoutingToolkit = { + next: preRoutingResult.next, + rewriteUrl: preRoutingResult.rewriteUrl, +}; + +/** + * See {@link OnPreRoutingToolkit}. + * @public + */ +export type OnPreRoutingHandler = ( + request: KibanaRequest, + response: LifecycleResponseFactory, + toolkit: OnPreRoutingToolkit +) => OnPreRoutingResult | KibanaResponse | Promise; + +/** + * @public + * Adopt custom request interceptor to Hapi lifecycle system. + * @param fn - an extension point allowing to perform custom logic for + * incoming HTTP requests. + */ +export function adoptToHapiOnRequest(fn: OnPreRoutingHandler, log: Logger) { + return async function interceptPreRoutingRequest( + request: Request, + responseToolkit: HapiResponseToolkit + ): Promise { + const hapiResponseAdapter = new HapiResponseAdapter(responseToolkit); + + try { + const result = await fn(KibanaRequest.from(request), lifecycleResponseFactory, toolkit); + if (result instanceof KibanaResponse) { + return hapiResponseAdapter.handle(result); + } + + if (preRoutingResult.isNext(result)) { + return responseToolkit.continue; + } + + if (preRoutingResult.isRewriteUrl(result)) { + const { url } = result; + request.setUrl(url); + // We should update raw request as well since it can be proxied to the old platform + request.raw.req.url = url; + return responseToolkit.continue; + } + throw new Error( + `Unexpected result from OnPreRouting. Expected OnPreRoutingResult or KibanaResponse, but given: ${result}.` + ); + } catch (error) { + log.error(error); + return hapiResponseAdapter.toInternalError(); + } + }; +} diff --git a/src/core/server/http/types.ts b/src/core/server/http/types.ts index 241af1a3020cb..3df098a1df00d 100644 --- a/src/core/server/http/types.ts +++ b/src/core/server/http/types.ts @@ -25,6 +25,7 @@ import { HttpServerSetup } from './http_server'; import { SessionStorageCookieOptions } from './cookie_session_storage'; import { SessionStorageFactory } from './session_storage'; import { AuthenticationHandler } from './lifecycle/auth'; +import { OnPreRoutingHandler } from './lifecycle/on_pre_routing'; import { OnPreAuthHandler } from './lifecycle/on_pre_auth'; import { OnPostAuthHandler } from './lifecycle/on_post_auth'; import { OnPreResponseHandler } from './lifecycle/on_pre_response'; @@ -145,15 +146,26 @@ export interface HttpServiceSetup { ) => Promise>; /** - * To define custom logic to perform for incoming requests. + * To define custom logic to perform for incoming requests before server performs a route lookup. * * @remarks - * Runs the handler before Auth interceptor performs a check that user has access to requested resources, so it's the - * only place when you can forward a request to another URL right on the server. - * Can register any number of registerOnPostAuth, which are called in sequence + * It's the only place when you can forward a request to another URL right on the server. + * Can register any number of registerOnPreRouting, which are called in sequence + * (from the first registered to the last). See {@link OnPreRoutingHandler}. + * + * @param handler {@link OnPreRoutingHandler} - function to call. + */ + registerOnPreRouting: (handler: OnPreRoutingHandler) => void; + + /** + * To define custom logic to perform for incoming requests before + * the Auth interceptor performs a check that user has access to requested resources. + * + * @remarks + * Can register any number of registerOnPreAuth, which are called in sequence * (from the first registered to the last). See {@link OnPreAuthHandler}. * - * @param handler {@link OnPreAuthHandler} - function to call. + * @param handler {@link OnPreRoutingHandler} - function to call. */ registerOnPreAuth: (handler: OnPreAuthHandler) => void; @@ -170,13 +182,11 @@ export interface HttpServiceSetup { registerAuth: (handler: AuthenticationHandler) => void; /** - * To define custom logic to perform for incoming requests. + * To define custom logic after Auth interceptor did make sure a user has access to the requested resource. * * @remarks - * Runs the handler after Auth interceptor - * did make sure a user has access to the requested resource. * The auth state is available at stage via http.auth.get(..) - * Can register any number of registerOnPreAuth, which are called in sequence + * Can register any number of registerOnPostAuth, which are called in sequence * (from the first registered to the last). See {@link OnPostAuthHandler}. * * @param handler {@link OnPostAuthHandler} - function to call. diff --git a/src/core/server/index.ts b/src/core/server/index.ts index dcaa5f2367214..706ec88c6ebfd 100644 --- a/src/core/server/index.ts +++ b/src/core/server/index.ts @@ -148,6 +148,8 @@ export { LegacyRequest, OnPreAuthHandler, OnPreAuthToolkit, + OnPreRoutingHandler, + OnPreRoutingToolkit, OnPostAuthHandler, OnPostAuthToolkit, OnPreResponseHandler, diff --git a/src/core/server/legacy/legacy_service.ts b/src/core/server/legacy/legacy_service.ts index 6b34a4eb58319..fada40e773f12 100644 --- a/src/core/server/legacy/legacy_service.ts +++ b/src/core/server/legacy/legacy_service.ts @@ -301,6 +301,7 @@ export class LegacyService implements CoreService { ), createRouter: () => router, resources: setupDeps.core.httpResources.createRegistrar(router), + registerOnPreRouting: setupDeps.core.http.registerOnPreRouting, registerOnPreAuth: setupDeps.core.http.registerOnPreAuth, registerAuth: setupDeps.core.http.registerAuth, registerOnPostAuth: setupDeps.core.http.registerOnPostAuth, diff --git a/src/core/server/plugins/plugin_context.ts b/src/core/server/plugins/plugin_context.ts index a6dd13a12b527..c17b8df8bb52c 100644 --- a/src/core/server/plugins/plugin_context.ts +++ b/src/core/server/plugins/plugin_context.ts @@ -157,6 +157,7 @@ export function createPluginSetupContext( ), createRouter: () => router, resources: deps.httpResources.createRegistrar(router), + registerOnPreRouting: deps.http.registerOnPreRouting, registerOnPreAuth: deps.http.registerOnPreAuth, registerAuth: deps.http.registerAuth, registerOnPostAuth: deps.http.registerOnPostAuth, diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index 3d3e1905577d9..886544a4df317 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -811,6 +811,7 @@ export interface HttpServiceSetup { registerOnPostAuth: (handler: OnPostAuthHandler) => void; registerOnPreAuth: (handler: OnPreAuthHandler) => void; registerOnPreResponse: (handler: OnPreResponseHandler) => void; + registerOnPreRouting: (handler: OnPreRoutingHandler) => void; registerRouteHandlerContext: (contextName: T, provider: RequestHandlerContextProvider) => RequestHandlerContextContainer; } @@ -1536,7 +1537,6 @@ export type OnPreAuthHandler = (request: KibanaRequest, response: LifecycleRespo // @public export interface OnPreAuthToolkit { next: () => OnPreAuthResult; - rewriteUrl: (url: string) => OnPreAuthResult; } // @public @@ -1560,6 +1560,17 @@ export interface OnPreResponseToolkit { next: (responseExtensions?: OnPreResponseExtensions) => OnPreResponseResult; } +// Warning: (ae-forgotten-export) The symbol "OnPreRoutingResult" needs to be exported by the entry point index.d.ts +// +// @public +export type OnPreRoutingHandler = (request: KibanaRequest, response: LifecycleResponseFactory, toolkit: OnPreRoutingToolkit) => OnPreRoutingResult | KibanaResponse | Promise; + +// @public +export interface OnPreRoutingToolkit { + next: () => OnPreRoutingResult; + rewriteUrl: (url: string) => OnPreRoutingResult; +} + // @public export interface OpsMetrics { concurrent_connections: OpsServerMetrics['concurrent_connections']; diff --git a/x-pack/plugins/spaces/server/lib/request_interceptors/on_request_interceptor.ts b/x-pack/plugins/spaces/server/lib/request_interceptors/on_request_interceptor.ts index 18e9da25576eb..4b3a5d662f12d 100644 --- a/x-pack/plugins/spaces/server/lib/request_interceptors/on_request_interceptor.ts +++ b/x-pack/plugins/spaces/server/lib/request_interceptors/on_request_interceptor.ts @@ -5,7 +5,7 @@ */ import { KibanaRequest, - OnPreAuthToolkit, + OnPreRoutingToolkit, LifecycleResponseFactory, CoreSetup, } from 'src/core/server'; @@ -18,10 +18,10 @@ export interface OnRequestInterceptorDeps { http: CoreSetup['http']; } export function initSpacesOnRequestInterceptor({ http }: OnRequestInterceptorDeps) { - http.registerOnPreAuth(async function spacesOnPreAuthHandler( + http.registerOnPreRouting(async function spacesOnPreRoutingHandler( request: KibanaRequest, response: LifecycleResponseFactory, - toolkit: OnPreAuthToolkit + toolkit: OnPreRoutingToolkit ) { const serverBasePath = http.basePath.serverBasePath; const path = request.url.pathname; From ec43d45b511fbae15b6a8dc016ea49299b054301 Mon Sep 17 00:00:00 2001 From: Spencer Date: Mon, 13 Jul 2020 12:29:29 -0700 Subject: [PATCH 23/66] [scripts/report_failed_tests] fix report_failed_tests integration on CI (#71131) Co-authored-by: spalger Co-authored-by: Elastic Machine --- .../kbn-test/src/failed_tests_reporter/README.md | 6 +++--- .../run_failed_tests_reporter_cli.ts | 12 ++++++++++-- vars/kibanaPipeline.groovy | 2 +- 3 files changed, 14 insertions(+), 6 deletions(-) diff --git a/packages/kbn-test/src/failed_tests_reporter/README.md b/packages/kbn-test/src/failed_tests_reporter/README.md index 20592ecd733b6..0473ae7357def 100644 --- a/packages/kbn-test/src/failed_tests_reporter/README.md +++ b/packages/kbn-test/src/failed_tests_reporter/README.md @@ -7,15 +7,15 @@ A little CLI that runs in CI to find the failed tests in the JUnit reports, then To fetch some JUnit reports from a recent build on CI, visit its `Google Cloud Storage Upload Report` and execute the following in the JS Console: ```js -copy(`wget "${Array.from($$('a[href$=".xml"]')).filter(a => a.innerText === 'Download').map(a => a.href.replace('https://storage.cloud.google.com/', 'https://storage.googleapis.com/')).join('" "')}"`) +copy(`wget -x -nH --cut-dirs 5 -P "target/downloaded_junit" "${Array.from($$('a[href$=".xml"]')).filter(a => a.innerText === 'Download').map(a => a.href.replace('https://storage.cloud.google.com/', 'https://storage.googleapis.com/')).join('" "')}"`) ``` -This copies a script to download the reports, which you should execute in the `test/junit` directory. +This copies a script to download the reports, which you should execute in the root of the Kibana repository. Next, run the CLI in `--no-github-update` mode so that it doesn't actually communicate with Github and `--no-report-update` to prevent the script from mutating the reports on disk and instead log the updated report. ```sh -node scripts/report_failed_tests.js --verbose --no-github-update --no-report-update +node scripts/report_failed_tests.js --verbose --no-github-update --no-report-update target/downloaded_junit/**/*.xml ``` Unless you specify the `GITHUB_TOKEN` environment variable requests to read existing issues will use anonymous access which is limited to 60 requests per hour. \ No newline at end of file diff --git a/packages/kbn-test/src/failed_tests_reporter/run_failed_tests_reporter_cli.ts b/packages/kbn-test/src/failed_tests_reporter/run_failed_tests_reporter_cli.ts index 3bcea44cf73b6..8a951ac969199 100644 --- a/packages/kbn-test/src/failed_tests_reporter/run_failed_tests_reporter_cli.ts +++ b/packages/kbn-test/src/failed_tests_reporter/run_failed_tests_reporter_cli.ts @@ -17,6 +17,8 @@ * under the License. */ +import Path from 'path'; + import { REPO_ROOT, run, createFailError, createFlagError } from '@kbn/dev-utils'; import globby from 'globby'; @@ -28,6 +30,8 @@ import { readTestReport } from './test_report'; import { addMessagesToReport } from './add_messages_to_report'; import { getReportMessageIter } from './report_metadata'; +const DEFAULT_PATTERNS = [Path.resolve(REPO_ROOT, 'target/junit/**/*.xml')]; + export function runFailedTestsReporterCli() { run( async ({ log, flags }) => { @@ -67,11 +71,15 @@ export function runFailedTestsReporterCli() { throw createFlagError('Missing --build-url or process.env.BUILD_URL'); } - const reportPaths = await globby(['target/junit/**/*.xml'], { - cwd: REPO_ROOT, + const patterns = flags._.length ? flags._ : DEFAULT_PATTERNS; + const reportPaths = await globby(patterns, { absolute: true, }); + if (!reportPaths.length) { + throw createFailError(`Unable to find any junit reports with patterns [${patterns}]`); + } + const newlyCreatedIssues: Array<{ failure: TestFailure; newIssue: GithubIssueMini; diff --git a/vars/kibanaPipeline.groovy b/vars/kibanaPipeline.groovy index f3fc5f84583c9..f43fe9f96c3ef 100644 --- a/vars/kibanaPipeline.groovy +++ b/vars/kibanaPipeline.groovy @@ -209,7 +209,7 @@ def runErrorReporter() { bash( """ source src/dev/ci_setup/setup_env.sh - node scripts/report_failed_tests ${dryRun} + node scripts/report_failed_tests ${dryRun} target/junit/**/*.xml """, "Report failed tests, if necessary" ) From 7282597a297b859b27e0bd9921d385198cc11e04 Mon Sep 17 00:00:00 2001 From: Jen Huang Date: Mon, 13 Jul 2020 12:46:00 -0700 Subject: [PATCH 24/66] [Ingest Manager] Rename `settings.monitoring` to `agent.monitoring` (#71467) * Rename settings.monitoring to agent.monitoring; simplify default file name for downloaded agent yaml * Fix test --- .../ingest_manager/common/services/config_to_yaml.ts | 2 +- .../ingest_manager/common/types/models/agent_config.ts | 2 +- .../ingest_manager/server/routes/agent_config/handlers.ts | 2 +- .../ingest_manager/server/services/agent_config.test.ts | 6 +++--- .../plugins/ingest_manager/server/services/agent_config.ts | 4 ++-- .../apps/endpoint/policy_details.ts | 2 +- 6 files changed, 9 insertions(+), 9 deletions(-) diff --git a/x-pack/plugins/ingest_manager/common/services/config_to_yaml.ts b/x-pack/plugins/ingest_manager/common/services/config_to_yaml.ts index 7e03e4572f9ee..1fb6fead454ef 100644 --- a/x-pack/plugins/ingest_manager/common/services/config_to_yaml.ts +++ b/x-pack/plugins/ingest_manager/common/services/config_to_yaml.ts @@ -12,7 +12,7 @@ const CONFIG_KEYS_ORDER = [ 'revision', 'type', 'outputs', - 'settings', + 'agent', 'inputs', 'enabled', 'use_output', diff --git a/x-pack/plugins/ingest_manager/common/types/models/agent_config.ts b/x-pack/plugins/ingest_manager/common/types/models/agent_config.ts index a6040742e45fc..00ba51fc1843a 100644 --- a/x-pack/plugins/ingest_manager/common/types/models/agent_config.ts +++ b/x-pack/plugins/ingest_manager/common/types/models/agent_config.ts @@ -62,7 +62,7 @@ export interface FullAgentConfig { }; inputs: FullAgentConfigInput[]; revision?: number; - settings?: { + agent?: { monitoring: { use_output?: string; enabled: boolean; diff --git a/x-pack/plugins/ingest_manager/server/routes/agent_config/handlers.ts b/x-pack/plugins/ingest_manager/server/routes/agent_config/handlers.ts index 2aaf889296bd6..718aca89ea4fd 100644 --- a/x-pack/plugins/ingest_manager/server/routes/agent_config/handlers.ts +++ b/x-pack/plugins/ingest_manager/server/routes/agent_config/handlers.ts @@ -283,7 +283,7 @@ export const downloadFullAgentConfig: RequestHandler< const body = configToYaml(fullAgentConfig); const headers: ResponseHeaders = { 'content-type': 'text/x-yaml', - 'content-disposition': `attachment; filename="elastic-agent-config-${fullAgentConfig.id}.yml"`, + 'content-disposition': `attachment; filename="elastic-agent.yml"`, }; return response.ok({ body, diff --git a/x-pack/plugins/ingest_manager/server/services/agent_config.test.ts b/x-pack/plugins/ingest_manager/server/services/agent_config.test.ts index c46e648ad088a..225251b061e58 100644 --- a/x-pack/plugins/ingest_manager/server/services/agent_config.test.ts +++ b/x-pack/plugins/ingest_manager/server/services/agent_config.test.ts @@ -61,7 +61,7 @@ describe('agent config', () => { }, inputs: [], revision: 1, - settings: { + agent: { monitoring: { enabled: false, logs: false, @@ -90,7 +90,7 @@ describe('agent config', () => { }, inputs: [], revision: 1, - settings: { + agent: { monitoring: { use_output: 'default', enabled: true, @@ -120,7 +120,7 @@ describe('agent config', () => { }, inputs: [], revision: 1, - settings: { + agent: { monitoring: { use_output: 'default', enabled: true, diff --git a/x-pack/plugins/ingest_manager/server/services/agent_config.ts b/x-pack/plugins/ingest_manager/server/services/agent_config.ts index 5f98c8881388d..c068b594318c1 100644 --- a/x-pack/plugins/ingest_manager/server/services/agent_config.ts +++ b/x-pack/plugins/ingest_manager/server/services/agent_config.ts @@ -417,7 +417,7 @@ class AgentConfigService { revision: config.revision, ...(config.monitoring_enabled && config.monitoring_enabled.length > 0 ? { - settings: { + agent: { monitoring: { use_output: defaultOutput.name, enabled: true, @@ -427,7 +427,7 @@ class AgentConfigService { }, } : { - settings: { + agent: { monitoring: { enabled: false, logs: false, metrics: false }, }, }), diff --git a/x-pack/test/security_solution_endpoint/apps/endpoint/policy_details.ts b/x-pack/test/security_solution_endpoint/apps/endpoint/policy_details.ts index 7207bb3fc37b3..9a0a819f68b62 100644 --- a/x-pack/test/security_solution_endpoint/apps/endpoint/policy_details.ts +++ b/x-pack/test/security_solution_endpoint/apps/endpoint/policy_details.ts @@ -195,7 +195,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { }, }, revision: 3, - settings: { + agent: { monitoring: { enabled: false, logs: false, From b3c6ce9aea01047c85b990a0349a27b89570ac6d Mon Sep 17 00:00:00 2001 From: Jonathan Budzenski Date: Mon, 13 Jul 2020 14:47:16 -0500 Subject: [PATCH 25/66] rm index: false from binary mappings (#71343) * rm index: false from binary mappings * test against unverified snapshot * two more * Mapping adjustments * Revert "Mapping adjustments" This reverts commit 52d68dcd6d9f63f847f393de242e184b3d7704c8. * Revert "test against unverified snapshot" This reverts commit 4284ac37f100f4a928ed436b7a09bd53b8d60699. Co-authored-by: Madison Caldwell --- .../ingest_manager/server/saved_objects/index.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/x-pack/plugins/ingest_manager/server/saved_objects/index.ts b/x-pack/plugins/ingest_manager/server/saved_objects/index.ts index 6c360fdeda460..4c58ac57a54a2 100644 --- a/x-pack/plugins/ingest_manager/server/saved_objects/index.ts +++ b/x-pack/plugins/ingest_manager/server/saved_objects/index.ts @@ -67,7 +67,7 @@ const savedObjectTypes: { [key: string]: SavedObjectsType } = { last_checkin_status: { type: 'keyword' }, config_revision: { type: 'integer' }, default_api_key_id: { type: 'keyword' }, - default_api_key: { type: 'binary', index: false }, + default_api_key: { type: 'binary' }, updated_at: { type: 'date' }, current_error_events: { type: 'text', index: false }, packages: { type: 'keyword' }, @@ -85,7 +85,7 @@ const savedObjectTypes: { [key: string]: SavedObjectsType } = { properties: { agent_id: { type: 'keyword' }, type: { type: 'keyword' }, - data: { type: 'binary', index: false }, + data: { type: 'binary' }, sent_at: { type: 'date' }, created_at: { type: 'date' }, }, @@ -146,7 +146,7 @@ const savedObjectTypes: { [key: string]: SavedObjectsType } = { properties: { name: { type: 'keyword' }, type: { type: 'keyword' }, - api_key: { type: 'binary', index: false }, + api_key: { type: 'binary' }, api_key_id: { type: 'keyword' }, config_id: { type: 'keyword' }, created_at: { type: 'date' }, @@ -170,8 +170,8 @@ const savedObjectTypes: { [key: string]: SavedObjectsType } = { is_default: { type: 'boolean' }, hosts: { type: 'keyword' }, ca_sha256: { type: 'keyword', index: false }, - fleet_enroll_username: { type: 'binary', index: false }, - fleet_enroll_password: { type: 'binary', index: false }, + fleet_enroll_username: { type: 'binary' }, + fleet_enroll_password: { type: 'binary' }, config: { type: 'flattened' }, }, }, From 1d23a48f98a49eaed359caca5aec43a0b867a2d0 Mon Sep 17 00:00:00 2001 From: Jen Huang Date: Mon, 13 Jul 2020 12:56:57 -0700 Subject: [PATCH 26/66] Fix create agent config flyout being covered by bottom bar (#71502) --- .../step_select_config.tsx | 1 + .../list_page/components/create_config.tsx | 17 +++++++++++++---- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_package_config_page/step_select_config.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_package_config_page/step_select_config.tsx index d3120f9051f45..91c80b7eee4c8 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_package_config_page/step_select_config.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_package_config_page/step_select_config.tsx @@ -148,6 +148,7 @@ export const StepSelectConfig: React.FunctionComponent<{ setSelectedConfigId(newAgentConfig.id); } }} + ownFocus={true} /> ) : null} diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/list_page/components/create_config.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/list_page/components/create_config.tsx index 795c46ec282c5..37fce340da6ea 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/list_page/components/create_config.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/list_page/components/create_config.tsx @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ import React, { useState } from 'react'; +import styled from 'styled-components'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { @@ -17,16 +18,24 @@ import { EuiButtonEmpty, EuiButton, EuiText, + EuiFlyoutProps, } from '@elastic/eui'; import { NewAgentConfig, AgentConfig } from '../../../../types'; import { useCapabilities, useCore, sendCreateAgentConfig } from '../../../../hooks'; import { AgentConfigForm, agentConfigFormValidation } from '../../components'; -interface Props { +const FlyoutWithHigherZIndex = styled(EuiFlyout)` + z-index: ${(props) => props.theme.eui.euiZLevel5}; +`; + +interface Props extends EuiFlyoutProps { onClose: (createdAgentConfig?: AgentConfig) => void; } -export const CreateAgentConfigFlyout: React.FunctionComponent = ({ onClose }) => { +export const CreateAgentConfigFlyout: React.FunctionComponent = ({ + onClose, + ...restOfProps +}) => { const { notifications } = useCore(); const hasWriteCapabilites = useCapabilities().write; const [agentConfig, setAgentConfig] = useState({ @@ -147,10 +156,10 @@ export const CreateAgentConfigFlyout: React.FunctionComponent = ({ onClos ); return ( - + {header} {body} {footer} - + ); }; From 8d86a74ba8319420131e1d5187f616b90eeca233 Mon Sep 17 00:00:00 2001 From: spalger Date: Mon, 13 Jul 2020 13:17:42 -0700 Subject: [PATCH 27/66] Revert "Bump lodash package version (#71392)" This reverts commit 60032b81ca698ac18daef5c7fcb210453e1377a2. --- package.json | 1 - yarn.lock | 13 +++++++++---- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 7ab6bfb91a376..55a099b4e5c0c 100644 --- a/package.json +++ b/package.json @@ -87,7 +87,6 @@ "**/@types/hoist-non-react-statics": "^3.3.1", "**/@types/chai": "^4.2.11", "**/cypress/@types/lodash": "^4.14.155", - "**/cypress/lodash": "^4.15.19", "**/typescript": "3.9.5", "**/graphql-toolkit/lodash": "^4.17.15", "**/hoist-non-react-statics": "^3.3.2", diff --git a/yarn.lock b/yarn.lock index 290713d32d333..bd6c2031d0ec8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -20916,16 +20916,21 @@ lodash.uniq@^4.5.0: resolved "https://registry.yarnpkg.com/lodash.uniq/-/lodash.uniq-4.5.0.tgz#d0225373aeb652adc1bc82e4945339a842754773" integrity sha1-0CJTc662Uq3BvILklFM5qEJ1R3M= -lodash@4.17.11, lodash@4.17.15, lodash@>4.17.4, lodash@^4, lodash@^4.0.0, lodash@^4.0.1, lodash@^4.10.0, lodash@^4.11.1, lodash@^4.14.0, lodash@^4.15.0, lodash@^4.15.19, lodash@^4.17.0, lodash@^4.17.10, lodash@^4.17.11, lodash@^4.17.12, lodash@^4.17.13, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.16, lodash@^4.17.2, lodash@^4.17.4, lodash@^4.2.0, lodash@^4.2.1, lodash@^4.3.0, lodash@^4.6.1, lodash@~4.17.10, lodash@~4.17.15, lodash@~4.17.5: - version "4.17.19" - resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.19.tgz#e48ddedbe30b3321783c5b4301fbd353bc1e4a4b" - integrity sha512-JNvd8XER9GQX0v2qJgsaN/mzFCNA5BRe/j8JN9d+tWyGLSodKQHKFicdwNYzWwI3wjRnaKPsGj1XkBjx/F96DQ== +lodash@4.17.11, lodash@4.17.15, lodash@>4.17.4, lodash@^4, lodash@^4.0.0, lodash@^4.0.1, lodash@^4.10.0, lodash@^4.11.1, lodash@^4.14.0, lodash@^4.15.0, lodash@^4.17.0, lodash@^4.17.10, lodash@^4.17.11, lodash@^4.17.12, lodash@^4.17.13, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.2, lodash@^4.17.4, lodash@^4.2.0, lodash@^4.2.1, lodash@^4.3.0, lodash@^4.6.1, lodash@~4.17.10, lodash@~4.17.15, lodash@~4.17.5: + version "4.17.15" + resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.15.tgz#b447f6670a0455bbfeedd11392eff330ea097548" + integrity sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A== lodash@^3.10.1: version "3.10.1" resolved "https://registry.yarnpkg.com/lodash/-/lodash-3.10.1.tgz#5bf45e8e49ba4189e17d482789dfd15bd140b7b6" integrity sha1-W/Rejkm6QYnhfUgnid/RW9FAt7Y= +lodash@^4.17.16: + version "4.17.19" + resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.19.tgz#e48ddedbe30b3321783c5b4301fbd353bc1e4a4b" + integrity sha512-JNvd8XER9GQX0v2qJgsaN/mzFCNA5BRe/j8JN9d+tWyGLSodKQHKFicdwNYzWwI3wjRnaKPsGj1XkBjx/F96DQ== + "lodash@npm:@elastic/lodash@3.10.1-kibana4": version "3.10.1-kibana4" resolved "https://registry.yarnpkg.com/@elastic/lodash/-/lodash-3.10.1-kibana4.tgz#d491228fd659b4a1b0dfa08ba9c67a4979b9746d" From d7a679ba8c9f9863ae3e6d7f5a6e7fe427ba3f9b Mon Sep 17 00:00:00 2001 From: Aaron Caldwell Date: Mon, 13 Jul 2020 14:27:19 -0600 Subject: [PATCH 28/66] [Maps] Fix proxy handling issues (#71182) --- src/plugins/maps_legacy/server/index.ts | 33 +++++-- x-pack/plugins/maps/public/meta.test.js | 5 + x-pack/plugins/maps/public/meta.ts | 17 ++-- x-pack/plugins/maps/server/plugin.ts | 7 +- x-pack/plugins/maps/server/routes.js | 126 ++++++++++++------------ 5 files changed, 108 insertions(+), 80 deletions(-) diff --git a/src/plugins/maps_legacy/server/index.ts b/src/plugins/maps_legacy/server/index.ts index 18f58189fc607..5da3ce1a84408 100644 --- a/src/plugins/maps_legacy/server/index.ts +++ b/src/plugins/maps_legacy/server/index.ts @@ -17,8 +17,9 @@ * under the License. */ -import { PluginConfigDescriptor } from 'kibana/server'; -import { PluginInitializerContext } from 'kibana/public'; +import { Plugin, PluginConfigDescriptor } from 'kibana/server'; +import { PluginInitializerContext } from 'src/core/server'; +import { Observable } from 'rxjs'; import { configSchema, ConfigSchema } from '../config'; export const config: PluginConfigDescriptor = { @@ -37,13 +38,27 @@ export const config: PluginConfigDescriptor = { schema: configSchema, }; -export const plugin = (initializerContext: PluginInitializerContext) => ({ - setup() { +export interface MapsLegacyPluginSetup { + config$: Observable; +} + +export class MapsLegacyPlugin implements Plugin { + readonly _initializerContext: PluginInitializerContext; + + constructor(initializerContext: PluginInitializerContext) { + this._initializerContext = initializerContext; + } + + public setup() { // @ts-ignore - const config$ = initializerContext.config.create(); + const config$ = this._initializerContext.config.create(); return { - config: config$, + config$, }; - }, - start() {}, -}); + } + + public start() {} +} + +export const plugin = (initializerContext: PluginInitializerContext) => + new MapsLegacyPlugin(initializerContext); diff --git a/x-pack/plugins/maps/public/meta.test.js b/x-pack/plugins/maps/public/meta.test.js index 5c04a57c00058..3486bf003aee0 100644 --- a/x-pack/plugins/maps/public/meta.test.js +++ b/x-pack/plugins/maps/public/meta.test.js @@ -36,6 +36,11 @@ describe('getGlyphUrl', () => { beforeAll(() => { require('./kibana_services').getIsEmsEnabled = () => true; require('./kibana_services').getEmsFontLibraryUrl = () => EMS_FONTS_URL_MOCK; + require('./kibana_services').getHttp = () => ({ + basePath: { + prepend: (url) => url, // No need to actually prepend a dev basepath for test + }, + }); }); describe('EMS proxy enabled', () => { diff --git a/x-pack/plugins/maps/public/meta.ts b/x-pack/plugins/maps/public/meta.ts index 54c5eac7fe1b0..34c5f004fd7f3 100644 --- a/x-pack/plugins/maps/public/meta.ts +++ b/x-pack/plugins/maps/public/meta.ts @@ -30,8 +30,6 @@ import { getKibanaVersion, } from './kibana_services'; -const GIS_API_RELATIVE = `../${GIS_API_PATH}`; - export function getKibanaRegionList(): unknown[] { return getRegionmapLayers(); } @@ -69,10 +67,14 @@ export function getEMSClient(): EMSClient { const proxyElasticMapsServiceInMaps = getProxyElasticMapsServiceInMaps(); const proxyPath = ''; const tileApiUrl = proxyElasticMapsServiceInMaps - ? relativeToAbsolute(`${GIS_API_RELATIVE}/${EMS_TILES_CATALOGUE_PATH}`) + ? relativeToAbsolute( + getHttp().basePath.prepend(`/${GIS_API_PATH}/${EMS_TILES_CATALOGUE_PATH}`) + ) : getEmsTileApiUrl(); const fileApiUrl = proxyElasticMapsServiceInMaps - ? relativeToAbsolute(`${GIS_API_RELATIVE}/${EMS_FILES_CATALOGUE_PATH}`) + ? relativeToAbsolute( + getHttp().basePath.prepend(`/${GIS_API_PATH}/${EMS_FILES_CATALOGUE_PATH}`) + ) : getEmsFileApiUrl(); emsClient = new EMSClient({ @@ -101,8 +103,11 @@ export function getGlyphUrl(): string { return getHttp().basePath.prepend(`/${FONTS_API_PATH}/{fontstack}/{range}`); } return getProxyElasticMapsServiceInMaps() - ? relativeToAbsolute(`../${GIS_API_PATH}/${EMS_TILES_CATALOGUE_PATH}/${EMS_GLYPHS_PATH}`) + - `/{fontstack}/{range}` + ? relativeToAbsolute( + getHttp().basePath.prepend( + `/${GIS_API_PATH}/${EMS_TILES_CATALOGUE_PATH}/${EMS_GLYPHS_PATH}` + ) + ) + `/{fontstack}/{range}` : getEmsFontLibraryUrl(); } diff --git a/x-pack/plugins/maps/server/plugin.ts b/x-pack/plugins/maps/server/plugin.ts index dbcce50ac2b9a..7d091099c1aaa 100644 --- a/x-pack/plugins/maps/server/plugin.ts +++ b/x-pack/plugins/maps/server/plugin.ts @@ -26,12 +26,14 @@ import { initRoutes } from './routes'; import { ILicense } from '../../licensing/common/types'; import { LicensingPluginSetup } from '../../licensing/server'; import { HomeServerPluginSetup } from '../../../../src/plugins/home/server'; +import { MapsLegacyPluginSetup } from '../../../../src/plugins/maps_legacy/server'; interface SetupDeps { features: FeaturesPluginSetupContract; usageCollection: UsageCollectionSetup; home: HomeServerPluginSetup; licensing: LicensingPluginSetup; + mapsLegacy: MapsLegacyPluginSetup; } export class MapsPlugin implements Plugin { @@ -129,9 +131,10 @@ export class MapsPlugin implements Plugin { // @ts-ignore async setup(core: CoreSetup, plugins: SetupDeps) { - const { usageCollection, home, licensing, features } = plugins; + const { usageCollection, home, licensing, features, mapsLegacy } = plugins; // @ts-ignore const config$ = this._initializerContext.config.create(); + const mapsLegacyConfig = await mapsLegacy.config$.pipe(take(1)).toPromise(); const currentConfig = await config$.pipe(take(1)).toPromise(); // @ts-ignore @@ -150,7 +153,7 @@ export class MapsPlugin implements Plugin { initRoutes( core.http.createRouter(), license.uid, - currentConfig, + mapsLegacyConfig, this.kibanaVersion, this._logger ); diff --git a/x-pack/plugins/maps/server/routes.js b/x-pack/plugins/maps/server/routes.js index ad66712eb3ad6..1876c0de19c56 100644 --- a/x-pack/plugins/maps/server/routes.js +++ b/x-pack/plugins/maps/server/routes.js @@ -73,9 +73,10 @@ export function initRoutes(router, licenseUid, mapConfig, kbnVersion, logger) { validate: { query: schema.object({ id: schema.maybe(schema.string()), - x: schema.maybe(schema.number()), - y: schema.maybe(schema.number()), - z: schema.maybe(schema.number()), + elastic_tile_service_tos: schema.maybe(schema.string()), + my_app_name: schema.maybe(schema.string()), + my_app_version: schema.maybe(schema.string()), + license: schema.maybe(schema.string()), }), }, }, @@ -111,9 +112,9 @@ export function initRoutes(router, licenseUid, mapConfig, kbnVersion, logger) { path: `${ROOT}/${EMS_TILES_API_PATH}/${EMS_TILES_RASTER_TILE_PATH}`, validate: false, }, - async (context, request, { ok, badRequest }) => { + async (context, request, response) => { if (!checkEMSProxyEnabled()) { - return badRequest('map.proxyElasticMapsServiceInMaps disabled'); + return response.badRequest('map.proxyElasticMapsServiceInMaps disabled'); } if ( @@ -138,7 +139,7 @@ export function initRoutes(router, licenseUid, mapConfig, kbnVersion, logger) { .replace('{y}', request.query.y) .replace('{z}', request.query.z); - return await proxyResource({ url, contentType: 'image/png' }, { ok, badRequest }); + return await proxyResource({ url, contentType: 'image/png' }, response); } ); @@ -203,7 +204,9 @@ export function initRoutes(router, licenseUid, mapConfig, kbnVersion, logger) { }); //rewrite return ok({ - body: layers, + body: { + layers, + }, }); } ); @@ -293,7 +296,11 @@ export function initRoutes(router, licenseUid, mapConfig, kbnVersion, logger) { path: `${ROOT}/${EMS_TILES_API_PATH}/${EMS_TILES_VECTOR_STYLE_PATH}`, validate: { query: schema.object({ - id: schema.maybe(schema.string()), + id: schema.string(), + elastic_tile_service_tos: schema.maybe(schema.string()), + my_app_name: schema.maybe(schema.string()), + my_app_version: schema.maybe(schema.string()), + license: schema.maybe(schema.string()), }), }, }, @@ -302,11 +309,6 @@ export function initRoutes(router, licenseUid, mapConfig, kbnVersion, logger) { return badRequest('map.proxyElasticMapsServiceInMaps disabled'); } - if (!request.query.id) { - logger.warn('Must supply id parameter to retrieve EMS vector style'); - return null; - } - const tmsServices = await emsClient.getTMSServices(); const tmsService = tmsServices.find((layer) => layer.getId() === request.query.id); if (!tmsService) { @@ -342,8 +344,12 @@ export function initRoutes(router, licenseUid, mapConfig, kbnVersion, logger) { path: `${ROOT}/${EMS_TILES_API_PATH}/${EMS_TILES_VECTOR_SOURCE_PATH}`, validate: { query: schema.object({ - id: schema.maybe(schema.string()), + id: schema.string(), sourceId: schema.maybe(schema.string()), + elastic_tile_service_tos: schema.maybe(schema.string()), + my_app_name: schema.maybe(schema.string()), + my_app_version: schema.maybe(schema.string()), + license: schema.maybe(schema.string()), }), }, }, @@ -352,11 +358,6 @@ export function initRoutes(router, licenseUid, mapConfig, kbnVersion, logger) { return badRequest('map.proxyElasticMapsServiceInMaps disabled'); } - if (!request.query.id || !request.query.sourceId) { - logger.warn('Must supply id and sourceId parameter to retrieve EMS vector source'); - return null; - } - const tmsServices = await emsClient.getTMSServices(); const tmsService = tmsServices.find((layer) => layer.getId() === request.query.id); if (!tmsService) { @@ -381,28 +382,21 @@ export function initRoutes(router, licenseUid, mapConfig, kbnVersion, logger) { path: `${ROOT}/${EMS_TILES_API_PATH}/${EMS_TILES_VECTOR_TILE_PATH}`, validate: { query: schema.object({ - id: schema.maybe(schema.string()), - sourceId: schema.maybe(schema.string()), - x: schema.maybe(schema.number()), - y: schema.maybe(schema.number()), - z: schema.maybe(schema.number()), + id: schema.string(), + sourceId: schema.string(), + x: schema.number(), + y: schema.number(), + z: schema.number(), + elastic_tile_service_tos: schema.maybe(schema.string()), + my_app_name: schema.maybe(schema.string()), + my_app_version: schema.maybe(schema.string()), + license: schema.maybe(schema.string()), }), }, }, - async (context, request, { ok, badRequest }) => { + async (context, request, response) => { if (!checkEMSProxyEnabled()) { - return badRequest('map.proxyElasticMapsServiceInMaps disabled'); - } - - if ( - !request.query.id || - !request.query.sourceId || - typeof parseInt(request.query.x, 10) !== 'number' || - typeof parseInt(request.query.y, 10) !== 'number' || - typeof parseInt(request.query.z, 10) !== 'number' - ) { - logger.warn('Must supply id/sourceId/x/y/z parameters to retrieve EMS vector tile'); - return null; + return response.badRequest('map.proxyElasticMapsServiceInMaps disabled'); } const tmsServices = await emsClient.getTMSServices(); @@ -417,24 +411,29 @@ export function initRoutes(router, licenseUid, mapConfig, kbnVersion, logger) { .replace('{y}', request.query.y) .replace('{z}', request.query.z); - return await proxyResource({ url }, { ok, badRequest }); + return await proxyResource({ url }, response); } ); router.get( { path: `${ROOT}/${EMS_TILES_API_PATH}/${EMS_GLYPHS_PATH}/{fontstack}/{range}`, - validate: false, + validate: { + params: schema.object({ + fontstack: schema.string(), + range: schema.string(), + }), + }, }, - async (context, request, { ok, badRequest }) => { + async (context, request, response) => { if (!checkEMSProxyEnabled()) { - return badRequest('map.proxyElasticMapsServiceInMaps disabled'); + return response.badRequest('map.proxyElasticMapsServiceInMaps disabled'); } const url = mapConfig.emsFontLibraryUrl .replace('{fontstack}', request.params.fontstack) .replace('{range}', request.params.range); - return await proxyResource({ url }, { ok, badRequest }); + return await proxyResource({ url }, response); } ); @@ -442,19 +441,22 @@ export function initRoutes(router, licenseUid, mapConfig, kbnVersion, logger) { { path: `${ROOT}/${EMS_TILES_API_PATH}/${EMS_SPRITES_PATH}/{id}/sprite{scaling?}.{extension}`, validate: { + query: schema.object({ + elastic_tile_service_tos: schema.maybe(schema.string()), + my_app_name: schema.maybe(schema.string()), + my_app_version: schema.maybe(schema.string()), + license: schema.maybe(schema.string()), + }), params: schema.object({ id: schema.string(), + scaling: schema.maybe(schema.string()), + extension: schema.string(), }), }, }, - async (context, request, { ok, badRequest }) => { + async (context, request, response) => { if (!checkEMSProxyEnabled()) { - return badRequest('map.proxyElasticMapsServiceInMaps disabled'); - } - - if (!request.params.id) { - logger.warn('Must supply id parameter to retrieve EMS vector source sprite'); - return null; + return response.badRequest('map.proxyElasticMapsServiceInMaps disabled'); } const tmsServices = await emsClient.getTMSServices(); @@ -479,7 +481,7 @@ export function initRoutes(router, licenseUid, mapConfig, kbnVersion, logger) { url: proxyPathUrl, contentType: request.params.extension === 'png' ? 'image/png' : '', }, - { ok, badRequest } + response ); } ); @@ -570,25 +572,23 @@ export function initRoutes(router, licenseUid, mapConfig, kbnVersion, logger) { return proxyEMSInMaps; } - async function proxyResource({ url, contentType }, { ok, badRequest }) { + async function proxyResource({ url, contentType }, response) { try { const resource = await fetch(url); const arrayBuffer = await resource.arrayBuffer(); - const bufferedResponse = Buffer.from(arrayBuffer); - const headers = { - 'Content-Disposition': 'inline', - }; - if (contentType) { - headers['Content-type'] = contentType; - } - - return ok({ - body: bufferedResponse, - headers, + const buffer = Buffer.from(arrayBuffer); + + return response.ok({ + body: buffer, + headers: { + 'content-disposition': 'inline', + 'content-length': buffer.length, + ...(contentType ? { 'Content-type': contentType } : {}), + }, }); } catch (e) { logger.warn(`Cannot connect to EMS for resource, error: ${e.message}`); - return badRequest(`Cannot connect to EMS`); + return response.badRequest(`Cannot connect to EMS`); } } } From 85d42535ea0a30f8a254b284669723c2cfb414ab Mon Sep 17 00:00:00 2001 From: Ross Wolf <31489089+rw-access@users.noreply.github.com> Date: Mon, 13 Jul 2020 14:44:14 -0600 Subject: [PATCH 29/66] [SIEM][Detection Rules] Add 7.9 rules (#71332) --- NOTICE.txt | 96 +++-- ...t.json => apm_403_response_to_a_post.json} | 6 +- ... apm_405_response_method_not_allowed.json} | 6 +- ...er_agent.json => apm_null_user_agent.json} | 6 +- ..._agent.json => apm_sqlmap_user_agent.json} | 6 +- ...collection_cloudtrail_logging_created.json | 48 +++ ..._control_certutil_network_connection.json} | 8 +- ...control_dns_directly_to_the_internet.json} | 11 +- ...er_protocol_activity_to_the_internet.json} | 11 +- ...at_protocol_activity_to_the_internet.json} | 11 +- ..._control_nat_traversal_port_activity.json} | 11 +- ...command_and_control_port_26_activity.json} | 11 +- ...l_port_8000_activity_to_the_internet.json} | 11 +- ...to_point_tunneling_protocol_activity.json} | 11 +- ..._proxy_port_activity_to_the_internet.json} | 13 +- ...e_desktop_protocol_from_the_internet.json} | 11 +- ...and_and_control_smtp_to_the_internet.json} | 11 +- ...server_port_activity_to_the_internet.json} | 11 +- ...l_ssh_secure_shell_from_the_internet.json} | 11 +- ...rol_ssh_secure_shell_to_the_internet.json} | 11 +- ...and_and_control_telnet_port_activity.json} | 11 +- ...control_tor_activity_to_the_internet.json} | 11 +- ..._network_computing_from_the_internet.json} | 11 +- ...al_network_computing_to_the_internet.json} | 11 +- ...l_access_attempted_bypass_of_okta_mfa.json | 43 ++ ...al_access_credential_dumping_msbuild.json} | 8 +- ...ial_access_iam_user_addition_to_group.json | 62 +++ ..._access_secretsmanager_getsecretvalue.json | 49 +++ ...> credential_access_tcpdump_activity.json} | 8 +- ...en_file_attribute_with_via_attribexe.json} | 8 +- ...empt_to_disable_iptables_or_firewall.json} | 8 +- ...on_attempt_to_disable_syslog_service.json} | 8 +- ...base32_encoding_or_decoding_activity.json} | 8 +- ...base64_encoding_or_decoding_activity.json} | 8 +- ..._evasion_clearing_windows_event_logs.json} | 8 +- ...se_evasion_cloudtrail_logging_deleted.json | 48 +++ ..._evasion_cloudtrail_logging_suspended.json | 48 +++ ...nse_evasion_cloudwatch_alarm_deletion.json | 48 +++ ..._evasion_config_service_rule_deletion.json | 48 +++ ...vasion_configuration_recorder_stopped.json | 48 +++ ...son => defense_evasion_cve_2020_0601.json} | 6 +- ...elete_volume_usn_journal_with_fsutil.json} | 8 +- ...eleting_backup_catalogs_with_wbadmin.json} | 8 +- ...deletion_of_bash_command_line_history.json | 39 ++ ...ense_evasion_disable_selinux_attempt.json} | 8 +- ...le_windows_firewall_rules_with_netsh.json} | 8 +- ...defense_evasion_ec2_flow_log_deletion.json | 48 +++ ...ense_evasion_ec2_network_acl_deletion.json | 50 +++ ...oding_or_decoding_files_via_certutil.json} | 8 +- ...cution_msbuild_started_by_office_app.json} | 8 +- ..._execution_msbuild_started_by_script.json} | 8 +- ...on_msbuild_started_by_system_process.json} | 8 +- ...on_execution_msbuild_started_renamed.json} | 8 +- ...ution_msbuild_started_unusal_process.json} | 8 +- ...tion_via_trusted_developer_utilities.json} | 6 +- ...ense_evasion_file_deletion_via_shred.json} | 8 +- ...efense_evasion_file_mod_writable_dir.json} | 8 +- ...e_evasion_guardduty_detector_deletion.json | 48 +++ ...on_hex_encoding_or_decoding_activity.json} | 8 +- .../defense_evasion_hidden_file_dir_tmp.json | 58 +++ ...=> defense_evasion_injection_msbuild.json} | 6 +- ...efense_evasion_kernel_module_removal.json} | 8 +- ...sc_lolbin_connecting_to_the_internet.json} | 10 +- ..._evasion_modification_of_boot_config.json} | 8 +- ...sion_s3_bucket_configuration_deletion.json | 51 +++ ...> defense_evasion_via_filter_manager.json} | 6 +- ...me_shadow_copy_deletion_via_vssadmin.json} | 8 +- ...volume_shadow_copy_deletion_via_wmic.json} | 8 +- .../defense_evasion_waf_acl_deletion.json | 48 +++ ...asion_waf_rule_or_rule_group_deletion.json | 48 +++ ... discovery_kernel_module_enumeration.json} | 8 +- ...discovery_net_command_system_account.json} | 8 +- ...ocess_discovery_via_tasklist_command.json} | 6 +- ...overy_virtual_machine_fingerprinting.json} | 8 +- ...=> discovery_whoami_command_activity.json} | 6 +- ...nd.json => discovery_whoami_commmand.json} | 8 +- .../prepackaged_rules/elastic_endpoint.json | 60 +++ ...endpoint_adversary_behavior_detected.json} | 6 +- ...on => endpoint_cred_dumping_detected.json} | 6 +- ...n => endpoint_cred_dumping_prevented.json} | 6 +- ... endpoint_cred_manipulation_detected.json} | 6 +- ...endpoint_cred_manipulation_prevented.json} | 6 +- ...ed.json => endpoint_exploit_detected.json} | 6 +- ...d.json => endpoint_exploit_prevented.json} | 6 +- ...ed.json => endpoint_malware_detected.json} | 6 +- ...d.json => endpoint_malware_prevented.json} | 6 +- ...> endpoint_permission_theft_detected.json} | 6 +- ... endpoint_permission_theft_prevented.json} | 6 +- ... endpoint_process_injection_detected.json} | 6 +- ...endpoint_process_injection_prevented.json} | 6 +- ...json => endpoint_ransomware_detected.json} | 6 +- ...son => endpoint_ransomware_prevented.json} | 6 +- ...ql_suspicious_ms_office_child_process.json | 35 -- ...l_suspicious_ms_outlook_child_process.json | 35 -- .../eql_unusual_parentchild_relationship.json | 35 -- ...nd_prompt_connecting_to_the_internet.json} | 8 +- ..._command_shell_started_by_powershell.json} | 8 +- ...ion_command_shell_started_by_svchost.json} | 8 +- ...e_program_connecting_to_the_internet.json} | 8 +- ... => execution_local_service_commands.json} | 8 +- ...n_msbuild_making_network_connections.json} | 8 +- ...ion_mshta_making_network_connections.json} | 8 +- ...work.json => execution_msxsl_network.json} | 8 +- ...ell.json => execution_perl_tty_shell.json} | 8 +- ...tion_psexec_lateral_movement_command.json} | 8 +- ...l.json => execution_python_tty_shell.json} | 8 +- ...r_program_connecting_to_the_internet.json} | 10 +- ...xecution_script_executing_powershell.json} | 8 +- ...on_suspicious_ms_office_child_process.json | 39 ++ ...n_suspicious_ms_outlook_child_process.json | 39 ++ .../execution_suspicious_pdf_reader.json | 39 ++ ...sual_network_connection_via_rundll32.json} | 8 +- ...n_unusual_process_network_connection.json} | 8 +- ... => execution_via_compiled_html_file.json} | 6 +- ... => execution_via_net_com_assemblies.json} | 8 +- .../execution_via_system_manager.json | 62 +++ ...ltration_ec2_snapshot_change_activity.json | 48 +++ .../prepackaged_rules/external_alerts.json | 54 +++ ...pact_attempt_to_revoke_okta_api_token.json | 46 ++ .../impact_cloudtrail_logging_updated.json | 63 +++ .../impact_cloudwatch_log_group_deletion.json | 63 +++ ...impact_cloudwatch_log_stream_deletion.json | 63 +++ .../impact_ec2_disable_ebs_encryption.json | 49 +++ .../impact_iam_deactivate_mfa_device.json | 48 +++ .../impact_iam_group_deletion.json | 48 +++ .../impact_possible_okta_dos_attack.json | 48 +++ .../impact_rds_cluster_deletion.json | 50 +++ .../impact_rds_instance_cluster_stoppage.json | 50 +++ .../rules/prepackaged_rules/index.ts | 399 +++++++++++------- .../initial_access_console_login_root.json | 62 +++ .../initial_access_password_recovery.json | 47 +++ ...ote_desktop_protocol_to_the_internet.json} | 11 +- ...ote_procedure_call_from_the_internet.json} | 11 +- ...emote_procedure_call_to_the_internet.json} | 11 +- ...ile_sharing_activity_to_the_internet.json} | 11 +- ...icious_activity_reported_by_okta_user.json | 91 ++++ ...ement_direct_outbound_smb_connection.json} | 8 +- ...ent_telnet_network_activity_external.json} | 8 +- ...ent_telnet_network_activity_internal.json} | 8 +- .../linux_hping_activity.json | 8 +- .../linux_iodine_activity.json | 8 +- .../linux_mknod_activity.json | 8 +- .../linux_netcat_network_connection.json | 8 +- .../linux_nmap_activity.json | 8 +- .../linux_nping_activity.json | 8 +- ...nux_process_started_in_temp_directory.json | 8 +- .../linux_socat_activity.json | 8 +- .../linux_strace_activity.json | 8 +- ... ml_linux_anomalous_network_activity.json} | 8 +- ...inux_anomalous_network_port_activity.json} | 8 +- ...> ml_linux_anomalous_network_service.json} | 8 +- ...linux_anomalous_network_url_activity.json} | 8 +- ...ml_linux_anomalous_process_all_hosts.json} | 8 +- ...json => ml_linux_anomalous_user_name.json} | 8 +- ....json => ml_packetbeat_dns_tunneling.json} | 8 +- ...n => ml_packetbeat_rare_dns_question.json} | 8 +- ... => ml_packetbeat_rare_server_domain.json} | 8 +- ...urls.json => ml_packetbeat_rare_urls.json} | 8 +- ...son => ml_packetbeat_rare_user_agent.json} | 8 +- ...son => ml_rare_process_by_host_linux.json} | 8 +- ...n => ml_rare_process_by_host_windows.json} | 8 +- ...json => ml_suspicious_login_activity.json} | 8 +- ...l_windows_anomalous_network_activity.json} | 8 +- ...> ml_windows_anomalous_path_activity.json} | 8 +- ..._windows_anomalous_process_all_hosts.json} | 8 +- ...l_windows_anomalous_process_creation.json} | 8 +- ....json => ml_windows_anomalous_script.json} | 8 +- ...json => ml_windows_anomalous_service.json} | 8 +- ...on => ml_windows_anomalous_user_name.json} | 8 +- ... => ml_windows_rare_user_runas_event.json} | 8 +- ...indows_rare_user_type10_remote_login.json} | 8 +- .../rules/prepackaged_rules/notice.ts | 42 +- ...a_attempt_to_deactivate_okta_mfa_rule.json | 29 ++ .../okta_attempt_to_delete_okta_policy.json | 29 ++ .../okta_attempt_to_modify_okta_mfa_rule.json | 29 ++ ...a_attempt_to_modify_okta_network_zone.json | 29 ++ .../okta_attempt_to_modify_okta_policy.json | 29 ++ ..._or_delete_application_sign_on_policy.json | 29 ++ ...threat_detected_by_okta_threatinsight.json | 26 ++ ...tor_privileges_assigned_to_okta_group.json | 46 ++ ...persistence_adobe_hijack_persistence.json} | 8 +- ...ence_attempt_to_create_okta_api_token.json | 46 ++ ..._deactivate_mfa_for_okta_user_account.json | 46 ++ ...nce_attempt_to_deactivate_okta_policy.json | 46 ++ ...set_mfa_factors_for_okta_user_account.json | 46 ++ .../persistence_ec2_network_acl_creation.json | 50 +++ .../persistence_iam_group_creation.json | 48 +++ ...> persistence_kernel_module_activity.json} | 10 +- ...stence_local_scheduled_task_commands.json} | 8 +- ...scalation_via_accessibility_features.json} | 6 +- .../persistence_rds_cluster_creation.json | 65 +++ ...istence_shell_activity_by_web_server.json} | 10 +- ...rsistence_system_shells_via_services.json} | 8 +- ...=> persistence_user_account_creation.json} | 8 +- ...persistence_via_application_shimming.json} | 6 +- ...ege_escalation_root_login_without_mfa.json | 47 +++ ..._escalation_setgid_bit_set_via_chmod.json} | 8 +- ..._escalation_setuid_bit_set_via_chmod.json} | 8 +- ...rivilege_escalation_sudoers_file_mod.json} | 8 +- ...e_escalation_uac_bypass_event_viewer.json} | 8 +- ...tion_unusual_parentchild_relationship.json | 39 ++ ...ege_escalation_updateassumerolepolicy.json | 47 +++ .../windows_suspicious_pdf_reader.json | 35 -- 203 files changed, 3845 insertions(+), 604 deletions(-) rename x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/{403_response_to_a_post.json => apm_403_response_to_a_post.json} (92%) rename x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/{405_response_method_not_allowed.json => apm_405_response_method_not_allowed.json} (91%) rename x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/{null_user_agent.json => apm_null_user_agent.json} (94%) rename x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/{sqlmap_user_agent.json => apm_sqlmap_user_agent.json} (92%) create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/collection_cloudtrail_logging_created.json rename x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/{windows_certutil_network_connection.json => command_and_control_certutil_network_connection.json} (77%) rename x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/{network_dns_directly_to_the_internet.json => command_and_control_dns_directly_to_the_internet.json} (78%) rename x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/{network_ftp_file_transfer_protocol_activity_to_the_internet.json => command_and_control_ftp_file_transfer_protocol_activity_to_the_internet.json} (83%) rename x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/{network_irc_internet_relay_chat_protocol_activity_to_the_internet.json => command_and_control_irc_internet_relay_chat_protocol_activity_to_the_internet.json} (83%) rename x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/{network_nat_traversal_port_activity.json => command_and_control_nat_traversal_port_activity.json} (87%) rename x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/{network_port_26_activity.json => command_and_control_port_26_activity.json} (85%) rename x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/{network_port_8000_activity_to_the_internet.json => command_and_control_port_8000_activity_to_the_internet.json} (79%) rename x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/{network_pptp_point_to_point_tunneling_protocol_activity.json => command_and_control_pptp_point_to_point_tunneling_protocol_activity.json} (85%) rename x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/{network_proxy_port_activity_to_the_internet.json => command_and_control_proxy_port_activity_to_the_internet.json} (53%) rename x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/{network_rdp_remote_desktop_protocol_from_the_internet.json => command_and_control_rdp_remote_desktop_protocol_from_the_internet.json} (85%) rename x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/{network_smtp_to_the_internet.json => command_and_control_smtp_to_the_internet.json} (79%) rename x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/{network_sql_server_port_activity_to_the_internet.json => command_and_control_sql_server_port_activity_to_the_internet.json} (75%) rename x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/{network_ssh_secure_shell_from_the_internet.json => command_and_control_ssh_secure_shell_from_the_internet.json} (85%) rename x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/{network_ssh_secure_shell_to_the_internet.json => command_and_control_ssh_secure_shell_to_the_internet.json} (80%) rename x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/{network_telnet_port_activity.json => command_and_control_telnet_port_activity.json} (91%) rename x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/{network_tor_activity_to_the_internet.json => command_and_control_tor_activity_to_the_internet.json} (82%) rename x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/{network_vnc_virtual_network_computing_from_the_internet.json => command_and_control_vnc_virtual_network_computing_from_the_internet.json} (82%) rename x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/{network_vnc_virtual_network_computing_to_the_internet.json => command_and_control_vnc_virtual_network_computing_to_the_internet.json} (78%) create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_attempted_bypass_of_okta_mfa.json rename x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/{windows_credential_dumping_msbuild.json => credential_access_credential_dumping_msbuild.json} (78%) create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_iam_user_addition_to_group.json create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_secretsmanager_getsecretvalue.json rename x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/{linux_tcpdump_activity.json => credential_access_tcpdump_activity.json} (89%) rename x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/{eql_adding_the_hidden_file_attribute_with_via_attribexe.json => defense_evasion_adding_the_hidden_file_attribute_with_via_attribexe.json} (85%) rename x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/{linux_attempt_to_disable_iptables_or_firewall.json => defense_evasion_attempt_to_disable_iptables_or_firewall.json} (65%) rename x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/{linux_attempt_to_disable_syslog_service.json => defense_evasion_attempt_to_disable_syslog_service.json} (68%) rename x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/{linux_base16_or_base32_encoding_or_decoding_activity.json => defense_evasion_base16_or_base32_encoding_or_decoding_activity.json} (86%) rename x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/{linux_base64_encoding_or_decoding_activity.json => defense_evasion_base64_encoding_or_decoding_activity.json} (85%) rename x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/{eql_clearing_windows_event_logs.json => defense_evasion_clearing_windows_event_logs.json} (75%) create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_cloudtrail_logging_deleted.json create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_cloudtrail_logging_suspended.json create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_cloudwatch_alarm_deletion.json create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_config_service_rule_deletion.json create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_configuration_recorder_stopped.json rename x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/{windows_cve_2020_0601.json => defense_evasion_cve_2020_0601.json} (93%) rename x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/{eql_delete_volume_usn_journal_with_fsutil.json => defense_evasion_delete_volume_usn_journal_with_fsutil.json} (79%) rename x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/{eql_deleting_backup_catalogs_with_wbadmin.json => defense_evasion_deleting_backup_catalogs_with_wbadmin.json} (78%) create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_deletion_of_bash_command_line_history.json rename x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/{linux_disable_selinux_attempt.json => defense_evasion_disable_selinux_attempt.json} (82%) rename x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/{eql_disable_windows_firewall_rules_with_netsh.json => defense_evasion_disable_windows_firewall_rules_with_netsh.json} (76%) create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_ec2_flow_log_deletion.json create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_ec2_network_acl_deletion.json rename x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/{eql_encoding_or_decoding_files_via_certutil.json => defense_evasion_encoding_or_decoding_files_via_certutil.json} (79%) rename x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/{windows_execution_msbuild_started_by_office_app.json => defense_evasion_execution_msbuild_started_by_office_app.json} (83%) rename x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/{windows_execution_msbuild_started_by_script.json => defense_evasion_execution_msbuild_started_by_script.json} (84%) rename x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/{windows_execution_msbuild_started_by_system_process.json => defense_evasion_execution_msbuild_started_by_system_process.json} (85%) rename x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/{windows_execution_msbuild_started_renamed.json => defense_evasion_execution_msbuild_started_renamed.json} (77%) rename x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/{windows_execution_msbuild_started_unusal_process.json => defense_evasion_execution_msbuild_started_unusal_process.json} (82%) rename x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/{windows_execution_via_trusted_developer_utilities.json => defense_evasion_execution_via_trusted_developer_utilities.json} (94%) rename x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/{linux_file_deletion_via_shred.json => defense_evasion_file_deletion_via_shred.json} (79%) rename x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/{linux_file_mod_writable_dir.json => defense_evasion_file_mod_writable_dir.json} (78%) create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_guardduty_detector_deletion.json rename x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/{linux_hex_encoding_or_decoding_activity.json => defense_evasion_hex_encoding_or_decoding_activity.json} (87%) create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_hidden_file_dir_tmp.json rename x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/{windows_injection_msbuild.json => defense_evasion_injection_msbuild.json} (94%) rename x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/{linux_kernel_module_removal.json => defense_evasion_kernel_module_removal.json} (86%) rename x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/{windows_misc_lolbin_connecting_to_the_internet.json => defense_evasion_misc_lolbin_connecting_to_the_internet.json} (80%) rename x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/{windows_modification_of_boot_config.json => defense_evasion_modification_of_boot_config.json} (74%) create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_s3_bucket_configuration_deletion.json rename x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/{windows_defense_evasion_via_filter_manager.json => defense_evasion_via_filter_manager.json} (91%) rename x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/{eql_volume_shadow_copy_deletion_via_vssadmin.json => defense_evasion_volume_shadow_copy_deletion_via_vssadmin.json} (78%) rename x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/{eql_volume_shadow_copy_deletion_via_wmic.json => defense_evasion_volume_shadow_copy_deletion_via_wmic.json} (78%) create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_waf_acl_deletion.json create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_waf_rule_or_rule_group_deletion.json rename x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/{linux_kernel_module_enumeration.json => discovery_kernel_module_enumeration.json} (83%) rename x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/{windows_net_command_system_account.json => discovery_net_command_system_account.json} (77%) rename x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/{windows_process_discovery_via_tasklist_command.json => discovery_process_discovery_via_tasklist_command.json} (93%) rename x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/{linux_virtual_machine_fingerprinting.json => discovery_virtual_machine_fingerprinting.json} (75%) rename x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/{windows_whoami_command_activity.json => discovery_whoami_command_activity.json} (93%) rename x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/{linux_whoami_commmand.json => discovery_whoami_commmand.json} (84%) create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/elastic_endpoint.json rename x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/{elastic_endpoint_security_adversary_behavior_detected.json => endpoint_adversary_behavior_detected.json} (90%) rename x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/{elastic_endpoint_security_cred_dumping_detected.json => endpoint_cred_dumping_detected.json} (90%) rename x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/{elastic_endpoint_security_cred_dumping_prevented.json => endpoint_cred_dumping_prevented.json} (90%) rename x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/{elastic_endpoint_security_cred_manipulation_detected.json => endpoint_cred_manipulation_detected.json} (90%) rename x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/{elastic_endpoint_security_cred_manipulation_prevented.json => endpoint_cred_manipulation_prevented.json} (90%) rename x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/{elastic_endpoint_security_exploit_detected.json => endpoint_exploit_detected.json} (90%) rename x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/{elastic_endpoint_security_exploit_prevented.json => endpoint_exploit_prevented.json} (90%) rename x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/{elastic_endpoint_security_malware_detected.json => endpoint_malware_detected.json} (90%) rename x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/{elastic_endpoint_security_malware_prevented.json => endpoint_malware_prevented.json} (90%) rename x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/{elastic_endpoint_security_permission_theft_detected.json => endpoint_permission_theft_detected.json} (90%) rename x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/{elastic_endpoint_security_permission_theft_prevented.json => endpoint_permission_theft_prevented.json} (90%) rename x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/{elastic_endpoint_security_process_injection_detected.json => endpoint_process_injection_detected.json} (90%) rename x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/{elastic_endpoint_security_process_injection_prevented.json => endpoint_process_injection_prevented.json} (90%) rename x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/{elastic_endpoint_security_ransomware_detected.json => endpoint_ransomware_detected.json} (90%) rename x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/{elastic_endpoint_security_ransomware_prevented.json => endpoint_ransomware_prevented.json} (90%) delete mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/eql_suspicious_ms_office_child_process.json delete mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/eql_suspicious_ms_outlook_child_process.json delete mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/eql_unusual_parentchild_relationship.json rename x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/{windows_command_prompt_connecting_to_the_internet.json => execution_command_prompt_connecting_to_the_internet.json} (85%) rename x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/{windows_command_shell_started_by_powershell.json => execution_command_shell_started_by_powershell.json} (83%) rename x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/{windows_command_shell_started_by_svchost.json => execution_command_shell_started_by_svchost.json} (77%) rename x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/{windows_html_help_executable_program_connecting_to_the_internet.json => execution_html_help_executable_program_connecting_to_the_internet.json} (84%) rename x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/{eql_local_service_commands.json => execution_local_service_commands.json} (78%) rename x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/{eql_msbuild_making_network_connections.json => execution_msbuild_making_network_connections.json} (80%) rename x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/{eql_mshta_making_network_connections.json => execution_mshta_making_network_connections.json} (84%) rename x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/{windows_msxsl_network.json => execution_msxsl_network.json} (78%) rename x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/{linux_perl_tty_shell.json => execution_perl_tty_shell.json} (74%) rename x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/{eql_psexec_lateral_movement_command.json => execution_psexec_lateral_movement_command.json} (89%) rename x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/{linux_python_tty_shell.json => execution_python_tty_shell.json} (71%) rename x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/{windows_register_server_program_connecting_to_the_internet.json => execution_register_server_program_connecting_to_the_internet.json} (77%) rename x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/{eql_windows_script_executing_powershell.json => execution_script_executing_powershell.json} (78%) create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_suspicious_ms_office_child_process.json create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_suspicious_ms_outlook_child_process.json create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_suspicious_pdf_reader.json rename x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/{eql_unusual_network_connection_via_rundll32.json => execution_unusual_network_connection_via_rundll32.json} (76%) rename x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/{eql_unusual_process_network_connection.json => execution_unusual_process_network_connection.json} (72%) rename x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/{windows_execution_via_compiled_html_file.json => execution_via_compiled_html_file.json} (95%) rename x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/{windows_execution_via_net_com_assemblies.json => execution_via_net_com_assemblies.json} (86%) create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_via_system_manager.json create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/exfiltration_ec2_snapshot_change_activity.json create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/external_alerts.json create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_attempt_to_revoke_okta_api_token.json create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_cloudtrail_logging_updated.json create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_cloudwatch_log_group_deletion.json create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_cloudwatch_log_stream_deletion.json create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_ec2_disable_ebs_encryption.json create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_iam_deactivate_mfa_device.json create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_iam_group_deletion.json create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_possible_okta_dos_attack.json create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_rds_cluster_deletion.json create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_rds_instance_cluster_stoppage.json create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/initial_access_console_login_root.json create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/initial_access_password_recovery.json rename x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/{network_rdp_remote_desktop_protocol_to_the_internet.json => initial_access_rdp_remote_desktop_protocol_to_the_internet.json} (83%) rename x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/{network_rpc_remote_procedure_call_from_the_internet.json => initial_access_rpc_remote_procedure_call_from_the_internet.json} (71%) rename x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/{network_rpc_remote_procedure_call_to_the_internet.json => initial_access_rpc_remote_procedure_call_to_the_internet.json} (71%) rename x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/{network_smb_windows_file_sharing_activity_to_the_internet.json => initial_access_smb_windows_file_sharing_activity_to_the_internet.json} (78%) create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/initial_access_suspicious_activity_reported_by_okta_user.json rename x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/{eql_direct_outbound_smb_connection.json => lateral_movement_direct_outbound_smb_connection.json} (82%) rename x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/{linux_telnet_network_activity_external.json => lateral_movement_telnet_network_activity_external.json} (80%) rename x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/{linux_telnet_network_activity_internal.json => lateral_movement_telnet_network_activity_internal.json} (80%) rename x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/{linux_anomalous_network_activity.json => ml_linux_anomalous_network_activity.json} (93%) rename x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/{linux_anomalous_network_port_activity.json => ml_linux_anomalous_network_port_activity.json} (83%) rename x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/{linux_anomalous_network_service.json => ml_linux_anomalous_network_service.json} (81%) rename x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/{linux_anomalous_network_url_activity.json => ml_linux_anomalous_network_url_activity.json} (88%) rename x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/{linux_anomalous_process_all_hosts.json => ml_linux_anomalous_process_all_hosts.json} (91%) rename x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/{linux_anomalous_user_name.json => ml_linux_anomalous_user_name.json} (93%) rename x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/{packetbeat_dns_tunneling.json => ml_packetbeat_dns_tunneling.json} (85%) rename x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/{packetbeat_rare_dns_question.json => ml_packetbeat_rare_dns_question.json} (89%) rename x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/{packetbeat_rare_server_domain.json => ml_packetbeat_rare_server_domain.json} (89%) rename x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/{packetbeat_rare_urls.json => ml_packetbeat_rare_urls.json} (91%) rename x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/{packetbeat_rare_user_agent.json => ml_packetbeat_rare_user_agent.json} (90%) rename x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/{rare_process_by_host_linux.json => ml_rare_process_by_host_linux.json} (91%) rename x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/{rare_process_by_host_windows.json => ml_rare_process_by_host_windows.json} (93%) rename x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/{suspicious_login_activity.json => ml_suspicious_login_activity.json} (80%) rename x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/{windows_anomalous_network_activity.json => ml_windows_anomalous_network_activity.json} (94%) rename x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/{windows_anomalous_path_activity.json => ml_windows_anomalous_path_activity.json} (88%) rename x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/{windows_anomalous_process_all_hosts.json => ml_windows_anomalous_process_all_hosts.json} (93%) rename x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/{windows_anomalous_process_creation.json => ml_windows_anomalous_process_creation.json} (89%) rename x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/{windows_anomalous_script.json => ml_windows_anomalous_script.json} (83%) rename x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/{windows_anomalous_service.json => ml_windows_anomalous_service.json} (85%) rename x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/{windows_anomalous_user_name.json => ml_windows_anomalous_user_name.json} (94%) rename x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/{windows_rare_user_runas_event.json => ml_windows_rare_user_runas_event.json} (85%) rename x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/{windows_rare_user_type10_remote_login.json => ml_windows_rare_user_type10_remote_login.json} (90%) create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/okta_attempt_to_deactivate_okta_mfa_rule.json create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/okta_attempt_to_delete_okta_policy.json create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/okta_attempt_to_modify_okta_mfa_rule.json create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/okta_attempt_to_modify_okta_network_zone.json create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/okta_attempt_to_modify_okta_policy.json create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/okta_attempt_to_modify_or_delete_application_sign_on_policy.json create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/okta_threat_detected_by_okta_threatinsight.json create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_administrator_privileges_assigned_to_okta_group.json rename x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/{eql_adobe_hijack_persistence.json => persistence_adobe_hijack_persistence.json} (68%) create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_attempt_to_create_okta_api_token.json create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_attempt_to_deactivate_mfa_for_okta_user_account.json create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_attempt_to_deactivate_okta_policy.json create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_attempt_to_reset_mfa_factors_for_okta_user_account.json create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_ec2_network_acl_creation.json create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_iam_group_creation.json rename x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/{linux_kernel_module_activity.json => persistence_kernel_module_activity.json} (79%) rename x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/{eql_local_scheduled_task_commands.json => persistence_local_scheduled_task_commands.json} (76%) rename x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/{windows_priv_escalation_via_accessibility_features.json => persistence_priv_escalation_via_accessibility_features.json} (95%) create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_rds_cluster_creation.json rename x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/{linux_shell_activity_by_web_server.json => persistence_shell_activity_by_web_server.json} (75%) rename x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/{eql_system_shells_via_services.json => persistence_system_shells_via_services.json} (78%) rename x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/{eql_user_account_creation.json => persistence_user_account_creation.json} (74%) rename x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/{windows_persistence_via_application_shimming.json => persistence_via_application_shimming.json} (94%) create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_root_login_without_mfa.json rename x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/{linux_setgid_bit_set_via_chmod.json => privilege_escalation_setgid_bit_set_via_chmod.json} (86%) rename x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/{linux_setuid_bit_set_via_chmod.json => privilege_escalation_setuid_bit_set_via_chmod.json} (86%) rename x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/{linux_sudoers_file_mod.json => privilege_escalation_sudoers_file_mod.json} (84%) rename x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/{windows_uac_bypass_event_viewer.json => privilege_escalation_uac_bypass_event_viewer.json} (73%) create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_unusual_parentchild_relationship.json create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_updateassumerolepolicy.json delete mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/windows_suspicious_pdf_reader.json diff --git a/NOTICE.txt b/NOTICE.txt index 94312d46c35ec..56280e6e3883e 100644 --- a/NOTICE.txt +++ b/NOTICE.txt @@ -147,6 +147,70 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +--- +Detection Rules +Copyright 2020 Elasticsearch B.V. + +--- +This product bundles rules based on https://github.com/BlueTeamLabs/sentinel-attack +which is available under a "MIT" license. The files based on this license are: + +- defense_evasion_via_filter_manager +- discovery_process_discovery_via_tasklist_command +- persistence_priv_escalation_via_accessibility_features +- persistence_via_application_shimming +- defense_evasion_execution_via_trusted_developer_utilities + +MIT License + +Copyright (c) 2019 Edoardo Gerosa, Olaf Hartong + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do +so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +--- +This product bundles rules based on https://github.com/FSecureLABS/leonidas +which is available under a "MIT" license. The files based on this license are: + +- credential_access_secretsmanager_getsecretvalue.toml + +MIT License + +Copyright (c) 2020 F-Secure LABS + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + --- This product bundles bootstrap@3.3.6 which is available under a "MIT" license. @@ -220,38 +284,6 @@ WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. ---- -This product bundles rules based on https://github.com/BlueTeamLabs/sentinel-attack -which is available under a "MIT" license. The files based on this license are: - -- windows_defense_evasion_via_filter_manager.json -- windows_process_discovery_via_tasklist_command.json -- windows_priv_escalation_via_accessibility_features.json -- windows_persistence_via_application_shimming.json -- windows_execution_via_trusted_developer_utilities.json - -MIT License - -Copyright (c) 2019 Edoardo Gerosa, Olaf Hartong - -Permission is hereby granted, free of charge, to any person obtaining a copy of -this software and associated documentation files (the "Software"), to deal in -the Software without restriction, including without limitation the rights to -use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies -of the Software, and to permit persons to whom the Software is furnished to do -so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - --- This product includes code that is adapted from mapbox-gl-js, which is available under a "BSD-3-Clause" license. diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/403_response_to_a_post.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/apm_403_response_to_a_post.json similarity index 92% rename from x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/403_response_to_a_post.json rename to x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/apm_403_response_to_a_post.json index 73005db600ca0..9139ca82cc7d8 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/403_response_to_a_post.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/apm_403_response_to_a_post.json @@ -1,4 +1,7 @@ { + "author": [ + "Elastic" + ], "description": "A POST request to web application returned a 403 response, which indicates the web application declined to process the request because the action requested was not allowed", "false_positives": [ "Security scans and tests may result in these errors. Misconfigured or buggy applications may produce large numbers of these errors. If the source is unexpected, the user unauthorized, or the request unusual, these may indicate suspicious or malicious activity." @@ -7,6 +10,7 @@ "apm-*-transaction*" ], "language": "kuery", + "license": "Elastic License", "name": "Web Application Suspicious Activity: POST Request Declined", "query": "http.response.status_code:403 and http.request.method:post", "references": [ @@ -20,5 +24,5 @@ "Elastic" ], "type": "query", - "version": 2 + "version": 3 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/405_response_method_not_allowed.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/apm_405_response_method_not_allowed.json similarity index 91% rename from x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/405_response_method_not_allowed.json rename to x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/apm_405_response_method_not_allowed.json index de080ff342448..2eb7d711e5fb8 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/405_response_method_not_allowed.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/apm_405_response_method_not_allowed.json @@ -1,4 +1,7 @@ { + "author": [ + "Elastic" + ], "description": "A request to web application returned a 405 response which indicates the web application declined to process the request because the HTTP method is not allowed for the resource", "false_positives": [ "Security scans and tests may result in these errors. Misconfigured or buggy applications may produce large numbers of these errors. If the source is unexpected, the user unauthorized, or the request unusual, these may indicate suspicious or malicious activity." @@ -7,6 +10,7 @@ "apm-*-transaction*" ], "language": "kuery", + "license": "Elastic License", "name": "Web Application Suspicious Activity: Unauthorized Method", "query": "http.response.status_code:405", "references": [ @@ -20,5 +24,5 @@ "Elastic" ], "type": "query", - "version": 2 + "version": 3 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/null_user_agent.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/apm_null_user_agent.json similarity index 94% rename from x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/null_user_agent.json rename to x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/apm_null_user_agent.json index 489077c9a5516..e78395be8fb1b 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/null_user_agent.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/apm_null_user_agent.json @@ -1,4 +1,7 @@ { + "author": [ + "Elastic" + ], "description": "A request to a web application server contained no identifying user agent string.", "false_positives": [ "Some normal applications and scripts may contain no user agent. Most legitimate web requests from the Internet contain a user agent string. Requests from web browsers almost always contain a user agent string. If the source is unexpected, the user unauthorized, or the request unusual, these may indicate suspicious or malicious activity." @@ -25,6 +28,7 @@ "apm-*-transaction*" ], "language": "kuery", + "license": "Elastic License", "name": "Web Application Suspicious Activity: No User Agent", "query": "url.path:*", "references": [ @@ -38,5 +42,5 @@ "Elastic" ], "type": "query", - "version": 2 + "version": 3 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/sqlmap_user_agent.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/apm_sqlmap_user_agent.json similarity index 92% rename from x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/sqlmap_user_agent.json rename to x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/apm_sqlmap_user_agent.json index 3ad82d14be7a7..aaaab6b5c6031 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/sqlmap_user_agent.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/apm_sqlmap_user_agent.json @@ -1,4 +1,7 @@ { + "author": [ + "Elastic" + ], "description": "This is an example of how to detect an unwanted web client user agent. This search matches the user agent for sqlmap 1.3.11, which is a popular FOSS tool for testing web applications for SQL injection vulnerabilities.", "false_positives": [ "This rule does not indicate that a SQL injection attack occurred, only that the `sqlmap` tool was used. Security scans and tests may result in these errors. If the source is not an authorized security tester, this is generally suspicious or malicious activity." @@ -7,6 +10,7 @@ "apm-*-transaction*" ], "language": "kuery", + "license": "Elastic License", "name": "Web Application Suspicious Activity: sqlmap User Agent", "query": "user_agent.original:\"sqlmap/1.3.11#stable (http://sqlmap.org)\"", "references": [ @@ -20,5 +24,5 @@ "Elastic" ], "type": "query", - "version": 2 + "version": 3 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/collection_cloudtrail_logging_created.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/collection_cloudtrail_logging_created.json new file mode 100644 index 0000000000000..4437612a5056b --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/collection_cloudtrail_logging_created.json @@ -0,0 +1,48 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies the creation of an AWS log trail that specifies the settings for delivery of log data.", + "false_positives": [ + "Trail creations may be made by a system or network administrator. Verify whether the user identity, user agent, and/or hostname should be making changes in your environment. Trail creations from unfamiliar users or hosts should be investigated. If known behavior is causing false positives, it can be exempted from the rule." + ], + "from": "now-60m", + "index": [ + "filebeat-*" + ], + "interval": "10m", + "language": "kuery", + "license": "Elastic License", + "name": "AWS CloudTrail Log Created", + "query": "event.action:CreateTrail and event.dataset:aws.cloudtrail and event.provider:cloudtrail.amazonaws.com and event.outcome:success", + "references": [ + "https://docs.aws.amazon.com/awscloudtrail/latest/APIReference/API_CreateTrail.html", + "https://awscli.amazonaws.com/v2/documentation/api/latest/reference/cloudtrail/create-trail.html" + ], + "risk_score": 21, + "rule_id": "594e0cbf-86cc-45aa-9ff7-ff27db27d3ed", + "severity": "low", + "tags": [ + "AWS", + "Elastic" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0009", + "name": "Collection", + "reference": "https://attack.mitre.org/tactics/TA0009/" + }, + "technique": [ + { + "id": "T1530", + "name": "Data from Cloud Storage Object", + "reference": "https://attack.mitre.org/techniques/T1530/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/windows_certutil_network_connection.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_certutil_network_connection.json similarity index 77% rename from x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/windows_certutil_network_connection.json rename to x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_certutil_network_connection.json index 82db7de3d3130..4132d03c27854 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/windows_certutil_network_connection.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_certutil_network_connection.json @@ -1,11 +1,15 @@ { + "author": [ + "Elastic" + ], "description": "Identifies certutil.exe making a network connection. Adversaries could abuse certutil.exe to download a certificate, or malware, from a remote URL.", "index": [ "winlogbeat-*" ], "language": "kuery", + "license": "Elastic License", "name": "Network Connection via Certutil", - "query": "process.name:certutil.exe and event.action:\"Network connection detected (rule: NetworkConnect)\" and not destination.ip:(10.0.0.0/8 or 172.16.0.0/12 or 192.168.0.0/16)", + "query": "event.category:network and event.type:connection and process.name:certutil.exe and not destination.ip:(10.0.0.0/8 or 172.16.0.0/12 or 192.168.0.0/16)", "risk_score": 21, "rule_id": "3838e0e3-1850-4850-a411-2e8c5ba40ba8", "severity": "low", @@ -31,5 +35,5 @@ } ], "type": "query", - "version": 1 + "version": 2 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/network_dns_directly_to_the_internet.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_dns_directly_to_the_internet.json similarity index 78% rename from x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/network_dns_directly_to_the_internet.json rename to x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_dns_directly_to_the_internet.json index 1ffabbc876e2e..79ec202c41ffb 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/network_dns_directly_to_the_internet.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_dns_directly_to_the_internet.json @@ -1,14 +1,19 @@ { + "author": [ + "Elastic" + ], "description": "This rule detects when an internal network client sends DNS traffic directly to the Internet. This is atypical behavior for a managed network, and can be indicative of malware, exfiltration, command and control, or, simply, misconfiguration. This DNS activity also impacts your organization's ability to provide enterprise monitoring and logging of DNS, and opens your network to a variety of abuses and malicious communications.", "false_positives": [ "Exclude DNS servers from this rule as this is expected behavior. Endpoints usually query local DNS servers defined in their DHCP scopes, but this may be overridden if a user configures their endpoint to use a remote DNS server. This is uncommon in managed enterprise networks because it could break intranet name resolution when split horizon DNS is utilized. Some consumer VPN services and browser plug-ins may send DNS traffic to remote Internet destinations. In that case, such devices or networks can be excluded from this rule when this is expected behavior." ], "index": [ - "filebeat-*" + "filebeat-*", + "packetbeat-*" ], "language": "kuery", + "license": "Elastic License", "name": "DNS Activity to the Internet", - "query": "destination.port:53 and source.ip:(10.0.0.0/8 or 172.16.0.0/12 or 192.168.0.0/16) and not destination.ip:(10.0.0.0/8 or 127.0.0.0/8 or 169.254.169.254/32 or 172.16.0.0/12 or 192.168.0.0/16 or 224.0.0.251 or 224.0.0.252 or 255.255.255.255 or \"::1\" or \"ff02::fb\")", + "query": "event.category:(network or network_traffic) and (event.type:connection or type:dns) and (destination.port:53 or event.dataset:zeek.dns) and source.ip:(10.0.0.0/8 or 172.16.0.0/12 or 192.168.0.0/16) and not destination.ip:(10.0.0.0/8 or 127.0.0.0/8 or 169.254.169.254/32 or 172.16.0.0/12 or 192.168.0.0/16 or 224.0.0.251 or 224.0.0.252 or 255.255.255.255 or \"::1\" or \"ff02::fb\")", "references": [ "https://www.us-cert.gov/ncas/alerts/TA15-240A", "https://nvlpubs.nist.gov/nistpubs/SpecialPublications/NIST.SP.800-81-2.pdf" @@ -38,5 +43,5 @@ } ], "type": "query", - "version": 3 + "version": 4 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/network_ftp_file_transfer_protocol_activity_to_the_internet.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_ftp_file_transfer_protocol_activity_to_the_internet.json similarity index 83% rename from x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/network_ftp_file_transfer_protocol_activity_to_the_internet.json rename to x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_ftp_file_transfer_protocol_activity_to_the_internet.json index 0649d408a5c22..9a009ffd3fd21 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/network_ftp_file_transfer_protocol_activity_to_the_internet.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_ftp_file_transfer_protocol_activity_to_the_internet.json @@ -1,14 +1,19 @@ { + "author": [ + "Elastic" + ], "description": "This rule detects events that may indicate the use of FTP network connections to the Internet. The File Transfer Protocol (FTP) has been around in its current form since the 1980s. It can be a common and efficient procedure on your network to send and receive files. Because of this, adversaries will also often use this protocol to exfiltrate data from your network or download new tools. Additionally, FTP is a plain-text protocol which, if intercepted, may expose usernames and passwords. FTP activity involving servers subject to regulations or compliance standards may be unauthorized.", "false_positives": [ "FTP servers should be excluded from this rule as this is expected behavior. Some business workflows may use FTP for data exchange. These workflows often have expected characteristics such as users, sources, and destinations. FTP activity involving an unusual source or destination may be more suspicious. FTP activity involving a production server that has no known associated FTP workflow or business requirement is often suspicious." ], "index": [ - "filebeat-*" + "filebeat-*", + "packetbeat-*" ], "language": "kuery", + "license": "Elastic License", "name": "FTP (File Transfer Protocol) Activity to the Internet", - "query": "network.transport:tcp and destination.port:(20 or 21) and source.ip:(10.0.0.0/8 or 172.16.0.0/12 or 192.168.0.0/16) and not destination.ip:(10.0.0.0/8 or 127.0.0.0/8 or 172.16.0.0/12 or 192.168.0.0/16 or \"::1\")", + "query": "event.category:(network or network_traffic) and network.transport:tcp and (destination.port:(20 or 21) or event.dataset:zeek.ftp) and source.ip:(10.0.0.0/8 or 172.16.0.0/12 or 192.168.0.0/16) and not destination.ip:(10.0.0.0/8 or 127.0.0.0/8 or 172.16.0.0/12 or 192.168.0.0/16 or \"::1\")", "risk_score": 21, "rule_id": "87ec6396-9ac4-4706-bcf0-2ebb22002f43", "severity": "low", @@ -49,5 +54,5 @@ } ], "type": "query", - "version": 3 + "version": 4 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/network_irc_internet_relay_chat_protocol_activity_to_the_internet.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_irc_internet_relay_chat_protocol_activity_to_the_internet.json similarity index 83% rename from x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/network_irc_internet_relay_chat_protocol_activity_to_the_internet.json rename to x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_irc_internet_relay_chat_protocol_activity_to_the_internet.json index bdabfa4d5f38f..af30861d85e04 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/network_irc_internet_relay_chat_protocol_activity_to_the_internet.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_irc_internet_relay_chat_protocol_activity_to_the_internet.json @@ -1,14 +1,19 @@ { + "author": [ + "Elastic" + ], "description": "This rule detects events that use common ports for Internet Relay Chat (IRC) to the Internet. IRC is a common protocol that can be used for chat and file transfers. This protocol is also a good candidate for remote control of malware and data transfers to and from a network.", "false_positives": [ "IRC activity may be normal behavior for developers and engineers but is unusual for non-engineering end users. IRC activity involving an unusual source or destination may be more suspicious. IRC activity involving a production server is often suspicious. Because these ports are in the ephemeral range, this rule may false under certain conditions, such as when a NAT-ed web server replies to a client which has used a port in the range by coincidence. In this case, these servers can be excluded. Some legacy applications may use these ports, but this is very uncommon and usually only appears in local traffic using private IPs, which does not match this rule's conditions." ], "index": [ - "filebeat-*" + "filebeat-*", + "packetbeat-*" ], "language": "kuery", + "license": "Elastic License", "name": "IRC (Internet Relay Chat) Protocol Activity to the Internet", - "query": "network.transport:tcp and destination.port:(6667 or 6697) and source.ip:(10.0.0.0/8 or 172.16.0.0/12 or 192.168.0.0/16) and not destination.ip:(10.0.0.0/8 or 127.0.0.0/8 or 172.16.0.0/12 or 192.168.0.0/16 or \"::1\")", + "query": "event.category:(network or network_traffic) and network.transport:tcp and (destination.port:(6667 or 6697) or event.dataset:zeek.irc) and source.ip:(10.0.0.0/8 or 172.16.0.0/12 or 192.168.0.0/16) and not destination.ip:(10.0.0.0/8 or 127.0.0.0/8 or 172.16.0.0/12 or 192.168.0.0/16 or \"::1\")", "risk_score": 47, "rule_id": "c6474c34-4953-447a-903e-9fcb7b6661aa", "severity": "medium", @@ -49,5 +54,5 @@ } ], "type": "query", - "version": 3 + "version": 4 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/network_nat_traversal_port_activity.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_nat_traversal_port_activity.json similarity index 87% rename from x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/network_nat_traversal_port_activity.json rename to x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_nat_traversal_port_activity.json index 63bdd2b83e3bc..e42bf4029eb01 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/network_nat_traversal_port_activity.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_nat_traversal_port_activity.json @@ -1,14 +1,19 @@ { + "author": [ + "Elastic" + ], "description": "This rule detects events that could be describing IPSEC NAT Traversal traffic. IPSEC is a VPN technology that allows one system to talk to another using encrypted tunnels. NAT Traversal enables these tunnels to communicate over the Internet where one of the sides is behind a NAT router gateway. This may be common on your network, but this technique is also used by threat actors to avoid detection.", "false_positives": [ "Some networks may utilize these protocols but usage that is unfamiliar to local network administrators can be unexpected and suspicious. Because this port is in the ephemeral range, this rule may false under certain conditions, such as when an application server with a public IP address replies to a client which has used a UDP port in the range by coincidence. This is uncommon but such servers can be excluded." ], "index": [ - "filebeat-*" + "filebeat-*", + "packetbeat-*" ], "language": "kuery", + "license": "Elastic License", "name": "IPSEC NAT Traversal Port Activity", - "query": "network.transport:udp and destination.port:4500", + "query": "event.category:(network or network_traffic) and network.transport:udp and destination.port:4500", "risk_score": 21, "rule_id": "a9cb3641-ff4b-4cdc-a063-b4b8d02a67c7", "severity": "low", @@ -34,5 +39,5 @@ } ], "type": "query", - "version": 2 + "version": 3 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/network_port_26_activity.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_port_26_activity.json similarity index 85% rename from x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/network_port_26_activity.json rename to x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_port_26_activity.json index df809d2225352..ed20554ae8c40 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/network_port_26_activity.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_port_26_activity.json @@ -1,14 +1,19 @@ { + "author": [ + "Elastic" + ], "description": "This rule detects events that may indicate use of SMTP on TCP port 26. This port is commonly used by several popular mail transfer agents to deconflict with the default SMTP port 25. This port has also been used by a malware family called BadPatch for command and control of Windows systems.", "false_positives": [ "Servers that process email traffic may cause false positives and should be excluded from this rule as this is expected behavior." ], "index": [ - "filebeat-*" + "filebeat-*", + "packetbeat-*" ], "language": "kuery", + "license": "Elastic License", "name": "SMTP on Port 26/TCP", - "query": "network.transport:tcp and destination.port:26", + "query": "event.category:(network or network_traffic) and network.transport:tcp and (destination.port:26 or (event.dataset:zeek.smtp and destination.port:26))", "references": [ "https://unit42.paloaltonetworks.com/unit42-badpatch/", "https://isc.sans.edu/forums/diary/Next+up+whats+up+with+TCP+port+26/25564/" @@ -53,5 +58,5 @@ } ], "type": "query", - "version": 2 + "version": 3 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/network_port_8000_activity_to_the_internet.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_port_8000_activity_to_the_internet.json similarity index 79% rename from x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/network_port_8000_activity_to_the_internet.json rename to x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_port_8000_activity_to_the_internet.json index 11b711d8f7464..319f95ed88e08 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/network_port_8000_activity_to_the_internet.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_port_8000_activity_to_the_internet.json @@ -1,14 +1,19 @@ { + "author": [ + "Elastic" + ], "description": "TCP Port 8000 is commonly used for development environments of web server software. It generally should not be exposed directly to the Internet. If you are running software like this on the Internet, you should consider placing it behind a reverse proxy.", "false_positives": [ "Because this port is in the ephemeral range, this rule may false under certain conditions, such as when a NATed web server replies to a client which has used a port in the range by coincidence. In this case, such servers can be excluded. Some applications may use this port but this is very uncommon and usually appears in local traffic using private IPs, which this rule does not match. Some cloud environments, particularly development environments, may use this port when VPNs or direct connects are not in use and cloud instances are accessed across the Internet." ], "index": [ - "filebeat-*" + "filebeat-*", + "packetbeat-*" ], "language": "kuery", + "license": "Elastic License", "name": "TCP Port 8000 Activity to the Internet", - "query": "network.transport:tcp and destination.port:8000 and source.ip:(10.0.0.0/8 or 172.16.0.0/12 or 192.168.0.0/16) and not destination.ip:(10.0.0.0/8 or 127.0.0.0/8 or 172.16.0.0/12 or 192.168.0.0/16 or \"::1\")", + "query": "event.category:(network or network_traffic) and network.transport:tcp and destination.port:8000 and source.ip:(10.0.0.0/8 or 172.16.0.0/12 or 192.168.0.0/16) and not destination.ip:(10.0.0.0/8 or 127.0.0.0/8 or 172.16.0.0/12 or 192.168.0.0/16 or \"::1\")", "risk_score": 21, "rule_id": "08d5d7e2-740f-44d8-aeda-e41f4263efaf", "severity": "low", @@ -34,5 +39,5 @@ } ], "type": "query", - "version": 3 + "version": 4 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/network_pptp_point_to_point_tunneling_protocol_activity.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_pptp_point_to_point_tunneling_protocol_activity.json similarity index 85% rename from x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/network_pptp_point_to_point_tunneling_protocol_activity.json rename to x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_pptp_point_to_point_tunneling_protocol_activity.json index 87d37b77f53b4..bd478f2b23fc0 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/network_pptp_point_to_point_tunneling_protocol_activity.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_pptp_point_to_point_tunneling_protocol_activity.json @@ -1,14 +1,19 @@ { + "author": [ + "Elastic" + ], "description": "This rule detects events that may indicate use of a PPTP VPN connection. Some threat actors use these types of connections to tunnel their traffic while avoiding detection.", "false_positives": [ "Some networks may utilize PPTP protocols but this is uncommon as more modern VPN technologies are available. Usage that is unfamiliar to local network administrators can be unexpected and suspicious. Torrenting applications may use this port. Because this port is in the ephemeral range, this rule may false under certain conditions, such as when an application server replies to a client that used this port by coincidence. This is uncommon but such servers can be excluded." ], "index": [ - "filebeat-*" + "filebeat-*", + "packetbeat-*" ], "language": "kuery", + "license": "Elastic License", "name": "PPTP (Point to Point Tunneling Protocol) Activity", - "query": "network.transport:tcp and destination.port:1723", + "query": "event.category:(network or network_traffic) and network.transport:tcp and destination.port:1723", "risk_score": 21, "rule_id": "d2053495-8fe7-4168-b3df-dad844046be3", "severity": "low", @@ -34,5 +39,5 @@ } ], "type": "query", - "version": 2 + "version": 3 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/network_proxy_port_activity_to_the_internet.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_proxy_port_activity_to_the_internet.json similarity index 53% rename from x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/network_proxy_port_activity_to_the_internet.json rename to x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_proxy_port_activity_to_the_internet.json index 35ba1ca806296..ee02505300611 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/network_proxy_port_activity_to_the_internet.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_proxy_port_activity_to_the_internet.json @@ -1,14 +1,19 @@ { + "author": [ + "Elastic" + ], "description": "This rule detects events that may describe network events of proxy use to the Internet. It includes popular HTTP proxy ports and SOCKS proxy ports. Typically, environments will use an internal IP address for a proxy server. It can also be used to circumvent network controls and detection mechanisms.", "false_positives": [ - "Some proxied applications may use these ports but this usually occurs in local traffic using private IPs which this rule does not match. Proxies are widely used as a security technology but in enterprise environments this is usually local traffic which this rule does not match. Internet proxy services using these ports can be white-listed if desired. Some screen recording applications may use these ports. Proxy port activity involving an unusual source or destination may be more suspicious. Some cloud environments may use this port when VPNs or direct connects are not in use and cloud instances are accessed across the Internet. Because these ports are in the ephemeral range, this rule may false under certain conditions such as when a NATed web server replies to a client which has used a port in the range by coincidence. In this case, such servers can be excluded if desired." + "Some proxied applications may use these ports but this usually occurs in local traffic using private IPs which this rule does not match. Proxies are widely used as a security technology but in enterprise environments this is usually local traffic which this rule does not match. If desired, internet proxy services using these ports can be added to allowlists. Some screen recording applications may use these ports. Proxy port activity involving an unusual source or destination may be more suspicious. Some cloud environments may use this port when VPNs or direct connects are not in use and cloud instances are accessed across the Internet. Because these ports are in the ephemeral range, this rule may false under certain conditions such as when a NATed web server replies to a client which has used a port in the range by coincidence. In this case, such servers can be excluded if desired." ], "index": [ - "filebeat-*" + "filebeat-*", + "packetbeat-*" ], "language": "kuery", + "license": "Elastic License", "name": "Proxy Port Activity to the Internet", - "query": "network.transport:tcp and destination.port:(1080 or 3128 or 8080) and source.ip:(10.0.0.0/8 or 172.16.0.0/12 or 192.168.0.0/16) and not destination.ip:(10.0.0.0/8 or 127.0.0.0/8 or 172.16.0.0/12 or 192.168.0.0/16 or \"::1\")", + "query": "event.category:(network or network_traffic) and network.transport:tcp and (destination.port:(1080 or 3128 or 8080) or event.dataset:zeek.socks) and source.ip:(10.0.0.0/8 or 172.16.0.0/12 or 192.168.0.0/16) and not destination.ip:(10.0.0.0/8 or 127.0.0.0/8 or 172.16.0.0/12 or 192.168.0.0/16 or \"::1\")", "risk_score": 47, "rule_id": "ad0e5e75-dd89-4875-8d0a-dfdc1828b5f3", "severity": "medium", @@ -34,5 +39,5 @@ } ], "type": "query", - "version": 3 + "version": 4 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/network_rdp_remote_desktop_protocol_from_the_internet.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_rdp_remote_desktop_protocol_from_the_internet.json similarity index 85% rename from x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/network_rdp_remote_desktop_protocol_from_the_internet.json rename to x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_rdp_remote_desktop_protocol_from_the_internet.json index 7b0c9b2927cab..87544647b17e1 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/network_rdp_remote_desktop_protocol_from_the_internet.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_rdp_remote_desktop_protocol_from_the_internet.json @@ -1,14 +1,19 @@ { + "author": [ + "Elastic" + ], "description": "This rule detects network events that may indicate the use of RDP traffic from the Internet. RDP is commonly used by system administrators to remotely control a system for maintenance or to use shared resources. It should almost never be directly exposed to the Internet, as it is frequently targeted and exploited by threat actors as an initial access or back-door vector.", "false_positives": [ "Some network security policies allow RDP directly from the Internet but usage that is unfamiliar to server or network owners can be unexpected and suspicious. RDP services may be exposed directly to the Internet in some networks such as cloud environments. In such cases, only RDP gateways, bastions or jump servers may be expected expose RDP directly to the Internet and can be exempted from this rule. RDP may be required by some work-flows such as remote access and support for specialized software products and servers. Such work-flows are usually known and not unexpected." ], "index": [ - "filebeat-*" + "filebeat-*", + "packetbeat-*" ], "language": "kuery", + "license": "Elastic License", "name": "RDP (Remote Desktop Protocol) from the Internet", - "query": "network.transport:tcp and destination.port:3389 and not source.ip:(10.0.0.0/8 or 172.16.0.0/12 or 192.168.0.0/16) and destination.ip:(10.0.0.0/8 or 127.0.0.0/8 or 172.16.0.0/12 or 192.168.0.0/16 or \"::1\")", + "query": "event.category:(network or network_traffic) and network.transport:tcp and (destination.port:3389 or event.dataset:zeek.rdp) and not source.ip:(10.0.0.0/8 or 172.16.0.0/12 or 192.168.0.0/16) and destination.ip:(10.0.0.0/8 or 127.0.0.0/8 or 172.16.0.0/12 or 192.168.0.0/16 or \"::1\")", "risk_score": 47, "rule_id": "8c1bdde8-4204-45c0-9e0c-c85ca3902488", "severity": "medium", @@ -64,5 +69,5 @@ } ], "type": "query", - "version": 3 + "version": 4 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/network_smtp_to_the_internet.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_smtp_to_the_internet.json similarity index 79% rename from x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/network_smtp_to_the_internet.json rename to x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_smtp_to_the_internet.json index c05efa1c0e26b..3a082c29a4cf1 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/network_smtp_to_the_internet.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_smtp_to_the_internet.json @@ -1,14 +1,19 @@ { + "author": [ + "Elastic" + ], "description": "This rule detects events that may describe SMTP traffic from internal hosts to a host across the Internet. In an enterprise network, there is typically a dedicated internal host that performs this function. It is also frequently abused by threat actors for command and control, or data exfiltration.", "false_positives": [ "NATed servers that process email traffic may false and should be excluded from this rule as this is expected behavior for them. Consumer and personal devices may send email traffic to remote Internet destinations. In this case, such devices or networks can be excluded from this rule if this is expected behavior." ], "index": [ - "filebeat-*" + "filebeat-*", + "packetbeat-*" ], "language": "kuery", + "license": "Elastic License", "name": "SMTP to the Internet", - "query": "network.transport:tcp and destination.port:(25 or 465 or 587) and source.ip:(10.0.0.0/8 or 172.16.0.0/12 or 192.168.0.0/16) and not destination.ip:(10.0.0.0/8 or 127.0.0.0/8 or 172.16.0.0/12 or 192.168.0.0/16 or \"::1\")", + "query": "event.category:(network or network_traffic) and network.transport:tcp and (destination.port:(25 or 465 or 587) or event.dataset:zeek.smtp) and source.ip:(10.0.0.0/8 or 172.16.0.0/12 or 192.168.0.0/16) and not destination.ip:(10.0.0.0/8 or 127.0.0.0/8 or 172.16.0.0/12 or 192.168.0.0/16 or \"::1\")", "risk_score": 21, "rule_id": "67a9beba-830d-4035-bfe8-40b7e28f8ac4", "severity": "low", @@ -49,5 +54,5 @@ } ], "type": "query", - "version": 3 + "version": 4 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/network_sql_server_port_activity_to_the_internet.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_sql_server_port_activity_to_the_internet.json similarity index 75% rename from x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/network_sql_server_port_activity_to_the_internet.json rename to x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_sql_server_port_activity_to_the_internet.json index 5ed7ca4112015..95ac4d8836800 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/network_sql_server_port_activity_to_the_internet.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_sql_server_port_activity_to_the_internet.json @@ -1,14 +1,19 @@ { + "author": [ + "Elastic" + ], "description": "This rule detects events that may describe database traffic (MS SQL, Oracle, MySQL, and Postgresql) across the Internet. Databases should almost never be directly exposed to the Internet, as they are frequently targeted by threat actors to gain initial access to network resources.", "false_positives": [ "Because these ports are in the ephemeral range, this rule may false under certain conditions such as when a NATed web server replies to a client which has used a port in the range by coincidence. In this case, such servers can be excluded if desired. Some cloud environments may use this port when VPNs or direct connects are not in use and database instances are accessed directly across the Internet." ], "index": [ - "filebeat-*" + "filebeat-*", + "packetbeat-*" ], "language": "kuery", + "license": "Elastic License", "name": "SQL Traffic to the Internet", - "query": "network.transport:tcp and destination.port:(1433 or 1521 or 3336 or 5432) and source.ip:(10.0.0.0/8 or 172.16.0.0/12 or 192.168.0.0/16) and not destination.ip:(10.0.0.0/8 or 127.0.0.0/8 or 172.16.0.0/12 or 192.168.0.0/16 or \"::1\")", + "query": "event.category:(network or network_traffic) and network.transport:tcp and (destination.port:(1433 or 1521 or 3306 or 5432) or event.dataset:zeek.mysql) and source.ip:(10.0.0.0/8 or 172.16.0.0/12 or 192.168.0.0/16) and not destination.ip:(10.0.0.0/8 or 127.0.0.0/8 or 172.16.0.0/12 or 192.168.0.0/16 or \"::1\")", "risk_score": 47, "rule_id": "139c7458-566a-410c-a5cd-f80238d6a5cd", "severity": "medium", @@ -34,5 +39,5 @@ } ], "type": "query", - "version": 3 + "version": 4 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/network_ssh_secure_shell_from_the_internet.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_ssh_secure_shell_from_the_internet.json similarity index 85% rename from x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/network_ssh_secure_shell_from_the_internet.json rename to x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_ssh_secure_shell_from_the_internet.json index 2bd9a3f63ee8c..fe5608459ffce 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/network_ssh_secure_shell_from_the_internet.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_ssh_secure_shell_from_the_internet.json @@ -1,14 +1,19 @@ { + "author": [ + "Elastic" + ], "description": "This rule detects network events that may indicate the use of SSH traffic from the Internet. SSH is commonly used by system administrators to remotely control a system using the command line shell. If it is exposed to the Internet, it should be done with strong security controls as it is frequently targeted and exploited by threat actors as an initial access or back-door vector.", "false_positives": [ "Some network security policies allow SSH directly from the Internet but usage that is unfamiliar to server or network owners can be unexpected and suspicious. SSH services may be exposed directly to the Internet in some networks such as cloud environments. In such cases, only SSH gateways, bastions or jump servers may be expected expose SSH directly to the Internet and can be exempted from this rule. SSH may be required by some work-flows such as remote access and support for specialized software products and servers. Such work-flows are usually known and not unexpected." ], "index": [ - "filebeat-*" + "filebeat-*", + "packetbeat-*" ], "language": "kuery", + "license": "Elastic License", "name": "SSH (Secure Shell) from the Internet", - "query": "network.transport:tcp and destination.port:22 and not source.ip:(10.0.0.0/8 or 127.0.0.0/8 or 172.16.0.0/12 or 192.168.0.0/16 or \"::1\") and destination.ip:(10.0.0.0/8 or 172.16.0.0/12 or 192.168.0.0/16)", + "query": "event.category:(network or network_traffic) and network.transport:tcp and (destination.port:22 or event.dataset:zeek.ssh) and not source.ip:(10.0.0.0/8 or 127.0.0.0/8 or 172.16.0.0/12 or 192.168.0.0/16 or \"::1\") and destination.ip:(10.0.0.0/8 or 172.16.0.0/12 or 192.168.0.0/16)", "risk_score": 47, "rule_id": "ea0784f0-a4d7-4fea-ae86-4baaf27a6f17", "severity": "medium", @@ -64,5 +69,5 @@ } ], "type": "query", - "version": 3 + "version": 4 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/network_ssh_secure_shell_to_the_internet.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_ssh_secure_shell_to_the_internet.json similarity index 80% rename from x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/network_ssh_secure_shell_to_the_internet.json rename to x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_ssh_secure_shell_to_the_internet.json index 6512a1627db89..9ecfe39a79303 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/network_ssh_secure_shell_to_the_internet.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_ssh_secure_shell_to_the_internet.json @@ -1,14 +1,19 @@ { + "author": [ + "Elastic" + ], "description": "This rule detects network events that may indicate the use of SSH traffic from the Internet. SSH is commonly used by system administrators to remotely control a system using the command line shell. If it is exposed to the Internet, it should be done with strong security controls as it is frequently targeted and exploited by threat actors as an initial access or back-door vector.", "false_positives": [ "SSH connections may be made directly to Internet destinations in order to access Linux cloud server instances but such connections are usually made only by engineers. In such cases, only SSH gateways, bastions or jump servers may be expected Internet destinations and can be exempted from this rule. SSH may be required by some work-flows such as remote access and support for specialized software products and servers. Such work-flows are usually known and not unexpected. Usage that is unfamiliar to server or network owners can be unexpected and suspicious." ], "index": [ - "filebeat-*" + "filebeat-*", + "packetbeat-*" ], "language": "kuery", + "license": "Elastic License", "name": "SSH (Secure Shell) to the Internet", - "query": "network.transport:tcp and destination.port:22 and source.ip:(10.0.0.0/8 or 172.16.0.0/12 or 192.168.0.0/16) and not destination.ip:(10.0.0.0/8 or 127.0.0.0/8 or 172.16.0.0/12 or 192.168.0.0/16 or \"::1\")", + "query": "event.category:(network or network_traffic) and network.transport:tcp and (destination.port:22 or event.dataset:zeek.ssh) and source.ip:(10.0.0.0/8 or 172.16.0.0/12 or 192.168.0.0/16) and not destination.ip:(10.0.0.0/8 or 127.0.0.0/8 or 172.16.0.0/12 or 192.168.0.0/16 or \"::1\")", "risk_score": 21, "rule_id": "6f1500bc-62d7-4eb9-8601-7485e87da2f4", "severity": "low", @@ -34,5 +39,5 @@ } ], "type": "query", - "version": 3 + "version": 4 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/network_telnet_port_activity.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_telnet_port_activity.json similarity index 91% rename from x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/network_telnet_port_activity.json rename to x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_telnet_port_activity.json index af60c991ceea2..561a100afa44a 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/network_telnet_port_activity.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_telnet_port_activity.json @@ -1,14 +1,19 @@ { + "author": [ + "Elastic" + ], "description": "This rule detects network events that may indicate the use of Telnet traffic. Telnet is commonly used by system administrators to remotely control older or embed ed systems using the command line shell. It should almost never be directly exposed to the Internet, as it is frequently targeted and exploited by threat actors as an initial access or back-door vector. As a plain-text protocol, it may also expose usernames and passwords to anyone capable of observing the traffic.", "false_positives": [ "IoT (Internet of Things) devices and networks may use telnet and can be excluded if desired. Some business work-flows may use Telnet for administration of older devices. These often have a predictable behavior. Telnet activity involving an unusual source or destination may be more suspicious. Telnet activity involving a production server that has no known associated Telnet work-flow or business requirement is often suspicious." ], "index": [ - "filebeat-*" + "filebeat-*", + "packetbeat-*" ], "language": "kuery", + "license": "Elastic License", "name": "Telnet Port Activity", - "query": "network.transport:tcp and destination.port:23", + "query": "event.category:(network or network_traffic) and network.transport:tcp and destination.port:23", "risk_score": 47, "rule_id": "34fde489-94b0-4500-a76f-b8a157cf9269", "severity": "medium", @@ -64,5 +69,5 @@ } ], "type": "query", - "version": 2 + "version": 3 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/network_tor_activity_to_the_internet.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_tor_activity_to_the_internet.json similarity index 82% rename from x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/network_tor_activity_to_the_internet.json rename to x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_tor_activity_to_the_internet.json index ff2ead0eaaf49..b278c36d01c1b 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/network_tor_activity_to_the_internet.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_tor_activity_to_the_internet.json @@ -1,14 +1,19 @@ { + "author": [ + "Elastic" + ], "description": "This rule detects network events that may indicate the use of Tor traffic to the Internet. Tor is a network protocol that sends traffic through a series of encrypted tunnels used to conceal a user's location and usage. Tor may be used by threat actors as an alternate communication pathway to conceal the actor's identity and avoid detection.", "false_positives": [ "Tor client activity is uncommon in managed enterprise networks but may be common in unmanaged or public networks where few security policies apply. Because these ports are in the ephemeral range, this rule may false under certain conditions such as when a NATed web server replies to a client which has used one of these ports by coincidence. In this case, such servers can be excluded if desired." ], "index": [ - "filebeat-*" + "filebeat-*", + "packetbeat-*" ], "language": "kuery", + "license": "Elastic License", "name": "Tor Activity to the Internet", - "query": "network.transport:tcp and destination.port:(9001 or 9030) and source.ip:(10.0.0.0/8 or 172.16.0.0/12 or 192.168.0.0/16) and not destination.ip:(10.0.0.0/8 or 127.0.0.0/8 or 172.16.0.0/12 or 192.168.0.0/16 or \"::1\")", + "query": "event.category:(network or network_traffic) and network.transport:tcp and destination.port:(9001 or 9030) and source.ip:(10.0.0.0/8 or 172.16.0.0/12 or 192.168.0.0/16) and not destination.ip:(10.0.0.0/8 or 127.0.0.0/8 or 172.16.0.0/12 or 192.168.0.0/16 or \"::1\")", "risk_score": 47, "rule_id": "7d2c38d7-ede7-4bdf-b140-445906e6c540", "severity": "medium", @@ -49,5 +54,5 @@ } ], "type": "query", - "version": 3 + "version": 4 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/network_vnc_virtual_network_computing_from_the_internet.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_vnc_virtual_network_computing_from_the_internet.json similarity index 82% rename from x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/network_vnc_virtual_network_computing_from_the_internet.json rename to x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_vnc_virtual_network_computing_from_the_internet.json index 7fac7938579ca..2e039544cfd99 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/network_vnc_virtual_network_computing_from_the_internet.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_vnc_virtual_network_computing_from_the_internet.json @@ -1,14 +1,19 @@ { + "author": [ + "Elastic" + ], "description": "This rule detects network events that may indicate the use of VNC traffic from the Internet. VNC is commonly used by system administrators to remotely control a system for maintenance or to use shared resources. It should almost never be directly exposed to the Internet, as it is frequently targeted and exploited by threat actors as an initial access or back-door vector.", "false_positives": [ "VNC connections may be received directly to Linux cloud server instances but such connections are usually made only by engineers. VNC is less common than SSH or RDP but may be required by some work-flows such as remote access and support for specialized software products or servers. Such work-flows are usually known and not unexpected. Usage that is unfamiliar to server or network owners can be unexpected and suspicious." ], "index": [ - "filebeat-*" + "filebeat-*", + "packetbeat-*" ], "language": "kuery", + "license": "Elastic License", "name": "VNC (Virtual Network Computing) from the Internet", - "query": "network.transport:tcp and destination.port >= 5800 and destination.port <= 5810 and not source.ip:(10.0.0.0/8 or 127.0.0.0/8 or 172.16.0.0/12 or 192.168.0.0/16 or \"::1\") and destination.ip:(10.0.0.0/8 or 172.16.0.0/12 or 192.168.0.0/16)", + "query": "event.category:(network or network_traffic) and network.transport:tcp and destination.port >= 5800 and destination.port <= 5810 and not source.ip:(10.0.0.0/8 or 127.0.0.0/8 or 172.16.0.0/12 or 192.168.0.0/16 or \"::1\") and destination.ip:(10.0.0.0/8 or 172.16.0.0/12 or 192.168.0.0/16)", "risk_score": 73, "rule_id": "5700cb81-df44-46aa-a5d7-337798f53eb8", "severity": "high", @@ -49,5 +54,5 @@ } ], "type": "query", - "version": 3 + "version": 4 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/network_vnc_virtual_network_computing_to_the_internet.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_vnc_virtual_network_computing_to_the_internet.json similarity index 78% rename from x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/network_vnc_virtual_network_computing_to_the_internet.json rename to x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_vnc_virtual_network_computing_to_the_internet.json index 0a620d355b9ae..e4282539c5a9d 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/network_vnc_virtual_network_computing_to_the_internet.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/command_and_control_vnc_virtual_network_computing_to_the_internet.json @@ -1,14 +1,19 @@ { + "author": [ + "Elastic" + ], "description": "This rule detects network events that may indicate the use of VNC traffic to the Internet. VNC is commonly used by system administrators to remotely control a system for maintenance or to use shared resources. It should almost never be directly exposed to the Internet, as it is frequently targeted and exploited by threat actors as an initial access or back-door vector.", "false_positives": [ "VNC connections may be made directly to Linux cloud server instances but such connections are usually made only by engineers. VNC is less common than SSH or RDP but may be required by some work flows such as remote access and support for specialized software products or servers. Such work-flows are usually known and not unexpected. Usage that is unfamiliar to server or network owners can be unexpected and suspicious." ], "index": [ - "filebeat-*" + "filebeat-*", + "packetbeat-*" ], "language": "kuery", + "license": "Elastic License", "name": "VNC (Virtual Network Computing) to the Internet", - "query": "network.transport:tcp and destination.port >= 5800 and destination.port <= 5810 and source.ip:(10.0.0.0/8 or 172.16.0.0/12 or 192.168.0.0/16) and not destination.ip:(10.0.0.0/8 or 127.0.0.0/8 or 172.16.0.0/12 or 192.168.0.0/16 or \"::1\")", + "query": "event.category:(network or network_traffic) and network.transport:tcp and destination.port >= 5800 and destination.port <= 5810 and source.ip:(10.0.0.0/8 or 172.16.0.0/12 or 192.168.0.0/16) and not destination.ip:(10.0.0.0/8 or 127.0.0.0/8 or 172.16.0.0/12 or 192.168.0.0/16 or \"::1\")", "risk_score": 47, "rule_id": "3ad49c61-7adc-42c1-b788-732eda2f5abf", "severity": "medium", @@ -34,5 +39,5 @@ } ], "type": "query", - "version": 3 + "version": 4 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_attempted_bypass_of_okta_mfa.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_attempted_bypass_of_okta_mfa.json new file mode 100644 index 0000000000000..e3e4b7b54c3b2 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_attempted_bypass_of_okta_mfa.json @@ -0,0 +1,43 @@ +{ + "author": [ + "Elastic" + ], + "description": "An adversary may attempt to bypass the Okta multi-factor authentication (MFA) policies configured for an organization in order to obtain unauthorized access to an application. This rule detects when an Okta MFA bypass attempt occurs.", + "index": [ + "filebeat-*" + ], + "language": "kuery", + "license": "Elastic License", + "name": "Attempted Bypass of Okta MFA", + "query": "event.module:okta and event.dataset:okta.system and event.action:user.mfa.attempt_bypass", + "references": [ + "https://developer.okta.com/docs/reference/api/system-log/", + "https://developer.okta.com/docs/reference/api/event-types/" + ], + "risk_score": 73, + "rule_id": "3805c3dc-f82c-4f8d-891e-63c24d3102b0", + "severity": "high", + "tags": [ + "Elastic", + "Okta" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0006", + "name": "Credential Access", + "reference": "https://attack.mitre.org/tactics/TA0006/" + }, + "technique": [ + { + "id": "T1111", + "name": "Two-Factor Authentication Interception", + "reference": "https://attack.mitre.org/techniques/T1111/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/windows_credential_dumping_msbuild.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_credential_dumping_msbuild.json similarity index 78% rename from x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/windows_credential_dumping_msbuild.json rename to x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_credential_dumping_msbuild.json index 4ff7891438554..a2936f3f09519 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/windows_credential_dumping_msbuild.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_credential_dumping_msbuild.json @@ -1,4 +1,7 @@ { + "author": [ + "Elastic" + ], "description": "An instance of MSBuild, the Microsoft Build Engine, loaded DLLs (dynamically linked libraries) responsible for Windows credential management. This technique is sometimes used for credential dumping.", "false_positives": [ "The Build Engine is commonly used by Windows developers but use by non-engineers is unusual." @@ -7,8 +10,9 @@ "winlogbeat-*" ], "language": "kuery", + "license": "Elastic License", "name": "Microsoft Build Engine Loading Windows Credential Libraries", - "query": "(winlog.event_data.OriginalFileName: (vaultcli.dll or SAMLib.DLL) or dll.name: (vaultcli.dll or SAMLib.DLL)) and process.name: MSBuild.exe and event.action: \"Image loaded (rule: ImageLoad)\"", + "query": "event.category:process and event.type:change and (winlog.event_data.OriginalFileName:(vaultcli.dll or SAMLib.DLL) or dll.name:(vaultcli.dll or SAMLib.DLL)) and process.name: MSBuild.exe", "risk_score": 73, "rule_id": "9d110cb3-5f4b-4c9a-b9f5-53f0a1707ae5", "severity": "high", @@ -34,5 +38,5 @@ } ], "type": "query", - "version": 1 + "version": 2 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_iam_user_addition_to_group.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_iam_user_addition_to_group.json new file mode 100644 index 0000000000000..1e268d2f6bf06 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_iam_user_addition_to_group.json @@ -0,0 +1,62 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies the addition of a user to a specified group in AWS Identity and Access Management (IAM).", + "false_positives": [ + "Adding users to a specified group may be done by a system or network administrator. Verify whether the user identity, user agent, and/or hostname should be making changes in your environment. User additions from unfamiliar users or hosts should be investigated. If known behavior is causing false positives, it can be exempted from the rule." + ], + "from": "now-60m", + "index": [ + "filebeat-*" + ], + "interval": "10m", + "language": "kuery", + "license": "Elastic License", + "name": "AWS IAM User Addition to Group", + "query": "event.action:AddUserToGroup and event.dataset:aws.cloudtrail and event.provider:iam.amazonaws.com and event.outcome:success", + "references": [ + "https://docs.aws.amazon.com/IAM/latest/APIReference/API_AddUserToGroup.html" + ], + "risk_score": 21, + "rule_id": "333de828-8190-4cf5-8d7c-7575846f6fe0", + "severity": "low", + "tags": [ + "AWS", + "Elastic" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0006", + "name": "Credential Access", + "reference": "https://attack.mitre.org/tactics/TA0006/" + }, + "technique": [ + { + "id": "T1098", + "name": "Account Manipulation", + "reference": "https://attack.mitre.org/techniques/T1098/" + } + ] + }, + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0003", + "name": "Persistence", + "reference": "https://attack.mitre.org/tactics/TA0003/" + }, + "technique": [ + { + "id": "T1098", + "name": "Account Manipulation", + "reference": "https://attack.mitre.org/techniques/T1098/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_secretsmanager_getsecretvalue.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_secretsmanager_getsecretvalue.json new file mode 100644 index 0000000000000..740805f71a3cd --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_secretsmanager_getsecretvalue.json @@ -0,0 +1,49 @@ +{ + "author": [ + "Nick Jones", + "Elastic" + ], + "description": "An adversary may attempt to access the secrets in secrets manager to steal certificates, credentials, or other sensitive material", + "false_positives": [ + "Verify whether the user identity, user agent, and/or hostname should be using GetSecretString API for the specified SecretId. If known behavior is causing false positives, it can be exempted from the rule." + ], + "from": "now-60m", + "index": [ + "filebeat-*" + ], + "interval": "10m", + "language": "kuery", + "license": "Elastic License", + "name": "AWS Access Secret in Secrets Manager", + "query": "event.dataset:aws.cloudtrail and event.provider:secretsmanager.amazonaws.com and event.action:GetSecretValue", + "references": [ + "https://docs.aws.amazon.com/secretsmanager/latest/apireference/API_GetSecretValue.html", + "http://detectioninthe.cloud/credential_access/access_secret_in_secrets_manager/" + ], + "risk_score": 21, + "rule_id": "a00681e3-9ed6-447c-ab2c-be648821c622", + "severity": "low", + "tags": [ + "AWS", + "Elastic" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0006", + "name": "Credential Access", + "reference": "https://attack.mitre.org/tactics/TA0006/" + }, + "technique": [ + { + "id": "T1528", + "name": "Steal Application Access Token", + "reference": "https://attack.mitre.org/techniques/T1528/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_tcpdump_activity.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_tcpdump_activity.json similarity index 89% rename from x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_tcpdump_activity.json rename to x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_tcpdump_activity.json index b372645cc492a..9abbe3de148dd 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_tcpdump_activity.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_tcpdump_activity.json @@ -1,4 +1,7 @@ { + "author": [ + "Elastic" + ], "description": "The Tcpdump program ran on a Linux host. Tcpdump is a network monitoring or packet sniffing tool that can be used to capture insecure credentials or data in motion. Sniffing can also be used to discover details of network services as a prelude to lateral movement or defense evasion.", "false_positives": [ "Some normal use of this command may originate from server or network administrators engaged in network troubleshooting." @@ -7,8 +10,9 @@ "auditbeat-*" ], "language": "kuery", + "license": "Elastic License", "name": "Network Sniffing via Tcpdump", - "query": "process.name:tcpdump and event.action:executed", + "query": "event.category:process and event.type:(start or process_started) and process.name:tcpdump", "risk_score": 21, "rule_id": "7a137d76-ce3d-48e2-947d-2747796a78c0", "severity": "low", @@ -49,5 +53,5 @@ } ], "type": "query", - "version": 2 + "version": 3 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/eql_adding_the_hidden_file_attribute_with_via_attribexe.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_adding_the_hidden_file_attribute_with_via_attribexe.json similarity index 85% rename from x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/eql_adding_the_hidden_file_attribute_with_via_attribexe.json rename to x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_adding_the_hidden_file_attribute_with_via_attribexe.json index b61a6236db565..861821d24b73c 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/eql_adding_the_hidden_file_attribute_with_via_attribexe.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_adding_the_hidden_file_attribute_with_via_attribexe.json @@ -1,11 +1,15 @@ { + "author": [ + "Elastic" + ], "description": "Adversaries can add the 'hidden' attribute to files to hide them from the user in an attempt to evade detection.", "index": [ "winlogbeat-*" ], "language": "kuery", + "license": "Elastic License", "name": "Adding Hidden File Attribute via Attrib", - "query": "event.action:\"Process Create (rule: ProcessCreate)\" and process.name:attrib.exe and process.args:+h", + "query": "event.category:process and event.type:(start or process_started) and process.name:attrib.exe and process.args:+h", "risk_score": 21, "rule_id": "4630d948-40d4-4cef-ac69-4002e29bc3db", "severity": "low", @@ -46,5 +50,5 @@ } ], "type": "query", - "version": 2 + "version": 3 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_attempt_to_disable_iptables_or_firewall.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_attempt_to_disable_iptables_or_firewall.json similarity index 65% rename from x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_attempt_to_disable_iptables_or_firewall.json rename to x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_attempt_to_disable_iptables_or_firewall.json index 77d0ddc22ff40..431d133845f0e 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_attempt_to_disable_iptables_or_firewall.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_attempt_to_disable_iptables_or_firewall.json @@ -1,11 +1,15 @@ { + "author": [ + "Elastic" + ], "description": "Adversaries may attempt to disable the iptables or firewall service in an attempt to affect how a host is allowed to receive or send network traffic.", "index": [ "auditbeat-*" ], "language": "kuery", + "license": "Elastic License", "name": "Attempt to Disable IPTables or Firewall", - "query": "event.action:(executed or process_started) and (process.name:service and process.args:stop or process.name:chkconfig and process.args:off) and process.args:(ip6tables or iptables) or process.name:systemctl and process.args:(firewalld and (disable or stop or kill))", + "query": "event.category:process and event.type:(start or process_started) and process.name:ufw and process.args:(allow or disable or reset) or (((process.name:service and process.args:stop) or (process.name:chkconfig and process.args:off) or (process.name:systemctl and process.args:(disable or stop or kill))) and process.args:(firewalld or ip6tables or iptables))", "risk_score": 47, "rule_id": "125417b8-d3df-479f-8418-12d7e034fee3", "severity": "medium", @@ -31,5 +35,5 @@ } ], "type": "query", - "version": 1 + "version": 2 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_attempt_to_disable_syslog_service.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_attempt_to_disable_syslog_service.json similarity index 68% rename from x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_attempt_to_disable_syslog_service.json rename to x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_attempt_to_disable_syslog_service.json index d4584035d53b4..13dd405c79326 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_attempt_to_disable_syslog_service.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_attempt_to_disable_syslog_service.json @@ -1,11 +1,15 @@ { + "author": [ + "Elastic" + ], "description": "Adversaries may attempt to disable the syslog service in an attempt to an attempt to disrupt event logging and evade detection by security controls.", "index": [ "auditbeat-*" ], "language": "kuery", + "license": "Elastic License", "name": "Attempt to Disable Syslog Service", - "query": "event.action:(executed or process_started) and ((process.name:service and process.args:stop) or (process.name:chkconfig and process.args:off) or (process.name:systemctl and process.args:(disable or stop or kill))) and process.args:(syslog or rsyslog or \"syslog-ng\")", + "query": "event.category:process and event.type:(start or process_started) and ((process.name:service and process.args:stop) or (process.name:chkconfig and process.args:off) or (process.name:systemctl and process.args:(disable or stop or kill))) and process.args:(syslog or rsyslog or \"syslog-ng\")", "risk_score": 47, "rule_id": "2f8a1226-5720-437d-9c20-e0029deb6194", "severity": "medium", @@ -31,5 +35,5 @@ } ], "type": "query", - "version": 1 + "version": 2 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_base16_or_base32_encoding_or_decoding_activity.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_base16_or_base32_encoding_or_decoding_activity.json similarity index 86% rename from x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_base16_or_base32_encoding_or_decoding_activity.json rename to x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_base16_or_base32_encoding_or_decoding_activity.json index 9518138ad6799..67fb0b2e6755a 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_base16_or_base32_encoding_or_decoding_activity.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_base16_or_base32_encoding_or_decoding_activity.json @@ -1,4 +1,7 @@ { + "author": [ + "Elastic" + ], "description": "Adversaries may encode/decode data in an attempt to evade detection by host- or network-based security controls.", "false_positives": [ "Automated tools such as Jenkins may encode or decode files as part of their normal behavior. These events can be filtered by the process executable or username values." @@ -7,8 +10,9 @@ "auditbeat-*" ], "language": "kuery", + "license": "Elastic License", "name": "Base16 or Base32 Encoding/Decoding Activity", - "query": "event.action:(executed or process_started) and process.name:(base16 or base32 or base32plain or base32hex)", + "query": "event.category:process and event.type:(start or process_started) and process.name:(base16 or base32 or base32plain or base32hex)", "risk_score": 21, "rule_id": "debff20a-46bc-4a4d-bae5-5cdd14222795", "severity": "low", @@ -49,5 +53,5 @@ } ], "type": "query", - "version": 1 + "version": 2 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_base64_encoding_or_decoding_activity.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_base64_encoding_or_decoding_activity.json similarity index 85% rename from x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_base64_encoding_or_decoding_activity.json rename to x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_base64_encoding_or_decoding_activity.json index 37f3e3eaccd90..f60dede360b4b 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_base64_encoding_or_decoding_activity.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_base64_encoding_or_decoding_activity.json @@ -1,4 +1,7 @@ { + "author": [ + "Elastic" + ], "description": "Adversaries may encode/decode data in an attempt to evade detection by host- or network-based security controls.", "false_positives": [ "Automated tools such as Jenkins may encode or decode files as part of their normal behavior. These events can be filtered by the process executable or username values." @@ -7,8 +10,9 @@ "auditbeat-*" ], "language": "kuery", + "license": "Elastic License", "name": "Base64 Encoding/Decoding Activity", - "query": "event.action:(executed or process_started) and process.name:(base64 or base64plain or base64url or base64mime or base64pem)", + "query": "event.category:process and event.type:(start or process_started) and process.name:(base64 or base64plain or base64url or base64mime or base64pem)", "risk_score": 21, "rule_id": "97f22dab-84e8-409d-955e-dacd1d31670b", "severity": "low", @@ -49,5 +53,5 @@ } ], "type": "query", - "version": 1 + "version": 2 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/eql_clearing_windows_event_logs.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_clearing_windows_event_logs.json similarity index 75% rename from x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/eql_clearing_windows_event_logs.json rename to x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_clearing_windows_event_logs.json index d5e60ce3c10d9..7c6ede8df7346 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/eql_clearing_windows_event_logs.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_clearing_windows_event_logs.json @@ -1,11 +1,15 @@ { + "author": [ + "Elastic" + ], "description": "Identifies attempts to clear Windows event log stores. This is often done by attackers in an attempt to evade detection or destroy forensic evidence on a system.", "index": [ "winlogbeat-*" ], "language": "kuery", + "license": "Elastic License", "name": "Clearing Windows Event Logs", - "query": "event.action:\"Process Create (rule: ProcessCreate)\" and process.name:wevtutil.exe and process.args:cl or process.name:powershell.exe and process.args:Clear-EventLog", + "query": "event.category:process and event.type:(start or process_started) and process.name:wevtutil.exe and process.args:cl or process.name:powershell.exe and process.args:Clear-EventLog", "risk_score": 21, "rule_id": "d331bbe2-6db4-4941-80a5-8270db72eb61", "severity": "low", @@ -31,5 +35,5 @@ } ], "type": "query", - "version": 2 + "version": 3 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_cloudtrail_logging_deleted.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_cloudtrail_logging_deleted.json new file mode 100644 index 0000000000000..2a74b8fecd809 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_cloudtrail_logging_deleted.json @@ -0,0 +1,48 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies the deletion of an AWS log trail. An adversary may delete trails in an attempt to evade defenses.", + "false_positives": [ + "Trail deletions may be made by a system or network administrator. Verify whether the user identity, user agent, and/or hostname should be making changes in your environment. Trail deletions from unfamiliar users or hosts should be investigated. If known behavior is causing false positives, it can be exempted from the rule." + ], + "from": "now-60m", + "index": [ + "filebeat-*" + ], + "interval": "10m", + "language": "kuery", + "license": "Elastic License", + "name": "AWS CloudTrail Log Deleted", + "query": "event.action:DeleteTrail and event.dataset:aws.cloudtrail and event.provider:cloudtrail.amazonaws.com and event.outcome:success", + "references": [ + "https://docs.aws.amazon.com/awscloudtrail/latest/APIReference/API_DeleteTrail.html", + "https://awscli.amazonaws.com/v2/documentation/api/latest/reference/cloudtrail/delete-trail.html" + ], + "risk_score": 47, + "rule_id": "7024e2a0-315d-4334-bb1a-441c593e16ab", + "severity": "medium", + "tags": [ + "AWS", + "Elastic" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0005", + "name": "Defense Evasion", + "reference": "https://attack.mitre.org/tactics/TA0005/" + }, + "technique": [ + { + "id": "T1089", + "name": "Disabling Security Tools", + "reference": "https://attack.mitre.org/techniques/T1089/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_cloudtrail_logging_suspended.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_cloudtrail_logging_suspended.json new file mode 100644 index 0000000000000..5d6c1a93bab1d --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_cloudtrail_logging_suspended.json @@ -0,0 +1,48 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies suspending the recording of AWS API calls and log file delivery for the specified trail. An adversary may suspend trails in an attempt to evade defenses.", + "false_positives": [ + "Suspending the recording of a trail may be done by a system or network administrator. Verify whether the user identity, user agent, and/or hostname should be making changes in your environment. Trail suspensions from unfamiliar users or hosts should be investigated. If known behavior is causing false positives, it can be exempted from the rule." + ], + "from": "now-60m", + "index": [ + "filebeat-*" + ], + "interval": "10m", + "language": "kuery", + "license": "Elastic License", + "name": "AWS CloudTrail Log Suspended", + "query": "event.action:StopLogging and event.dataset:aws.cloudtrail and event.provider:cloudtrail.amazonaws.com and event.outcome:success", + "references": [ + "https://docs.aws.amazon.com/awscloudtrail/latest/APIReference/API_StopLogging.html", + "https://awscli.amazonaws.com/v2/documentation/api/latest/reference/cloudtrail/stop-logging.html" + ], + "risk_score": 47, + "rule_id": "1aa8fa52-44a7-4dae-b058-f3333b91c8d7", + "severity": "medium", + "tags": [ + "AWS", + "Elastic" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0005", + "name": "Defense Evasion", + "reference": "https://attack.mitre.org/tactics/TA0005/" + }, + "technique": [ + { + "id": "T1089", + "name": "Disabling Security Tools", + "reference": "https://attack.mitre.org/techniques/T1089/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_cloudwatch_alarm_deletion.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_cloudwatch_alarm_deletion.json new file mode 100644 index 0000000000000..9ac45ba872809 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_cloudwatch_alarm_deletion.json @@ -0,0 +1,48 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies the deletion of an AWS CloudWatch alarm. An adversary may delete alarms in an attempt to evade defenses.", + "false_positives": [ + "Verify whether the user identity, user agent, and/or hostname should be making changes in your environment. Alarm deletions from unfamiliar users or hosts should be investigated. If known behavior is causing false positives, it can be exempted from the rule." + ], + "from": "now-60m", + "index": [ + "filebeat-*" + ], + "interval": "10m", + "language": "kuery", + "license": "Elastic License", + "name": "AWS CloudWatch Alarm Deletion", + "query": "event.action:DeleteAlarms and event.dataset:aws.cloudtrail and event.provider:monitoring.amazonaws.com and event.outcome:success", + "references": [ + "https://awscli.amazonaws.com/v2/documentation/api/latest/reference/cloudwatch/delete-alarms.html", + "https://docs.aws.amazon.com/AmazonCloudWatch/latest/APIReference/API_DeleteAlarms.html" + ], + "risk_score": 47, + "rule_id": "f772ec8a-e182-483c-91d2-72058f76a44c", + "severity": "medium", + "tags": [ + "AWS", + "Elastic" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0005", + "name": "Defense Evasion", + "reference": "https://attack.mitre.org/tactics/TA0005/" + }, + "technique": [ + { + "id": "T1089", + "name": "Disabling Security Tools", + "reference": "https://attack.mitre.org/techniques/T1089/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_config_service_rule_deletion.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_config_service_rule_deletion.json new file mode 100644 index 0000000000000..9ef37bd4e44e1 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_config_service_rule_deletion.json @@ -0,0 +1,48 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies attempts to delete an AWS Config Service rule. An adversary may tamper with Config rules in order to reduce visibiltiy into the security posture of an account and / or its workload instances.", + "false_positives": [ + "Privileged IAM users with security responsibilities may be expected to make changes to the Config rules in order to align with local security policies and requirements. Automation, orchestration, and security tools may also make changes to the Config service, where they are used to automate setup or configuration of AWS accounts. Other kinds of user or service contexts do not commonly make changes to this service." + ], + "from": "now-60m", + "index": [ + "filebeat-*" + ], + "interval": "10m", + "language": "kuery", + "license": "Elastic License", + "name": "AWS Config Service Tampering", + "query": "event.dataset: aws.cloudtrail and event.action: DeleteConfigRule and event.provider: config.amazonaws.com", + "references": [ + "https://docs.aws.amazon.com/config/latest/developerguide/how-does-config-work.html", + "https://docs.aws.amazon.com/config/latest/APIReference/API_Operations.html" + ], + "risk_score": 47, + "rule_id": "7024e2a0-315d-4334-bb1a-552d604f27bc", + "severity": "medium", + "tags": [ + "AWS", + "Elastic" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0005", + "name": "Defense Evasion", + "reference": "https://attack.mitre.org/tactics/TA0005/" + }, + "technique": [ + { + "id": "T1089", + "name": "Disabling Security Tools", + "reference": "https://attack.mitre.org/techniques/T1089/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_configuration_recorder_stopped.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_configuration_recorder_stopped.json new file mode 100644 index 0000000000000..0aed7aa5ad0ca --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_configuration_recorder_stopped.json @@ -0,0 +1,48 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies an AWS configuration change to stop recording a designated set of resources.", + "false_positives": [ + "Verify whether the user identity, user agent, and/or hostname should be making changes in your environment. Recording changes from unfamiliar users or hosts should be investigated. If known behavior is causing false positives, it can be exempted from the rule." + ], + "from": "now-60m", + "index": [ + "filebeat-*" + ], + "interval": "10m", + "language": "kuery", + "license": "Elastic License", + "name": "AWS Configuration Recorder Stopped", + "query": "event.action:StopConfigurationRecorder and event.dataset:aws.cloudtrail and event.provider:config.amazonaws.com and event.outcome:success", + "references": [ + "https://awscli.amazonaws.com/v2/documentation/api/latest/reference/configservice/stop-configuration-recorder.html", + "https://docs.aws.amazon.com/config/latest/APIReference/API_StopConfigurationRecorder.html" + ], + "risk_score": 73, + "rule_id": "fbd44836-0d69-4004-a0b4-03c20370c435", + "severity": "high", + "tags": [ + "AWS", + "Elastic" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0005", + "name": "Defense Evasion", + "reference": "https://attack.mitre.org/tactics/TA0005/" + }, + "technique": [ + { + "id": "T1089", + "name": "Disabling Security Tools", + "reference": "https://attack.mitre.org/techniques/T1089/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/windows_cve_2020_0601.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_cve_2020_0601.json similarity index 93% rename from x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/windows_cve_2020_0601.json rename to x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_cve_2020_0601.json index b42427a912cbb..2abad3c255f15 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/windows_cve_2020_0601.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_cve_2020_0601.json @@ -1,9 +1,13 @@ { + "author": [ + "Elastic" + ], "description": "A spoofing vulnerability exists in the way Windows CryptoAPI (Crypt32.dll) validates Elliptic Curve Cryptography (ECC) certificates. An attacker could exploit the vulnerability by using a spoofed code-signing certificate to sign a malicious executable, making it appear the file was from a trusted, legitimate source.", "index": [ "winlogbeat-*" ], "language": "kuery", + "license": "Elastic License", "name": "Windows CryptoAPI Spoofing Vulnerability (CVE-2020-0601 - CurveBall)", "query": "event.provider:\"Microsoft-Windows-Audit-CVE\" and message:\"[CVE-2020-0601]\"", "risk_score": 21, @@ -31,5 +35,5 @@ } ], "type": "query", - "version": 1 + "version": 2 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/eql_delete_volume_usn_journal_with_fsutil.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_delete_volume_usn_journal_with_fsutil.json similarity index 79% rename from x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/eql_delete_volume_usn_journal_with_fsutil.json rename to x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_delete_volume_usn_journal_with_fsutil.json index 6f65a871fce77..ba9f43651e32f 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/eql_delete_volume_usn_journal_with_fsutil.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_delete_volume_usn_journal_with_fsutil.json @@ -1,11 +1,15 @@ { + "author": [ + "Elastic" + ], "description": "Identifies use of the fsutil.exe to delete the volume USNJRNL. This technique is used by attackers to eliminate evidence of files created during post-exploitation activities.", "index": [ "winlogbeat-*" ], "language": "kuery", + "license": "Elastic License", "name": "Delete Volume USN Journal with Fsutil", - "query": "event.action:\"Process Create (rule: ProcessCreate)\" and process.name:fsutil.exe and process.args:(deletejournal and usn)", + "query": "event.category:process and event.type:(start or process_started) and process.name:fsutil.exe and process.args:(deletejournal and usn)", "risk_score": 21, "rule_id": "f675872f-6d85-40a3-b502-c0d2ef101e92", "severity": "low", @@ -31,5 +35,5 @@ } ], "type": "query", - "version": 2 + "version": 3 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/eql_deleting_backup_catalogs_with_wbadmin.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_deleting_backup_catalogs_with_wbadmin.json similarity index 78% rename from x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/eql_deleting_backup_catalogs_with_wbadmin.json rename to x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_deleting_backup_catalogs_with_wbadmin.json index 97029cebd665a..79c2d4c25b7d5 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/eql_deleting_backup_catalogs_with_wbadmin.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_deleting_backup_catalogs_with_wbadmin.json @@ -1,11 +1,15 @@ { + "author": [ + "Elastic" + ], "description": "Identifies use of the wbadmin.exe to delete the backup catalog. Ransomware and other malware may do this to prevent system recovery.", "index": [ "winlogbeat-*" ], "language": "kuery", + "license": "Elastic License", "name": "Deleting Backup Catalogs with Wbadmin", - "query": "event.action:\"Process Create (rule: ProcessCreate)\" and process.name:wbadmin.exe and process.args:(catalog and delete)", + "query": "event.category:process and event.type:(start or process_started) and process.name:wbadmin.exe and process.args:(catalog and delete)", "risk_score": 21, "rule_id": "581add16-df76-42bb-af8e-c979bfb39a59", "severity": "low", @@ -31,5 +35,5 @@ } ], "type": "query", - "version": 2 + "version": 3 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_deletion_of_bash_command_line_history.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_deletion_of_bash_command_line_history.json new file mode 100644 index 0000000000000..b9727e18dddcf --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_deletion_of_bash_command_line_history.json @@ -0,0 +1,39 @@ +{ + "author": [ + "Elastic" + ], + "description": "Adversaries may attempt to clear the bash command line history in an attempt to evade detection or forensic investigations.", + "index": [ + "auditbeat-*" + ], + "language": "lucene", + "license": "Elastic License", + "name": "Deletion of Bash Command Line History", + "query": "event.category:process AND event.type:(start or process_started) AND process.name:rm AND process.args:/\\/(home\\/.{1,255}|root)\\/\\.bash_history/", + "risk_score": 47, + "rule_id": "7bcbb3ac-e533-41ad-a612-d6c3bf666aba", + "severity": "medium", + "tags": [ + "Elastic", + "Linux" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0005", + "name": "Defense Evasion", + "reference": "https://attack.mitre.org/tactics/TA0005/" + }, + "technique": [ + { + "id": "T1146", + "name": "Clear Command History", + "reference": "https://attack.mitre.org/techniques/T1146/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_disable_selinux_attempt.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_disable_selinux_attempt.json similarity index 82% rename from x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_disable_selinux_attempt.json rename to x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_disable_selinux_attempt.json index d33331cd4f8d4..e8f5f1a8de1c5 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_disable_selinux_attempt.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_disable_selinux_attempt.json @@ -1,11 +1,15 @@ { + "author": [ + "Elastic" + ], "description": "Identifies potential attempts to disable Security-Enhanced Linux (SELinux), which is a Linux kernel security feature to support access control policies. Adversaries may disable security tools to avoid possible detection of their tools and activities.", "index": [ "auditbeat-*" ], "language": "kuery", + "license": "Elastic License", "name": "Potential Disabling of SELinux", - "query": "event.action:executed and process.name:setenforce and process.args:0", + "query": "event.category:process and event.type:(start or process_started) and process.name:setenforce and process.args:0", "risk_score": 47, "rule_id": "eb9eb8ba-a983-41d9-9c93-a1c05112ca5e", "severity": "medium", @@ -31,5 +35,5 @@ } ], "type": "query", - "version": 1 + "version": 2 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/eql_disable_windows_firewall_rules_with_netsh.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_disable_windows_firewall_rules_with_netsh.json similarity index 76% rename from x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/eql_disable_windows_firewall_rules_with_netsh.json rename to x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_disable_windows_firewall_rules_with_netsh.json index 03af66f2cffb2..2b45f059ec8d9 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/eql_disable_windows_firewall_rules_with_netsh.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_disable_windows_firewall_rules_with_netsh.json @@ -1,11 +1,15 @@ { + "author": [ + "Elastic" + ], "description": "Identifies use of the netsh.exe to disable or weaken the local firewall. Attackers will use this command line tool to disable the firewall during troubleshooting or to enable network mobility.", "index": [ "winlogbeat-*" ], "language": "kuery", + "license": "Elastic License", "name": "Disable Windows Firewall Rules via Netsh", - "query": "event.action:\"Process Create (rule: ProcessCreate)\" and process.name:netsh.exe and process.args:(disable and firewall and set) or process.args:(advfirewall and off and state)", + "query": "event.category:process and event.type:(start or process_started) and process.name:netsh.exe and process.args:(disable and firewall and set) or process.args:(advfirewall and off and state)", "risk_score": 47, "rule_id": "4b438734-3793-4fda-bd42-ceeada0be8f9", "severity": "medium", @@ -31,5 +35,5 @@ } ], "type": "query", - "version": 2 + "version": 3 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_ec2_flow_log_deletion.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_ec2_flow_log_deletion.json new file mode 100644 index 0000000000000..b1f6c42f6f61a --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_ec2_flow_log_deletion.json @@ -0,0 +1,48 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies the deletion of one or more flow logs in AWS Elastic Compute Cloud (EC2). An adversary may delete flow logs in an attempt to evade defenses.", + "false_positives": [ + "Verify whether the user identity, user agent, and/or hostname should be making changes in your environment. Flow log deletions from unfamiliar users or hosts should be investigated. If known behavior is causing false positives, it can be exempted from the rule." + ], + "from": "now-60m", + "index": [ + "filebeat-*" + ], + "interval": "10m", + "language": "kuery", + "license": "Elastic License", + "name": "AWS EC2 Flow Log Deletion", + "query": "event.action:DeleteFlowLogs and event.dataset:aws.cloudtrail and event.provider:ec2.amazonaws.com and event.outcome:success", + "references": [ + "https://awscli.amazonaws.com/v2/documentation/api/latest/reference/ec2/delete-flow-logs.html", + "https://docs.aws.amazon.com/AWSEC2/latest/APIReference/API_DeleteFlowLogs.html" + ], + "risk_score": 73, + "rule_id": "9395fd2c-9947-4472-86ef-4aceb2f7e872", + "severity": "high", + "tags": [ + "AWS", + "Elastic" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0005", + "name": "Defense Evasion", + "reference": "https://attack.mitre.org/tactics/TA0005/" + }, + "technique": [ + { + "id": "T1089", + "name": "Disabling Security Tools", + "reference": "https://attack.mitre.org/techniques/T1089/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_ec2_network_acl_deletion.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_ec2_network_acl_deletion.json new file mode 100644 index 0000000000000..7dc4e33afcd36 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_ec2_network_acl_deletion.json @@ -0,0 +1,50 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies the deletion of an Amazon Elastic Compute Cloud (EC2) network access control list (ACL) or one of its ingress/egress entries.", + "false_positives": [ + "Network ACL's may be deleted by a network administrator. Verify whether the user identity, user agent, and/or hostname should be making changes in your environment. Network ACL deletions from unfamiliar users or hosts should be investigated. If known behavior is causing false positives, it can be exempted from the rule." + ], + "from": "now-60m", + "index": [ + "filebeat-*" + ], + "interval": "10m", + "language": "kuery", + "license": "Elastic License", + "name": "AWS EC2 Network Access Control List Deletion", + "query": "event.action:(DeleteNetworkAcl or DeleteNetworkAclEntry) and event.dataset:aws.cloudtrail and event.provider:ec2.amazonaws.com and event.outcome:success", + "references": [ + "https://awscli.amazonaws.com/v2/documentation/api/latest/reference/ec2/delete-network-acl.html", + "https://docs.aws.amazon.com/AWSEC2/latest/APIReference/API_DeleteNetworkAcl.html", + "https://awscli.amazonaws.com/v2/documentation/api/latest/reference/ec2/delete-network-acl-entry.html", + "https://docs.aws.amazon.com/AWSEC2/latest/APIReference/API_DeleteNetworkAclEntry.html" + ], + "risk_score": 47, + "rule_id": "8623535c-1e17-44e1-aa97-7a0699c3037d", + "severity": "medium", + "tags": [ + "AWS", + "Elastic" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0005", + "name": "Defense Evasion", + "reference": "https://attack.mitre.org/tactics/TA0005/" + }, + "technique": [ + { + "id": "T1089", + "name": "Disabling Security Tools", + "reference": "https://attack.mitre.org/techniques/T1089/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/eql_encoding_or_decoding_files_via_certutil.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_encoding_or_decoding_files_via_certutil.json similarity index 79% rename from x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/eql_encoding_or_decoding_files_via_certutil.json rename to x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_encoding_or_decoding_files_via_certutil.json index aaca5242e717b..056de9e5c003e 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/eql_encoding_or_decoding_files_via_certutil.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_encoding_or_decoding_files_via_certutil.json @@ -1,11 +1,15 @@ { + "author": [ + "Elastic" + ], "description": "Identifies the use of certutil.exe to encode or decode data. CertUtil is a native Windows component which is part of Certificate Services. CertUtil is often abused by attackers to encode or decode base64 data for stealthier command and control or exfiltration.", "index": [ "winlogbeat-*" ], "language": "kuery", + "license": "Elastic License", "name": "Encoding or Decoding Files via CertUtil", - "query": "event.action:\"Process Create (rule: ProcessCreate)\" and process.name:certutil.exe and process.args:(-decode or -encode or /decode or /encode)", + "query": "event.category:process and event.type:(start or process_started) and process.name:certutil.exe and process.args:(-decode or -encode or /decode or /encode)", "risk_score": 47, "rule_id": "fd70c98a-c410-42dc-a2e3-761c71848acf", "severity": "medium", @@ -31,5 +35,5 @@ } ], "type": "query", - "version": 2 + "version": 3 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/windows_execution_msbuild_started_by_office_app.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_execution_msbuild_started_by_office_app.json similarity index 83% rename from x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/windows_execution_msbuild_started_by_office_app.json rename to x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_execution_msbuild_started_by_office_app.json index 78f34c15bbd31..814caee4e888a 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/windows_execution_msbuild_started_by_office_app.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_execution_msbuild_started_by_office_app.json @@ -1,4 +1,7 @@ { + "author": [ + "Elastic" + ], "description": "An instance of MSBuild, the Microsoft Build Engine, was started by Excel or Word. This is unusual behavior for the Build Engine and could have been caused by an Excel or Word document executing a malicious script payload.", "false_positives": [ "The Build Engine is commonly used by Windows developers but use by non-engineers is unusual. It is quite unusual for this program to be started by an Office application like Word or Excel." @@ -7,8 +10,9 @@ "winlogbeat-*" ], "language": "kuery", + "license": "Elastic License", "name": "Microsoft Build Engine Started by an Office Application", - "query": "process.name:MSBuild.exe and process.parent.name:(eqnedt32.exe or excel.exe or fltldr.exe or msaccess.exe or mspub.exe or outlook.exe or powerpnt.exe or winword.exe) and event.action: \"Process Create (rule: ProcessCreate)\"", + "query": "event.category:process and event.type:(start or process_started) and process.name:MSBuild.exe and process.parent.name:(eqnedt32.exe or excel.exe or fltldr.exe or msaccess.exe or mspub.exe or outlook.exe or powerpnt.exe or winword.exe)", "references": [ "https://blog.talosintelligence.com/2020/02/building-bypass-with-msbuild.html" ], @@ -52,5 +56,5 @@ } ], "type": "query", - "version": 1 + "version": 2 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/windows_execution_msbuild_started_by_script.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_execution_msbuild_started_by_script.json similarity index 84% rename from x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/windows_execution_msbuild_started_by_script.json rename to x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_execution_msbuild_started_by_script.json index 3952a4680a523..6426f8722df3d 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/windows_execution_msbuild_started_by_script.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_execution_msbuild_started_by_script.json @@ -1,4 +1,7 @@ { + "author": [ + "Elastic" + ], "description": "An instance of MSBuild, the Microsoft Build Engine, was started by a script or the Windows command interpreter. This behavior is unusual and is sometimes used by malicious payloads.", "false_positives": [ "The Build Engine is commonly used by Windows developers but use by non-engineers is unusual." @@ -7,8 +10,9 @@ "winlogbeat-*" ], "language": "kuery", + "license": "Elastic License", "name": "Microsoft Build Engine Started by a Script Process", - "query": "process.name:MSBuild.exe and process.parent.name:(cmd.exe or powershell.exe or cscript.exe or wscript.exe) and event.action:\"Process Create (rule: ProcessCreate)\"", + "query": "event.category:process and event.type: start and process.name:MSBuild.exe and process.parent.name:(cmd.exe or powershell.exe or cscript.exe or wscript.exe)", "risk_score": 21, "rule_id": "9d110cb3-5f4b-4c9a-b9f5-53f0a1707ae2", "severity": "low", @@ -49,5 +53,5 @@ } ], "type": "query", - "version": 1 + "version": 2 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/windows_execution_msbuild_started_by_system_process.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_execution_msbuild_started_by_system_process.json similarity index 85% rename from x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/windows_execution_msbuild_started_by_system_process.json rename to x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_execution_msbuild_started_by_system_process.json index a2e29c3900144..b27dfced0f4f6 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/windows_execution_msbuild_started_by_system_process.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_execution_msbuild_started_by_system_process.json @@ -1,4 +1,7 @@ { + "author": [ + "Elastic" + ], "description": "An instance of MSBuild, the Microsoft Build Engine, was started by Explorer or the WMI (Windows Management Instrumentation) subsystem. This behavior is unusual and is sometimes used by malicious payloads.", "false_positives": [ "The Build Engine is commonly used by Windows developers but use by non-engineers is unusual." @@ -7,8 +10,9 @@ "winlogbeat-*" ], "language": "kuery", + "license": "Elastic License", "name": "Microsoft Build Engine Started by a System Process", - "query": "process.name:MSBuild.exe and process.parent.name:(explorer.exe or wmiprvse.exe) and event.action:\"Process Create (rule: ProcessCreate)\"", + "query": "event.category:process and event.type:(start or process_started) and process.name:MSBuild.exe and process.parent.name:(explorer.exe or wmiprvse.exe)", "risk_score": 47, "rule_id": "9d110cb3-5f4b-4c9a-b9f5-53f0a1707ae3", "severity": "medium", @@ -49,5 +53,5 @@ } ], "type": "query", - "version": 1 + "version": 2 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/windows_execution_msbuild_started_renamed.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_execution_msbuild_started_renamed.json similarity index 77% rename from x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/windows_execution_msbuild_started_renamed.json rename to x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_execution_msbuild_started_renamed.json index 1e63b259a86ec..d7da758e57c6d 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/windows_execution_msbuild_started_renamed.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_execution_msbuild_started_renamed.json @@ -1,4 +1,7 @@ { + "author": [ + "Elastic" + ], "description": "An instance of MSBuild, the Microsoft Build Engine, was started after being renamed. This is uncommon behavior and may indicate an attempt to run unnoticed or undetected.", "false_positives": [ "The Build Engine is commonly used by Windows developers but use by non-engineers is unusual." @@ -7,8 +10,9 @@ "winlogbeat-*" ], "language": "kuery", + "license": "Elastic License", "name": "Microsoft Build Engine Using an Alternate Name", - "query": "(pe.original_file_name:MSBuild.exe or winlog.event_data.OriginalFileName: MSBuild.exe) and not process.name: MSBuild.exe and event.action: \"Process Create (rule: ProcessCreate)\"", + "query": "event.category:process and event.type:(start or process_started) and (pe.original_file_name:MSBuild.exe or winlog.event_data.OriginalFileName:MSBuild.exe) and not process.name: MSBuild.exe", "risk_score": 21, "rule_id": "9d110cb3-5f4b-4c9a-b9f5-53f0a1707ae4", "severity": "low", @@ -34,5 +38,5 @@ } ], "type": "query", - "version": 1 + "version": 2 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/windows_execution_msbuild_started_unusal_process.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_execution_msbuild_started_unusal_process.json similarity index 82% rename from x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/windows_execution_msbuild_started_unusal_process.json rename to x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_execution_msbuild_started_unusal_process.json index 117d5982421a4..30d482e9b9569 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/windows_execution_msbuild_started_unusal_process.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_execution_msbuild_started_unusal_process.json @@ -1,4 +1,7 @@ { + "author": [ + "Elastic" + ], "description": "An instance of MSBuild, the Microsoft Build Engine, started a PowerShell script or the Visual C# Command Line Compiler. This technique is sometimes used to deploy a malicious payload using the Build Engine.", "false_positives": [ "The Build Engine is commonly used by Windows developers but use by non-engineers is unusual. If a build system triggers this rule it can be exempted by process, user or host name." @@ -7,8 +10,9 @@ "winlogbeat-*" ], "language": "kuery", + "license": "Elastic License", "name": "Microsoft Build Engine Started an Unusual Process", - "query": "process.parent.name:MSBuild.exe and process.name:(csc.exe or iexplore.exe or powershell.exe)", + "query": "event.category:process and event.type:(start or process_started) and process.parent.name:MSBuild.exe and process.name:(csc.exe or iexplore.exe or powershell.exe)", "references": [ "https://blog.talosintelligence.com/2020/02/building-bypass-with-msbuild.html" ], @@ -37,5 +41,5 @@ } ], "type": "query", - "version": 1 + "version": 2 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/windows_execution_via_trusted_developer_utilities.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_execution_via_trusted_developer_utilities.json similarity index 94% rename from x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/windows_execution_via_trusted_developer_utilities.json rename to x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_execution_via_trusted_developer_utilities.json index 202bfc6b46afc..480169e5ed991 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/windows_execution_via_trusted_developer_utilities.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_execution_via_trusted_developer_utilities.json @@ -1,4 +1,7 @@ { + "author": [ + "Elastic" + ], "description": "Identifies possibly suspicious activity using trusted Windows developer activity.", "false_positives": [ "These programs may be used by Windows developers but use by non-engineers is unusual." @@ -7,6 +10,7 @@ "winlogbeat-*" ], "language": "kuery", + "license": "Elastic License", "name": "Trusted Developer Application Usage", "query": "event.code:1 and process.name:(MSBuild.exe or msxsl.exe)", "risk_score": 21, @@ -49,5 +53,5 @@ } ], "type": "query", - "version": 2 + "version": 3 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_file_deletion_via_shred.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_file_deletion_via_shred.json similarity index 79% rename from x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_file_deletion_via_shred.json rename to x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_file_deletion_via_shred.json index 4fd72a212f0ba..4aad56abd0534 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_file_deletion_via_shred.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_file_deletion_via_shred.json @@ -1,11 +1,15 @@ { + "author": [ + "Elastic" + ], "description": "Malware or other files dropped or created on a system by an adversary may leave traces behind as to what was done within a network and how. Adversaries may remove these files over the course of an intrusion to keep their footprint low or remove them at the end as part of the post-intrusion cleanup process.", "index": [ "auditbeat-*" ], "language": "kuery", + "license": "Elastic License", "name": "File Deletion via Shred", - "query": "event.action:(executed or process_started) and process.name:shred and process.args:(\"-u\" or \"--remove\" or \"-z\" or \"--zero\")", + "query": "event.category:process and event.type:(start or process_started) and process.name:shred and process.args:(\"-u\" or \"--remove\" or \"-z\" or \"--zero\")", "risk_score": 21, "rule_id": "a1329140-8de3-4445-9f87-908fb6d824f4", "severity": "low", @@ -31,5 +35,5 @@ } ], "type": "query", - "version": 1 + "version": 2 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_file_mod_writable_dir.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_file_mod_writable_dir.json similarity index 78% rename from x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_file_mod_writable_dir.json rename to x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_file_mod_writable_dir.json index 66c5848b17707..c630ad1eecec0 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_file_mod_writable_dir.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_file_mod_writable_dir.json @@ -1,4 +1,7 @@ { + "author": [ + "Elastic" + ], "description": "Identifies file permission modifications in common writable directories by a non-root user. Adversaries often drop files or payloads into a writable directory and change permissions prior to execution.", "false_positives": [ "Certain programs or applications may modify files or change ownership in writable directories. These can be exempted by username." @@ -7,8 +10,9 @@ "auditbeat-*" ], "language": "kuery", + "license": "Elastic License", "name": "File Permission Modification in Writable Directory", - "query": "event.action:executed and process.name:(chmod or chown or chattr or chgrp) and process.working_directory:(/tmp or /var/tmp or /dev/shm) and not user.name:root", + "query": "event.category:process and event.type:(start or process_started) and process.name:(chmod or chown or chattr or chgrp) and process.working_directory:(/tmp or /var/tmp or /dev/shm) and not user.name:root", "risk_score": 21, "rule_id": "9f9a2a82-93a8-4b1a-8778-1780895626d4", "severity": "low", @@ -34,5 +38,5 @@ } ], "type": "query", - "version": 1 + "version": 2 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_guardduty_detector_deletion.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_guardduty_detector_deletion.json new file mode 100644 index 0000000000000..c456396c85cd8 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_guardduty_detector_deletion.json @@ -0,0 +1,48 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies the deletion of an Amazon GuardDuty detector. Upon deletion, GuardDuty stops monitoring the environment and all existing findings are lost.", + "false_positives": [ + "The GuardDuty detector may be deleted by a system or network administrator. Verify whether the user identity, user agent, and/or hostname should be making changes in your environment. Detector deletions from unfamiliar users or hosts should be investigated. If known behavior is causing false positives, it can be exempted from the rule." + ], + "from": "now-60m", + "index": [ + "filebeat-*" + ], + "interval": "10m", + "language": "kuery", + "license": "Elastic License", + "name": "AWS GuardDuty Detector Deletion", + "query": "event.action:DeleteDetector and event.dataset:aws.cloudtrail and event.provider:guardduty.amazonaws.com and event.outcome:success", + "references": [ + "https://awscli.amazonaws.com/v2/documentation/api/latest/reference/guardduty/delete-detector.html", + "https://docs.aws.amazon.com/guardduty/latest/APIReference/API_DeleteDetector.html" + ], + "risk_score": 73, + "rule_id": "523116c0-d89d-4d7c-82c2-39e6845a78ef", + "severity": "high", + "tags": [ + "AWS", + "Elastic" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0005", + "name": "Defense Evasion", + "reference": "https://attack.mitre.org/tactics/TA0005/" + }, + "technique": [ + { + "id": "T1089", + "name": "Disabling Security Tools", + "reference": "https://attack.mitre.org/techniques/T1089/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_hex_encoding_or_decoding_activity.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_hex_encoding_or_decoding_activity.json similarity index 87% rename from x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_hex_encoding_or_decoding_activity.json rename to x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_hex_encoding_or_decoding_activity.json index a67d310d2ad81..3c1ea7ee229c9 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_hex_encoding_or_decoding_activity.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_hex_encoding_or_decoding_activity.json @@ -1,4 +1,7 @@ { + "author": [ + "Elastic" + ], "description": "Adversaries may encode/decode data in an attempt to evade detection by host- or network-based security controls.", "false_positives": [ "Automated tools such as Jenkins may encode or decode files as part of their normal behavior. These events can be filtered by the process executable or username values." @@ -7,8 +10,9 @@ "auditbeat-*" ], "language": "kuery", + "license": "Elastic License", "name": "Hex Encoding/Decoding Activity", - "query": "event.action:(executed or process_started) and process.name:(hex or xxd)", + "query": "event.category:process and event.type:(start or process_started) and process.name:(hexdump or od or xxd)", "risk_score": 21, "rule_id": "a9198571-b135-4a76-b055-e3e5a476fd83", "severity": "low", @@ -49,5 +53,5 @@ } ], "type": "query", - "version": 1 + "version": 2 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_hidden_file_dir_tmp.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_hidden_file_dir_tmp.json new file mode 100644 index 0000000000000..7202d9be3b8c3 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_hidden_file_dir_tmp.json @@ -0,0 +1,58 @@ +{ + "author": [ + "Elastic" + ], + "description": "Users can mark specific files as hidden simply by putting a \".\" as the first character in the file or folder name. Adversaries can use this to their advantage to hide files and folders on the system for persistence and defense evasion. This rule looks for hidden files or folders in common writable directories.", + "false_positives": [ + "Certain tools may create hidden temporary files or directories upon installation or as part of their normal behavior. These events can be filtered by the process arguments, username, or process name values." + ], + "index": [ + "auditbeat-*" + ], + "language": "lucene", + "license": "Elastic License", + "max_signals": 33, + "name": "Creation of Hidden Files and Directories", + "query": "event.category:process AND event.type:(start or process_started) AND process.working_directory:(\"/tmp\" or \"/var/tmp\" or \"/dev/shm\") AND process.args:/\\.[a-zA-Z0-9_\\-][a-zA-Z0-9_\\-\\.]{1,254}/ AND NOT process.name:(ls or find)", + "risk_score": 47, + "rule_id": "b9666521-4742-49ce-9ddc-b8e84c35acae", + "severity": "medium", + "tags": [ + "Elastic", + "Linux" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0005", + "name": "Defense Evasion", + "reference": "https://attack.mitre.org/tactics/TA0005/" + }, + "technique": [ + { + "id": "T1158", + "name": "Hidden Files and Directories", + "reference": "https://attack.mitre.org/techniques/T1158/" + } + ] + }, + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0003", + "name": "Persistence", + "reference": "https://attack.mitre.org/tactics/TA0003/" + }, + "technique": [ + { + "id": "T1158", + "name": "Hidden Files and Directories", + "reference": "https://attack.mitre.org/techniques/T1158/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/windows_injection_msbuild.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_injection_msbuild.json similarity index 94% rename from x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/windows_injection_msbuild.json rename to x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_injection_msbuild.json index 32a8f50c4b911..9abce01769e92 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/windows_injection_msbuild.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_injection_msbuild.json @@ -1,4 +1,7 @@ { + "author": [ + "Elastic" + ], "description": "An instance of MSBuild, the Microsoft Build Engine, created a thread in another process. This technique is sometimes used to evade detection or elevate privileges.", "false_positives": [ "The Build Engine is commonly used by Windows developers but use by non-engineers is unusual." @@ -7,6 +10,7 @@ "winlogbeat-*" ], "language": "kuery", + "license": "Elastic License", "name": "Process Injection by the Microsoft Build Engine", "query": "process.name:MSBuild.exe and event.action:\"CreateRemoteThread detected (rule: CreateRemoteThread)\"", "risk_score": 21, @@ -49,5 +53,5 @@ } ], "type": "query", - "version": 1 + "version": 2 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_kernel_module_removal.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_kernel_module_removal.json similarity index 86% rename from x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_kernel_module_removal.json rename to x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_kernel_module_removal.json index bb88a2acad53d..f055ee44efb39 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_kernel_module_removal.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_kernel_module_removal.json @@ -1,4 +1,7 @@ { + "author": [ + "Elastic" + ], "description": "Kernel modules are pieces of code that can be loaded and unloaded into the kernel upon demand. They extend the functionality of the kernel without the need to reboot the system. This rule identifies attempts to remove a kernel module.", "false_positives": [ "There is usually no reason to remove modules, but some buggy modules require it. These can be exempted by username. Note that some Linux distributions are not built to support the removal of modules at all." @@ -7,8 +10,9 @@ "auditbeat-*" ], "language": "kuery", + "license": "Elastic License", "name": "Kernel Module Removal", - "query": "event.action:executed and process.args:(rmmod and sudo or modprobe and sudo and (\"--remove\" or \"-r\"))", + "query": "event.category:process and event.type:(start or process_started) and process.args:((rmmod and sudo) or (modprobe and sudo and (\"--remove\" or \"-r\")))", "references": [ "http://man7.org/linux/man-pages/man8/modprobe.8.html" ], @@ -52,5 +56,5 @@ } ], "type": "query", - "version": 1 + "version": 2 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/windows_misc_lolbin_connecting_to_the_internet.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_misc_lolbin_connecting_to_the_internet.json similarity index 80% rename from x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/windows_misc_lolbin_connecting_to_the_internet.json rename to x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_misc_lolbin_connecting_to_the_internet.json index 361a3e99b4dbd..afa1467b15074 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/windows_misc_lolbin_connecting_to_the_internet.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_misc_lolbin_connecting_to_the_internet.json @@ -1,11 +1,15 @@ { - "description": "Binaries signed with trusted digital certificates can execute on Windows systems protected by digital signature validation. Adversaries may use these binaries to 'live off the land' and execute malicious files that could bypass application whitelisting and signature validation.", + "author": [ + "Elastic" + ], + "description": "Binaries signed with trusted digital certificates can execute on Windows systems protected by digital signature validation. Adversaries may use these binaries to 'live off the land' and execute malicious files that could bypass application allowlists and signature validation.", "index": [ "winlogbeat-*" ], "language": "kuery", + "license": "Elastic License", "name": "Network Connection via Signed Binary", - "query": "process.name:(expand.exe or extrac.exe or ieexec.exe or makecab.exe) and event.action:\"Network connection detected (rule: NetworkConnect)\" and not destination.ip:(10.0.0.0/8 or 172.16.0.0/12 or 192.168.0.0/16)", + "query": "event.category:network and event.type:connection and process.name:(expand.exe or extrac.exe or ieexec.exe or makecab.exe) and not destination.ip:(10.0.0.0/8 or 172.16.0.0/12 or 192.168.0.0/16)", "risk_score": 21, "rule_id": "63e65ec3-43b1-45b0-8f2d-45b34291dc44", "severity": "low", @@ -46,5 +50,5 @@ } ], "type": "query", - "version": 2 + "version": 3 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/windows_modification_of_boot_config.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_modification_of_boot_config.json similarity index 74% rename from x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/windows_modification_of_boot_config.json rename to x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_modification_of_boot_config.json index 66195acafa5cb..801b60a2572e2 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/windows_modification_of_boot_config.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_modification_of_boot_config.json @@ -1,11 +1,15 @@ { + "author": [ + "Elastic" + ], "description": "Identifies use of bcdedit.exe to delete boot configuration data. This tactic is sometimes used as by malware or an attacker as a destructive technique.", "index": [ "winlogbeat-*" ], "language": "kuery", + "license": "Elastic License", "name": "Modification of Boot Configuration", - "query": "event.action:\"Process Create (rule: ProcessCreate)\" and process.name:bcdedit.exe and process.args:(/set and (bootstatuspolicy and ignoreallfailures or no and recoveryenabled))", + "query": "event.category:process and event.type:(start or process_started) and process.name:bcdedit.exe and process.args:(/set and (bootstatuspolicy and ignoreallfailures or no and recoveryenabled))", "risk_score": 21, "rule_id": "69c251fb-a5d6-4035-b5ec-40438bd829ff", "severity": "low", @@ -31,5 +35,5 @@ } ], "type": "query", - "version": 1 + "version": 2 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_s3_bucket_configuration_deletion.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_s3_bucket_configuration_deletion.json new file mode 100644 index 0000000000000..77f9e0f4a313c --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_s3_bucket_configuration_deletion.json @@ -0,0 +1,51 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies the deletion of various Amazon Simple Storage Service (S3) bucket configuration components.", + "false_positives": [ + "Bucket components may be deleted by a system or network administrator. Verify whether the user identity, user agent, and/or hostname should be making changes in your environment. Bucket component deletions from unfamiliar users or hosts should be investigated. If known behavior is causing false positives, it can be exempted from the rule." + ], + "from": "now-60m", + "index": [ + "filebeat-*" + ], + "interval": "10m", + "language": "kuery", + "license": "Elastic License", + "name": "AWS S3 Bucket Configuration Deletion", + "query": "event.action:(DeleteBucketPolicy or DeleteBucketReplication or DeleteBucketCors or DeleteBucketEncryption or DeleteBucketLifecycle) and event.dataset:aws.cloudtrail and event.provider:s3.amazonaws.com and event.outcome:success", + "references": [ + "https://docs.aws.amazon.com/AmazonS3/latest/API/API_DeleteBucketPolicy.html", + "https://docs.aws.amazon.com/AmazonS3/latest/API/API_DeleteBucketReplication.html", + "https://docs.aws.amazon.com/AmazonS3/latest/API/API_DeleteBucketCors.html", + "https://docs.aws.amazon.com/AmazonS3/latest/API/API_DeleteBucketEncryption.html", + "https://docs.aws.amazon.com/AmazonS3/latest/API/API_DeleteBucketLifecycle.html" + ], + "risk_score": 21, + "rule_id": "227dc608-e558-43d9-b521-150772250bae", + "severity": "low", + "tags": [ + "AWS", + "Elastic" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0005", + "name": "Defense Evasion", + "reference": "https://attack.mitre.org/tactics/TA0005/" + }, + "technique": [ + { + "id": "T1070", + "name": "Indicator Removal on Host", + "reference": "https://attack.mitre.org/techniques/T1070/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/windows_defense_evasion_via_filter_manager.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_via_filter_manager.json similarity index 91% rename from x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/windows_defense_evasion_via_filter_manager.json rename to x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_via_filter_manager.json index ba684c4d721ee..24d1899fe5593 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/windows_defense_evasion_via_filter_manager.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_via_filter_manager.json @@ -1,9 +1,13 @@ { + "author": [ + "Elastic" + ], "description": "The Filter Manager Control Program (fltMC.exe) binary may be abused by adversaries to unload a filter driver and evade defenses.", "index": [ "winlogbeat-*" ], "language": "kuery", + "license": "Elastic License", "name": "Potential Evasion via Filter Manager", "query": "event.code:1 and process.name:fltMC.exe", "risk_score": 21, @@ -31,5 +35,5 @@ } ], "type": "query", - "version": 2 + "version": 3 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/eql_volume_shadow_copy_deletion_via_vssadmin.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_volume_shadow_copy_deletion_via_vssadmin.json similarity index 78% rename from x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/eql_volume_shadow_copy_deletion_via_vssadmin.json rename to x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_volume_shadow_copy_deletion_via_vssadmin.json index 700fd5215133d..3166cc23ae726 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/eql_volume_shadow_copy_deletion_via_vssadmin.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_volume_shadow_copy_deletion_via_vssadmin.json @@ -1,11 +1,15 @@ { + "author": [ + "Elastic" + ], "description": "Identifies use of vssadmin.exe for shadow copy deletion on endpoints. This commonly occurs in tandem with ransomware or other destructive attacks.", "index": [ "winlogbeat-*" ], "language": "kuery", + "license": "Elastic License", "name": "Volume Shadow Copy Deletion via VssAdmin", - "query": "event.action:\"Process Create (rule: ProcessCreate)\" and process.name:vssadmin.exe and process.args:(delete and shadows)", + "query": "event.category:process and event.type:(start or process_started) and process.name:vssadmin.exe and process.args:(delete and shadows)", "risk_score": 73, "rule_id": "b5ea4bfe-a1b2-421f-9d47-22a75a6f2921", "severity": "high", @@ -31,5 +35,5 @@ } ], "type": "query", - "version": 2 + "version": 3 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/eql_volume_shadow_copy_deletion_via_wmic.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_volume_shadow_copy_deletion_via_wmic.json similarity index 78% rename from x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/eql_volume_shadow_copy_deletion_via_wmic.json rename to x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_volume_shadow_copy_deletion_via_wmic.json index 59222be6c598a..730879684a811 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/eql_volume_shadow_copy_deletion_via_wmic.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_volume_shadow_copy_deletion_via_wmic.json @@ -1,11 +1,15 @@ { + "author": [ + "Elastic" + ], "description": "Identifies use of wmic.exe for shadow copy deletion on endpoints. This commonly occurs in tandem with ransomware or other destructive attacks.", "index": [ "winlogbeat-*" ], "language": "kuery", + "license": "Elastic License", "name": "Volume Shadow Copy Deletion via WMIC", - "query": "event.action:\"Process Create (rule: ProcessCreate)\" and process.name:WMIC.exe and process.args:(delete and shadowcopy)", + "query": "event.category:process and event.type:(start or process_started) and process.name:WMIC.exe and process.args:(delete and shadowcopy)", "risk_score": 73, "rule_id": "dc9c1f74-dac3-48e3-b47f-eb79db358f57", "severity": "high", @@ -31,5 +35,5 @@ } ], "type": "query", - "version": 2 + "version": 3 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_waf_acl_deletion.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_waf_acl_deletion.json new file mode 100644 index 0000000000000..708f931a5f8ab --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_waf_acl_deletion.json @@ -0,0 +1,48 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies the deletion of a specified AWS Web Application Firewall (WAF) access control list.", + "false_positives": [ + "Firewall ACL's may be deleted by a system or network administrator. Verify whether the user identity, user agent, and/or hostname should be making changes in your environment. Web ACL deletions from unfamiliar users or hosts should be investigated. If known behavior is causing false positives, it can be exempted from the rule." + ], + "from": "now-60m", + "index": [ + "filebeat-*" + ], + "interval": "10m", + "language": "kuery", + "license": "Elastic License", + "name": "AWS WAF Access Control List Deletion", + "query": "event.action:DeleteWebACL and event.dataset:aws.cloudtrail and event.outcome:success", + "references": [ + "https://awscli.amazonaws.com/v2/documentation/api/latest/reference/waf-regional/delete-web-acl.html", + "https://docs.aws.amazon.com/waf/latest/APIReference/API_wafRegional_DeleteWebACL.html" + ], + "risk_score": 47, + "rule_id": "91d04cd4-47a9-4334-ab14-084abe274d49", + "severity": "medium", + "tags": [ + "AWS", + "Elastic" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0005", + "name": "Defense Evasion", + "reference": "https://attack.mitre.org/tactics/TA0005/" + }, + "technique": [ + { + "id": "T1089", + "name": "Disabling Security Tools", + "reference": "https://attack.mitre.org/techniques/T1089/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_waf_rule_or_rule_group_deletion.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_waf_rule_or_rule_group_deletion.json new file mode 100644 index 0000000000000..37dae51ec3125 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_waf_rule_or_rule_group_deletion.json @@ -0,0 +1,48 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies the deletion of a specified AWS Web Application Firewall (WAF) rule or rule group.", + "false_positives": [ + "WAF rules or rule groups may be deleted by a system or network administrator. Verify whether the user identity, user agent, and/or hostname should be making changes in your environment. Rule deletions from unfamiliar users or hosts should be investigated. If known behavior is causing false positives, it can be exempted from the rule." + ], + "from": "now-60m", + "index": [ + "filebeat-*" + ], + "interval": "10m", + "language": "kuery", + "license": "Elastic License", + "name": "AWS WAF Rule or Rule Group Deletion", + "query": "event.module:aws and event.dataset:aws.cloudtrail and event.action:(DeleteRule or DeleteRuleGroup) and event.outcome:success", + "references": [ + "https://awscli.amazonaws.com/v2/documentation/api/latest/reference/waf/delete-rule-group.html", + "https://docs.aws.amazon.com/waf/latest/APIReference/API_waf_DeleteRuleGroup.html" + ], + "risk_score": 47, + "rule_id": "5beaebc1-cc13-4bfc-9949-776f9e0dc318", + "severity": "medium", + "tags": [ + "AWS", + "Elastic" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0005", + "name": "Defense Evasion", + "reference": "https://attack.mitre.org/tactics/TA0005/" + }, + "technique": [ + { + "id": "T1089", + "name": "Disabling Security Tools", + "reference": "https://attack.mitre.org/techniques/T1089/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_kernel_module_enumeration.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/discovery_kernel_module_enumeration.json similarity index 83% rename from x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_kernel_module_enumeration.json rename to x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/discovery_kernel_module_enumeration.json index 85564506bcff9..14472f02280a3 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_kernel_module_enumeration.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/discovery_kernel_module_enumeration.json @@ -1,4 +1,7 @@ { + "author": [ + "Elastic" + ], "description": "Loadable Kernel Modules (or LKMs) are pieces of code that can be loaded and unloaded into the kernel upon demand. They extend the functionality of the kernel without the need to reboot the system. This identifies attempts to enumerate information about a kernel module.", "false_positives": [ "Security tools and device drivers may run these programs in order to enumerate kernel modules. Use of these programs by ordinary users is uncommon. These can be exempted by process name or username." @@ -7,8 +10,9 @@ "auditbeat-*" ], "language": "kuery", + "license": "Elastic License", "name": "Enumeration of Kernel Modules", - "query": "event.action:executed and process.args:(kmod and list and sudo or sudo and (depmod or lsmod or modinfo))", + "query": "event.category:process and event.type:(start or process_started) and process.args:(kmod and list and sudo or sudo and (depmod or lsmod or modinfo))", "risk_score": 47, "rule_id": "2d8043ed-5bda-4caf-801c-c1feb7410504", "severity": "medium", @@ -34,5 +38,5 @@ } ], "type": "query", - "version": 1 + "version": 2 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/windows_net_command_system_account.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/discovery_net_command_system_account.json similarity index 77% rename from x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/windows_net_command_system_account.json rename to x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/discovery_net_command_system_account.json index b2770ac2383fd..a2fe82c43b15a 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/windows_net_command_system_account.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/discovery_net_command_system_account.json @@ -1,11 +1,15 @@ { + "author": [ + "Elastic" + ], "description": "Identifies the SYSTEM account using the Net utility. The Net utility is a component of the Windows operating system. It is used in command line operations for control of users, groups, services, and network connections.", "index": [ "winlogbeat-*" ], "language": "kuery", + "license": "Elastic License", "name": "Net command via SYSTEM account", - "query": "(process.name:net.exe or process.name:net1.exe and not process.parent.name:net.exe) and user.name:SYSTEM and event.action:\"Process Create (rule: ProcessCreate)\"", + "query": "event.category:process and event.type:(start or process_started) and (process.name:net.exe or process.name:net1.exe and not process.parent.name:net.exe) and user.name:SYSTEM", "risk_score": 21, "rule_id": "2856446a-34e6-435b-9fb5-f8f040bfa7ed", "severity": "low", @@ -31,5 +35,5 @@ } ], "type": "query", - "version": 1 + "version": 2 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/windows_process_discovery_via_tasklist_command.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/discovery_process_discovery_via_tasklist_command.json similarity index 93% rename from x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/windows_process_discovery_via_tasklist_command.json rename to x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/discovery_process_discovery_via_tasklist_command.json index 489c8a47561b5..e9a495c752f95 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/windows_process_discovery_via_tasklist_command.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/discovery_process_discovery_via_tasklist_command.json @@ -1,4 +1,7 @@ { + "author": [ + "Elastic" + ], "description": "Adversaries may attempt to get information about running processes on a system.", "false_positives": [ "Administrators may use the tasklist command to display a list of currently running processes. By itself, it does not indicate malicious activity. After obtaining a foothold, it's possible adversaries may use discovery commands like tasklist to get information about running processes." @@ -7,6 +10,7 @@ "winlogbeat-*" ], "language": "kuery", + "license": "Elastic License", "name": "Process Discovery via Tasklist", "query": "event.code:1 and process.name:tasklist.exe", "risk_score": 21, @@ -34,5 +38,5 @@ } ], "type": "query", - "version": 2 + "version": 3 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_virtual_machine_fingerprinting.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/discovery_virtual_machine_fingerprinting.json similarity index 75% rename from x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_virtual_machine_fingerprinting.json rename to x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/discovery_virtual_machine_fingerprinting.json index 28c4b6d6ee0e5..94f09f73b454e 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_virtual_machine_fingerprinting.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/discovery_virtual_machine_fingerprinting.json @@ -1,4 +1,7 @@ { + "author": [ + "Elastic" + ], "description": "An adversary may attempt to get detailed information about the operating system and hardware. This rule identifies common locations used to discover virtual machine hardware by a non-root user. This technique has been used by the Pupy RAT and other malware.", "false_positives": [ "Certain tools or automated software may enumerate hardware information. These tools can be exempted via user name or process arguments to eliminate potential noise." @@ -7,8 +10,9 @@ "auditbeat-*" ], "language": "kuery", + "license": "Elastic License", "name": "Virtual Machine Fingerprinting", - "query": "event.action:executed and process.args:(\"/sys/class/dmi/id/bios_version\" or \"/sys/class/dmi/id/product_name\" or \"/sys/class/dmi/id/chassis_vendor\" or \"/proc/scsi/scsi\" or \"/proc/ide/hd0/model\") and not user.name:root", + "query": "event.category:process and event.type:(start or process_started) and process.args:(\"/sys/class/dmi/id/bios_version\" or \"/sys/class/dmi/id/product_name\" or \"/sys/class/dmi/id/chassis_vendor\" or \"/proc/scsi/scsi\" or \"/proc/ide/hd0/model\") and not user.name:root", "risk_score": 73, "rule_id": "5b03c9fb-9945-4d2f-9568-fd690fee3fba", "severity": "high", @@ -34,5 +38,5 @@ } ], "type": "query", - "version": 1 + "version": 2 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/windows_whoami_command_activity.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/discovery_whoami_command_activity.json similarity index 93% rename from x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/windows_whoami_command_activity.json rename to x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/discovery_whoami_command_activity.json index c01396dd51527..6511ff6e19d80 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/windows_whoami_command_activity.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/discovery_whoami_command_activity.json @@ -1,4 +1,7 @@ { + "author": [ + "Elastic" + ], "description": "Identifies use of whoami.exe which displays user, group, and privileges information for the user who is currently logged on to the local system.", "false_positives": [ "Some normal use of this program, at varying levels of frequency, may originate from scripts, automation tools and frameworks. Usage by non-engineers and ordinary users is unusual." @@ -7,6 +10,7 @@ "winlogbeat-*" ], "language": "kuery", + "license": "Elastic License", "name": "Whoami Process Activity", "query": "process.name:whoami.exe and event.code:1", "risk_score": 21, @@ -34,5 +38,5 @@ } ], "type": "query", - "version": 2 + "version": 3 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_whoami_commmand.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/discovery_whoami_commmand.json similarity index 84% rename from x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_whoami_commmand.json rename to x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/discovery_whoami_commmand.json index e96c8dc3887e0..a7833c4a01751 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_whoami_commmand.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/discovery_whoami_commmand.json @@ -1,4 +1,7 @@ { + "author": [ + "Elastic" + ], "description": "The whoami application was executed on a Linux host. This is often used by tools and persistence mechanisms to test for privileged access.", "false_positives": [ "Security testing tools and frameworks may run this command. Some normal use of this command may originate from automation tools and frameworks." @@ -7,8 +10,9 @@ "auditbeat-*" ], "language": "kuery", + "license": "Elastic License", "name": "User Discovery via Whoami", - "query": "process.name:whoami and event.action:executed", + "query": "event.category:process and event.type:(start or process_started) and process.name:whoami", "risk_score": 21, "rule_id": "120559c6-5e24-49f4-9e30-8ffe697df6b9", "severity": "low", @@ -34,5 +38,5 @@ } ], "type": "query", - "version": 2 + "version": 3 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/elastic_endpoint.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/elastic_endpoint.json new file mode 100644 index 0000000000000..6d2f198c9b943 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/elastic_endpoint.json @@ -0,0 +1,60 @@ +{ + "author": [ + "Elastic" + ], + "description": "Generates a detection alert each time an Elastic Endpoint alert is received. Enabling this rule allows you to immediately begin investigating your Elastic Endpoint alerts.", + "enabled": true, + "from": "now-10m", + "index": [ + "logs-endpoint.alerts-*" + ], + "language": "kuery", + "license": "Elastic License", + "max_signals": 10000, + "name": "Elastic Endpoint", + "query": "event.kind:alert and event.module:(endpoint and not endgame)", + "risk_score": 47, + "risk_score_mapping": [ + { + "field": "event.risk_score", + "operator": "equals", + "value": "" + } + ], + "rule_id": "9a1a2dae-0b5f-4c3d-8305-a268d404c306", + "rule_name_override": "message", + "severity": "medium", + "severity_mapping": [ + { + "field": "event.severity", + "operator": "equals", + "severity": "low", + "value": "21" + }, + { + "field": "event.severity", + "operator": "equals", + "severity": "medium", + "value": "47" + }, + { + "field": "event.severity", + "operator": "equals", + "severity": "high", + "value": "73" + }, + { + "field": "event.severity", + "operator": "equals", + "severity": "critical", + "value": "99" + } + ], + "tags": [ + "Elastic", + "Endpoint" + ], + "timestamp_override": "event.ingested", + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/elastic_endpoint_security_adversary_behavior_detected.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_adversary_behavior_detected.json similarity index 90% rename from x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/elastic_endpoint_security_adversary_behavior_detected.json rename to x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_adversary_behavior_detected.json index ca97e9901975f..5075630e24f29 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/elastic_endpoint_security_adversary_behavior_detected.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_adversary_behavior_detected.json @@ -1,4 +1,7 @@ { + "author": [ + "Elastic" + ], "description": "Elastic Endpoint detected an Adversary Behavior. Click the Elastic Endpoint icon in the event.module column or the link in the rule.reference column in the External Alerts tab of the SIEM Detections page for additional information.", "from": "now-15m", "index": [ @@ -6,6 +9,7 @@ ], "interval": "10m", "language": "kuery", + "license": "Elastic License", "name": "Adversary Behavior - Detected - Elastic Endpoint", "query": "event.kind:alert and event.module:endgame and (event.action:rules_engine_event or endgame.event_subtype_full:rules_engine_event)", "risk_score": 47, @@ -16,5 +20,5 @@ "Endpoint" ], "type": "query", - "version": 2 + "version": 3 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/elastic_endpoint_security_cred_dumping_detected.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_cred_dumping_detected.json similarity index 90% rename from x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/elastic_endpoint_security_cred_dumping_detected.json rename to x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_cred_dumping_detected.json index 18472abbd70d7..4bf9ba8ec36e1 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/elastic_endpoint_security_cred_dumping_detected.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_cred_dumping_detected.json @@ -1,4 +1,7 @@ { + "author": [ + "Elastic" + ], "description": "Elastic Endpoint detected Credential Dumping. Click the Elastic Endpoint icon in the event.module column or the link in the rule.reference column in the External Alerts tab of the SIEM Detections page for additional information.", "from": "now-15m", "index": [ @@ -6,6 +9,7 @@ ], "interval": "10m", "language": "kuery", + "license": "Elastic License", "name": "Credential Dumping - Detected - Elastic Endpoint", "query": "event.kind:alert and event.module:endgame and endgame.metadata.type:detection and (event.action:cred_theft_event or endgame.event_subtype_full:cred_theft_event)", "risk_score": 73, @@ -16,5 +20,5 @@ "Endpoint" ], "type": "query", - "version": 2 + "version": 3 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/elastic_endpoint_security_cred_dumping_prevented.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_cred_dumping_prevented.json similarity index 90% rename from x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/elastic_endpoint_security_cred_dumping_prevented.json rename to x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_cred_dumping_prevented.json index 11b9fa93f5f17..bed473b12b046 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/elastic_endpoint_security_cred_dumping_prevented.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_cred_dumping_prevented.json @@ -1,4 +1,7 @@ { + "author": [ + "Elastic" + ], "description": "Elastic Endpoint prevented Credential Dumping. Click the Elastic Endpoint icon in the event.module column or the link in the rule.reference column in the External Alerts tab of the SIEM Detections page for additional information.", "from": "now-15m", "index": [ @@ -6,6 +9,7 @@ ], "interval": "10m", "language": "kuery", + "license": "Elastic License", "name": "Credential Dumping - Prevented - Elastic Endpoint", "query": "event.kind:alert and event.module:endgame and endgame.metadata.type:prevention and (event.action:cred_theft_event or endgame.event_subtype_full:cred_theft_event)", "risk_score": 47, @@ -16,5 +20,5 @@ "Endpoint" ], "type": "query", - "version": 2 + "version": 3 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/elastic_endpoint_security_cred_manipulation_detected.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_cred_manipulation_detected.json similarity index 90% rename from x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/elastic_endpoint_security_cred_manipulation_detected.json rename to x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_cred_manipulation_detected.json index ae4b59d101a3a..02ba20bb59aec 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/elastic_endpoint_security_cred_manipulation_detected.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_cred_manipulation_detected.json @@ -1,4 +1,7 @@ { + "author": [ + "Elastic" + ], "description": "Elastic Endpoint detected Credential Manipulation. Click the Elastic Endpoint icon in the event.module column or the link in the rule.reference column in the External Alerts tab of the SIEM Detections page for additional information.", "from": "now-15m", "index": [ @@ -6,6 +9,7 @@ ], "interval": "10m", "language": "kuery", + "license": "Elastic License", "name": "Credential Manipulation - Detected - Elastic Endpoint", "query": "event.kind:alert and event.module:endgame and endgame.metadata.type:detection and (event.action:token_manipulation_event or endgame.event_subtype_full:token_manipulation_event)", "risk_score": 73, @@ -16,5 +20,5 @@ "Endpoint" ], "type": "query", - "version": 2 + "version": 3 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/elastic_endpoint_security_cred_manipulation_prevented.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_cred_manipulation_prevented.json similarity index 90% rename from x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/elastic_endpoint_security_cred_manipulation_prevented.json rename to x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_cred_manipulation_prevented.json index 2db3fbbde7547..128f8d5639d5d 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/elastic_endpoint_security_cred_manipulation_prevented.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_cred_manipulation_prevented.json @@ -1,4 +1,7 @@ { + "author": [ + "Elastic" + ], "description": "Elastic Endpoint prevented Credential Manipulation. Click the Elastic Endpoint icon in the event.module column or the link in the rule.reference column in the External Alerts tab of the SIEM Detections page for additional information.", "from": "now-15m", "index": [ @@ -6,6 +9,7 @@ ], "interval": "10m", "language": "kuery", + "license": "Elastic License", "name": "Credential Manipulation - Prevented - Elastic Endpoint", "query": "event.kind:alert and event.module:endgame and endgame.metadata.type:prevention and (event.action:token_manipulation_event or endgame.event_subtype_full:token_manipulation_event)", "risk_score": 47, @@ -16,5 +20,5 @@ "Endpoint" ], "type": "query", - "version": 2 + "version": 3 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/elastic_endpoint_security_exploit_detected.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_exploit_detected.json similarity index 90% rename from x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/elastic_endpoint_security_exploit_detected.json rename to x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_exploit_detected.json index a57d56cec9bcd..a11b839792b79 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/elastic_endpoint_security_exploit_detected.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_exploit_detected.json @@ -1,4 +1,7 @@ { + "author": [ + "Elastic" + ], "description": "Elastic Endpoint detected an Exploit. Click the Elastic Endpoint icon in the event.module column or the link in the rule.reference column in the External Alerts tab of the SIEM Detections page for additional information.", "from": "now-15m", "index": [ @@ -6,6 +9,7 @@ ], "interval": "10m", "language": "kuery", + "license": "Elastic License", "name": "Exploit - Detected - Elastic Endpoint", "query": "event.kind:alert and event.module:endgame and endgame.metadata.type:detection and (event.action:exploit_event or endgame.event_subtype_full:exploit_event)", "risk_score": 73, @@ -16,5 +20,5 @@ "Endpoint" ], "type": "query", - "version": 2 + "version": 3 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/elastic_endpoint_security_exploit_prevented.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_exploit_prevented.json similarity index 90% rename from x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/elastic_endpoint_security_exploit_prevented.json rename to x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_exploit_prevented.json index f8f1b774a191a..2deb7bce3b203 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/elastic_endpoint_security_exploit_prevented.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_exploit_prevented.json @@ -1,4 +1,7 @@ { + "author": [ + "Elastic" + ], "description": "Elastic Endpoint prevented an Exploit. Click the Elastic Endpoint icon in the event.module column or the link in the rule.reference column in the External Alerts tab of the SIEM Detections page for additional information.", "from": "now-15m", "index": [ @@ -6,6 +9,7 @@ ], "interval": "10m", "language": "kuery", + "license": "Elastic License", "name": "Exploit - Prevented - Elastic Endpoint", "query": "event.kind:alert and event.module:endgame and endgame.metadata.type:prevention and (event.action:exploit_event or endgame.event_subtype_full:exploit_event)", "risk_score": 47, @@ -16,5 +20,5 @@ "Endpoint" ], "type": "query", - "version": 2 + "version": 3 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/elastic_endpoint_security_malware_detected.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_malware_detected.json similarity index 90% rename from x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/elastic_endpoint_security_malware_detected.json rename to x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_malware_detected.json index 4024a50c3a0fe..d1389b21f2d7e 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/elastic_endpoint_security_malware_detected.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_malware_detected.json @@ -1,4 +1,7 @@ { + "author": [ + "Elastic" + ], "description": "Elastic Endpoint detected Malware. Click the Elastic Endpoint icon in the event.module column or the link in the rule.reference column in the External Alerts tab of the SIEM Detections page for additional information.", "from": "now-15m", "index": [ @@ -6,6 +9,7 @@ ], "interval": "10m", "language": "kuery", + "license": "Elastic License", "name": "Malware - Detected - Elastic Endpoint", "query": "event.kind:alert and event.module:endgame and endgame.metadata.type:detection and (event.action:file_classification_event or endgame.event_subtype_full:file_classification_event)", "risk_score": 99, @@ -16,5 +20,5 @@ "Endpoint" ], "type": "query", - "version": 2 + "version": 3 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/elastic_endpoint_security_malware_prevented.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_malware_prevented.json similarity index 90% rename from x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/elastic_endpoint_security_malware_prevented.json rename to x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_malware_prevented.json index b21bd00229c04..b83bc259175c6 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/elastic_endpoint_security_malware_prevented.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_malware_prevented.json @@ -1,4 +1,7 @@ { + "author": [ + "Elastic" + ], "description": "Elastic Endpoint prevented Malware. Click the Elastic Endpoint icon in the event.module column or the link in the rule.reference column in the External Alerts tab of the SIEM Detections page for additional information.", "from": "now-15m", "index": [ @@ -6,6 +9,7 @@ ], "interval": "10m", "language": "kuery", + "license": "Elastic License", "name": "Malware - Prevented - Elastic Endpoint", "query": "event.kind:alert and event.module:endgame and endgame.metadata.type:prevention and (event.action:file_classification_event or endgame.event_subtype_full:file_classification_event)", "risk_score": 73, @@ -16,5 +20,5 @@ "Endpoint" ], "type": "query", - "version": 2 + "version": 3 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/elastic_endpoint_security_permission_theft_detected.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_permission_theft_detected.json similarity index 90% rename from x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/elastic_endpoint_security_permission_theft_detected.json rename to x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_permission_theft_detected.json index 1aba34f7b15c0..b81b9c67644c6 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/elastic_endpoint_security_permission_theft_detected.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_permission_theft_detected.json @@ -1,4 +1,7 @@ { + "author": [ + "Elastic" + ], "description": "Elastic Endpoint detected Permission Theft. Click the Elastic Endpoint icon in the event.module column or the link in the rule.reference column in the External Alerts tab of the SIEM Detections page for additional information.", "from": "now-15m", "index": [ @@ -6,6 +9,7 @@ ], "interval": "10m", "language": "kuery", + "license": "Elastic License", "name": "Permission Theft - Detected - Elastic Endpoint", "query": "event.kind:alert and event.module:endgame and endgame.metadata.type:detection and (event.action:token_protection_event or endgame.event_subtype_full:token_protection_event)", "risk_score": 73, @@ -16,5 +20,5 @@ "Endpoint" ], "type": "query", - "version": 2 + "version": 3 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/elastic_endpoint_security_permission_theft_prevented.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_permission_theft_prevented.json similarity index 90% rename from x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/elastic_endpoint_security_permission_theft_prevented.json rename to x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_permission_theft_prevented.json index b383349b5e204..b69598cffc230 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/elastic_endpoint_security_permission_theft_prevented.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_permission_theft_prevented.json @@ -1,4 +1,7 @@ { + "author": [ + "Elastic" + ], "description": "Elastic Endpoint prevented Permission Theft. Click the Elastic Endpoint icon in the event.module column or the link in the rule.reference column in the External Alerts tab of the SIEM Detections page for additional information.", "from": "now-15m", "index": [ @@ -6,6 +9,7 @@ ], "interval": "10m", "language": "kuery", + "license": "Elastic License", "name": "Permission Theft - Prevented - Elastic Endpoint", "query": "event.kind:alert and event.module:endgame and endgame.metadata.type:prevention and (event.action:token_protection_event or endgame.event_subtype_full:token_protection_event)", "risk_score": 47, @@ -16,5 +20,5 @@ "Endpoint" ], "type": "query", - "version": 2 + "version": 3 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/elastic_endpoint_security_process_injection_detected.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_process_injection_detected.json similarity index 90% rename from x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/elastic_endpoint_security_process_injection_detected.json rename to x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_process_injection_detected.json index d7f5b24548344..8299e11392398 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/elastic_endpoint_security_process_injection_detected.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_process_injection_detected.json @@ -1,4 +1,7 @@ { + "author": [ + "Elastic" + ], "description": "Elastic Endpoint detected Process Injection. Click the Elastic Endpoint icon in the event.module column or the link in the rule.reference column in the External Alerts tab of the SIEM Detections page for additional information.", "from": "now-15m", "index": [ @@ -6,6 +9,7 @@ ], "interval": "10m", "language": "kuery", + "license": "Elastic License", "name": "Process Injection - Detected - Elastic Endpoint", "query": "event.kind:alert and event.module:endgame and endgame.metadata.type:detection and (event.action:kernel_shellcode_event or endgame.event_subtype_full:kernel_shellcode_event)", "risk_score": 73, @@ -16,5 +20,5 @@ "Endpoint" ], "type": "query", - "version": 2 + "version": 3 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/elastic_endpoint_security_process_injection_prevented.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_process_injection_prevented.json similarity index 90% rename from x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/elastic_endpoint_security_process_injection_prevented.json rename to x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_process_injection_prevented.json index a2595dee2f724..237558ae372a8 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/elastic_endpoint_security_process_injection_prevented.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_process_injection_prevented.json @@ -1,4 +1,7 @@ { + "author": [ + "Elastic" + ], "description": "Elastic Endpoint prevented Process Injection. Click the Elastic Endpoint icon in the event.module column or the link in the rule.reference column in the External Alerts tab of the SIEM Detections page for additional information.", "from": "now-15m", "index": [ @@ -6,6 +9,7 @@ ], "interval": "10m", "language": "kuery", + "license": "Elastic License", "name": "Process Injection - Prevented - Elastic Endpoint", "query": "event.kind:alert and event.module:endgame and endgame.metadata.type:prevention and (event.action:kernel_shellcode_event or endgame.event_subtype_full:kernel_shellcode_event)", "risk_score": 47, @@ -16,5 +20,5 @@ "Endpoint" ], "type": "query", - "version": 2 + "version": 3 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/elastic_endpoint_security_ransomware_detected.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_ransomware_detected.json similarity index 90% rename from x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/elastic_endpoint_security_ransomware_detected.json rename to x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_ransomware_detected.json index 9dd62717958e1..4ead850c60e8f 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/elastic_endpoint_security_ransomware_detected.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_ransomware_detected.json @@ -1,4 +1,7 @@ { + "author": [ + "Elastic" + ], "description": "Elastic Endpoint detected Ransomware. Click the Elastic Endpoint icon in the event.module column or the link in the rule.reference column in the External Alerts tab of the SIEM Detections page for additional information.", "from": "now-15m", "index": [ @@ -6,6 +9,7 @@ ], "interval": "10m", "language": "kuery", + "license": "Elastic License", "name": "Ransomware - Detected - Elastic Endpoint", "query": "event.kind:alert and event.module:endgame and endgame.metadata.type:detection and (event.action:ransomware_event or endgame.event_subtype_full:ransomware_event)", "risk_score": 99, @@ -16,5 +20,5 @@ "Endpoint" ], "type": "query", - "version": 2 + "version": 3 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/elastic_endpoint_security_ransomware_prevented.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_ransomware_prevented.json similarity index 90% rename from x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/elastic_endpoint_security_ransomware_prevented.json rename to x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_ransomware_prevented.json index cfa9ff6cca2ee..25d167afa204c 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/elastic_endpoint_security_ransomware_prevented.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_ransomware_prevented.json @@ -1,4 +1,7 @@ { + "author": [ + "Elastic" + ], "description": "Elastic Endpoint prevented Ransomware. Click the Elastic Endpoint icon in the event.module column or the link in the rule.reference column in the External Alerts tab of the SIEM Detections page for additional information.", "from": "now-15m", "index": [ @@ -6,6 +9,7 @@ ], "interval": "10m", "language": "kuery", + "license": "Elastic License", "name": "Ransomware - Prevented - Elastic Endpoint", "query": "event.kind:alert and event.module:endgame and endgame.metadata.type:prevention and (event.action:ransomware_event or endgame.event_subtype_full:ransomware_event)", "risk_score": 73, @@ -16,5 +20,5 @@ "Endpoint" ], "type": "query", - "version": 2 + "version": 3 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/eql_suspicious_ms_office_child_process.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/eql_suspicious_ms_office_child_process.json deleted file mode 100644 index e234688a432e2..0000000000000 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/eql_suspicious_ms_office_child_process.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "description": "Identifies suspicious child processes of frequently targeted Microsoft Office applications (Word, PowerPoint, Excel). These child processes are often launched during exploitation of Office applications or from documents with malicious macros.", - "index": [ - "winlogbeat-*" - ], - "language": "kuery", - "name": "Suspicious MS Office Child Process", - "query": "event.action:\"Process Create (rule: ProcessCreate)\" and process.parent.name:(eqnedt32.exe or excel.exe or fltldr.exe or msaccess.exe or mspub.exe or powerpnt.exe or winword.exe) and process.name:(Microsoft.Workflow.Compiler.exe or arp.exe or atbroker.exe or bginfo.exe or bitsadmin.exe or cdb.exe or certutil.exe or cmd.exe or cmstp.exe or cscript.exe or csi.exe or dnx.exe or dsget.exe or dsquery.exe or forfiles.exe or fsi.exe or ftp.exe or gpresult.exe or hostname.exe or ieexec.exe or iexpress.exe or installutil.exe or ipconfig.exe or mshta.exe or msxsl.exe or nbtstat.exe or net.exe or net1.exe or netsh.exe or netstat.exe or nltest.exe or odbcconf.exe or ping.exe or powershell.exe or pwsh.exe or qprocess.exe or quser.exe or qwinsta.exe or rcsi.exe or reg.exe or regasm.exe or regsvcs.exe or regsvr32.exe or sc.exe or schtasks.exe or systeminfo.exe or tasklist.exe or tracert.exe or whoami.exe or wmic.exe or wscript.exe or xwizard.exe)", - "risk_score": 21, - "rule_id": "a624863f-a70d-417f-a7d2-7a404638d47f", - "severity": "low", - "tags": [ - "Elastic", - "Windows" - ], - "threat": [ - { - "framework": "MITRE ATT&CK", - "tactic": { - "id": "TA0002", - "name": "Execution", - "reference": "https://attack.mitre.org/tactics/TA0002/" - }, - "technique": [ - { - "id": "T1193", - "name": "Spearphishing Attachment", - "reference": "https://attack.mitre.org/techniques/T1193/" - } - ] - } - ], - "type": "query", - "version": 2 -} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/eql_suspicious_ms_outlook_child_process.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/eql_suspicious_ms_outlook_child_process.json deleted file mode 100644 index dcc5e5a095f12..0000000000000 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/eql_suspicious_ms_outlook_child_process.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "description": "Identifies suspicious child processes of Microsoft Outlook. These child processes are often associated with spear phishing activity.", - "index": [ - "winlogbeat-*" - ], - "language": "kuery", - "name": "Suspicious MS Outlook Child Process", - "query": "event.action:\"Process Create (rule: ProcessCreate)\" and process.parent.name:outlook.exe and process.name:(Microsoft.Workflow.Compiler.exe or arp.exe or atbroker.exe or bginfo.exe or bitsadmin.exe or cdb.exe or certutil.exe or cmd.exe or cmstp.exe or cscript.exe or csi.exe or dnx.exe or dsget.exe or dsquery.exe or forfiles.exe or fsi.exe or ftp.exe or gpresult.exe or hostname.exe or ieexec.exe or iexpress.exe or installutil.exe or ipconfig.exe or mshta.exe or msxsl.exe or nbtstat.exe or net.exe or net1.exe or netsh.exe or netstat.exe or nltest.exe or odbcconf.exe or ping.exe or powershell.exe or pwsh.exe or qprocess.exe or quser.exe or qwinsta.exe or rcsi.exe or reg.exe or regasm.exe or regsvcs.exe or regsvr32.exe or sc.exe or schtasks.exe or systeminfo.exe or tasklist.exe or tracert.exe or whoami.exe or wmic.exe or wscript.exe or xwizard.exe)", - "risk_score": 21, - "rule_id": "32f4675e-6c49-4ace-80f9-97c9259dca2e", - "severity": "low", - "tags": [ - "Elastic", - "Windows" - ], - "threat": [ - { - "framework": "MITRE ATT&CK", - "tactic": { - "id": "TA0002", - "name": "Execution", - "reference": "https://attack.mitre.org/tactics/TA0002/" - }, - "technique": [ - { - "id": "T1193", - "name": "Spearphishing Attachment", - "reference": "https://attack.mitre.org/techniques/T1193/" - } - ] - } - ], - "type": "query", - "version": 2 -} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/eql_unusual_parentchild_relationship.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/eql_unusual_parentchild_relationship.json deleted file mode 100644 index ea87ce1aea81d..0000000000000 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/eql_unusual_parentchild_relationship.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "description": "Identifies Windows programs run from unexpected parent processes. This could indicate masquerading or other strange activity on a system.", - "index": [ - "winlogbeat-*" - ], - "language": "kuery", - "name": "Unusual Parent-Child Relationship", - "query": "event.action:\"Process Create (rule: ProcessCreate)\" and process.parent.executable:* and (process.name:smss.exe and not process.parent.name:(System or smss.exe) or process.name:csrss.exe and not process.parent.name:(smss.exe or svchost.exe) or process.name:wininit.exe and not process.parent.name:smss.exe or process.name:winlogon.exe and not process.parent.name:smss.exe or process.name:lsass.exe and not process.parent.name:wininit.exe or process.name:LogonUI.exe and not process.parent.name:(wininit.exe or winlogon.exe) or process.name:services.exe and not process.parent.name:wininit.exe or process.name:svchost.exe and not process.parent.name:(MsMpEng.exe or services.exe) or process.name:spoolsv.exe and not process.parent.name:services.exe or process.name:taskhost.exe and not process.parent.name:(services.exe or svchost.exe) or process.name:taskhostw.exe and not process.parent.name:(services.exe or svchost.exe) or process.name:userinit.exe and not process.parent.name:(dwm.exe or winlogon.exe))", - "risk_score": 47, - "rule_id": "35df0dd8-092d-4a83-88c1-5151a804f31b", - "severity": "medium", - "tags": [ - "Elastic", - "Windows" - ], - "threat": [ - { - "framework": "MITRE ATT&CK", - "tactic": { - "id": "TA0004", - "name": "Privilege Escalation", - "reference": "https://attack.mitre.org/tactics/TA0004/" - }, - "technique": [ - { - "id": "T1093", - "name": "Process Hollowing", - "reference": "https://attack.mitre.org/techniques/T1093/" - } - ] - } - ], - "type": "query", - "version": 2 -} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/windows_command_prompt_connecting_to_the_internet.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_command_prompt_connecting_to_the_internet.json similarity index 85% rename from x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/windows_command_prompt_connecting_to_the_internet.json rename to x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_command_prompt_connecting_to_the_internet.json index 51fceacddb3c9..97197be498a8d 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/windows_command_prompt_connecting_to_the_internet.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_command_prompt_connecting_to_the_internet.json @@ -1,4 +1,7 @@ { + "author": [ + "Elastic" + ], "description": "Identifies cmd.exe making a network connection. Adversaries could abuse cmd.exe to download or execute malware from a remote URL.", "false_positives": [ "Administrators may use the command prompt for regular administrative tasks. It's important to baseline your environment for network connections being made from the command prompt to determine any abnormal use of this tool." @@ -7,8 +10,9 @@ "winlogbeat-*" ], "language": "kuery", + "license": "Elastic License", "name": "Command Prompt Network Connection", - "query": "process.name:cmd.exe and event.action:\"Network connection detected (rule: NetworkConnect)\" and not destination.ip:(10.0.0.0/8 or 172.16.0.0/12 or 192.168.0.0/16)", + "query": "event.category:network and event.type:connection and process.name:cmd.exe and not destination.ip:(10.0.0.0/8 or 172.16.0.0/12 or 192.168.0.0/16)", "risk_score": 21, "rule_id": "89f9a4b0-9f8f-4ee0-8823-c4751a6d6696", "severity": "low", @@ -49,5 +53,5 @@ } ], "type": "query", - "version": 2 + "version": 3 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/windows_command_shell_started_by_powershell.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_command_shell_started_by_powershell.json similarity index 83% rename from x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/windows_command_shell_started_by_powershell.json rename to x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_command_shell_started_by_powershell.json index 8e88549a44ada..832ca1e1e7d39 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/windows_command_shell_started_by_powershell.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_command_shell_started_by_powershell.json @@ -1,11 +1,15 @@ { + "author": [ + "Elastic" + ], "description": "Identifies a suspicious parent child process relationship with cmd.exe descending from PowerShell.exe.", "index": [ "winlogbeat-*" ], "language": "kuery", + "license": "Elastic License", "name": "PowerShell spawning Cmd", - "query": "process.parent.name:powershell.exe and process.name:cmd.exe", + "query": "event.category:process and event.type:(start or process_started) and process.parent.name:powershell.exe and process.name:cmd.exe", "risk_score": 21, "rule_id": "0f616aee-8161-4120-857e-742366f5eeb3", "severity": "low", @@ -46,5 +50,5 @@ } ], "type": "query", - "version": 2 + "version": 3 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/windows_command_shell_started_by_svchost.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_command_shell_started_by_svchost.json similarity index 77% rename from x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/windows_command_shell_started_by_svchost.json rename to x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_command_shell_started_by_svchost.json index f36f853a8e760..e92ee45c0f3b6 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/windows_command_shell_started_by_svchost.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_command_shell_started_by_svchost.json @@ -1,11 +1,15 @@ { + "author": [ + "Elastic" + ], "description": "Identifies a suspicious parent child process relationship with cmd.exe descending from svchost.exe", "index": [ "winlogbeat-*" ], "language": "kuery", + "license": "Elastic License", "name": "Svchost spawning Cmd", - "query": "process.parent.name:svchost.exe and process.name:cmd.exe", + "query": "event.category:process and event.type:(start or process_started) and process.parent.name:svchost.exe and process.name:cmd.exe", "risk_score": 21, "rule_id": "fd7a6052-58fa-4397-93c3-4795249ccfa2", "severity": "low", @@ -31,5 +35,5 @@ } ], "type": "query", - "version": 2 + "version": 3 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/windows_html_help_executable_program_connecting_to_the_internet.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_html_help_executable_program_connecting_to_the_internet.json similarity index 84% rename from x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/windows_html_help_executable_program_connecting_to_the_internet.json rename to x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_html_help_executable_program_connecting_to_the_internet.json index 906995b3b6662..c75f77301e531 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/windows_html_help_executable_program_connecting_to_the_internet.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_html_help_executable_program_connecting_to_the_internet.json @@ -1,11 +1,15 @@ { + "author": [ + "Elastic" + ], "description": "Compiled HTML files (.chm) are commonly distributed as part of the Microsoft HTML Help system. Adversaries may conceal malicious code in a CHM file and deliver it to a victim for execution. CHM content is loaded by the HTML Help executable program (hh.exe).", "index": [ "winlogbeat-*" ], "language": "kuery", + "license": "Elastic License", "name": "Network Connection via Compiled HTML File", - "query": "process.name:hh.exe and event.action:\"Network connection detected (rule: NetworkConnect)\" and not destination.ip:(10.0.0.0/8 or 172.16.0.0/12 or 192.168.0.0/16)", + "query": "event.category:network and event.type:connection and process.name:hh.exe and not destination.ip:(10.0.0.0/8 or 172.16.0.0/12 or 192.168.0.0/16)", "risk_score": 21, "rule_id": "b29ee2be-bf99-446c-ab1a-2dc0183394b8", "severity": "low", @@ -46,5 +50,5 @@ } ], "type": "query", - "version": 2 + "version": 3 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/eql_local_service_commands.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_local_service_commands.json similarity index 78% rename from x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/eql_local_service_commands.json rename to x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_local_service_commands.json index e842b732254ca..9b50d99761ad2 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/eql_local_service_commands.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_local_service_commands.json @@ -1,11 +1,15 @@ { + "author": [ + "Elastic" + ], "description": "Identifies use of sc.exe to create, modify, or start services on remote hosts. This could be indicative of adversary lateral movement but will be noisy if commonly done by admins.", "index": [ "winlogbeat-*" ], "language": "kuery", + "license": "Elastic License", "name": "Local Service Commands", - "query": "event.action:\"Process Create (rule: ProcessCreate)\" and process.name:sc.exe and process.args:(config or create or failure or start)", + "query": "event.category:process and event.type:(start or process_started) and process.name:sc.exe and process.args:(config or create or failure or start)", "risk_score": 21, "rule_id": "e8571d5f-bea1-46c2-9f56-998de2d3ed95", "severity": "low", @@ -31,5 +35,5 @@ } ], "type": "query", - "version": 2 + "version": 3 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/eql_msbuild_making_network_connections.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_msbuild_making_network_connections.json similarity index 80% rename from x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/eql_msbuild_making_network_connections.json rename to x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_msbuild_making_network_connections.json index f3d75c7fead8b..192e35df1da3f 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/eql_msbuild_making_network_connections.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_msbuild_making_network_connections.json @@ -1,11 +1,15 @@ { + "author": [ + "Elastic" + ], "description": "Identifies MsBuild.exe making outbound network connections. This may indicate adversarial activity as MsBuild is often leveraged by adversaries to execute code and evade detection.", "index": [ "winlogbeat-*" ], "language": "kuery", + "license": "Elastic License", "name": "MsBuild Making Network Connections", - "query": "event.action:\"Network connection detected (rule: NetworkConnect)\" and process.name:MSBuild.exe and not destination.ip:(127.0.0.1 or \"::1\")", + "query": "event.category:network and event.type:connection and process.name:MSBuild.exe and not destination.ip:(127.0.0.1 or \"::1\")", "risk_score": 47, "rule_id": "0e79980b-4250-4a50-a509-69294c14e84b", "severity": "medium", @@ -31,5 +35,5 @@ } ], "type": "query", - "version": 2 + "version": 3 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/eql_mshta_making_network_connections.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_mshta_making_network_connections.json similarity index 84% rename from x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/eql_mshta_making_network_connections.json rename to x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_mshta_making_network_connections.json index eb2dd0eeff6ea..cb098086e3324 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/eql_mshta_making_network_connections.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_mshta_making_network_connections.json @@ -1,11 +1,15 @@ { + "author": [ + "Elastic" + ], "description": "Identifies mshta.exe making a network connection. This may indicate adversarial activity as mshta.exe is often leveraged by adversaries to execute malicious scripts and evade detection.", "index": [ "winlogbeat-*" ], "language": "kuery", + "license": "Elastic License", "name": "Network Connection via Mshta", - "query": "event.action:\"Network connection detected (rule: NetworkConnect)\" and process.name:mshta.exe", + "query": "event.category:network and event.type:connection and process.name:mshta.exe", "references": [ "https://www.fireeye.com/blog/threat-research/2017/05/cyber-espionage-apt32.html" ], @@ -34,5 +38,5 @@ } ], "type": "query", - "version": 2 + "version": 3 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/windows_msxsl_network.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_msxsl_network.json similarity index 78% rename from x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/windows_msxsl_network.json rename to x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_msxsl_network.json index 735ae0b2d6a7b..9f1d2fc62fadf 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/windows_msxsl_network.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_msxsl_network.json @@ -1,11 +1,15 @@ { + "author": [ + "Elastic" + ], "description": "Identifies msxsl.exe making a network connection. This may indicate adversarial activity as msxsl.exe is often leveraged by adversaries to execute malicious scripts and evade detection.", "index": [ "winlogbeat-*" ], "language": "kuery", + "license": "Elastic License", "name": "Network Connection via MsXsl", - "query": "process.name:msxsl.exe and event.action:\"Network connection detected (rule: NetworkConnect)\" and not destination.ip:(10.0.0.0/8 or 172.16.0.0/12 or 192.168.0.0/16)", + "query": "event.category:network and event.type:connection and process.name:msxsl.exe and not destination.ip:(10.0.0.0/8 or 172.16.0.0/12 or 192.168.0.0/16)", "risk_score": 21, "rule_id": "b86afe07-0d98-4738-b15d-8d7465f95ff5", "severity": "low", @@ -31,5 +35,5 @@ } ], "type": "query", - "version": 1 + "version": 2 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_perl_tty_shell.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_perl_tty_shell.json similarity index 74% rename from x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_perl_tty_shell.json rename to x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_perl_tty_shell.json index 2f003f8ec9d03..db96fe1bc1b50 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_perl_tty_shell.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_perl_tty_shell.json @@ -1,11 +1,15 @@ { + "author": [ + "Elastic" + ], "description": "Identifies when a terminal (tty) is spawned via Perl. Attackers may upgrade a simple reverse shell to a fully interactive tty after obtaining initial access to a host.", "index": [ "auditbeat-*" ], "language": "kuery", + "license": "Elastic License", "name": "Interactive Terminal Spawned via Perl", - "query": "event.action:executed and process.name:perl and process.args:(\"exec \\\"/bin/sh\\\";\" or \"exec \\\"/bin/dash\\\";\" or \"exec \\\"/bin/bash\\\";\")", + "query": "event.category:process and event.type:(start or process_started) and process.name:perl and process.args:(\"exec \\\"/bin/sh\\\";\" or \"exec \\\"/bin/dash\\\";\" or \"exec \\\"/bin/bash\\\";\")", "risk_score": 73, "rule_id": "05e5a668-7b51-4a67-93ab-e9af405c9ef3", "severity": "high", @@ -31,5 +35,5 @@ } ], "type": "query", - "version": 1 + "version": 2 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/eql_psexec_lateral_movement_command.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_psexec_lateral_movement_command.json similarity index 89% rename from x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/eql_psexec_lateral_movement_command.json rename to x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_psexec_lateral_movement_command.json index 2abf38eb1b0ef..a5ac6cffd2376 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/eql_psexec_lateral_movement_command.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_psexec_lateral_movement_command.json @@ -1,4 +1,7 @@ { + "author": [ + "Elastic" + ], "description": "Identifies use of the SysInternals tool PsExec.exe making a network connection. This could be an indication of lateral movement.", "false_positives": [ "PsExec is a dual-use tool that can be used for benign or malicious activity. It's important to baseline your environment to determine the amount of noise to expect from this tool." @@ -7,8 +10,9 @@ "winlogbeat-*" ], "language": "kuery", + "license": "Elastic License", "name": "PsExec Network Connection", - "query": "process.name:PsExec.exe and event.action:\"Network connection detected (rule: NetworkConnect)\"", + "query": "event.category:network and event.type:connection and process.name:PsExec.exe", "risk_score": 21, "rule_id": "55d551c6-333b-4665-ab7e-5d14a59715ce", "severity": "low", @@ -49,5 +53,5 @@ } ], "type": "query", - "version": 2 + "version": 3 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_python_tty_shell.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_python_tty_shell.json similarity index 71% rename from x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_python_tty_shell.json rename to x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_python_tty_shell.json index 42e014e919cad..59be6da19e93f 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_python_tty_shell.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_python_tty_shell.json @@ -1,11 +1,15 @@ { + "author": [ + "Elastic" + ], "description": "Identifies when a terminal (tty) is spawned via Python. Attackers may upgrade a simple reverse shell to a fully interactive tty after obtaining initial access to a host.", "index": [ "auditbeat-*" ], "language": "kuery", + "license": "Elastic License", "name": "Interactive Terminal Spawned via Python", - "query": "event.action:executed and process.name:python and process.args:(\"import pty; pty.spawn(\\\"/bin/sh\\\")\" or \"import pty; pty.spawn(\\\"/bin/dash\\\")\" or \"import pty; pty.spawn(\\\"/bin/bash\\\")\")", + "query": "event.category:process and event.type:(start or process_started) and process.name:python and process.args:(\"import pty; pty.spawn(\\\"/bin/sh\\\")\" or \"import pty; pty.spawn(\\\"/bin/dash\\\")\" or \"import pty; pty.spawn(\\\"/bin/bash\\\")\")", "risk_score": 73, "rule_id": "d76b02ef-fc95-4001-9297-01cb7412232f", "severity": "high", @@ -31,5 +35,5 @@ } ], "type": "query", - "version": 1 + "version": 2 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/windows_register_server_program_connecting_to_the_internet.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_register_server_program_connecting_to_the_internet.json similarity index 77% rename from x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/windows_register_server_program_connecting_to_the_internet.json rename to x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_register_server_program_connecting_to_the_internet.json index f6fc38f963640..262313782fe33 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/windows_register_server_program_connecting_to_the_internet.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_register_server_program_connecting_to_the_internet.json @@ -1,5 +1,8 @@ { - "description": "Identifies the native Windows tools regsvr32.exe and regsvr64.exe making a network connection. This may be indicative of an attacker bypassing whitelisting or running arbitrary scripts via a signed Microsoft binary.", + "author": [ + "Elastic" + ], + "description": "Identifies the native Windows tools regsvr32.exe and regsvr64.exe making a network connection. This may be indicative of an attacker bypassing allowlists or running arbitrary scripts via a signed Microsoft binary.", "false_positives": [ "Security testing may produce events like this. Activity of this kind performed by non-engineers and ordinary users is unusual." ], @@ -7,8 +10,9 @@ "winlogbeat-*" ], "language": "kuery", + "license": "Elastic License", "name": "Network Connection via Regsvr", - "query": "process.name:(regsvr32.exe or regsvr64.exe) and event.action:\"Network connection detected (rule: NetworkConnect)\" and not destination.ip:(10.0.0.0/8 or 169.254.169.254 or 172.16.0.0/12 or 192.168.0.0/16)", + "query": "event.category:network and event.type:connection and process.name:(regsvr32.exe or regsvr64.exe) and not destination.ip:(10.0.0.0/8 or 169.254.169.254 or 172.16.0.0/12 or 192.168.0.0/16)", "risk_score": 21, "rule_id": "fb02b8d3-71ee-4af1-bacd-215d23f17efa", "severity": "low", @@ -49,5 +53,5 @@ } ], "type": "query", - "version": 2 + "version": 3 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/eql_windows_script_executing_powershell.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_script_executing_powershell.json similarity index 78% rename from x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/eql_windows_script_executing_powershell.json rename to x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_script_executing_powershell.json index 27411e35ee828..6f9170f476d90 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/eql_windows_script_executing_powershell.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_script_executing_powershell.json @@ -1,11 +1,15 @@ { + "author": [ + "Elastic" + ], "description": "Identifies a PowerShell process launched by either cscript.exe or wscript.exe. Observing Windows scripting processes executing a PowerShell script, may be indicative of malicious activity.", "index": [ "winlogbeat-*" ], "language": "kuery", + "license": "Elastic License", "name": "Windows Script Executing PowerShell", - "query": "event.action:\"Process Create (rule: ProcessCreate)\" and process.parent.name:(cscript.exe or wscript.exe) and process.name:powershell.exe", + "query": "event.category:process and event.type:(start or process_started) and process.parent.name:(cscript.exe or wscript.exe) and process.name:powershell.exe", "risk_score": 21, "rule_id": "f545ff26-3c94-4fd0-bd33-3c7f95a3a0fc", "severity": "low", @@ -31,5 +35,5 @@ } ], "type": "query", - "version": 2 + "version": 3 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_suspicious_ms_office_child_process.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_suspicious_ms_office_child_process.json new file mode 100644 index 0000000000000..1b5fd4e1f502d --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_suspicious_ms_office_child_process.json @@ -0,0 +1,39 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies suspicious child processes of frequently targeted Microsoft Office applications (Word, PowerPoint, Excel). These child processes are often launched during exploitation of Office applications or from documents with malicious macros.", + "index": [ + "winlogbeat-*" + ], + "language": "kuery", + "license": "Elastic License", + "name": "Suspicious MS Office Child Process", + "query": "event.category:process and event.type:(start or process_started) and process.parent.name:(eqnedt32.exe or excel.exe or fltldr.exe or msaccess.exe or mspub.exe or powerpnt.exe or winword.exe) and process.name:(Microsoft.Workflow.Compiler.exe or arp.exe or atbroker.exe or bginfo.exe or bitsadmin.exe or cdb.exe or certutil.exe or cmd.exe or cmstp.exe or cscript.exe or csi.exe or dnx.exe or dsget.exe or dsquery.exe or forfiles.exe or fsi.exe or ftp.exe or gpresult.exe or hostname.exe or ieexec.exe or iexpress.exe or installutil.exe or ipconfig.exe or mshta.exe or msxsl.exe or nbtstat.exe or net.exe or net1.exe or netsh.exe or netstat.exe or nltest.exe or odbcconf.exe or ping.exe or powershell.exe or pwsh.exe or qprocess.exe or quser.exe or qwinsta.exe or rcsi.exe or reg.exe or regasm.exe or regsvcs.exe or regsvr32.exe or sc.exe or schtasks.exe or systeminfo.exe or tasklist.exe or tracert.exe or whoami.exe or wmic.exe or wscript.exe or xwizard.exe)", + "risk_score": 21, + "rule_id": "a624863f-a70d-417f-a7d2-7a404638d47f", + "severity": "low", + "tags": [ + "Elastic", + "Windows" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0002", + "name": "Execution", + "reference": "https://attack.mitre.org/tactics/TA0002/" + }, + "technique": [ + { + "id": "T1193", + "name": "Spearphishing Attachment", + "reference": "https://attack.mitre.org/techniques/T1193/" + } + ] + } + ], + "type": "query", + "version": 3 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_suspicious_ms_outlook_child_process.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_suspicious_ms_outlook_child_process.json new file mode 100644 index 0000000000000..f874b7e3f8e80 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_suspicious_ms_outlook_child_process.json @@ -0,0 +1,39 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies suspicious child processes of Microsoft Outlook. These child processes are often associated with spear phishing activity.", + "index": [ + "winlogbeat-*" + ], + "language": "kuery", + "license": "Elastic License", + "name": "Suspicious MS Outlook Child Process", + "query": "event.category:process and event.type:(start or process_started) and process.parent.name:outlook.exe and process.name:(Microsoft.Workflow.Compiler.exe or arp.exe or atbroker.exe or bginfo.exe or bitsadmin.exe or cdb.exe or certutil.exe or cmd.exe or cmstp.exe or cscript.exe or csi.exe or dnx.exe or dsget.exe or dsquery.exe or forfiles.exe or fsi.exe or ftp.exe or gpresult.exe or hostname.exe or ieexec.exe or iexpress.exe or installutil.exe or ipconfig.exe or mshta.exe or msxsl.exe or nbtstat.exe or net.exe or net1.exe or netsh.exe or netstat.exe or nltest.exe or odbcconf.exe or ping.exe or powershell.exe or pwsh.exe or qprocess.exe or quser.exe or qwinsta.exe or rcsi.exe or reg.exe or regasm.exe or regsvcs.exe or regsvr32.exe or sc.exe or schtasks.exe or systeminfo.exe or tasklist.exe or tracert.exe or whoami.exe or wmic.exe or wscript.exe or xwizard.exe)", + "risk_score": 21, + "rule_id": "32f4675e-6c49-4ace-80f9-97c9259dca2e", + "severity": "low", + "tags": [ + "Elastic", + "Windows" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0002", + "name": "Execution", + "reference": "https://attack.mitre.org/tactics/TA0002/" + }, + "technique": [ + { + "id": "T1193", + "name": "Spearphishing Attachment", + "reference": "https://attack.mitre.org/techniques/T1193/" + } + ] + } + ], + "type": "query", + "version": 3 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_suspicious_pdf_reader.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_suspicious_pdf_reader.json new file mode 100644 index 0000000000000..35206d130ea5f --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_suspicious_pdf_reader.json @@ -0,0 +1,39 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies suspicious child processes of PDF reader applications. These child processes are often launched via exploitation of PDF applications or social engineering.", + "index": [ + "winlogbeat-*" + ], + "language": "kuery", + "license": "Elastic License", + "name": "Suspicious PDF Reader Child Process", + "query": "event.category:process and event.type:(start or process_started) and process.parent.name:(AcroRd32.exe or Acrobat.exe or FoxitPhantomPDF.exe or FoxitReader.exe) and process.name:(arp.exe or dsquery.exe or dsget.exe or gpresult.exe or hostname.exe or ipconfig.exe or nbtstat.exe or net.exe or net1.exe or netsh.exe or netstat.exe or nltest.exe or ping.exe or qprocess.exe or quser.exe or qwinsta.exe or reg.exe or sc.exe or systeminfo.exe or tasklist.exe or tracert.exe or whoami.exe or bginfo.exe or cdb.exe or cmstp.exe or csi.exe or dnx.exe or fsi.exe or ieexec.exe or iexpress.exe or installutil.exe or Microsoft.Workflow.Compiler.exe or msbuild.exe or mshta.exe or msxsl.exe or odbcconf.exe or rcsi.exe or regsvr32.exe or xwizard.exe or atbroker.exe or forfiles.exe or schtasks.exe or regasm.exe or regsvcs.exe or cmd.exe or cscript.exe or powershell.exe or pwsh.exe or wmic.exe or wscript.exe or bitsadmin.exe or certutil.exe or ftp.exe)", + "risk_score": 21, + "rule_id": "53a26770-9cbd-40c5-8b57-61d01a325e14", + "severity": "low", + "tags": [ + "Elastic", + "Windows" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0002", + "name": "Execution", + "reference": "https://attack.mitre.org/tactics/TA0002/" + }, + "technique": [ + { + "id": "T1204", + "name": "User Execution", + "reference": "https://attack.mitre.org/techniques/T1204/" + } + ] + } + ], + "type": "query", + "version": 2 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/eql_unusual_network_connection_via_rundll32.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_unusual_network_connection_via_rundll32.json similarity index 76% rename from x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/eql_unusual_network_connection_via_rundll32.json rename to x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_unusual_network_connection_via_rundll32.json index c2be97f110a38..43f1f8a5c9c61 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/eql_unusual_network_connection_via_rundll32.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_unusual_network_connection_via_rundll32.json @@ -1,11 +1,15 @@ { + "author": [ + "Elastic" + ], "description": "Identifies unusual instances of rundll32.exe making outbound network connections. This may indicate adversarial activity and may identify malicious DLLs.", "index": [ "winlogbeat-*" ], "language": "kuery", + "license": "Elastic License", "name": "Unusual Network Connection via RunDLL32", - "query": "process.name:rundll32.exe and event.action:\"Network connection detected (rule: NetworkConnect)\" and not destination.ip:(10.0.0.0/8 or 172.16.0.0/12 or 192.168.0.0/16 or 127.0.0.0/8)", + "query": "event.category:network and event.type:connection and process.name:rundll32.exe and not destination.ip:(10.0.0.0/8 or 172.16.0.0/12 or 192.168.0.0/16 or 127.0.0.0/8)", "risk_score": 21, "rule_id": "52aaab7b-b51c-441a-89ce-4387b3aea886", "severity": "low", @@ -31,5 +35,5 @@ } ], "type": "query", - "version": 3 + "version": 4 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/eql_unusual_process_network_connection.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_unusual_process_network_connection.json similarity index 72% rename from x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/eql_unusual_process_network_connection.json rename to x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_unusual_process_network_connection.json index 481768e76ee37..b49d1b358cb8d 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/eql_unusual_process_network_connection.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_unusual_process_network_connection.json @@ -1,11 +1,15 @@ { + "author": [ + "Elastic" + ], "description": "Identifies network activity from unexpected system applications. This may indicate adversarial activity as these applications are often leveraged by adversaries to execute code and evade detection.", "index": [ "winlogbeat-*" ], "language": "kuery", + "license": "Elastic License", "name": "Unusual Process Network Connection", - "query": "event.action:\"Network connection detected (rule: NetworkConnect)\" and process.name:(Microsoft.Workflow.Compiler.exe or bginfo.exe or cdb.exe or cmstp.exe or csi.exe or dnx.exe or fsi.exe or ieexec.exe or iexpress.exe or odbcconf.exe or rcsi.exe or xwizard.exe)", + "query": "event.category:network and event.type:connection and process.name:(Microsoft.Workflow.Compiler.exe or bginfo.exe or cdb.exe or cmstp.exe or csi.exe or dnx.exe or fsi.exe or ieexec.exe or iexpress.exe or odbcconf.exe or rcsi.exe or xwizard.exe)", "risk_score": 21, "rule_id": "610949a1-312f-4e04-bb55-3a79b8c95267", "severity": "low", @@ -31,5 +35,5 @@ } ], "type": "query", - "version": 2 + "version": 3 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/windows_execution_via_compiled_html_file.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_via_compiled_html_file.json similarity index 95% rename from x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/windows_execution_via_compiled_html_file.json rename to x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_via_compiled_html_file.json index 07c87531c4a4a..f59b41c31b124 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/windows_execution_via_compiled_html_file.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_via_compiled_html_file.json @@ -1,4 +1,7 @@ { + "author": [ + "Elastic" + ], "description": "Compiled HTML files (.chm) are commonly distributed as part of the Microsoft HTML Help system. Adversaries may conceal malicious code in a CHM file and deliver it to a victim for execution. CHM content is loaded by the HTML Help executable program (hh.exe).", "false_positives": [ "The HTML Help executable program (hh.exe) runs whenever a user clicks a compiled help (.chm) file or menu item that opens the help file inside the Help Viewer. This is not always malicious, but adversaries may abuse this technology to conceal malicious code." @@ -7,6 +10,7 @@ "winlogbeat-*" ], "language": "kuery", + "license": "Elastic License", "name": "Process Activity via Compiled HTML File", "query": "event.code:1 and process.name:hh.exe", "risk_score": 21, @@ -49,5 +53,5 @@ } ], "type": "query", - "version": 2 + "version": 3 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/windows_execution_via_net_com_assemblies.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_via_net_com_assemblies.json similarity index 86% rename from x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/windows_execution_via_net_com_assemblies.json rename to x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_via_net_com_assemblies.json index fb59cff68410e..2c141da80e797 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/windows_execution_via_net_com_assemblies.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_via_net_com_assemblies.json @@ -1,11 +1,15 @@ { + "author": [ + "Elastic" + ], "description": "RegSvcs.exe and RegAsm.exe are Windows command line utilities that are used to register .NET Component Object Model (COM) assemblies. Adversaries can use RegSvcs.exe and RegAsm.exe to proxy execution of code through a trusted Windows utility.", "index": [ "winlogbeat-*" ], "language": "kuery", + "license": "Elastic License", "name": "Execution via Regsvcs/Regasm", - "query": "process.name:(RegAsm.exe or RegSvcs.exe) and event.action:\"Process Create (rule: ProcessCreate)\"", + "query": "event.category:process and event.type:(start or process_started) and process.name:(RegAsm.exe or RegSvcs.exe)", "risk_score": 21, "rule_id": "47f09343-8d1f-4bb5-8bb0-00c9d18f5010", "severity": "low", @@ -46,5 +50,5 @@ } ], "type": "query", - "version": 1 + "version": 2 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_via_system_manager.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_via_system_manager.json new file mode 100644 index 0000000000000..90338f4460725 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_via_system_manager.json @@ -0,0 +1,62 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies the execution of commands and scripts via System Manager. Execution methods such as RunShellScript, RunPowerShellScript, and alike can be abused by an authenticated attacker to install a backdoor or to interact with a compromised instance via reverse-shell using system only commands.", + "false_positives": [ + "Verify whether the user identity, user agent, and/or hostname should be making changes in your environment. Suspicious commands from unfamiliar users or hosts should be investigated. If known behavior is causing false positives, it can be exempted from the rule." + ], + "from": "now-60m", + "index": [ + "filebeat-*" + ], + "interval": "10m", + "language": "kuery", + "license": "Elastic License", + "name": "AWS Execution via System Manager", + "query": "event.module:aws and event.dataset:aws.cloudtrail and event.provider:ssm.amazonaws.com and event.action:SendCommand and event.outcome:success", + "references": [ + "https://docs.aws.amazon.com/systems-manager/latest/userguide/ssm-plugins.html" + ], + "risk_score": 21, + "rule_id": "37b211e8-4e2f-440f-86d8-06cc8f158cfa", + "severity": "low", + "tags": [ + "AWS", + "Elastic" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0002", + "name": "Execution", + "reference": "https://attack.mitre.org/tactics/TA0002/" + }, + "technique": [ + { + "id": "T1064", + "name": "Scripting", + "reference": "https://attack.mitre.org/techniques/T1064/" + } + ] + }, + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0002", + "name": "Execution", + "reference": "https://attack.mitre.org/tactics/TA0002/" + }, + "technique": [ + { + "id": "T1086", + "name": "PowerShell", + "reference": "https://attack.mitre.org/techniques/T1086/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/exfiltration_ec2_snapshot_change_activity.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/exfiltration_ec2_snapshot_change_activity.json new file mode 100644 index 0000000000000..04cc697cf36f9 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/exfiltration_ec2_snapshot_change_activity.json @@ -0,0 +1,48 @@ +{ + "author": [ + "Elastic" + ], + "description": "An attempt was made to modify AWS EC2 snapshot attributes. Snapshots are sometimes shared by threat actors in order to exfiltrate bulk data from an EC2 fleet. If the permissions were modified, verify the snapshot was not shared with an unauthorized or unexpected AWS account.", + "false_positives": [ + "IAM users may occasionally share EC2 snapshots with another AWS account belonging to the same organization. If known behavior is causing false positives, it can be exempted from the rule." + ], + "from": "now-60m", + "index": [ + "filebeat-*" + ], + "interval": "10m", + "language": "kuery", + "license": "Elastic License", + "name": "AWS EC2 Snapshot Activity", + "query": "event.module:aws and event.dataset:aws.cloudtrail and event.provider:ec2.amazonaws.com and event.action:ModifySnapshotAttribute", + "references": [ + "https://awscli.amazonaws.com/v2/documentation/api/latest/reference/ec2/modify-snapshot-attribute.html", + "https://docs.aws.amazon.com/AWSEC2/latest/APIReference/API_ModifySnapshotAttribute.html" + ], + "risk_score": 47, + "rule_id": "98fd7407-0bd5-5817-cda0-3fcc33113a56", + "severity": "medium", + "tags": [ + "AWS", + "Elastic" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0010", + "name": "Exfiltration", + "reference": "https://attack.mitre.org/tactics/TA0010/" + }, + "technique": [ + { + "id": "T1537", + "name": "Transfer Data to Cloud Account", + "reference": "https://attack.mitre.org/techniques/T1537/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/external_alerts.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/external_alerts.json new file mode 100644 index 0000000000000..c8ebb2ed0e5d7 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/external_alerts.json @@ -0,0 +1,54 @@ +{ + "author": [ + "Elastic" + ], + "description": "Generates a detection alert for each external alert written to the configured securitySolution:defaultIndex. Enabling this rule allows you to immediately begin investigating external alerts in the app.", + "language": "kuery", + "license": "Elastic License", + "max_signals": 10000, + "name": "External Alerts", + "query": "event.kind:alert and not event.module:(endgame or endpoint)", + "risk_score": 47, + "risk_score_mapping": [ + { + "field": "event.risk_score", + "operator": "equals", + "value": "" + } + ], + "rule_id": "eb079c62-4481-4d6e-9643-3ca499df7aaa", + "rule_name_override": "message", + "severity": "medium", + "severity_mapping": [ + { + "field": "event.severity", + "operator": "equals", + "severity": "low", + "value": "21" + }, + { + "field": "event.severity", + "operator": "equals", + "severity": "medium", + "value": "47" + }, + { + "field": "event.severity", + "operator": "equals", + "severity": "high", + "value": "73" + }, + { + "field": "event.severity", + "operator": "equals", + "severity": "critical", + "value": "99" + } + ], + "tags": [ + "Elastic" + ], + "timestamp_override": "event.ingested", + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_attempt_to_revoke_okta_api_token.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_attempt_to_revoke_okta_api_token.json new file mode 100644 index 0000000000000..0f4ded9fcfe87 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_attempt_to_revoke_okta_api_token.json @@ -0,0 +1,46 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies attempts to revoke an Okta API token. An adversary may attempt to revoke or delete an Okta API token to disrupt an organization's business operations.", + "false_positives": [ + "If the behavior of revoking Okta API tokens is expected, consider adding exceptions to this rule to filter false positives." + ], + "index": [ + "filebeat-*" + ], + "language": "kuery", + "license": "Elastic License", + "name": "Attempt to Revoke Okta API Token", + "query": "event.module:okta and event.dataset:okta.system and event.action:system.api_token.revoke", + "references": [ + "https://developer.okta.com/docs/reference/api/system-log/", + "https://developer.okta.com/docs/reference/api/event-types/" + ], + "risk_score": 21, + "rule_id": "676cff2b-450b-4cf1-8ed2-c0c58a4a2dd7", + "severity": "low", + "tags": [ + "Elastic", + "Okta" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0040", + "name": "Impact", + "reference": "https://attack.mitre.org/tactics/TA0040/" + }, + "technique": [ + { + "id": "T1531", + "name": "Account Access Removal", + "reference": "https://attack.mitre.org/techniques/T1531/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_cloudtrail_logging_updated.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_cloudtrail_logging_updated.json new file mode 100644 index 0000000000000..d969ef21027f0 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_cloudtrail_logging_updated.json @@ -0,0 +1,63 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies an update to an AWS log trail setting that specifies the delivery of log files.", + "false_positives": [ + "Trail updates may be made by a system or network administrator. Verify whether the user identity, user agent, and/or hostname should be making changes in your environment. Trail updates from unfamiliar users or hosts should be investigated. If known behavior is causing false positives, it can be exempted from the rule." + ], + "from": "now-60m", + "index": [ + "filebeat-*" + ], + "interval": "10m", + "language": "kuery", + "license": "Elastic License", + "name": "AWS CloudTrail Log Updated", + "query": "event.action:UpdateTrail and event.dataset:aws.cloudtrail and event.provider:cloudtrail.amazonaws.com and event.outcome:success", + "references": [ + "https://docs.aws.amazon.com/awscloudtrail/latest/APIReference/API_UpdateTrail.html", + "https://awscli.amazonaws.com/v2/documentation/api/latest/reference/cloudtrail/update-trail.html" + ], + "risk_score": 21, + "rule_id": "3e002465-876f-4f04-b016-84ef48ce7e5d", + "severity": "low", + "tags": [ + "AWS", + "Elastic" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0040", + "name": "Impact", + "reference": "https://attack.mitre.org/tactics/TA0040/" + }, + "technique": [ + { + "id": "T1492", + "name": "Stored Data Manipulation", + "reference": "https://attack.mitre.org/techniques/T1492/" + } + ] + }, + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0009", + "name": "Collection", + "reference": "https://attack.mitre.org/tactics/TA0009/" + }, + "technique": [ + { + "id": "T1530", + "name": "Data from Cloud Storage Object", + "reference": "https://attack.mitre.org/techniques/T1530/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_cloudwatch_log_group_deletion.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_cloudwatch_log_group_deletion.json new file mode 100644 index 0000000000000..d33593d4a44b2 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_cloudwatch_log_group_deletion.json @@ -0,0 +1,63 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies the deletion of a specified AWS CloudWatch log group. When a log group is deleted, all the archived log events associated with the log group are also permanently deleted.", + "false_positives": [ + "Verify whether the user identity, user agent, and/or hostname should be making changes in your environment. Log group deletions from unfamiliar users or hosts should be investigated. If known behavior is causing false positives, it can be exempted from the rule." + ], + "from": "now-60m", + "index": [ + "filebeat-*" + ], + "interval": "10m", + "language": "kuery", + "license": "Elastic License", + "name": "AWS CloudWatch Log Group Deletion", + "query": "event.action:DeleteLogGroup and event.dataset:aws.cloudtrail and event.provider:logs.amazonaws.com and event.outcome:success", + "references": [ + "https://awscli.amazonaws.com/v2/documentation/api/latest/reference/logs/delete-log-group.html", + "https://docs.aws.amazon.com/AmazonCloudWatchLogs/latest/APIReference/API_DeleteLogGroup.html" + ], + "risk_score": 47, + "rule_id": "68a7a5a5-a2fc-4a76-ba9f-26849de881b4", + "severity": "medium", + "tags": [ + "AWS", + "Elastic" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0040", + "name": "Impact", + "reference": "https://attack.mitre.org/tactics/TA0040/" + }, + "technique": [ + { + "id": "T1485", + "name": "Data Destruction", + "reference": "https://attack.mitre.org/techniques/T1485/" + } + ] + }, + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0005", + "name": "Defense Evasion", + "reference": "https://attack.mitre.org/tactics/TA0005/" + }, + "technique": [ + { + "id": "T1089", + "name": "Disabling Security Tools", + "reference": "https://attack.mitre.org/techniques/T1089/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_cloudwatch_log_stream_deletion.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_cloudwatch_log_stream_deletion.json new file mode 100644 index 0000000000000..a1108dd07abdd --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_cloudwatch_log_stream_deletion.json @@ -0,0 +1,63 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies the deletion of an AWS CloudWatch log stream, which permanently deletes all associated archived log events with the stream.", + "false_positives": [ + "A log stream may be deleted by a system administrator. Verify whether the user identity, user agent, and/or hostname should be making changes in your environment. Log stream deletions from unfamiliar users or hosts should be investigated. If known behavior is causing false positives, it can be exempted from the rule." + ], + "from": "now-60m", + "index": [ + "filebeat-*" + ], + "interval": "10m", + "language": "kuery", + "license": "Elastic License", + "name": "AWS CloudWatch Log Stream Deletion", + "query": "event.action:DeleteLogStream and event.dataset:aws.cloudtrail and event.provider:logs.amazonaws.com and event.outcome:success", + "references": [ + "https://awscli.amazonaws.com/v2/documentation/api/latest/reference/logs/delete-log-stream.html", + "https://docs.aws.amazon.com/AmazonCloudWatchLogs/latest/APIReference/API_DeleteLogStream.html" + ], + "risk_score": 47, + "rule_id": "d624f0ae-3dd1-4856-9aad-ccfe4d4bfa17", + "severity": "medium", + "tags": [ + "AWS", + "Elastic" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0040", + "name": "Impact", + "reference": "https://attack.mitre.org/tactics/TA0040/" + }, + "technique": [ + { + "id": "T1485", + "name": "Data Destruction", + "reference": "https://attack.mitre.org/techniques/T1485/" + } + ] + }, + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0005", + "name": "Defense Evasion", + "reference": "https://attack.mitre.org/tactics/TA0005/" + }, + "technique": [ + { + "id": "T1089", + "name": "Disabling Security Tools", + "reference": "https://attack.mitre.org/techniques/T1089/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_ec2_disable_ebs_encryption.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_ec2_disable_ebs_encryption.json new file mode 100644 index 0000000000000..4681b475d92e7 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_ec2_disable_ebs_encryption.json @@ -0,0 +1,49 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies disabling of Amazon Elastic Block Store (EBS) encryption by default in the current region. Disabling encryption by default does not change the encryption status of your existing volumes.", + "false_positives": [ + "Disabling encryption may be done by a system or network administrator. Verify whether the user identity, user agent, and/or hostname should be making changes in your environment. Disabling encryption by unfamiliar users or hosts should be investigated. If known behavior is causing false positives, it can be exempted from the rule." + ], + "from": "now-60m", + "index": [ + "filebeat-*" + ], + "interval": "10m", + "language": "kuery", + "license": "Elastic License", + "name": "AWS EC2 Encryption Disabled", + "query": "event.action:DisableEbsEncryptionByDefault and event.dataset:aws.cloudtrail and event.provider:ec2.amazonaws.com and event.outcome:success", + "references": [ + "https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/EBSEncryption.html", + "https://awscli.amazonaws.com/v2/documentation/api/latest/reference/ec2/disable-ebs-encryption-by-default.html", + "https://docs.aws.amazon.com/AWSEC2/latest/APIReference/API_DisableEbsEncryptionByDefault.html" + ], + "risk_score": 47, + "rule_id": "bb9b13b2-1700-48a8-a750-b43b0a72ab69", + "severity": "medium", + "tags": [ + "AWS", + "Elastic" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0040", + "name": "Impact", + "reference": "https://attack.mitre.org/tactics/TA0040/" + }, + "technique": [ + { + "id": "T1492", + "name": "Stored Data Manipulation", + "reference": "https://attack.mitre.org/techniques/T1492/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_iam_deactivate_mfa_device.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_iam_deactivate_mfa_device.json new file mode 100644 index 0000000000000..f873e3483a34f --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_iam_deactivate_mfa_device.json @@ -0,0 +1,48 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies the deactivation of a specified multi-factor authentication (MFA) device and removes it from association with the user name for which it was originally enabled. In AWS Identity and Access Management (IAM), a device must be deactivated before it can be deleted.", + "false_positives": [ + "A MFA device may be deactivated by a system or network administrator. Verify whether the user identity, user agent, and/or hostname should be making changes in your environment. MFA device deactivations from unfamiliar users or hosts should be investigated. If known behavior is causing false positives, it can be exempted from the rule." + ], + "from": "now-60m", + "index": [ + "filebeat-*" + ], + "interval": "10m", + "language": "kuery", + "license": "Elastic License", + "name": "AWS IAM Deactivation of MFA Device", + "query": "event.action:DeactivateMFADevice and event.dataset:aws.cloudtrail and event.provider:iam.amazonaws.com and event.outcome:success", + "references": [ + "https://awscli.amazonaws.com/v2/documentation/api/latest/reference/iam/deactivate-mfa-device.html", + "https://docs.aws.amazon.com/IAM/latest/APIReference/API_DeactivateMFADevice.html" + ], + "risk_score": 47, + "rule_id": "d8fc1cca-93ed-43c1-bbb6-c0dd3eff2958", + "severity": "medium", + "tags": [ + "AWS", + "Elastic" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0040", + "name": "Impact", + "reference": "https://attack.mitre.org/tactics/TA0040/" + }, + "technique": [ + { + "id": "T1531", + "name": "Account Access Removal", + "reference": "https://attack.mitre.org/techniques/T1531/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_iam_group_deletion.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_iam_group_deletion.json new file mode 100644 index 0000000000000..23364c8b3aa28 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_iam_group_deletion.json @@ -0,0 +1,48 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies the deletion of a specified AWS Identity and Access Management (IAM) resource group. Deleting a resource group does not delete resources that are members of the group; it only deletes the group structure.", + "false_positives": [ + "A resource group may be deleted by a system administrator. Verify whether the user identity, user agent, and/or hostname should be making changes in your environment. Resource group deletions from unfamiliar users or hosts should be investigated. If known behavior is causing false positives, it can be exempted from the rule." + ], + "from": "now-60m", + "index": [ + "filebeat-*" + ], + "interval": "10m", + "language": "kuery", + "license": "Elastic License", + "name": "AWS IAM Group Deletion", + "query": "event.action:DeleteGroup and event.dataset:aws.cloudtrail and event.provider:iam.amazonaws.com and event.outcome:success", + "references": [ + "https://awscli.amazonaws.com/v2/documentation/api/latest/reference/iam/delete-group.html", + "https://docs.aws.amazon.com/IAM/latest/APIReference/API_DeleteGroup.html" + ], + "risk_score": 21, + "rule_id": "867616ec-41e5-4edc-ada2-ab13ab45de8a", + "severity": "low", + "tags": [ + "AWS", + "Elastic" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0040", + "name": "Impact", + "reference": "https://attack.mitre.org/tactics/TA0040/" + }, + "technique": [ + { + "id": "T1531", + "name": "Account Access Removal", + "reference": "https://attack.mitre.org/techniques/T1531/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_possible_okta_dos_attack.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_possible_okta_dos_attack.json new file mode 100644 index 0000000000000..8c76f182442a5 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_possible_okta_dos_attack.json @@ -0,0 +1,48 @@ +{ + "author": [ + "Elastic" + ], + "description": "An adversary may attempt to disrupt an organization's business operations by performing a denial of service (DoS) attack against its Okta infrastructure.", + "index": [ + "filebeat-*" + ], + "language": "kuery", + "license": "Elastic License", + "name": "Possible Okta DoS Attack", + "query": "event.module:okta and event.dataset:okta.system and event.action:(application.integration.rate_limit_exceeded or system.org.rate_limit.warning or system.org.rate_limit.violation or core.concurrency.org.limit.violation)", + "references": [ + "https://developer.okta.com/docs/reference/api/system-log/", + "https://developer.okta.com/docs/reference/api/event-types/" + ], + "risk_score": 47, + "rule_id": "e6e3ecff-03dd-48ec-acbd-54a04de10c68", + "severity": "medium", + "tags": [ + "Elastic", + "Okta" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0040", + "name": "Impact", + "reference": "https://attack.mitre.org/tactics/TA0040/" + }, + "technique": [ + { + "id": "T1498", + "name": "Network Denial of Service", + "reference": "https://attack.mitre.org/techniques/T1498/" + }, + { + "id": "T1499", + "name": "Endpoint Denial of Service", + "reference": "https://attack.mitre.org/techniques/T1499/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_rds_cluster_deletion.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_rds_cluster_deletion.json new file mode 100644 index 0000000000000..88ec942b0e5e5 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_rds_cluster_deletion.json @@ -0,0 +1,50 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies the deletion of an Amazon Relational Database Service (RDS) Aurora database cluster or global database cluster.", + "false_positives": [ + "Clusters may be deleted by a system administrator. Verify whether the user identity, user agent, and/or hostname should be making changes in your environment. Cluster deletions from unfamiliar users or hosts should be investigated. If known behavior is causing false positives, it can be exempted from the rule." + ], + "from": "now-60m", + "index": [ + "filebeat-*" + ], + "interval": "10m", + "language": "kuery", + "license": "Elastic License", + "name": "AWS RDS Cluster Deletion", + "query": "event.action:(DeleteDBCluster or DeleteGlobalCluster) and event.dataset:aws.cloudtrail and event.provider:rds.amazonaws.com and event.outcome:success", + "references": [ + "https://awscli.amazonaws.com/v2/documentation/api/latest/reference/rds/delete-db-cluster.html", + "https://docs.aws.amazon.com/AmazonRDS/latest/APIReference/API_DeleteDBCluster.html", + "https://awscli.amazonaws.com/v2/documentation/api/latest/reference/rds/delete-global-cluster.html", + "https://docs.aws.amazon.com/AmazonRDS/latest/APIReference/API_DeleteGlobalCluster.html" + ], + "risk_score": 47, + "rule_id": "9055ece6-2689-4224-a0e0-b04881e1f8ad", + "severity": "medium", + "tags": [ + "AWS", + "Elastic" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0040", + "name": "Impact", + "reference": "https://attack.mitre.org/tactics/TA0040/" + }, + "technique": [ + { + "id": "T1485", + "name": "Data Destruction", + "reference": "https://attack.mitre.org/techniques/T1485/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_rds_instance_cluster_stoppage.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_rds_instance_cluster_stoppage.json new file mode 100644 index 0000000000000..2c25781e24d19 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_rds_instance_cluster_stoppage.json @@ -0,0 +1,50 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies that an Amazon Relational Database Service (RDS) cluster or instance has been stopped.", + "false_positives": [ + "Valid clusters or instances may be stopped by a system administrator. Verify whether the user identity, user agent, and/or hostname should be making changes in your environment. Cluster or instance stoppages from unfamiliar users or hosts should be investigated. If known behavior is causing false positives, it can be exempted from the rule." + ], + "from": "now-60m", + "index": [ + "filebeat-*" + ], + "interval": "10m", + "language": "kuery", + "license": "Elastic License", + "name": "AWS RDS Instance/Cluster Stoppage", + "query": "event.action:(StopDBCluster or StopDBInstance) and event.dataset:aws.cloudtrail and event.provider:rds.amazonaws.com and event.outcome:success", + "references": [ + "https://awscli.amazonaws.com/v2/documentation/api/latest/reference/rds/stop-db-cluster.html", + "https://docs.aws.amazon.com/AmazonRDS/latest/APIReference/API_StopDBCluster.html", + "https://awscli.amazonaws.com/v2/documentation/api/latest/reference/rds/stop-db-instance.html", + "https://docs.aws.amazon.com/AmazonRDS/latest/APIReference/API_StopDBInstance.html" + ], + "risk_score": 47, + "rule_id": "ecf2b32c-e221-4bd4-aa3b-c7d59b3bc01d", + "severity": "medium", + "tags": [ + "AWS", + "Elastic" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0040", + "name": "Impact", + "reference": "https://attack.mitre.org/tactics/TA0040/" + }, + "technique": [ + { + "id": "T1489", + "name": "Service Stop", + "reference": "https://attack.mitre.org/techniques/T1489/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/index.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/index.ts index 0a2317898e8a3..880caca03cb7d 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/index.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/index.ts @@ -4,154 +4,208 @@ * you may not use this file except in compliance with the Elastic License. */ -// Auto generated file from scripts/regen_prepackage_rules_index.sh -// Do not hand edit. Run that script to regenerate package information instead +// Auto generated file from either: +// - scripts/regen_prepackage_rules_index.sh +// - detection-rules repo using CLI command build-release +// Do not hand edit. Run script/command to regenerate package information instead + +import rule1 from './apm_403_response_to_a_post.json'; +import rule2 from './apm_405_response_method_not_allowed.json'; +import rule3 from './apm_null_user_agent.json'; +import rule4 from './apm_sqlmap_user_agent.json'; +import rule5 from './command_and_control_dns_directly_to_the_internet.json'; +import rule6 from './command_and_control_ftp_file_transfer_protocol_activity_to_the_internet.json'; +import rule7 from './command_and_control_irc_internet_relay_chat_protocol_activity_to_the_internet.json'; +import rule8 from './command_and_control_nat_traversal_port_activity.json'; +import rule9 from './command_and_control_port_26_activity.json'; +import rule10 from './command_and_control_port_8000_activity_to_the_internet.json'; +import rule11 from './command_and_control_pptp_point_to_point_tunneling_protocol_activity.json'; +import rule12 from './command_and_control_proxy_port_activity_to_the_internet.json'; +import rule13 from './command_and_control_rdp_remote_desktop_protocol_from_the_internet.json'; +import rule14 from './command_and_control_smtp_to_the_internet.json'; +import rule15 from './command_and_control_sql_server_port_activity_to_the_internet.json'; +import rule16 from './command_and_control_ssh_secure_shell_from_the_internet.json'; +import rule17 from './command_and_control_ssh_secure_shell_to_the_internet.json'; +import rule18 from './command_and_control_telnet_port_activity.json'; +import rule19 from './command_and_control_tor_activity_to_the_internet.json'; +import rule20 from './command_and_control_vnc_virtual_network_computing_from_the_internet.json'; +import rule21 from './command_and_control_vnc_virtual_network_computing_to_the_internet.json'; +import rule22 from './credential_access_tcpdump_activity.json'; +import rule23 from './defense_evasion_adding_the_hidden_file_attribute_with_via_attribexe.json'; +import rule24 from './defense_evasion_clearing_windows_event_logs.json'; +import rule25 from './defense_evasion_delete_volume_usn_journal_with_fsutil.json'; +import rule26 from './defense_evasion_deleting_backup_catalogs_with_wbadmin.json'; +import rule27 from './defense_evasion_disable_windows_firewall_rules_with_netsh.json'; +import rule28 from './defense_evasion_encoding_or_decoding_files_via_certutil.json'; +import rule29 from './defense_evasion_execution_via_trusted_developer_utilities.json'; +import rule30 from './defense_evasion_misc_lolbin_connecting_to_the_internet.json'; +import rule31 from './defense_evasion_via_filter_manager.json'; +import rule32 from './defense_evasion_volume_shadow_copy_deletion_via_vssadmin.json'; +import rule33 from './defense_evasion_volume_shadow_copy_deletion_via_wmic.json'; +import rule34 from './discovery_process_discovery_via_tasklist_command.json'; +import rule35 from './discovery_whoami_command_activity.json'; +import rule36 from './discovery_whoami_commmand.json'; +import rule37 from './endpoint_adversary_behavior_detected.json'; +import rule38 from './endpoint_cred_dumping_detected.json'; +import rule39 from './endpoint_cred_dumping_prevented.json'; +import rule40 from './endpoint_cred_manipulation_detected.json'; +import rule41 from './endpoint_cred_manipulation_prevented.json'; +import rule42 from './endpoint_exploit_detected.json'; +import rule43 from './endpoint_exploit_prevented.json'; +import rule44 from './endpoint_malware_detected.json'; +import rule45 from './endpoint_malware_prevented.json'; +import rule46 from './endpoint_permission_theft_detected.json'; +import rule47 from './endpoint_permission_theft_prevented.json'; +import rule48 from './endpoint_process_injection_detected.json'; +import rule49 from './endpoint_process_injection_prevented.json'; +import rule50 from './endpoint_ransomware_detected.json'; +import rule51 from './endpoint_ransomware_prevented.json'; +import rule52 from './execution_command_prompt_connecting_to_the_internet.json'; +import rule53 from './execution_command_shell_started_by_powershell.json'; +import rule54 from './execution_command_shell_started_by_svchost.json'; +import rule55 from './execution_html_help_executable_program_connecting_to_the_internet.json'; +import rule56 from './execution_local_service_commands.json'; +import rule57 from './execution_msbuild_making_network_connections.json'; +import rule58 from './execution_mshta_making_network_connections.json'; +import rule59 from './execution_psexec_lateral_movement_command.json'; +import rule60 from './execution_register_server_program_connecting_to_the_internet.json'; +import rule61 from './execution_script_executing_powershell.json'; +import rule62 from './execution_suspicious_ms_office_child_process.json'; +import rule63 from './execution_suspicious_ms_outlook_child_process.json'; +import rule64 from './execution_unusual_network_connection_via_rundll32.json'; +import rule65 from './execution_unusual_process_network_connection.json'; +import rule66 from './execution_via_compiled_html_file.json'; +import rule67 from './initial_access_rdp_remote_desktop_protocol_to_the_internet.json'; +import rule68 from './initial_access_rpc_remote_procedure_call_from_the_internet.json'; +import rule69 from './initial_access_rpc_remote_procedure_call_to_the_internet.json'; +import rule70 from './initial_access_smb_windows_file_sharing_activity_to_the_internet.json'; +import rule71 from './lateral_movement_direct_outbound_smb_connection.json'; +import rule72 from './linux_hping_activity.json'; +import rule73 from './linux_iodine_activity.json'; +import rule74 from './linux_mknod_activity.json'; +import rule75 from './linux_netcat_network_connection.json'; +import rule76 from './linux_nmap_activity.json'; +import rule77 from './linux_nping_activity.json'; +import rule78 from './linux_process_started_in_temp_directory.json'; +import rule79 from './linux_socat_activity.json'; +import rule80 from './linux_strace_activity.json'; +import rule81 from './persistence_adobe_hijack_persistence.json'; +import rule82 from './persistence_kernel_module_activity.json'; +import rule83 from './persistence_local_scheduled_task_commands.json'; +import rule84 from './persistence_priv_escalation_via_accessibility_features.json'; +import rule85 from './persistence_shell_activity_by_web_server.json'; +import rule86 from './persistence_system_shells_via_services.json'; +import rule87 from './persistence_user_account_creation.json'; +import rule88 from './persistence_via_application_shimming.json'; +import rule89 from './privilege_escalation_unusual_parentchild_relationship.json'; +import rule90 from './defense_evasion_modification_of_boot_config.json'; +import rule91 from './privilege_escalation_uac_bypass_event_viewer.json'; +import rule92 from './discovery_net_command_system_account.json'; +import rule93 from './execution_msxsl_network.json'; +import rule94 from './command_and_control_certutil_network_connection.json'; +import rule95 from './defense_evasion_cve_2020_0601.json'; +import rule96 from './credential_access_credential_dumping_msbuild.json'; +import rule97 from './defense_evasion_execution_msbuild_started_by_office_app.json'; +import rule98 from './defense_evasion_execution_msbuild_started_by_script.json'; +import rule99 from './defense_evasion_execution_msbuild_started_by_system_process.json'; +import rule100 from './defense_evasion_execution_msbuild_started_renamed.json'; +import rule101 from './defense_evasion_execution_msbuild_started_unusal_process.json'; +import rule102 from './defense_evasion_injection_msbuild.json'; +import rule103 from './execution_via_net_com_assemblies.json'; +import rule104 from './ml_linux_anomalous_network_activity.json'; +import rule105 from './ml_linux_anomalous_network_port_activity.json'; +import rule106 from './ml_linux_anomalous_network_service.json'; +import rule107 from './ml_linux_anomalous_network_url_activity.json'; +import rule108 from './ml_linux_anomalous_process_all_hosts.json'; +import rule109 from './ml_linux_anomalous_user_name.json'; +import rule110 from './ml_packetbeat_dns_tunneling.json'; +import rule111 from './ml_packetbeat_rare_dns_question.json'; +import rule112 from './ml_packetbeat_rare_server_domain.json'; +import rule113 from './ml_packetbeat_rare_urls.json'; +import rule114 from './ml_packetbeat_rare_user_agent.json'; +import rule115 from './ml_rare_process_by_host_linux.json'; +import rule116 from './ml_rare_process_by_host_windows.json'; +import rule117 from './ml_suspicious_login_activity.json'; +import rule118 from './ml_windows_anomalous_network_activity.json'; +import rule119 from './ml_windows_anomalous_path_activity.json'; +import rule120 from './ml_windows_anomalous_process_all_hosts.json'; +import rule121 from './ml_windows_anomalous_process_creation.json'; +import rule122 from './ml_windows_anomalous_script.json'; +import rule123 from './ml_windows_anomalous_service.json'; +import rule124 from './ml_windows_anomalous_user_name.json'; +import rule125 from './ml_windows_rare_user_runas_event.json'; +import rule126 from './ml_windows_rare_user_type10_remote_login.json'; +import rule127 from './execution_suspicious_pdf_reader.json'; +import rule128 from './privilege_escalation_sudoers_file_mod.json'; +import rule129 from './execution_python_tty_shell.json'; +import rule130 from './execution_perl_tty_shell.json'; +import rule131 from './defense_evasion_base16_or_base32_encoding_or_decoding_activity.json'; +import rule132 from './defense_evasion_base64_encoding_or_decoding_activity.json'; +import rule133 from './defense_evasion_hex_encoding_or_decoding_activity.json'; +import rule134 from './defense_evasion_file_mod_writable_dir.json'; +import rule135 from './defense_evasion_disable_selinux_attempt.json'; +import rule136 from './discovery_kernel_module_enumeration.json'; +import rule137 from './lateral_movement_telnet_network_activity_external.json'; +import rule138 from './lateral_movement_telnet_network_activity_internal.json'; +import rule139 from './privilege_escalation_setgid_bit_set_via_chmod.json'; +import rule140 from './privilege_escalation_setuid_bit_set_via_chmod.json'; +import rule141 from './defense_evasion_attempt_to_disable_iptables_or_firewall.json'; +import rule142 from './defense_evasion_kernel_module_removal.json'; +import rule143 from './defense_evasion_attempt_to_disable_syslog_service.json'; +import rule144 from './defense_evasion_file_deletion_via_shred.json'; +import rule145 from './discovery_virtual_machine_fingerprinting.json'; +import rule146 from './defense_evasion_hidden_file_dir_tmp.json'; +import rule147 from './defense_evasion_deletion_of_bash_command_line_history.json'; +import rule148 from './impact_cloudwatch_log_group_deletion.json'; +import rule149 from './impact_cloudwatch_log_stream_deletion.json'; +import rule150 from './impact_rds_instance_cluster_stoppage.json'; +import rule151 from './persistence_attempt_to_deactivate_mfa_for_okta_user_account.json'; +import rule152 from './persistence_rds_cluster_creation.json'; +import rule153 from './credential_access_attempted_bypass_of_okta_mfa.json'; +import rule154 from './defense_evasion_waf_acl_deletion.json'; +import rule155 from './impact_attempt_to_revoke_okta_api_token.json'; +import rule156 from './impact_iam_group_deletion.json'; +import rule157 from './impact_possible_okta_dos_attack.json'; +import rule158 from './impact_rds_cluster_deletion.json'; +import rule159 from './initial_access_suspicious_activity_reported_by_okta_user.json'; +import rule160 from './okta_attempt_to_deactivate_okta_mfa_rule.json'; +import rule161 from './okta_attempt_to_modify_okta_mfa_rule.json'; +import rule162 from './okta_attempt_to_modify_okta_network_zone.json'; +import rule163 from './okta_attempt_to_modify_okta_policy.json'; +import rule164 from './okta_threat_detected_by_okta_threatinsight.json'; +import rule165 from './persistence_administrator_privileges_assigned_to_okta_group.json'; +import rule166 from './persistence_attempt_to_create_okta_api_token.json'; +import rule167 from './persistence_attempt_to_deactivate_okta_policy.json'; +import rule168 from './persistence_attempt_to_reset_mfa_factors_for_okta_user_account.json'; +import rule169 from './defense_evasion_cloudtrail_logging_deleted.json'; +import rule170 from './defense_evasion_ec2_network_acl_deletion.json'; +import rule171 from './impact_iam_deactivate_mfa_device.json'; +import rule172 from './defense_evasion_s3_bucket_configuration_deletion.json'; +import rule173 from './defense_evasion_guardduty_detector_deletion.json'; +import rule174 from './okta_attempt_to_delete_okta_policy.json'; +import rule175 from './credential_access_iam_user_addition_to_group.json'; +import rule176 from './persistence_ec2_network_acl_creation.json'; +import rule177 from './impact_ec2_disable_ebs_encryption.json'; +import rule178 from './persistence_iam_group_creation.json'; +import rule179 from './defense_evasion_waf_rule_or_rule_group_deletion.json'; +import rule180 from './collection_cloudtrail_logging_created.json'; +import rule181 from './defense_evasion_cloudtrail_logging_suspended.json'; +import rule182 from './impact_cloudtrail_logging_updated.json'; +import rule183 from './initial_access_console_login_root.json'; +import rule184 from './defense_evasion_cloudwatch_alarm_deletion.json'; +import rule185 from './defense_evasion_ec2_flow_log_deletion.json'; +import rule186 from './defense_evasion_configuration_recorder_stopped.json'; +import rule187 from './exfiltration_ec2_snapshot_change_activity.json'; +import rule188 from './defense_evasion_config_service_rule_deletion.json'; +import rule189 from './okta_attempt_to_modify_or_delete_application_sign_on_policy.json'; +import rule190 from './initial_access_password_recovery.json'; +import rule191 from './credential_access_secretsmanager_getsecretvalue.json'; +import rule192 from './execution_via_system_manager.json'; +import rule193 from './privilege_escalation_root_login_without_mfa.json'; +import rule194 from './privilege_escalation_updateassumerolepolicy.json'; +import rule195 from './elastic_endpoint.json'; +import rule196 from './external_alerts.json'; -import rule1 from './403_response_to_a_post.json'; -import rule2 from './405_response_method_not_allowed.json'; -import rule3 from './elastic_endpoint_security_adversary_behavior_detected.json'; -import rule4 from './elastic_endpoint_security_cred_dumping_detected.json'; -import rule5 from './elastic_endpoint_security_cred_dumping_prevented.json'; -import rule6 from './elastic_endpoint_security_cred_manipulation_detected.json'; -import rule7 from './elastic_endpoint_security_cred_manipulation_prevented.json'; -import rule8 from './elastic_endpoint_security_exploit_detected.json'; -import rule9 from './elastic_endpoint_security_exploit_prevented.json'; -import rule10 from './elastic_endpoint_security_malware_detected.json'; -import rule11 from './elastic_endpoint_security_malware_prevented.json'; -import rule12 from './elastic_endpoint_security_permission_theft_detected.json'; -import rule13 from './elastic_endpoint_security_permission_theft_prevented.json'; -import rule14 from './elastic_endpoint_security_process_injection_detected.json'; -import rule15 from './elastic_endpoint_security_process_injection_prevented.json'; -import rule16 from './elastic_endpoint_security_ransomware_detected.json'; -import rule17 from './elastic_endpoint_security_ransomware_prevented.json'; -import rule18 from './eql_adding_the_hidden_file_attribute_with_via_attribexe.json'; -import rule19 from './eql_adobe_hijack_persistence.json'; -import rule20 from './eql_clearing_windows_event_logs.json'; -import rule21 from './eql_delete_volume_usn_journal_with_fsutil.json'; -import rule22 from './eql_deleting_backup_catalogs_with_wbadmin.json'; -import rule23 from './eql_direct_outbound_smb_connection.json'; -import rule24 from './eql_disable_windows_firewall_rules_with_netsh.json'; -import rule25 from './eql_encoding_or_decoding_files_via_certutil.json'; -import rule26 from './eql_local_scheduled_task_commands.json'; -import rule27 from './eql_local_service_commands.json'; -import rule28 from './eql_msbuild_making_network_connections.json'; -import rule29 from './eql_mshta_making_network_connections.json'; -import rule30 from './eql_psexec_lateral_movement_command.json'; -import rule31 from './eql_suspicious_ms_office_child_process.json'; -import rule32 from './eql_suspicious_ms_outlook_child_process.json'; -import rule33 from './eql_system_shells_via_services.json'; -import rule34 from './eql_unusual_network_connection_via_rundll32.json'; -import rule35 from './eql_unusual_parentchild_relationship.json'; -import rule36 from './eql_unusual_process_network_connection.json'; -import rule37 from './eql_user_account_creation.json'; -import rule38 from './eql_volume_shadow_copy_deletion_via_vssadmin.json'; -import rule39 from './eql_volume_shadow_copy_deletion_via_wmic.json'; -import rule40 from './eql_windows_script_executing_powershell.json'; -import rule41 from './linux_anomalous_network_activity.json'; -import rule42 from './linux_anomalous_network_port_activity.json'; -import rule43 from './linux_anomalous_network_service.json'; -import rule44 from './linux_anomalous_network_url_activity.json'; -import rule45 from './linux_anomalous_process_all_hosts.json'; -import rule46 from './linux_anomalous_user_name.json'; -import rule47 from './linux_attempt_to_disable_iptables_or_firewall.json'; -import rule48 from './linux_attempt_to_disable_syslog_service.json'; -import rule49 from './linux_base16_or_base32_encoding_or_decoding_activity.json'; -import rule50 from './linux_base64_encoding_or_decoding_activity.json'; -import rule51 from './linux_disable_selinux_attempt.json'; -import rule52 from './linux_file_deletion_via_shred.json'; -import rule53 from './linux_file_mod_writable_dir.json'; -import rule54 from './linux_hex_encoding_or_decoding_activity.json'; -import rule55 from './linux_hping_activity.json'; -import rule56 from './linux_iodine_activity.json'; -import rule57 from './linux_kernel_module_activity.json'; -import rule58 from './linux_kernel_module_enumeration.json'; -import rule59 from './linux_kernel_module_removal.json'; -import rule60 from './linux_mknod_activity.json'; -import rule61 from './linux_netcat_network_connection.json'; -import rule62 from './linux_nmap_activity.json'; -import rule63 from './linux_nping_activity.json'; -import rule64 from './linux_perl_tty_shell.json'; -import rule65 from './linux_process_started_in_temp_directory.json'; -import rule66 from './linux_python_tty_shell.json'; -import rule67 from './linux_setgid_bit_set_via_chmod.json'; -import rule68 from './linux_setuid_bit_set_via_chmod.json'; -import rule69 from './linux_shell_activity_by_web_server.json'; -import rule70 from './linux_socat_activity.json'; -import rule71 from './linux_strace_activity.json'; -import rule72 from './linux_sudoers_file_mod.json'; -import rule73 from './linux_tcpdump_activity.json'; -import rule74 from './linux_telnet_network_activity_external.json'; -import rule75 from './linux_telnet_network_activity_internal.json'; -import rule76 from './linux_virtual_machine_fingerprinting.json'; -import rule77 from './linux_whoami_commmand.json'; -import rule78 from './network_dns_directly_to_the_internet.json'; -import rule79 from './network_ftp_file_transfer_protocol_activity_to_the_internet.json'; -import rule80 from './network_irc_internet_relay_chat_protocol_activity_to_the_internet.json'; -import rule81 from './network_nat_traversal_port_activity.json'; -import rule82 from './network_port_26_activity.json'; -import rule83 from './network_port_8000_activity_to_the_internet.json'; -import rule84 from './network_pptp_point_to_point_tunneling_protocol_activity.json'; -import rule85 from './network_proxy_port_activity_to_the_internet.json'; -import rule86 from './network_rdp_remote_desktop_protocol_from_the_internet.json'; -import rule87 from './network_rdp_remote_desktop_protocol_to_the_internet.json'; -import rule88 from './network_rpc_remote_procedure_call_from_the_internet.json'; -import rule89 from './network_rpc_remote_procedure_call_to_the_internet.json'; -import rule90 from './network_smb_windows_file_sharing_activity_to_the_internet.json'; -import rule91 from './network_smtp_to_the_internet.json'; -import rule92 from './network_sql_server_port_activity_to_the_internet.json'; -import rule93 from './network_ssh_secure_shell_from_the_internet.json'; -import rule94 from './network_ssh_secure_shell_to_the_internet.json'; -import rule95 from './network_telnet_port_activity.json'; -import rule96 from './network_tor_activity_to_the_internet.json'; -import rule97 from './network_vnc_virtual_network_computing_from_the_internet.json'; -import rule98 from './network_vnc_virtual_network_computing_to_the_internet.json'; -import rule99 from './null_user_agent.json'; -import rule100 from './packetbeat_dns_tunneling.json'; -import rule101 from './packetbeat_rare_dns_question.json'; -import rule102 from './packetbeat_rare_server_domain.json'; -import rule103 from './packetbeat_rare_urls.json'; -import rule104 from './packetbeat_rare_user_agent.json'; -import rule105 from './rare_process_by_host_linux.json'; -import rule106 from './rare_process_by_host_windows.json'; -import rule107 from './sqlmap_user_agent.json'; -import rule108 from './suspicious_login_activity.json'; -import rule109 from './windows_anomalous_network_activity.json'; -import rule110 from './windows_anomalous_path_activity.json'; -import rule111 from './windows_anomalous_process_all_hosts.json'; -import rule112 from './windows_anomalous_process_creation.json'; -import rule113 from './windows_anomalous_script.json'; -import rule114 from './windows_anomalous_service.json'; -import rule115 from './windows_anomalous_user_name.json'; -import rule116 from './windows_certutil_network_connection.json'; -import rule117 from './windows_command_prompt_connecting_to_the_internet.json'; -import rule118 from './windows_command_shell_started_by_powershell.json'; -import rule119 from './windows_command_shell_started_by_svchost.json'; -import rule120 from './windows_credential_dumping_msbuild.json'; -import rule121 from './windows_cve_2020_0601.json'; -import rule122 from './windows_defense_evasion_via_filter_manager.json'; -import rule123 from './windows_execution_msbuild_started_by_office_app.json'; -import rule124 from './windows_execution_msbuild_started_by_script.json'; -import rule125 from './windows_execution_msbuild_started_by_system_process.json'; -import rule126 from './windows_execution_msbuild_started_renamed.json'; -import rule127 from './windows_execution_msbuild_started_unusal_process.json'; -import rule128 from './windows_execution_via_compiled_html_file.json'; -import rule129 from './windows_execution_via_net_com_assemblies.json'; -import rule130 from './windows_execution_via_trusted_developer_utilities.json'; -import rule131 from './windows_html_help_executable_program_connecting_to_the_internet.json'; -import rule132 from './windows_injection_msbuild.json'; -import rule133 from './windows_misc_lolbin_connecting_to_the_internet.json'; -import rule134 from './windows_modification_of_boot_config.json'; -import rule135 from './windows_msxsl_network.json'; -import rule136 from './windows_net_command_system_account.json'; -import rule137 from './windows_persistence_via_application_shimming.json'; -import rule138 from './windows_priv_escalation_via_accessibility_features.json'; -import rule139 from './windows_process_discovery_via_tasklist_command.json'; -import rule140 from './windows_rare_user_runas_event.json'; -import rule141 from './windows_rare_user_type10_remote_login.json'; -import rule142 from './windows_register_server_program_connecting_to_the_internet.json'; -import rule143 from './windows_suspicious_pdf_reader.json'; -import rule144 from './windows_uac_bypass_event_viewer.json'; -import rule145 from './windows_whoami_command_activity.json'; export const rawRules = [ rule1, rule2, @@ -298,4 +352,55 @@ export const rawRules = [ rule143, rule144, rule145, + rule146, + rule147, + rule148, + rule149, + rule150, + rule151, + rule152, + rule153, + rule154, + rule155, + rule156, + rule157, + rule158, + rule159, + rule160, + rule161, + rule162, + rule163, + rule164, + rule165, + rule166, + rule167, + rule168, + rule169, + rule170, + rule171, + rule172, + rule173, + rule174, + rule175, + rule176, + rule177, + rule178, + rule179, + rule180, + rule181, + rule182, + rule183, + rule184, + rule185, + rule186, + rule187, + rule188, + rule189, + rule190, + rule191, + rule192, + rule193, + rule194, + rule195, + rule196, ]; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/initial_access_console_login_root.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/initial_access_console_login_root.json new file mode 100644 index 0000000000000..0f761f0d2a5f5 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/initial_access_console_login_root.json @@ -0,0 +1,62 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies a successful login to the AWS Management Console by the Root user.", + "false_positives": [ + "It's strongly recommended that the root user is not used for everyday tasks, including the administrative ones. Verify whether the IP address, location, and/or hostname should be logging in as root in your environment. Unfamiliar root logins should be investigated immediately. If known behavior is causing false positives, it can be exempted from the rule." + ], + "from": "now-60m", + "index": [ + "filebeat-*" + ], + "interval": "10m", + "language": "kuery", + "license": "Elastic License", + "name": "AWS Management Console Root Login", + "query": "event.action:ConsoleLogin and event.module:aws and event.dataset:aws.cloudtrail and event.provider:signin.amazonaws.com and aws.cloudtrail.user_identity.type:Root and event.outcome:success", + "references": [ + "https://docs.aws.amazon.com/IAM/latest/UserGuide/id_root-user.html" + ], + "risk_score": 73, + "rule_id": "e2a67480-3b79-403d-96e3-fdd2992c50ef", + "severity": "high", + "tags": [ + "AWS", + "Elastic" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0001", + "name": "Initial Access", + "reference": "https://attack.mitre.org/tactics/TA0001/" + }, + "technique": [ + { + "id": "T1078", + "name": "Valid Accounts", + "reference": "https://attack.mitre.org/techniques/T1078/" + } + ] + }, + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0003", + "name": "Persistence", + "reference": "https://attack.mitre.org/tactics/TA0003/" + }, + "technique": [ + { + "id": "T1078", + "name": "Valid Accounts", + "reference": "https://attack.mitre.org/techniques/T1078/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/initial_access_password_recovery.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/initial_access_password_recovery.json new file mode 100644 index 0000000000000..1042ce19a14c7 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/initial_access_password_recovery.json @@ -0,0 +1,47 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies AWS IAM password recovery requests. An adversary may attempt to gain unauthorized AWS access by abusing password recovery mechanisms.", + "false_positives": [ + "Verify whether the user identity, user agent, and/or hostname should be requesting changes in your environment. Password reset attempts from unfamiliar users should be investigated. If known behavior is causing false positives, it can be exempted from the rule." + ], + "from": "now-60m", + "index": [ + "filebeat-*" + ], + "interval": "10m", + "language": "kuery", + "license": "Elastic License", + "name": "AWS IAM Password Recovery Requested", + "query": "event.action:PasswordRecoveryRequested and event.provider:signin.amazonaws.com and event.outcome:success", + "references": [ + "https://www.cadosecurity.com/2020/06/11/an-ongoing-aws-phishing-campaign/" + ], + "risk_score": 21, + "rule_id": "69c420e8-6c9e-4d28-86c0-8a2be2d1e78c", + "severity": "low", + "tags": [ + "AWS", + "Elastic" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0001", + "name": "Initial Access", + "reference": "https://attack.mitre.org/tactics/TA0001/" + }, + "technique": [ + { + "id": "T1078", + "name": "Valid Accounts", + "reference": "https://attack.mitre.org/techniques/T1078/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/network_rdp_remote_desktop_protocol_to_the_internet.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/initial_access_rdp_remote_desktop_protocol_to_the_internet.json similarity index 83% rename from x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/network_rdp_remote_desktop_protocol_to_the_internet.json rename to x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/initial_access_rdp_remote_desktop_protocol_to_the_internet.json index 17d00ebff4603..2d5f96492cc36 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/network_rdp_remote_desktop_protocol_to_the_internet.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/initial_access_rdp_remote_desktop_protocol_to_the_internet.json @@ -1,14 +1,19 @@ { + "author": [ + "Elastic" + ], "description": "This rule detects network events that may indicate the use of RDP traffic to the Internet. RDP is commonly used by system administrators to remotely control a system for maintenance or to use shared resources. It should almost never be directly exposed to the Internet, as it is frequently targeted and exploited by threat actors as an initial access or back-door vector.", "false_positives": [ "RDP connections may be made directly to Internet destinations in order to access Windows cloud server instances but such connections are usually made only by engineers. In such cases, only RDP gateways, bastions or jump servers may be expected Internet destinations and can be exempted from this rule. RDP may be required by some work-flows such as remote access and support for specialized software products and servers. Such work-flows are usually known and not unexpected. Usage that is unfamiliar to server or network owners can be unexpected and suspicious." ], "index": [ - "filebeat-*" + "filebeat-*", + "packetbeat-*" ], "language": "kuery", + "license": "Elastic License", "name": "RDP (Remote Desktop Protocol) to the Internet", - "query": "network.transport:tcp and destination.port:3389 and source.ip:(10.0.0.0/8 or 172.16.0.0/12 or 192.168.0.0/16) and not destination.ip:(10.0.0.0/8 or 127.0.0.0/8 or 172.16.0.0/12 or 192.168.0.0/16 or \"::1\")", + "query": "event.category:(network or network_traffic) and network.transport:tcp and (destination.port:3389 or event.dataset:zeek.rdp) and source.ip:(10.0.0.0/8 or 172.16.0.0/12 or 192.168.0.0/16) and not destination.ip:(10.0.0.0/8 or 127.0.0.0/8 or 172.16.0.0/12 or 192.168.0.0/16 or \"::1\")", "risk_score": 21, "rule_id": "e56993d2-759c-4120-984c-9ec9bb940fd5", "severity": "low", @@ -49,5 +54,5 @@ } ], "type": "query", - "version": 3 + "version": 4 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/network_rpc_remote_procedure_call_from_the_internet.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/initial_access_rpc_remote_procedure_call_from_the_internet.json similarity index 71% rename from x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/network_rpc_remote_procedure_call_from_the_internet.json rename to x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/initial_access_rpc_remote_procedure_call_from_the_internet.json index 719d0e39e94cd..d28e52c163d3c 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/network_rpc_remote_procedure_call_from_the_internet.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/initial_access_rpc_remote_procedure_call_from_the_internet.json @@ -1,11 +1,16 @@ { + "author": [ + "Elastic" + ], "description": "This rule detects network events that may indicate the use of RPC traffic from the Internet. RPC is commonly used by system administrators to remotely control a system for maintenance or to use shared resources. It should almost never be directly exposed to the Internet, as it is frequently targeted and exploited by threat actors as an initial access or back-door vector.", "index": [ - "filebeat-*" + "filebeat-*", + "packetbeat-*" ], "language": "kuery", + "license": "Elastic License", "name": "RPC (Remote Procedure Call) from the Internet", - "query": "network.transport:tcp and destination.port:135 and not source.ip:(10.0.0.0/8 or 127.0.0.0/8 or 172.16.0.0/12 or 192.168.0.0/16 or \"::1\") and destination.ip:(10.0.0.0/8 or 172.16.0.0/12 or 192.168.0.0/16)", + "query": "event.category:(network or network_traffic) and network.transport:tcp and (destination.port:135 or event.dataset:zeek.dce_rpc) and not source.ip:(10.0.0.0/8 or 127.0.0.0/8 or 172.16.0.0/12 or 192.168.0.0/16 or \"::1\") and destination.ip:(10.0.0.0/8 or 172.16.0.0/12 or 192.168.0.0/16)", "risk_score": 73, "rule_id": "143cb236-0956-4f42-a706-814bcaa0cf5a", "severity": "high", @@ -31,5 +36,5 @@ } ], "type": "query", - "version": 3 + "version": 4 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/network_rpc_remote_procedure_call_to_the_internet.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/initial_access_rpc_remote_procedure_call_to_the_internet.json similarity index 71% rename from x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/network_rpc_remote_procedure_call_to_the_internet.json rename to x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/initial_access_rpc_remote_procedure_call_to_the_internet.json index a7791047cab26..01c661af5609d 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/network_rpc_remote_procedure_call_to_the_internet.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/initial_access_rpc_remote_procedure_call_to_the_internet.json @@ -1,11 +1,16 @@ { + "author": [ + "Elastic" + ], "description": "This rule detects network events that may indicate the use of RPC traffic to the Internet. RPC is commonly used by system administrators to remotely control a system for maintenance or to use shared resources. It should almost never be directly exposed to the Internet, as it is frequently targeted and exploited by threat actors as an initial access or back-door vector.", "index": [ - "filebeat-*" + "filebeat-*", + "packetbeat-*" ], "language": "kuery", + "license": "Elastic License", "name": "RPC (Remote Procedure Call) to the Internet", - "query": "network.transport:tcp and destination.port:135 and source.ip:(10.0.0.0/8 or 172.16.0.0/12 or 192.168.0.0/16) and not destination.ip:(10.0.0.0/8 or 127.0.0.0/8 or 172.16.0.0/12 or 192.168.0.0/16 or \"::1\")", + "query": "event.category:(network or network_traffic) and network.transport:tcp and (destination.port:135 or event.dataset:zeek.dce_rpc) and source.ip:(10.0.0.0/8 or 172.16.0.0/12 or 192.168.0.0/16) and not destination.ip:(10.0.0.0/8 or 127.0.0.0/8 or 172.16.0.0/12 or 192.168.0.0/16 or \"::1\")", "risk_score": 73, "rule_id": "32923416-763a-4531-bb35-f33b9232ecdb", "severity": "high", @@ -31,5 +36,5 @@ } ], "type": "query", - "version": 3 + "version": 4 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/network_smb_windows_file_sharing_activity_to_the_internet.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/initial_access_smb_windows_file_sharing_activity_to_the_internet.json similarity index 78% rename from x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/network_smb_windows_file_sharing_activity_to_the_internet.json rename to x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/initial_access_smb_windows_file_sharing_activity_to_the_internet.json index eca200e318c42..7ef56023eba55 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/network_smb_windows_file_sharing_activity_to_the_internet.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/initial_access_smb_windows_file_sharing_activity_to_the_internet.json @@ -1,11 +1,16 @@ { + "author": [ + "Elastic" + ], "description": "This rule detects network events that may indicate the use of Windows file sharing (also called SMB or CIFS) traffic to the Internet. SMB is commonly used within networks to share files, printers, and other system resources amongst trusted systems. It should almost never be directly exposed to the Internet, as it is frequently targeted and exploited by threat actors as an initial access or back-door vector or for data exfiltration.", "index": [ - "filebeat-*" + "filebeat-*", + "packetbeat-*" ], "language": "kuery", + "license": "Elastic License", "name": "SMB (Windows File Sharing) Activity to the Internet", - "query": "network.transport:tcp and destination.port:(139 or 445) and source.ip:(10.0.0.0/8 or 172.16.0.0/12 or 192.168.0.0/16) and not destination.ip:(10.0.0.0/8 or 127.0.0.0/8 or 172.16.0.0/12 or 192.168.0.0/16 or \"::1\")", + "query": "event.category:(network or network_traffic) and network.transport:tcp and (destination.port:(139 or 445) or event.dataset:zeek.smb) and source.ip:(10.0.0.0/8 or 172.16.0.0/12 or 192.168.0.0/16) and not destination.ip:(10.0.0.0/8 or 127.0.0.0/8 or 172.16.0.0/12 or 192.168.0.0/16 or \"::1\")", "risk_score": 73, "rule_id": "c82b2bd8-d701-420c-ba43-f11a155b681a", "severity": "high", @@ -46,5 +51,5 @@ } ], "type": "query", - "version": 3 + "version": 4 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/initial_access_suspicious_activity_reported_by_okta_user.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/initial_access_suspicious_activity_reported_by_okta_user.json new file mode 100644 index 0000000000000..5fa8a655c08bf --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/initial_access_suspicious_activity_reported_by_okta_user.json @@ -0,0 +1,91 @@ +{ + "author": [ + "Elastic" + ], + "description": "This rule detects when a user reports suspicious activity for their Okta account. These events should be investigated, as they can help security teams identify when an adversary is attempting to gain access to their network.", + "false_positives": [ + "A user may report suspicious activity on their Okta account in error." + ], + "index": [ + "filebeat-*" + ], + "language": "kuery", + "license": "Elastic License", + "name": "Suspicious Activity Reported by Okta User", + "query": "event.module:okta and event.dataset:okta.system and event.action:user.account.report_suspicious_activity_by_enduser", + "references": [ + "https://developer.okta.com/docs/reference/api/system-log/", + "https://developer.okta.com/docs/reference/api/event-types/" + ], + "risk_score": 47, + "rule_id": "f994964f-6fce-4d75-8e79-e16ccc412588", + "severity": "medium", + "tags": [ + "Elastic", + "Okta" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0001", + "name": "Initial Access", + "reference": "https://attack.mitre.org/tactics/TA0001/" + }, + "technique": [ + { + "id": "T1078", + "name": "Valid Accounts", + "reference": "https://attack.mitre.org/techniques/T1078/" + } + ] + }, + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0003", + "name": "Persistence", + "reference": "https://attack.mitre.org/tactics/TA0003/" + }, + "technique": [ + { + "id": "T1078", + "name": "Valid Accounts", + "reference": "https://attack.mitre.org/techniques/T1078/" + } + ] + }, + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0004", + "name": "Privilege Escalation", + "reference": "https://attack.mitre.org/tactics/TA0004/" + }, + "technique": [ + { + "id": "T1078", + "name": "Valid Accounts", + "reference": "https://attack.mitre.org/techniques/T1078/" + } + ] + }, + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0005", + "name": "Defense Evasion", + "reference": "https://attack.mitre.org/tactics/TA0005/" + }, + "technique": [ + { + "id": "T1078", + "name": "Valid Accounts", + "reference": "https://attack.mitre.org/techniques/T1078/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/eql_direct_outbound_smb_connection.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/lateral_movement_direct_outbound_smb_connection.json similarity index 82% rename from x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/eql_direct_outbound_smb_connection.json rename to x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/lateral_movement_direct_outbound_smb_connection.json index 8bbdc72573e0d..b4850e77ae719 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/eql_direct_outbound_smb_connection.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/lateral_movement_direct_outbound_smb_connection.json @@ -1,11 +1,15 @@ { + "author": [ + "Elastic" + ], "description": "Identifies unexpected processes making network connections over port 445. Windows File Sharing is typically implemented over Server Message Block (SMB), which communicates between hosts using port 445. When legitimate, these network connections are established by the kernel. Processes making 445/tcp connections may be port scanners, exploits, or suspicious user-level processes moving laterally.", "index": [ "winlogbeat-*" ], "language": "kuery", + "license": "Elastic License", "name": "Direct Outbound SMB Connection", - "query": "event.action:\"Network connection detected (rule: NetworkConnect)\" and destination.port:445 and not process.pid:4 and not destination.ip:(127.0.0.1 or \"::1\")", + "query": "event.category:network and event.type:connection and destination.port:445 and not process.pid:4 and not destination.ip:(127.0.0.1 or \"::1\")", "risk_score": 47, "rule_id": "c82c7d8f-fb9e-4874-a4bd-fd9e3f9becf1", "severity": "medium", @@ -31,5 +35,5 @@ } ], "type": "query", - "version": 2 + "version": 3 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_telnet_network_activity_external.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/lateral_movement_telnet_network_activity_external.json similarity index 80% rename from x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_telnet_network_activity_external.json rename to x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/lateral_movement_telnet_network_activity_external.json index 9f6b80b8bf1ef..27e5da09452e7 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_telnet_network_activity_external.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/lateral_movement_telnet_network_activity_external.json @@ -1,4 +1,7 @@ { + "author": [ + "Elastic" + ], "description": "Telnet provides a command line interface for communication with a remote device or server. This rule identifies Telnet network connections to publicly routable IP addresses.", "false_positives": [ "Telnet can be used for both benign or malicious purposes. Telnet is included by default in some Linux distributions, so its presence is not inherently suspicious. The use of Telnet to manage devices remotely has declined in recent years in favor of more secure protocols such as SSH. Telnet usage by non-automated tools or frameworks may be suspicious." @@ -7,8 +10,9 @@ "auditbeat-*" ], "language": "kuery", + "license": "Elastic License", "name": "Connection to External Network via Telnet", - "query": "event.action:(\"connected-to\" or \"network_flow\") and process.name:telnet and not destination.ip:(127.0.0.0/8 or 10.0.0.0/8 or 172.16.0.0/12 or 192.168.0.0/16 or \"FE80::/10\" or \"::1/128\")", + "query": "event.category:network and event.type:(connection or start) and process.name:telnet and not destination.ip:(127.0.0.0/8 or 10.0.0.0/8 or 172.16.0.0/12 or 192.168.0.0/16 or \"FE80::/10\" or \"::1/128\")", "risk_score": 47, "rule_id": "e19e64ee-130e-4c07-961f-8a339f0b8362", "severity": "medium", @@ -34,5 +38,5 @@ } ], "type": "query", - "version": 1 + "version": 2 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_telnet_network_activity_internal.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/lateral_movement_telnet_network_activity_internal.json similarity index 80% rename from x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_telnet_network_activity_internal.json rename to x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/lateral_movement_telnet_network_activity_internal.json index a2e94f1d2d015..0273800c18d52 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_telnet_network_activity_internal.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/lateral_movement_telnet_network_activity_internal.json @@ -1,4 +1,7 @@ { + "author": [ + "Elastic" + ], "description": "Telnet provides a command line interface for communication with a remote device or server. This rule identifies Telnet network connections to non-publicly routable IP addresses.", "false_positives": [ "Telnet can be used for both benign or malicious purposes. Telnet is included by default in some Linux distributions, so its presence is not inherently suspicious. The use of Telnet to manage devices remotely has declined in recent years in favor of more secure protocols such as SSH. Telnet usage by non-automated tools or frameworks may be suspicious." @@ -7,8 +10,9 @@ "auditbeat-*" ], "language": "kuery", + "license": "Elastic License", "name": "Connection to Internal Network via Telnet", - "query": "event.action:(\"connected-to\" or \"network_flow\") and process.name:telnet and destination.ip:((10.0.0.0/8 or 172.16.0.0/12 or 192.168.0.0/16 or \"FE80::/10\") and not (127.0.0.0/8 or \"::1/128\"))", + "query": "event.category:network and event.type:(connection or start) and process.name:telnet and destination.ip:((10.0.0.0/8 or 172.16.0.0/12 or 192.168.0.0/16 or \"FE80::/10\") and not (127.0.0.0/8 or \"::1/128\"))", "risk_score": 47, "rule_id": "1b21abcc-4d9f-4b08-a7f5-316f5f94b973", "severity": "medium", @@ -34,5 +38,5 @@ } ], "type": "query", - "version": 1 + "version": 2 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_hping_activity.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_hping_activity.json index bd954683723f4..a842d8ef952ff 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_hping_activity.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_hping_activity.json @@ -1,4 +1,7 @@ { + "author": [ + "Elastic" + ], "description": "Hping ran on a Linux host. Hping is a FOSS command-line packet analyzer and has the ability to construct network packets for a wide variety of network security testing applications, including scanning and firewall auditing.", "false_positives": [ "Normal use of hping is uncommon apart from security testing and research. Use by non-security engineers is very uncommon." @@ -7,8 +10,9 @@ "auditbeat-*" ], "language": "kuery", + "license": "Elastic License", "name": "Hping Process Activity", - "query": "process.name:(hping or hping2 or hping3) and event.action:executed", + "query": "event.category:process and event.type:(start or process_started) and process.name:(hping or hping2 or hping3)", "references": [ "https://en.wikipedia.org/wiki/Hping" ], @@ -20,5 +24,5 @@ "Linux" ], "type": "query", - "version": 2 + "version": 3 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_iodine_activity.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_iodine_activity.json index 63b0155bbd82c..c1ce773c2aa44 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_iodine_activity.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_iodine_activity.json @@ -1,4 +1,7 @@ { + "author": [ + "Elastic" + ], "description": "Iodine is a tool for tunneling Internet protocol version 4 (IPV4) traffic over the DNS protocol to circumvent firewalls, network security groups, and network access lists while evading detection.", "false_positives": [ "Normal use of Iodine is uncommon apart from security testing and research. Use by non-security engineers is very uncommon." @@ -7,8 +10,9 @@ "auditbeat-*" ], "language": "kuery", + "license": "Elastic License", "name": "Potential DNS Tunneling via Iodine", - "query": "process.name:(iodine or iodined) and event.action:executed", + "query": "event.category:process and event.type:(start or process_started) and process.name:(iodine or iodined)", "references": [ "https://code.kryo.se/iodine/" ], @@ -20,5 +24,5 @@ "Linux" ], "type": "query", - "version": 2 + "version": 3 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_mknod_activity.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_mknod_activity.json index 21208ade670ee..98b262edfe6f6 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_mknod_activity.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_mknod_activity.json @@ -1,4 +1,7 @@ { + "author": [ + "Elastic" + ], "description": "The Linux mknod program is sometimes used in the command payload of a remote command injection (RCI) and other exploits. It is used to export a command shell when the traditional version of netcat is not available to the payload.", "false_positives": [ "Mknod is a Linux system program. Some normal use of this program, at varying levels of frequency, may originate from scripts, automation tools, and frameworks. Usage by web servers is more likely to be suspicious." @@ -7,8 +10,9 @@ "auditbeat-*" ], "language": "kuery", + "license": "Elastic License", "name": "Mknod Process Activity", - "query": "process.name:mknod and event.action:executed", + "query": "event.category:process and event.type:(start or process_started) and process.name:mknod", "references": [ "https://pen-testing.sans.org/blog/2013/05/06/netcat-without-e-no-problem" ], @@ -20,5 +24,5 @@ "Linux" ], "type": "query", - "version": 2 + "version": 3 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_netcat_network_connection.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_netcat_network_connection.json index caacef3b33deb..30d34f245c6d2 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_netcat_network_connection.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_netcat_network_connection.json @@ -1,4 +1,7 @@ { + "author": [ + "Elastic" + ], "description": "A netcat process is engaging in network activity on a Linux host. Netcat is often used as a persistence mechanism by exporting a reverse shell or by serving a shell on a listening port. Netcat is also sometimes used for data exfiltration.", "false_positives": [ "Netcat is a dual-use tool that can be used for benign or malicious activity. Netcat is included in some Linux distributions so its presence is not necessarily suspicious. Some normal use of this program, while uncommon, may originate from scripts, automation tools, and frameworks." @@ -7,8 +10,9 @@ "auditbeat-*" ], "language": "kuery", + "license": "Elastic License", "name": "Netcat Network Activity", - "query": "process.name:(nc or ncat or netcat or netcat.openbsd or netcat.traditional) and event.action:(bound-socket or connected-to or socket_opened)", + "query": "event.category:network and event.type:(access or connection or start) and process.name:(nc or ncat or netcat or netcat.openbsd or netcat.traditional)", "references": [ "http://pentestmonkey.net/cheat-sheet/shells/reverse-shell-cheat-sheet", "https://www.sans.org/security-resources/sec560/netcat_cheat_sheet_v1.pdf", @@ -22,5 +26,5 @@ "Linux" ], "type": "query", - "version": 2 + "version": 3 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_nmap_activity.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_nmap_activity.json index 99324460cc00a..57f5fe57b0e0b 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_nmap_activity.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_nmap_activity.json @@ -1,4 +1,7 @@ { + "author": [ + "Elastic" + ], "description": "Nmap was executed on a Linux host. Nmap is a FOSS tool for network scanning and security testing. It can map and discover networks, and identify listening services and operating systems. It is sometimes used to gather information in support of exploitation, execution or lateral movement.", "false_positives": [ "Security testing tools and frameworks may run `Nmap` in the course of security auditing. Some normal use of this command may originate from security engineers and network or server administrators. Use of nmap by ordinary users is uncommon." @@ -7,8 +10,9 @@ "auditbeat-*" ], "language": "kuery", + "license": "Elastic License", "name": "Nmap Process Activity", - "query": "process.name:nmap", + "query": "event.category:process and event.type:(start or process_started) and process.name:nmap", "references": [ "https://en.wikipedia.org/wiki/Nmap" ], @@ -20,5 +24,5 @@ "Linux" ], "type": "query", - "version": 2 + "version": 3 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_nping_activity.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_nping_activity.json index b4d44c65cd89c..086492edeb8ad 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_nping_activity.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_nping_activity.json @@ -1,4 +1,7 @@ { + "author": [ + "Elastic" + ], "description": "Nping ran on a Linux host. Nping is part of the Nmap tool suite and has the ability to construct raw packets for a wide variety of security testing applications, including denial of service testing.", "false_positives": [ "Some normal use of this command may originate from security engineers and network or server administrators, but this is usually not routine or unannounced. Use of `Nping` by non-engineers or ordinary users is uncommon." @@ -7,8 +10,9 @@ "auditbeat-*" ], "language": "kuery", + "license": "Elastic License", "name": "Nping Process Activity", - "query": "process.name:nping and event.action:executed", + "query": "event.category:process and event.type:(start or process_started) and process.name:nping", "references": [ "https://en.wikipedia.org/wiki/Nmap" ], @@ -20,5 +24,5 @@ "Linux" ], "type": "query", - "version": 2 + "version": 3 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_process_started_in_temp_directory.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_process_started_in_temp_directory.json index c20a41ac91d02..09680fcf8e996 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_process_started_in_temp_directory.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_process_started_in_temp_directory.json @@ -1,4 +1,7 @@ { + "author": [ + "Elastic" + ], "description": "Identifies processes running in a temporary folder. This is sometimes done by adversaries to hide malware.", "false_positives": [ "Build systems, like Jenkins, may start processes in the `/tmp` directory. These can be exempted by name or by username." @@ -7,8 +10,9 @@ "auditbeat-*" ], "language": "kuery", + "license": "Elastic License", "name": "Unusual Process Execution - Temp", - "query": "process.working_directory:/tmp and event.action:executed", + "query": "event.category:process and event.type:(start or process_started) and process.working_directory:/tmp", "risk_score": 47, "rule_id": "df959768-b0c9-4d45-988c-5606a2be8e5a", "severity": "medium", @@ -17,5 +21,5 @@ "Linux" ], "type": "query", - "version": 2 + "version": 3 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_socat_activity.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_socat_activity.json index b0f9a19bfacaa..057d8ba9859a8 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_socat_activity.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_socat_activity.json @@ -1,4 +1,7 @@ { + "author": [ + "Elastic" + ], "description": "A Socat process is running on a Linux host. Socat is often used as a persistence mechanism by exporting a reverse shell, or by serving a shell on a listening port. Socat is also sometimes used for lateral movement.", "false_positives": [ "Socat is a dual-use tool that can be used for benign or malicious activity. Some normal use of this program, at varying levels of frequency, may originate from scripts, automation tools, and frameworks. Usage by web servers is more likely to be suspicious." @@ -7,8 +10,9 @@ "auditbeat-*" ], "language": "kuery", + "license": "Elastic License", "name": "Socat Process Activity", - "query": "process.name:socat and not process.args:-V and event.action:executed", + "query": "event.category:process and event.type:(start or process_started) and process.name:socat and not process.args:-V", "references": [ "https://blog.ropnop.com/upgrading-simple-shells-to-fully-interactive-ttys/#method-2-using-socat" ], @@ -20,5 +24,5 @@ "Linux" ], "type": "query", - "version": 2 + "version": 3 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_strace_activity.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_strace_activity.json index 9e449ebfdfd81..3dd18c8242a5e 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_strace_activity.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_strace_activity.json @@ -1,4 +1,7 @@ { + "author": [ + "Elastic" + ], "description": "Strace runs in a privileged context and can be used to escape restrictive environments by instantiating a shell in order to elevate privileges or move laterally.", "false_positives": [ "Strace is a dual-use tool that can be used for benign or malicious activity. Some normal use of this command may originate from developers or SREs engaged in debugging or system call tracing." @@ -7,8 +10,9 @@ "auditbeat-*" ], "language": "kuery", + "license": "Elastic License", "name": "Strace Process Activity", - "query": "process.name:strace and event.action:executed", + "query": "event.category:process and event.type:(start or process_started) and process.name:strace", "references": [ "https://en.wikipedia.org/wiki/Strace" ], @@ -20,5 +24,5 @@ "Linux" ], "type": "query", - "version": 2 + "version": 3 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_anomalous_network_activity.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/ml_linux_anomalous_network_activity.json similarity index 93% rename from x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_anomalous_network_activity.json rename to x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/ml_linux_anomalous_network_activity.json index d910f83b0c8bd..3ef426af909ff 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_anomalous_network_activity.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/ml_linux_anomalous_network_activity.json @@ -1,16 +1,20 @@ { "anomaly_threshold": 50, + "author": [ + "Elastic" + ], "description": "Identifies Linux processes that do not usually use the network but have unexpected network activity, which can indicate command-and-control, lateral movement, persistence, or data exfiltration activity. A process with unusual network activity can denote process exploitation or injection, where the process is used to run persistence mechanisms that allow a malicious actor remote access or control of the host, data exfiltration, and execution of unauthorized network applications.", "false_positives": [ "A newly installed program or one that rarely uses the network could trigger this signal." ], "from": "now-45m", "interval": "15m", + "license": "Elastic License", "machine_learning_job_id": "linux_anomalous_network_activity_ecs", "name": "Unusual Linux Network Activity", "note": "### Investigating Unusual Network Activity ###\nSignals from this rule indicate the presence of network activity from a Linux process for which network activity is rare and unusual. Here are some possible avenues of investigation:\n- Consider the IP addresses and ports. Are these used by normal but infrequent network workflows? Are they expected or unexpected? \n- If the destination IP address is remote or external, does it associate with an expected domain, organization or geography? Note: avoid interacting directly with suspected malicious IP addresses.\n- Consider the user as identified by the username field. Is this network activity part of an expected workflow for the user who ran the program?\n- Examine the history of execution. If this process manifested only very recently, it might be part of a new software package. If it has a consistent cadence - for example if it runs monthly or quarterly - it might be part of a monthly or quarterly business or maintenance process.\n- Examine the process arguments, title and working directory. These may provide indications as to the source of the program or the nature of the tasks it is performing.", "references": [ - "https://www.elastic.co/guide/en/siem/guide/current/prebuilt-ml-jobs.html" + "https://www.elastic.co/guide/en/security/current/prebuilt-ml-jobs.html" ], "risk_score": 21, "rule_id": "52afbdc5-db15-485e-bc24-f5707f820c4b", @@ -21,5 +25,5 @@ "ML" ], "type": "machine_learning", - "version": 1 + "version": 2 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_anomalous_network_port_activity.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/ml_linux_anomalous_network_port_activity.json similarity index 83% rename from x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_anomalous_network_port_activity.json rename to x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/ml_linux_anomalous_network_port_activity.json index aa0d1cb125aed..add1c2941970e 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_anomalous_network_port_activity.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/ml_linux_anomalous_network_port_activity.json @@ -1,15 +1,19 @@ { "anomaly_threshold": 50, + "author": [ + "Elastic" + ], "description": "Identifies unusual destination port activity that can indicate command-and-control, persistence mechanism, or data exfiltration activity. Rarely used destination port activity is generally unusual in Linux fleets, and can indicate unauthorized access or threat actor activity.", "false_positives": [ "A newly installed program or one that rarely uses the network could trigger this signal." ], "from": "now-45m", "interval": "15m", + "license": "Elastic License", "machine_learning_job_id": "linux_anomalous_network_port_activity_ecs", "name": "Unusual Linux Network Port Activity", "references": [ - "https://www.elastic.co/guide/en/siem/guide/current/prebuilt-ml-jobs.html" + "https://www.elastic.co/guide/en/security/current/prebuilt-ml-jobs.html" ], "risk_score": 21, "rule_id": "3c7e32e6-6104-46d9-a06e-da0f8b5795a0", @@ -20,5 +24,5 @@ "ML" ], "type": "machine_learning", - "version": 1 + "version": 2 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_anomalous_network_service.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/ml_linux_anomalous_network_service.json similarity index 81% rename from x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_anomalous_network_service.json rename to x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/ml_linux_anomalous_network_service.json index 5d137b81d1314..af5b331f4cb04 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_anomalous_network_service.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/ml_linux_anomalous_network_service.json @@ -1,15 +1,19 @@ { "anomaly_threshold": 50, + "author": [ + "Elastic" + ], "description": "Identifies unusual listening ports on Linux instances that can indicate execution of unauthorized services, backdoors, or persistence mechanisms.", "false_positives": [ "A newly installed program or one that rarely uses the network could trigger this signal." ], "from": "now-45m", "interval": "15m", + "license": "Elastic License", "machine_learning_job_id": "linux_anomalous_network_service", "name": "Unusual Linux Network Service", "references": [ - "https://www.elastic.co/guide/en/siem/guide/current/prebuilt-ml-jobs.html" + "https://www.elastic.co/guide/en/security/current/prebuilt-ml-jobs.html" ], "risk_score": 21, "rule_id": "52afbdc5-db15-596e-bc35-f5707f820c4b", @@ -20,5 +24,5 @@ "ML" ], "type": "machine_learning", - "version": 1 + "version": 2 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_anomalous_network_url_activity.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/ml_linux_anomalous_network_url_activity.json similarity index 88% rename from x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_anomalous_network_url_activity.json rename to x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/ml_linux_anomalous_network_url_activity.json index 3732e575a2e41..89a6955fd1781 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_anomalous_network_url_activity.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/ml_linux_anomalous_network_url_activity.json @@ -1,15 +1,19 @@ { "anomaly_threshold": 50, + "author": [ + "Elastic" + ], "description": "A machine learning job detected an unusual web URL request from a Linux host, which can indicate malware delivery and execution. Wget and cURL are commonly used by Linux programs to download code and data. Most of the time, their usage is entirely normal. Generally, because they use a list of URLs, they repeatedly download from the same locations. However, Wget and cURL are sometimes used to deliver Linux exploit payloads, and threat actors use these tools to download additional software and code. For these reasons, unusual URLs can indicate unauthorized downloads or threat activity.", "false_positives": [ "A new and unusual program or artifact download in the course of software upgrades, debugging, or troubleshooting could trigger this signal." ], "from": "now-45m", "interval": "15m", + "license": "Elastic License", "machine_learning_job_id": "linux_anomalous_network_url_activity_ecs", "name": "Unusual Linux Web Activity", "references": [ - "https://www.elastic.co/guide/en/siem/guide/current/prebuilt-ml-jobs.html" + "https://www.elastic.co/guide/en/security/current/prebuilt-ml-jobs.html" ], "risk_score": 21, "rule_id": "52afbdc5-db15-485e-bc35-f5707f820c4c", @@ -20,5 +24,5 @@ "ML" ], "type": "machine_learning", - "version": 1 + "version": 2 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_anomalous_process_all_hosts.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/ml_linux_anomalous_process_all_hosts.json similarity index 91% rename from x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_anomalous_process_all_hosts.json rename to x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/ml_linux_anomalous_process_all_hosts.json index 259f0147953ad..6e73e4dd6dc94 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_anomalous_process_all_hosts.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/ml_linux_anomalous_process_all_hosts.json @@ -1,16 +1,20 @@ { "anomaly_threshold": 50, + "author": [ + "Elastic" + ], "description": "Searches for rare processes running on multiple Linux hosts in an entire fleet or network. This reduces the detection of false positives since automated maintenance processes usually only run occasionally on a single machine but are common to all or many hosts in a fleet.", "false_positives": [ "A newly installed program or one that runs rarely as part of a monthly or quarterly workflow could trigger this signal." ], "from": "now-45m", "interval": "15m", + "license": "Elastic License", "machine_learning_job_id": "linux_anomalous_process_all_hosts_ecs", "name": "Anomalous Process For a Linux Population", "note": "### Investigating an Unusual Linux Process ###\nSignals from this rule indicate the presence of a Linux process that is rare and unusual for all of the monitored Linux hosts for which Auditbeat data is available. Here are some possible avenues of investigation:\n- Consider the user as identified by the username field. Is this program part of an expected workflow for the user who ran this program on this host?\n- Examine the history of execution. If this process manifested only very recently, it might be part of a new software package. If it has a consistent cadence - for example if it runs monthly or quarterly - it might be part of a monthly or quarterly business process.\n- Examine the process arguments, title and working directory. These may provide indications as to the source of the program or the nature of the tasks it is performing.", "references": [ - "https://www.elastic.co/guide/en/siem/guide/current/prebuilt-ml-jobs.html" + "https://www.elastic.co/guide/en/security/current/prebuilt-ml-jobs.html" ], "risk_score": 21, "rule_id": "647fc812-7996-4795-8869-9c4ea595fe88", @@ -21,5 +25,5 @@ "ML" ], "type": "machine_learning", - "version": 1 + "version": 2 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_anomalous_user_name.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/ml_linux_anomalous_user_name.json similarity index 93% rename from x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_anomalous_user_name.json rename to x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/ml_linux_anomalous_user_name.json index 2e7bd0d1d99d7..c910fb552f966 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_anomalous_user_name.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/ml_linux_anomalous_user_name.json @@ -1,16 +1,20 @@ { "anomaly_threshold": 50, + "author": [ + "Elastic" + ], "description": "A machine learning job detected activity for a username that is not normally active, which can indicate unauthorized changes, activity by unauthorized users, lateral movement, or compromised credentials. In many organizations, new usernames are not often created apart from specific types of system activities, such as creating new accounts for new employees. These user accounts quickly become active and routine. Events from rarely used usernames can point to suspicious activity. Additionally, automated Linux fleets tend to see activity from rarely used usernames only when personnel log in to make authorized or unauthorized changes, or threat actors have acquired credentials and log in for malicious purposes. Unusual usernames can also indicate pivoting, where compromised credentials are used to try and move laterally from one host to another.", "false_positives": [ "Uncommon user activity can be due to an engineer logging onto a server instance in order to perform manual troubleshooting or reconfiguration." ], "from": "now-45m", "interval": "15m", + "license": "Elastic License", "machine_learning_job_id": "linux_anomalous_user_name_ecs", "name": "Unusual Linux Username", "note": "### Investigating an Unusual Linux User ###\nSignals from this rule indicate activity for a Linux user name that is rare and unusual. Here are some possible avenues of investigation:\n- Consider the user as identified by the username field. Is this program part of an expected workflow for the user who ran this program on this host? Could this be related to troubleshooting or debugging activity by a developer or site reliability engineer?\n- Examine the history of user activity. If this user manifested only very recently, it might be a service account for a new software package. If it has a consistent cadence - for example if it runs monthly or quarterly - it might be part of a monthly or quarterly business process.\n- Examine the process arguments, title and working directory. These may provide indications as to the source of the program or the nature of the tasks that the user is performing.", "references": [ - "https://www.elastic.co/guide/en/siem/guide/current/prebuilt-ml-jobs.html" + "https://www.elastic.co/guide/en/security/current/prebuilt-ml-jobs.html" ], "risk_score": 21, "rule_id": "b347b919-665f-4aac-b9e8-68369bf2340c", @@ -21,5 +25,5 @@ "ML" ], "type": "machine_learning", - "version": 1 + "version": 2 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/packetbeat_dns_tunneling.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/ml_packetbeat_dns_tunneling.json similarity index 85% rename from x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/packetbeat_dns_tunneling.json rename to x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/ml_packetbeat_dns_tunneling.json index c5cf6385afaf0..b78c4d3459b85 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/packetbeat_dns_tunneling.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/ml_packetbeat_dns_tunneling.json @@ -1,15 +1,19 @@ { "anomaly_threshold": 50, + "author": [ + "Elastic" + ], "description": "A machine learning job detected unusually large numbers of DNS queries for a single top-level DNS domain, which is often used for DNS tunneling. DNS tunneling can be used for command-and-control, persistence, or data exfiltration activity. For example, dnscat tends to generate many DNS questions for a top-level domain as it uses the DNS protocol to tunnel data.", "false_positives": [ "DNS domains that use large numbers of child domains, such as software or content distribution networks, can trigger this signal and such parent domains can be excluded." ], "from": "now-45m", "interval": "15m", + "license": "Elastic License", "machine_learning_job_id": "packetbeat_dns_tunneling", "name": "DNS Tunneling", "references": [ - "https://www.elastic.co/guide/en/siem/guide/current/prebuilt-ml-jobs.html" + "https://www.elastic.co/guide/en/security/current/prebuilt-ml-jobs.html" ], "risk_score": 21, "rule_id": "91f02f01-969f-4167-8f66-07827ac3bdd9", @@ -20,5 +24,5 @@ "Packetbeat" ], "type": "machine_learning", - "version": 1 + "version": 2 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/packetbeat_rare_dns_question.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/ml_packetbeat_rare_dns_question.json similarity index 89% rename from x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/packetbeat_rare_dns_question.json rename to x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/ml_packetbeat_rare_dns_question.json index 4623639b6e8b7..970962dd75eed 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/packetbeat_rare_dns_question.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/ml_packetbeat_rare_dns_question.json @@ -1,15 +1,19 @@ { "anomaly_threshold": 50, + "author": [ + "Elastic" + ], "description": "A machine learning job detected a rare and unusual DNS query that indicate network activity with unusual DNS domains. This can be due to initial access, persistence, command-and-control, or exfiltration activity. For example, when a user clicks on a link in a phishing email or opens a malicious document, a request may be sent to download and run a payload from an uncommon domain. When malware is already running, it may send requests to an uncommon DNS domain the malware uses for command-and-control communication.", "false_positives": [ "A newly installed program or one that runs rarely as part of a monthly or quarterly workflow could trigger this signal. Network activity that occurs rarely, in small quantities, can trigger this signal. Possible examples are browsing technical support or vendor networks sparsely. A user who visits a new or unique web destination may trigger this signal." ], "from": "now-45m", "interval": "15m", + "license": "Elastic License", "machine_learning_job_id": "packetbeat_rare_dns_question", "name": "Unusual DNS Activity", "references": [ - "https://www.elastic.co/guide/en/siem/guide/current/prebuilt-ml-jobs.html" + "https://www.elastic.co/guide/en/security/current/prebuilt-ml-jobs.html" ], "risk_score": 21, "rule_id": "746edc4c-c54c-49c6-97a1-651223819448", @@ -20,5 +24,5 @@ "Packetbeat" ], "type": "machine_learning", - "version": 1 + "version": 2 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/packetbeat_rare_server_domain.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/ml_packetbeat_rare_server_domain.json similarity index 89% rename from x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/packetbeat_rare_server_domain.json rename to x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/ml_packetbeat_rare_server_domain.json index dd14191d30df2..f9465a329e973 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/packetbeat_rare_server_domain.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/ml_packetbeat_rare_server_domain.json @@ -1,15 +1,19 @@ { "anomaly_threshold": 50, + "author": [ + "Elastic" + ], "description": "A machine learning job detected an unusual network destination domain name. This can be due to initial access, persistence, command-and-control, or exfiltration activity. For example, when a user clicks on a link in a phishing email or opens a malicious document, a request may be sent to download and run a payload from an uncommon web server name. When malware is already running, it may send requests to an uncommon DNS domain the malware uses for command-and-control communication.", "false_positives": [ "Web activity that occurs rarely in small quantities can trigger this signal. Possible examples are browsing technical support or vendor URLs that are used very sparsely. A user who visits a new and unique web destination may trigger this signal when the activity is sparse. Web applications that generate URLs unique to a transaction may trigger this when they are used sparsely. Web domains can be excluded in cases such as these." ], "from": "now-45m", "interval": "15m", + "license": "Elastic License", "machine_learning_job_id": "packetbeat_rare_server_domain", "name": "Unusual Network Destination Domain Name", "references": [ - "https://www.elastic.co/guide/en/siem/guide/current/prebuilt-ml-jobs.html" + "https://www.elastic.co/guide/en/security/current/prebuilt-ml-jobs.html" ], "risk_score": 21, "rule_id": "17e68559-b274-4948-ad0b-f8415bb31126", @@ -20,5 +24,5 @@ "Packetbeat" ], "type": "machine_learning", - "version": 1 + "version": 2 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/packetbeat_rare_urls.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/ml_packetbeat_rare_urls.json similarity index 91% rename from x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/packetbeat_rare_urls.json rename to x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/ml_packetbeat_rare_urls.json index 386e00054c2cc..e22f9975b54e4 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/packetbeat_rare_urls.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/ml_packetbeat_rare_urls.json @@ -1,15 +1,19 @@ { "anomaly_threshold": 50, + "author": [ + "Elastic" + ], "description": "A machine learning job detected a rare and unusual URL that indicates unusual web browsing activity. This can be due to initial access, persistence, command-and-control, or exfiltration activity. For example, in a strategic web compromise or watering hole attack, when a trusted website is compromised to target a particular sector or organization, targeted users may receive emails with uncommon URLs for trusted websites. These URLs can be used to download and run a payload. When malware is already running, it may send requests to uncommon URLs on trusted websites the malware uses for command-and-control communication. When rare URLs are observed being requested for a local web server by a remote source, these can be due to web scanning, enumeration or attack traffic, or they can be due to bots and web scrapers which are part of common Internet background traffic.", "false_positives": [ "Web activity that occurs rarely in small quantities can trigger this signal. Possible examples are browsing technical support or vendor URLs that are used very sparsely. A user who visits a new and unique web destination may trigger this signal when the activity is sparse. Web applications that generate URLs unique to a transaction may trigger this when they are used sparsely. Web domains can be excluded in cases such as these." ], "from": "now-45m", "interval": "15m", + "license": "Elastic License", "machine_learning_job_id": "packetbeat_rare_urls", "name": "Unusual Web Request", "references": [ - "https://www.elastic.co/guide/en/siem/guide/current/prebuilt-ml-jobs.html" + "https://www.elastic.co/guide/en/security/current/prebuilt-ml-jobs.html" ], "risk_score": 21, "rule_id": "91f02f01-969f-4167-8f55-07827ac3acc9", @@ -20,5 +24,5 @@ "Packetbeat" ], "type": "machine_learning", - "version": 1 + "version": 2 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/packetbeat_rare_user_agent.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/ml_packetbeat_rare_user_agent.json similarity index 90% rename from x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/packetbeat_rare_user_agent.json rename to x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/ml_packetbeat_rare_user_agent.json index a68c43b228303..2ce6f44d90593 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/packetbeat_rare_user_agent.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/ml_packetbeat_rare_user_agent.json @@ -1,15 +1,19 @@ { "anomaly_threshold": 50, + "author": [ + "Elastic" + ], "description": "A machine learning job detected a rare and unusual user agent indicating web browsing activity by an unusual process other than a web browser. This can be due to persistence, command-and-control, or exfiltration activity. Uncommon user agents coming from remote sources to local destinations are often the result of scanners, bots, and web scrapers, which are part of common Internet background traffic. Much of this is noise, but more targeted attacks on websites using tools like Burp or SQLmap can sometimes be discovered by spotting uncommon user agents. Uncommon user agents in traffic from local sources to remote destinations can be any number of things, including harmless programs like weather monitoring or stock-trading programs. However, uncommon user agents from local sources can also be due to malware or scanning activity.", "false_positives": [ "Web activity that is uncommon, like security scans, may trigger this signal and may need to be excluded. A new or rarely used program that calls web services may trigger this signal." ], "from": "now-45m", "interval": "15m", + "license": "Elastic License", "machine_learning_job_id": "packetbeat_rare_user_agent", "name": "Unusual Web User Agent", "references": [ - "https://www.elastic.co/guide/en/siem/guide/current/prebuilt-ml-jobs.html" + "https://www.elastic.co/guide/en/security/current/prebuilt-ml-jobs.html" ], "risk_score": 21, "rule_id": "91f02f01-969f-4167-8d77-07827ac4cee0", @@ -20,5 +24,5 @@ "Packetbeat" ], "type": "machine_learning", - "version": 1 + "version": 2 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/rare_process_by_host_linux.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/ml_rare_process_by_host_linux.json similarity index 91% rename from x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/rare_process_by_host_linux.json rename to x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/ml_rare_process_by_host_linux.json index 9d9fb5e4a0a8d..c62666134c84e 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/rare_process_by_host_linux.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/ml_rare_process_by_host_linux.json @@ -1,16 +1,20 @@ { "anomaly_threshold": 50, + "author": [ + "Elastic" + ], "description": "Identifies rare processes that do not usually run on individual hosts, which can indicate execution of unauthorized services, malware, or persistence mechanisms. Processes are considered rare when they only run occasionally as compared with other processes running on the host.", "false_positives": [ "A newly installed program or one that runs rarely as part of a monthly or quarterly workflow could trigger this signal." ], "from": "now-45m", "interval": "15m", + "license": "Elastic License", "machine_learning_job_id": "rare_process_by_host_linux_ecs", "name": "Unusual Process For a Linux Host", "note": "### Investigating an Unusual Linux Process ###\nSignals from this rule indicate the presence of a Linux process that is rare and unusual for the host it ran on. Here are some possible avenues of investigation:\n- Consider the user as identified by the username field. Is this program part of an expected workflow for the user who ran this program on this host?\n- Examine the history of execution. If this process manifested only very recently, it might be part of a new software package. If it has a consistent cadence - for example if it runs monthly or quarterly - it might be part of a monthly or quarterly business process.\n- Examine the process arguments, title and working directory. These may provide indications as to the source of the program or the nature of the tasks it is performing.", "references": [ - "https://www.elastic.co/guide/en/siem/guide/current/prebuilt-ml-jobs.html" + "https://www.elastic.co/guide/en/security/current/prebuilt-ml-jobs.html" ], "risk_score": 21, "rule_id": "46f804f5-b289-43d6-a881-9387cf594f75", @@ -21,5 +25,5 @@ "ML" ], "type": "machine_learning", - "version": 1 + "version": 2 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/rare_process_by_host_windows.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/ml_rare_process_by_host_windows.json similarity index 93% rename from x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/rare_process_by_host_windows.json rename to x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/ml_rare_process_by_host_windows.json index 0c1d097a73dc2..5d86637553eab 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/rare_process_by_host_windows.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/ml_rare_process_by_host_windows.json @@ -1,16 +1,20 @@ { "anomaly_threshold": 50, + "author": [ + "Elastic" + ], "description": "Identifies rare processes that do not usually run on individual hosts, which can indicate execution of unauthorized services, malware, or persistence mechanisms. Processes are considered rare when they only run occasionally as compared with other processes running on the host.", "false_positives": [ "A newly installed program or one that runs rarely as part of a monthly or quarterly workflow could trigger this signal." ], "from": "now-45m", "interval": "15m", + "license": "Elastic License", "machine_learning_job_id": "rare_process_by_host_windows_ecs", "name": "Unusual Process For a Windows Host", "note": "### Investigating an Unusual Windows Process ###\nSignals from this rule indicate the presence of a Windows process that is rare and unusual for the host it ran on. Here are some possible avenues of investigation:\n- Consider the user as identified by the username field. Is this program part of an expected workflow for the user who ran this program on this host?\n- Examine the history of execution. If this process manifested only very recently, it might be part of a new software package. If it has a consistent cadence - for example if it runs monthly or quarterly - it might be part of a monthly or quarterly business process.\n- Examine the process metadata like the values of the Company, Description and Product fields which may indicate whether the program is associated with an expected software vendor or package. \n- Examine arguments and working directory. These may provide indications as to the source of the program or the nature of the tasks it is performing.\n- Consider the same for the parent process. If the parent process is a legitimate system utility or service, this could be related to software updates or system management. If the parent process is something user-facing like an Office application, this process could be more suspicious.\n- If you have file hash values in the event data, and you suspect malware, you can optionally run a search for the file hash to see if the file is identified as malware by anti-malware tools. ", "references": [ - "https://www.elastic.co/guide/en/siem/guide/current/prebuilt-ml-jobs.html" + "https://www.elastic.co/guide/en/security/current/prebuilt-ml-jobs.html" ], "risk_score": 21, "rule_id": "6d448b96-c922-4adb-b51c-b767f1ea5b76", @@ -21,5 +25,5 @@ "Windows" ], "type": "machine_learning", - "version": 1 + "version": 2 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/suspicious_login_activity.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/ml_suspicious_login_activity.json similarity index 80% rename from x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/suspicious_login_activity.json rename to x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/ml_suspicious_login_activity.json index b3c3f2d76a8c9..93413f8d0a8a8 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/suspicious_login_activity.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/ml_suspicious_login_activity.json @@ -1,15 +1,19 @@ { "anomaly_threshold": 50, + "author": [ + "Elastic" + ], "description": "Identifies an unusually high number of authentication attempts.", "false_positives": [ "Security audits may trigger this signal. Conditions that generate bursts of failed logins, such as misconfigured applications or account lockouts could trigger this signal." ], "from": "now-45m", "interval": "15m", + "license": "Elastic License", "machine_learning_job_id": "suspicious_login_activity_ecs", "name": "Unusual Login Activity", "references": [ - "https://www.elastic.co/guide/en/siem/guide/current/prebuilt-ml-jobs.html" + "https://www.elastic.co/guide/en/security/current/prebuilt-ml-jobs.html" ], "risk_score": 21, "rule_id": "4330272b-9724-4bc6-a3ca-f1532b81e5c2", @@ -20,5 +24,5 @@ "ML" ], "type": "machine_learning", - "version": 1 + "version": 2 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/windows_anomalous_network_activity.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/ml_windows_anomalous_network_activity.json similarity index 94% rename from x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/windows_anomalous_network_activity.json rename to x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/ml_windows_anomalous_network_activity.json index 0a85fee3de436..a24e1c1c9eb0b 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/windows_anomalous_network_activity.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/ml_windows_anomalous_network_activity.json @@ -1,16 +1,20 @@ { "anomaly_threshold": 50, + "author": [ + "Elastic" + ], "description": "Identifies Windows processes that do not usually use the network but have unexpected network activity, which can indicate command-and-control, lateral movement, persistence, or data exfiltration activity. A process with unusual network activity can denote process exploitation or injection, where the process is used to run persistence mechanisms that allow a malicious actor remote access or control of the host, data exfiltration, and execution of unauthorized network applications.", "false_positives": [ "A newly installed program or one that rarely uses the network could trigger this signal." ], "from": "now-45m", "interval": "15m", + "license": "Elastic License", "machine_learning_job_id": "windows_anomalous_network_activity_ecs", "name": "Unusual Windows Network Activity", "note": "### Investigating Unusual Network Activity ###\nSignals from this rule indicate the presence of network activity from a Windows process for which network activity is very unusual. Here are some possible avenues of investigation:\n- Consider the IP addresses, protocol and ports. Are these used by normal but infrequent network workflows? Are they expected or unexpected? \n- If the destination IP address is remote or external, does it associate with an expected domain, organization or geography? Note: avoid interacting directly with suspected malicious IP addresses.\n- Consider the user as identified by the username field. Is this network activity part of an expected workflow for the user who ran the program?\n- Examine the history of execution. If this process manifested only very recently, it might be part of a new software package. If it has a consistent cadence - for example if it runs monthly or quarterly - it might be part of a monthly or quarterly business process.\n- Examine the process arguments, title and working directory. These may provide indications as to the source of the program or the nature of the tasks it is performing.\n- Consider the same for the parent process. If the parent process is a legitimate system utility or service, this could be related to software updates or system management. If the parent process is something user-facing like an Office application, this process could be more suspicious.\n- If you have file hash values in the event data, and you suspect malware, you can optionally run a search for the file hash to see if the file is identified as malware by anti-malware tools.", "references": [ - "https://www.elastic.co/guide/en/siem/guide/current/prebuilt-ml-jobs.html" + "https://www.elastic.co/guide/en/security/current/prebuilt-ml-jobs.html" ], "risk_score": 21, "rule_id": "ba342eb2-583c-439f-b04d-1fdd7c1417cc", @@ -21,5 +25,5 @@ "Windows" ], "type": "machine_learning", - "version": 1 + "version": 2 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/windows_anomalous_path_activity.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/ml_windows_anomalous_path_activity.json similarity index 88% rename from x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/windows_anomalous_path_activity.json rename to x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/ml_windows_anomalous_path_activity.json index 2652915d21d85..9be69a6bfdcbe 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/windows_anomalous_path_activity.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/ml_windows_anomalous_path_activity.json @@ -1,15 +1,19 @@ { "anomaly_threshold": 50, + "author": [ + "Elastic" + ], "description": "Identifies processes started from atypical folders in the file system, which might indicate malware execution or persistence mechanisms. In corporate Windows environments, software installation is centrally managed and it is unusual for programs to be executed from user or temporary directories. Processes executed from these locations can denote that a user downloaded software directly from the Internet or a malicious script or macro executed malware.", "false_positives": [ "A new and unusual program or artifact download in the course of software upgrades, debugging, or troubleshooting could trigger this signal. Users downloading and running programs from unusual locations, such as temporary directories, browser caches, or profile paths could trigger this signal." ], "from": "now-45m", "interval": "15m", + "license": "Elastic License", "machine_learning_job_id": "windows_anomalous_path_activity_ecs", "name": "Unusual Windows Path Activity", "references": [ - "https://www.elastic.co/guide/en/siem/guide/current/prebuilt-ml-jobs.html" + "https://www.elastic.co/guide/en/security/current/prebuilt-ml-jobs.html" ], "risk_score": 21, "rule_id": "445a342e-03fb-42d0-8656-0367eb2dead5", @@ -20,5 +24,5 @@ "Windows" ], "type": "machine_learning", - "version": 1 + "version": 2 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/windows_anomalous_process_all_hosts.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/ml_windows_anomalous_process_all_hosts.json similarity index 93% rename from x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/windows_anomalous_process_all_hosts.json rename to x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/ml_windows_anomalous_process_all_hosts.json index 4e70426a4faf8..79792d2fd328b 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/windows_anomalous_process_all_hosts.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/ml_windows_anomalous_process_all_hosts.json @@ -1,16 +1,20 @@ { "anomaly_threshold": 50, + "author": [ + "Elastic" + ], "description": "Searches for rare processes running on multiple hosts in an entire fleet or network. This reduces the detection of false positives since automated maintenance processes usually only run occasionally on a single machine but are common to all or many hosts in a fleet.", "false_positives": [ "A newly installed program or one that runs rarely as part of a monthly or quarterly workflow could trigger this signal." ], "from": "now-45m", "interval": "15m", + "license": "Elastic License", "machine_learning_job_id": "windows_anomalous_process_all_hosts_ecs", "name": "Anomalous Process For a Windows Population", "note": "### Investigating an Unusual Windows Process ###\nSignals from this rule indicate the presence of a Windows process that is rare and unusual for all of the Windows hosts for which Winlogbeat data is available. Here are some possible avenues of investigation:\n- Consider the user as identified by the username field. Is this program part of an expected workflow for the user who ran this program on this host?\n- Examine the history of execution. If this process manifested only very recently, it might be part of a new software package. If it has a consistent cadence - for example if it runs monthly or quarterly - it might be part of a monthly or quarterly business process.\n- Examine the process metadata like the values of the Company, Description and Product fields which may indicate whether the program is associated with an expected software vendor or package. \n- Examine arguments and working directory. These may provide indications as to the source of the program or the nature of the tasks it is performing.\n- Consider the same for the parent process. If the parent process is a legitimate system utility or service, this could be related to software updates or system management. If the parent process is something user-facing like an Office application, this process could be more suspicious.\n- If you have file hash values in the event data, and you suspect malware, you can optionally run a search for the file hash to see if the file is identified as malware by anti-malware tools. ", "references": [ - "https://www.elastic.co/guide/en/siem/guide/current/prebuilt-ml-jobs.html" + "https://www.elastic.co/guide/en/security/current/prebuilt-ml-jobs.html" ], "risk_score": 21, "rule_id": "6e40d56f-5c0e-4ac6-aece-bee96645b172", @@ -21,5 +25,5 @@ "Windows" ], "type": "machine_learning", - "version": 1 + "version": 2 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/windows_anomalous_process_creation.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/ml_windows_anomalous_process_creation.json similarity index 89% rename from x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/windows_anomalous_process_creation.json rename to x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/ml_windows_anomalous_process_creation.json index 4742fd951f471..c031e7177abe6 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/windows_anomalous_process_creation.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/ml_windows_anomalous_process_creation.json @@ -1,15 +1,19 @@ { "anomaly_threshold": 50, + "author": [ + "Elastic" + ], "description": "Identifies unusual parent-child process relationships that can indicate malware execution or persistence mechanisms. Malicious scripts often call on other applications and processes as part of their exploit payload. For example, when a malicious Office document runs scripts as part of an exploit payload, Excel or Word may start a script interpreter process, which, in turn, runs a script that downloads and executes malware. Another common scenario is Outlook running an unusual process when malware is downloaded in an email. Monitoring and identifying anomalous process relationships is a method of detecting new and emerging malware that is not yet recognized by anti-virus scanners.", "false_positives": [ "Users running scripts in the course of technical support operations of software upgrades could trigger this signal. A newly installed program or one that runs rarely as part of a monthly or quarterly workflow could trigger this signal." ], "from": "now-45m", "interval": "15m", + "license": "Elastic License", "machine_learning_job_id": "windows_anomalous_process_creation", "name": "Anomalous Windows Process Creation", "references": [ - "https://www.elastic.co/guide/en/siem/guide/current/prebuilt-ml-jobs.html" + "https://www.elastic.co/guide/en/security/current/prebuilt-ml-jobs.html" ], "risk_score": 21, "rule_id": "0b29cab4-dbbd-4a3f-9e8e-1287c7c11ae5", @@ -20,5 +24,5 @@ "Windows" ], "type": "machine_learning", - "version": 1 + "version": 2 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/windows_anomalous_script.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/ml_windows_anomalous_script.json similarity index 83% rename from x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/windows_anomalous_script.json rename to x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/ml_windows_anomalous_script.json index bc38877a00ad0..7d05a0286ea97 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/windows_anomalous_script.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/ml_windows_anomalous_script.json @@ -1,15 +1,19 @@ { "anomaly_threshold": 50, + "author": [ + "Elastic" + ], "description": "A machine learning job detected a PowerShell script with unusual data characteristics, such as obfuscation, that may be a characteristic of malicious PowerShell script text blocks.", "false_positives": [ "Certain kinds of security testing may trigger this signal. PowerShell scripts that use high levels of obfuscation or have unusual script block payloads may trigger this signal." ], "from": "now-45m", "interval": "15m", + "license": "Elastic License", "machine_learning_job_id": "windows_anomalous_script", "name": "Suspicious Powershell Script", "references": [ - "https://www.elastic.co/guide/en/siem/guide/current/prebuilt-ml-jobs.html" + "https://www.elastic.co/guide/en/security/current/prebuilt-ml-jobs.html" ], "risk_score": 21, "rule_id": "1781d055-5c66-4adf-9d60-fc0fa58337b6", @@ -20,5 +24,5 @@ "Windows" ], "type": "machine_learning", - "version": 1 + "version": 2 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/windows_anomalous_service.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/ml_windows_anomalous_service.json similarity index 85% rename from x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/windows_anomalous_service.json rename to x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/ml_windows_anomalous_service.json index 92c4b22823120..7870f75b3d075 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/windows_anomalous_service.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/ml_windows_anomalous_service.json @@ -1,15 +1,19 @@ { "anomaly_threshold": 50, + "author": [ + "Elastic" + ], "description": "A machine learning job detected an unusual Windows service, This can indicate execution of unauthorized services, malware, or persistence mechanisms. In corporate Windows environments, hosts do not generally run many rare or unique services. This job helps detect malware and persistence mechanisms that have been installed and run as a service.", "false_positives": [ "A newly installed program or one that runs rarely as part of a monthly or quarterly workflow could trigger this signal." ], "from": "now-45m", "interval": "15m", + "license": "Elastic License", "machine_learning_job_id": "windows_anomalous_service", "name": "Unusual Windows Service", "references": [ - "https://www.elastic.co/guide/en/siem/guide/current/prebuilt-ml-jobs.html" + "https://www.elastic.co/guide/en/security/current/prebuilt-ml-jobs.html" ], "risk_score": 21, "rule_id": "1781d055-5c66-4adf-9c71-fc0fa58338c7", @@ -20,5 +24,5 @@ "Windows" ], "type": "machine_learning", - "version": 1 + "version": 2 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/windows_anomalous_user_name.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/ml_windows_anomalous_user_name.json similarity index 94% rename from x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/windows_anomalous_user_name.json rename to x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/ml_windows_anomalous_user_name.json index 9ad05eda8f518..42e6740beaa0c 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/windows_anomalous_user_name.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/ml_windows_anomalous_user_name.json @@ -1,16 +1,20 @@ { "anomaly_threshold": 50, + "author": [ + "Elastic" + ], "description": "A machine learning job detected activity for a username that is not normally active, which can indicate unauthorized changes, activity by unauthorized users, lateral movement, or compromised credentials. In many organizations, new usernames are not often created apart from specific types of system activities, such as creating new accounts for new employees. These user accounts quickly become active and routine. Events from rarely used usernames can point to suspicious activity. Additionally, automated Linux fleets tend to see activity from rarely used usernames only when personnel log in to make authorized or unauthorized changes, or threat actors have acquired credentials and log in for malicious purposes. Unusual usernames can also indicate pivoting, where compromised credentials are used to try and move laterally from one host to another.", "false_positives": [ "Uncommon user activity can be due to an administrator or help desk technician logging onto a workstation or server in order to perform manual troubleshooting or reconfiguration." ], "from": "now-45m", "interval": "15m", + "license": "Elastic License", "machine_learning_job_id": "windows_anomalous_user_name_ecs", "name": "Unusual Windows Username", "note": "### Investigating an Unusual Windows User ###\nSignals from this rule indicate activity for a Windows user name that is rare and unusual. Here are some possible avenues of investigation:\n- Consider the user as identified by the username field. Is this program part of an expected workflow for the user who ran this program on this host? Could this be related to occasional troubleshooting or support activity?\n- Examine the history of user activity. If this user manifested only very recently, it might be a service account for a new software package. If it has a consistent cadence - for example if it runs monthly or quarterly - it might be part of a monthly or quarterly business process.\n- Examine the process arguments, title and working directory. These may provide indications as to the source of the program or the nature of the tasks that the user is performing.\n- Consider the same for the parent process. If the parent process is a legitimate system utility or service, this could be related to software updates or system management. If the parent process is something user-facing like an Office application, this process could be more suspicious.", "references": [ - "https://www.elastic.co/guide/en/siem/guide/current/prebuilt-ml-jobs.html" + "https://www.elastic.co/guide/en/security/current/prebuilt-ml-jobs.html" ], "risk_score": 21, "rule_id": "1781d055-5c66-4adf-9c59-fc0fa58336a5", @@ -21,5 +25,5 @@ "Windows" ], "type": "machine_learning", - "version": 1 + "version": 2 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/windows_rare_user_runas_event.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/ml_windows_rare_user_runas_event.json similarity index 85% rename from x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/windows_rare_user_runas_event.json rename to x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/ml_windows_rare_user_runas_event.json index a227b36064a9d..1af765f568bb1 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/windows_rare_user_runas_event.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/ml_windows_rare_user_runas_event.json @@ -1,15 +1,19 @@ { "anomaly_threshold": 50, + "author": [ + "Elastic" + ], "description": "A machine learning job detected an unusual user context switch, using the runas command or similar techniques, which can indicate account takeover or privilege escalation using compromised accounts. Privilege elevation using tools like runas are more commonly used by domain and network administrators than by regular Windows users.", "false_positives": [ "Uncommon user privilege elevation activity can be due to an administrator, help desk technician, or a user performing manual troubleshooting or reconfiguration." ], "from": "now-45m", "interval": "15m", + "license": "Elastic License", "machine_learning_job_id": "windows_rare_user_runas_event", "name": "Unusual Windows User Privilege Elevation Activity", "references": [ - "https://www.elastic.co/guide/en/siem/guide/current/prebuilt-ml-jobs.html" + "https://www.elastic.co/guide/en/security/current/prebuilt-ml-jobs.html" ], "risk_score": 21, "rule_id": "1781d055-5c66-4adf-9d82-fc0fa58449c8", @@ -20,5 +24,5 @@ "Windows" ], "type": "machine_learning", - "version": 1 + "version": 2 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/windows_rare_user_type10_remote_login.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/ml_windows_rare_user_type10_remote_login.json similarity index 90% rename from x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/windows_rare_user_type10_remote_login.json rename to x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/ml_windows_rare_user_type10_remote_login.json index 15241d7869c00..2043af2b8dcb4 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/windows_rare_user_type10_remote_login.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/ml_windows_rare_user_type10_remote_login.json @@ -1,16 +1,20 @@ { "anomaly_threshold": 50, + "author": [ + "Elastic" + ], "description": "A machine learning job detected an unusual remote desktop protocol (RDP) username, which can indicate account takeover or credentialed persistence using compromised accounts. RDP attacks, such as BlueKeep, also tend to use unusual usernames.", "false_positives": [ "Uncommon username activity can be due to an engineer logging onto a server instance in order to perform manual troubleshooting or reconfiguration." ], "from": "now-45m", "interval": "15m", + "license": "Elastic License", "machine_learning_job_id": "windows_rare_user_type10_remote_login", "name": "Unusual Windows Remote User", "note": "### Investigating an Unusual Windows User ###\nSignals from this rule indicate activity for a rare and unusual Windows RDP (remote desktop) user. Here are some possible avenues of investigation:\n- Consider the user as identified by the username field. Is the user part of a group who normally logs into Windows hosts using RDP (remote desktop protocol)? Is this logon activity part of an expected workflow for the user? \n- Consider the source of the login. If the source is remote, could this be related to occasional troubleshooting or support activity by a vendor or an employee working remotely?", "references": [ - "https://www.elastic.co/guide/en/siem/guide/current/prebuilt-ml-jobs.html" + "https://www.elastic.co/guide/en/security/current/prebuilt-ml-jobs.html" ], "risk_score": 21, "rule_id": "1781d055-5c66-4adf-9e93-fc0fa69550c9", @@ -21,5 +25,5 @@ "Windows" ], "type": "machine_learning", - "version": 1 + "version": 2 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/notice.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/notice.ts index a597220db752f..cad41391e2b42 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/notice.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/notice.ts @@ -1,14 +1,18 @@ /* eslint-disable @kbn/eslint/require-license-header */ /* @notice + * Detection Rules + * Copyright 2020 Elasticsearch B.V. + * + * --- * This product bundles rules based on https://github.com/BlueTeamLabs/sentinel-attack * which is available under a "MIT" license. The files based on this license are: * - * - windows_defense_evasion_via_filter_manager.json - * - windows_process_discovery_via_tasklist_command.json - * - windows_priv_escalation_via_accessibility_features.json - * - windows_persistence_via_application_shimming.json - * - windows_execution_via_trusted_developer_utilities.json + * - defense_evasion_via_filter_manager + * - discovery_process_discovery_via_tasklist_command + * - persistence_priv_escalation_via_accessibility_features + * - persistence_via_application_shimming + * - defense_evasion_execution_via_trusted_developer_utilities * * MIT License * @@ -31,4 +35,32 @@ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. + * + * --- + * This product bundles rules based on https://github.com/FSecureLABS/leonidas + * which is available under a "MIT" license. The files based on this license are: + * + * - credential_access_secretsmanager_getsecretvalue.toml + * + * MIT License + * + * Copyright (c) 2020 F-Secure LABS + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. */ diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/okta_attempt_to_deactivate_okta_mfa_rule.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/okta_attempt_to_deactivate_okta_mfa_rule.json new file mode 100644 index 0000000000000..737044d5a9bdc --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/okta_attempt_to_deactivate_okta_mfa_rule.json @@ -0,0 +1,29 @@ +{ + "author": [ + "Elastic" + ], + "description": "An adversary may attempt to deactivate an Okta multi-factor authentication (MFA) rule in order to remove or weaken an organization's security controls.", + "false_positives": [ + "Consider adding exceptions to this rule to filter false positives if Okta MFA rules are regularly deactivated in your organization." + ], + "index": [ + "filebeat-*" + ], + "language": "kuery", + "license": "Elastic License", + "name": "Attempt to Deactivate Okta MFA Rule", + "query": "event.module:okta and event.dataset:okta.system and event.action:policy.rule.deactivate", + "references": [ + "https://developer.okta.com/docs/reference/api/system-log/", + "https://developer.okta.com/docs/reference/api/event-types/" + ], + "risk_score": 21, + "rule_id": "cc92c835-da92-45c9-9f29-b4992ad621a0", + "severity": "low", + "tags": [ + "Elastic", + "Okta" + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/okta_attempt_to_delete_okta_policy.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/okta_attempt_to_delete_okta_policy.json new file mode 100644 index 0000000000000..ea8ba7223095f --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/okta_attempt_to_delete_okta_policy.json @@ -0,0 +1,29 @@ +{ + "author": [ + "Elastic" + ], + "description": "An adversary may attempt to delete an Okta policy in order to weaken an organization's security controls. For example, an adversary may attempt to delete an Okta multi-factor authentication (MFA) policy in order to weaken the authentication requirements for user accounts.", + "false_positives": [ + "Consider adding exceptions to this rule to filter false positives if Okta policies are regularly deleted in your organization." + ], + "index": [ + "filebeat-*" + ], + "language": "kuery", + "license": "Elastic License", + "name": "Attempt to Delete Okta Policy", + "query": "event.module:okta and event.dataset:okta.system and event.action:policy.lifecycle.delete", + "references": [ + "https://developer.okta.com/docs/reference/api/system-log/", + "https://developer.okta.com/docs/reference/api/event-types/" + ], + "risk_score": 21, + "rule_id": "b4bb1440-0fcb-4ed1-87e5-b06d58efc5e9", + "severity": "low", + "tags": [ + "Elastic", + "Okta" + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/okta_attempt_to_modify_okta_mfa_rule.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/okta_attempt_to_modify_okta_mfa_rule.json new file mode 100644 index 0000000000000..dfe16f56da0e2 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/okta_attempt_to_modify_okta_mfa_rule.json @@ -0,0 +1,29 @@ +{ + "author": [ + "Elastic" + ], + "description": "An adversary may attempt to modify an Okta multi-factor authentication (MFA) rule in order to remove or weaken an organization's security controls.", + "false_positives": [ + "Consider adding exceptions to this rule to filter false positives if Okta MFA rules are regularly modified in your organization." + ], + "index": [ + "filebeat-*" + ], + "language": "kuery", + "license": "Elastic License", + "name": "Attempt to Modify Okta MFA Rule", + "query": "event.module:okta and event.dataset:okta.system and event.action:(policy.rule.update or policy.rule.delete)", + "references": [ + "https://developer.okta.com/docs/reference/api/system-log/", + "https://developer.okta.com/docs/reference/api/event-types/" + ], + "risk_score": 21, + "rule_id": "000047bb-b27a-47ec-8b62-ef1a5d2c9e19", + "severity": "low", + "tags": [ + "Elastic", + "Okta" + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/okta_attempt_to_modify_okta_network_zone.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/okta_attempt_to_modify_okta_network_zone.json new file mode 100644 index 0000000000000..61c45f8e7d85e --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/okta_attempt_to_modify_okta_network_zone.json @@ -0,0 +1,29 @@ +{ + "author": [ + "Elastic" + ], + "description": "Okta network zones can be configured to limit or restrict access to a network based on IP addresses or geolocations. An adversary may attempt to modify, delete, or deactivate an Okta network zone in order to remove or weaken an organization's security controls.", + "false_positives": [ + "Consider adding exceptions to this rule to filter false positives if Oyour organization's Okta network zones are regularly modified." + ], + "index": [ + "filebeat-*" + ], + "language": "kuery", + "license": "Elastic License", + "name": "Attempt to Modify Okta Network Zone", + "query": "event.module:okta and event.dataset:okta.system and event.action:(zone.update or zone.deactivate or zone.delete or network_zone.rule.disabled or zone.remove_blacklist)", + "references": [ + "https://developer.okta.com/docs/reference/api/system-log/", + "https://developer.okta.com/docs/reference/api/event-types/" + ], + "risk_score": 47, + "rule_id": "e48236ca-b67a-4b4e-840c-fdc7782bc0c3", + "severity": "medium", + "tags": [ + "Elastic", + "Okta" + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/okta_attempt_to_modify_okta_policy.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/okta_attempt_to_modify_okta_policy.json new file mode 100644 index 0000000000000..a864b900a5998 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/okta_attempt_to_modify_okta_policy.json @@ -0,0 +1,29 @@ +{ + "author": [ + "Elastic" + ], + "description": "An adversary may attempt to modify an Okta policy in order to weaken an organization's security controls. For example, an adversary may attempt to modify an Okta multi-factor authentication (MFA) policy in order to weaken the authentication requirements for user accounts.", + "false_positives": [ + "Consider adding exceptions to this rule to filter false positives if Okta policies are regularly modified in your organization." + ], + "index": [ + "filebeat-*" + ], + "language": "kuery", + "license": "Elastic License", + "name": "Attempt to Modify Okta Policy", + "query": "event.module:okta and event.dataset:okta.system and event.action:policy.lifecycle.update", + "references": [ + "https://developer.okta.com/docs/reference/api/system-log/", + "https://developer.okta.com/docs/reference/api/event-types/" + ], + "risk_score": 21, + "rule_id": "6731fbf2-8f28-49ed-9ab9-9a918ceb5a45", + "severity": "low", + "tags": [ + "Elastic", + "Okta" + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/okta_attempt_to_modify_or_delete_application_sign_on_policy.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/okta_attempt_to_modify_or_delete_application_sign_on_policy.json new file mode 100644 index 0000000000000..ff7546ac2f1a6 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/okta_attempt_to_modify_or_delete_application_sign_on_policy.json @@ -0,0 +1,29 @@ +{ + "author": [ + "Elastic" + ], + "description": "An adversary may attempt to modify or delete the sign on policy for an Okta application in order to remove or weaken an organization's security controls.", + "false_positives": [ + "Consider adding exceptions to this rule to filter false positives if sign on policies for Okta applications are regularly modified or deleted in your organization." + ], + "index": [ + "filebeat-*" + ], + "language": "kuery", + "license": "Elastic License", + "name": "Modification or Removal of an Okta Application Sign-On Policy", + "query": "event.module:okta and event.dataset:okta.system and event.action:(application.policy.sign_on.update or application.policy.sign_on.rule.delete)", + "references": [ + "https://developer.okta.com/docs/reference/api/system-log/", + "https://developer.okta.com/docs/reference/api/event-types/" + ], + "risk_score": 47, + "rule_id": "cd16fb10-0261-46e8-9932-a0336278cdbe", + "severity": "medium", + "tags": [ + "Elastic", + "Okta" + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/okta_threat_detected_by_okta_threatinsight.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/okta_threat_detected_by_okta_threatinsight.json new file mode 100644 index 0000000000000..7a1b6e3d82d7c --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/okta_threat_detected_by_okta_threatinsight.json @@ -0,0 +1,26 @@ +{ + "author": [ + "Elastic" + ], + "description": "This rule detects when Okta ThreatInsight identifies a request from a malicious IP address. Investigating requests from IP addresses identified as malicious by Okta ThreatInsight can help security teams monitor for and respond to credential based attacks against their organization, such as brute force and password spraying attacks.", + "index": [ + "filebeat-*" + ], + "language": "kuery", + "license": "Elastic License", + "name": "Threat Detected by Okta ThreatInsight", + "query": "event.module:okta and event.dataset:okta.system and event.action:security.threat.detected", + "references": [ + "https://developer.okta.com/docs/reference/api/system-log/", + "https://developer.okta.com/docs/reference/api/event-types/" + ], + "risk_score": 47, + "rule_id": "6885d2ae-e008-4762-b98a-e8e1cd3a81e9", + "severity": "medium", + "tags": [ + "Elastic", + "Okta" + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_administrator_privileges_assigned_to_okta_group.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_administrator_privileges_assigned_to_okta_group.json new file mode 100644 index 0000000000000..70e7eb1706e1b --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_administrator_privileges_assigned_to_okta_group.json @@ -0,0 +1,46 @@ +{ + "author": [ + "Elastic" + ], + "description": "An adversary may attempt to assign administrator privileges to an Okta group in order to assign additional permissions to compromised user accounts.", + "false_positives": [ + "Consider adding exceptions to this rule to filter false positives if administrator privileges are regularly assigned to Okta groups in your organization." + ], + "index": [ + "filebeat-*" + ], + "language": "kuery", + "license": "Elastic License", + "name": "Administrator Privileges Assigned to Okta Group", + "query": "event.module:okta and event.dataset:okta.system and event.action:group.privilege.grant", + "references": [ + "https://developer.okta.com/docs/reference/api/system-log/", + "https://developer.okta.com/docs/reference/api/event-types/" + ], + "risk_score": 21, + "rule_id": "b8075894-0b62-46e5-977c-31275da34419", + "severity": "low", + "tags": [ + "Elastic", + "Okta" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0003", + "name": "Persistence", + "reference": "https://attack.mitre.org/tactics/TA0003/" + }, + "technique": [ + { + "id": "T1098", + "name": "Account Manipulation", + "reference": "https://attack.mitre.org/techniques/T1098/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/eql_adobe_hijack_persistence.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_adobe_hijack_persistence.json similarity index 68% rename from x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/eql_adobe_hijack_persistence.json rename to x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_adobe_hijack_persistence.json index 8d455f501d2b2..c5d8e50d3dba7 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/eql_adobe_hijack_persistence.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_adobe_hijack_persistence.json @@ -1,11 +1,15 @@ { + "author": [ + "Elastic" + ], "description": "Detects writing executable files that will be automatically launched by Adobe on launch.", "index": [ "winlogbeat-*" ], "language": "kuery", + "license": "Elastic License", "name": "Adobe Hijack Persistence", - "query": "file.path:(\"C:\\Program Files (x86)\\Adobe\\Acrobat Reader DC\\Reader\\AcroCEF\\RdrCEF.exe\" or \"C:\\Program Files\\Adobe\\Acrobat Reader DC\\Reader\\AcroCEF\\RdrCEF.exe\") and event.action:\"File created (rule: FileCreate)\" and not process.name:msiexec.exe", + "query": "event.category:file and event.type:creation and file.path:(\"C:\\Program Files (x86)\\Adobe\\Acrobat Reader DC\\Reader\\AcroCEF\\RdrCEF.exe\" or \"C:\\Program Files\\Adobe\\Acrobat Reader DC\\Reader\\AcroCEF\\RdrCEF.exe\") and not process.name:msiexec.exe", "risk_score": 21, "rule_id": "2bf78aa2-9c56-48de-b139-f169bf99cf86", "severity": "low", @@ -31,5 +35,5 @@ } ], "type": "query", - "version": 2 + "version": 3 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_attempt_to_create_okta_api_token.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_attempt_to_create_okta_api_token.json new file mode 100644 index 0000000000000..453580d580344 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_attempt_to_create_okta_api_token.json @@ -0,0 +1,46 @@ +{ + "author": [ + "Elastic" + ], + "description": "An adversary may create an Okta API token to maintain access to an organization's network while they work to achieve their objectives. An attacker may abuse an API token to execute techniques such as creating user accounts or disabling security rules or policies.", + "false_positives": [ + "If the behavior of creating Okta API tokens is expected, consider adding exceptions to this rule to filter false positives." + ], + "index": [ + "filebeat-*" + ], + "language": "kuery", + "license": "Elastic License", + "name": "Attempt to Create Okta API Token", + "query": "event.module:okta and event.dataset:okta.system and event.action:system.api_token.create", + "references": [ + "https://developer.okta.com/docs/reference/api/system-log/", + "https://developer.okta.com/docs/reference/api/event-types/" + ], + "risk_score": 21, + "rule_id": "96b9f4ea-0e8c-435b-8d53-2096e75fcac5", + "severity": "low", + "tags": [ + "Elastic", + "Okta" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0003", + "name": "Persistence", + "reference": "https://attack.mitre.org/tactics/TA0003/" + }, + "technique": [ + { + "id": "T1136", + "name": "Create Account", + "reference": "https://attack.mitre.org/techniques/T1136/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_attempt_to_deactivate_mfa_for_okta_user_account.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_attempt_to_deactivate_mfa_for_okta_user_account.json new file mode 100644 index 0000000000000..e5648285c5289 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_attempt_to_deactivate_mfa_for_okta_user_account.json @@ -0,0 +1,46 @@ +{ + "author": [ + "Elastic" + ], + "description": "An adversary may deactivate multi-factor authentication (MFA) for an Okta user account in order to weaken the authentication requirements for the account.", + "false_positives": [ + "If the behavior of deactivating MFA for Okta user accounts is expected, consider adding exceptions to this rule to filter false positives." + ], + "index": [ + "filebeat-*" + ], + "language": "kuery", + "license": "Elastic License", + "name": "Attempt to Deactivate MFA for Okta User Account", + "query": "event.module:okta and event.dataset:okta.system and event.action:user.mfa.factor.deactivate", + "references": [ + "https://developer.okta.com/docs/reference/api/system-log/", + "https://developer.okta.com/docs/reference/api/event-types/" + ], + "risk_score": 21, + "rule_id": "cd89602e-9db0-48e3-9391-ae3bf241acd8", + "severity": "low", + "tags": [ + "Elastic", + "Okta" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0003", + "name": "Persistence", + "reference": "https://attack.mitre.org/tactics/TA0003/" + }, + "technique": [ + { + "id": "T1098", + "name": "Account Manipulation", + "reference": "https://attack.mitre.org/techniques/T1098/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_attempt_to_deactivate_okta_policy.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_attempt_to_deactivate_okta_policy.json new file mode 100644 index 0000000000000..53da259042738 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_attempt_to_deactivate_okta_policy.json @@ -0,0 +1,46 @@ +{ + "author": [ + "Elastic" + ], + "description": "An adversary may attempt to deactivate an Okta policy in order to weaken an organization's security controls. For example, an adversary may attempt to deactivate an Okta multi-factor authentication (MFA) policy in order to weaken the authentication requirements for user accounts.", + "false_positives": [ + "If the behavior of deactivating Okta policies is expected, consider adding exceptions to this rule to filter false positives." + ], + "index": [ + "filebeat-*" + ], + "language": "kuery", + "license": "Elastic License", + "name": "Attempt to Deactivate Okta Policy", + "query": "event.module:okta and event.dataset:okta.system and event.action:policy.lifecycle.deactivate", + "references": [ + "https://developer.okta.com/docs/reference/api/system-log/", + "https://developer.okta.com/docs/reference/api/event-types/" + ], + "risk_score": 21, + "rule_id": "b719a170-3bdb-4141-b0e3-13e3cf627bfe", + "severity": "low", + "tags": [ + "Elastic", + "Okta" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0003", + "name": "Persistence", + "reference": "https://attack.mitre.org/tactics/TA0003/" + }, + "technique": [ + { + "id": "T1098", + "name": "Account Manipulation", + "reference": "https://attack.mitre.org/techniques/T1098/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_attempt_to_reset_mfa_factors_for_okta_user_account.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_attempt_to_reset_mfa_factors_for_okta_user_account.json new file mode 100644 index 0000000000000..f662c0c0b8eb6 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_attempt_to_reset_mfa_factors_for_okta_user_account.json @@ -0,0 +1,46 @@ +{ + "author": [ + "Elastic" + ], + "description": "An adversary may attempt to remove the multi-factor authentication (MFA) factors registered on an Okta user's account in order to register new MFA factors and abuse the account to blend in with normal activity in the victim's environment.", + "false_positives": [ + "Consider adding exceptions to this rule to filter false positives if the MFA factors for Okta user accounts are regularly reset in your organization." + ], + "index": [ + "filebeat-*" + ], + "language": "kuery", + "license": "Elastic License", + "name": "Attempt to Reset MFA Factors for Okta User Account", + "query": "event.module:okta and event.dataset:okta.system and event.action:user.mfa.factor.reset_all", + "references": [ + "https://developer.okta.com/docs/reference/api/system-log/", + "https://developer.okta.com/docs/reference/api/event-types/" + ], + "risk_score": 21, + "rule_id": "729aa18d-06a6-41c7-b175-b65b739b1181", + "severity": "low", + "tags": [ + "Elastic", + "Okta" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0003", + "name": "Persistence", + "reference": "https://attack.mitre.org/tactics/TA0003/" + }, + "technique": [ + { + "id": "T1098", + "name": "Account Manipulation", + "reference": "https://attack.mitre.org/techniques/T1098/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_ec2_network_acl_creation.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_ec2_network_acl_creation.json new file mode 100644 index 0000000000000..911536d2567f4 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_ec2_network_acl_creation.json @@ -0,0 +1,50 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies the creation of an AWS Elastic Compute Cloud (EC2) network access control list (ACL) or an entry in a network ACL with a specified rule number.", + "false_positives": [ + "Network ACL's may be created by a network administrator. Verify whether the user identity, user agent, and/or hostname should be making changes in your environment. Network ACL creations from unfamiliar users or hosts should be investigated. If known behavior is causing false positives, it can be exempted from the rule." + ], + "from": "now-60m", + "index": [ + "filebeat-*" + ], + "interval": "10m", + "language": "kuery", + "license": "Elastic License", + "name": "AWS EC2 Network Access Control List Creation", + "query": "event.action:(CreateNetworkAcl or CreateNetworkAclEntry) and event.dataset:aws.cloudtrail and event.provider:ec2.amazonaws.com and event.outcome:success", + "references": [ + "https://awscli.amazonaws.com/v2/documentation/api/latest/reference/ec2/create-network-acl.html", + "https://docs.aws.amazon.com/AWSEC2/latest/APIReference/API_CreateNetworkAcl.html", + "https://awscli.amazonaws.com/v2/documentation/api/latest/reference/ec2/create-network-acl-entry.html", + "https://docs.aws.amazon.com/AWSEC2/latest/APIReference/API_CreateNetworkAclEntry.html" + ], + "risk_score": 21, + "rule_id": "39144f38-5284-4f8e-a2ae-e3fd628d90b0", + "severity": "low", + "tags": [ + "AWS", + "Elastic" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0003", + "name": "Persistence", + "reference": "https://attack.mitre.org/tactics/TA0003/" + }, + "technique": [ + { + "id": "T1108", + "name": "Redundant Access", + "reference": "https://attack.mitre.org/techniques/T1108/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_iam_group_creation.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_iam_group_creation.json new file mode 100644 index 0000000000000..7c1c4d02737a6 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_iam_group_creation.json @@ -0,0 +1,48 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies the creation of a group in AWS Identity and Access Management (IAM). Groups specify permissions for multiple users. Any user in a group automatically has the permissions that are assigned to the group.", + "false_positives": [ + "A group may be created by a system or network administrator. Verify whether the user identity, user agent, and/or hostname should be making changes in your environment. Group creations from unfamiliar users or hosts should be investigated. If known behavior is causing false positives, it can be exempted from the rule." + ], + "from": "now-60m", + "index": [ + "filebeat-*" + ], + "interval": "10m", + "language": "kuery", + "license": "Elastic License", + "name": "AWS IAM Group Creation", + "query": "event.action:CreateGroup and event.dataset:aws.cloudtrail and event.provider:iam.amazonaws.com and event.outcome:success", + "references": [ + "https://awscli.amazonaws.com/v2/documentation/api/latest/reference/iam/create-group.html", + "https://docs.aws.amazon.com/IAM/latest/APIReference/API_CreateGroup.html" + ], + "risk_score": 21, + "rule_id": "169f3a93-efc7-4df2-94d6-0d9438c310d1", + "severity": "low", + "tags": [ + "AWS", + "Elastic" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0003", + "name": "Persistence", + "reference": "https://attack.mitre.org/tactics/TA0003/" + }, + "technique": [ + { + "id": "T1108", + "name": "Redundant Access", + "reference": "https://attack.mitre.org/techniques/T1108/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_kernel_module_activity.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_kernel_module_activity.json similarity index 79% rename from x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_kernel_module_activity.json rename to x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_kernel_module_activity.json index 95fe337fbfd1b..48ed65caceda7 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_kernel_module_activity.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_kernel_module_activity.json @@ -1,4 +1,7 @@ { + "author": [ + "Elastic" + ], "description": "Identifies loadable kernel module errors, which are often indicative of potential persistence attempts.", "false_positives": [ "Security tools and device drivers may run these programs in order to load legitimate kernel modules. Use of these programs by ordinary users is uncommon." @@ -7,8 +10,9 @@ "auditbeat-*" ], "language": "kuery", + "license": "Elastic License", "name": "Persistence via Kernel Module Modification", - "query": "process.name:(insmod or kmod or modprobe or rmod) and event.action:executed", + "query": "event.category:process and event.type:(start or process_started) and process.name:(insmod or kmod or modprobe or rmod)", "references": [ "https://www.hackers-arise.com/single-post/2017/11/03/Linux-for-Hackers-Part-10-Loadable-Kernel-Modules-LKM" ], @@ -25,7 +29,7 @@ "tactic": { "id": "TA0003", "name": "Persistence", - "reference": "https://attack.mitre.org/techniques/TA0003/" + "reference": "https://attack.mitre.org/tactics/TA0003/" }, "technique": [ { @@ -37,5 +41,5 @@ } ], "type": "query", - "version": 2 + "version": 3 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/eql_local_scheduled_task_commands.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_local_scheduled_task_commands.json similarity index 76% rename from x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/eql_local_scheduled_task_commands.json rename to x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_local_scheduled_task_commands.json index 7b674c270f884..b99690f78b2b4 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/eql_local_scheduled_task_commands.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_local_scheduled_task_commands.json @@ -1,4 +1,7 @@ { + "author": [ + "Elastic" + ], "description": "A scheduled task can be used by an adversary to establish persistence, move laterally, and/or escalate privileges.", "false_positives": [ "Legitimate scheduled tasks may be created during installation of new software." @@ -7,8 +10,9 @@ "winlogbeat-*" ], "language": "kuery", + "license": "Elastic License", "name": "Local Scheduled Task Commands", - "query": "event.action:\"Process Create (rule: ProcessCreate)\" and process.name:schtasks.exe and process.args:(-change or -create or -run or -s or /S or /change or /create or /run)", + "query": "event.category:process and event.type:(start or process_started) and process.name:schtasks.exe and process.args:(-change or -create or -run or -s or /S or /change or /create or /run)", "risk_score": 21, "rule_id": "afcce5ad-65de-4ed2-8516-5e093d3ac99a", "severity": "low", @@ -34,5 +38,5 @@ } ], "type": "query", - "version": 2 + "version": 3 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/windows_priv_escalation_via_accessibility_features.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_priv_escalation_via_accessibility_features.json similarity index 95% rename from x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/windows_priv_escalation_via_accessibility_features.json rename to x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_priv_escalation_via_accessibility_features.json index 59ae2f6ad3bb8..b96d14881ae3d 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/windows_priv_escalation_via_accessibility_features.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_priv_escalation_via_accessibility_features.json @@ -1,9 +1,13 @@ { + "author": [ + "Elastic" + ], "description": "Windows contains accessibility features that may be launched with a key combination before a user has logged in. An adversary can modify the way these programs are launched to get a command prompt or backdoor without logging in to the system.", "index": [ "winlogbeat-*" ], "language": "kuery", + "license": "Elastic License", "name": "Potential Modification of Accessibility Binaries", "query": "event.code:1 and process.parent.name:winlogon.exe and process.name:(atbroker.exe or displayswitch.exe or magnify.exe or narrator.exe or osk.exe or sethc.exe or utilman.exe)", "risk_score": 21, @@ -46,5 +50,5 @@ } ], "type": "query", - "version": 2 + "version": 3 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_rds_cluster_creation.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_rds_cluster_creation.json new file mode 100644 index 0000000000000..c6e23acab0fb5 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_rds_cluster_creation.json @@ -0,0 +1,65 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies the creation of a new Amazon Relational Database Service (RDS) Aurora DB cluster or global database spread across multiple regions.", + "false_positives": [ + "Valid clusters may be created by a system or network administrator. Verify whether the user identity, user agent, and/or hostname should be making changes in your environment. Cluster creations from unfamiliar users or hosts should be investigated. If known behavior is causing false positives, it can be exempted from the rule." + ], + "from": "now-60m", + "index": [ + "filebeat-*" + ], + "interval": "10m", + "language": "kuery", + "license": "Elastic License", + "name": "AWS RDS Cluster Creation", + "query": "event.action:(CreateDBCluster or CreateGlobalCluster) and event.dataset:aws.cloudtrail and event.provider:rds.amazonaws.com and event.outcome:success", + "references": [ + "https://awscli.amazonaws.com/v2/documentation/api/latest/reference/rds/create-db-cluster.html", + "https://docs.aws.amazon.com/AmazonRDS/latest/APIReference/API_CreateDBCluster.html", + "https://awscli.amazonaws.com/v2/documentation/api/latest/reference/rds/create-global-cluster.html", + "https://docs.aws.amazon.com/AmazonRDS/latest/APIReference/API_CreateGlobalCluster.html" + ], + "risk_score": 21, + "rule_id": "e14c5fd7-fdd7-49c2-9e5b-ec49d817bc8d", + "severity": "low", + "tags": [ + "AWS", + "Elastic" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0003", + "name": "Persistence", + "reference": "https://attack.mitre.org/tactics/TA0003/" + }, + "technique": [ + { + "id": "T1108", + "name": "Redundant Access", + "reference": "https://attack.mitre.org/techniques/T1108/" + } + ] + }, + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0005", + "name": "Defense Evasion", + "reference": "https://attack.mitre.org/tactics/TA0005/" + }, + "technique": [ + { + "id": "T1108", + "name": "Redundant Access", + "reference": "https://attack.mitre.org/techniques/T1108/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_shell_activity_by_web_server.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_shell_activity_by_web_server.json similarity index 75% rename from x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_shell_activity_by_web_server.json rename to x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_shell_activity_by_web_server.json index 4d6000bda3b01..24ea80e10f5e3 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_shell_activity_by_web_server.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_shell_activity_by_web_server.json @@ -1,4 +1,7 @@ { + "author": [ + "Elastic" + ], "description": "Identifies suspicious commands executed via a web server, which may suggest a vulnerability and remote shell access.", "false_positives": [ "Network monitoring or management products may have a web server component that runs shell commands as part of normal behavior." @@ -7,8 +10,9 @@ "auditbeat-*" ], "language": "kuery", + "license": "Elastic License", "name": "Potential Shell via Web Server", - "query": "process.name:(bash or dash) and user.name:(apache or nginx or www or \"www-data\") and event.action:executed", + "query": "event.category:process and event.type:(start or process_started) and process.name:(bash or dash) and user.name:(apache or nginx or www or \"www-data\")", "references": [ "https://pentestlab.blog/tag/web-shell/" ], @@ -25,7 +29,7 @@ "tactic": { "id": "TA0003", "name": "Persistence", - "reference": "https://attack.mitre.org/techniques/TA0003/" + "reference": "https://attack.mitre.org/tactics/TA0003/" }, "technique": [ { @@ -37,5 +41,5 @@ } ], "type": "query", - "version": 3 + "version": 4 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/eql_system_shells_via_services.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_system_shells_via_services.json similarity index 78% rename from x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/eql_system_shells_via_services.json rename to x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_system_shells_via_services.json index 504c41f05871a..c3684006a49e5 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/eql_system_shells_via_services.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_system_shells_via_services.json @@ -1,11 +1,15 @@ { + "author": [ + "Elastic" + ], "description": "Windows services typically run as SYSTEM and can be used as a privilege escalation opportunity. Malware or penetration testers may run a shell as a service to gain SYSTEM permissions.", "index": [ "winlogbeat-*" ], "language": "kuery", + "license": "Elastic License", "name": "System Shells via Services", - "query": "event.action:\"Process Create (rule: ProcessCreate)\" and process.parent.name:services.exe and process.name:(cmd.exe or powershell.exe)", + "query": "event.category:process and event.type:(start or process_started) and process.parent.name:services.exe and process.name:(cmd.exe or powershell.exe)", "risk_score": 47, "rule_id": "0022d47d-39c7-4f69-a232-4fe9dc7a3acd", "severity": "medium", @@ -31,5 +35,5 @@ } ], "type": "query", - "version": 2 + "version": 3 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/eql_user_account_creation.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_user_account_creation.json similarity index 74% rename from x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/eql_user_account_creation.json rename to x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_user_account_creation.json index 247a1cde22596..5704f6d14bfec 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/eql_user_account_creation.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_user_account_creation.json @@ -1,11 +1,15 @@ { + "author": [ + "Elastic" + ], "description": "Identifies attempts to create new local users. This is sometimes done by attackers to increase access to a system or domain.", "index": [ "winlogbeat-*" ], "language": "kuery", + "license": "Elastic License", "name": "User Account Creation", - "query": "event.action:\"Process Create (rule: ProcessCreate)\" and process.name:(net.exe or net1.exe) and not process.parent.name:net.exe and process.args:(user and (/ad or /add))", + "query": "event.category:process and event.type:(start or process_started) and process.name:(net.exe or net1.exe) and not process.parent.name:net.exe and process.args:(user and (/ad or /add))", "risk_score": 21, "rule_id": "1aa9181a-492b-4c01-8b16-fa0735786b2b", "severity": "low", @@ -31,5 +35,5 @@ } ], "type": "query", - "version": 2 + "version": 3 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/windows_persistence_via_application_shimming.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_via_application_shimming.json similarity index 94% rename from x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/windows_persistence_via_application_shimming.json rename to x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_via_application_shimming.json index 5b77fdb01a605..a5a9676053c2d 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/windows_persistence_via_application_shimming.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_via_application_shimming.json @@ -1,9 +1,13 @@ { + "author": [ + "Elastic" + ], "description": "The Application Shim was created to allow for backward compatibility of software as the operating system codebase changes over time. This Windows functionality has been abused by attackers to stealthily gain persistence and arbitrary code execution in legitimate Windows processes.", "index": [ "winlogbeat-*" ], "language": "kuery", + "license": "Elastic License", "name": "Potential Application Shimming via Sdbinst", "query": "event.code:1 and process.name:sdbinst.exe", "risk_score": 21, @@ -46,5 +50,5 @@ } ], "type": "query", - "version": 2 + "version": 3 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_root_login_without_mfa.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_root_login_without_mfa.json new file mode 100644 index 0000000000000..6db9e04edc0cb --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_root_login_without_mfa.json @@ -0,0 +1,47 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies attempts to login to AWS as the root user without using multi-factor authentication (MFA). Amazon AWS best practices indicate that the root user should be protected by MFA.", + "false_positives": [ + "Some organizations allow login with the root user without MFA, however this is not considered best practice by AWS and increases the risk of compromised credentials." + ], + "from": "now-60m", + "index": [ + "filebeat-*" + ], + "interval": "10m", + "language": "kuery", + "license": "Elastic License", + "name": "AWS Root Login Without MFA", + "query": "event.module:aws and event.dataset:aws.cloudtrail and event.provider:signin.amazonaws.com and event.action:ConsoleLogin and aws.cloudtrail.user_identity.type:Root and aws.cloudtrail.console_login.additional_eventdata.mfa_used:false and event.outcome:success", + "references": [ + "https://docs.aws.amazon.com/IAM/latest/UserGuide/id_root-user.html" + ], + "risk_score": 21, + "rule_id": "bc0c6f0d-dab0-47a3-b135-0925f0a333bc", + "severity": "low", + "tags": [ + "AWS", + "Elastic" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0004", + "name": "Privilege Escalation", + "reference": "https://attack.mitre.org/tactics/TA0004/" + }, + "technique": [ + { + "id": "T1078", + "name": "Valid Accounts", + "reference": "https://attack.mitre.org/techniques/T1078/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_setgid_bit_set_via_chmod.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_setgid_bit_set_via_chmod.json similarity index 86% rename from x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_setgid_bit_set_via_chmod.json rename to x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_setgid_bit_set_via_chmod.json index c104330348596..3738c04346e6e 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_setgid_bit_set_via_chmod.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_setgid_bit_set_via_chmod.json @@ -1,12 +1,16 @@ { + "author": [ + "Elastic" + ], "description": "An adversary may add the setgid bit to a file or directory in order to run a file with the privileges of the owning group. An adversary can take advantage of this to either do a shell escape or exploit a vulnerability in an application with the setgid bit to get code running in a different user\u2019s context. Additionally, adversaries can use this mechanism on their own malware to make sure they're able to execute in elevated contexts in the future.", "index": [ "auditbeat-*" ], "language": "lucene", + "license": "Elastic License", "max_signals": 33, "name": "Setgid Bit Set via chmod", - "query": "event.action:(executed OR process_started) AND process.name:chmod AND process.args:(g+s OR /2[0-9]{3}/) AND NOT user.name:root", + "query": "event.category:process AND event.type:(start or process_started) AND process.name:chmod AND process.args:(g+s OR /2[0-9]{3}/) AND NOT user.name:root", "risk_score": 21, "rule_id": "3a86e085-094c-412d-97ff-2439731e59cb", "severity": "low", @@ -47,5 +51,5 @@ } ], "type": "query", - "version": 1 + "version": 2 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_setuid_bit_set_via_chmod.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_setuid_bit_set_via_chmod.json similarity index 86% rename from x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_setuid_bit_set_via_chmod.json rename to x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_setuid_bit_set_via_chmod.json index 72b62b67aa2d4..58dcd2d671f52 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_setuid_bit_set_via_chmod.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_setuid_bit_set_via_chmod.json @@ -1,12 +1,16 @@ { + "author": [ + "Elastic" + ], "description": "An adversary may add the setuid bit to a file or directory in order to run a file with the privileges of the owning user. An adversary can take advantage of this to either do a shell escape or exploit a vulnerability in an application with the setuid bit to get code running in a different user\u2019s context. Additionally, adversaries can use this mechanism on their own malware to make sure they're able to execute in elevated contexts in the future.", "index": [ "auditbeat-*" ], "language": "lucene", + "license": "Elastic License", "max_signals": 33, "name": "Setuid Bit Set via chmod", - "query": "event.action:(executed OR process_started) AND process.name:chmod AND process.args:(u+s OR /4[0-9]{3}/) AND NOT user.name:root", + "query": "event.category:process AND event.type:(start or process_started) AND process.name:chmod AND process.args:(u+s OR /4[0-9]{3}/) AND NOT user.name:root", "risk_score": 21, "rule_id": "8a1b0278-0f9a-487d-96bd-d4833298e87a", "severity": "low", @@ -47,5 +51,5 @@ } ], "type": "query", - "version": 1 + "version": 2 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_sudoers_file_mod.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_sudoers_file_mod.json similarity index 84% rename from x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_sudoers_file_mod.json rename to x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_sudoers_file_mod.json index 3cb9259e92132..9850d4d908b69 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/linux_sudoers_file_mod.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_sudoers_file_mod.json @@ -1,11 +1,15 @@ { + "author": [ + "Elastic" + ], "description": "A sudoers file specifies the commands that users or groups can run and from which terminals. Adversaries can take advantage of these configurations to execute commands as other users or spawn processes with higher privileges.", "index": [ "auditbeat-*" ], "language": "kuery", + "license": "Elastic License", "name": "Sudoers File Modification", - "query": "event.module:file_integrity and event.action:updated and file.path:/etc/sudoers", + "query": "event.category:file and event.type:change and file.path:/etc/sudoers", "risk_score": 21, "rule_id": "931e25a5-0f5e-4ae0-ba0d-9e94eff7e3a4", "severity": "low", @@ -31,5 +35,5 @@ } ], "type": "query", - "version": 1 + "version": 2 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/windows_uac_bypass_event_viewer.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_uac_bypass_event_viewer.json similarity index 73% rename from x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/windows_uac_bypass_event_viewer.json rename to x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_uac_bypass_event_viewer.json index 1fb44f0c842de..d8b59804fecdf 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/windows_uac_bypass_event_viewer.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_uac_bypass_event_viewer.json @@ -1,11 +1,15 @@ { + "author": [ + "Elastic" + ], "description": "Identifies User Account Control (UAC) bypass via eventvwr.exe. Attackers bypass UAC to stealthily execute code with elevated permissions.", "index": [ "winlogbeat-*" ], "language": "kuery", + "license": "Elastic License", "name": "Bypass UAC via Event Viewer", - "query": "process.parent.name:eventvwr.exe and event.action:\"Process Create (rule: ProcessCreate)\" and not process.executable:(\"C:\\Windows\\SysWOW64\\mmc.exe\" or \"C:\\Windows\\System32\\mmc.exe\")", + "query": "event.category:process and event.type:(start or process_started) and process.parent.name:eventvwr.exe and not process.executable:(\"C:\\Windows\\SysWOW64\\mmc.exe\" or \"C:\\Windows\\System32\\mmc.exe\")", "risk_score": 21, "rule_id": "31b4c719-f2b4-41f6-a9bd-fce93c2eaf62", "severity": "low", @@ -31,5 +35,5 @@ } ], "type": "query", - "version": 1 + "version": 2 } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_unusual_parentchild_relationship.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_unusual_parentchild_relationship.json new file mode 100644 index 0000000000000..bc80953d0aa61 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_unusual_parentchild_relationship.json @@ -0,0 +1,39 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies Windows programs run from unexpected parent processes. This could indicate masquerading or other strange activity on a system.", + "index": [ + "winlogbeat-*" + ], + "language": "kuery", + "license": "Elastic License", + "name": "Unusual Parent-Child Relationship", + "query": "event.category:process and event.type:(start or process_started) and process.parent.executable:* and (process.name:smss.exe and not process.parent.name:(System or smss.exe) or process.name:csrss.exe and not process.parent.name:(smss.exe or svchost.exe) or process.name:wininit.exe and not process.parent.name:smss.exe or process.name:winlogon.exe and not process.parent.name:smss.exe or process.name:lsass.exe and not process.parent.name:wininit.exe or process.name:LogonUI.exe and not process.parent.name:(wininit.exe or winlogon.exe) or process.name:services.exe and not process.parent.name:wininit.exe or process.name:svchost.exe and not process.parent.name:(MsMpEng.exe or services.exe) or process.name:spoolsv.exe and not process.parent.name:services.exe or process.name:taskhost.exe and not process.parent.name:(services.exe or svchost.exe) or process.name:taskhostw.exe and not process.parent.name:(services.exe or svchost.exe) or process.name:userinit.exe and not process.parent.name:(dwm.exe or winlogon.exe))", + "risk_score": 47, + "rule_id": "35df0dd8-092d-4a83-88c1-5151a804f31b", + "severity": "medium", + "tags": [ + "Elastic", + "Windows" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0004", + "name": "Privilege Escalation", + "reference": "https://attack.mitre.org/tactics/TA0004/" + }, + "technique": [ + { + "id": "T1093", + "name": "Process Hollowing", + "reference": "https://attack.mitre.org/techniques/T1093/" + } + ] + } + ], + "type": "query", + "version": 3 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_updateassumerolepolicy.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_updateassumerolepolicy.json new file mode 100644 index 0000000000000..623f90716b2b6 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_updateassumerolepolicy.json @@ -0,0 +1,47 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies attempts to modify an AWS IAM Assume Role Policy. An adversary may attempt to modify the AssumeRolePolicy of a misconfigured role in order to gain the privileges of that role.", + "false_positives": [ + "Verify whether the user identity, user agent, and/or hostname should be making changes in your environment. Policy updates from unfamiliar users or hosts should be investigated. If known behavior is causing false positives, it can be exempted from the rule." + ], + "from": "now-60m", + "index": [ + "filebeat-*" + ], + "interval": "10m", + "language": "kuery", + "license": "Elastic License", + "name": "AWS IAM Assume Role Policy Update", + "query": "event.module:aws and event.dataset:aws.cloudtrail and event.provider:iam.amazonaws.com and event.action:UpdateAssumeRolePolicy and event.outcome:success", + "references": [ + "https://labs.bishopfox.com/tech-blog/5-privesc-attack-vectors-in-aws" + ], + "risk_score": 21, + "rule_id": "a60326d7-dca7-4fb7-93eb-1ca03a1febbd", + "severity": "low", + "tags": [ + "AWS", + "Elastic" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0004", + "name": "Privilege Escalation", + "reference": "https://attack.mitre.org/tactics/TA0004/" + }, + "technique": [ + { + "id": "T1078", + "name": "Valid Accounts", + "reference": "https://attack.mitre.org/techniques/T1078/" + } + ] + } + ], + "type": "query", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/windows_suspicious_pdf_reader.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/windows_suspicious_pdf_reader.json deleted file mode 100644 index 6c2b167a76ee4..0000000000000 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/windows_suspicious_pdf_reader.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "description": "Identifies suspicious child processes of PDF reader applications. These child processes are often launched via exploitation of PDF applications or social engineering.", - "index": [ - "winlogbeat-*" - ], - "language": "kuery", - "name": "Suspicious PDF Reader Child Process", - "query": "event.action:\"Process Create (rule: ProcessCreate)\" and process.parent.name:(AcroRd32.exe or Acrobat.exe or FoxitPhantomPDF.exe or FoxitReader.exe) and process.name:(arp.exe or dsquery.exe or dsget.exe or gpresult.exe or hostname.exe or ipconfig.exe or nbtstat.exe or net.exe or net1.exe or netsh.exe or netstat.exe or nltest.exe or ping.exe or qprocess.exe or quser.exe or qwinsta.exe or reg.exe or sc.exe or systeminfo.exe or tasklist.exe or tracert.exe or whoami.exe or bginfo.exe or cdb.exe or cmstp.exe or csi.exe or dnx.exe or fsi.exe or ieexec.exe or iexpress.exe or installutil.exe or Microsoft.Workflow.Compiler.exe or msbuild.exe or mshta.exe or msxsl.exe or odbcconf.exe or rcsi.exe or regsvr32.exe or xwizard.exe or atbroker.exe or forfiles.exe or schtasks.exe or regasm.exe or regsvcs.exe or cmd.exe or cscript.exe or powershell.exe or pwsh.exe or wmic.exe or wscript.exe or bitsadmin.exe or certutil.exe or ftp.exe)", - "risk_score": 21, - "rule_id": "53a26770-9cbd-40c5-8b57-61d01a325e14", - "severity": "low", - "tags": [ - "Elastic", - "Windows" - ], - "threat": [ - { - "framework": "MITRE ATT&CK", - "tactic": { - "id": "TA0002", - "name": "Execution", - "reference": "https://attack.mitre.org/tactics/TA0002/" - }, - "technique": [ - { - "id": "T1204", - "name": "User Execution", - "reference": "https://attack.mitre.org/techniques/T1204/" - } - ] - } - ], - "type": "query", - "version": 1 -} From 4d6ad89194d0fdae4d1b0ae711373ec9c4d61dfe Mon Sep 17 00:00:00 2001 From: Poff Poffenberger Date: Mon, 13 Jul 2020 15:45:36 -0500 Subject: [PATCH 30/66] [Canvas] Add simple variables to workpads (#66139) * Add simple variables to Canvas workpads * Fix type for workpad variable action and clarify comment * Fix types in fixtures and templates * Fixing type check errors on actions * Addressing pr feedback and refactoring canvas sidebar accordions * Render true/false instead of Yes/no on variables * add warning callout when editing a variable * Address review feedback * More feedback * updating storyshot with new edit mode callout * Some animation tweaks for the panel * one more panel tweak * Removing the slide transition for now Co-authored-by: Elastic Machine --- .../canvas/.storybook/storyshots.test.js | 4 + .../canvas/__tests__/fixtures/workpads.ts | 1 + .../uis/datasources/esdocs.js | 2 +- x-pack/plugins/canvas/i18n/components.ts | 140 +- .../public/components/arg_form/arg_form.js | 2 +- .../public/components/arg_form/arg_form.scss | 57 +- .../public/components/arg_form/arg_label.js | 10 +- .../datasource/datasource_preview/index.js | 11 +- .../element_config/element_config.js | 73 - .../element_config/element_config.tsx | 62 + .../components/page_config/page_config.js | 2 +- .../components/sidebar/global_config.tsx | 2 - .../public/components/sidebar/sidebar.scss | 56 + .../delete_var.stories.storyshot | 109 ++ .../__snapshots__/edit_var.stories.storyshot | 1236 +++++++++++++++++ .../var_config.stories.storyshot | 87 ++ .../__examples__/delete_var.stories.tsx | 23 + .../__examples__/edit_var.stories.tsx | 65 + .../__examples__/var_config.stories.tsx | 41 + .../components/var_config/delete_var.tsx | 77 + .../components/var_config/edit_var.scss | 8 + .../public/components/var_config/edit_var.tsx | 189 +++ .../public/components/var_config/index.tsx | 66 + .../components/var_config/var_config.scss | 66 + .../components/var_config/var_config.tsx | 230 +++ .../components/var_config/var_panel.scss | 31 + .../components/var_config/var_value_field.tsx | 69 + .../public/components/workpad_config/index.ts | 12 +- .../workpad_config/workpad_config.tsx | 25 +- .../canvas/public/functions/filters.ts | 4 +- .../canvas/public/lib/run_interpreter.ts | 16 +- .../canvas/public/lib/workpad_service.js | 4 +- .../canvas/public/state/actions/elements.js | 22 +- .../canvas/public/state/actions/workpad.ts | 11 +- .../plugins/canvas/public/state/defaults.js | 1 + .../canvas/public/state/reducers/workpad.js | 5 + .../canvas/public/state/selectors/workpad.ts | 30 +- .../server/routes/workpad/workpad_schema.ts | 7 + .../server/templates/pitch_presentation.ts | 1 + .../canvas/server/templates/status_report.ts | 1 + .../canvas/server/templates/summary_report.ts | 1 + .../canvas/server/templates/theme_dark.ts | 1 + .../canvas/server/templates/theme_light.ts | 1 + x-pack/plugins/canvas/types/canvas.ts | 7 + 44 files changed, 2698 insertions(+), 170 deletions(-) delete mode 100644 x-pack/plugins/canvas/public/components/element_config/element_config.js create mode 100644 x-pack/plugins/canvas/public/components/element_config/element_config.tsx create mode 100644 x-pack/plugins/canvas/public/components/var_config/__examples__/__snapshots__/delete_var.stories.storyshot create mode 100644 x-pack/plugins/canvas/public/components/var_config/__examples__/__snapshots__/edit_var.stories.storyshot create mode 100644 x-pack/plugins/canvas/public/components/var_config/__examples__/__snapshots__/var_config.stories.storyshot create mode 100644 x-pack/plugins/canvas/public/components/var_config/__examples__/delete_var.stories.tsx create mode 100644 x-pack/plugins/canvas/public/components/var_config/__examples__/edit_var.stories.tsx create mode 100644 x-pack/plugins/canvas/public/components/var_config/__examples__/var_config.stories.tsx create mode 100644 x-pack/plugins/canvas/public/components/var_config/delete_var.tsx create mode 100644 x-pack/plugins/canvas/public/components/var_config/edit_var.scss create mode 100644 x-pack/plugins/canvas/public/components/var_config/edit_var.tsx create mode 100644 x-pack/plugins/canvas/public/components/var_config/index.tsx create mode 100644 x-pack/plugins/canvas/public/components/var_config/var_config.scss create mode 100644 x-pack/plugins/canvas/public/components/var_config/var_config.tsx create mode 100644 x-pack/plugins/canvas/public/components/var_config/var_panel.scss create mode 100644 x-pack/plugins/canvas/public/components/var_config/var_value_field.tsx diff --git a/x-pack/plugins/canvas/.storybook/storyshots.test.js b/x-pack/plugins/canvas/.storybook/storyshots.test.js index a3412c3a14e79..7195b97712464 100644 --- a/x-pack/plugins/canvas/.storybook/storyshots.test.js +++ b/x-pack/plugins/canvas/.storybook/storyshots.test.js @@ -84,6 +84,10 @@ import { RenderedElement } from '../shareable_runtime/components/rendered_elemen jest.mock('../shareable_runtime/components/rendered_element'); RenderedElement.mockImplementation(() => 'RenderedElement'); +import { EuiObserver } from '@elastic/eui/test-env/components/observer/observer'; +jest.mock('@elastic/eui/test-env/components/observer/observer'); +EuiObserver.mockImplementation(() => 'EuiObserver'); + addSerializer(styleSheetSerializer); // Initialize Storyshots and build the Jest Snapshots diff --git a/x-pack/plugins/canvas/__tests__/fixtures/workpads.ts b/x-pack/plugins/canvas/__tests__/fixtures/workpads.ts index 271fc7a979057..4b1f31cb14687 100644 --- a/x-pack/plugins/canvas/__tests__/fixtures/workpads.ts +++ b/x-pack/plugins/canvas/__tests__/fixtures/workpads.ts @@ -25,6 +25,7 @@ const BaseWorkpad: CanvasWorkpad = { pages: [], colors: [], isWriteable: true, + variables: [], }; const BasePage: CanvasPage = { diff --git a/x-pack/plugins/canvas/canvas_plugin_src/uis/datasources/esdocs.js b/x-pack/plugins/canvas/canvas_plugin_src/uis/datasources/esdocs.js index 7384986fa5c2b..618fe756ba0a4 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/uis/datasources/esdocs.js +++ b/x-pack/plugins/canvas/canvas_plugin_src/uis/datasources/esdocs.js @@ -107,7 +107,7 @@ const EsdocsDatasource = ({ args, updateArgs, defaultIndex }) => { diff --git a/x-pack/plugins/canvas/i18n/components.ts b/x-pack/plugins/canvas/i18n/components.ts index 8acda5da4f0d2..78083f26a38b1 100644 --- a/x-pack/plugins/canvas/i18n/components.ts +++ b/x-pack/plugins/canvas/i18n/components.ts @@ -545,7 +545,7 @@ export const ComponentStrings = { }), getTitle: () => i18n.translate('xpack.canvas.pageConfig.title', { - defaultMessage: 'Page styles', + defaultMessage: 'Page settings', }), getTransitionLabel: () => i18n.translate('xpack.canvas.pageConfig.transitionLabel', { @@ -899,6 +899,144 @@ export const ComponentStrings = { defaultMessage: 'Close tray', }), }, + VarConfig: { + getAddButtonLabel: () => + i18n.translate('xpack.canvas.varConfig.addButtonLabel', { + defaultMessage: 'Add a variable', + }), + getAddTooltipLabel: () => + i18n.translate('xpack.canvas.varConfig.addTooltipLabel', { + defaultMessage: 'Add a variable', + }), + getCopyActionButtonLabel: () => + i18n.translate('xpack.canvas.varConfig.copyActionButtonLabel', { + defaultMessage: 'Copy snippet', + }), + getCopyActionTooltipLabel: () => + i18n.translate('xpack.canvas.varConfig.copyActionTooltipLabel', { + defaultMessage: 'Copy variable syntax to clipboard', + }), + getCopyNotificationDescription: () => + i18n.translate('xpack.canvas.varConfig.copyNotificationDescription', { + defaultMessage: 'Variable syntax copied to clipboard', + }), + getDeleteActionButtonLabel: () => + i18n.translate('xpack.canvas.varConfig.deleteActionButtonLabel', { + defaultMessage: 'Delete variable', + }), + getDeleteNotificationDescription: () => + i18n.translate('xpack.canvas.varConfig.deleteNotificationDescription', { + defaultMessage: 'Variable successfully deleted', + }), + getEditActionButtonLabel: () => + i18n.translate('xpack.canvas.varConfig.editActionButtonLabel', { + defaultMessage: 'Edit variable', + }), + getEmptyDescription: () => + i18n.translate('xpack.canvas.varConfig.emptyDescription', { + defaultMessage: + 'This workpad has no variables currently. You may add variables to store and edit common values. These variables can then be used in elements or within the expression editor.', + }), + getTableNameLabel: () => + i18n.translate('xpack.canvas.varConfig.tableNameLabel', { + defaultMessage: 'Name', + }), + getTableTypeLabel: () => + i18n.translate('xpack.canvas.varConfig.tableTypeLabel', { + defaultMessage: 'Type', + }), + getTableValueLabel: () => + i18n.translate('xpack.canvas.varConfig.tableValueLabel', { + defaultMessage: 'Value', + }), + getTitle: () => + i18n.translate('xpack.canvas.varConfig.titleLabel', { + defaultMessage: 'Variables', + }), + getTitleTooltip: () => + i18n.translate('xpack.canvas.varConfig.titleTooltip', { + defaultMessage: 'Add variables to store and edit common values', + }), + }, + VarConfigDeleteVar: { + getCancelButtonLabel: () => + i18n.translate('xpack.canvas.varConfigDeleteVar.cancelButtonLabel', { + defaultMessage: 'Cancel', + }), + getDeleteButtonLabel: () => + i18n.translate('xpack.canvas.varConfigDeleteVar.deleteButtonLabel', { + defaultMessage: 'Delete variable', + }), + getTitle: () => + i18n.translate('xpack.canvas.varConfigDeleteVar.titleLabel', { + defaultMessage: 'Delete variable?', + }), + getWarningDescription: () => + i18n.translate('xpack.canvas.varConfigDeleteVar.warningDescription', { + defaultMessage: + 'Deleting this variable may adversely affect the workpad. Are you sure you wish to continue?', + }), + }, + VarConfigEditVar: { + getAddTitle: () => + i18n.translate('xpack.canvas.varConfigEditVar.addTitleLabel', { + defaultMessage: 'Add variable', + }), + getCancelButtonLabel: () => + i18n.translate('xpack.canvas.varConfigEditVar.cancelButtonLabel', { + defaultMessage: 'Cancel', + }), + getDuplicateNameError: () => + i18n.translate('xpack.canvas.varConfigEditVar.duplicateNameError', { + defaultMessage: 'Variable name already in use', + }), + getEditTitle: () => + i18n.translate('xpack.canvas.varConfigEditVar.editTitleLabel', { + defaultMessage: 'Edit variable', + }), + getEditWarning: () => + i18n.translate('xpack.canvas.varConfigEditVar.editWarning', { + defaultMessage: 'Editing a variable in use may adversely affect your workpad', + }), + getNameFieldLabel: () => + i18n.translate('xpack.canvas.varConfigEditVar.nameFieldLabel', { + defaultMessage: 'Name', + }), + getSaveButtonLabel: () => + i18n.translate('xpack.canvas.varConfigEditVar.saveButtonLabel', { + defaultMessage: 'Save changes', + }), + getTypeBooleanLabel: () => + i18n.translate('xpack.canvas.varConfigEditVar.typeBooleanLabel', { + defaultMessage: 'Boolean', + }), + getTypeFieldLabel: () => + i18n.translate('xpack.canvas.varConfigEditVar.typeFieldLabel', { + defaultMessage: 'Type', + }), + getTypeNumberLabel: () => + i18n.translate('xpack.canvas.varConfigEditVar.typeNumberLabel', { + defaultMessage: 'Number', + }), + getTypeStringLabel: () => + i18n.translate('xpack.canvas.varConfigEditVar.typeStringLabel', { + defaultMessage: 'String', + }), + getValueFieldLabel: () => + i18n.translate('xpack.canvas.varConfigEditVar.valueFieldLabel', { + defaultMessage: 'Value', + }), + }, + VarConfigVarValueField: { + getFalseOption: () => + i18n.translate('xpack.canvas.varConfigVarValueField.falseOption', { + defaultMessage: 'False', + }), + getTrueOption: () => + i18n.translate('xpack.canvas.varConfigVarValueField.trueOption', { + defaultMessage: 'True', + }), + }, WorkpadConfig: { getApplyStylesheetButtonLabel: () => i18n.translate('xpack.canvas.workpadConfig.applyStylesheetButtonLabel', { diff --git a/x-pack/plugins/canvas/public/components/arg_form/arg_form.js b/x-pack/plugins/canvas/public/components/arg_form/arg_form.js index dfd99b18646a6..f356eedff19cf 100644 --- a/x-pack/plugins/canvas/public/components/arg_form/arg_form.js +++ b/x-pack/plugins/canvas/public/components/arg_form/arg_form.js @@ -120,7 +120,7 @@ class ArgFormComponent extends PureComponent { ); return ( -

+
{ @@ -17,18 +17,16 @@ export const ArgLabel = (props) => { {expandable ? ( - - {label} - + {label} } extraAction={simpleArg} initialIsOpen={initialIsOpen} > -
{children}
+
{children}
) : ( simpleArg && ( diff --git a/x-pack/plugins/canvas/public/components/datasource/datasource_preview/index.js b/x-pack/plugins/canvas/public/components/datasource/datasource_preview/index.js index 045e98bab870e..dcd933c2320cf 100644 --- a/x-pack/plugins/canvas/public/components/datasource/datasource_preview/index.js +++ b/x-pack/plugins/canvas/public/components/datasource/datasource_preview/index.js @@ -15,10 +15,13 @@ export const DatasourcePreview = compose( withState('datatable', 'setDatatable'), lifecycle({ componentDidMount() { - interpretAst({ - type: 'expression', - chain: [this.props.function], - }).then(this.props.setDatatable); + interpretAst( + { + type: 'expression', + chain: [this.props.function], + }, + {} + ).then(this.props.setDatatable); }, }), branch(({ datatable }) => !datatable, renderComponent(Loading)) diff --git a/x-pack/plugins/canvas/public/components/element_config/element_config.js b/x-pack/plugins/canvas/public/components/element_config/element_config.js deleted file mode 100644 index 5d710ef883548..0000000000000 --- a/x-pack/plugins/canvas/public/components/element_config/element_config.js +++ /dev/null @@ -1,73 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { EuiFlexGroup, EuiFlexItem, EuiStat, EuiAccordion, EuiText, EuiSpacer } from '@elastic/eui'; -import PropTypes from 'prop-types'; -import React from 'react'; -import { ComponentStrings } from '../../../i18n'; - -const { ElementConfig: strings } = ComponentStrings; - -export const ElementConfig = ({ elementStats }) => { - if (!elementStats) { - return null; - } - - const { total, ready, error } = elementStats; - const progress = total > 0 ? Math.round(((ready + error) / total) * 100) : 100; - - return ( - - {strings.getTitle()} - - } - initialIsOpen={false} - > - - - - - - - - - - - - - - - - - ); -}; - -ElementConfig.propTypes = { - elementStats: PropTypes.object, -}; diff --git a/x-pack/plugins/canvas/public/components/element_config/element_config.tsx b/x-pack/plugins/canvas/public/components/element_config/element_config.tsx new file mode 100644 index 0000000000000..c2fd827d62099 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/element_config/element_config.tsx @@ -0,0 +1,62 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiFlexGroup, EuiFlexItem, EuiStat, EuiAccordion } from '@elastic/eui'; +import PropTypes from 'prop-types'; +import React from 'react'; +import { ComponentStrings } from '../../../i18n'; +import { State } from '../../../types'; + +const { ElementConfig: strings } = ComponentStrings; + +interface Props { + elementStats: State['transient']['elementStats']; +} + +export const ElementConfig = ({ elementStats }: Props) => { + if (!elementStats) { + return null; + } + + const { total, ready, error } = elementStats; + const progress = total > 0 ? Math.round(((ready + error) / total) * 100) : 100; + + return ( +
+ +
+ + + + + + + + + + + + + + +
+
+
+ ); +}; + +ElementConfig.propTypes = { + elementStats: PropTypes.object, +}; diff --git a/x-pack/plugins/canvas/public/components/page_config/page_config.js b/x-pack/plugins/canvas/public/components/page_config/page_config.js index 51a4762fca501..c45536ac7b175 100644 --- a/x-pack/plugins/canvas/public/components/page_config/page_config.js +++ b/x-pack/plugins/canvas/public/components/page_config/page_config.js @@ -30,7 +30,7 @@ export const PageConfig = ({ }) => { return ( - +

{strings.getTitle()}

diff --git a/x-pack/plugins/canvas/public/components/sidebar/global_config.tsx b/x-pack/plugins/canvas/public/components/sidebar/global_config.tsx index f89ab79a086cf..62673a5b38cc8 100644 --- a/x-pack/plugins/canvas/public/components/sidebar/global_config.tsx +++ b/x-pack/plugins/canvas/public/components/sidebar/global_config.tsx @@ -17,8 +17,6 @@ export const GlobalConfig: FunctionComponent = () => ( - - diff --git a/x-pack/plugins/canvas/public/components/sidebar/sidebar.scss b/x-pack/plugins/canvas/public/components/sidebar/sidebar.scss index 338d515165e43..76d758197aa19 100644 --- a/x-pack/plugins/canvas/public/components/sidebar/sidebar.scss +++ b/x-pack/plugins/canvas/public/components/sidebar/sidebar.scss @@ -31,12 +31,68 @@ &--isEmpty { border-bottom: none; } + + .canvasSidebar__expandable:last-child { + .canvasSidebar__accordion { + margin-bottom: (-$euiSizeS); + } + + .canvasSidebar__accordion:after { + content: none; + } + + .canvasSidebar__accordion.euiAccordion-isOpen:after { + display: none; + } + } } .canvasSidebar__panel-noMinWidth .euiButton { min-width: 0; } +.canvasSidebar__expandable + .canvasSidebar__expandable { + margin-top: 0; + + .canvasSidebar__accordion:before { + display: none; + } +} + +.canvasSidebar__accordion { + padding: $euiSizeM; + margin: 0 (-$euiSizeM); + background: $euiColorLightestShade; + position: relative; + + &.euiAccordion-isOpen { + background: transparent; + } + + &:before, + &:after { + content: ''; + height: 1px; + position: absolute; + left: 0; + width: 100%; + background: $euiColorLightShade; + } + + &:before { + top: 0; + } + + &:after { + bottom: 0; + } +} + +.canvasSidebar__accordionContent { + padding-top: $euiSize; + padding-left: $euiSizeXS + $euiSizeS + $euiSize; +} + @keyframes sidebarPop { 0% { opacity: 0; diff --git a/x-pack/plugins/canvas/public/components/var_config/__examples__/__snapshots__/delete_var.stories.storyshot b/x-pack/plugins/canvas/public/components/var_config/__examples__/__snapshots__/delete_var.stories.storyshot new file mode 100644 index 0000000000000..64f8cba665c15 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/var_config/__examples__/__snapshots__/delete_var.stories.storyshot @@ -0,0 +1,109 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Storyshots components/Variables/DeleteVar default 1`] = ` +Array [ +
+ +
, +
+
+
+
+
+
+ Deleting this variable may adversely affect the workpad. Are you sure you wish to continue? +
+
+
+
+
+
+
+ +
+
+ +
+
+
+
, +] +`; diff --git a/x-pack/plugins/canvas/public/components/var_config/__examples__/__snapshots__/edit_var.stories.storyshot b/x-pack/plugins/canvas/public/components/var_config/__examples__/__snapshots__/edit_var.stories.storyshot new file mode 100644 index 0000000000000..65043e13e5143 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/var_config/__examples__/__snapshots__/edit_var.stories.storyshot @@ -0,0 +1,1236 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Storyshots components/Variables/EditVar edit variable (boolean) 1`] = ` +Array [ +
+ +
, +
+
+
+
+ +
+
+
+
+
+
+ +
+
+
+
+ +
+
+ + Select an option: +
+ +
+ + + + Boolean + +
+ , is selected +
+ +
+ + +
+
+
+
+
+
+
+
+ +
+
+
+
+ +
+
+
+
+
+
+ +
+
+
+
+
+ + +
+
+ + +
+
+
+
+
+
+
+
+ +
+
+ +
+
+ +
, +] +`; + +exports[`Storyshots components/Variables/EditVar edit variable (number) 1`] = ` +Array [ +
+ +
, +
+
+
+
+ +
+
+
+
+
+
+ +
+
+
+
+ +
+
+ + Select an option: +
+ +
+ + + + Number + +
+ , is selected +
+ +
+ + +
+
+
+
+
+
+
+
+ +
+
+
+
+ +
+
+
+
+
+
+ +
+
+
+
+ +
+
+
+
+
+
+
+ +
+
+ +
+
+ +
, +] +`; + +exports[`Storyshots components/Variables/EditVar edit variable (string) 1`] = ` +Array [ +
+ +
, +
+
+
+
+ +
+
+
+
+
+
+ +
+
+
+
+ +
+
+ + Select an option: +
+ +
+ + + + String + +
+ , is selected +
+ +
+ + +
+
+
+
+
+
+
+
+ +
+
+
+
+ +
+
+
+
+
+
+ +
+
+
+
+ +
+
+
+
+
+
+
+ +
+
+ +
+
+ +
, +] +`; + +exports[`Storyshots components/Variables/EditVar new variable 1`] = ` +Array [ +
+ +
, +
+
+
+
+ +
+
+
+
+ +
+
+ + Select an option: +
+ +
+ + + + String + +
+ , is selected +
+ +
+ + +
+
+
+
+
+
+
+
+ +
+
+
+
+ +
+
+
+
+
+
+ +
+
+
+
+ +
+
+
+
+
+
+
+ +
+
+ +
+
+ +
, +] +`; diff --git a/x-pack/plugins/canvas/public/components/var_config/__examples__/__snapshots__/var_config.stories.storyshot b/x-pack/plugins/canvas/public/components/var_config/__examples__/__snapshots__/var_config.stories.storyshot new file mode 100644 index 0000000000000..146f07a9d0118 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/var_config/__examples__/__snapshots__/var_config.stories.storyshot @@ -0,0 +1,87 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Storyshots components/Variables/VarConfig default 1`] = ` +
+
+
+
+ +
+ + + +
+
+
+
+
+
+
+`; diff --git a/x-pack/plugins/canvas/public/components/var_config/__examples__/delete_var.stories.tsx b/x-pack/plugins/canvas/public/components/var_config/__examples__/delete_var.stories.tsx new file mode 100644 index 0000000000000..8f5b73d1f6ae9 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/var_config/__examples__/delete_var.stories.tsx @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { action } from '@storybook/addon-actions'; +import { storiesOf } from '@storybook/react'; +import React from 'react'; + +import { CanvasVariable } from '../../../../types'; + +import { DeleteVar } from '../delete_var'; + +const variable: CanvasVariable = { + name: 'homeUrl', + value: 'https://elastic.co', + type: 'string', +}; + +storiesOf('components/Variables/DeleteVar', module).add('default', () => ( + +)); diff --git a/x-pack/plugins/canvas/public/components/var_config/__examples__/edit_var.stories.tsx b/x-pack/plugins/canvas/public/components/var_config/__examples__/edit_var.stories.tsx new file mode 100644 index 0000000000000..0369c2c09a39c --- /dev/null +++ b/x-pack/plugins/canvas/public/components/var_config/__examples__/edit_var.stories.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { action } from '@storybook/addon-actions'; +import { storiesOf } from '@storybook/react'; +import React from 'react'; + +import { CanvasVariable } from '../../../../types'; + +import { EditVar } from '../edit_var'; + +const variables: CanvasVariable[] = [ + { + name: 'homeUrl', + value: 'https://elastic.co', + type: 'string', + }, + { + name: 'bigNumber', + value: 1000, + type: 'number', + }, + { + name: 'zenMode', + value: true, + type: 'boolean', + }, +]; + +storiesOf('components/Variables/EditVar', module) + .add('new variable', () => ( + + )) + .add('edit variable (string)', () => ( + + )) + .add('edit variable (number)', () => ( + + )) + .add('edit variable (boolean)', () => ( + + )); diff --git a/x-pack/plugins/canvas/public/components/var_config/__examples__/var_config.stories.tsx b/x-pack/plugins/canvas/public/components/var_config/__examples__/var_config.stories.tsx new file mode 100644 index 0000000000000..ac5c97d122138 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/var_config/__examples__/var_config.stories.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { action } from '@storybook/addon-actions'; +import { storiesOf } from '@storybook/react'; +import React from 'react'; + +import { CanvasVariable } from '../../../../types'; + +import { VarConfig } from '../var_config'; + +const variables: CanvasVariable[] = [ + { + name: 'homeUrl', + value: 'https://elastic.co', + type: 'string', + }, + { + name: 'bigNumber', + value: 1000, + type: 'number', + }, + { + name: 'zenMode', + value: true, + type: 'boolean', + }, +]; + +storiesOf('components/Variables/VarConfig', module).add('default', () => ( + +)); diff --git a/x-pack/plugins/canvas/public/components/var_config/delete_var.tsx b/x-pack/plugins/canvas/public/components/var_config/delete_var.tsx new file mode 100644 index 0000000000000..fa1771a752848 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/var_config/delete_var.tsx @@ -0,0 +1,77 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FC } from 'react'; +import { + EuiIcon, + EuiFlexGroup, + EuiFlexItem, + EuiButton, + EuiButtonEmpty, + EuiSpacer, + EuiText, +} from '@elastic/eui'; +import { CanvasVariable } from '../../../types'; + +import { ComponentStrings } from '../../../i18n'; +const { VarConfigDeleteVar: strings } = ComponentStrings; + +import './var_panel.scss'; + +interface Props { + selectedVar: CanvasVariable; + onDelete: (v: CanvasVariable) => void; + onCancel: () => void; +} + +export const DeleteVar: FC = ({ selectedVar, onCancel, onDelete }) => { + return ( + +
+ +
+
+
+ + + + {strings.getWarningDescription()} + + + + + + + + + onDelete(selectedVar)} + iconType="trash" + > + {strings.getDeleteButtonLabel()} + + + + onCancel()}> + {strings.getCancelButtonLabel()} + + + +
+
+
+ ); +}; diff --git a/x-pack/plugins/canvas/public/components/var_config/edit_var.scss b/x-pack/plugins/canvas/public/components/var_config/edit_var.scss new file mode 100644 index 0000000000000..7d4a7a4c81ba1 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/var_config/edit_var.scss @@ -0,0 +1,8 @@ +.canvasEditVar__typeOption { + display: flex; + align-items: center; + + .canvasEditVar__tokenIcon { + margin-right: 15px; + } +} diff --git a/x-pack/plugins/canvas/public/components/var_config/edit_var.tsx b/x-pack/plugins/canvas/public/components/var_config/edit_var.tsx new file mode 100644 index 0000000000000..a1a5541431d26 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/var_config/edit_var.tsx @@ -0,0 +1,189 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useState, FC } from 'react'; +import { + EuiIcon, + EuiFlexGroup, + EuiFlexItem, + EuiToken, + EuiSuperSelect, + EuiForm, + EuiFormRow, + EuiFieldText, + EuiButton, + EuiButtonEmpty, + EuiSpacer, + EuiCallOut, +} from '@elastic/eui'; +import { CanvasVariable } from '../../../types'; + +import { VarValueField } from './var_value_field'; + +import { ComponentStrings } from '../../../i18n'; +const { VarConfigEditVar: strings } = ComponentStrings; + +import './edit_var.scss'; +import './var_panel.scss'; + +interface Props { + selectedVar: CanvasVariable | null; + variables: CanvasVariable[]; + onSave: (v: CanvasVariable) => void; + onCancel: () => void; +} + +const checkDupeName = (newName: string, oldName: string | null, variables: CanvasVariable[]) => { + const match = variables.find((v) => { + // If the new name matches an existing variable and that + // matched variable name isn't the old name, then there + // is a duplicate + return newName === v.name && (!oldName || v.name !== oldName); + }); + + return !!match; +}; + +export const EditVar: FC = ({ variables, selectedVar, onCancel, onSave }) => { + // If there isn't a selected variable, we're creating a new var + const isNew = selectedVar === null; + + const [type, setType] = useState(isNew ? 'string' : selectedVar!.type); + const [name, setName] = useState(isNew ? '' : selectedVar!.name); + const [value, setValue] = useState(isNew ? '' : selectedVar!.value); + + const hasDupeName = checkDupeName(name, selectedVar && selectedVar.name, variables); + + const typeOptions = [ + { + value: 'string', + inputDisplay: ( +
+ {' '} + {strings.getTypeStringLabel()} +
+ ), + }, + { + value: 'number', + inputDisplay: ( +
+ {' '} + {strings.getTypeNumberLabel()} +
+ ), + }, + { + value: 'boolean', + inputDisplay: ( +
+ {' '} + {strings.getTypeBooleanLabel()} +
+ ), + }, + ]; + + return ( + <> +
+ +
+
+ {!isNew && ( +
+ + +
+ )} + + + + { + // Only have these types possible in the dropdown + setType(v as CanvasVariable['type']); + + // Reset default value + if (v === 'boolean') { + // Just setting a default value + setValue(true); + } else if (v === 'number') { + // Setting default number + setValue(0); + } else { + setValue(''); + } + }} + compressed={true} + /> + + + setName(e.target.value)} + isInvalid={hasDupeName} + /> + + + setValue(v)} /> + + + + + + + + onSave({ + name, + value, + type, + }) + } + disabled={hasDupeName || !name} + iconType="save" + > + {strings.getSaveButtonLabel()} + + + + onCancel()}> + {strings.getCancelButtonLabel()} + + + + +
+ + ); +}; diff --git a/x-pack/plugins/canvas/public/components/var_config/index.tsx b/x-pack/plugins/canvas/public/components/var_config/index.tsx new file mode 100644 index 0000000000000..526037b79e0e0 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/var_config/index.tsx @@ -0,0 +1,66 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FC } from 'react'; +import copy from 'copy-to-clipboard'; +import { VarConfig as ChildComponent } from './var_config'; +import { + withKibana, + KibanaReactContextValue, + KibanaServices, +} from '../../../../../../src/plugins/kibana_react/public'; +import { CanvasServices } from '../../services'; + +import { ComponentStrings } from '../../../i18n'; + +import { CanvasVariable } from '../../../types'; + +const { VarConfig: strings } = ComponentStrings; + +interface Props { + kibana: KibanaReactContextValue<{ canvas: CanvasServices } & KibanaServices>; + + variables: CanvasVariable[]; + setVariables: (variables: CanvasVariable[]) => void; +} + +const WrappedComponent: FC = ({ kibana, variables, setVariables }) => { + const onDeleteVar = (v: CanvasVariable) => { + const index = variables.findIndex((targetVar: CanvasVariable) => { + return targetVar.name === v.name; + }); + if (index !== -1) { + const newVars = [...variables]; + newVars.splice(index, 1); + setVariables(newVars); + + kibana.services.canvas.notify.success(strings.getDeleteNotificationDescription()); + } + }; + + const onCopyVar = (v: CanvasVariable) => { + const snippetStr = `{var "${v.name}"}`; + copy(snippetStr, { debug: true }); + kibana.services.canvas.notify.success(strings.getCopyNotificationDescription()); + }; + + const onAddVar = (v: CanvasVariable) => { + setVariables([...variables, v]); + }; + + const onEditVar = (oldVar: CanvasVariable, newVar: CanvasVariable) => { + const existingVarIndex = variables.findIndex((v) => oldVar.name === v.name); + + const newVars = [...variables]; + newVars[existingVarIndex] = newVar; + + setVariables(newVars); + }; + + return ; +}; + +export const VarConfig = withKibana(WrappedComponent); diff --git a/x-pack/plugins/canvas/public/components/var_config/var_config.scss b/x-pack/plugins/canvas/public/components/var_config/var_config.scss new file mode 100644 index 0000000000000..19fe64e7422fd --- /dev/null +++ b/x-pack/plugins/canvas/public/components/var_config/var_config.scss @@ -0,0 +1,66 @@ +.canvasVarConfig__container { + width: 100%; + position: relative; + + &.canvasVarConfig-isEditMode { + .canvasVarConfig__innerContainer { + transform: translateX(-50%); + } + } +} + +.canvasVarConfig__list { + table { + background-color: transparent; + } + + thead tr th, + thead tr td { + border-bottom: none; + border-top: none; + } + + tbody tr td { + border-top: none; + border-bottom: none; + } + + tbody tr:hover { + background-color: transparent; + } + + tbody tr:last-child td { + border-bottom: none; + } +} + +.canvasVarConfig__innerContainer { + width: calc(200% + 48px); // Account for the extra padding + + position: relative; + + display: flex; + flex-direction: row; + align-content: stretch; + + .canvasVarConfig__editView { + margin-left: 0; + } + + .canvasVarConfig__listView { + margin-right: 0; + } +} + +.canvasVarConfig__editView { + width: 50%; + height: 100%; + + flex-shrink: 0; +} + +.canvasVarConfig__listView { + width: 50%; + + flex-shrink: 0; +} diff --git a/x-pack/plugins/canvas/public/components/var_config/var_config.tsx b/x-pack/plugins/canvas/public/components/var_config/var_config.tsx new file mode 100644 index 0000000000000..6120130c77e24 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/var_config/var_config.tsx @@ -0,0 +1,230 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useState, FC } from 'react'; +import { + EuiAccordion, + EuiButtonIcon, + EuiToken, + EuiToolTip, + EuiText, + EuiInMemoryTable, + EuiBasicTableColumn, + EuiTableActionsColumnType, + EuiSpacer, + EuiButton, +} from '@elastic/eui'; + +import { CanvasVariable } from '../../../types'; +import { ComponentStrings } from '../../../i18n'; + +import { EditVar } from './edit_var'; +import { DeleteVar } from './delete_var'; + +import './var_config.scss'; + +const { VarConfig: strings } = ComponentStrings; + +enum PanelMode { + List, + Edit, + Delete, +} + +const typeToToken = { + number: 'tokenNumber', + boolean: 'tokenBoolean', + string: 'tokenString', +}; + +interface Props { + variables: CanvasVariable[]; + onCopyVar: (v: CanvasVariable) => void; + onDeleteVar: (v: CanvasVariable) => void; + onAddVar: (v: CanvasVariable) => void; + onEditVar: (oldVar: CanvasVariable, newVar: CanvasVariable) => void; +} + +export const VarConfig: FC = ({ + variables, + onCopyVar, + onDeleteVar, + onAddVar, + onEditVar, +}) => { + const [panelMode, setPanelMode] = useState(PanelMode.List); + const [selectedVar, setSelectedVar] = useState(null); + + const selectAndEditVar = (v: CanvasVariable) => { + setSelectedVar(v); + setPanelMode(PanelMode.Edit); + }; + + const selectAndDeleteVar = (v: CanvasVariable) => { + setSelectedVar(v); + setPanelMode(PanelMode.Delete); + }; + + const actions: EuiTableActionsColumnType['actions'] = [ + { + type: 'icon', + name: strings.getCopyActionButtonLabel(), + description: strings.getCopyActionTooltipLabel(), + icon: 'copyClipboard', + onClick: onCopyVar, + isPrimary: true, + }, + { + type: 'icon', + name: strings.getEditActionButtonLabel(), + description: '', + icon: 'pencil', + onClick: selectAndEditVar, + }, + { + type: 'icon', + name: strings.getDeleteActionButtonLabel(), + description: '', + icon: 'trash', + color: 'danger', + onClick: selectAndDeleteVar, + }, + ]; + + const varColumns: Array> = [ + { + field: 'type', + name: strings.getTableTypeLabel(), + sortable: true, + render: (varType: CanvasVariable['type'], _v: CanvasVariable) => { + return ; + }, + width: '50px', + }, + { + field: 'name', + name: strings.getTableNameLabel(), + sortable: true, + }, + { + field: 'value', + name: strings.getTableValueLabel(), + sortable: true, + truncateText: true, + render: (value: CanvasVariable['value'], _v: CanvasVariable) => { + return '' + value; + }, + }, + { + actions, + width: '60px', + }, + ]; + + return ( +
+
+ + {strings.getTitle()} + + } + extraAction={ + + { + setSelectedVar(null); + setPanelMode(PanelMode.Edit); + }} + /> + + } + > + {variables.length !== 0 && ( +
+ +
+ )} + {variables.length === 0 && ( +
+ + {strings.getEmptyDescription()} + + + setPanelMode(PanelMode.Edit)} + > + {strings.getAddButtonLabel()} + +
+ )} +
+
+ {panelMode === PanelMode.Edit && ( + { + if (!selectedVar) { + onAddVar(newVar); + } else { + onEditVar(selectedVar, newVar); + } + + setSelectedVar(null); + setPanelMode(PanelMode.List); + }} + onCancel={() => { + setSelectedVar(null); + setPanelMode(PanelMode.List); + }} + /> + )} + + {panelMode === PanelMode.Delete && selectedVar && ( + { + onDeleteVar(v); + + setSelectedVar(null); + setPanelMode(PanelMode.List); + }} + onCancel={() => { + setSelectedVar(null); + setPanelMode(PanelMode.List); + }} + /> + )} +
+
+
+ ); +}; diff --git a/x-pack/plugins/canvas/public/components/var_config/var_panel.scss b/x-pack/plugins/canvas/public/components/var_config/var_panel.scss new file mode 100644 index 0000000000000..84f92aab28146 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/var_config/var_panel.scss @@ -0,0 +1,31 @@ +.canvasVarHeader__triggerWrapper { + display: flex; + align-items: center; +} + +.canvasVarHeader__button { + @include euiFontSize; + text-align: left; + + width: 100%; + flex-grow: 1; + + display: flex; + align-items: center; +} + +.canvasVarHeader__iconWrapper { + width: $euiSize; + height: $euiSize; + + border-radius: $euiBorderRadius; + + margin-right: $euiSizeS; + margin-left: $euiSizeXS; + + flex-shrink: 0; +} + +.canvasVarHeader__anchor { + display: inline-block; +} \ No newline at end of file diff --git a/x-pack/plugins/canvas/public/components/var_config/var_value_field.tsx b/x-pack/plugins/canvas/public/components/var_config/var_value_field.tsx new file mode 100644 index 0000000000000..c86be4efec043 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/var_config/var_value_field.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FC } from 'react'; +import { EuiFieldText, EuiFieldNumber, EuiButtonGroup } from '@elastic/eui'; +import { htmlIdGenerator } from '@elastic/eui'; + +import { CanvasVariable } from '../../../types'; + +import { ComponentStrings } from '../../../i18n'; +const { VarConfigVarValueField: strings } = ComponentStrings; + +interface Props { + type: CanvasVariable['type']; + value: CanvasVariable['value']; + onChange: (v: CanvasVariable['value']) => void; +} + +export const VarValueField: FC = ({ type, value, onChange }) => { + const idPrefix = htmlIdGenerator()(); + + const options = [ + { + id: `${idPrefix}-true`, + label: strings.getTrueOption(), + }, + { + id: `${idPrefix}-false`, + label: strings.getFalseOption(), + }, + ]; + + if (type === 'number') { + return ( + onChange(e.target.value)} + /> + ); + } else if (type === 'boolean') { + return ( + { + const val = id.replace(`${idPrefix}-`, '') === 'true'; + onChange(val); + }} + buttonSize="compressed" + isFullWidth + /> + ); + } + + return ( + onChange(e.target.value)} + /> + ); +}; diff --git a/x-pack/plugins/canvas/public/components/workpad_config/index.ts b/x-pack/plugins/canvas/public/components/workpad_config/index.ts index c69a1fd9b8137..bba08d7647e9e 100644 --- a/x-pack/plugins/canvas/public/components/workpad_config/index.ts +++ b/x-pack/plugins/canvas/public/components/workpad_config/index.ts @@ -7,11 +7,17 @@ import { connect } from 'react-redux'; import { get } from 'lodash'; -import { sizeWorkpad as setSize, setName, setWorkpadCSS } from '../../state/actions/workpad'; +import { + sizeWorkpad as setSize, + setName, + setWorkpadCSS, + updateWorkpadVariables, +} from '../../state/actions/workpad'; + import { getWorkpad } from '../../state/selectors/workpad'; import { DEFAULT_WORKPAD_CSS } from '../../../common/lib/constants'; import { WorkpadConfig as Component } from './workpad_config'; -import { State } from '../../../types'; +import { State, CanvasVariable } from '../../../types'; const mapStateToProps = (state: State) => { const workpad = getWorkpad(state); @@ -23,6 +29,7 @@ const mapStateToProps = (state: State) => { height: get(workpad, 'height'), }, css: get(workpad, 'css', DEFAULT_WORKPAD_CSS), + variables: get(workpad, 'variables', []), }; }; @@ -30,6 +37,7 @@ const mapDispatchToProps = { setSize, setName, setWorkpadCSS, + setWorkpadVariables: (vars: CanvasVariable[]) => updateWorkpadVariables(vars), }; export const WorkpadConfig = connect(mapStateToProps, mapDispatchToProps)(Component); diff --git a/x-pack/plugins/canvas/public/components/workpad_config/workpad_config.tsx b/x-pack/plugins/canvas/public/components/workpad_config/workpad_config.tsx index 7b7a1e08b2c5d..a7424882f1072 100644 --- a/x-pack/plugins/canvas/public/components/workpad_config/workpad_config.tsx +++ b/x-pack/plugins/canvas/public/components/workpad_config/workpad_config.tsx @@ -19,10 +19,13 @@ import { EuiToolTip, EuiTextArea, EuiAccordion, - EuiText, EuiButton, } from '@elastic/eui'; + +import { VarConfig } from '../var_config'; + import { DEFAULT_WORKPAD_CSS } from '../../../common/lib/constants'; +import { CanvasVariable } from '../../../types'; import { ComponentStrings } from '../../../i18n'; const { WorkpadConfig: strings } = ComponentStrings; @@ -34,14 +37,16 @@ interface Props { }; name: string; css?: string; + variables: CanvasVariable[]; setSize: ({ height, width }: { height: number; width: number }) => void; setName: (name: string) => void; setWorkpadCSS: (css: string) => void; + setWorkpadVariables: (vars: CanvasVariable[]) => void; } export const WorkpadConfig: FunctionComponent = (props) => { const [css, setCSS] = useState(props.css); - const { size, name, setSize, setName, setWorkpadCSS } = props; + const { size, name, setSize, setName, setWorkpadCSS, variables, setWorkpadVariables } = props; const rotate = () => setSize({ width: size.height, height: size.width }); const badges = [ @@ -129,23 +134,25 @@ export const WorkpadConfig: FunctionComponent = (props) => {
-
+ + + +
- - {strings.getGlobalCSSLabel()} - + {strings.getGlobalCSSLabel()} } > -
+
F if (filterList && filterList.length) { const filterExpression = filterList.join(' | '); const filterAST = fromExpression(filterExpression); - return interpretAst(filterAST); + return interpretAst(filterAST, getWorkpadVariablesAsObject(getState())); } else { const filterType = initialize.typesRegistry.get('filter'); return filterType?.from(null, {}); diff --git a/x-pack/plugins/canvas/public/lib/run_interpreter.ts b/x-pack/plugins/canvas/public/lib/run_interpreter.ts index 07c0ca4b1ce15..12e07ed3535f6 100644 --- a/x-pack/plugins/canvas/public/lib/run_interpreter.ts +++ b/x-pack/plugins/canvas/public/lib/run_interpreter.ts @@ -15,8 +15,12 @@ interface Options { /** * Meant to be a replacement for plugins/interpreter/interpretAST */ -export async function interpretAst(ast: ExpressionAstExpression): Promise { - return await expressionsService.getService().execute(ast).getData(); +export async function interpretAst( + ast: ExpressionAstExpression, + variables: Record +): Promise { + const context = { variables }; + return await expressionsService.getService().execute(ast, null, context).getData(); } /** @@ -24,6 +28,7 @@ export async function interpretAst(ast: ExpressionAstExpression): Promise, options: Options = {} ): Promise { + const context = { variables }; + try { - const renderable = await expressionsService.getService().execute(ast, input).getData(); + const renderable = await expressionsService.getService().execute(ast, input, context).getData(); if (getType(renderable) === 'render') { return renderable; } if (options.castToRender) { - return runInterpreter(fromExpression('render'), renderable, { + return runInterpreter(fromExpression('render'), renderable, variables, { castToRender: false, }); } diff --git a/x-pack/plugins/canvas/public/lib/workpad_service.js b/x-pack/plugins/canvas/public/lib/workpad_service.js index 1617759e83dd8..2047e20424acc 100644 --- a/x-pack/plugins/canvas/public/lib/workpad_service.js +++ b/x-pack/plugins/canvas/public/lib/workpad_service.js @@ -21,6 +21,7 @@ const validKeys = [ 'assets', 'colors', 'css', + 'variables', 'height', 'id', 'isWriteable', @@ -61,6 +62,7 @@ export function create(workpad) { return fetch.post(getApiPath(), { ...sanitizeWorkpad({ ...workpad }), assets: workpad.assets || {}, + variables: workpad.variables || [], }); } @@ -73,7 +75,7 @@ export async function createFromTemplate(templateId) { export function get(workpadId) { return fetch.get(`${getApiPath()}/${workpadId}`).then(({ data: workpad }) => { // shim old workpads with new properties - return { css: DEFAULT_WORKPAD_CSS, ...workpad }; + return { css: DEFAULT_WORKPAD_CSS, variables: [], ...workpad }; }); } diff --git a/x-pack/plugins/canvas/public/state/actions/elements.js b/x-pack/plugins/canvas/public/state/actions/elements.js index e89e62917da39..2ba011373c670 100644 --- a/x-pack/plugins/canvas/public/state/actions/elements.js +++ b/x-pack/plugins/canvas/public/state/actions/elements.js @@ -9,7 +9,13 @@ import immutable from 'object-path-immutable'; import { get, pick, cloneDeep, without } from 'lodash'; import { toExpression, safeElementFromExpression } from '@kbn/interpreter/common'; import { createThunk } from '../../lib/create_thunk'; -import { getPages, getNodeById, getNodes, getSelectedPageIndex } from '../selectors/workpad'; +import { + getPages, + getWorkpadVariablesAsObject, + getNodeById, + getNodes, + getSelectedPageIndex, +} from '../selectors/workpad'; import { getValue as getResolvedArgsValue } from '../selectors/resolved_args'; import { getDefaultElement } from '../defaults'; import { ErrorStrings } from '../../../i18n'; @@ -96,13 +102,15 @@ export const fetchContext = createThunk( return i < index; }); + const variables = getWorkpadVariablesAsObject(getState()); + // get context data from a partial AST return interpretAst( { ...element.ast, chain: astChain, }, - prevContextValue + variables ).then((value) => { dispatch( args.setValue({ @@ -114,7 +122,7 @@ export const fetchContext = createThunk( } ); -const fetchRenderableWithContextFn = ({ dispatch }, element, ast, context) => { +const fetchRenderableWithContextFn = ({ dispatch, getState }, element, ast, context) => { const argumentPath = [element.id, 'expressionRenderable']; dispatch( args.setLoading({ @@ -128,7 +136,9 @@ const fetchRenderableWithContextFn = ({ dispatch }, element, ast, context) => { value: renderable, }); - return runInterpreter(ast, context, { castToRender: true }) + const variables = getWorkpadVariablesAsObject(getState()); + + return runInterpreter(ast, context, variables, { castToRender: true }) .then((renderable) => { dispatch(getAction(renderable)); }) @@ -172,7 +182,9 @@ export const fetchAllRenderables = createThunk( const ast = element.ast || safeElementFromExpression(element.expression); const argumentPath = [element.id, 'expressionRenderable']; - return runInterpreter(ast, null, { castToRender: true }) + const variables = getWorkpadVariablesAsObject(getState()); + + return runInterpreter(ast, null, variables, { castToRender: true }) .then((renderable) => ({ path: argumentPath, value: renderable })) .catch((err) => { services.notify.getService().error(err); diff --git a/x-pack/plugins/canvas/public/state/actions/workpad.ts b/x-pack/plugins/canvas/public/state/actions/workpad.ts index 419832e404594..7af55730f5787 100644 --- a/x-pack/plugins/canvas/public/state/actions/workpad.ts +++ b/x-pack/plugins/canvas/public/state/actions/workpad.ts @@ -10,7 +10,7 @@ import { createThunk } from '../../lib/create_thunk'; import { getWorkpadColors } from '../selectors/workpad'; // @ts-expect-error import { fetchAllRenderables } from './elements'; -import { CanvasWorkpad } from '../../../types'; +import { CanvasWorkpad, CanvasVariable } from '../../../types'; export const sizeWorkpad = createAction<{ height: number; width: number }>('sizeWorkpad'); export const setName = createAction('setName'); @@ -18,6 +18,7 @@ export const setWriteable = createAction('setWriteable'); export const setColors = createAction('setColors'); export const setRefreshInterval = createAction('setRefreshInterval'); export const setWorkpadCSS = createAction('setWorkpadCSS'); +export const setWorkpadVariables = createAction('setWorkpadVariables'); export const enableAutoplay = createAction('enableAutoplay'); export const setAutoplayInterval = createAction('setAutoplayInterval'); export const resetWorkpad = createAction('resetWorkpad'); @@ -38,6 +39,14 @@ export const removeColor = createThunk('removeColor', ({ dispatch, getState }, c dispatch(setColors(without(getWorkpadColors(getState()), color))); }); +export const updateWorkpadVariables = createThunk( + 'updateWorkpadVariables', + ({ dispatch }, vars) => { + dispatch(setWorkpadVariables(vars)); + dispatch(fetchAllRenderables()); + } +); + export const setWorkpad = createThunk( 'setWorkpad', ( diff --git a/x-pack/plugins/canvas/public/state/defaults.js b/x-pack/plugins/canvas/public/state/defaults.js index 13ff7102bcafe..5cffb5e865d64 100644 --- a/x-pack/plugins/canvas/public/state/defaults.js +++ b/x-pack/plugins/canvas/public/state/defaults.js @@ -81,6 +81,7 @@ export const getDefaultWorkpad = () => { '#FFFFFF', 'rgba(255,255,255,0)', // 'transparent' ], + variables: [], isWriteable: true, }; }; diff --git a/x-pack/plugins/canvas/public/state/reducers/workpad.js b/x-pack/plugins/canvas/public/state/reducers/workpad.js index 30f9c638a054f..9a0c30bdf1337 100644 --- a/x-pack/plugins/canvas/public/state/reducers/workpad.js +++ b/x-pack/plugins/canvas/public/state/reducers/workpad.js @@ -14,6 +14,7 @@ import { setName, setWriteable, setWorkpadCSS, + setWorkpadVariables, resetWorkpad, } from '../actions/workpad'; @@ -59,6 +60,10 @@ export const workpadReducer = handleActions( return { ...workpadState, css: payload }; }, + [setWorkpadVariables]: (workpadState, { payload }) => { + return { ...workpadState, variables: payload }; + }, + [resetWorkpad]: () => ({ ...getDefaultWorkpad() }), }, {} diff --git a/x-pack/plugins/canvas/public/state/selectors/workpad.ts b/x-pack/plugins/canvas/public/state/selectors/workpad.ts index 83f4984b4a300..1d7ea05daaa61 100644 --- a/x-pack/plugins/canvas/public/state/selectors/workpad.ts +++ b/x-pack/plugins/canvas/public/state/selectors/workpad.ts @@ -10,7 +10,14 @@ import { safeElementFromExpression, fromExpression } from '@kbn/interpreter/comm // @ts-expect-error untyped local import { append } from '../../lib/modify_path'; import { getAssets } from './assets'; -import { State, CanvasWorkpad, CanvasPage, CanvasElement, ResolvedArgType } from '../../../types'; +import { + State, + CanvasWorkpad, + CanvasPage, + CanvasElement, + CanvasVariable, + ResolvedArgType, +} from '../../../types'; import { ExpressionContext, CanvasGroup, @@ -49,6 +56,23 @@ export function getWorkpadPersisted(state: State) { return getWorkpad(state); } +export function getWorkpadVariables(state: State) { + const workpad = getWorkpad(state); + return get(workpad, 'variables', []); +} + +export function getWorkpadVariablesAsObject(state: State) { + const variables = getWorkpadVariables(state); + if (variables.length === 0) { + return {}; + } + + return (variables as CanvasVariable[]).reduce( + (vars: Record, v: CanvasVariable) => ({ ...vars, [v.name]: v.value }), + {} + ); +} + export function getWorkpadInfo(state: State): WorkpadInfo { return { ...getWorkpad(state), @@ -326,7 +350,9 @@ export function getElements( return elements.map((el) => omit(el, ['ast'])); } - return elements.map(appendAst); + const elementAppendAst = (elem: CanvasElement) => appendAst(elem); + + return elements.map(elementAppendAst); } const augment = (type: string) => (n: T): T => ({ diff --git a/x-pack/plugins/canvas/server/routes/workpad/workpad_schema.ts b/x-pack/plugins/canvas/server/routes/workpad/workpad_schema.ts index 0c31f517a74b3..5bbd2caa0cb99 100644 --- a/x-pack/plugins/canvas/server/routes/workpad/workpad_schema.ts +++ b/x-pack/plugins/canvas/server/routes/workpad/workpad_schema.ts @@ -51,12 +51,19 @@ export const WorkpadAssetSchema = schema.object({ value: schema.string(), }); +export const WorkpadVariable = schema.object({ + name: schema.string(), + value: schema.oneOf([schema.string(), schema.number(), schema.boolean()]), + type: schema.string(), +}); + export const WorkpadSchema = schema.object({ '@created': schema.maybe(schema.string()), '@timestamp': schema.maybe(schema.string()), assets: schema.maybe(schema.recordOf(schema.string(), WorkpadAssetSchema)), colors: schema.arrayOf(schema.string()), css: schema.string(), + variables: schema.arrayOf(WorkpadVariable), height: schema.number(), id: schema.string(), isWriteable: schema.maybe(schema.boolean()), diff --git a/x-pack/plugins/canvas/server/templates/pitch_presentation.ts b/x-pack/plugins/canvas/server/templates/pitch_presentation.ts index 95f0dc4c3da39..416d3aee2dd03 100644 --- a/x-pack/plugins/canvas/server/templates/pitch_presentation.ts +++ b/x-pack/plugins/canvas/server/templates/pitch_presentation.ts @@ -1644,5 +1644,6 @@ export const pitch: CanvasTemplate = { }, css: ".canvasPage h1, .canvasPage h2, .canvasPage h3, .canvasPage h4, .canvasPage h5 {\nfont-family: 'Futura';\ncolor: #444444;\n}\n\n.canvasPage h1 {\nfont-size: 112px;\nfont-weight: bold;\ncolor: #FFFFFF;\n}\n\n.canvasPage h2 {\nfont-size: 48px;\nfont-weight: bold;\n}\n\n.canvasPage h3 {\nfont-size: 30px;\nfont-weight: 300;\ntext-transform: uppercase;\ncolor: #FFFFFF;\n}\n\n.canvasPage h5 {\nfont-size: 24px;\nfont-style: italic;\n}", + variables: [], }, }; diff --git a/x-pack/plugins/canvas/server/templates/status_report.ts b/x-pack/plugins/canvas/server/templates/status_report.ts index b396ed784cbed..447e1f99afaee 100644 --- a/x-pack/plugins/canvas/server/templates/status_report.ts +++ b/x-pack/plugins/canvas/server/templates/status_report.ts @@ -17,6 +17,7 @@ export const status: CanvasTemplate = { height: 792, css: '.canvasPage h1, .canvasPage h2, .canvasPage h3, .canvasPage h4, .canvasPage h5, .canvasPage h6, .canvasPage li, .canvasPage p, .canvasPage th, .canvasPage td {\nfont-family: "Gill Sans" !important;\ncolor: #333333;\n}\n\n.canvasPage h1, .canvasPage h2 {\nfont-weight: 400;\n}\n\n.canvasPage h2 {\ntext-transform: uppercase;\ncolor: #1785B0;\n}\n\n.canvasMarkdown p,\n.canvasMarkdown li {\nfont-size: 18px;\n}\n\n.canvasMarkdown li {\nmargin-bottom: .75em;\n}\n\n.canvasMarkdown h3:not(:first-child) {\nmargin-top: 2em;\n}\n\n.canvasMarkdown a {\ncolor: #1785B0;\n}\n\n.canvasMarkdown th,\n.canvasMarkdown td {\npadding: .5em 1em;\n}\n\n.canvasMarkdown th {\nbackground-color: #FAFBFD;\n}\n\n.canvasMarkdown table,\n.canvasMarkdown th,\n.canvasMarkdown td {\nborder: 1px solid #e4e9f2;\n}', + variables: [], page: 0, pages: [ { diff --git a/x-pack/plugins/canvas/server/templates/summary_report.ts b/x-pack/plugins/canvas/server/templates/summary_report.ts index 1b32a80fa82c7..64f04eef4194e 100644 --- a/x-pack/plugins/canvas/server/templates/summary_report.ts +++ b/x-pack/plugins/canvas/server/templates/summary_report.ts @@ -493,5 +493,6 @@ export const summary: CanvasTemplate = { '@created': '2019-05-31T16:01:45.751Z', assets: {}, css: 'h3 {\ncolor: #343741;\nfont-weight: 400;\n}\n\nh5 {\ncolor: #69707D;\n}', + variables: [], }, }; diff --git a/x-pack/plugins/canvas/server/templates/theme_dark.ts b/x-pack/plugins/canvas/server/templates/theme_dark.ts index 8dce2c5eb9b6e..5822a17976cd3 100644 --- a/x-pack/plugins/canvas/server/templates/theme_dark.ts +++ b/x-pack/plugins/canvas/server/templates/theme_dark.ts @@ -17,6 +17,7 @@ export const dark: CanvasTemplate = { height: 720, page: 0, css: '', + variables: [], pages: [ { id: 'page-fda26a1f-c096-44e4-a149-cb99e1038a34', diff --git a/x-pack/plugins/canvas/server/templates/theme_light.ts b/x-pack/plugins/canvas/server/templates/theme_light.ts index fb654a2fd2954..d278e057bb441 100644 --- a/x-pack/plugins/canvas/server/templates/theme_light.ts +++ b/x-pack/plugins/canvas/server/templates/theme_light.ts @@ -14,6 +14,7 @@ export const light: CanvasTemplate = { template: { name: 'Light', css: '', + variables: [], width: 1080, height: 720, page: 0, diff --git a/x-pack/plugins/canvas/types/canvas.ts b/x-pack/plugins/canvas/types/canvas.ts index 2f20dc88fdec4..cc07f498f1eec 100644 --- a/x-pack/plugins/canvas/types/canvas.ts +++ b/x-pack/plugins/canvas/types/canvas.ts @@ -37,12 +37,19 @@ export interface CanvasPage { groups: CanvasGroup[]; } +export interface CanvasVariable { + name: string; + value: boolean | number | string; + type: 'boolean' | 'number' | 'string'; +} + export interface CanvasWorkpad { '@created': string; '@timestamp': string; assets: { [id: string]: CanvasAsset }; colors: string[]; css: string; + variables: CanvasVariable[]; height: number; id: string; isWriteable: boolean; From 1b1962f18c7a1700427d1391187e30bea76ffac7 Mon Sep 17 00:00:00 2001 From: Melissa Alvarez Date: Mon, 13 Jul 2020 16:51:22 -0400 Subject: [PATCH 31/66] [ML] DF Analytics creation and update: adds `max_num_threads` (#71318) * add max_num_threads to edit flyout * add maxNumThreads setting to job wizard * add maxNumThreads to cloning --- .../data_frame_analytics/common/analytics.ts | 2 + .../advanced_step/advanced_step_details.tsx | 10 +++ .../advanced_step/advanced_step_form.tsx | 63 +++++++++++++++---- .../advanced_step/hyper_parameters.tsx | 12 ++-- .../outlier_hyper_parameters.tsx | 8 +-- .../components/action_clone/clone_button.tsx | 4 ++ .../action_edit/edit_button_flyout.tsx | 52 ++++++++++++++- .../hooks/use_create_analytics_form/state.ts | 8 +++ .../routes/schemas/data_analytics_schema.ts | 3 + 9 files changed, 137 insertions(+), 25 deletions(-) diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/common/analytics.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/common/analytics.ts index 618ea5184007d..06254f0de092e 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/common/analytics.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/common/analytics.ts @@ -339,6 +339,7 @@ export interface UpdateDataFrameAnalyticsConfig { allow_lazy_start?: string; description?: string; model_memory_limit?: string; + max_num_threads?: number; } export interface DataFrameAnalyticsConfig { @@ -358,6 +359,7 @@ export interface DataFrameAnalyticsConfig { excludes: string[]; }; model_memory_limit: string; + max_num_threads?: number; create_time: number; version: string; allow_lazy_start?: boolean; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/advanced_step/advanced_step_details.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/advanced_step/advanced_step_details.tsx index a9c8b6d4040ad..875590d0f9ee4 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/advanced_step/advanced_step_details.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/advanced_step/advanced_step_details.tsx @@ -45,6 +45,7 @@ export const AdvancedStepDetails: FC<{ setCurrentStep: any; state: State }> = ({ jobType, lambda, method, + maxNumThreads, maxTrees, modelMemoryLimit, nNeighbors, @@ -214,6 +215,15 @@ export const AdvancedStepDetails: FC<{ setCurrentStep: any; state: State }> = ({ ); } + if (maxNumThreads !== undefined) { + advancedFirstCol.push({ + title: i18n.translate('xpack.ml.dataframe.analytics.create.configDetails.maxNumThreads', { + defaultMessage: 'Maximum number of threads', + }), + description: `${maxNumThreads}`, + }); + } + return ( diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/advanced_step/advanced_step_form.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/advanced_step/advanced_step_form.tsx index 21b0d3d7dd89e..11184afb0e715 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/advanced_step/advanced_step_form.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/advanced_step/advanced_step_form.tsx @@ -9,7 +9,7 @@ import { EuiAccordion, EuiFieldNumber, EuiFieldText, - EuiFlexGroup, + EuiFlexGrid, EuiFlexItem, EuiFormRow, EuiSelect, @@ -57,6 +57,7 @@ export const AdvancedStepForm: FC = ({ gamma, jobType, lambda, + maxNumThreads, maxTrees, method, modelMemoryLimit, @@ -82,7 +83,8 @@ export const AdvancedStepForm: FC = ({ const isStepInvalid = mmlInvalid || Object.keys(advancedParamErrors).length > 0 || - fetchingAdvancedParamErrors === true; + fetchingAdvancedParamErrors === true || + maxNumThreads === 0; useEffect(() => { setFetchingAdvancedParamErrors(true); @@ -112,6 +114,7 @@ export const AdvancedStepForm: FC = ({ featureInfluenceThreshold, gamma, lambda, + maxNumThreads, maxTrees, method, nNeighbors, @@ -123,7 +126,7 @@ export const AdvancedStepForm: FC = ({ const outlierDetectionAdvancedConfig = ( - + = ({ /> - + = ({ const regAndClassAdvancedConfig = ( - + = ({ /> - + = ({ })} - + {jobType === ANALYSIS_CONFIG_TYPE.OUTLIER_DETECTION && outlierDetectionAdvancedConfig} {isRegOrClassJob && regAndClassAdvancedConfig} {jobType === ANALYSIS_CONFIG_TYPE.CLASSIFICATION && ( - + = ({ )} - + = ({ /> - + + + + setFormState({ + maxNumThreads: e.target.value === '' ? undefined : +e.target.value, + }) + } + step={1} + value={getNumberValue(maxNumThreads)} + /> + + + = ({ initialIsOpen={false} data-test-subj="mlAnalyticsCreateJobWizardHyperParametersSection" > - + {jobType === ANALYSIS_CONFIG_TYPE.OUTLIER_DETECTION && ( = ({ advancedParamErrors={advancedParamErrors} /> )} - + = ({ actions, state, advancedParamErrors return ( - + = ({ actions, state, advancedParamErrors /> - + = ({ actions, state, advancedParamErrors /> - + = ({ actions, state, advancedParamErrors /> - + = ({ actions, state, advancedParamErrors /> - + = ({ actions, state, advancedParamErrors /> - + = ({ actions, state, advancedPara return ( - + = ({ actions, state, advancedPara /> - + = ({ actions, state, advancedPara /> - + = ({ actions, state, advancedPara /> - + > = ({ closeFlyout, item } const [description, setDescription] = useState(config.description || ''); const [modelMemoryLimit, setModelMemoryLimit] = useState(config.model_memory_limit); const [mmlValidationError, setMmlValidationError] = useState(); + const [maxNumThreads, setMaxNumThreads] = useState(config.max_num_threads); const { services: { notifications }, @@ -59,7 +61,7 @@ export const EditButtonFlyout: FC> = ({ closeFlyout, item } const { refresh } = useRefreshAnalyticsList(); // Disable if mml is not valid - const updateButtonDisabled = mmlValidationError !== undefined; + const updateButtonDisabled = mmlValidationError !== undefined || maxNumThreads === 0; useEffect(() => { if (mmLValidator === undefined) { @@ -93,7 +95,8 @@ export const EditButtonFlyout: FC> = ({ closeFlyout, item } allow_lazy_start: allowLazyStart, description, }, - modelMemoryLimit && { model_memory_limit: modelMemoryLimit } + modelMemoryLimit && { model_memory_limit: modelMemoryLimit }, + maxNumThreads && { max_num_threads: maxNumThreads } ); try { @@ -210,7 +213,7 @@ export const EditButtonFlyout: FC> = ({ closeFlyout, item } helpText={ state !== DATA_FRAME_TASK_STATE.STOPPED && i18n.translate('xpack.ml.dataframe.analyticsList.editFlyout.modelMemoryHelpText', { - defaultMessage: 'Model memory limit cannot be edited while the job is running.', + defaultMessage: 'Model memory limit cannot be edited until the job has stopped.', }) } label={i18n.translate( @@ -236,6 +239,49 @@ export const EditButtonFlyout: FC> = ({ closeFlyout, item } )} /> + + + setMaxNumThreads(e.target.value === '' ? undefined : +e.target.value) + } + step={1} + min={1} + readOnly={state !== DATA_FRAME_TASK_STATE.STOPPED} + value={maxNumThreads} + /> + diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/state.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/state.ts index 0d425c8ead4a2..68a3613f91b5e 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/state.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/state.ts @@ -23,6 +23,7 @@ export enum DEFAULT_MODEL_MEMORY_LIMIT { } export const DEFAULT_NUM_TOP_FEATURE_IMPORTANCE_VALUES = 0; +export const DEFAULT_MAX_NUM_THREADS = 1; export const UNSET_CONFIG_ITEM = '--'; export type EsIndexName = string; @@ -68,6 +69,7 @@ export interface State { jobConfigQueryString: string | undefined; lambda: number | undefined; loadingFieldOptions: boolean; + maxNumThreads: undefined | number; maxTrees: undefined | number; method: undefined | string; modelMemoryLimit: string | undefined; @@ -134,6 +136,7 @@ export const getInitialState = (): State => ({ jobConfigQueryString: undefined, lambda: undefined, loadingFieldOptions: false, + maxNumThreads: DEFAULT_MAX_NUM_THREADS, maxTrees: undefined, method: undefined, modelMemoryLimit: undefined, @@ -200,6 +203,10 @@ export const getJobConfigFromFormState = ( model_memory_limit: formState.modelMemoryLimit, }; + if (formState.maxNumThreads !== undefined) { + jobConfig.max_num_threads = formState.maxNumThreads; + } + const resultsFieldEmpty = typeof formState?.resultsField === 'string' && formState?.resultsField.trim() === ''; @@ -291,6 +298,7 @@ export function getCloneFormStateFromJobConfig( ? analyticsJobConfig.source.index.join(',') : analyticsJobConfig.source.index, modelMemoryLimit: analyticsJobConfig.model_memory_limit, + maxNumThreads: analyticsJobConfig.max_num_threads, includes: analyticsJobConfig.analyzed_fields.includes, }; diff --git a/x-pack/plugins/ml/server/routes/schemas/data_analytics_schema.ts b/x-pack/plugins/ml/server/routes/schemas/data_analytics_schema.ts index 5469c2fefdf33..0c3e186c314cc 100644 --- a/x-pack/plugins/ml/server/routes/schemas/data_analytics_schema.ts +++ b/x-pack/plugins/ml/server/routes/schemas/data_analytics_schema.ts @@ -28,6 +28,7 @@ export const dataAnalyticsJobConfigSchema = schema.object({ analysis: schema.any(), analyzed_fields: schema.any(), model_memory_limit: schema.string(), + max_num_threads: schema.maybe(schema.number()), }); export const dataAnalyticsEvaluateSchema = schema.object({ @@ -52,6 +53,7 @@ export const dataAnalyticsExplainSchema = schema.object({ analysis: schema.any(), analyzed_fields: schema.maybe(schema.any()), model_memory_limit: schema.maybe(schema.string()), + max_num_threads: schema.maybe(schema.number()), }); export const analyticsIdSchema = schema.object({ @@ -73,6 +75,7 @@ export const dataAnalyticsJobUpdateSchema = schema.object({ description: schema.maybe(schema.string()), model_memory_limit: schema.maybe(schema.string()), allow_lazy_start: schema.maybe(schema.boolean()), + max_num_threads: schema.maybe(schema.number()), }); export const stopsDataFrameAnalyticsJobQuerySchema = schema.object({ From f86c0792a12ef928d5f405651933e3903eae3f7f Mon Sep 17 00:00:00 2001 From: nnamdifrankie <56440728+nnamdifrankie@users.noreply.github.com> Date: Mon, 13 Jul 2020 16:57:04 -0400 Subject: [PATCH 32/66] [SecuritySolution-Endpoint]: add filter of default Elastic Agent ids for Endpoint Agent initial state (#71478) [SecuritySolution-Endpoint]: add filter of default Elastic Agent ids for Endpoint Agent initial state --- .../server/endpoint/routes/metadata/index.ts | 12 ++++++++- .../endpoint/routes/metadata/metadata.test.ts | 25 +++++++++++++++++-- 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/metadata/index.ts b/x-pack/plugins/security_solution/server/endpoint/routes/metadata/index.ts index 4b2eb3ea1ddb0..7915f1a8cbf50 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/metadata/index.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/metadata/index.ts @@ -37,6 +37,16 @@ const HOST_STATUS_MAPPING = new Map([ ['offline', HostStatus.OFFLINE], ]); +/** + * 00000000-0000-0000-0000-000000000000 is initial Elastic Agent id sent by Endpoint before policy is configured + * 11111111-1111-1111-1111-111111111111 is Elastic Agent id sent by Endpoint when policy does not contain an id + */ + +const IGNORED_ELASTIC_AGENT_IDS = [ + '00000000-0000-0000-0000-000000000000', + '11111111-1111-1111-1111-111111111111', +]; + const getLogger = (endpointAppContext: EndpointAppContext): Logger => { return endpointAppContext.logFactory.get('metadata'); }; @@ -97,7 +107,7 @@ export function registerEndpointRoutes(router: IRouter, endpointAppContext: Endp endpointAppContext, metadataIndexPattern, { - unenrolledAgentIds, + unenrolledAgentIds: unenrolledAgentIds.concat(IGNORED_ELASTIC_AGENT_IDS), } ); diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/metadata/metadata.test.ts b/x-pack/plugins/security_solution/server/endpoint/routes/metadata/metadata.test.ts index 81027b42eb64f..321eb0195aac3 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/metadata/metadata.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/metadata/metadata.test.ts @@ -138,7 +138,16 @@ describe('test endpoint route', () => { expect(mockScopedClient.callAsCurrentUser).toHaveBeenCalledTimes(1); expect(mockScopedClient.callAsCurrentUser.mock.calls[0][1]?.body?.query).toEqual({ - match_all: {}, + bool: { + must_not: { + terms: { + 'elastic.agent.id': [ + '00000000-0000-0000-0000-000000000000', + '11111111-1111-1111-1111-111111111111', + ], + }, + }, + }, }); expect(routeConfig.options).toEqual({ authRequired: true, tags: ['access:securitySolution'] }); expect(mockResponse.ok).toBeCalled(); @@ -184,11 +193,22 @@ describe('test endpoint route', () => { expect(mockScopedClient.callAsCurrentUser.mock.calls[0][1]?.body?.query).toEqual({ bool: { must: [ + { + bool: { + must_not: { + terms: { + 'elastic.agent.id': [ + '00000000-0000-0000-0000-000000000000', + '11111111-1111-1111-1111-111111111111', + ], + }, + }, + }, + }, { bool: { must_not: { bool: { - minimum_should_match: 1, should: [ { match: { @@ -196,6 +216,7 @@ describe('test endpoint route', () => { }, }, ], + minimum_should_match: 1, }, }, }, From 3ac8e367f8bd025c7502c5f9ba2b65e9bcbb7501 Mon Sep 17 00:00:00 2001 From: Nicolas Chaulet Date: Mon, 13 Jul 2020 17:02:09 -0400 Subject: [PATCH 33/66] [Ingest Manager] Log a warning if registryUrl is set in non gold (#71514) --- .../server/services/epm/registry/registry_url.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/x-pack/plugins/ingest_manager/server/services/epm/registry/registry_url.ts b/x-pack/plugins/ingest_manager/server/services/epm/registry/registry_url.ts index d92d6faf8472e..90232eb8f29e3 100644 --- a/x-pack/plugins/ingest_manager/server/services/epm/registry/registry_url.ts +++ b/x-pack/plugins/ingest_manager/server/services/epm/registry/registry_url.ts @@ -20,5 +20,9 @@ export const getRegistryUrl = (): string => { return customUrl; } + if (customUrl) { + appContextService.getLogger().warn('Gold license is required to use a custom registry url.'); + } + return DEFAULT_REGISTRY_URL; }; From 29580bee4e88a4391c381a303b6f171db9d38f19 Mon Sep 17 00:00:00 2001 From: Alison Goryachev Date: Mon, 13 Jul 2020 17:12:33 -0400 Subject: [PATCH 34/66] fix console example (#71515) --- .../console/public/application/components/editor_example.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/plugins/console/public/application/components/editor_example.tsx b/src/plugins/console/public/application/components/editor_example.tsx index 72a1056b1a866..b33d349cede28 100644 --- a/src/plugins/console/public/application/components/editor_example.tsx +++ b/src/plugins/console/public/application/components/editor_example.tsx @@ -27,13 +27,13 @@ interface EditorExampleProps { const exampleText = ` # index a doc -PUT index/1 +PUT index/_doc/1 { "body": "here" } # and get it ... -GET index/1 +GET index/_doc/1 `; export function EditorExample(props: EditorExampleProps) { From ff7b736cc31c3b611512c690f387baa59a932a6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20St=C3=BCrmer?= Date: Mon, 13 Jul 2020 23:29:55 +0200 Subject: [PATCH 35/66] [Logs UI] Show log analysis ML jobs in a list (#71132) This modifies the ML job setup flyout of the anomalies tab to offer a list of the two available modules. Via the list each of the modules' jobs can be created or re-created. --- .../infra/common/log_analysis/log_analysis.ts | 10 +- .../logging/log_analysis_job_status/index.ts | 1 + .../job_configuration_outdated_callout.tsx | 25 ++-- .../job_definition_outdated_callout.tsx | 25 ++-- .../log_analysis_job_problem_indicator.tsx | 12 +- .../notices_section.tsx | 7 +- .../quality_warning_notices.tsx | 5 +- .../initial_configuration_step.tsx | 2 +- .../log_analysis_setup/manage_jobs_button.tsx | 18 +++ .../process_step/process_step.tsx | 7 +- .../setup_flyout/index.tsx} | 3 + .../log_entry_categories_setup_view.tsx | 87 ++++++++++++++ .../log_entry_rate_setup_view.tsx} | 72 +++--------- .../setup_flyout/module_list.tsx | 55 +++++++++ .../setup_flyout/module_list_card.tsx | 46 ++++++++ .../setup_flyout/setup_flyout.tsx | 80 +++++++++++++ .../setup_flyout/setup_flyout_state.ts | 45 +++++++ .../logs/log_analysis/log_analysis_module.tsx | 10 -- .../log_analysis_module_status.tsx | 16 +-- .../log_analysis/log_analysis_module_types.ts | 54 ++++++++- .../modules/log_entry_categories/index.ts | 10 ++ .../log_entry_categories/module_descriptor.ts | 31 +++-- .../use_log_entry_categories_module.tsx | 10 +- .../use_log_entry_categories_quality.ts | 9 +- .../use_log_entry_categories_setup.tsx | 3 +- .../modules/log_entry_rate/index.ts | 9 ++ .../log_entry_rate/module_descriptor.ts | 31 +++-- .../use_log_entry_rate_module.tsx | 10 +- .../use_log_entry_rate_setup.tsx | 8 +- .../log_entry_categories/page_content.tsx | 11 +- .../log_entry_categories/page_providers.tsx | 3 +- .../page_results_content.tsx | 28 ++--- .../sections/notices/quality_warnings.tsx | 45 ------- .../log_entry_categories/setup_flyout.tsx | 13 +-- .../logs/log_entry_rate/page_content.tsx | 90 ++++++++++---- .../logs/log_entry_rate/page_providers.tsx | 14 ++- .../log_entry_rate/page_results_content.tsx | 110 ++++++++++-------- .../sections/anomalies/expanded_row.tsx | 10 +- .../sections/anomalies/index.tsx | 18 +-- .../infra/public/pages/logs/page_content.tsx | 17 +-- .../translations/translations/ja-JP.json | 6 - .../translations/translations/zh-CN.json | 6 - 42 files changed, 714 insertions(+), 358 deletions(-) rename x-pack/plugins/infra/public/{pages/logs/log_entry_categories/sections/notices => components/logging/log_analysis_job_status}/notices_section.tsx (83%) rename x-pack/plugins/infra/public/{pages/logs/log_entry_categories/sections/notices => components/logging/log_analysis_job_status}/quality_warning_notices.tsx (96%) create mode 100644 x-pack/plugins/infra/public/components/logging/log_analysis_setup/manage_jobs_button.tsx rename x-pack/plugins/infra/public/{pages/logs/log_entry_categories/sections/notices/index.ts => components/logging/log_analysis_setup/setup_flyout/index.tsx} (77%) create mode 100644 x-pack/plugins/infra/public/components/logging/log_analysis_setup/setup_flyout/log_entry_categories_setup_view.tsx rename x-pack/plugins/infra/public/{pages/logs/log_entry_rate/setup_flyout.tsx => components/logging/log_analysis_setup/setup_flyout/log_entry_rate_setup_view.tsx} (50%) create mode 100644 x-pack/plugins/infra/public/components/logging/log_analysis_setup/setup_flyout/module_list.tsx create mode 100644 x-pack/plugins/infra/public/components/logging/log_analysis_setup/setup_flyout/module_list_card.tsx create mode 100644 x-pack/plugins/infra/public/components/logging/log_analysis_setup/setup_flyout/setup_flyout.tsx create mode 100644 x-pack/plugins/infra/public/components/logging/log_analysis_setup/setup_flyout/setup_flyout_state.ts create mode 100644 x-pack/plugins/infra/public/containers/logs/log_analysis/modules/log_entry_categories/index.ts rename x-pack/plugins/infra/public/{pages/logs => containers/logs/log_analysis/modules}/log_entry_categories/module_descriptor.ts (77%) rename x-pack/plugins/infra/public/{pages/logs => containers/logs/log_analysis/modules}/log_entry_categories/use_log_entry_categories_module.tsx (88%) rename x-pack/plugins/infra/public/{pages/logs => containers/logs/log_analysis/modules}/log_entry_categories/use_log_entry_categories_quality.ts (92%) rename x-pack/plugins/infra/public/{pages/logs => containers/logs/log_analysis/modules}/log_entry_categories/use_log_entry_categories_setup.tsx (92%) create mode 100644 x-pack/plugins/infra/public/containers/logs/log_analysis/modules/log_entry_rate/index.ts rename x-pack/plugins/infra/public/{pages/logs => containers/logs/log_analysis/modules}/log_entry_rate/module_descriptor.ts (76%) rename x-pack/plugins/infra/public/{pages/logs => containers/logs/log_analysis/modules}/log_entry_rate/use_log_entry_rate_module.tsx (86%) rename x-pack/plugins/infra/public/{pages/logs => containers/logs/log_analysis/modules}/log_entry_rate/use_log_entry_rate_setup.tsx (82%) delete mode 100644 x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/notices/quality_warnings.tsx diff --git a/x-pack/plugins/infra/common/log_analysis/log_analysis.ts b/x-pack/plugins/infra/common/log_analysis/log_analysis.ts index b8fba7a14e243..680a2a0fef114 100644 --- a/x-pack/plugins/infra/common/log_analysis/log_analysis.ts +++ b/x-pack/plugins/infra/common/log_analysis/log_analysis.ts @@ -14,18 +14,10 @@ export type JobStatus = | 'finished' | 'failed'; -export type SetupStatusRequiredReason = - | 'missing' // jobs are missing - | 'reconfiguration' // the configurations don't match the source configurations - | 'update'; // the definitions don't match the module definitions - export type SetupStatus = | { type: 'initializing' } // acquiring job statuses to determine setup status | { type: 'unknown' } // job status could not be acquired (failed request etc) - | { - type: 'required'; - reason: SetupStatusRequiredReason; - } // setup required + | { type: 'required' } // setup required | { type: 'pending' } // In the process of setting up the module for the first time or retrying, waiting for response | { type: 'succeeded' } // setup succeeded, notifying user | { diff --git a/x-pack/plugins/infra/public/components/logging/log_analysis_job_status/index.ts b/x-pack/plugins/infra/public/components/logging/log_analysis_job_status/index.ts index e954cf21229ee..afad55dd22d43 100644 --- a/x-pack/plugins/infra/public/components/logging/log_analysis_job_status/index.ts +++ b/x-pack/plugins/infra/public/components/logging/log_analysis_job_status/index.ts @@ -5,4 +5,5 @@ */ export * from './log_analysis_job_problem_indicator'; +export * from './notices_section'; export * from './recreate_job_button'; diff --git a/x-pack/plugins/infra/public/components/logging/log_analysis_job_status/job_configuration_outdated_callout.tsx b/x-pack/plugins/infra/public/components/logging/log_analysis_job_status/job_configuration_outdated_callout.tsx index 13b7d1927f676..a8a7ec4f5f44f 100644 --- a/x-pack/plugins/infra/public/components/logging/log_analysis_job_status/job_configuration_outdated_callout.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_analysis_job_status/job_configuration_outdated_callout.tsx @@ -11,19 +11,24 @@ import React from 'react'; import { RecreateJobCallout } from './recreate_job_callout'; export const JobConfigurationOutdatedCallout: React.FC<{ + moduleName: string; onRecreateMlJob: () => void; -}> = ({ onRecreateMlJob }) => ( - +}> = ({ moduleName, onRecreateMlJob }) => ( + ); - -const jobConfigurationOutdatedTitle = i18n.translate( - 'xpack.infra.logs.analysis.jobConfigurationOutdatedCalloutTitle', - { - defaultMessage: 'ML job configuration outdated', - } -); diff --git a/x-pack/plugins/infra/public/components/logging/log_analysis_job_status/job_definition_outdated_callout.tsx b/x-pack/plugins/infra/public/components/logging/log_analysis_job_status/job_definition_outdated_callout.tsx index 5072fb09cdceb..7d876b91fc6b5 100644 --- a/x-pack/plugins/infra/public/components/logging/log_analysis_job_status/job_definition_outdated_callout.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_analysis_job_status/job_definition_outdated_callout.tsx @@ -11,19 +11,24 @@ import React from 'react'; import { RecreateJobCallout } from './recreate_job_callout'; export const JobDefinitionOutdatedCallout: React.FC<{ + moduleName: string; onRecreateMlJob: () => void; -}> = ({ onRecreateMlJob }) => ( - +}> = ({ moduleName, onRecreateMlJob }) => ( + ); - -const jobDefinitionOutdatedTitle = i18n.translate( - 'xpack.infra.logs.analysis.jobDefinitionOutdatedCalloutTitle', - { - defaultMessage: 'ML job definition outdated', - } -); diff --git a/x-pack/plugins/infra/public/components/logging/log_analysis_job_status/log_analysis_job_problem_indicator.tsx b/x-pack/plugins/infra/public/components/logging/log_analysis_job_status/log_analysis_job_problem_indicator.tsx index e7e89bb365e4f..9cdf4a667d140 100644 --- a/x-pack/plugins/infra/public/components/logging/log_analysis_job_status/log_analysis_job_problem_indicator.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_analysis_job_status/log_analysis_job_problem_indicator.tsx @@ -16,6 +16,7 @@ export const LogAnalysisJobProblemIndicator: React.FC<{ hasOutdatedJobDefinitions: boolean; hasStoppedJobs: boolean; isFirstUse: boolean; + moduleName: string; onRecreateMlJobForReconfiguration: () => void; onRecreateMlJobForUpdate: () => void; }> = ({ @@ -23,16 +24,23 @@ export const LogAnalysisJobProblemIndicator: React.FC<{ hasOutdatedJobDefinitions, hasStoppedJobs, isFirstUse, + moduleName, onRecreateMlJobForReconfiguration, onRecreateMlJobForUpdate, }) => { return ( <> {hasOutdatedJobDefinitions ? ( - + ) : null} {hasOutdatedJobConfigurations ? ( - + ) : null} {hasStoppedJobs ? : null} {isFirstUse ? : null} diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/notices/notices_section.tsx b/x-pack/plugins/infra/public/components/logging/log_analysis_job_status/notices_section.tsx similarity index 83% rename from x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/notices/notices_section.tsx rename to x-pack/plugins/infra/public/components/logging/log_analysis_job_status/notices_section.tsx index 8f44b5b54c48f..aa72281b9fbdb 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/notices/notices_section.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_analysis_job_status/notices_section.tsx @@ -5,8 +5,8 @@ */ import React from 'react'; -import { LogAnalysisJobProblemIndicator } from '../../../../../components/logging/log_analysis_job_status'; -import { QualityWarning } from './quality_warnings'; +import { QualityWarning } from '../../../containers/logs/log_analysis/log_analysis_module_types'; +import { LogAnalysisJobProblemIndicator } from './log_analysis_job_problem_indicator'; import { CategoryQualityWarnings } from './quality_warning_notices'; export const CategoryJobNoticesSection: React.FC<{ @@ -14,6 +14,7 @@ export const CategoryJobNoticesSection: React.FC<{ hasOutdatedJobDefinitions: boolean; hasStoppedJobs: boolean; isFirstUse: boolean; + moduleName: string; onRecreateMlJobForReconfiguration: () => void; onRecreateMlJobForUpdate: () => void; qualityWarnings: QualityWarning[]; @@ -22,6 +23,7 @@ export const CategoryJobNoticesSection: React.FC<{ hasOutdatedJobDefinitions, hasStoppedJobs, isFirstUse, + moduleName, onRecreateMlJobForReconfiguration, onRecreateMlJobForUpdate, qualityWarnings, @@ -32,6 +34,7 @@ export const CategoryJobNoticesSection: React.FC<{ hasOutdatedJobDefinitions={hasOutdatedJobDefinitions} hasStoppedJobs={hasStoppedJobs} isFirstUse={isFirstUse} + moduleName={moduleName} onRecreateMlJobForReconfiguration={onRecreateMlJobForReconfiguration} onRecreateMlJobForUpdate={onRecreateMlJobForUpdate} /> diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/notices/quality_warning_notices.tsx b/x-pack/plugins/infra/public/components/logging/log_analysis_job_status/quality_warning_notices.tsx similarity index 96% rename from x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/notices/quality_warning_notices.tsx rename to x-pack/plugins/infra/public/components/logging/log_analysis_job_status/quality_warning_notices.tsx index 73b6b88db873a..0d93ead5a82c6 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/notices/quality_warning_notices.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_analysis_job_status/quality_warning_notices.tsx @@ -8,7 +8,10 @@ import { EuiCallOut } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import React from 'react'; -import { CategoryQualityWarningReason, QualityWarning } from './quality_warnings'; +import type { + CategoryQualityWarningReason, + QualityWarning, +} from '../../../containers/logs/log_analysis/log_analysis_module_types'; export const CategoryQualityWarnings: React.FC<{ qualityWarnings: QualityWarning[] }> = ({ qualityWarnings, diff --git a/x-pack/plugins/infra/public/components/logging/log_analysis_setup/initial_configuration_step/initial_configuration_step.tsx b/x-pack/plugins/infra/public/components/logging/log_analysis_setup/initial_configuration_step/initial_configuration_step.tsx index c9b14a1ffe47a..d4c3c727bd34e 100644 --- a/x-pack/plugins/infra/public/components/logging/log_analysis_setup/initial_configuration_step/initial_configuration_step.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_analysis_setup/initial_configuration_step/initial_configuration_step.tsx @@ -84,7 +84,7 @@ export const InitialConfigurationStep: React.FunctionComponent> = (props) => ( + + + +); diff --git a/x-pack/plugins/infra/public/components/logging/log_analysis_setup/process_step/process_step.tsx b/x-pack/plugins/infra/public/components/logging/log_analysis_setup/process_step/process_step.tsx index 3fa72fe8a07e7..a9c94b5983803 100644 --- a/x-pack/plugins/infra/public/components/logging/log_analysis_setup/process_step/process_step.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_analysis_setup/process_step/process_step.tsx @@ -101,11 +101,10 @@ export const ProcessStep: React.FunctionComponent = ({ /> - ) : setupStatus.type === 'required' && - (setupStatus.reason === 'update' || setupStatus.reason === 'reconfiguration') ? ( - - ) : ( + ) : setupStatus.type === 'required' ? ( + ) : ( + )} ); diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/notices/index.ts b/x-pack/plugins/infra/public/components/logging/log_analysis_setup/setup_flyout/index.tsx similarity index 77% rename from x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/notices/index.ts rename to x-pack/plugins/infra/public/components/logging/log_analysis_setup/setup_flyout/index.tsx index 41bc2aa258807..881996073871e 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/sections/notices/index.ts +++ b/x-pack/plugins/infra/public/components/logging/log_analysis_setup/setup_flyout/index.tsx @@ -3,3 +3,6 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ + +export * from './setup_flyout'; +export * from './setup_flyout_state'; diff --git a/x-pack/plugins/infra/public/components/logging/log_analysis_setup/setup_flyout/log_entry_categories_setup_view.tsx b/x-pack/plugins/infra/public/components/logging/log_analysis_setup/setup_flyout/log_entry_categories_setup_view.tsx new file mode 100644 index 0000000000000..2bc5b08a1016a --- /dev/null +++ b/x-pack/plugins/infra/public/components/logging/log_analysis_setup/setup_flyout/log_entry_categories_setup_view.tsx @@ -0,0 +1,87 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiSpacer, EuiSteps, EuiText, EuiTitle } from '@elastic/eui'; +import React, { useCallback, useMemo } from 'react'; +import { useLogEntryCategoriesSetup } from '../../../../containers/logs/log_analysis/modules/log_entry_categories'; +import { createInitialConfigurationStep } from '../initial_configuration_step'; +import { createProcessStep } from '../process_step'; + +export const LogEntryCategoriesSetupView: React.FC<{ + onClose: () => void; +}> = ({ onClose }) => { + const { + cleanUpAndSetUp, + endTime, + isValidating, + lastSetupErrorMessages, + moduleDescriptor, + setEndTime, + setStartTime, + setValidatedIndices, + setUp, + setupStatus, + startTime, + validatedIndices, + validationErrors, + viewResults, + } = useLogEntryCategoriesSetup(); + + const viewResultsAndClose = useCallback(() => { + viewResults(); + onClose(); + }, [viewResults, onClose]); + + const steps = useMemo( + () => [ + createInitialConfigurationStep({ + setStartTime, + setEndTime, + startTime, + endTime, + isValidating, + validatedIndices, + setupStatus, + setValidatedIndices, + validationErrors, + }), + createProcessStep({ + cleanUpAndSetUp, + errorMessages: lastSetupErrorMessages, + isConfigurationValid: validationErrors.length <= 0 && !isValidating, + setUp, + setupStatus, + viewResults: viewResultsAndClose, + }), + ], + [ + cleanUpAndSetUp, + endTime, + isValidating, + lastSetupErrorMessages, + setEndTime, + setStartTime, + setUp, + setValidatedIndices, + setupStatus, + startTime, + validatedIndices, + validationErrors, + viewResultsAndClose, + ] + ); + + return ( + <> + +

{moduleDescriptor.moduleName}

+
+ {moduleDescriptor.moduleDescription} + + + + ); +}; diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/setup_flyout.tsx b/x-pack/plugins/infra/public/components/logging/log_analysis_setup/setup_flyout/log_entry_rate_setup_view.tsx similarity index 50% rename from x-pack/plugins/infra/public/pages/logs/log_entry_rate/setup_flyout.tsx rename to x-pack/plugins/infra/public/components/logging/log_analysis_setup/setup_flyout/log_entry_rate_setup_view.tsx index 0e9e34432f28b..0b7037e60de0b 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/setup_flyout.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_analysis_setup/setup_flyout/log_entry_rate_setup_view.tsx @@ -5,37 +5,20 @@ */ import React, { useMemo, useCallback } from 'react'; -import { FormattedMessage } from '@kbn/i18n/react'; -import { - EuiFlyout, - EuiFlyoutHeader, - EuiFlyoutBody, - EuiTitle, - EuiText, - EuiSpacer, - EuiSteps, -} from '@elastic/eui'; +import { EuiTitle, EuiText, EuiSpacer, EuiSteps } from '@elastic/eui'; +import { createInitialConfigurationStep } from '../initial_configuration_step'; +import { createProcessStep } from '../process_step'; +import { useLogEntryRateSetup } from '../../../../containers/logs/log_analysis/modules/log_entry_rate'; -import { - createInitialConfigurationStep, - createProcessStep, -} from '../../../components/logging/log_analysis_setup'; -import { useLogEntryRateSetup } from './use_log_entry_rate_setup'; - -interface LogEntryRateSetupFlyoutProps { - isOpen: boolean; +export const LogEntryRateSetupView: React.FC<{ onClose: () => void; -} - -export const LogEntryRateSetupFlyout: React.FC = ({ - isOpen, - onClose, -}) => { +}> = ({ onClose }) => { const { cleanUpAndSetUp, endTime, isValidating, lastSetupErrorMessages, + moduleDescriptor, setEndTime, setStartTime, setValidatedIndices, @@ -91,39 +74,14 @@ export const LogEntryRateSetupFlyout: React.FC = ( ] ); - if (!isOpen) { - return null; - } return ( - - - -

- -

-
-
- - -

- -

-
- - - - - -
-
+ <> + +

{moduleDescriptor.moduleName}

+
+ {moduleDescriptor.moduleDescription} + + + ); }; diff --git a/x-pack/plugins/infra/public/components/logging/log_analysis_setup/setup_flyout/module_list.tsx b/x-pack/plugins/infra/public/components/logging/log_analysis_setup/setup_flyout/module_list.tsx new file mode 100644 index 0000000000000..8239ab4a730ff --- /dev/null +++ b/x-pack/plugins/infra/public/components/logging/log_analysis_setup/setup_flyout/module_list.tsx @@ -0,0 +1,55 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import React, { useCallback } from 'react'; +import { + logEntryCategoriesModule, + useLogEntryCategoriesModuleContext, +} from '../../../../containers/logs/log_analysis/modules/log_entry_categories'; +import { + logEntryRateModule, + useLogEntryRateModuleContext, +} from '../../../../containers/logs/log_analysis/modules/log_entry_rate'; +import { LogAnalysisModuleListCard } from './module_list_card'; +import type { ModuleId } from './setup_flyout_state'; + +export const LogAnalysisModuleList: React.FC<{ + onViewModuleSetup: (module: ModuleId) => void; +}> = ({ onViewModuleSetup }) => { + const { setupStatus: logEntryRateSetupStatus } = useLogEntryRateModuleContext(); + const { setupStatus: logEntryCategoriesSetupStatus } = useLogEntryCategoriesModuleContext(); + + const viewLogEntryRateSetupFlyout = useCallback(() => { + onViewModuleSetup('logs_ui_analysis'); + }, [onViewModuleSetup]); + const viewLogEntryCategoriesSetupFlyout = useCallback(() => { + onViewModuleSetup('logs_ui_categories'); + }, [onViewModuleSetup]); + + return ( + <> + + + + + + + + + + ); +}; diff --git a/x-pack/plugins/infra/public/components/logging/log_analysis_setup/setup_flyout/module_list_card.tsx b/x-pack/plugins/infra/public/components/logging/log_analysis_setup/setup_flyout/module_list_card.tsx new file mode 100644 index 0000000000000..17806dbe93797 --- /dev/null +++ b/x-pack/plugins/infra/public/components/logging/log_analysis_setup/setup_flyout/module_list_card.tsx @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiCard, EuiIcon } from '@elastic/eui'; +import React from 'react'; +import { EuiButton } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { RecreateJobButton } from '../../log_analysis_job_status'; +import { SetupStatus } from '../../../../../common/log_analysis'; + +export const LogAnalysisModuleListCard: React.FC<{ + moduleDescription: string; + moduleName: string; + moduleStatus: SetupStatus; + onViewSetup: () => void; +}> = ({ moduleDescription, moduleName, moduleStatus, onViewSetup }) => { + const icon = + moduleStatus.type === 'required' ? ( + + ) : ( + + ); + const footerContent = + moduleStatus.type === 'required' ? ( + + + + ) : ( + + ); + + return ( + {footerContent}
} + icon={icon} + title={moduleName} + /> + ); +}; diff --git a/x-pack/plugins/infra/public/components/logging/log_analysis_setup/setup_flyout/setup_flyout.tsx b/x-pack/plugins/infra/public/components/logging/log_analysis_setup/setup_flyout/setup_flyout.tsx new file mode 100644 index 0000000000000..8e00254431438 --- /dev/null +++ b/x-pack/plugins/infra/public/components/logging/log_analysis_setup/setup_flyout/setup_flyout.tsx @@ -0,0 +1,80 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + EuiButtonEmpty, + EuiFlexGroup, + EuiFlexItem, + EuiFlyout, + EuiFlyoutBody, + EuiFlyoutHeader, + EuiTitle, +} from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import React from 'react'; +import { LogEntryRateSetupView } from './log_entry_rate_setup_view'; +import { LogEntryCategoriesSetupView } from './log_entry_categories_setup_view'; +import { LogAnalysisModuleList } from './module_list'; +import { useLogAnalysisSetupFlyoutStateContext } from './setup_flyout_state'; + +const FLYOUT_HEADING_ID = 'logAnalysisSetupFlyoutHeading'; + +export const LogAnalysisSetupFlyout: React.FC = () => { + const { + closeFlyout, + flyoutView, + showModuleList, + showModuleSetup, + } = useLogAnalysisSetupFlyoutStateContext(); + + if (flyoutView.view === 'hidden') { + return null; + } + + return ( + + + +

+ +

+
+
+ + {flyoutView.view === 'moduleList' ? ( + + ) : flyoutView.view === 'moduleSetup' && flyoutView.module === 'logs_ui_analysis' ? ( + + + + ) : flyoutView.view === 'moduleSetup' && flyoutView.module === 'logs_ui_categories' ? ( + + + + ) : null} + +
+ ); +}; + +const LogAnalysisSetupFlyoutSubPage: React.FC<{ + onViewModuleList: () => void; +}> = ({ children, onViewModuleList }) => ( + + + + + + + {children} + +); diff --git a/x-pack/plugins/infra/public/components/logging/log_analysis_setup/setup_flyout/setup_flyout_state.ts b/x-pack/plugins/infra/public/components/logging/log_analysis_setup/setup_flyout/setup_flyout_state.ts new file mode 100644 index 0000000000000..7a64584df4303 --- /dev/null +++ b/x-pack/plugins/infra/public/components/logging/log_analysis_setup/setup_flyout/setup_flyout_state.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import createContainer from 'constate'; +import { useState, useCallback } from 'react'; + +export type ModuleId = 'logs_ui_analysis' | 'logs_ui_categories'; + +type FlyoutView = + | { view: 'hidden' } + | { view: 'moduleList' } + | { view: 'moduleSetup'; module: ModuleId }; + +export const useLogAnalysisSetupFlyoutState = ({ + initialFlyoutView = { view: 'hidden' }, +}: { + initialFlyoutView?: FlyoutView; +}) => { + const [flyoutView, setFlyoutView] = useState(initialFlyoutView); + + const closeFlyout = useCallback(() => setFlyoutView({ view: 'hidden' }), []); + const showModuleList = useCallback(() => setFlyoutView({ view: 'moduleList' }), []); + const showModuleSetup = useCallback( + (module: ModuleId) => { + setFlyoutView({ view: 'moduleSetup', module }); + }, + [setFlyoutView] + ); + + return { + closeFlyout, + flyoutView, + setFlyoutView, + showModuleList, + showModuleSetup, + }; +}; + +export const [ + LogAnalysisSetupFlyoutStateProvider, + useLogAnalysisSetupFlyoutStateContext, +] = createContainer(useLogAnalysisSetupFlyoutState); diff --git a/x-pack/plugins/infra/public/containers/logs/log_analysis/log_analysis_module.tsx b/x-pack/plugins/infra/public/containers/logs/log_analysis/log_analysis_module.tsx index a70758e3aefd7..79768302a7310 100644 --- a/x-pack/plugins/infra/public/containers/logs/log_analysis/log_analysis_module.tsx +++ b/x-pack/plugins/infra/public/containers/logs/log_analysis/log_analysis_module.tsx @@ -111,14 +111,6 @@ export const useLogAnalysisModule = ({ [cleanUpModule, dispatchModuleStatus, setUpModule] ); - const viewSetupForReconfiguration = useCallback(() => { - dispatchModuleStatus({ type: 'requestedJobConfigurationUpdate' }); - }, [dispatchModuleStatus]); - - const viewSetupForUpdate = useCallback(() => { - dispatchModuleStatus({ type: 'requestedJobDefinitionUpdate' }); - }, [dispatchModuleStatus]); - const viewResults = useCallback(() => { dispatchModuleStatus({ type: 'viewedResults' }); }, [dispatchModuleStatus]); @@ -143,7 +135,5 @@ export const useLogAnalysisModule = ({ setupStatus: moduleStatus.setupStatus, sourceConfiguration, viewResults, - viewSetupForReconfiguration, - viewSetupForUpdate, }; }; diff --git a/x-pack/plugins/infra/public/containers/logs/log_analysis/log_analysis_module_status.tsx b/x-pack/plugins/infra/public/containers/logs/log_analysis/log_analysis_module_status.tsx index a0046b630bfe1..84b5404fe96aa 100644 --- a/x-pack/plugins/infra/public/containers/logs/log_analysis/log_analysis_module_status.tsx +++ b/x-pack/plugins/infra/public/containers/logs/log_analysis/log_analysis_module_status.tsx @@ -43,8 +43,6 @@ type StatusReducerAction = payload: FetchJobStatusResponsePayload; } | { type: 'failedFetchingJobStatuses' } - | { type: 'requestedJobConfigurationUpdate' } - | { type: 'requestedJobDefinitionUpdate' } | { type: 'viewedResults' }; const createInitialState = ({ @@ -173,18 +171,6 @@ const createStatusReducer = (jobTypes: JobType[]) => ( ), }; } - case 'requestedJobConfigurationUpdate': { - return { - ...state, - setupStatus: { type: 'required', reason: 'reconfiguration' }, - }; - } - case 'requestedJobDefinitionUpdate': { - return { - ...state, - setupStatus: { type: 'required', reason: 'update' }, - }; - } case 'viewedResults': { return { ...state, @@ -251,7 +237,7 @@ const getSetupStatus = (everyJobStatus: Record Object.entries(everyJobStatus).reduce((setupStatus, [, jobStatus]) => { if (jobStatus === 'missing') { - return { type: 'required', reason: 'missing' }; + return { type: 'required' }; } else if (setupStatus.type === 'required' || setupStatus.type === 'succeeded') { return setupStatus; } else if (setupStatus.type === 'skipped' || isJobStatusWithResults(jobStatus)) { diff --git a/x-pack/plugins/infra/public/containers/logs/log_analysis/log_analysis_module_types.ts b/x-pack/plugins/infra/public/containers/logs/log_analysis/log_analysis_module_types.ts index cc9ef73019844..4930c8b478a9c 100644 --- a/x-pack/plugins/infra/public/containers/logs/log_analysis/log_analysis_module_types.ts +++ b/x-pack/plugins/infra/public/containers/logs/log_analysis/log_analysis_module_types.ts @@ -4,18 +4,22 @@ * you may not use this file except in compliance with the Elastic License. */ -import { DeleteJobsResponsePayload } from './api/ml_cleanup'; -import { FetchJobStatusResponsePayload } from './api/ml_get_jobs_summary_api'; -import { GetMlModuleResponsePayload } from './api/ml_get_module'; -import { SetupMlModuleResponsePayload } from './api/ml_setup_module_api'; import { - ValidationIndicesResponsePayload, ValidateLogEntryDatasetsResponsePayload, + ValidationIndicesResponsePayload, } from '../../../../common/http_api/log_analysis'; import { DatasetFilter } from '../../../../common/log_analysis'; +import { DeleteJobsResponsePayload } from './api/ml_cleanup'; +import { FetchJobStatusResponsePayload } from './api/ml_get_jobs_summary_api'; +import { GetMlModuleResponsePayload } from './api/ml_get_module'; +import { SetupMlModuleResponsePayload } from './api/ml_setup_module_api'; + +export { JobModelSizeStats, JobSummary } from './api/ml_get_jobs_summary_api'; export interface ModuleDescriptor { moduleId: string; + moduleName: string; + moduleDescription: string; jobTypes: JobType[]; bucketSpan: number; getJobIds: (spaceId: string, sourceId: string) => Record; @@ -46,3 +50,43 @@ export interface ModuleSourceConfiguration { spaceId: string; timestampField: string; } + +interface ManyCategoriesWarningReason { + type: 'manyCategories'; + categoriesDocumentRatio: number; +} + +interface ManyDeadCategoriesWarningReason { + type: 'manyDeadCategories'; + deadCategoriesRatio: number; +} + +interface ManyRareCategoriesWarningReason { + type: 'manyRareCategories'; + rareCategoriesRatio: number; +} + +interface NoFrequentCategoriesWarningReason { + type: 'noFrequentCategories'; +} + +interface SingleCategoryWarningReason { + type: 'singleCategory'; +} + +export type CategoryQualityWarningReason = + | ManyCategoriesWarningReason + | ManyDeadCategoriesWarningReason + | ManyRareCategoriesWarningReason + | NoFrequentCategoriesWarningReason + | SingleCategoryWarningReason; + +export type CategoryQualityWarningReasonType = CategoryQualityWarningReason['type']; + +export interface CategoryQualityWarning { + type: 'categoryQualityWarning'; + jobId: string; + reasons: CategoryQualityWarningReason[]; +} + +export type QualityWarning = CategoryQualityWarning; diff --git a/x-pack/plugins/infra/public/containers/logs/log_analysis/modules/log_entry_categories/index.ts b/x-pack/plugins/infra/public/containers/logs/log_analysis/modules/log_entry_categories/index.ts new file mode 100644 index 0000000000000..63f1025214331 --- /dev/null +++ b/x-pack/plugins/infra/public/containers/logs/log_analysis/modules/log_entry_categories/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export * from './module_descriptor'; +export * from './use_log_entry_categories_module'; +export * from './use_log_entry_categories_quality'; +export * from './use_log_entry_categories_setup'; diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/module_descriptor.ts b/x-pack/plugins/infra/public/containers/logs/log_analysis/modules/log_entry_categories/module_descriptor.ts similarity index 77% rename from x-pack/plugins/infra/public/pages/logs/log_entry_categories/module_descriptor.ts rename to x-pack/plugins/infra/public/containers/logs/log_analysis/modules/log_entry_categories/module_descriptor.ts index 8d9b9130f74a4..9682b3e74db3b 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/module_descriptor.ts +++ b/x-pack/plugins/infra/public/containers/logs/log_analysis/modules/log_entry_categories/module_descriptor.ts @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import { i18n } from '@kbn/i18n'; import { bucketSpan, categoriesMessageField, @@ -12,19 +13,25 @@ import { LogEntryCategoriesJobType, logEntryCategoriesJobTypes, partitionField, -} from '../../../../common/log_analysis'; -import { - cleanUpJobsAndDatafeeds, - ModuleDescriptor, - ModuleSourceConfiguration, -} from '../../../containers/logs/log_analysis'; -import { callJobsSummaryAPI } from '../../../containers/logs/log_analysis/api/ml_get_jobs_summary_api'; -import { callGetMlModuleAPI } from '../../../containers/logs/log_analysis/api/ml_get_module'; -import { callSetupMlModuleAPI } from '../../../containers/logs/log_analysis/api/ml_setup_module_api'; -import { callValidateDatasetsAPI } from '../../../containers/logs/log_analysis/api/validate_datasets'; -import { callValidateIndicesAPI } from '../../../containers/logs/log_analysis/api/validate_indices'; +} from '../../../../../../common/log_analysis'; +import { callJobsSummaryAPI } from '../../api/ml_get_jobs_summary_api'; +import { callGetMlModuleAPI } from '../../api/ml_get_module'; +import { callSetupMlModuleAPI } from '../../api/ml_setup_module_api'; +import { callValidateDatasetsAPI } from '../../api/validate_datasets'; +import { callValidateIndicesAPI } from '../../api/validate_indices'; +import { cleanUpJobsAndDatafeeds } from '../../log_analysis_cleanup'; +import { ModuleDescriptor, ModuleSourceConfiguration } from '../../log_analysis_module_types'; const moduleId = 'logs_ui_categories'; +const moduleName = i18n.translate('xpack.infra.logs.analysis.logEntryCategoriesModuleName', { + defaultMessage: 'Categorization', +}); +const moduleDescription = i18n.translate( + 'xpack.infra.logs.analysis.logEntryCategoriesModuleDescription', + { + defaultMessage: 'Use Machine Learning to automatically categorize log messages.', + } +); const getJobIds = (spaceId: string, sourceId: string) => logEntryCategoriesJobTypes.reduce( @@ -138,6 +145,8 @@ const validateSetupDatasets = async ( export const logEntryCategoriesModule: ModuleDescriptor = { moduleId, + moduleName, + moduleDescription, jobTypes: logEntryCategoriesJobTypes, bucketSpan, getJobIds, diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/use_log_entry_categories_module.tsx b/x-pack/plugins/infra/public/containers/logs/log_analysis/modules/log_entry_categories/use_log_entry_categories_module.tsx similarity index 88% rename from x-pack/plugins/infra/public/pages/logs/log_entry_categories/use_log_entry_categories_module.tsx rename to x-pack/plugins/infra/public/containers/logs/log_analysis/modules/log_entry_categories/use_log_entry_categories_module.tsx index fe832d3fe3a54..0b12d6834d522 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/use_log_entry_categories_module.tsx +++ b/x-pack/plugins/infra/public/containers/logs/log_analysis/modules/log_entry_categories/use_log_entry_categories_module.tsx @@ -6,12 +6,10 @@ import createContainer from 'constate'; import { useMemo } from 'react'; -import { - ModuleSourceConfiguration, - useLogAnalysisModule, - useLogAnalysisModuleConfiguration, - useLogAnalysisModuleDefinition, -} from '../../../containers/logs/log_analysis'; +import { useLogAnalysisModule } from '../../log_analysis_module'; +import { useLogAnalysisModuleConfiguration } from '../../log_analysis_module_configuration'; +import { useLogAnalysisModuleDefinition } from '../../log_analysis_module_definition'; +import { ModuleSourceConfiguration } from '../../log_analysis_module_types'; import { logEntryCategoriesModule } from './module_descriptor'; import { useLogEntryCategoriesQuality } from './use_log_entry_categories_quality'; diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/use_log_entry_categories_quality.ts b/x-pack/plugins/infra/public/containers/logs/log_analysis/modules/log_entry_categories/use_log_entry_categories_quality.ts similarity index 92% rename from x-pack/plugins/infra/public/pages/logs/log_entry_categories/use_log_entry_categories_quality.ts rename to x-pack/plugins/infra/public/containers/logs/log_analysis/modules/log_entry_categories/use_log_entry_categories_quality.ts index 51e049d576235..346281fa94e1b 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/use_log_entry_categories_quality.ts +++ b/x-pack/plugins/infra/public/containers/logs/log_analysis/modules/log_entry_categories/use_log_entry_categories_quality.ts @@ -5,9 +5,12 @@ */ import { useMemo } from 'react'; - -import { JobModelSizeStats, JobSummary } from '../../../containers/logs/log_analysis'; -import { QualityWarning, CategoryQualityWarningReason } from './sections/notices/quality_warnings'; +import { + JobModelSizeStats, + JobSummary, + QualityWarning, + CategoryQualityWarningReason, +} from '../../log_analysis_module_types'; export const useLogEntryCategoriesQuality = ({ jobSummaries }: { jobSummaries: JobSummary[] }) => { const categoryQualityWarnings: QualityWarning[] = useMemo( diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/use_log_entry_categories_setup.tsx b/x-pack/plugins/infra/public/containers/logs/log_analysis/modules/log_entry_categories/use_log_entry_categories_setup.tsx similarity index 92% rename from x-pack/plugins/infra/public/pages/logs/log_entry_categories/use_log_entry_categories_setup.tsx rename to x-pack/plugins/infra/public/containers/logs/log_analysis/modules/log_entry_categories/use_log_entry_categories_setup.tsx index c011230942d7c..399c30cf47e71 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/use_log_entry_categories_setup.tsx +++ b/x-pack/plugins/infra/public/containers/logs/log_analysis/modules/log_entry_categories/use_log_entry_categories_setup.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { useAnalysisSetupState } from '../../../containers/logs/log_analysis'; +import { useAnalysisSetupState } from '../../log_analysis_setup_state'; import { useLogEntryCategoriesModuleContext } from './use_log_entry_categories_module'; export const useLogEntryCategoriesSetup = () => { @@ -41,6 +41,7 @@ export const useLogEntryCategoriesSetup = () => { endTime, isValidating, lastSetupErrorMessages, + moduleDescriptor, setEndTime, setStartTime, setValidatedIndices, diff --git a/x-pack/plugins/infra/public/containers/logs/log_analysis/modules/log_entry_rate/index.ts b/x-pack/plugins/infra/public/containers/logs/log_analysis/modules/log_entry_rate/index.ts new file mode 100644 index 0000000000000..7fc1e4558961a --- /dev/null +++ b/x-pack/plugins/infra/public/containers/logs/log_analysis/modules/log_entry_rate/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export * from './module_descriptor'; +export * from './use_log_entry_rate_module'; +export * from './use_log_entry_rate_setup'; diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/module_descriptor.ts b/x-pack/plugins/infra/public/containers/logs/log_analysis/modules/log_entry_rate/module_descriptor.ts similarity index 76% rename from x-pack/plugins/infra/public/pages/logs/log_entry_rate/module_descriptor.ts rename to x-pack/plugins/infra/public/containers/logs/log_analysis/modules/log_entry_rate/module_descriptor.ts index 6ca306f39e947..001174a2b7558 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/module_descriptor.ts +++ b/x-pack/plugins/infra/public/containers/logs/log_analysis/modules/log_entry_rate/module_descriptor.ts @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import { i18n } from '@kbn/i18n'; import { bucketSpan, DatasetFilter, @@ -11,19 +12,25 @@ import { LogEntryRateJobType, logEntryRateJobTypes, partitionField, -} from '../../../../common/log_analysis'; -import { - cleanUpJobsAndDatafeeds, - ModuleDescriptor, - ModuleSourceConfiguration, -} from '../../../containers/logs/log_analysis'; -import { callJobsSummaryAPI } from '../../../containers/logs/log_analysis/api/ml_get_jobs_summary_api'; -import { callGetMlModuleAPI } from '../../../containers/logs/log_analysis/api/ml_get_module'; -import { callSetupMlModuleAPI } from '../../../containers/logs/log_analysis/api/ml_setup_module_api'; -import { callValidateDatasetsAPI } from '../../../containers/logs/log_analysis/api/validate_datasets'; -import { callValidateIndicesAPI } from '../../../containers/logs/log_analysis/api/validate_indices'; +} from '../../../../../../common/log_analysis'; +import { ModuleDescriptor, ModuleSourceConfiguration } from '../../log_analysis_module_types'; +import { cleanUpJobsAndDatafeeds } from '../../log_analysis_cleanup'; +import { callJobsSummaryAPI } from '../../api/ml_get_jobs_summary_api'; +import { callGetMlModuleAPI } from '../../api/ml_get_module'; +import { callSetupMlModuleAPI } from '../../api/ml_setup_module_api'; +import { callValidateDatasetsAPI } from '../../api/validate_datasets'; +import { callValidateIndicesAPI } from '../../api/validate_indices'; const moduleId = 'logs_ui_analysis'; +const moduleName = i18n.translate('xpack.infra.logs.analysis.logEntryRateModuleName', { + defaultMessage: 'Log rate', +}); +const moduleDescription = i18n.translate( + 'xpack.infra.logs.analysis.logEntryRateModuleDescription', + { + defaultMessage: 'Use Machine Learning to automatically detect anomalous log entry rates.', + } +); const getJobIds = (spaceId: string, sourceId: string) => logEntryRateJobTypes.reduce( @@ -126,6 +133,8 @@ const validateSetupDatasets = async ( export const logEntryRateModule: ModuleDescriptor = { moduleId, + moduleName, + moduleDescription, jobTypes: logEntryRateJobTypes, bucketSpan, getJobIds, diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/use_log_entry_rate_module.tsx b/x-pack/plugins/infra/public/containers/logs/log_analysis/modules/log_entry_rate/use_log_entry_rate_module.tsx similarity index 86% rename from x-pack/plugins/infra/public/pages/logs/log_entry_rate/use_log_entry_rate_module.tsx rename to x-pack/plugins/infra/public/containers/logs/log_analysis/modules/log_entry_rate/use_log_entry_rate_module.tsx index 07bdb0249cd3d..f9832e2cdd7ec 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/use_log_entry_rate_module.tsx +++ b/x-pack/plugins/infra/public/containers/logs/log_analysis/modules/log_entry_rate/use_log_entry_rate_module.tsx @@ -6,12 +6,10 @@ import createContainer from 'constate'; import { useMemo } from 'react'; -import { - ModuleSourceConfiguration, - useLogAnalysisModule, - useLogAnalysisModuleConfiguration, - useLogAnalysisModuleDefinition, -} from '../../../containers/logs/log_analysis'; +import { ModuleSourceConfiguration } from '../../log_analysis_module_types'; +import { useLogAnalysisModule } from '../../log_analysis_module'; +import { useLogAnalysisModuleConfiguration } from '../../log_analysis_module_configuration'; +import { useLogAnalysisModuleDefinition } from '../../log_analysis_module_definition'; import { logEntryRateModule } from './module_descriptor'; export const useLogEntryRateModule = ({ diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/use_log_entry_rate_setup.tsx b/x-pack/plugins/infra/public/containers/logs/log_analysis/modules/log_entry_rate/use_log_entry_rate_setup.tsx similarity index 82% rename from x-pack/plugins/infra/public/pages/logs/log_entry_rate/use_log_entry_rate_setup.tsx rename to x-pack/plugins/infra/public/containers/logs/log_analysis/modules/log_entry_rate/use_log_entry_rate_setup.tsx index 3595b6bf830fc..f67ab1fef823e 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/use_log_entry_rate_setup.tsx +++ b/x-pack/plugins/infra/public/containers/logs/log_analysis/modules/log_entry_rate/use_log_entry_rate_setup.tsx @@ -4,7 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import { useAnalysisSetupState } from '../../../containers/logs/log_analysis'; +import createContainer from 'constate'; +import { useAnalysisSetupState } from '../../log_analysis_setup_state'; import { useLogEntryRateModuleContext } from './use_log_entry_rate_module'; export const useLogEntryRateSetup = () => { @@ -41,6 +42,7 @@ export const useLogEntryRateSetup = () => { endTime, isValidating, lastSetupErrorMessages, + moduleDescriptor, setEndTime, setStartTime, setValidatedIndices, @@ -52,3 +54,7 @@ export const useLogEntryRateSetup = () => { viewResults, }; }; + +export const [LogEntryRateSetupProvider, useLogEntryRateSetupContext] = createContainer( + useLogEntryRateSetup +); diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/page_content.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/page_content.tsx index 26633cd190a07..2880b1b794443 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/page_content.tsx +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/page_content.tsx @@ -5,7 +5,7 @@ */ import { i18n } from '@kbn/i18n'; -import React, { useEffect, useState, useCallback } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import { isJobStatusWithResults } from '../../../../common/log_analysis'; import { LoadingPage } from '../../../components/loading_page'; import { @@ -17,10 +17,10 @@ import { import { SourceErrorPage } from '../../../components/source_error_page'; import { SourceLoadingPage } from '../../../components/source_loading_page'; import { useLogAnalysisCapabilitiesContext } from '../../../containers/logs/log_analysis'; +import { useLogEntryCategoriesModuleContext } from '../../../containers/logs/log_analysis/modules/log_entry_categories'; import { useLogSourceContext } from '../../../containers/logs/log_source'; import { LogEntryCategoriesResultsContent } from './page_results_content'; import { LogEntryCategoriesSetupContent } from './page_setup_content'; -import { useLogEntryCategoriesModuleContext } from './use_log_entry_categories_module'; import { LogEntryCategoriesSetupFlyout } from './setup_flyout'; export const LogEntryCategoriesPageContent = () => { @@ -50,13 +50,6 @@ export const LogEntryCategoriesPageContent = () => { } }, [fetchJobStatus, hasLogAnalysisReadCapabilities]); - // Open flyout if there are no ML jobs - useEffect(() => { - if (setupStatus.type === 'required' && setupStatus.reason === 'missing') { - openFlyout(); - } - }, [setupStatus, openFlyout]); - if (isLoading || isUninitialized) { return ; } else if (hasFailedLoadingSource) { diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/page_providers.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/page_providers.tsx index cecea733b49e4..48ad156714ccf 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/page_providers.tsx +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/page_providers.tsx @@ -5,10 +5,9 @@ */ import React from 'react'; - +import { LogEntryCategoriesModuleProvider } from '../../../containers/logs/log_analysis/modules/log_entry_categories'; import { useLogSourceContext } from '../../../containers/logs/log_source'; import { useKibanaSpaceId } from '../../../utils/use_kibana_space_id'; -import { LogEntryCategoriesModuleProvider } from './use_log_entry_categories_module'; export const LogEntryCategoriesPageProviders: React.FunctionComponent = ({ children }) => { const { sourceId, sourceConfiguration } = useLogSourceContext(); diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/page_results_content.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/page_results_content.tsx index 8ce582df7466e..5e602e1f63862 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/page_results_content.tsx +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/page_results_content.tsx @@ -12,17 +12,17 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; import { euiStyled, useTrackPageview } from '../../../../../observability/public'; import { TimeRange } from '../../../../common/http_api/shared/time_range'; +import { CategoryJobNoticesSection } from '../../../components/logging/log_analysis_job_status'; +import { useLogEntryCategoriesModuleContext } from '../../../containers/logs/log_analysis/modules/log_entry_categories'; +import { ViewLogInContext } from '../../../containers/logs/view_log_in_context'; import { useInterval } from '../../../hooks/use_interval'; -import { CategoryJobNoticesSection } from './sections/notices/notices_section'; +import { PageViewLogInContext } from '../stream/page_view_log_in_context'; import { TopCategoriesSection } from './sections/top_categories'; -import { useLogEntryCategoriesModuleContext } from './use_log_entry_categories_module'; import { useLogEntryCategoriesResults } from './use_log_entry_categories_results'; import { StringTimeRange, useLogEntryCategoriesResultsUrlState, } from './use_log_entry_categories_results_url_state'; -import { PageViewLogInContext } from '../stream/page_view_log_in_context'; -import { ViewLogInContext } from '../../../containers/logs/view_log_in_context'; const JOB_STATUS_POLLING_INTERVAL = 30000; @@ -39,9 +39,8 @@ export const LogEntryCategoriesResultsContent: React.FunctionComponent { - viewSetupForReconfiguration(); - onOpenSetup(); - }, [onOpenSetup, viewSetupForReconfiguration]); - - const viewSetupFlyoutForUpdate = useCallback(() => { - viewSetupForUpdate(); - onOpenSetup(); - }, [onOpenSetup, viewSetupForUpdate]); - const hasResults = useMemo(() => topLogEntryCategories.length > 0, [ topLogEntryCategories.length, ]); @@ -210,8 +199,9 @@ export const LogEntryCategoriesResultsContent: React.FunctionComponent @@ -223,7 +213,7 @@ export const LogEntryCategoriesResultsContent: React.FunctionComponent { +export const LogEntryRatePageContent = memo(() => { const { hasFailedLoadingSource, isLoading, @@ -38,24 +45,52 @@ export const LogEntryRatePageContent = () => { hasLogAnalysisSetupCapabilities, } = useLogAnalysisCapabilitiesContext(); - const { fetchJobStatus, setupStatus, jobStatus } = useLogEntryRateModuleContext(); + const { + fetchJobStatus: fetchLogEntryCategoriesJobStatus, + fetchModuleDefinition: fetchLogEntryCategoriesModuleDefinition, + jobStatus: logEntryCategoriesJobStatus, + setupStatus: logEntryCategoriesSetupStatus, + } = useLogEntryCategoriesModuleContext(); + const { + fetchJobStatus: fetchLogEntryRateJobStatus, + fetchModuleDefinition: fetchLogEntryRateModuleDefinition, + jobStatus: logEntryRateJobStatus, + setupStatus: logEntryRateSetupStatus, + } = useLogEntryRateModuleContext(); - const [isFlyoutOpen, setIsFlyoutOpen] = useState(false); - const openFlyout = useCallback(() => setIsFlyoutOpen(true), []); - const closeFlyout = useCallback(() => setIsFlyoutOpen(false), []); + const { showModuleList } = useLogAnalysisSetupFlyoutStateContext(); + + const fetchAllJobStatuses = useCallback( + () => Promise.all([fetchLogEntryCategoriesJobStatus(), fetchLogEntryRateJobStatus()]), + [fetchLogEntryCategoriesJobStatus, fetchLogEntryRateJobStatus] + ); useEffect(() => { if (hasLogAnalysisReadCapabilities) { - fetchJobStatus(); + fetchAllJobStatuses(); } - }, [fetchJobStatus, hasLogAnalysisReadCapabilities]); + }, [fetchAllJobStatuses, hasLogAnalysisReadCapabilities]); - // Open flyout if there are no ML jobs useEffect(() => { - if (setupStatus.type === 'required' && setupStatus.reason === 'missing') { - openFlyout(); + if (hasLogAnalysisReadCapabilities) { + fetchLogEntryCategoriesModuleDefinition(); + } + }, [fetchLogEntryCategoriesModuleDefinition, hasLogAnalysisReadCapabilities]); + + useEffect(() => { + if (hasLogAnalysisReadCapabilities) { + fetchLogEntryRateModuleDefinition(); + } + }, [fetchLogEntryRateModuleDefinition, hasLogAnalysisReadCapabilities]); + + useInterval(() => { + if (logEntryCategoriesSetupStatus.type !== 'pending' && hasLogAnalysisReadCapabilities) { + fetchLogEntryCategoriesJobStatus(); + } + if (logEntryRateSetupStatus.type !== 'pending' && hasLogAnalysisReadCapabilities) { + fetchLogEntryRateJobStatus(); } - }, [setupStatus, openFlyout]); + }, JOB_STATUS_POLLING_INTERVAL); if (isLoading || isUninitialized) { return ; @@ -65,7 +100,10 @@ export const LogEntryRatePageContent = () => { return ; } else if (!hasLogAnalysisReadCapabilities) { return ; - } else if (setupStatus.type === 'initializing') { + } else if ( + logEntryCategoriesSetupStatus.type === 'initializing' || + logEntryRateSetupStatus.type === 'initializing' + ) { return ( { })} /> ); - } else if (setupStatus.type === 'unknown') { - return ; - } else if (isJobStatusWithResults(jobStatus['log-entry-rate'])) { + } else if ( + logEntryCategoriesSetupStatus.type === 'unknown' || + logEntryRateSetupStatus.type === 'unknown' + ) { + return ; + } else if ( + isJobStatusWithResults(logEntryCategoriesJobStatus['log-entry-categories-count']) || + isJobStatusWithResults(logEntryRateJobStatus['log-entry-rate']) + ) { return ( <> - - + + ); } else if (!hasLogAnalysisSetupCapabilities) { @@ -87,9 +131,9 @@ export const LogEntryRatePageContent = () => { } else { return ( <> - - + + ); } -}; +}); diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/page_providers.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/page_providers.tsx index e91ef87bdf34a..ac11260d2075d 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/page_providers.tsx +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/page_providers.tsx @@ -5,10 +5,11 @@ */ import React from 'react'; - +import { LogAnalysisSetupFlyoutStateProvider } from '../../../components/logging/log_analysis_setup/setup_flyout'; +import { LogEntryCategoriesModuleProvider } from '../../../containers/logs/log_analysis/modules/log_entry_categories'; +import { LogEntryRateModuleProvider } from '../../../containers/logs/log_analysis/modules/log_entry_rate'; import { useLogSourceContext } from '../../../containers/logs/log_source'; import { useKibanaSpaceId } from '../../../utils/use_kibana_space_id'; -import { LogEntryRateModuleProvider } from './use_log_entry_rate_module'; export const LogEntryRatePageProviders: React.FunctionComponent = ({ children }) => { const { sourceId, sourceConfiguration } = useLogSourceContext(); @@ -21,7 +22,14 @@ export const LogEntryRatePageProviders: React.FunctionComponent = ({ children }) spaceId={spaceId} timestampField={sourceConfiguration?.configuration.fields.timestamp ?? ''} > - {children} + + {children} + ); }; diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/page_results_content.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/page_results_content.tsx index 21c3e3ec70029..f2a60541b3b3c 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/page_results_content.tsx +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/page_results_content.tsx @@ -11,19 +11,23 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { euiStyled, useTrackPageview } from '../../../../../observability/public'; import { TimeRange } from '../../../../common/http_api/shared/time_range'; import { bucketSpan } from '../../../../common/log_analysis'; -import { LogAnalysisJobProblemIndicator } from '../../../components/logging/log_analysis_job_status'; +import { + CategoryJobNoticesSection, + LogAnalysisJobProblemIndicator, +} from '../../../components/logging/log_analysis_job_status'; +import { useLogAnalysisSetupFlyoutStateContext } from '../../../components/logging/log_analysis_setup/setup_flyout'; +import { useLogEntryCategoriesModuleContext } from '../../../containers/logs/log_analysis/modules/log_entry_categories'; +import { useLogEntryRateModuleContext } from '../../../containers/logs/log_analysis/modules/log_entry_rate'; +import { useLogSourceContext } from '../../../containers/logs/log_source'; import { useInterval } from '../../../hooks/use_interval'; import { AnomaliesResults } from './sections/anomalies'; -import { useLogEntryRateModuleContext } from './use_log_entry_rate_module'; -import { useLogEntryRateResults } from './use_log_entry_rate_results'; import { useLogEntryAnomaliesResults } from './use_log_entry_anomalies_results'; +import { useLogEntryRateResults } from './use_log_entry_rate_results'; import { StringTimeRange, useLogAnalysisResultsUrlState, } from './use_log_entry_rate_results_url_state'; -const JOB_STATUS_POLLING_INTERVAL = 30000; - export const SORT_DEFAULTS = { direction: 'desc' as const, field: 'anomalyScore' as const, @@ -33,28 +37,29 @@ export const PAGINATION_DEFAULTS = { pageSize: 25, }; -interface LogEntryRateResultsContentProps { - onOpenSetup: () => void; -} - -export const LogEntryRateResultsContent: React.FunctionComponent = ({ - onOpenSetup, -}) => { +export const LogEntryRateResultsContent: React.FunctionComponent = () => { useTrackPageview({ app: 'infra_logs', path: 'log_entry_rate_results' }); useTrackPageview({ app: 'infra_logs', path: 'log_entry_rate_results', delay: 15000 }); + const { sourceId } = useLogSourceContext(); + const { - fetchJobStatus, - fetchModuleDefinition, - setupStatus, - viewSetupForReconfiguration, - viewSetupForUpdate, - hasOutdatedJobConfigurations, - hasOutdatedJobDefinitions, - hasStoppedJobs, - sourceConfiguration: { sourceId }, + hasOutdatedJobConfigurations: hasOutdatedLogEntryRateJobConfigurations, + hasOutdatedJobDefinitions: hasOutdatedLogEntryRateJobDefinitions, + hasStoppedJobs: hasStoppedLogEntryRateJobs, + moduleDescriptor: logEntryRateModuleDescriptor, + setupStatus: logEntryRateSetupStatus, } = useLogEntryRateModuleContext(); + const { + categoryQualityWarnings, + hasOutdatedJobConfigurations: hasOutdatedLogEntryCategoriesJobConfigurations, + hasOutdatedJobDefinitions: hasOutdatedLogEntryCategoriesJobDefinitions, + hasStoppedJobs: hasStoppedLogEntryCategoriesJobs, + moduleDescriptor: logEntryCategoriesModuleDescriptor, + setupStatus: logEntryCategoriesSetupStatus, + } = useLogEntryCategoriesModuleContext(); + const { timeRange: selectedTimeRange, setTimeRange: setSelectedTimeRange, @@ -145,41 +150,33 @@ export const LogEntryRateResultsContent: React.FunctionComponent { - viewSetupForReconfiguration(); - onOpenSetup(); - }, [viewSetupForReconfiguration, onOpenSetup]); + const { showModuleList, showModuleSetup } = useLogAnalysisSetupFlyoutStateContext(); - const viewSetupFlyoutForUpdate = useCallback(() => { - viewSetupForUpdate(); - onOpenSetup(); - }, [viewSetupForUpdate, onOpenSetup]); - - /* eslint-disable-next-line react-hooks/exhaustive-deps */ - const hasResults = useMemo(() => (logEntryRate?.histogramBuckets?.length ?? 0) > 0, [ - logEntryRate, + const showLogEntryRateSetup = useCallback(() => showModuleSetup('logs_ui_analysis'), [ + showModuleSetup, + ]); + const showLogEntryCategoriesSetup = useCallback(() => showModuleSetup('logs_ui_categories'), [ + showModuleSetup, ]); + const hasLogRateResults = (logEntryRate?.histogramBuckets?.length ?? 0) > 0; + const hasAnomalyResults = logEntryAnomalies.length > 0; + const isFirstUse = useMemo( () => - ((setupStatus.type === 'skipped' && !!setupStatus.newlyCreated) || - setupStatus.type === 'succeeded') && - !hasResults, - [hasResults, setupStatus] + ((logEntryCategoriesSetupStatus.type === 'skipped' && + !!logEntryCategoriesSetupStatus.newlyCreated) || + logEntryCategoriesSetupStatus.type === 'succeeded' || + (logEntryRateSetupStatus.type === 'skipped' && !!logEntryRateSetupStatus.newlyCreated) || + logEntryRateSetupStatus.type === 'succeeded') && + !(hasLogRateResults || hasAnomalyResults), + [hasAnomalyResults, hasLogRateResults, logEntryCategoriesSetupStatus, logEntryRateSetupStatus] ); useEffect(() => { getLogEntryRate(); }, [getLogEntryRate, queryTimeRange.lastChangedTime]); - useEffect(() => { - fetchModuleDefinition(); - }, [fetchModuleDefinition]); - - useInterval(() => { - fetchJobStatus(); - }, JOB_STATUS_POLLING_INTERVAL); - useInterval( () => { handleQueryTimeRangeChange({ @@ -209,12 +206,23 @@ export const LogEntryRateResultsContent: React.FunctionComponent + @@ -222,7 +230,7 @@ export const LogEntryRateResultsContent: React.FunctionComponent void; timeRange: TimeRange; - viewSetupForReconfiguration: () => void; + onViewModuleList: () => void; page: Page; fetchNextPage?: FetchNextPage; fetchPreviousPage?: FetchPreviousPage; @@ -54,7 +54,7 @@ export const AnomaliesResults: React.FunctionComponent<{ logEntryRateResults, setTimeRange, timeRange, - viewSetupForReconfiguration, + onViewModuleList, anomalies, changeSortOptions, sortOptions, @@ -93,7 +93,7 @@ export const AnomaliesResults: React.FunctionComponent<{ - + diff --git a/x-pack/plugins/infra/public/pages/logs/page_content.tsx b/x-pack/plugins/infra/public/pages/logs/page_content.tsx index c5047dbdf3bb5..426ae8e9d05a8 100644 --- a/x-pack/plugins/infra/public/pages/logs/page_content.tsx +++ b/x-pack/plugins/infra/public/pages/logs/page_content.tsx @@ -42,10 +42,10 @@ export const LogsPageContent: React.FunctionComponent = () => { pathname: '/stream', }; - const logRateTab = { + const anomaliesTab = { app: 'logs', - title: logRateTabTitle, - pathname: '/log-rate', + title: anomaliesTabTitle, + pathname: '/anomalies', }; const logCategoriesTab = { @@ -77,7 +77,7 @@ export const LogsPageContent: React.FunctionComponent = () => { - + @@ -96,10 +96,11 @@ export const LogsPageContent: React.FunctionComponent = () => { - + - + + @@ -114,8 +115,8 @@ const streamTabTitle = i18n.translate('xpack.infra.logs.index.streamTabTitle', { defaultMessage: 'Stream', }); -const logRateTabTitle = i18n.translate('xpack.infra.logs.index.logRateBetaBadgeTitle', { - defaultMessage: 'Log Rate', +const anomaliesTabTitle = i18n.translate('xpack.infra.logs.index.anomaliesTabTitle', { + defaultMessage: 'Anomalies', }); const logCategoriesTabTitle = i18n.translate('xpack.infra.logs.index.logCategoriesBetaBadgeTitle', { diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index c1f36372ec94e..cba436f2e8b3b 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -7469,14 +7469,9 @@ "xpack.infra.logs.alerting.threshold.fired": "実行", "xpack.infra.logs.analysis.analyzeInMlButtonLabel": "ML で分析", "xpack.infra.logs.analysis.anomaliesSectionLineSeriesName": "15 分ごとのログエントリー (平均)", - "xpack.infra.logs.analysis.anomaliesSectionLoadingAriaLabel": "異常を読み込み中", "xpack.infra.logs.analysis.anomaliesSectionTitle": "異常", "xpack.infra.logs.analysis.anomalySectionNoDataBody": "時間範囲を調整する必要があるかもしれません。", "xpack.infra.logs.analysis.anomalySectionNoDataTitle": "表示するデータがありません。", - "xpack.infra.logs.analysis.jobConfigurationOutdatedCalloutMessage": "異なるソース構成を使用して ML ジョブが作成されました。現在の構成を適用するにはジョブを再作成してください。これにより以前検出された異常が削除されます。", - "xpack.infra.logs.analysis.jobConfigurationOutdatedCalloutTitle": "古い ML ジョブ構成", - "xpack.infra.logs.analysis.jobDefinitionOutdatedCalloutMessage": "ML ジョブの新しいバージョンが利用可能です。新しいバージョンをデプロイするにはジョブを再作成してください。これにより以前検出された異常が削除されます。", - "xpack.infra.logs.analysis.jobDefinitionOutdatedCalloutTitle": "古い ML ジョブ定義", "xpack.infra.logs.analysis.jobStoppedCalloutMessage": "ML ジョブが手動またはリソース不足により停止しました。新しいログエントリーはジョブが再起動するまで処理されません。", "xpack.infra.logs.analysis.jobStoppedCalloutTitle": "ML ジョブが停止しました", "xpack.infra.logs.analysis.missingMlResultsPrivilegesBody": "本機能は機械学習ジョブを利用し、そのステータスと結果にアクセスするためには、少なくとも{machineLearningUserRole}ロールが必要です。", @@ -7517,7 +7512,6 @@ "xpack.infra.logs.highlights.highlightsPopoverButtonLabel": "ハイライト", "xpack.infra.logs.highlights.highlightTermsFieldLabel": "ハイライトする用語", "xpack.infra.logs.index.logCategoriesBetaBadgeTitle": "カテゴリー", - "xpack.infra.logs.index.logRateBetaBadgeTitle": "ログレート", "xpack.infra.logs.index.settingsTabTitle": "設定", "xpack.infra.logs.index.streamTabTitle": "ストリーム", "xpack.infra.logs.jumpToTailText": "最も新しいエントリーに移動", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 7e36d5676585c..f512ad1046bac 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -7474,14 +7474,9 @@ "xpack.infra.logs.alerting.threshold.fired": "已触发", "xpack.infra.logs.analysis.analyzeInMlButtonLabel": "在 ML 中分析", "xpack.infra.logs.analysis.anomaliesSectionLineSeriesName": "每 15 分钟日志条目数(平均值)", - "xpack.infra.logs.analysis.anomaliesSectionLoadingAriaLabel": "正在加载异常", "xpack.infra.logs.analysis.anomaliesSectionTitle": "异常", "xpack.infra.logs.analysis.anomalySectionNoDataBody": "您可能想调整时间范围。", "xpack.infra.logs.analysis.anomalySectionNoDataTitle": "没有可显示的数据。", - "xpack.infra.logs.analysis.jobConfigurationOutdatedCalloutMessage": "创建 ML 作业时所使用的源配置不同。重新创建作业以应用当前配置。这将移除以前检测到的异常。", - "xpack.infra.logs.analysis.jobConfigurationOutdatedCalloutTitle": "ML 作业配置已过期", - "xpack.infra.logs.analysis.jobDefinitionOutdatedCalloutMessage": "ML 作业有更新的版本可用。重新创建作业以部署更新的版本。这将移除以前检测到的异常。", - "xpack.infra.logs.analysis.jobDefinitionOutdatedCalloutTitle": "ML 作业定义已过期", "xpack.infra.logs.analysis.jobStoppedCalloutMessage": "ML 作业已手动停止或由于缺乏资源而停止。作业重新启动后,才会处理新的日志条目。", "xpack.infra.logs.analysis.jobStoppedCalloutTitle": "ML 作业已停止", "xpack.infra.logs.analysis.missingMlResultsPrivilegesBody": "此功能使用 Machine Learning 作业,要访问这些作业的状态和结果,至少需要 {machineLearningUserRole} 角色。", @@ -7522,7 +7517,6 @@ "xpack.infra.logs.highlights.highlightsPopoverButtonLabel": "突出显示", "xpack.infra.logs.highlights.highlightTermsFieldLabel": "要突出显示的词", "xpack.infra.logs.index.logCategoriesBetaBadgeTitle": "类别", - "xpack.infra.logs.index.logRateBetaBadgeTitle": "日志速率", "xpack.infra.logs.index.settingsTabTitle": "设置", "xpack.infra.logs.index.streamTabTitle": "流式传输", "xpack.infra.logs.jumpToTailText": "跳到最近的条目", From 3222951db19ba25415b472558a9812cd6e8575f1 Mon Sep 17 00:00:00 2001 From: Tim Sullivan Date: Mon, 13 Jul 2020 14:50:49 -0700 Subject: [PATCH 36/66] [Data Plugin] Allow server-side date formatters to accept custom timezone (#70668) * [Data Plugin] Allow server-side date formatters to accept custom timezone When Advanced Settings shows the date format timezone to be "Browser," this means nothing to field formatters in the server-side context. The field formatters need a way to accept custom format parameters. This allows a server-side module that creates a FieldFormatMap to set a timezone as a custom parameter. When custom formatting parameters exist, they get combined with the defaults. * add more to tests - need help though * simplify changes * api doc changes * fix src/plugins/data/public/field_formats/constants.ts * rerun api changes * re-use public code in server, add test * fix path for tests * weird api change needed but no real diff * 3td time api doc chagens * move shared code to common Co-authored-by: Elastic Machine --- ...lugins-data-public.baseformatterspublic.md | 2 +- ...plugin-plugins-data-server.fieldformats.md | 1 - .../constants/base_formatters.ts | 2 - ...anos.test.ts => date_nanos_shared.test.ts} | 2 +- .../{date_nanos.ts => date_nanos_shared.ts} | 12 ++- .../common/field_formats/converters/index.ts | 1 - .../field_formats/field_formats_registry.ts | 12 ++- .../data/common/field_formats/index.ts | 1 - .../data/public/field_formats/constants.ts | 4 +- .../field_formats/converters/date_nanos.ts | 20 +++++ .../public/field_formats/converters/index.ts | 1 + .../data/public/field_formats/index.ts | 2 +- src/plugins/data/public/index.ts | 3 +- src/plugins/data/public/public.api.md | 74 ++++++++--------- .../converters/date_nanos_server.test.ts | 74 +++++++++++++++++ .../converters/date_nanos_server.ts | 79 +++++++++++++++++++ .../server/field_formats/converters/index.ts | 1 + .../field_formats/field_formats_service.ts | 8 +- src/plugins/data/server/index.ts | 2 - src/plugins/data/server/server.api.md | 50 ++++++------ 20 files changed, 263 insertions(+), 88 deletions(-) rename src/plugins/data/common/field_formats/converters/{date_nanos.test.ts => date_nanos_shared.test.ts} (99%) rename src/plugins/data/common/field_formats/converters/{date_nanos.ts => date_nanos_shared.ts} (93%) create mode 100644 src/plugins/data/public/field_formats/converters/date_nanos.ts create mode 100644 src/plugins/data/server/field_formats/converters/date_nanos_server.test.ts create mode 100644 src/plugins/data/server/field_formats/converters/date_nanos_server.ts diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.baseformatterspublic.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.baseformatterspublic.md index ddbf1a8459d1f..25f046983cbce 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.baseformatterspublic.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.baseformatterspublic.md @@ -7,5 +7,5 @@ Signature: ```typescript -baseFormattersPublic: (import("../../common").FieldFormatInstanceType | typeof DateFormat)[] +baseFormattersPublic: (import("../../common").FieldFormatInstanceType | typeof DateNanosFormat | typeof DateFormat)[] ``` diff --git a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.fieldformats.md b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.fieldformats.md index 45fc1a608e8ca..0dddc65f4db92 100644 --- a/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.fieldformats.md +++ b/docs/development/plugins/data/server/kibana-plugin-plugins-data-server.fieldformats.md @@ -13,7 +13,6 @@ fieldFormats: { BoolFormat: typeof BoolFormat; BytesFormat: typeof BytesFormat; ColorFormat: typeof ColorFormat; - DateNanosFormat: typeof DateNanosFormat; DurationFormat: typeof DurationFormat; IpFormat: typeof IpFormat; NumberFormat: typeof NumberFormat; diff --git a/src/plugins/data/common/field_formats/constants/base_formatters.ts b/src/plugins/data/common/field_formats/constants/base_formatters.ts index 921c50571f727..99c24496cf220 100644 --- a/src/plugins/data/common/field_formats/constants/base_formatters.ts +++ b/src/plugins/data/common/field_formats/constants/base_formatters.ts @@ -23,7 +23,6 @@ import { BoolFormat, BytesFormat, ColorFormat, - DateNanosFormat, DurationFormat, IpFormat, NumberFormat, @@ -40,7 +39,6 @@ export const baseFormatters: FieldFormatInstanceType[] = [ BoolFormat, BytesFormat, ColorFormat, - DateNanosFormat, DurationFormat, IpFormat, NumberFormat, diff --git a/src/plugins/data/common/field_formats/converters/date_nanos.test.ts b/src/plugins/data/common/field_formats/converters/date_nanos_shared.test.ts similarity index 99% rename from src/plugins/data/common/field_formats/converters/date_nanos.test.ts rename to src/plugins/data/common/field_formats/converters/date_nanos_shared.test.ts index 267f023e9b69d..6843427d273ff 100644 --- a/src/plugins/data/common/field_formats/converters/date_nanos.test.ts +++ b/src/plugins/data/common/field_formats/converters/date_nanos_shared.test.ts @@ -18,7 +18,7 @@ */ import moment from 'moment-timezone'; -import { DateNanosFormat, analysePatternForFract, formatWithNanos } from './date_nanos'; +import { DateNanosFormat, analysePatternForFract, formatWithNanos } from './date_nanos_shared'; describe('Date Nanos Format', () => { let convert: Function; diff --git a/src/plugins/data/common/field_formats/converters/date_nanos.ts b/src/plugins/data/common/field_formats/converters/date_nanos_shared.ts similarity index 93% rename from src/plugins/data/common/field_formats/converters/date_nanos.ts rename to src/plugins/data/common/field_formats/converters/date_nanos_shared.ts index 3fa2b1c276cd7..89a63243c76f0 100644 --- a/src/plugins/data/common/field_formats/converters/date_nanos.ts +++ b/src/plugins/data/common/field_formats/converters/date_nanos_shared.ts @@ -18,11 +18,9 @@ */ import { i18n } from '@kbn/i18n'; -import moment, { Moment } from 'moment'; import { memoize, noop } from 'lodash'; -import { KBN_FIELD_TYPES } from '../../kbn_field_types/types'; -import { FieldFormat } from '../field_format'; -import { TextContextTypeConvert, FIELD_FORMAT_IDS } from '../types'; +import moment, { Moment } from 'moment'; +import { FieldFormat, FIELD_FORMAT_IDS, KBN_FIELD_TYPES, TextContextTypeConvert } from '../../'; /** * Analyse the given moment.js format pattern for the fractional sec part (S,SS,SSS...) @@ -76,9 +74,9 @@ export class DateNanosFormat extends FieldFormat { }); static fieldType = KBN_FIELD_TYPES.DATE; - private memoizedConverter: Function = noop; - private memoizedPattern: string = ''; - private timeZone: string = ''; + protected memoizedConverter: Function = noop; + protected memoizedPattern: string = ''; + protected timeZone: string = ''; getParamDefaults() { return { diff --git a/src/plugins/data/common/field_formats/converters/index.ts b/src/plugins/data/common/field_formats/converters/index.ts index cc9fae7fc9965..f71ddf5f781f7 100644 --- a/src/plugins/data/common/field_formats/converters/index.ts +++ b/src/plugins/data/common/field_formats/converters/index.ts @@ -19,7 +19,6 @@ export { UrlFormat } from './url'; export { BytesFormat } from './bytes'; -export { DateNanosFormat } from './date_nanos'; export { RelativeDateFormat } from './relative_date'; export { DurationFormat } from './duration'; export { IpFormat } from './ip'; diff --git a/src/plugins/data/common/field_formats/field_formats_registry.ts b/src/plugins/data/common/field_formats/field_formats_registry.ts index 74a942b51583d..84bedd2f9dee0 100644 --- a/src/plugins/data/common/field_formats/field_formats_registry.ts +++ b/src/plugins/data/common/field_formats/field_formats_registry.ts @@ -180,10 +180,18 @@ export class FieldFormatsRegistry { * @param {ES_FIELD_TYPES[]} esTypes * @return {FieldFormat} */ - getDefaultInstancePlain(fieldType: KBN_FIELD_TYPES, esTypes?: ES_FIELD_TYPES[]): FieldFormat { + getDefaultInstancePlain( + fieldType: KBN_FIELD_TYPES, + esTypes?: ES_FIELD_TYPES[], + params: Record = {} + ): FieldFormat { const conf = this.getDefaultConfig(fieldType, esTypes); + const instanceParams = { + ...conf.params, + ...params, + }; - return this.getInstance(conf.id, conf.params); + return this.getInstance(conf.id, instanceParams); } /** * Returns a cache key built by the given variables for caching in memoized diff --git a/src/plugins/data/common/field_formats/index.ts b/src/plugins/data/common/field_formats/index.ts index 104ff030873aa..d622af2f663a1 100644 --- a/src/plugins/data/common/field_formats/index.ts +++ b/src/plugins/data/common/field_formats/index.ts @@ -27,7 +27,6 @@ export { BoolFormat, BytesFormat, ColorFormat, - DateNanosFormat, DurationFormat, IpFormat, NumberFormat, diff --git a/src/plugins/data/public/field_formats/constants.ts b/src/plugins/data/public/field_formats/constants.ts index a5c2b4e379908..d5e292c0e78e5 100644 --- a/src/plugins/data/public/field_formats/constants.ts +++ b/src/plugins/data/public/field_formats/constants.ts @@ -18,6 +18,6 @@ */ import { baseFormatters } from '../../common'; -import { DateFormat } from './converters/date'; +import { DateFormat, DateNanosFormat } from './converters'; -export const baseFormattersPublic = [DateFormat, ...baseFormatters]; +export const baseFormattersPublic = [DateFormat, DateNanosFormat, ...baseFormatters]; diff --git a/src/plugins/data/public/field_formats/converters/date_nanos.ts b/src/plugins/data/public/field_formats/converters/date_nanos.ts new file mode 100644 index 0000000000000..d83926826011a --- /dev/null +++ b/src/plugins/data/public/field_formats/converters/date_nanos.ts @@ -0,0 +1,20 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export { DateNanosFormat } from '../../../common/field_formats/converters/date_nanos_shared'; diff --git a/src/plugins/data/public/field_formats/converters/index.ts b/src/plugins/data/public/field_formats/converters/index.ts index c51111092beca..f5f154084242f 100644 --- a/src/plugins/data/public/field_formats/converters/index.ts +++ b/src/plugins/data/public/field_formats/converters/index.ts @@ -18,3 +18,4 @@ */ export { DateFormat } from './date'; +export { DateNanosFormat } from './date_nanos'; diff --git a/src/plugins/data/public/field_formats/index.ts b/src/plugins/data/public/field_formats/index.ts index 015d5b39561bb..4525959fb864d 100644 --- a/src/plugins/data/public/field_formats/index.ts +++ b/src/plugins/data/public/field_formats/index.ts @@ -18,5 +18,5 @@ */ export { FieldFormatsService, FieldFormatsSetup, FieldFormatsStart } from './field_formats_service'; -export { DateFormat } from './converters'; +export { DateFormat, DateNanosFormat } from './converters'; export { baseFormattersPublic } from './constants'; diff --git a/src/plugins/data/public/index.ts b/src/plugins/data/public/index.ts index abec908b41c0f..2efd1c82aae79 100644 --- a/src/plugins/data/public/index.ts +++ b/src/plugins/data/public/index.ts @@ -157,7 +157,6 @@ import { BoolFormat, BytesFormat, ColorFormat, - DateNanosFormat, DurationFormat, IpFormat, NumberFormat, @@ -170,7 +169,7 @@ import { TruncateFormat, } from '../common/field_formats'; -import { DateFormat } from './field_formats'; +import { DateNanosFormat, DateFormat } from './field_formats'; export { baseFormattersPublic } from './field_formats'; // Field formats helpers namespace: diff --git a/src/plugins/data/public/public.api.md b/src/plugins/data/public/public.api.md index b532bacf5df25..0c23ba340304f 100644 --- a/src/plugins/data/public/public.api.md +++ b/src/plugins/data/public/public.api.md @@ -246,11 +246,12 @@ export class AggParamType extends Ba makeAgg: (agg: TAggConfig, state?: AggConfigSerialized) => TAggConfig; } +// Warning: (ae-forgotten-export) The symbol "DateNanosFormat" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "DateFormat" needs to be exported by the entry point index.d.ts // Warning: (ae-missing-release-tag) "baseFormattersPublic" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) -export const baseFormattersPublic: (import("../../common").FieldFormatInstanceType | typeof DateFormat)[]; +export const baseFormattersPublic: (import("../../common").FieldFormatInstanceType | typeof DateNanosFormat | typeof DateFormat)[]; // Warning: (ae-missing-release-tag) "BUCKET_TYPES" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // @@ -1955,42 +1956,41 @@ export const UI_SETTINGS: { // src/plugins/data/public/index.ts:136:21 - (ae-forgotten-export) The symbol "getEsQueryConfig" needs to be exported by the entry point index.d.ts // src/plugins/data/public/index.ts:136:21 - (ae-forgotten-export) The symbol "luceneStringToDsl" needs to be exported by the entry point index.d.ts // src/plugins/data/public/index.ts:136:21 - (ae-forgotten-export) The symbol "decorateQuery" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:177:26 - (ae-forgotten-export) The symbol "FieldFormatsRegistry" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:177:26 - (ae-forgotten-export) The symbol "BoolFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:177:26 - (ae-forgotten-export) The symbol "BytesFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:177:26 - (ae-forgotten-export) The symbol "ColorFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:177:26 - (ae-forgotten-export) The symbol "DateNanosFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:177:26 - (ae-forgotten-export) The symbol "DurationFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:177:26 - (ae-forgotten-export) The symbol "IpFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:177:26 - (ae-forgotten-export) The symbol "NumberFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:177:26 - (ae-forgotten-export) The symbol "PercentFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:177:26 - (ae-forgotten-export) The symbol "RelativeDateFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:177:26 - (ae-forgotten-export) The symbol "SourceFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:177:26 - (ae-forgotten-export) The symbol "StaticLookupFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:177:26 - (ae-forgotten-export) The symbol "UrlFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:177:26 - (ae-forgotten-export) The symbol "StringFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:177:26 - (ae-forgotten-export) The symbol "TruncateFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:233:27 - (ae-forgotten-export) The symbol "isFilterable" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:233:27 - (ae-forgotten-export) The symbol "isNestedField" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:233:27 - (ae-forgotten-export) The symbol "validateIndexPattern" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:233:27 - (ae-forgotten-export) The symbol "getFromSavedObject" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:233:27 - (ae-forgotten-export) The symbol "flattenHitWrapper" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:233:27 - (ae-forgotten-export) The symbol "formatHitProvider" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:370:20 - (ae-forgotten-export) The symbol "getRequestInspectorStats" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:370:20 - (ae-forgotten-export) The symbol "getResponseInspectorStats" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:370:20 - (ae-forgotten-export) The symbol "tabifyAggResponse" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:370:20 - (ae-forgotten-export) The symbol "tabifyGetColumns" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:372:1 - (ae-forgotten-export) The symbol "CidrMask" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:373:1 - (ae-forgotten-export) The symbol "dateHistogramInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:382:1 - (ae-forgotten-export) The symbol "InvalidEsCalendarIntervalError" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:383:1 - (ae-forgotten-export) The symbol "InvalidEsIntervalFormatError" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:384:1 - (ae-forgotten-export) The symbol "Ipv4Address" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:385:1 - (ae-forgotten-export) The symbol "isDateHistogramBucketAggConfig" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:389:1 - (ae-forgotten-export) The symbol "isValidEsInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:390:1 - (ae-forgotten-export) The symbol "isValidInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:393:1 - (ae-forgotten-export) The symbol "parseInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:394:1 - (ae-forgotten-export) The symbol "propFilter" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:397:1 - (ae-forgotten-export) The symbol "toAbsoluteDates" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:176:26 - (ae-forgotten-export) The symbol "FieldFormatsRegistry" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:176:26 - (ae-forgotten-export) The symbol "BoolFormat" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:176:26 - (ae-forgotten-export) The symbol "BytesFormat" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:176:26 - (ae-forgotten-export) The symbol "ColorFormat" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:176:26 - (ae-forgotten-export) The symbol "DurationFormat" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:176:26 - (ae-forgotten-export) The symbol "IpFormat" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:176:26 - (ae-forgotten-export) The symbol "NumberFormat" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:176:26 - (ae-forgotten-export) The symbol "PercentFormat" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:176:26 - (ae-forgotten-export) The symbol "RelativeDateFormat" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:176:26 - (ae-forgotten-export) The symbol "SourceFormat" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:176:26 - (ae-forgotten-export) The symbol "StaticLookupFormat" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:176:26 - (ae-forgotten-export) The symbol "UrlFormat" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:176:26 - (ae-forgotten-export) The symbol "StringFormat" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:176:26 - (ae-forgotten-export) The symbol "TruncateFormat" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:232:27 - (ae-forgotten-export) The symbol "isFilterable" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:232:27 - (ae-forgotten-export) The symbol "isNestedField" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:232:27 - (ae-forgotten-export) The symbol "validateIndexPattern" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:232:27 - (ae-forgotten-export) The symbol "getFromSavedObject" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:232:27 - (ae-forgotten-export) The symbol "flattenHitWrapper" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:232:27 - (ae-forgotten-export) The symbol "formatHitProvider" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:369:20 - (ae-forgotten-export) The symbol "getRequestInspectorStats" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:369:20 - (ae-forgotten-export) The symbol "getResponseInspectorStats" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:369:20 - (ae-forgotten-export) The symbol "tabifyAggResponse" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:369:20 - (ae-forgotten-export) The symbol "tabifyGetColumns" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:371:1 - (ae-forgotten-export) The symbol "CidrMask" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:372:1 - (ae-forgotten-export) The symbol "dateHistogramInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:381:1 - (ae-forgotten-export) The symbol "InvalidEsCalendarIntervalError" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:382:1 - (ae-forgotten-export) The symbol "InvalidEsIntervalFormatError" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:383:1 - (ae-forgotten-export) The symbol "Ipv4Address" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:384:1 - (ae-forgotten-export) The symbol "isDateHistogramBucketAggConfig" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:388:1 - (ae-forgotten-export) The symbol "isValidEsInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:389:1 - (ae-forgotten-export) The symbol "isValidInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:392:1 - (ae-forgotten-export) The symbol "parseInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:393:1 - (ae-forgotten-export) The symbol "propFilter" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:396:1 - (ae-forgotten-export) The symbol "toAbsoluteDates" needs to be exported by the entry point index.d.ts // src/plugins/data/public/query/state_sync/connect_to_query_state.ts:41:60 - (ae-forgotten-export) The symbol "FilterStateStore" needs to be exported by the entry point index.d.ts // src/plugins/data/public/types.ts:52:5 - (ae-forgotten-export) The symbol "createFiltersFromValueClickAction" needs to be exported by the entry point index.d.ts // src/plugins/data/public/types.ts:53:5 - (ae-forgotten-export) The symbol "createFiltersFromRangeSelectAction" needs to be exported by the entry point index.d.ts diff --git a/src/plugins/data/server/field_formats/converters/date_nanos_server.test.ts b/src/plugins/data/server/field_formats/converters/date_nanos_server.test.ts new file mode 100644 index 0000000000000..ba8e128f32728 --- /dev/null +++ b/src/plugins/data/server/field_formats/converters/date_nanos_server.test.ts @@ -0,0 +1,74 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { DateNanosFormat } from './date_nanos_server'; +import { FieldFormatsGetConfigFn } from 'src/plugins/data/common'; + +describe('Date Nanos Format: Server side edition', () => { + let convert: Function; + let mockConfig: Record; + let getConfig: FieldFormatsGetConfigFn; + + const dateTime = '2019-05-05T14:04:56.201900001Z'; + + beforeEach(() => { + mockConfig = {}; + mockConfig.dateNanosFormat = 'MMMM Do YYYY, HH:mm:ss.SSSSSSSSS'; + mockConfig['dateFormat:tz'] = 'Browser'; + + getConfig = (key: string) => mockConfig[key]; + }); + + test('should format according to the given timezone parameter', () => { + const dateNy = new DateNanosFormat({ timezone: 'America/New_York' }, getConfig); + convert = dateNy.convert.bind(dateNy); + expect(convert(dateTime)).toMatchInlineSnapshot(`"May 5th 2019, 10:04:56.201900001"`); + + const datePhx = new DateNanosFormat({ timezone: 'America/Phoenix' }, getConfig); + convert = datePhx.convert.bind(datePhx); + expect(convert(dateTime)).toMatchInlineSnapshot(`"May 5th 2019, 07:04:56.201900001"`); + }); + + test('should format according to UTC if no timezone parameter is given or exists in settings', () => { + const utcFormat = 'May 5th 2019, 14:04:56.201900001'; + const dateUtc = new DateNanosFormat({ timezone: 'UTC' }, getConfig); + convert = dateUtc.convert.bind(dateUtc); + expect(convert(dateTime)).toBe(utcFormat); + + const dateDefault = new DateNanosFormat({}, getConfig); + convert = dateDefault.convert.bind(dateDefault); + expect(convert(dateTime)).toBe(utcFormat); + }); + + test('should format according to dateFormat:tz if the setting is not "Browser"', () => { + mockConfig['dateFormat:tz'] = 'America/Phoenix'; + + const date = new DateNanosFormat({}, getConfig); + convert = date.convert.bind(date); + expect(convert(dateTime)).toMatchInlineSnapshot(`"May 5th 2019, 07:04:56.201900001"`); + }); + + test('should defer to meta params for timezone, not the UI config', () => { + mockConfig['dateFormat:tz'] = 'America/Phoenix'; + + const date = new DateNanosFormat({ timezone: 'America/New_York' }, getConfig); + convert = date.convert.bind(date); + expect(convert(dateTime)).toMatchInlineSnapshot(`"May 5th 2019, 10:04:56.201900001"`); + }); +}); diff --git a/src/plugins/data/server/field_formats/converters/date_nanos_server.ts b/src/plugins/data/server/field_formats/converters/date_nanos_server.ts new file mode 100644 index 0000000000000..299b2aac93d49 --- /dev/null +++ b/src/plugins/data/server/field_formats/converters/date_nanos_server.ts @@ -0,0 +1,79 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { memoize } from 'lodash'; +import moment from 'moment-timezone'; +import { + analysePatternForFract, + DateNanosFormat, + formatWithNanos, +} from '../../../common/field_formats/converters/date_nanos_shared'; +import { TextContextTypeConvert } from '../../../common'; + +class DateNanosFormatServer extends DateNanosFormat { + textConvert: TextContextTypeConvert = (val) => { + // don't give away our ref to converter so + // we can hot-swap when config changes + const pattern = this.param('pattern'); + const timezone = this.param('timezone'); + const fractPattern = analysePatternForFract(pattern); + const fallbackPattern = this.param('patternFallback'); + + const timezoneChanged = this.timeZone !== timezone; + const datePatternChanged = this.memoizedPattern !== pattern; + if (timezoneChanged || datePatternChanged) { + this.timeZone = timezone; + this.memoizedPattern = pattern; + + this.memoizedConverter = memoize((value: any) => { + if (value === null || value === undefined) { + return '-'; + } + + /* On the server, importing moment returns a new instance. Unlike on + * the client side, it doesn't have the dateFormat:tz configuration + * baked in. + * We need to set the timezone manually here. The date is taken in as + * UTC and converted into the desired timezone. */ + let date; + if (this.timeZone === 'Browser') { + // Assume a warning has been logged that this can be unpredictable. It + // would be too verbose to log anything here. + date = moment.utc(val); + } else { + date = moment.utc(val).tz(this.timeZone); + } + + if (typeof value !== 'string' && date.isValid()) { + // fallback for max/min aggregation, where unixtime in ms is returned as a number + // aggregations in Elasticsearch generally just return ms + return date.format(fallbackPattern); + } else if (date.isValid()) { + return formatWithNanos(date, value, fractPattern); + } else { + return value; + } + }); + } + + return this.memoizedConverter(val); + }; +} + +export { DateNanosFormatServer as DateNanosFormat }; diff --git a/src/plugins/data/server/field_formats/converters/index.ts b/src/plugins/data/server/field_formats/converters/index.ts index f5c69df972869..1c6b827e2fbb5 100644 --- a/src/plugins/data/server/field_formats/converters/index.ts +++ b/src/plugins/data/server/field_formats/converters/index.ts @@ -18,3 +18,4 @@ */ export { DateFormat } from './date_server'; +export { DateNanosFormat } from './date_nanos_server'; diff --git a/src/plugins/data/server/field_formats/field_formats_service.ts b/src/plugins/data/server/field_formats/field_formats_service.ts index 70584efbee0a0..cafb88de4b893 100644 --- a/src/plugins/data/server/field_formats/field_formats_service.ts +++ b/src/plugins/data/server/field_formats/field_formats_service.ts @@ -23,10 +23,14 @@ import { baseFormatters, } from '../../common/field_formats'; import { IUiSettingsClient } from '../../../../core/server'; -import { DateFormat } from './converters'; +import { DateFormat, DateNanosFormat } from './converters'; export class FieldFormatsService { - private readonly fieldFormatClasses: FieldFormatInstanceType[] = [DateFormat, ...baseFormatters]; + private readonly fieldFormatClasses: FieldFormatInstanceType[] = [ + DateFormat, + DateNanosFormat, + ...baseFormatters, + ]; public setup() { return { diff --git a/src/plugins/data/server/index.ts b/src/plugins/data/server/index.ts index 0dd0115add8ad..b94238dcf96a4 100644 --- a/src/plugins/data/server/index.ts +++ b/src/plugins/data/server/index.ts @@ -86,7 +86,6 @@ import { BoolFormat, BytesFormat, ColorFormat, - DateNanosFormat, DurationFormat, IpFormat, NumberFormat, @@ -105,7 +104,6 @@ export const fieldFormats = { BoolFormat, BytesFormat, ColorFormat, - DateNanosFormat, DurationFormat, IpFormat, NumberFormat, diff --git a/src/plugins/data/server/server.api.md b/src/plugins/data/server/server.api.md index 6b62d942de688..1fe03119c789d 100644 --- a/src/plugins/data/server/server.api.md +++ b/src/plugins/data/server/server.api.md @@ -295,7 +295,6 @@ export const fieldFormats: { BoolFormat: typeof BoolFormat; BytesFormat: typeof BytesFormat; ColorFormat: typeof ColorFormat; - DateNanosFormat: typeof DateNanosFormat; DurationFormat: typeof DurationFormat; IpFormat: typeof IpFormat; NumberFormat: typeof NumberFormat; @@ -804,31 +803,30 @@ export const UI_SETTINGS: { // src/plugins/data/server/index.ts:40:23 - (ae-forgotten-export) The symbol "buildFilter" needs to be exported by the entry point index.d.ts // src/plugins/data/server/index.ts:71:21 - (ae-forgotten-export) The symbol "getEsQueryConfig" needs to be exported by the entry point index.d.ts // src/plugins/data/server/index.ts:71:21 - (ae-forgotten-export) The symbol "buildEsQuery" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:102:26 - (ae-forgotten-export) The symbol "FieldFormatsRegistry" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:102:26 - (ae-forgotten-export) The symbol "FieldFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:102:26 - (ae-forgotten-export) The symbol "BoolFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:102:26 - (ae-forgotten-export) The symbol "BytesFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:102:26 - (ae-forgotten-export) The symbol "ColorFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:102:26 - (ae-forgotten-export) The symbol "DateNanosFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:102:26 - (ae-forgotten-export) The symbol "DurationFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:102:26 - (ae-forgotten-export) The symbol "IpFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:102:26 - (ae-forgotten-export) The symbol "NumberFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:102:26 - (ae-forgotten-export) The symbol "PercentFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:102:26 - (ae-forgotten-export) The symbol "RelativeDateFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:102:26 - (ae-forgotten-export) The symbol "SourceFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:102:26 - (ae-forgotten-export) The symbol "StaticLookupFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:102:26 - (ae-forgotten-export) The symbol "UrlFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:102:26 - (ae-forgotten-export) The symbol "StringFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:102:26 - (ae-forgotten-export) The symbol "TruncateFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:129:27 - (ae-forgotten-export) The symbol "isFilterable" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:129:27 - (ae-forgotten-export) The symbol "isNestedField" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:185:1 - (ae-forgotten-export) The symbol "dateHistogramInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:186:1 - (ae-forgotten-export) The symbol "InvalidEsCalendarIntervalError" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:187:1 - (ae-forgotten-export) The symbol "InvalidEsIntervalFormatError" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:188:1 - (ae-forgotten-export) The symbol "Ipv4Address" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:189:1 - (ae-forgotten-export) The symbol "isValidEsInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:190:1 - (ae-forgotten-export) The symbol "isValidInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/server/index.ts:193:1 - (ae-forgotten-export) The symbol "toAbsoluteDates" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:101:26 - (ae-forgotten-export) The symbol "FieldFormatsRegistry" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:101:26 - (ae-forgotten-export) The symbol "FieldFormat" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:101:26 - (ae-forgotten-export) The symbol "BoolFormat" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:101:26 - (ae-forgotten-export) The symbol "BytesFormat" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:101:26 - (ae-forgotten-export) The symbol "ColorFormat" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:101:26 - (ae-forgotten-export) The symbol "DurationFormat" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:101:26 - (ae-forgotten-export) The symbol "IpFormat" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:101:26 - (ae-forgotten-export) The symbol "NumberFormat" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:101:26 - (ae-forgotten-export) The symbol "PercentFormat" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:101:26 - (ae-forgotten-export) The symbol "RelativeDateFormat" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:101:26 - (ae-forgotten-export) The symbol "SourceFormat" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:101:26 - (ae-forgotten-export) The symbol "StaticLookupFormat" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:101:26 - (ae-forgotten-export) The symbol "UrlFormat" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:101:26 - (ae-forgotten-export) The symbol "StringFormat" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:101:26 - (ae-forgotten-export) The symbol "TruncateFormat" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:127:27 - (ae-forgotten-export) The symbol "isFilterable" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:127:27 - (ae-forgotten-export) The symbol "isNestedField" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:183:1 - (ae-forgotten-export) The symbol "dateHistogramInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:184:1 - (ae-forgotten-export) The symbol "InvalidEsCalendarIntervalError" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:185:1 - (ae-forgotten-export) The symbol "InvalidEsIntervalFormatError" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:186:1 - (ae-forgotten-export) The symbol "Ipv4Address" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:187:1 - (ae-forgotten-export) The symbol "isValidEsInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:188:1 - (ae-forgotten-export) The symbol "isValidInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/server/index.ts:191:1 - (ae-forgotten-export) The symbol "toAbsoluteDates" needs to be exported by the entry point index.d.ts // (No @packageDocumentation comment for this package) From b3d75394759e3f586bb48eb392a11afcb9a07f36 Mon Sep 17 00:00:00 2001 From: Clint Andrew Hall Date: Mon, 13 Jul 2020 17:57:48 -0400 Subject: [PATCH 37/66] Inclusive Language Refactor (#71522) --- x-pack/plugins/canvas/server/lib/sanitize_name.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/x-pack/plugins/canvas/server/lib/sanitize_name.js b/x-pack/plugins/canvas/server/lib/sanitize_name.js index 295315c3ceb2e..4c787c816a331 100644 --- a/x-pack/plugins/canvas/server/lib/sanitize_name.js +++ b/x-pack/plugins/canvas/server/lib/sanitize_name.js @@ -5,9 +5,9 @@ */ export function sanitizeName(name) { - // blacklisted characters - const blacklist = ['(', ')']; - const pattern = blacklist.map((v) => escapeRegExp(v)).join('|'); + // invalid characters + const invalid = ['(', ')']; + const pattern = invalid.map((v) => escapeRegExp(v)).join('|'); const regex = new RegExp(pattern, 'g'); return name.replace(regex, '_'); } From 5c3f8b9941ace3067a1f49b4c080387aade68c63 Mon Sep 17 00:00:00 2001 From: Ryland Herrick Date: Mon, 13 Jul 2020 17:05:31 -0500 Subject: [PATCH 38/66] [Security Solution][Detections] Create value list indexes if they do not exist (#71360) * Add API functions and hooks for reading and creating the lists index * Ensure KibanaApiError extends the Error interface It has a name, so we should type it as such. This way, we can use it anywhere that an Error is accepted. * Return an Error from validationEither and thus from our useAsync hooks Because an io-ts pipeline needs a consistent type across its left value, and validateEither was returning a string, we were forcing all our errors to strings. In the case of an API error, however, this meant a loss of data, since the original error's extra fields were lost. By returning an Error from validateEither, we can now pass through Api errors from useAsync and thus use them directly in kibana utilities like toasts.addError. * WIP: implements checking for and consequent creation of lists index This adds most of the machinery that I think we're going to need. Not featured here: * lists privileges (stubbed out currently) * handling when lists is disabled * tests * Add frontend plugin for lists We need this to deteremine in security_solution whether lists is enabled or not. There's no other functionality here, just boilerplate. * Fix cross-plugin imports/exports Now that lists has a client plugin, the optimizer cares about code coming into and out of it. By default, you cannot import another plugin's common/ folder into your own common/ nor public/ folders. This is fixed by adding 'common' to extraPublicDirs, however: extraPublicDirs need to resolve to modules. Rather than adding each folder from which we export modules to extraPublicDirs, I've added common/index.ts and exporting everything through there. By convention, I'm adding shared_exports.ts as an index of these exported modules, and shared_imports.ts is used to import on the other end. For now, I've left the ad hoc _deps files so as to limit the changes here, but we should come back through and remove them at some point. NB that I did remove lists_common_deps as it was only used in one or two spots. * Fix test failing due to lack of context This component now uses useKibana indirectly through useListsConfig. * Lists and securitySolution require each other's bundles Without lists being a requiredBundle of securitySolution, we cannot import its code when the plugin is disabled. The opposite is also true, but there's no lists "app" to break. * Fix logic in useListsConfig Lists needs configuration if the index explicitly does not exist. If it is true (already exists) or null (lists is disabled or we could not read the index), we're good. * useList* behavior when lists plugin is disabled When the lists plugin is disabled, our calls in useListsIndex become no-ops so that: * useListsIndex state does not change * useListsConfig.needsConfiguration remains false as indexExists is never non-null This also removes use of our `useIsMounted` hook. Since the effects we're consuming come from useAsync hooks, state will (already) not be updated if the component is unmounted. * Fix warning due to dynamic creation of a styled component * Revert "Fix warning due to dynamic creation of a styled component" This reverts commit 7124a8fbd9eef8e827e3c4afc415d380b5ee3f05. (This was already fixed on master) * Check user's lists index privileges when determining configuration status If there is no lists index and the user cannot create it, we will display a configuration message in lieu of Detections * Adds a lists hook to read privileges (missing schemae) * Adds security hook useListsPrivileges to perform and parse the privileges request * Updates useListsConfig to use useListsPrivileges hook * Move lists hooks to their own subfolder * Redirect to main detections page if lists needs configuration If: * lists are enabled, and * lists indexes DNE, and * user cannot manage the lists indexes Then they will be redirected to the main detections page where they'll be instructed to configure detections. If any of the above is false, things work as normal. * Lock out of detections when user cannot write to value lists Rather than add conditional logic to all our UI components dealing with lists, we're going the heavy-handed route for now. * Mock lists config hook in relevant Detections page tests * Disable Detections when Lists is enabled This refactors useListsConfig.needsConfiguration to mean: * lists plugin is disabled, OR * lists indexes DNE and can't be created, OR, * user can't write to the lists index In any of these situations, we want to disable detections, and so we export that as a single boolean, needsConfiguration. * Remove unneeded complexity exception We refactored this to work :+1: * Remove outdated TODO We link to our documentation, which will describe the lists aspects of configuration. --- .../common/index.ts} | 2 +- x-pack/plugins/lists/common/shared_exports.ts | 42 ++++++ x-pack/plugins/lists/common/shared_imports.ts | 17 +++ .../plugins/lists/common/siem_common_deps.ts | 10 +- x-pack/plugins/lists/kibana.json | 4 +- .../plugins/lists/public/common/fp_utils.ts | 2 + x-pack/plugins/lists/public/index.ts | 16 +++ x-pack/plugins/lists/public/lists/api.test.ts | 117 ++++++++++++++-- x-pack/plugins/lists/public/lists/api.ts | 59 +++++++- .../lists/hooks/use_create_list_index.test.ts | 34 +++++ .../lists/hooks/use_create_list_index.ts | 14 ++ .../lists/hooks/use_read_list_index.test.ts | 34 +++++ .../public/lists/hooks/use_read_list_index.ts | 14 ++ .../lists/hooks/use_read_list_privileges.ts | 14 ++ x-pack/plugins/lists/public/plugin.ts | 29 ++++ .../public/{index.tsx => shared_exports.ts} | 4 + x-pack/plugins/lists/public/types.ts | 14 ++ .../build_exceptions_query.ts | 2 +- .../detection_engine/schemas/types/lists.ts | 2 +- .../plugins/security_solution/common/index.ts | 7 + .../common/shared_exports.ts | 13 ++ .../common/shared_imports.ts | 42 ++++++ .../security_solution/common/validate.test.ts | 2 +- .../security_solution/common/validate.ts | 4 +- x-pack/plugins/security_solution/kibana.json | 8 +- .../public/common/lib/kibana/hooks.ts | 6 + .../public/common/utils/api/index.ts | 1 + .../lists/__mocks__/use_lists_config.tsx | 7 + .../detection_engine/lists/translations.ts | 28 ++++ .../lists/use_lists_config.tsx | 38 +++++ .../lists/use_lists_index.tsx | 100 +++++++++++++ .../lists/use_lists_privileges.tsx | 132 ++++++++++++++++++ .../detection_engine.test.tsx | 1 + .../detection_engine/detection_engine.tsx | 11 +- .../rules/create/index.test.tsx | 1 + .../detection_engine/rules/create/index.tsx | 17 ++- .../rules/details/index.test.tsx | 1 + .../detection_engine/rules/details/index.tsx | 17 ++- .../rules/edit/index.test.tsx | 1 + .../detection_engine/rules/edit/index.tsx | 17 ++- .../pages/detection_engine/rules/helpers.tsx | 11 +- .../detection_engine/rules/index.test.tsx | 1 + .../pages/detection_engine/rules/index.tsx | 19 ++- .../public/lists_plugin_deps.ts | 48 +------ .../public/shared_imports.ts | 22 +++ .../plugins/security_solution/public/types.ts | 2 + .../detection_engine/signals/utils.test.ts | 2 +- 47 files changed, 891 insertions(+), 98 deletions(-) rename x-pack/plugins/{security_solution/common/detection_engine/lists_common_deps.ts => lists/common/index.ts} (71%) create mode 100644 x-pack/plugins/lists/common/shared_exports.ts create mode 100644 x-pack/plugins/lists/common/shared_imports.ts create mode 100644 x-pack/plugins/lists/public/index.ts create mode 100644 x-pack/plugins/lists/public/lists/hooks/use_create_list_index.test.ts create mode 100644 x-pack/plugins/lists/public/lists/hooks/use_create_list_index.ts create mode 100644 x-pack/plugins/lists/public/lists/hooks/use_read_list_index.test.ts create mode 100644 x-pack/plugins/lists/public/lists/hooks/use_read_list_index.ts create mode 100644 x-pack/plugins/lists/public/lists/hooks/use_read_list_privileges.ts create mode 100644 x-pack/plugins/lists/public/plugin.ts rename x-pack/plugins/lists/public/{index.tsx => shared_exports.ts} (79%) create mode 100644 x-pack/plugins/lists/public/types.ts create mode 100644 x-pack/plugins/security_solution/common/index.ts create mode 100644 x-pack/plugins/security_solution/common/shared_exports.ts create mode 100644 x-pack/plugins/security_solution/common/shared_imports.ts create mode 100644 x-pack/plugins/security_solution/public/detections/containers/detection_engine/lists/__mocks__/use_lists_config.tsx create mode 100644 x-pack/plugins/security_solution/public/detections/containers/detection_engine/lists/translations.ts create mode 100644 x-pack/plugins/security_solution/public/detections/containers/detection_engine/lists/use_lists_config.tsx create mode 100644 x-pack/plugins/security_solution/public/detections/containers/detection_engine/lists/use_lists_index.tsx create mode 100644 x-pack/plugins/security_solution/public/detections/containers/detection_engine/lists/use_lists_privileges.tsx diff --git a/x-pack/plugins/security_solution/common/detection_engine/lists_common_deps.ts b/x-pack/plugins/lists/common/index.ts similarity index 71% rename from x-pack/plugins/security_solution/common/detection_engine/lists_common_deps.ts rename to x-pack/plugins/lists/common/index.ts index 0499fdd1ac8db..b55ca5db30a44 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/lists_common_deps.ts +++ b/x-pack/plugins/lists/common/index.ts @@ -4,4 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -export { EntriesArray, exceptionListType, namespaceType } from '../../../lists/common/schemas'; +export * from './shared_exports'; diff --git a/x-pack/plugins/lists/common/shared_exports.ts b/x-pack/plugins/lists/common/shared_exports.ts new file mode 100644 index 0000000000000..2ad7e63d38c04 --- /dev/null +++ b/x-pack/plugins/lists/common/shared_exports.ts @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { + ListSchema, + CommentsArray, + CreateCommentsArray, + Comments, + CreateComments, + ExceptionListSchema, + ExceptionListItemSchema, + CreateExceptionListItemSchema, + UpdateExceptionListItemSchema, + Entry, + EntryExists, + EntryMatch, + EntryMatchAny, + EntryNested, + EntryList, + EntriesArray, + NamespaceType, + Operator, + OperatorEnum, + OperatorType, + OperatorTypeEnum, + ExceptionListTypeEnum, + exceptionListItemSchema, + exceptionListType, + createExceptionListItemSchema, + listSchema, + entry, + entriesNested, + entriesMatch, + entriesMatchAny, + entriesExists, + entriesList, + namespaceType, + ExceptionListType, +} from './schemas'; diff --git a/x-pack/plugins/lists/common/shared_imports.ts b/x-pack/plugins/lists/common/shared_imports.ts new file mode 100644 index 0000000000000..ad7c24b3db610 --- /dev/null +++ b/x-pack/plugins/lists/common/shared_imports.ts @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { + NonEmptyString, + DefaultUuid, + DefaultStringArray, + exactCheck, + getPaths, + foldLeftRight, + validate, + validateEither, + formatErrors, +} from '../../security_solution/common'; diff --git a/x-pack/plugins/lists/common/siem_common_deps.ts b/x-pack/plugins/lists/common/siem_common_deps.ts index dccc548985e77..2b37e2b7bf106 100644 --- a/x-pack/plugins/lists/common/siem_common_deps.ts +++ b/x-pack/plugins/lists/common/siem_common_deps.ts @@ -4,10 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -export { NonEmptyString } from '../../security_solution/common/detection_engine/schemas/types/non_empty_string'; -export { DefaultUuid } from '../../security_solution/common/detection_engine/schemas/types/default_uuid'; -export { DefaultStringArray } from '../../security_solution/common/detection_engine/schemas/types/default_string_array'; -export { exactCheck } from '../../security_solution/common/exact_check'; -export { getPaths, foldLeftRight } from '../../security_solution/common/test_utils'; -export { validate, validateEither } from '../../security_solution/common/validate'; -export { formatErrors } from '../../security_solution/common/format_errors'; +// DEPRECATED: Do not add exports to this file; please import from shared_imports instead + +export * from './shared_imports'; diff --git a/x-pack/plugins/lists/kibana.json b/x-pack/plugins/lists/kibana.json index b7aaac6d3fc76..1e25fd987552d 100644 --- a/x-pack/plugins/lists/kibana.json +++ b/x-pack/plugins/lists/kibana.json @@ -1,10 +1,12 @@ { "configPath": ["xpack", "lists"], + "extraPublicDirs": ["common"], "id": "lists", "kibanaVersion": "kibana", "requiredPlugins": [], "optionalPlugins": ["spaces", "security"], + "requiredBundles": ["securitySolution"], "server": true, - "ui": false, + "ui": true, "version": "8.0.0" } diff --git a/x-pack/plugins/lists/public/common/fp_utils.ts b/x-pack/plugins/lists/public/common/fp_utils.ts index 04e1033879476..196bfee0b501b 100644 --- a/x-pack/plugins/lists/public/common/fp_utils.ts +++ b/x-pack/plugins/lists/public/common/fp_utils.ts @@ -16,3 +16,5 @@ export const toPromise = async (taskEither: TaskEither): Promise (a) => Promise.resolve(a) ) ); + +export const toError = (e: unknown): Error => (e instanceof Error ? e : new Error(String(e))); diff --git a/x-pack/plugins/lists/public/index.ts b/x-pack/plugins/lists/public/index.ts new file mode 100644 index 0000000000000..2cff5af613d9a --- /dev/null +++ b/x-pack/plugins/lists/public/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export * from './shared_exports'; + +import { PluginInitializerContext } from '../../../../src/core/public'; + +import { Plugin } from './plugin'; +import { PluginSetup, PluginStart } from './types'; + +export const plugin = (context: PluginInitializerContext): Plugin => new Plugin(context); + +export { Plugin, PluginSetup, PluginStart }; diff --git a/x-pack/plugins/lists/public/lists/api.test.ts b/x-pack/plugins/lists/public/lists/api.test.ts index 38556e2eabc18..d54a3ca654943 100644 --- a/x-pack/plugins/lists/public/lists/api.test.ts +++ b/x-pack/plugins/lists/public/lists/api.test.ts @@ -6,10 +6,19 @@ import { HttpFetchOptions } from '../../../../../src/core/public'; import { httpServiceMock } from '../../../../../src/core/public/mocks'; +import { getAcknowledgeSchemaResponseMock } from '../../common/schemas/response/acknowledge_schema.mock'; import { getListResponseMock } from '../../common/schemas/response/list_schema.mock'; +import { getListItemIndexExistSchemaResponseMock } from '../../common/schemas/response/list_item_index_exist_schema.mock'; import { getFoundListSchemaMock } from '../../common/schemas/response/found_list_schema.mock'; -import { deleteList, exportList, findLists, importList } from './api'; +import { + createListIndex, + deleteList, + exportList, + findLists, + importList, + readListIndex, +} from './api'; import { ApiPayload, DeleteListParams, @@ -60,7 +69,7 @@ describe('Value Lists API', () => { ...((payload as unknown) as ApiPayload), signal: abortCtrl.signal, }) - ).rejects.toEqual('Invalid value "23" supplied to "id"'); + ).rejects.toEqual(new Error('Invalid value "23" supplied to "id"')); expect(httpMock.fetch).not.toHaveBeenCalled(); }); @@ -76,7 +85,7 @@ describe('Value Lists API', () => { ...payload, signal: abortCtrl.signal, }) - ).rejects.toEqual('Invalid value "undefined" supplied to "id"'); + ).rejects.toEqual(new Error('Invalid value "undefined" supplied to "id"')); }); }); @@ -129,7 +138,7 @@ describe('Value Lists API', () => { ...payload, signal: abortCtrl.signal, }) - ).rejects.toEqual('Invalid value "0" supplied to "per_page"'); + ).rejects.toEqual(new Error('Invalid value "0" supplied to "per_page"')); expect(httpMock.fetch).not.toHaveBeenCalled(); }); @@ -145,7 +154,7 @@ describe('Value Lists API', () => { ...payload, signal: abortCtrl.signal, }) - ).rejects.toEqual('Invalid value "undefined" supplied to "cursor"'); + ).rejects.toEqual(new Error('Invalid value "undefined" supplied to "cursor"')); }); }); @@ -214,7 +223,7 @@ describe('Value Lists API', () => { ...payload, signal: abortCtrl.signal, }) - ).rejects.toEqual('Invalid value "undefined" supplied to "file"'); + ).rejects.toEqual(new Error('Invalid value "undefined" supplied to "file"')); expect(httpMock.fetch).not.toHaveBeenCalled(); }); @@ -233,7 +242,7 @@ describe('Value Lists API', () => { ...payload, signal: abortCtrl.signal, }) - ).rejects.toEqual('Invalid value "other" supplied to "type"'); + ).rejects.toEqual(new Error('Invalid value "other" supplied to "type"')); expect(httpMock.fetch).not.toHaveBeenCalled(); }); @@ -254,7 +263,7 @@ describe('Value Lists API', () => { ...payload, signal: abortCtrl.signal, }) - ).rejects.toEqual('Invalid value "undefined" supplied to "id"'); + ).rejects.toEqual(new Error('Invalid value "undefined" supplied to "id"')); }); }); @@ -307,7 +316,7 @@ describe('Value Lists API', () => { ...payload, signal: abortCtrl.signal, }) - ).rejects.toEqual('Invalid value "23" supplied to "list_id"'); + ).rejects.toEqual(new Error('Invalid value "23" supplied to "list_id"')); expect(httpMock.fetch).not.toHaveBeenCalled(); }); @@ -325,7 +334,95 @@ describe('Value Lists API', () => { ...payload, signal: abortCtrl.signal, }) - ).rejects.toEqual('Invalid value "undefined" supplied to "id"'); + ).rejects.toEqual(new Error('Invalid value "undefined" supplied to "id"')); + }); + + describe('readListIndex', () => { + beforeEach(() => { + httpMock.fetch.mockResolvedValue(getListItemIndexExistSchemaResponseMock()); + }); + + it('GETs the list index', async () => { + const abortCtrl = new AbortController(); + await readListIndex({ + http: httpMock, + signal: abortCtrl.signal, + }); + + expect(httpMock.fetch).toHaveBeenCalledWith( + '/api/lists/index', + expect.objectContaining({ + method: 'GET', + }) + ); + }); + + it('returns the response when valid', async () => { + const abortCtrl = new AbortController(); + const result = await readListIndex({ + http: httpMock, + signal: abortCtrl.signal, + }); + + expect(result).toEqual(getListItemIndexExistSchemaResponseMock()); + }); + + it('rejects with an error if response payload is invalid', async () => { + const abortCtrl = new AbortController(); + const badResponse = { ...getListItemIndexExistSchemaResponseMock(), list_index: undefined }; + httpMock.fetch.mockResolvedValue(badResponse); + + await expect( + readListIndex({ + http: httpMock, + signal: abortCtrl.signal, + }) + ).rejects.toEqual(new Error('Invalid value "undefined" supplied to "list_index"')); + }); + }); + }); + + describe('createListIndex', () => { + beforeEach(() => { + httpMock.fetch.mockResolvedValue(getAcknowledgeSchemaResponseMock()); + }); + + it('GETs the list index', async () => { + const abortCtrl = new AbortController(); + await createListIndex({ + http: httpMock, + signal: abortCtrl.signal, + }); + + expect(httpMock.fetch).toHaveBeenCalledWith( + '/api/lists/index', + expect.objectContaining({ + method: 'POST', + }) + ); + }); + + it('returns the response when valid', async () => { + const abortCtrl = new AbortController(); + const result = await createListIndex({ + http: httpMock, + signal: abortCtrl.signal, + }); + + expect(result).toEqual(getAcknowledgeSchemaResponseMock()); + }); + + it('rejects with an error if response payload is invalid', async () => { + const abortCtrl = new AbortController(); + const badResponse = { acknowledged: undefined }; + httpMock.fetch.mockResolvedValue(badResponse); + + await expect( + createListIndex({ + http: httpMock, + signal: abortCtrl.signal, + }) + ).rejects.toEqual(new Error('Invalid value "undefined" supplied to "acknowledged"')); }); }); }); diff --git a/x-pack/plugins/lists/public/lists/api.ts b/x-pack/plugins/lists/public/lists/api.ts index d615239f4eb01..a1efae2af877a 100644 --- a/x-pack/plugins/lists/public/lists/api.ts +++ b/x-pack/plugins/lists/public/lists/api.ts @@ -9,24 +9,28 @@ import { flow } from 'fp-ts/lib/function'; import { pipe } from 'fp-ts/lib/pipeable'; import { + AcknowledgeSchema, DeleteListSchemaEncoded, ExportListItemQuerySchemaEncoded, FindListSchemaEncoded, FoundListSchema, ImportListItemQuerySchemaEncoded, ImportListItemSchemaEncoded, + ListItemIndexExistSchema, ListSchema, + acknowledgeSchema, deleteListSchema, exportListItemQuerySchema, findListSchema, foundListSchema, importListItemQuerySchema, importListItemSchema, + listItemIndexExistSchema, listSchema, } from '../../common/schemas'; -import { LIST_ITEM_URL, LIST_URL } from '../../common/constants'; +import { LIST_INDEX, LIST_ITEM_URL, LIST_PRIVILEGES_URL, LIST_URL } from '../../common/constants'; import { validateEither } from '../../common/siem_common_deps'; -import { toPromise } from '../common/fp_utils'; +import { toError, toPromise } from '../common/fp_utils'; import { ApiParams, @@ -66,7 +70,7 @@ const findListsWithValidation = async ({ per_page: String(pageSize), }, (payload) => fromEither(validateEither(findListSchema, payload)), - chain((payload) => tryCatch(() => findLists({ http, signal, ...payload }), String)), + chain((payload) => tryCatch(() => findLists({ http, signal, ...payload }), toError)), chain((response) => fromEither(validateEither(foundListSchema, response))), flow(toPromise) ); @@ -113,7 +117,7 @@ const importListWithValidation = async ({ map((body) => ({ ...body, ...query })) ) ), - chain((payload) => tryCatch(() => importList({ http, signal, ...payload }), String)), + chain((payload) => tryCatch(() => importList({ http, signal, ...payload }), toError)), chain((response) => fromEither(validateEither(listSchema, response))), flow(toPromise) ); @@ -139,7 +143,7 @@ const deleteListWithValidation = async ({ pipe( { id }, (payload) => fromEither(validateEither(deleteListSchema, payload)), - chain((payload) => tryCatch(() => deleteList({ http, signal, ...payload }), String)), + chain((payload) => tryCatch(() => deleteList({ http, signal, ...payload }), toError)), chain((response) => fromEither(validateEither(listSchema, response))), flow(toPromise) ); @@ -165,9 +169,52 @@ const exportListWithValidation = async ({ pipe( { list_id: listId }, (payload) => fromEither(validateEither(exportListItemQuerySchema, payload)), - chain((payload) => tryCatch(() => exportList({ http, signal, ...payload }), String)), + chain((payload) => tryCatch(() => exportList({ http, signal, ...payload }), toError)), chain((response) => fromEither(validateEither(listSchema, response))), flow(toPromise) ); export { exportListWithValidation as exportList }; + +const readListIndex = async ({ http, signal }: ApiParams): Promise => + http.fetch(LIST_INDEX, { + method: 'GET', + signal, + }); + +const readListIndexWithValidation = async ({ + http, + signal, +}: ApiParams): Promise => + flow( + () => tryCatch(() => readListIndex({ http, signal }), toError), + chain((response) => fromEither(validateEither(listItemIndexExistSchema, response))), + flow(toPromise) + )(); + +export { readListIndexWithValidation as readListIndex }; + +// TODO add types and validation +export const readListPrivileges = async ({ http, signal }: ApiParams): Promise => + http.fetch(LIST_PRIVILEGES_URL, { + method: 'GET', + signal, + }); + +const createListIndex = async ({ http, signal }: ApiParams): Promise => + http.fetch(LIST_INDEX, { + method: 'POST', + signal, + }); + +const createListIndexWithValidation = async ({ + http, + signal, +}: ApiParams): Promise => + flow( + () => tryCatch(() => createListIndex({ http, signal }), toError), + chain((response) => fromEither(validateEither(acknowledgeSchema, response))), + flow(toPromise) + )(); + +export { createListIndexWithValidation as createListIndex }; diff --git a/x-pack/plugins/lists/public/lists/hooks/use_create_list_index.test.ts b/x-pack/plugins/lists/public/lists/hooks/use_create_list_index.test.ts new file mode 100644 index 0000000000000..9f784dd8790bf --- /dev/null +++ b/x-pack/plugins/lists/public/lists/hooks/use_create_list_index.test.ts @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { act, renderHook } from '@testing-library/react-hooks'; + +import * as Api from '../api'; +import { httpServiceMock } from '../../../../../../src/core/public/mocks'; +import { getAcknowledgeSchemaResponseMock } from '../../../common/schemas/response/acknowledge_schema.mock'; + +import { useCreateListIndex } from './use_create_list_index'; + +jest.mock('../api'); + +describe('useCreateListIndex', () => { + let httpMock: ReturnType; + + beforeEach(() => { + httpMock = httpServiceMock.createStartContract(); + (Api.createListIndex as jest.Mock).mockResolvedValue(getAcknowledgeSchemaResponseMock()); + }); + + it('invokes Api.createListIndex', async () => { + const { result, waitForNextUpdate } = renderHook(() => useCreateListIndex()); + act(() => { + result.current.start({ http: httpMock }); + }); + await waitForNextUpdate(); + + expect(Api.createListIndex).toHaveBeenCalledWith(expect.objectContaining({ http: httpMock })); + }); +}); diff --git a/x-pack/plugins/lists/public/lists/hooks/use_create_list_index.ts b/x-pack/plugins/lists/public/lists/hooks/use_create_list_index.ts new file mode 100644 index 0000000000000..18df26c2ecfd7 --- /dev/null +++ b/x-pack/plugins/lists/public/lists/hooks/use_create_list_index.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { withOptionalSignal } from '../../common/with_optional_signal'; +import { useAsync } from '../../common/hooks/use_async'; +import { createListIndex } from '../api'; + +const createListIndexWithOptionalSignal = withOptionalSignal(createListIndex); + +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type +export const useCreateListIndex = () => useAsync(createListIndexWithOptionalSignal); diff --git a/x-pack/plugins/lists/public/lists/hooks/use_read_list_index.test.ts b/x-pack/plugins/lists/public/lists/hooks/use_read_list_index.test.ts new file mode 100644 index 0000000000000..9f4e41f1cdc9e --- /dev/null +++ b/x-pack/plugins/lists/public/lists/hooks/use_read_list_index.test.ts @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { act, renderHook } from '@testing-library/react-hooks'; + +import * as Api from '../api'; +import { httpServiceMock } from '../../../../../../src/core/public/mocks'; +import { getAcknowledgeSchemaResponseMock } from '../../../common/schemas/response/acknowledge_schema.mock'; + +import { useReadListIndex } from './use_read_list_index'; + +jest.mock('../api'); + +describe('useReadListIndex', () => { + let httpMock: ReturnType; + + beforeEach(() => { + httpMock = httpServiceMock.createStartContract(); + (Api.readListIndex as jest.Mock).mockResolvedValue(getAcknowledgeSchemaResponseMock()); + }); + + it('invokes Api.readListIndex', async () => { + const { result, waitForNextUpdate } = renderHook(() => useReadListIndex()); + act(() => { + result.current.start({ http: httpMock }); + }); + await waitForNextUpdate(); + + expect(Api.readListIndex).toHaveBeenCalledWith(expect.objectContaining({ http: httpMock })); + }); +}); diff --git a/x-pack/plugins/lists/public/lists/hooks/use_read_list_index.ts b/x-pack/plugins/lists/public/lists/hooks/use_read_list_index.ts new file mode 100644 index 0000000000000..7d15a0b1e08c9 --- /dev/null +++ b/x-pack/plugins/lists/public/lists/hooks/use_read_list_index.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { withOptionalSignal } from '../../common/with_optional_signal'; +import { useAsync } from '../../common/hooks/use_async'; +import { readListIndex } from '../api'; + +const readListIndexWithOptionalSignal = withOptionalSignal(readListIndex); + +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type +export const useReadListIndex = () => useAsync(readListIndexWithOptionalSignal); diff --git a/x-pack/plugins/lists/public/lists/hooks/use_read_list_privileges.ts b/x-pack/plugins/lists/public/lists/hooks/use_read_list_privileges.ts new file mode 100644 index 0000000000000..313f17a3bac4b --- /dev/null +++ b/x-pack/plugins/lists/public/lists/hooks/use_read_list_privileges.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { withOptionalSignal } from '../../common/with_optional_signal'; +import { useAsync } from '../../common/hooks/use_async'; +import { readListPrivileges } from '../api'; + +const readListPrivilegesWithOptionalSignal = withOptionalSignal(readListPrivileges); + +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type +export const useReadListPrivileges = () => useAsync(readListPrivilegesWithOptionalSignal); diff --git a/x-pack/plugins/lists/public/plugin.ts b/x-pack/plugins/lists/public/plugin.ts new file mode 100644 index 0000000000000..717e5d2885910 --- /dev/null +++ b/x-pack/plugins/lists/public/plugin.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + CoreSetup, + CoreStart, + Plugin as IPlugin, + PluginInitializerContext, +} from '../../../../src/core/public'; + +import { PluginSetup, PluginStart, SetupPlugins, StartPlugins } from './types'; + +export class Plugin implements IPlugin { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + constructor(initializerContext: PluginInitializerContext) {} // eslint-disable-line @typescript-eslint/no-useless-constructor + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + public setup(core: CoreSetup, plugins: SetupPlugins): PluginSetup { + return {}; + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + public start(core: CoreStart, plugins: StartPlugins): PluginStart { + return {}; + } +} diff --git a/x-pack/plugins/lists/public/index.tsx b/x-pack/plugins/lists/public/shared_exports.ts similarity index 79% rename from x-pack/plugins/lists/public/index.tsx rename to x-pack/plugins/lists/public/shared_exports.ts index 72bd46d6e2ce8..dc2e28634e1e8 100644 --- a/x-pack/plugins/lists/public/index.tsx +++ b/x-pack/plugins/lists/public/shared_exports.ts @@ -5,6 +5,7 @@ */ // Exports to be shared with plugins +export { useIsMounted } from './common/hooks/use_is_mounted'; export { useApi } from './exceptions/hooks/use_api'; export { usePersistExceptionItem } from './exceptions/hooks/persist_exception_item'; export { usePersistExceptionList } from './exceptions/hooks/persist_exception_list'; @@ -13,6 +14,9 @@ export { useFindLists } from './lists/hooks/use_find_lists'; export { useImportList } from './lists/hooks/use_import_list'; export { useDeleteList } from './lists/hooks/use_delete_list'; export { useExportList } from './lists/hooks/use_export_list'; +export { useReadListIndex } from './lists/hooks/use_read_list_index'; +export { useCreateListIndex } from './lists/hooks/use_create_list_index'; +export { useReadListPrivileges } from './lists/hooks/use_read_list_privileges'; export { addExceptionListItem, updateExceptionListItem, diff --git a/x-pack/plugins/lists/public/types.ts b/x-pack/plugins/lists/public/types.ts new file mode 100644 index 0000000000000..0a9b0460614bd --- /dev/null +++ b/x-pack/plugins/lists/public/types.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface PluginSetup {} +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface PluginStart {} +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface SetupPlugins {} +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface StartPlugins {} diff --git a/x-pack/plugins/security_solution/common/detection_engine/build_exceptions_query.ts b/x-pack/plugins/security_solution/common/detection_engine/build_exceptions_query.ts index a69ee809987f7..d3ac5d1490703 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/build_exceptions_query.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/build_exceptions_query.ts @@ -17,7 +17,7 @@ import { entriesMatch, entriesNested, ExceptionListItemSchema, -} from '../../../lists/common/schemas'; +} from '../shared_imports'; import { Language, Query } from './schemas/common/schemas'; type Operators = 'and' | 'or' | 'not'; diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/types/lists.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/types/lists.ts index cadc32a37a05d..e5aaee6d3ec74 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/types/lists.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/types/lists.ts @@ -6,7 +6,7 @@ import * as t from 'io-ts'; -import { exceptionListType, namespaceType } from '../../lists_common_deps'; +import { exceptionListType, namespaceType } from '../../../shared_imports'; export const list = t.exact( t.type({ diff --git a/x-pack/plugins/security_solution/common/index.ts b/x-pack/plugins/security_solution/common/index.ts new file mode 100644 index 0000000000000..b55ca5db30a44 --- /dev/null +++ b/x-pack/plugins/security_solution/common/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export * from './shared_exports'; diff --git a/x-pack/plugins/security_solution/common/shared_exports.ts b/x-pack/plugins/security_solution/common/shared_exports.ts new file mode 100644 index 0000000000000..1b5b17ef35cae --- /dev/null +++ b/x-pack/plugins/security_solution/common/shared_exports.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { NonEmptyString } from './detection_engine/schemas/types/non_empty_string'; +export { DefaultUuid } from './detection_engine/schemas/types/default_uuid'; +export { DefaultStringArray } from './detection_engine/schemas/types/default_string_array'; +export { exactCheck } from './exact_check'; +export { getPaths, foldLeftRight } from './test_utils'; +export { validate, validateEither } from './validate'; +export { formatErrors } from './format_errors'; diff --git a/x-pack/plugins/security_solution/common/shared_imports.ts b/x-pack/plugins/security_solution/common/shared_imports.ts new file mode 100644 index 0000000000000..f56f184a5a467 --- /dev/null +++ b/x-pack/plugins/security_solution/common/shared_imports.ts @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { + ListSchema, + CommentsArray, + CreateCommentsArray, + Comments, + CreateComments, + ExceptionListSchema, + ExceptionListItemSchema, + CreateExceptionListItemSchema, + UpdateExceptionListItemSchema, + Entry, + EntryExists, + EntryMatch, + EntryMatchAny, + EntryNested, + EntryList, + EntriesArray, + NamespaceType, + Operator, + OperatorEnum, + OperatorType, + OperatorTypeEnum, + ExceptionListTypeEnum, + exceptionListItemSchema, + exceptionListType, + createExceptionListItemSchema, + listSchema, + entry, + entriesNested, + entriesMatch, + entriesMatchAny, + entriesExists, + entriesList, + namespaceType, + ExceptionListType, +} from '../../lists/common'; diff --git a/x-pack/plugins/security_solution/common/validate.test.ts b/x-pack/plugins/security_solution/common/validate.test.ts index b2217099fca19..8cd322a25b5c0 100644 --- a/x-pack/plugins/security_solution/common/validate.test.ts +++ b/x-pack/plugins/security_solution/common/validate.test.ts @@ -43,6 +43,6 @@ describe('validateEither', () => { const payload = { a: 'some other value' }; const result = validateEither(schema, payload); - expect(result).toEqual(left('Invalid value "some other value" supplied to "a"')); + expect(result).toEqual(left(new Error('Invalid value "some other value" supplied to "a"'))); }); }); diff --git a/x-pack/plugins/security_solution/common/validate.ts b/x-pack/plugins/security_solution/common/validate.ts index f36df38c2a90d..9745c21a191f0 100644 --- a/x-pack/plugins/security_solution/common/validate.ts +++ b/x-pack/plugins/security_solution/common/validate.ts @@ -27,9 +27,9 @@ export const validate = ( export const validateEither = ( schema: T, obj: A -): Either => +): Either => pipe( obj, (a) => schema.validate(a, t.getDefaultContext(schema.asDecoder())), - mapLeft((errors) => formatErrors(errors).join(',')) + mapLeft((errors) => new Error(formatErrors(errors).join(','))) ); diff --git a/x-pack/plugins/security_solution/kibana.json b/x-pack/plugins/security_solution/kibana.json index 29d0ab58e8b55..92fc93453b9f1 100644 --- a/x-pack/plugins/security_solution/kibana.json +++ b/x-pack/plugins/security_solution/kibana.json @@ -1,6 +1,7 @@ { "id": "securitySolution", "version": "8.0.0", + "extraPublicDirs": ["common"], "kibanaVersion": "kibana", "configPath": ["xpack", "securitySolution"], "requiredPlugins": [ @@ -30,10 +31,5 @@ ], "server": true, "ui": true, - "requiredBundles": [ - "kibanaUtils", - "esUiShared", - "kibanaReact", - "ingestManager" - ] + "requiredBundles": ["esUiShared", "ingestManager", "kibanaUtils", "kibanaReact", "lists"] } diff --git a/x-pack/plugins/security_solution/public/common/lib/kibana/hooks.ts b/x-pack/plugins/security_solution/public/common/lib/kibana/hooks.ts index 813907d9af416..184aa4d8e673c 100644 --- a/x-pack/plugins/security_solution/public/common/lib/kibana/hooks.ts +++ b/x-pack/plugins/security_solution/public/common/lib/kibana/hooks.ts @@ -13,6 +13,7 @@ import { useUiSetting, useKibana } from './kibana_react'; import { errorToToaster, useStateToaster } from '../../components/toasters'; import { AuthenticatedUser } from '../../../../../security/common/model'; import { convertToCamelCase } from '../../../cases/containers/utils'; +import { StartServices } from '../../../types'; export const useDateFormat = (): string => useUiSetting(DEFAULT_DATE_FORMAT); @@ -124,3 +125,8 @@ export const useGetUserSavedObjectPermissions = () => { return savedObjectsPermissions; }; + +export const useToasts = (): StartServices['notifications']['toasts'] => + useKibana().services.notifications.toasts; + +export const useHttp = (): StartServices['http'] => useKibana().services.http; diff --git a/x-pack/plugins/security_solution/public/common/utils/api/index.ts b/x-pack/plugins/security_solution/public/common/utils/api/index.ts index e47e03ce4e627..ab442d0d09cf9 100644 --- a/x-pack/plugins/security_solution/public/common/utils/api/index.ts +++ b/x-pack/plugins/security_solution/public/common/utils/api/index.ts @@ -7,6 +7,7 @@ import { has } from 'lodash/fp'; export interface KibanaApiError { + name: string; message: string; body: { message: string; diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/lists/__mocks__/use_lists_config.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/lists/__mocks__/use_lists_config.tsx new file mode 100644 index 0000000000000..0f8e0fba1e3af --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/lists/__mocks__/use_lists_config.tsx @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const useListsConfig = jest.fn().mockReturnValue({}); diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/lists/translations.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/lists/translations.ts new file mode 100644 index 0000000000000..8c72f092918c9 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/lists/translations.ts @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; + +export const LISTS_INDEX_FETCH_FAILURE = i18n.translate( + 'xpack.securitySolution.containers.detectionEngine.alerts.fetchListsIndex.errorDescription', + { + defaultMessage: 'Failed to retrieve the lists index', + } +); + +export const LISTS_INDEX_CREATE_FAILURE = i18n.translate( + 'xpack.securitySolution.containers.detectionEngine.alerts.createListsIndex.errorDescription', + { + defaultMessage: 'Failed to create the lists index', + } +); + +export const LISTS_PRIVILEGES_READ_FAILURE = i18n.translate( + 'xpack.securitySolution.containers.detectionEngine.alerts.readListsPrivileges.errorDescription', + { + defaultMessage: 'Failed to retrieve lists privileges', + } +); diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/lists/use_lists_config.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/lists/use_lists_config.tsx new file mode 100644 index 0000000000000..ea5e075811d4b --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/lists/use_lists_config.tsx @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { useEffect } from 'react'; + +import { useKibana } from '../../../../common/lib/kibana'; +import { useListsIndex } from './use_lists_index'; +import { useListsPrivileges } from './use_lists_privileges'; + +export interface UseListsConfigReturn { + canManageIndex: boolean | null; + canWriteIndex: boolean | null; + enabled: boolean; + loading: boolean; + needsConfiguration: boolean; +} + +export const useListsConfig = (): UseListsConfigReturn => { + const { createIndex, indexExists, loading: indexLoading } = useListsIndex(); + const { canManageIndex, canWriteIndex, loading: privilegesLoading } = useListsPrivileges(); + const { lists } = useKibana().services; + + const enabled = lists != null; + const loading = indexLoading || privilegesLoading; + const needsIndex = indexExists === false; + const needsConfiguration = !enabled || needsIndex || canWriteIndex === false; + + useEffect(() => { + if (canManageIndex && needsIndex) { + createIndex(); + } + }, [canManageIndex, createIndex, needsIndex]); + + return { canManageIndex, canWriteIndex, enabled, loading, needsConfiguration }; +}; diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/lists/use_lists_index.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/lists/use_lists_index.tsx new file mode 100644 index 0000000000000..a9497fd4971c1 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/lists/use_lists_index.tsx @@ -0,0 +1,100 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { useEffect, useState, useCallback } from 'react'; + +import { useReadListIndex, useCreateListIndex } from '../../../../shared_imports'; +import { useHttp, useToasts, useKibana } from '../../../../common/lib/kibana'; +import { isApiError } from '../../../../common/utils/api'; +import * as i18n from './translations'; + +export interface UseListsIndexState { + indexExists: boolean | null; +} + +export interface UseListsIndexReturn extends UseListsIndexState { + loading: boolean; + createIndex: () => void; +} + +export const useListsIndex = (): UseListsIndexReturn => { + const [state, setState] = useState({ + indexExists: null, + }); + const { lists } = useKibana().services; + const http = useHttp(); + const toasts = useToasts(); + const { loading: readLoading, start: readListIndex, ...readListIndexState } = useReadListIndex(); + const { + loading: createLoading, + start: createListIndex, + ...createListIndexState + } = useCreateListIndex(); + const loading = readLoading || createLoading; + + const readIndex = useCallback(() => { + if (lists) { + readListIndex({ http }); + } + }, [http, lists, readListIndex]); + + const createIndex = useCallback(() => { + if (lists) { + createListIndex({ http }); + } + }, [createListIndex, http, lists]); + + // initial read list + useEffect(() => { + if (!readLoading && state.indexExists === null) { + readIndex(); + } + }, [readIndex, readLoading, state.indexExists]); + + // handle read result + useEffect(() => { + if (readListIndexState.result != null) { + setState({ + indexExists: + readListIndexState.result.list_index && readListIndexState.result.list_item_index, + }); + } + }, [readListIndexState.result]); + + // refetch index after creation + useEffect(() => { + if (createListIndexState.result != null) { + readIndex(); + } + }, [createListIndexState.result, readIndex]); + + // handle read error + useEffect(() => { + const error = readListIndexState.error; + if (isApiError(error)) { + setState({ indexExists: false }); + if (error.body.status_code !== 404) { + toasts.addError(error, { + title: i18n.LISTS_INDEX_FETCH_FAILURE, + toastMessage: error.body.message, + }); + } + } + }, [readListIndexState.error, toasts]); + + // handle create error + useEffect(() => { + const error = createListIndexState.error; + if (isApiError(error)) { + toasts.addError(error, { + title: i18n.LISTS_INDEX_CREATE_FAILURE, + toastMessage: error.body.message, + }); + } + }, [createListIndexState.error, toasts]); + + return { loading, createIndex, ...state }; +}; diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/lists/use_lists_privileges.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/lists/use_lists_privileges.tsx new file mode 100644 index 0000000000000..fbbcff33402c3 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/lists/use_lists_privileges.tsx @@ -0,0 +1,132 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { useEffect, useState, useCallback } from 'react'; + +import { useReadListPrivileges } from '../../../../shared_imports'; +import { useHttp, useToasts, useKibana } from '../../../../common/lib/kibana'; +import { isApiError } from '../../../../common/utils/api'; +import * as i18n from './translations'; + +export interface UseListsPrivilegesState { + isAuthenticated: boolean | null; + canManageIndex: boolean | null; + canWriteIndex: boolean | null; +} + +export interface UseListsPrivilegesReturn extends UseListsPrivilegesState { + loading: boolean; +} + +interface ListIndexPrivileges { + [indexName: string]: { + all: boolean; + create: boolean; + create_doc: boolean; + create_index: boolean; + delete: boolean; + delete_index: boolean; + index: boolean; + manage: boolean; + manage_follow_index: boolean; + manage_ilm: boolean; + manage_leader_index: boolean; + monitor: boolean; + read: boolean; + read_cross_cluster: boolean; + view_index_metadata: boolean; + write: boolean; + }; +} + +interface ListPrivileges { + is_authenticated: boolean; + lists: { + index: ListIndexPrivileges; + }; + listItems: { + index: ListIndexPrivileges; + }; +} + +const canManageIndex = (indexPrivileges: ListIndexPrivileges): boolean => { + const [indexName] = Object.keys(indexPrivileges); + const privileges = indexPrivileges[indexName]; + if (privileges == null) { + return false; + } + return privileges.manage; +}; + +const canWriteIndex = (indexPrivileges: ListIndexPrivileges): boolean => { + const [indexName] = Object.keys(indexPrivileges); + const privileges = indexPrivileges[indexName]; + if (privileges == null) { + return false; + } + + return privileges.create || privileges.create_doc || privileges.index || privileges.write; +}; + +export const useListsPrivileges = (): UseListsPrivilegesReturn => { + const [state, setState] = useState({ + isAuthenticated: null, + canManageIndex: null, + canWriteIndex: null, + }); + const { lists } = useKibana().services; + const http = useHttp(); + const toasts = useToasts(); + const { loading, start: readListPrivileges, ...privilegesState } = useReadListPrivileges(); + + const readPrivileges = useCallback(() => { + if (lists) { + readListPrivileges({ http }); + } + }, [http, lists, readListPrivileges]); + + // initRead + useEffect(() => { + if (!loading && state.isAuthenticated === null) { + readPrivileges(); + } + }, [loading, readPrivileges, state.isAuthenticated]); + + // handleReadResult + useEffect(() => { + if (privilegesState.result != null) { + try { + const { + is_authenticated: isAuthenticated, + lists: { index: listsPrivileges }, + listItems: { index: listItemsPrivileges }, + } = privilegesState.result as ListPrivileges; + + setState({ + isAuthenticated, + canManageIndex: canManageIndex(listsPrivileges) && canManageIndex(listItemsPrivileges), + canWriteIndex: canWriteIndex(listsPrivileges) && canWriteIndex(listItemsPrivileges), + }); + } catch (e) { + setState({ isAuthenticated: null, canManageIndex: false, canWriteIndex: false }); + } + } + }, [privilegesState.result]); + + // handleReadError + useEffect(() => { + const error = privilegesState.error; + if (isApiError(error)) { + setState({ isAuthenticated: null, canManageIndex: false, canWriteIndex: false }); + toasts.addError(error, { + title: i18n.LISTS_PRIVILEGES_READ_FAILURE, + toastMessage: error.body.message, + }); + } + }, [privilegesState.error, toasts]); + + return { loading, ...state }; +}; diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine.test.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine.test.tsx index fa7c85c95d87b..d5aa57ddd8754 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine.test.tsx @@ -14,6 +14,7 @@ import { DetectionEnginePageComponent } from './detection_engine'; import { useUserInfo } from '../../components/user_info'; import { useWithSource } from '../../../common/containers/source'; +jest.mock('../../containers/detection_engine/lists/use_lists_config'); jest.mock('../../components/user_info'); jest.mock('../../../common/containers/source'); jest.mock('../../../common/components/link_to'); diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine.tsx index 11f738320db6e..84cfc744312f9 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine.tsx @@ -34,6 +34,7 @@ import { useUserInfo } from '../../components/user_info'; import { OverviewEmpty } from '../../../overview/components/overview_empty'; import { DetectionEngineNoIndex } from './detection_engine_no_signal_index'; import { DetectionEngineHeaderPage } from '../../components/detection_engine_header_page'; +import { useListsConfig } from '../../containers/detection_engine/lists/use_lists_config'; import { DetectionEngineUserUnauthenticated } from './detection_engine_user_unauthenticated'; import * as i18n from './translations'; import { LinkButton } from '../../../common/components/links'; @@ -46,7 +47,7 @@ export const DetectionEnginePageComponent: React.FC = ({ }) => { const { to, from, deleteQuery, setQuery } = useGlobalTime(); const { - loading, + loading: userInfoLoading, isSignalIndexExists, isAuthenticated: isUserAuthenticated, hasEncryptionKey, @@ -54,9 +55,14 @@ export const DetectionEnginePageComponent: React.FC = ({ signalIndexName, hasIndexWrite, } = useUserInfo(); + const { + loading: listsConfigLoading, + needsConfiguration: needsListsConfiguration, + } = useListsConfig(); const history = useHistory(); const [lastAlerts] = useAlertInfo({}); const { formatUrl } = useFormatUrl(SecurityPageName.detections); + const loading = userInfoLoading || listsConfigLoading; const updateDateRangeCallback = useCallback( ({ x }) => { @@ -90,7 +96,8 @@ export const DetectionEnginePageComponent: React.FC = ({ ); } - if (isSignalIndexExists != null && !isSignalIndexExists && !loading) { + + if (!loading && (isSignalIndexExists === false || needsListsConfiguration)) { return ( diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/index.test.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/index.test.tsx index b7a2d017c3666..f7430a56c74d3 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/index.test.tsx @@ -22,6 +22,7 @@ jest.mock('react-router-dom', () => { }; }); +jest.mock('../../../../containers/detection_engine/lists/use_lists_config'); jest.mock('../../../../../common/components/link_to'); jest.mock('../../../../components/user_info'); diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/index.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/index.tsx index 6475b6f6b6b54..f6e13786e98d0 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/index.tsx @@ -10,6 +10,7 @@ import { useHistory } from 'react-router-dom'; import styled, { StyledComponent } from 'styled-components'; import { usePersistRule } from '../../../../containers/detection_engine/rules'; +import { useListsConfig } from '../../../../containers/detection_engine/lists/use_lists_config'; import { getRulesUrl, @@ -84,12 +85,17 @@ StepDefineRuleAccordion.displayName = 'StepDefineRuleAccordion'; const CreateRulePageComponent: React.FC = () => { const { - loading, + loading: userInfoLoading, isSignalIndexExists, isAuthenticated, hasEncryptionKey, canUserCRUD, } = useUserInfo(); + const { + loading: listsConfigLoading, + needsConfiguration: needsListsConfiguration, + } = useListsConfig(); + const loading = userInfoLoading || listsConfigLoading; const [, dispatchToaster] = useStateToaster(); const [openAccordionId, setOpenAccordionId] = useState(RuleStep.defineRule); const defineRuleRef = useRef(null); @@ -278,7 +284,14 @@ const CreateRulePageComponent: React.FC = () => { return null; } - if (redirectToDetections(isSignalIndexExists, isAuthenticated, hasEncryptionKey)) { + if ( + redirectToDetections( + isSignalIndexExists, + isAuthenticated, + hasEncryptionKey, + needsListsConfiguration + ) + ) { history.replace(getDetectionEngineUrl()); return null; } else if (userHasNoPermissions(canUserCRUD)) { diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/index.test.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/index.test.tsx index 11099e8cfc755..0a42602e5fbb2 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/index.test.tsx @@ -15,6 +15,7 @@ import { useUserInfo } from '../../../../components/user_info'; import { useWithSource } from '../../../../../common/containers/source'; import { useParams } from 'react-router-dom'; +jest.mock('../../../../containers/detection_engine/lists/use_lists_config'); jest.mock('../../../../../common/components/link_to'); jest.mock('../../../../components/user_info'); jest.mock('../../../../../common/containers/source'); 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 6ab08d94fa781..c74a2a3cf993a 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 @@ -34,6 +34,7 @@ import { import { SiemSearchBar } from '../../../../../common/components/search_bar'; import { WrapperPage } from '../../../../../common/components/wrapper_page'; import { useRule } from '../../../../containers/detection_engine/rules'; +import { useListsConfig } from '../../../../containers/detection_engine/lists/use_lists_config'; import { useWithSource } from '../../../../../common/containers/source'; import { SpyRoute } from '../../../../../common/utils/route/spy_routes'; @@ -105,7 +106,7 @@ export const RuleDetailsPageComponent: FC = ({ }) => { const { to, from, deleteQuery, setQuery } = useGlobalTime(); const { - loading, + loading: userInfoLoading, isSignalIndexExists, isAuthenticated, hasEncryptionKey, @@ -113,6 +114,11 @@ export const RuleDetailsPageComponent: FC = ({ hasIndexWrite, signalIndexName, } = useUserInfo(); + const { + loading: listsConfigLoading, + needsConfiguration: needsListsConfiguration, + } = useListsConfig(); + const loading = userInfoLoading || listsConfigLoading; const { detailName: ruleId } = useParams(); const [isLoading, rule] = useRule(ruleId); // This is used to re-trigger api rule status when user de/activate rule @@ -282,7 +288,14 @@ export const RuleDetailsPageComponent: FC = ({ } }, [rule]); - if (redirectToDetections(isSignalIndexExists, isAuthenticated, hasEncryptionKey)) { + if ( + redirectToDetections( + isSignalIndexExists, + isAuthenticated, + hasEncryptionKey, + needsListsConfiguration + ) + ) { history.replace(getDetectionEngineUrl()); return null; } diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/edit/index.test.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/edit/index.test.tsx index d754329bdd97f..71930e1523549 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/edit/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/edit/index.test.tsx @@ -12,6 +12,7 @@ import { EditRulePage } from './index'; import { useUserInfo } from '../../../../components/user_info'; import { useParams } from 'react-router-dom'; +jest.mock('../../../../containers/detection_engine/lists/use_lists_config'); jest.mock('../../../../../common/components/link_to'); jest.mock('../../../../components/user_info'); jest.mock('react-router-dom', () => { diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/edit/index.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/edit/index.tsx index 777f7766993d0..87cb5e77697b5 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/edit/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/edit/index.tsx @@ -20,6 +20,7 @@ import React, { FC, memo, useCallback, useEffect, useMemo, useRef, useState } fr import { useParams, useHistory } from 'react-router-dom'; import { useRule, usePersistRule } from '../../../../containers/detection_engine/rules'; +import { useListsConfig } from '../../../../containers/detection_engine/lists/use_lists_config'; import { WrapperPage } from '../../../../../common/components/wrapper_page'; import { getRuleDetailsUrl, @@ -74,12 +75,17 @@ const EditRulePageComponent: FC = () => { const history = useHistory(); const [, dispatchToaster] = useStateToaster(); const { - loading: initLoading, + loading: userInfoLoading, isSignalIndexExists, isAuthenticated, hasEncryptionKey, canUserCRUD, } = useUserInfo(); + const { + loading: listsConfigLoading, + needsConfiguration: needsListsConfiguration, + } = useListsConfig(); + const initLoading = userInfoLoading || listsConfigLoading; const { detailName: ruleId } = useParams(); const [loading, rule] = useRule(ruleId); @@ -365,7 +371,14 @@ const EditRulePageComponent: FC = () => { return null; } - if (redirectToDetections(isSignalIndexExists, isAuthenticated, hasEncryptionKey)) { + if ( + redirectToDetections( + isSignalIndexExists, + isAuthenticated, + hasEncryptionKey, + needsListsConfiguration + ) + ) { history.replace(getDetectionEngineUrl()); return null; } else if (userHasNoPermissions(canUserCRUD)) { diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/helpers.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/helpers.tsx index bf49ed5be90fb..6a98280076b30 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/helpers.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/helpers.tsx @@ -236,12 +236,13 @@ export const setFieldValue = ( export const redirectToDetections = ( isSignalIndexExists: boolean | null, isAuthenticated: boolean | null, - hasEncryptionKey: boolean | null + hasEncryptionKey: boolean | null, + needsListsConfiguration: boolean ) => - isSignalIndexExists != null && - isAuthenticated != null && - hasEncryptionKey != null && - (!isSignalIndexExists || !isAuthenticated || !hasEncryptionKey); + isSignalIndexExists === false || + isAuthenticated === false || + hasEncryptionKey === false || + needsListsConfiguration; export const getActionMessageRuleParams = (ruleType: RuleType): string[] => { const commonRuleParamsKeys = [ diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/index.test.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/index.test.tsx index f0ad670ddb665..9e30a735367b3 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/index.test.tsx @@ -22,6 +22,7 @@ jest.mock('react-router-dom', () => { }; }); +jest.mock('../../../containers/detection_engine/lists/use_lists_config'); jest.mock('../../../../common/components/link_to'); jest.mock('../../../components/user_info'); jest.mock('../../../containers/detection_engine/rules'); diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/index.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/index.tsx index 9cbc0e2aabfbe..84c34f2bed93c 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/index.tsx @@ -9,6 +9,7 @@ import React, { useCallback, useRef, useState } from 'react'; import { useHistory } from 'react-router-dom'; import { usePrePackagedRules, importRules } from '../../../containers/detection_engine/rules'; +import { useListsConfig } from '../../../containers/detection_engine/lists/use_lists_config'; import { getDetectionEngineUrl, getCreateRuleUrl, @@ -35,13 +36,18 @@ const RulesPageComponent: React.FC = () => { const [showImportModal, setShowImportModal] = useState(false); const refreshRulesData = useRef(null); const { - loading, + loading: userInfoLoading, isSignalIndexExists, isAuthenticated, hasEncryptionKey, canUserCRUD, hasIndexWrite, } = useUserInfo(); + const { + loading: listsConfigLoading, + needsConfiguration: needsListsConfiguration, + } = useListsConfig(); + const loading = userInfoLoading || listsConfigLoading; const { createPrePackagedRules, loading: prePackagedRuleLoading, @@ -58,12 +64,12 @@ const RulesPageComponent: React.FC = () => { isAuthenticated, hasEncryptionKey, }); + const { formatUrl } = useFormatUrl(SecurityPageName.detections); const prePackagedRuleStatus = getPrePackagedRuleStatus( rulesInstalled, rulesNotInstalled, rulesNotUpdated ); - const { formatUrl } = useFormatUrl(SecurityPageName.detections); const handleRefreshRules = useCallback(async () => { if (refreshRulesData.current != null) { @@ -96,7 +102,14 @@ const RulesPageComponent: React.FC = () => { [history] ); - if (redirectToDetections(isSignalIndexExists, isAuthenticated, hasEncryptionKey)) { + if ( + redirectToDetections( + isSignalIndexExists, + isAuthenticated, + hasEncryptionKey, + needsListsConfiguration + ) + ) { history.replace(getDetectionEngineUrl()); return null; } diff --git a/x-pack/plugins/security_solution/public/lists_plugin_deps.ts b/x-pack/plugins/security_solution/public/lists_plugin_deps.ts index e55fe13e6c9a0..2b37e2b7bf106 100644 --- a/x-pack/plugins/security_solution/public/lists_plugin_deps.ts +++ b/x-pack/plugins/security_solution/public/lists_plugin_deps.ts @@ -4,48 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -export { - useApi, - useExceptionList, - usePersistExceptionItem, - usePersistExceptionList, - useFindLists, - addExceptionListItem, - updateExceptionListItem, - fetchExceptionListById, - addExceptionList, - ExceptionIdentifiers, - ExceptionList, - Pagination, - UseExceptionListSuccess, -} from '../../lists/public'; -export { - ListSchema, - CommentsArray, - CreateCommentsArray, - Comments, - CreateComments, - ExceptionListSchema, - ExceptionListItemSchema, - CreateExceptionListItemSchema, - UpdateExceptionListItemSchema, - Entry, - EntryExists, - EntryNested, - EntryList, - EntriesArray, - NamespaceType, - Operator, - OperatorEnum, - OperatorType, - OperatorTypeEnum, - ExceptionListTypeEnum, - exceptionListItemSchema, - createExceptionListItemSchema, - listSchema, - entry, - entriesNested, - entriesExists, - entriesList, - ExceptionListType, -} from '../../lists/common/schemas'; +// DEPRECATED: Do not add exports to this file; please import from shared_imports instead + +export * from './shared_imports'; diff --git a/x-pack/plugins/security_solution/public/shared_imports.ts b/x-pack/plugins/security_solution/public/shared_imports.ts index 472006a9e55b1..93edc484c3569 100644 --- a/x-pack/plugins/security_solution/public/shared_imports.ts +++ b/x-pack/plugins/security_solution/public/shared_imports.ts @@ -4,6 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ +export * from '../common/shared_imports'; + export { getUseField, getFieldValidityAndErrorMessage, @@ -23,3 +25,23 @@ export { export { Field, SelectField } from '../../../../src/plugins/es_ui_shared/static/forms/components'; export { fieldValidators } from '../../../../src/plugins/es_ui_shared/static/forms/helpers'; export { ERROR_CODE } from '../../../../src/plugins/es_ui_shared/static/forms/helpers/field_validators/types'; + +export { + useIsMounted, + useApi, + useExceptionList, + usePersistExceptionItem, + usePersistExceptionList, + useFindLists, + useCreateListIndex, + useReadListIndex, + useReadListPrivileges, + addExceptionListItem, + updateExceptionListItem, + fetchExceptionListById, + addExceptionList, + ExceptionIdentifiers, + ExceptionList, + Pagination, + UseExceptionListSuccess, +} from '../../lists/public'; diff --git a/x-pack/plugins/security_solution/public/types.ts b/x-pack/plugins/security_solution/public/types.ts index f9c773a2fa1ab..3913b96b3e11a 100644 --- a/x-pack/plugins/security_solution/public/types.ts +++ b/x-pack/plugins/security_solution/public/types.ts @@ -14,6 +14,7 @@ import { UiActionsStart } from '../../../../src/plugins/ui_actions/public'; import { UsageCollectionSetup } from '../../../../src/plugins/usage_collection/public'; import { Storage } from '../../../../src/plugins/kibana_utils/public'; import { IngestManagerStart } from '../../ingest_manager/public'; +import { PluginStart as ListsPluginStart } from '../../lists/public'; import { TriggersAndActionsUIPublicPluginSetup as TriggersActionsSetup, TriggersAndActionsUIPublicPluginStart as TriggersActionsStart, @@ -33,6 +34,7 @@ export interface StartPlugins { embeddable: EmbeddableStart; inspector: InspectorStart; ingestManager?: IngestManagerStart; + lists?: ListsPluginStart; newsfeed?: NewsfeedStart; triggers_actions_ui: TriggersActionsStart; uiActions: UiActionsStart; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.test.ts index 4a6dd04656d8e..0cc3ca092a4dc 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.test.ts @@ -9,7 +9,7 @@ import sinon from 'sinon'; import { alertsMock, AlertServicesMock } from '../../../../../alerts/server/mocks'; import { listMock } from '../../../../../lists/server/mocks'; -import { EntriesArray } from '../../../../common/detection_engine/lists_common_deps'; +import { EntriesArray } from '../../../../common/shared_imports'; import { buildRuleMessageFactory } from './rule_messages'; import { ExceptionListClient } from '../../../../../lists/server'; import { getListArrayMock } from '../../../../common/detection_engine/schemas/types/lists.mock'; From 42cb6a4a26ddc65d88ef9cc99fac99dc15bce749 Mon Sep 17 00:00:00 2001 From: Spencer Date: Mon, 13 Jul 2020 15:16:11 -0700 Subject: [PATCH 39/66] [ftr] don't require the --no-debug flag to disable debug logging (#71535) Co-authored-by: spalger --- packages/kbn-dev-utils/src/run/run.ts | 9 +++++++-- packages/kbn-test/src/functional_test_runner/cli.ts | 4 +++- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/packages/kbn-dev-utils/src/run/run.ts b/packages/kbn-dev-utils/src/run/run.ts index 894db0d3fdadb..029d428565163 100644 --- a/packages/kbn-dev-utils/src/run/run.ts +++ b/packages/kbn-dev-utils/src/run/run.ts @@ -22,7 +22,7 @@ import { inspect } from 'util'; // @ts-ignore @types are outdated and module is super simple import exitHook from 'exit-hook'; -import { pickLevelFromFlags, ToolingLog } from '../tooling_log'; +import { pickLevelFromFlags, ToolingLog, LogLevel } from '../tooling_log'; import { createFlagError, isFailError } from './fail'; import { Flags, getFlags, getHelp } from './flags'; import { ProcRunner, withProcRunner } from '../proc_runner'; @@ -38,6 +38,9 @@ type RunFn = (args: { export interface Options { usage?: string; description?: string; + log?: { + defaultLevel?: LogLevel; + }; flags?: { allowUnexpected?: boolean; guessTypesForUnexpectedFlags?: boolean; @@ -58,7 +61,9 @@ export async function run(fn: RunFn, options: Options = {}) { } const log = new ToolingLog({ - level: pickLevelFromFlags(flags), + level: pickLevelFromFlags(flags, { + default: options.log?.defaultLevel, + }), writeTo: process.stdout, }); diff --git a/packages/kbn-test/src/functional_test_runner/cli.ts b/packages/kbn-test/src/functional_test_runner/cli.ts index 2a8e0c3d7de9a..d744be9467311 100644 --- a/packages/kbn-test/src/functional_test_runner/cli.ts +++ b/packages/kbn-test/src/functional_test_runner/cli.ts @@ -113,6 +113,9 @@ export function runFtrCli() { } }, { + log: { + defaultLevel: 'debug', + }, flags: { string: [ 'config', @@ -126,7 +129,6 @@ export function runFtrCli() { boolean: ['bail', 'invert', 'test-stats', 'updateBaselines', 'throttle', 'headless'], default: { config: 'test/functional/config.js', - debug: true, }, help: ` --config=path path to a config file From 439f2dd04704b74a881d2a705803b8c64f6513d2 Mon Sep 17 00:00:00 2001 From: Tyler Smalley Date: Mon, 13 Jul 2020 15:19:50 -0700 Subject: [PATCH 40/66] [skip test] Skips Alerting API test due to failing ES promotion https://github.com/elastic/kibana/issues/71558 Signed-off-by: Tyler Smalley --- .../security_and_spaces/tests/alerting/update.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/update.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/update.ts index 2bcc035beb7a9..37c0116396b1c 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/update.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/update.ts @@ -29,7 +29,8 @@ export default function createUpdateTests({ getService }: FtrProviderContext) { .then((response: SupertestResponse) => response.body); } - describe('update', () => { + // Failing ES promotion: https://github.com/elastic/kibana/issues/71558 + describe.skip('update', () => { const objectRemover = new ObjectRemover(supertest); after(() => objectRemover.removeAll()); From 0194f8c149ba2ce04341ebae42ee394d9cab1e1b Mon Sep 17 00:00:00 2001 From: Tyler Smalley Date: Mon, 13 Jul 2020 15:24:28 -0700 Subject: [PATCH 41/66] [test] Skips test preventing promotion of ES snapshot https://github.com/elastic/kibana/issues/71555 Signed-off-by: Tyler Smalley --- .../security_and_spaces/tests/create_rules_bulk.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_rules_bulk.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_rules_bulk.ts index 52865e43be750..897738d0919f2 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_rules_bulk.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_rules_bulk.ts @@ -29,7 +29,8 @@ export default ({ getService }: FtrProviderContext): void => { const supertest = getService('supertest'); const es = getService('es'); - describe('create_rules_bulk', () => { + // Preventing ES promotion: https://github.com/elastic/kibana/issues/71555 + describe.skip('create_rules_bulk', () => { describe('validation errors', () => { it('should give a 200 even if the index does not exist as all bulks return a 200 but have an error of 409 bad request in the body', async () => { const { body } = await supertest From b217cb3f969f6cd4fbe6faebb2c4045196c69ffa Mon Sep 17 00:00:00 2001 From: Tyler Smalley Date: Mon, 13 Jul 2020 15:26:34 -0700 Subject: [PATCH 42/66] [test] Skips Alerting test preventing ES snapshot promotion https://github.com/elastic/kibana/issues/71559 Signed-off-by: Tyler Smalley --- .../functional_with_es_ssl/apps/triggers_actions_ui/details.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/details.ts b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/details.ts index d86d272c1da8c..4c33a709d9bf9 100644 --- a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/details.ts +++ b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/details.ts @@ -19,7 +19,8 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { const retry = getService('retry'); const find = getService('find'); - describe('Alert Details', function () { + // Failing ES Promotion: https://github.com/elastic/kibana/issues/71559 + describe.skip('Alert Details', function () { describe('Header', function () { const testRunUuid = uuid.v4(); before(async () => { From 9e99f739a88fa1fc042a3e41a504a5aad8ebbad2 Mon Sep 17 00:00:00 2001 From: Paul Tavares <56442535+paul-tavares@users.noreply.github.com> Date: Mon, 13 Jul 2020 19:03:34 -0400 Subject: [PATCH 43/66] [SECURITY_SOLUTION][ENDPOINT] Fix Policy Details Name to ensure it truncates the value when its too long (#71526) * Fix title not truncated on policy details --- .../__snapshots__/page_view.test.tsx.snap | 44 +++++++++++++++++-- .../common/components/endpoint/page_view.tsx | 25 +++++++---- .../pages/policy/view/policy_details.tsx | 2 +- 3 files changed, 58 insertions(+), 13 deletions(-) diff --git a/x-pack/plugins/security_solution/public/common/components/endpoint/__snapshots__/page_view.test.tsx.snap b/x-pack/plugins/security_solution/public/common/components/endpoint/__snapshots__/page_view.test.tsx.snap index 096df5ceab256..bed5ac6950a2b 100644 --- a/x-pack/plugins/security_solution/public/common/components/endpoint/__snapshots__/page_view.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/common/components/endpoint/__snapshots__/page_view.test.tsx.snap @@ -25,6 +25,10 @@ exports[`PageView component should display body header custom element 1`] = ` margin-left: 12px; } +.c0 .endpoint-header-leftSection { + overflow: hidden; +} + @@ -120,6 +124,10 @@ exports[`PageView component should display body header wrapped in EuiTitle 1`] = margin-left: 12px; } +.c0 .endpoint-header-leftSection { + overflow: hidden; +} +
@@ -331,6 +344,10 @@ exports[`PageView component should display only body if not header props used 1` margin-left: 12px; } +.c0 .endpoint-header-leftSection { + overflow: hidden; +} + @@ -403,6 +420,10 @@ exports[`PageView component should display only header left 1`] = ` margin-left: 12px; } +.c0 .endpoint-header-leftSection { + overflow: hidden; +} +
@@ -505,6 +527,10 @@ exports[`PageView component should display only header right but include an empt margin-left: 12px; } +.c0 .endpoint-header-leftSection { + overflow: hidden; +} +
@@ -604,6 +631,10 @@ exports[`PageView component should pass through EuiPage props 1`] = ` margin-left: 12px; } +.c0 .endpoint-header-leftSection { + overflow: hidden; +} + @@ -721,10 +756,11 @@ exports[`PageView component should use custom element for header left and not wr className="euiPageHeader euiPageHeader--responsive endpoint-header" >

diff --git a/x-pack/plugins/security_solution/public/common/components/endpoint/page_view.tsx b/x-pack/plugins/security_solution/public/common/components/endpoint/page_view.tsx index 3d2a1d2d6fc9b..d4753b3a64e24 100644 --- a/x-pack/plugins/security_solution/public/common/components/endpoint/page_view.tsx +++ b/x-pack/plugins/security_solution/public/common/components/endpoint/page_view.tsx @@ -17,6 +17,7 @@ import { EuiTab, EuiTabs, EuiTitle, + EuiTitleProps, } from '@elastic/eui'; import React, { memo, MouseEventHandler, ReactNode, useMemo } from 'react'; import styled from 'styled-components'; @@ -45,6 +46,9 @@ const StyledEuiPage = styled(EuiPage)` .endpoint-navTabs { margin-left: ${(props) => props.theme.eui.euiSizeM}; } + .endpoint-header-leftSection { + overflow: hidden; + } `; const isStringOrNumber = /(string|number)/; @@ -54,13 +58,15 @@ const isStringOrNumber = /(string|number)/; * Can be used when wanting to customize the `headerLeft` value but still use the standard * title component */ -export const PageViewHeaderTitle = memo<{ children: ReactNode }>(({ children }) => { - return ( - -

{children}

- - ); -}); +export const PageViewHeaderTitle = memo & { children: ReactNode }>( + ({ children, size = 'l', ...otherProps }) => { + return ( + +

{children}

+
+ ); + } +); PageViewHeaderTitle.displayName = 'PageViewHeaderTitle'; @@ -135,7 +141,10 @@ export const PageView = memo( {(headerLeft || headerRight) && ( - + {isStringOrNumber.test(typeof headerLeft) ? ( {headerLeft} ) : ( diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_details.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_details.tsx index 2a4f839a4af1f..b5861b68a0756 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_details.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_details.tsx @@ -168,7 +168,7 @@ export const PolicyDetails = React.memo(() => { defaultMessage="Back to policy list" /> - {policyItem.name} + {policyItem.name}
); From 3d5afa90d2a379880dc38d30316c351bce6f28b3 Mon Sep 17 00:00:00 2001 From: Jen Huang Date: Mon, 13 Jul 2020 16:21:33 -0700 Subject: [PATCH 44/66] [Ingest Manager] Remove `epm` config options (#71542) * Remove `epm.enabled`, flatten `epm.registryUrl` * Update docs --- docs/settings/ingest-manager-settings.asciidoc | 4 +--- x-pack/plugins/ingest_manager/README.md | 2 +- x-pack/plugins/ingest_manager/common/types/index.ts | 5 +---- .../public/applications/ingest_manager/index.tsx | 6 +++--- .../applications/ingest_manager/layouts/default.tsx | 8 ++------ .../applications/ingest_manager/sections/epm/index.tsx | 7 +++---- x-pack/plugins/ingest_manager/server/index.ts | 5 +---- x-pack/plugins/ingest_manager/server/plugin.ts | 5 +---- .../server/services/epm/registry/registry_url.ts | 2 +- x-pack/test/ingest_manager_api_integration/config.ts | 2 +- x-pack/test/security_solution_cypress/config.ts | 1 - 11 files changed, 15 insertions(+), 32 deletions(-) diff --git a/docs/settings/ingest-manager-settings.asciidoc b/docs/settings/ingest-manager-settings.asciidoc index f46c769079040..604471edc4d59 100644 --- a/docs/settings/ingest-manager-settings.asciidoc +++ b/docs/settings/ingest-manager-settings.asciidoc @@ -20,8 +20,6 @@ See the {ingest-guide}/index.html[Ingest Management] docs for more information. |=== | `xpack.ingestManager.enabled` {ess-icon} | Set to `true` to enable {ingest-manager}. -| `xpack.ingestManager.epm.enabled` {ess-icon} - | Set to `true` (default) to enable {package-manager}. | `xpack.ingestManager.fleet.enabled` {ess-icon} | Set to `true` (default) to enable {fleet}. |=== @@ -32,7 +30,7 @@ See the {ingest-guide}/index.html[Ingest Management] docs for more information. [cols="2*<"] |=== -| `xpack.ingestManager.epm.registryUrl` +| `xpack.ingestManager.registryUrl` | The address to use to reach {package-manager} registry. |=== diff --git a/x-pack/plugins/ingest_manager/README.md b/x-pack/plugins/ingest_manager/README.md index eebafc76a5e00..1a19672331035 100644 --- a/x-pack/plugins/ingest_manager/README.md +++ b/x-pack/plugins/ingest_manager/README.md @@ -4,11 +4,11 @@ - The plugin is disabled by default. See the TypeScript type for the [the available plugin configuration options](https://github.com/elastic/kibana/blob/master/x-pack/plugins/ingest_manager/common/types/index.ts#L9-L27) - Setting `xpack.ingestManager.enabled=true` enables the plugin including the EPM and Fleet features. It also adds the `PACKAGE_CONFIG_API_ROUTES` and `AGENT_CONFIG_API_ROUTES` values in [`common/constants/routes.ts`](./common/constants/routes.ts) -- Adding `--xpack.ingestManager.epm.enabled=false` will disable the EPM API & UI - Adding `--xpack.ingestManager.fleet.enabled=false` will disable the Fleet API & UI - [code for adding the routes](https://github.com/elastic/kibana/blob/1f27d349533b1c2865c10c45b2cf705d7416fb36/x-pack/plugins/ingest_manager/server/plugin.ts#L115-L133) - [Integration tests](server/integration_tests/router.test.ts) - Both EPM and Fleet require `ingestManager` be enabled. They are not standalone features. +- For Gold+ license, a custom package registry URL can be used by setting `xpack.ingestManager.registryUrl=http://localhost:8080` ## Fleet Requirements diff --git a/x-pack/plugins/ingest_manager/common/types/index.ts b/x-pack/plugins/ingest_manager/common/types/index.ts index ff08b8a925204..0fce5cfa6226f 100644 --- a/x-pack/plugins/ingest_manager/common/types/index.ts +++ b/x-pack/plugins/ingest_manager/common/types/index.ts @@ -8,10 +8,7 @@ export * from './rest_spec'; export interface IngestManagerConfigType { enabled: boolean; - epm: { - enabled: boolean; - registryUrl?: string; - }; + registryUrl?: string; fleet: { enabled: boolean; tlsCheckDisabled: boolean; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/index.tsx index 94d3379f35e05..0eaf785405590 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/index.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/index.tsx @@ -59,7 +59,7 @@ const ErrorLayout = ({ children }: { children: JSX.Element }) => ( const IngestManagerRoutes = memo<{ history: AppMountParameters['history']; basepath: string }>( ({ history, ...rest }) => { - const { epm, fleet } = useConfig(); + const { fleet } = useConfig(); const { notifications } = useCore(); const [isPermissionsLoading, setIsPermissionsLoading] = useState(false); @@ -186,11 +186,11 @@ const IngestManagerRoutes = memo<{ history: AppMountParameters['history']; basep - + - + diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/layouts/default.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/layouts/default.tsx index 1f356301b714a..09da96fac4462 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/layouts/default.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/layouts/default.tsx @@ -41,7 +41,7 @@ export const DefaultLayout: React.FunctionComponent = ({ children, }) => { const { getHref } = useLink(); - const { epm, fleet } = useConfig(); + const { fleet } = useConfig(); const { uiSettings } = useCore(); const [isSettingsFlyoutOpen, setIsSettingsFlyoutOpen] = React.useState(false); @@ -71,11 +71,7 @@ export const DefaultLayout: React.FunctionComponent = ({ defaultMessage="Overview" /> - + { useBreadcrumbs('integrations'); - const { epm } = useConfig(); - return epm.enabled ? ( + return ( @@ -30,5 +29,5 @@ export const EPMApp: React.FunctionComponent = () => { - ) : null; + ); }; diff --git a/x-pack/plugins/ingest_manager/server/index.ts b/x-pack/plugins/ingest_manager/server/index.ts index 811ec8a3d0222..1823cc3561693 100644 --- a/x-pack/plugins/ingest_manager/server/index.ts +++ b/x-pack/plugins/ingest_manager/server/index.ts @@ -21,10 +21,7 @@ export const config = { }, schema: schema.object({ enabled: schema.boolean({ defaultValue: false }), - epm: schema.object({ - enabled: schema.boolean({ defaultValue: true }), - registryUrl: schema.maybe(schema.uri()), - }), + registryUrl: schema.maybe(schema.uri()), fleet: schema.object({ enabled: schema.boolean({ defaultValue: true }), tlsCheckDisabled: schema.boolean({ defaultValue: false }), diff --git a/x-pack/plugins/ingest_manager/server/plugin.ts b/x-pack/plugins/ingest_manager/server/plugin.ts index d1adbd8b2f65d..e32533dc907b9 100644 --- a/x-pack/plugins/ingest_manager/server/plugin.ts +++ b/x-pack/plugins/ingest_manager/server/plugin.ts @@ -215,12 +215,9 @@ export class IngestManagerPlugin registerOutputRoutes(router); registerSettingsRoutes(router); registerDataStreamRoutes(router); + registerEPMRoutes(router); // Conditional config routes - if (config.epm.enabled) { - registerEPMRoutes(router); - } - if (config.fleet.enabled) { const isESOUsingEphemeralEncryptionKey = deps.encryptedSavedObjects.usingEphemeralEncryptionKey; diff --git a/x-pack/plugins/ingest_manager/server/services/epm/registry/registry_url.ts b/x-pack/plugins/ingest_manager/server/services/epm/registry/registry_url.ts index 90232eb8f29e3..47c9121808988 100644 --- a/x-pack/plugins/ingest_manager/server/services/epm/registry/registry_url.ts +++ b/x-pack/plugins/ingest_manager/server/services/epm/registry/registry_url.ts @@ -8,7 +8,7 @@ import { appContextService, licenseService } from '../../'; export const getRegistryUrl = (): string => { const license = licenseService.getLicenseInformation(); - const customUrl = appContextService.getConfig()?.epm.registryUrl; + const customUrl = appContextService.getConfig()?.registryUrl; if ( customUrl && diff --git a/x-pack/test/ingest_manager_api_integration/config.ts b/x-pack/test/ingest_manager_api_integration/config.ts index 88ec8d53c1cde..e3cdf0eff4b3a 100644 --- a/x-pack/test/ingest_manager_api_integration/config.ts +++ b/x-pack/test/ingest_manager_api_integration/config.ts @@ -63,7 +63,7 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { serverArgs: [ ...xPackAPITestsConfig.get('kbnTestServer.serverArgs'), ...(registryPort - ? [`--xpack.ingestManager.epm.registryUrl=http://localhost:${registryPort}`] + ? [`--xpack.ingestManager.registryUrl=http://localhost:${registryPort}`] : []), ], }, diff --git a/x-pack/test/security_solution_cypress/config.ts b/x-pack/test/security_solution_cypress/config.ts index 0e92add2c6665..1ad3a36cc57ae 100644 --- a/x-pack/test/security_solution_cypress/config.ts +++ b/x-pack/test/security_solution_cypress/config.ts @@ -47,7 +47,6 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { // define custom kibana server args here `--elasticsearch.ssl.certificateAuthorities=${CA_CERT_PATH}`, '--xpack.ingestManager.enabled=true', - '--xpack.ingestManager.epm.enabled=true', '--xpack.ingestManager.fleet.enabled=true', ], }, From 00f03fbf34f13294414388c1bca26e02eaba8c52 Mon Sep 17 00:00:00 2001 From: Kevin Logan <56395104+kevinlog@users.noreply.github.com> Date: Mon, 13 Jul 2020 19:36:29 -0400 Subject: [PATCH 45/66] [SECURITY_SOLUTION] add onboarding logo (#71471) --- .../components/management_empty_state.tsx | 41 ++++++++++++------- .../security_administration_onboarding.svg | 1 + .../pages/endpoint_hosts/view/index.tsx | 2 +- .../components/endpoint_notice/index.tsx | 2 +- 4 files changed, 30 insertions(+), 16 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/management/images/security_administration_onboarding.svg diff --git a/x-pack/plugins/security_solution/public/management/components/management_empty_state.tsx b/x-pack/plugins/security_solution/public/management/components/management_empty_state.tsx index 6486b1f3be6d1..fb9f97f3f7570 100644 --- a/x-pack/plugins/security_solution/public/management/components/management_empty_state.tsx +++ b/x-pack/plugins/security_solution/public/management/components/management_empty_state.tsx @@ -18,14 +18,21 @@ import { EuiSelectableProps, EuiIcon, EuiLoadingSpinner, + EuiLink, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; +import onboardingLogo from '../images/security_administration_onboarding.svg'; const TEXT_ALIGN_CENTER: CSSProperties = Object.freeze({ textAlign: 'center', }); +const MAX_SIZE_ONBOARDING_LOGO: CSSProperties = Object.freeze({ + maxWidth: 550, + maxHeight: 420, +}); + interface ManagementStep { title: string; children: JSX.Element; @@ -45,7 +52,7 @@ const PolicyEmptyState = React.memo<{ ) : ( - +

@@ -55,26 +62,26 @@ const PolicyEmptyState = React.memo<{ />

- + - + - - + + - + @@ -91,14 +98,14 @@ const PolicyEmptyState = React.memo<{ - + @@ -120,14 +127,20 @@ const PolicyEmptyState = React.memo<{ - + + + + - + - + )} diff --git a/x-pack/plugins/security_solution/public/management/images/security_administration_onboarding.svg b/x-pack/plugins/security_solution/public/management/images/security_administration_onboarding.svg new file mode 100644 index 0000000000000..33bdae381fc1c --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/images/security_administration_onboarding.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx index 8edeab15d6a09..6c6ab3930d7ab 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx @@ -401,7 +401,7 @@ export const HostList = () => {

diff --git a/x-pack/plugins/security_solution/public/overview/components/endpoint_notice/index.tsx b/x-pack/plugins/security_solution/public/overview/components/endpoint_notice/index.tsx index 3758bd10bfc8f..7170412cb55ad 100644 --- a/x-pack/plugins/security_solution/public/overview/components/endpoint_notice/index.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/endpoint_notice/index.tsx @@ -42,7 +42,7 @@ export const EndpointNotice = memo<{ onDismiss: () => void }>(({ onDismiss }) =>

{/* eslint-disable-next-line @elastic/eui/href-or-on-click*/} From 82562a8e251fb0bfca68f3c5ce7bf096461eb7d5 Mon Sep 17 00:00:00 2001 From: Henry Harding Date: Mon, 13 Jul 2020 20:05:45 -0400 Subject: [PATCH 46/66] Add tooltips to Ingest manager overview section and update text to say Beta (#71373) * add tooltips and beta label to Ingest Manager overview page * updated footer messaging and about-this-release flyout * forgot to remove commented out code * fixed responsive issue with tooltip * removed unused import * fix i18n * update link to docs * update text Co-authored-by: Elastic Machine --- .../components/alpha_flyout.tsx | 58 +++++++------------ .../components/alpha_messaging.tsx | 11 ++-- .../overview/components/agent_section.tsx | 36 +++++------- .../components/configuration_section.tsx | 35 +++++------ .../components/datastream_section.tsx | 35 +++++------ .../components/integration_section.tsx | 36 +++++------- .../overview/components/overview_panel.tsx | 49 +++++++++++++++- .../sections/overview/index.tsx | 45 +++++++------- .../translations/translations/ja-JP.json | 5 -- .../translations/translations/zh-CN.json | 5 -- 10 files changed, 158 insertions(+), 157 deletions(-) diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/alpha_flyout.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/alpha_flyout.tsx index 1e7a14e350229..03c70f71529c9 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/alpha_flyout.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/alpha_flyout.tsx @@ -38,50 +38,34 @@ export const AlphaFlyout: React.FunctionComponent = ({ onClose }) => {

- - - - ), - forumLink: ( - - - - ), - }} - /> -

-

+ docsLink: ( + + + + ), + forumLink: ( + - + ), }} /> diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/alpha_messaging.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/alpha_messaging.tsx index f43419fc52ef0..ca4dfcb685e7b 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/alpha_messaging.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/alpha_messaging.tsx @@ -28,17 +28,20 @@ export const AlphaMessaging: React.FC<{}> = () => { {' – '} {' '} setIsAlphaFlyoutOpen(true)}> - View more details. +

diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/overview/components/agent_section.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/overview/components/agent_section.tsx index 6e61a55466e87..7e33589bffea1 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/overview/components/agent_section.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/overview/components/agent_section.tsx @@ -5,13 +5,13 @@ */ import React from 'react'; -import { EuiFlexItem, EuiI18nNumber } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; import { - EuiTitle, - EuiButtonEmpty, + EuiI18nNumber, EuiDescriptionListTitle, EuiDescriptionListDescription, + EuiFlexItem, } from '@elastic/eui'; import { OverviewPanel } from './overview_panel'; import { OverviewStats } from './overview_stats'; @@ -24,23 +24,19 @@ export const OverviewAgentSection = () => { return ( - -
- -

- -

-
- - - -
+ {agentStatusRequest.isLoading ? ( diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/overview/components/configuration_section.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/overview/components/configuration_section.tsx index 5a5e901d629b5..56aaba1d43321 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/overview/components/configuration_section.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/overview/components/configuration_section.tsx @@ -5,11 +5,11 @@ */ import React from 'react'; -import { EuiFlexItem, EuiI18nNumber } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; import { - EuiTitle, - EuiButtonEmpty, + EuiFlexItem, + EuiI18nNumber, EuiDescriptionListTitle, EuiDescriptionListDescription, } from '@elastic/eui'; @@ -30,23 +30,18 @@ export const OverviewConfigurationSection: React.FC<{ agentConfigs: AgentConfig[ return ( - -
- -

- -

-
- - - -
+ {packageConfigsRequest.isLoading ? ( diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/overview/components/datastream_section.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/overview/components/datastream_section.tsx index eab6cf087e127..41c011de2da5c 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/overview/components/datastream_section.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/overview/components/datastream_section.tsx @@ -5,11 +5,11 @@ */ import React from 'react'; -import { EuiFlexItem, EuiI18nNumber } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; import { - EuiTitle, - EuiButtonEmpty, + EuiFlexItem, + EuiI18nNumber, EuiDescriptionListTitle, EuiDescriptionListDescription, } from '@elastic/eui'; @@ -45,23 +45,18 @@ export const OverviewDatastreamSection: React.FC = () => { return ( - -
- -

- -

-
- - - -
+ {datastreamRequest.isLoading ? ( diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/overview/components/integration_section.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/overview/components/integration_section.tsx index b4669b0a0569b..ba16b47e73051 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/overview/components/integration_section.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/overview/components/integration_section.tsx @@ -5,11 +5,11 @@ */ import React from 'react'; -import { EuiFlexItem, EuiI18nNumber } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; import { - EuiTitle, - EuiButtonEmpty, + EuiFlexItem, + EuiI18nNumber, EuiDescriptionListTitle, EuiDescriptionListDescription, } from '@elastic/eui'; @@ -31,23 +31,19 @@ export const OverviewIntegrationSection: React.FC = () => { )?.length ?? 0; return ( - -
- -

- -

-
- - - -
+ {packagesRequest.isLoading ? ( diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/overview/components/overview_panel.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/overview/components/overview_panel.tsx index 2e75d1e4690d6..65811261a6d6b 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/overview/components/overview_panel.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/overview/components/overview_panel.tsx @@ -4,10 +4,18 @@ * you may not use this file except in compliance with the Elastic License. */ +import React from 'react'; import styled from 'styled-components'; -import { EuiPanel } from '@elastic/eui'; +import { + EuiPanel, + EuiFlexGroup, + EuiFlexItem, + EuiTitle, + EuiIconTip, + EuiButtonEmpty, +} from '@elastic/eui'; -export const OverviewPanel = styled(EuiPanel).attrs((props) => ({ +const StyledPanel = styled(EuiPanel).attrs((props) => ({ paddingSize: 'm', }))` header { @@ -26,3 +34,40 @@ export const OverviewPanel = styled(EuiPanel).attrs((props) => ({ padding: ${(props) => props.theme.eui.paddingSizes.xs} 0; } `; + +interface OverviewPanelProps { + title: string; + tooltip: string; + linkToText: string; + linkTo: string; + children: React.ReactNode; +} + +export const OverviewPanel = ({ + title, + tooltip, + linkToText, + linkTo, + children, +}: OverviewPanelProps) => { + return ( + +
+ + + +

{title}

+
+
+ + + +
+ + {linkToText} + +
+ {children} +
+ ); +}; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/overview/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/overview/index.tsx index ca4151fa5c46f..f4b68f0c5107e 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/overview/index.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/overview/index.tsx @@ -4,11 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ import React, { useState } from 'react'; -import styled from 'styled-components'; import { EuiButton, EuiBetaBadge, EuiText, + EuiTitle, EuiFlexGrid, EuiFlexGroup, EuiFlexItem, @@ -23,11 +23,6 @@ import { OverviewConfigurationSection } from './components/configuration_section import { OverviewIntegrationSection } from './components/integration_section'; import { OverviewDatastreamSection } from './components/datastream_section'; -const AlphaBadge = styled(EuiBetaBadge)` - vertical-align: top; - margin-left: ${(props) => props.theme.eui.paddingSizes.s}; -`; - export const IngestManagerOverview: React.FunctionComponent = () => { useBreadcrumbs('overview'); @@ -46,26 +41,30 @@ export const IngestManagerOverview: React.FunctionComponent = () => { leftColumn={ - -

- - + + +

+ +

+
+
+ + + -

-
+
+
@@ -102,9 +101,7 @@ export const IngestManagerOverview: React.FunctionComponent = () => { - - diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index cba436f2e8b3b..4050982a6ef99 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -8098,9 +8098,6 @@ "xpack.ingestManager.agentReassignConfig.flyoutTitle": "新しいエージェント構成を割り当て", "xpack.ingestManager.agentReassignConfig.selectConfigLabel": "エージェント構成", "xpack.ingestManager.agentReassignConfig.successSingleNotificationTitle": "新しいエージェント構成が再割り当てされました", - "xpack.ingestManager.alphaBadge.labelText": "実験的", - "xpack.ingestManager.alphaBadge.titleText": "実験的", - "xpack.ingestManager.alphaBadge.tooltipText": "このプラグインは今後のリリースで変更または削除される可能性があり、SLAのサポート対象になりません。", "xpack.ingestManager.alphaMessageDescription": "Ingest Managerは開発中であり、本番用ではありません。", "xpack.ingestManager.alphaMessageTitle": "実験的", "xpack.ingestManager.alphaMessaging.docsLink": "ドキュメンテーション", @@ -8108,8 +8105,6 @@ "xpack.ingestManager.alphaMessaging.flyoutTitle": "このリリースについて", "xpack.ingestManager.alphaMessaging.forumLink": "ディスカッションフォーラム", "xpack.ingestManager.alphaMessaging.introText": "このリリースはテスト段階であり、SLAの対象ではありません。ユーザーがIngest Managerと新しいElasticエージェントをテストしてフィードバックを提供することを目的としています。今後のリリースにおいて特定の機能が変更されたり、廃止されたりする可能性があるため、本番環境で使用しないでください。", - "xpack.ingestManager.alphaMessaging.warningNote": "注", - "xpack.ingestManager.alphaMessaging.warningText": "{note}:今後のリリースでは表示が制限されるため、Ingest Managerでは重要なデータを保存しないでください。このバージョンは、今後のリリースで廃止予定のインデックスストラテジーを使用していて、移行方法はありません。また、特定の機能のライセンスは検討中であり、今後変更される場合があります。結果として、ライセンスティアによっては、特定の機能へのアクセスが失われる場合があります。", "xpack.ingestManager.alphaMessging.closeFlyoutLabel": "閉じる", "xpack.ingestManager.appNavigation.configurationsLinkText": "構成", "xpack.ingestManager.appNavigation.dataStreamsLinkText": "データストリーム", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index f512ad1046bac..7fc142a7684a1 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -8103,9 +8103,6 @@ "xpack.ingestManager.agentReassignConfig.flyoutTitle": "分配新代理配置", "xpack.ingestManager.agentReassignConfig.selectConfigLabel": "代理配置", "xpack.ingestManager.agentReassignConfig.successSingleNotificationTitle": "代理配置已重新分配", - "xpack.ingestManager.alphaBadge.labelText": "实验性", - "xpack.ingestManager.alphaBadge.titleText": "实验性", - "xpack.ingestManager.alphaBadge.tooltipText": "在未来的版本中可能会更改或移除此插件,其不受支持 SLA 的约束。", "xpack.ingestManager.alphaMessageDescription": "Ingest Manager 仍处于开发状态,不适用于生产用途。", "xpack.ingestManager.alphaMessageTitle": "实验性", "xpack.ingestManager.alphaMessaging.docsLink": "文档", @@ -8113,8 +8110,6 @@ "xpack.ingestManager.alphaMessaging.flyoutTitle": "关于本版本", "xpack.ingestManager.alphaMessaging.forumLink": "讨论论坛", "xpack.ingestManager.alphaMessaging.introText": "本版本为实验性版本,不受支持 SLA 的约束。其用于用户测试 Ingest Manager 和新 Elastic 代理并提供相关反馈。因为在未来版本中可能更改或移除某些功能,所以不适用于生产环境。", - "xpack.ingestManager.alphaMessaging.warningNote": "注意", - "xpack.ingestManager.alphaMessaging.warningText": "{note}:不应使用 Ingest Manager 存储重要的数据,因为在未来的版本中可能看不到这些数据。此版本将使用在未来版本中会过时的索引策略,而且没有迁移路径。另外,某些功能的许可方式正在考虑之中,将来可能会变更。因为,根据您的许可证级别,您可能无法使用某些功能。", "xpack.ingestManager.alphaMessging.closeFlyoutLabel": "关闭", "xpack.ingestManager.appNavigation.configurationsLinkText": "配置", "xpack.ingestManager.appNavigation.dataStreamsLinkText": "数据流", From ddd8fa8947a57c7bb06475ef809860917b356970 Mon Sep 17 00:00:00 2001 From: Caroline Horn <549577+cchaos@users.noreply.github.com> Date: Mon, 13 Jul 2020 20:06:58 -0400 Subject: [PATCH 47/66] [Lens] 7.9 design cleanup (#71444) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix dimension popover layout and color picker “Auto” * Created ToolbarButton * Move disabled help text to tooltip for missing values * Darker side panel backgrounds * Adding to .asciidoc about where to put the SASS import * Moving `SASS` guidelines to STYLEGUIDE.md * Fix keyboard focus of XY settings popover * Fix dark mode --- STYLEGUIDE.md | 44 ++++++- docs/developer/getting-started/index.asciidoc | 12 +- docs/developer/getting-started/sass.asciidoc | 36 ------ .../editor_frame/_data_panel_wrapper.scss | 1 + .../editor_frame/_frame_layout.scss | 7 +- .../config_panel/_layer_panel.scss | 7 +- .../config_panel/dimension_popover.tsx | 3 +- .../editor_frame/config_panel/layer_panel.tsx | 24 ++-- .../config_panel/layer_settings.tsx | 15 ++- .../workspace_panel/chart_switch.scss | 8 +- .../workspace_panel/chart_switch.tsx | 14 +-- .../workspace_panel/workspace_panel.tsx | 27 +++-- .../change_indexpattern.tsx | 24 ++-- .../indexpattern_datasource/datapanel.scss | 8 +- .../indexpattern_datasource/datapanel.tsx | 2 +- .../dimension_panel/popover_editor.scss | 10 +- .../dimension_panel/popover_editor.tsx | 40 ++++--- .../indexpattern_datasource/layerpanel.tsx | 3 +- .../lens/public/toolbar_button/index.tsx | 7 ++ .../public/toolbar_button/toolbar_button.scss | 30 +++++ .../public/toolbar_button/toolbar_button.tsx | 53 ++++++++ .../xy_visualization/xy_config_panel.tsx | 113 +++++++++--------- 22 files changed, 284 insertions(+), 204 deletions(-) delete mode 100644 docs/developer/getting-started/sass.asciidoc create mode 100644 x-pack/plugins/lens/public/toolbar_button/index.tsx create mode 100644 x-pack/plugins/lens/public/toolbar_button/toolbar_button.scss create mode 100644 x-pack/plugins/lens/public/toolbar_button/toolbar_button.tsx diff --git a/STYLEGUIDE.md b/STYLEGUIDE.md index 48d4f929b6851..4ea7b04ebef6d 100644 --- a/STYLEGUIDE.md +++ b/STYLEGUIDE.md @@ -3,11 +3,18 @@ This guide applies to all development within the Kibana project and is recommended for the development of all Kibana plugins. +- [General](#general) +- [HTML](#html) +- [API endpoints](#api-endpoints) +- [TypeScript/JavaScript](#typeScript/javaScript) +- [SASS files](#sass-files) +- [React](#react) + Besides the content in this style guide, the following style guides may also apply to all development within the Kibana project. Please make sure to also read them: -- [Accessibility style guide](https://elastic.github.io/eui/#/guidelines/accessibility) -- [SASS style guide](https://elastic.github.io/eui/#/guidelines/sass) +- [Accessibility style guide (EUI Docs)](https://elastic.github.io/eui/#/guidelines/accessibility) +- [SASS style guide (EUI Docs)](https://elastic.github.io/eui/#/guidelines/sass) ## General @@ -582,6 +589,39 @@ Do not use setters, they cause more problems than they can solve. [sideeffect]: http://en.wikipedia.org/wiki/Side_effect_(computer_science) +## SASS files + +When writing a new component, create a sibling SASS file of the same name and import directly into the **top** of the JS/TS component file. Doing so ensures the styles are never separated or lost on import and allows for better modularization (smaller individual plugin asset footprint). + +All SASS (.scss) files will automatically build with the [EUI](https://elastic.github.io/eui/#/guidelines/sass) & Kibana invisibles (SASS variables, mixins, functions) from the [`globals_[theme].scss` file](src/legacy/ui/public/styles/_globals_v7light.scss). + +While the styles for this component will only be loaded if the component exists on the page, +the styles **will** be global and so it is recommended to use a three letter prefix on your +classes to ensure proper scope. + +**Example:** + +```tsx +// component.tsx + +import './component.scss'; +// All other imports below the SASS import + +export const Component = () => { + return ( +
+ ); +} +``` + +```scss +// component.scss + +.plgComponent { ... } +``` + +Do not use the underscore `_` SASS file naming pattern when importing directly into a javascript file. + ## React The following style guide rules are specific for working with the React framework. diff --git a/docs/developer/getting-started/index.asciidoc b/docs/developer/getting-started/index.asciidoc index ff1623e22f1eb..47c4a52daf303 100644 --- a/docs/developer/getting-started/index.asciidoc +++ b/docs/developer/getting-started/index.asciidoc @@ -29,7 +29,7 @@ you can switch to the correct version when using nvm by running: ---- nvm use ---- - + Install the latest version of https://yarnpkg.com[yarn]. Bootstrap {kib} and install all the dependencies: @@ -93,13 +93,13 @@ yarn es snapshot --license trial `trial` will give you access to all capabilities. -Read about more options for <>, like connecting to a remote host, running from source, -preserving data inbetween runs, running remote cluster, etc. +Read about more options for <>, like connecting to a remote host, running from source, +preserving data inbetween runs, running remote cluster, etc. [float] === Run {kib} -In another terminal window, start up {kib}. Include developer examples by adding an optional `--run-examples` flag. +In another terminal window, start up {kib}. Include developer examples by adding an optional `--run-examples` flag. [source,bash] ---- @@ -125,8 +125,6 @@ cause the {kib} server to reboot. * <> -* <> - * <> * <> @@ -137,8 +135,6 @@ include::sample-data.asciidoc[] include::debugging.asciidoc[] -include::sass.asciidoc[] - include::building-kibana.asciidoc[] include::development-plugin-resources.asciidoc[] \ No newline at end of file diff --git a/docs/developer/getting-started/sass.asciidoc b/docs/developer/getting-started/sass.asciidoc deleted file mode 100644 index 194e001f642e1..0000000000000 --- a/docs/developer/getting-started/sass.asciidoc +++ /dev/null @@ -1,36 +0,0 @@ -[[kibana-sass]] -=== Styling with SASS - -When writing a new component, create a sibling SASS file of the same -name and import directly into the JS/TS component file. Doing so ensures -the styles are never separated or lost on import and allows for better -modularization (smaller individual plugin asset footprint). - -All SASS (.scss) files will automatically build with the -https://elastic.github.io/eui/#/guidelines/sass[EUI] & {kib} invisibles (SASS variables, mixins, functions) from -the {kib-repo}tree/{branch}/src/legacy/ui/public/styles/_globals_v7light.scss[globals_THEME.scss] file. - -*Example:* - -[source,tsx] ----- -// component.tsx - -import './component.scss'; - -export const Component = () => { - return ( -
- ); -} ----- - -[source,scss] ----- -// component.scss - -.plgComponent { ... } ----- - -Do not use the underscore `_` SASS file naming pattern when importing -directly into a javascript file. \ No newline at end of file diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/_data_panel_wrapper.scss b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/_data_panel_wrapper.scss index 261d6672df93a..a7c8e4dfc6baa 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/_data_panel_wrapper.scss +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/_data_panel_wrapper.scss @@ -1,6 +1,7 @@ .lnsDataPanelWrapper { flex: 1 0 100%; overflow: hidden; + background-color: lightOrDarkTheme($euiColorLightestShade, $euiColorInk); } .lnsDataPanelWrapper__switchSource { diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/_frame_layout.scss b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/_frame_layout.scss index 35c28595a59c0..c2e8d4f6c0049 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/_frame_layout.scss +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/_frame_layout.scss @@ -22,7 +22,7 @@ // Leave out bottom padding so the suggestions scrollbar stays flush to window edge // Leave out left padding so the left sidebar's focus states are visible outside of content bounds // This also means needing to add same amount of margin to page content and suggestion items - padding: $euiSize $euiSize 0 0; + padding: $euiSize $euiSize 0; &:first-child { padding-left: $euiSize; @@ -40,9 +40,10 @@ .lnsFrameLayout__sidebar--right { @include euiScrollBar; - min-width: $lnsPanelMinWidth + $euiSize; + background-color: lightOrDarkTheme($euiColorLightestShade, $euiColorInk); + min-width: $lnsPanelMinWidth + $euiSizeXL; overflow-x: hidden; overflow-y: scroll; - padding-top: $euiSize; + padding: $euiSize 0 $euiSize $euiSize; max-height: 100%; } diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/_layer_panel.scss b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/_layer_panel.scss index 924f44a37c459..4e13fd95d1961 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/_layer_panel.scss +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/_layer_panel.scss @@ -2,6 +2,10 @@ margin-bottom: $euiSizeS; } +.lnsLayerPanel__sourceFlexItem { + max-width: calc(100% - #{$euiSize * 3.625}); +} + .lnsLayerPanel__row { background: $euiColorLightestShade; padding: $euiSizeS; @@ -32,5 +36,6 @@ } .lnsLayerPanel__styleEditor { - width: $euiSize * 28; + width: $euiSize * 30; + padding: $euiSizeS; } diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/dimension_popover.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/dimension_popover.tsx index cc8d97a445016..8d31e1bcc2e6a 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/dimension_popover.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/dimension_popover.tsx @@ -40,8 +40,7 @@ export function DimensionPopover({ }} button={trigger} anchorPosition="leftUp" - withTitle - panelPaddingSize="s" + panelPaddingSize="none" > {panel} diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx index 36d5bfd965e26..e51a155a19935 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx @@ -103,7 +103,7 @@ export function LayerPanel( {layerDatasource && ( - + - - - + ), }, ]; @@ -194,7 +191,6 @@ export function LayerPanel( }), content: (
- - setIsOpen(!isOpen)} data-test-subj="lns_layer_settings" /> diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.scss b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.scss index ae4a7861b1d90..8a44d59ff1c0d 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.scss +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.scss @@ -5,15 +5,9 @@ } } -.lnsChartSwitch__triggerButton { - @include euiTitle('xs'); - background-color: $euiColorEmptyShade; - border-color: $euiColorLightShade; -} - .lnsChartSwitch__summaryIcon { margin-right: $euiSizeS; - transform: translateY(-2px); + transform: translateY(-1px); } // Targeting img as this won't target normal EuiIcon's only the custom svgs's diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.tsx index 4c5a44ecc695e..fa87d80e5cf40 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.tsx @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import './chart_switch.scss'; import React, { useState, useMemo } from 'react'; import { EuiIcon, @@ -11,7 +12,6 @@ import { EuiPopoverTitle, EuiKeyPadMenu, EuiKeyPadMenuItem, - EuiButton, } from '@elastic/eui'; import { flatten } from 'lodash'; import { i18n } from '@kbn/i18n'; @@ -19,6 +19,7 @@ import { Visualization, FramePublicAPI, Datasource } from '../../../types'; import { Action } from '../state_management'; import { getSuggestions, switchToSuggestion, Suggestion } from '../suggestion_helpers'; import { trackUiEvent } from '../../../lens_ui_telemetry'; +import { ToolbarButton } from '../../../toolbar_button'; interface VisualizationSelection { visualizationId: string; @@ -72,8 +73,6 @@ function VisualizationSummary(props: Props) { ); } -import './chart_switch.scss'; - export function ChartSwitch(props: Props) { const [flyoutOpen, setFlyoutOpen] = useState(false); @@ -202,16 +201,13 @@ export function ChartSwitch(props: Props) { panelClassName="lnsChartSwitch__popoverPanel" panelPaddingSize="s" button={ - setFlyoutOpen(!flyoutOpen)} data-test-subj="lnsChartSwitchPopover" - iconSide="right" - iconType="arrowDown" - color="text" + fontWeight="bold" > - + } isOpen={flyoutOpen} closePopover={() => setFlyoutOpen(false)} diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.tsx index beb6952556067..9f5b6665b31d3 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.tsx @@ -15,6 +15,7 @@ import { EuiText, EuiBetaBadge, EuiButtonEmpty, + EuiLink, } from '@elastic/eui'; import { CoreStart, CoreSetup } from 'kibana/public'; import { @@ -208,18 +209,20 @@ export function InnerWorkspacePanel({ />{' '}

- - - +

+ + + + + +

); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/change_indexpattern.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/change_indexpattern.tsx index 94c0f4083dfee..5e2fe9d7bbc14 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/change_indexpattern.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/change_indexpattern.tsx @@ -6,18 +6,13 @@ import { i18n } from '@kbn/i18n'; import React, { useState } from 'react'; -import { - EuiButtonEmpty, - EuiPopover, - EuiPopoverTitle, - EuiSelectable, - EuiButtonEmptyProps, -} from '@elastic/eui'; +import { EuiPopover, EuiPopoverTitle, EuiSelectable } from '@elastic/eui'; import { EuiSelectableProps } from '@elastic/eui/src/components/selectable/selectable'; import { IndexPatternRef } from './types'; import { trackUiEvent } from '../lens_ui_telemetry'; +import { ToolbarButtonProps, ToolbarButton } from '../toolbar_button'; -export type ChangeIndexPatternTriggerProps = EuiButtonEmptyProps & { +export type ChangeIndexPatternTriggerProps = ToolbarButtonProps & { label: string; title?: string; }; @@ -40,29 +35,24 @@ export function ChangeIndexPattern({ const createTrigger = function () { const { label, title, ...rest } = trigger; return ( - setPopoverIsOpen(!isPopoverOpen)} + fullWidth {...rest} > {label} - + ); }; return ( <> setPopoverIsOpen(false)} - className="eui-textTruncate" - anchorClassName="eui-textTruncate" display="block" panelPaddingSize="s" ownFocus diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.scss b/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.scss index 3e767502fae3b..70fb57ee79ee5 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.scss +++ b/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.scss @@ -7,13 +7,7 @@ .lnsInnerIndexPatternDataPanel__header { display: flex; align-items: center; - height: $euiSize * 3; - margin-top: -$euiSizeS; -} - -.lnsInnerIndexPatternDataPanel__triggerButton { - @include euiTitle('xs'); - line-height: $euiSizeXXL; + margin-bottom: $euiSizeS; } /** diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.tsx index 91c068c2b4fab..6854452fd02a4 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/datapanel.tsx @@ -424,7 +424,7 @@ export const InnerIndexPatternDataPanel = function InnerIndexPatternDataPanel({ label: currentIndexPattern.title, title: currentIndexPattern.title, 'data-test-subj': 'indexPattern-switch-link', - className: 'lnsInnerIndexPatternDataPanel__triggerButton', + fontWeight: 'bold', }} indexPatternId={currentIndexPatternId} indexPatternRefs={indexPatternRefs} diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/popover_editor.scss b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/popover_editor.scss index f619fa55f9ceb..b8986cea48d4e 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/popover_editor.scss +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/popover_editor.scss @@ -1,7 +1,6 @@ .lnsIndexPatternDimensionEditor { - flex-grow: 1; - line-height: 0; - overflow: hidden; + width: $euiSize * 30; + padding: $euiSizeS; } .lnsIndexPatternDimensionEditor__left, @@ -11,10 +10,7 @@ .lnsIndexPatternDimensionEditor__left { background-color: $euiPageBackgroundColor; -} - -.lnsIndexPatternDimensionEditor__right { - width: $euiSize * 20; + width: $euiSize * 8; } .lnsIndexPatternDimensionEditor__operation > button { diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/popover_editor.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/popover_editor.tsx index 5b84108b99dd9..2fb7382f992e7 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/popover_editor.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/popover_editor.tsx @@ -299,25 +299,31 @@ export function PopoverEditor(props: PopoverEditorProps) {
{incompatibleSelectedOperationType && selectedColumn && ( - + <> + + + )} {incompatibleSelectedOperationType && !selectedColumn && ( - + <> + + + )} {!incompatibleSelectedOperationType && ParamEditor && ( <> diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/layerpanel.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/layerpanel.tsx index 1ae10e07b0c24..dac451013826e 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/layerpanel.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/layerpanel.tsx @@ -27,7 +27,8 @@ export function LayerPanel({ state, layerId, onChangeIndexPattern }: IndexPatter label: state.indexPatterns[layer.indexPatternId].title, title: state.indexPatterns[layer.indexPatternId].title, 'data-test-subj': 'lns_layerIndexPatternLabel', - size: 'xs', + size: 's', + fontWeight: 'normal', }} indexPatternId={layer.indexPatternId} indexPatternRefs={state.indexPatternRefs} diff --git a/x-pack/plugins/lens/public/toolbar_button/index.tsx b/x-pack/plugins/lens/public/toolbar_button/index.tsx new file mode 100644 index 0000000000000..ee6489726a0a7 --- /dev/null +++ b/x-pack/plugins/lens/public/toolbar_button/index.tsx @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { ToolbarButtonProps, ToolbarButton } from './toolbar_button'; diff --git a/x-pack/plugins/lens/public/toolbar_button/toolbar_button.scss b/x-pack/plugins/lens/public/toolbar_button/toolbar_button.scss new file mode 100644 index 0000000000000..f36fdfdf02aba --- /dev/null +++ b/x-pack/plugins/lens/public/toolbar_button/toolbar_button.scss @@ -0,0 +1,30 @@ +.lnsToolbarButton { + line-height: $euiButtonHeight; // Keeps alignment of text and chart icon + background-color: $euiColorEmptyShade; + border-color: $euiBorderColor; + + // Some toolbar buttons are just icons, but EuiButton comes with margin and min-width that need to be removed + min-width: 0; + + .lnsToolbarButton__text:empty { + margin: 0; + } + + // Toolbar buttons don't look good with centered text when fullWidth + &[class*='fullWidth'] { + text-align: left; + + .lnsToolbarButton__content { + justify-content: space-between; + } + } +} + +.lnsToolbarButton--bold { + font-weight: $euiFontWeightBold; +} + +.lnsToolbarButton--s { + box-shadow: none !important; // sass-lint:disable-line no-important + font-size: $euiFontSizeS; +} diff --git a/x-pack/plugins/lens/public/toolbar_button/toolbar_button.tsx b/x-pack/plugins/lens/public/toolbar_button/toolbar_button.tsx new file mode 100644 index 0000000000000..0a63781818171 --- /dev/null +++ b/x-pack/plugins/lens/public/toolbar_button/toolbar_button.tsx @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import './toolbar_button.scss'; +import React from 'react'; +import classNames from 'classnames'; +import { EuiButton, PropsOf, EuiButtonProps } from '@elastic/eui'; + +export type ToolbarButtonProps = PropsOf & { + /** + * Determines prominence + */ + fontWeight?: 'normal' | 'bold'; + /** + * Smaller buttons also remove extra shadow for less prominence + */ + size?: EuiButtonProps['size']; +}; + +export const ToolbarButton: React.FunctionComponent = ({ + children, + className, + fontWeight = 'normal', + size = 'm', + ...rest +}) => { + const classes = classNames( + 'lnsToolbarButton', + [`lnsToolbarButton--${fontWeight}`, `lnsToolbarButton--${size}`], + className + ); + return ( + + {children} + + ); +}; diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.tsx index 84ea53fb4dc3d..d22b3ec0a44a6 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.tsx @@ -4,11 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ +import './xy_config_panel.scss'; import React, { useState } from 'react'; import { i18n } from '@kbn/i18n'; import { debounce } from 'lodash'; import { - EuiButtonEmpty, EuiButtonGroup, EuiFlexGroup, EuiFlexItem, @@ -32,8 +32,7 @@ import { State, SeriesType, visualizationTypes, YAxisMode } from './types'; import { isHorizontalChart, isHorizontalSeries, getSeriesColor } from './state_helpers'; import { trackUiEvent } from '../lens_ui_telemetry'; import { fittingFunctionDefinitions } from './fitting_functions'; - -import './xy_config_panel.scss'; +import { ToolbarButton } from '../toolbar_button'; type UnwrapArray = T extends Array ? P : T; @@ -101,17 +100,16 @@ export function XyToolbar(props: VisualizationToolbarProps) { { setOpen(!open); }} > {i18n.translate('xpack.lens.xyChart.settingsLabel', { defaultMessage: 'Settings' })} - + } isOpen={open} closePopover={() => { @@ -119,12 +117,9 @@ export function XyToolbar(props: VisualizationToolbarProps) { }} anchorPosition="downRight" > - ) { }) } > - { - return { - value: id, - dropdownDisplay: ( - <> - {title} - -

{description}

-
- - ), - inputDisplay: title, - }; + props.setState({ ...props.state, fittingFunction: value })} - itemLayoutAlign="top" - hasDividers - /> - + > + { + return { + value: id, + dropdownDisplay: ( + <> + {title} + +

{description}

+
+ + ), + inputDisplay: title, + }; + })} + valueOfSelected={props.state?.fittingFunction || 'None'} + onChange={(value) => props.setState({ ...props.state, fittingFunction: value })} + itemLayoutAlign="top" + hasDividers + /> +
+
@@ -183,12 +185,12 @@ export function DimensionEditor(props: VisualizationDimensionEditorProps) })} > + ); + return ( - + {colorPicker} ) : ( - + colorPicker )} ); From 692db4f1725637194a525ef88b033cc658d2700a Mon Sep 17 00:00:00 2001 From: Larry Gregory Date: Mon, 13 Jul 2020 20:10:17 -0400 Subject: [PATCH 48/66] Search across spaces (#67644) Co-authored-by: Joe Portner <5295965+jportner@users.noreply.github.com> Co-authored-by: Elastic Machine --- ...gin-core-public.savedobjectsfindoptions.md | 3 +- ...blic.savedobjectsfindoptions.namespaces.md | 11 + ...gin-core-server.savedobjectsfindoptions.md | 3 +- ...rver.savedobjectsfindoptions.namespaces.md | 11 + ...core-server.savedobjectsrepository.find.md | 4 +- ...ugin-core-server.savedobjectsrepository.md | 2 +- src/core/public/public.api.md | 4 +- .../saved_objects/saved_objects_client.ts | 1 + .../get_sorted_objects_for_export.test.ts | 98 +++++- .../export/get_sorted_objects_for_export.ts | 9 +- src/core/server/saved_objects/routes/find.ts | 8 + .../routes/integration_tests/find.test.ts | 36 +++ .../service/lib/repository.test.js | 65 ++-- .../saved_objects/service/lib/repository.ts | 51 +++- .../lib/search_dsl/query_params.test.ts | 70 ++++- .../service/lib/search_dsl/query_params.ts | 51 +++- .../service/lib/search_dsl/search_dsl.test.ts | 6 +- .../service/lib/search_dsl/search_dsl.ts | 6 +- src/core/server/saved_objects/types.ts | 3 +- src/core/server/server.api.md | 6 +- .../apis/saved_objects/bulk_create.js | 3 + .../apis/saved_objects/bulk_get.js | 2 + .../apis/saved_objects/bulk_update.js | 3 + .../apis/saved_objects/create.js | 2 + .../apis/saved_objects/find.js | 89 ++++++ .../api_integration/apis/saved_objects/get.js | 1 + .../apis/saved_objects/update.js | 1 + .../apis/saved_objects_management/find.ts | 1 + ...ypted_saved_objects_client_wrapper.test.ts | 4 + .../encrypted_saved_objects_client_wrapper.ts | 52 ++-- .../get_descriptor_namespace.test.ts | 70 +++++ .../saved_objects/get_descriptor_namespace.ts | 16 + .../server/saved_objects/index.ts | 3 +- .../check_saved_objects_privileges.test.ts | 11 - .../check_saved_objects_privileges.ts | 16 +- ...ecure_saved_objects_client_wrapper.test.ts | 39 ++- .../secure_saved_objects_client_wrapper.ts | 17 +- x-pack/plugins/spaces/common/model/types.ts | 2 +- .../__snapshots__/spaces_client.test.ts.snap | 2 + .../lib/spaces_client/spaces_client.test.ts | 19 +- .../server/lib/spaces_client/spaces_client.ts | 18 +- .../spaces_saved_objects_client.test.ts | 109 ++++++- .../spaces_saved_objects_client.ts | 28 +- .../common/lib/saved_object_test_utils.ts | 56 +++- .../common/lib/types.ts | 1 + .../common/suites/bulk_create.ts | 2 +- .../common/suites/bulk_get.ts | 2 +- .../common/suites/bulk_update.ts | 2 +- .../common/suites/create.ts | 2 +- .../common/suites/delete.ts | 2 +- .../common/suites/export.ts | 4 +- .../common/suites/find.ts | 281 ++++++++++++------ .../common/suites/get.ts | 2 +- .../common/suites/import.ts | 2 +- .../common/suites/resolve_import_errors.ts | 2 +- .../common/suites/update.ts | 2 +- .../security_and_spaces/apis/find.ts | 124 ++++++-- .../security_only/apis/find.ts | 78 +++-- .../spaces_only/apis/find.ts | 17 +- .../common/suites/share_add.ts | 2 +- .../common/suites/share_remove.ts | 2 +- 61 files changed, 1209 insertions(+), 330 deletions(-) create mode 100644 docs/development/core/public/kibana-plugin-core-public.savedobjectsfindoptions.namespaces.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.savedobjectsfindoptions.namespaces.md create mode 100644 x-pack/plugins/encrypted_saved_objects/server/saved_objects/get_descriptor_namespace.test.ts create mode 100644 x-pack/plugins/encrypted_saved_objects/server/saved_objects/get_descriptor_namespace.ts diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjectsfindoptions.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectsfindoptions.md index 5f33d62382818..70ad235fb8971 100644 --- a/docs/development/core/public/kibana-plugin-core-public.savedobjectsfindoptions.md +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectsfindoptions.md @@ -8,7 +8,7 @@ Signature: ```typescript -export interface SavedObjectsFindOptions extends SavedObjectsBaseOptions +export interface SavedObjectsFindOptions ``` ## Properties @@ -19,6 +19,7 @@ export interface SavedObjectsFindOptions extends SavedObjectsBaseOptions | [fields](./kibana-plugin-core-public.savedobjectsfindoptions.fields.md) | string[] | An array of fields to include in the results | | [filter](./kibana-plugin-core-public.savedobjectsfindoptions.filter.md) | string | | | [hasReference](./kibana-plugin-core-public.savedobjectsfindoptions.hasreference.md) | {
type: string;
id: string;
} | | +| [namespaces](./kibana-plugin-core-public.savedobjectsfindoptions.namespaces.md) | string[] | | | [page](./kibana-plugin-core-public.savedobjectsfindoptions.page.md) | number | | | [perPage](./kibana-plugin-core-public.savedobjectsfindoptions.perpage.md) | number | | | [preference](./kibana-plugin-core-public.savedobjectsfindoptions.preference.md) | string | An optional ES preference value to be used for the query \* | diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjectsfindoptions.namespaces.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectsfindoptions.namespaces.md new file mode 100644 index 0000000000000..9cc9d64db1f65 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectsfindoptions.namespaces.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [SavedObjectsFindOptions](./kibana-plugin-core-public.savedobjectsfindoptions.md) > [namespaces](./kibana-plugin-core-public.savedobjectsfindoptions.namespaces.md) + +## SavedObjectsFindOptions.namespaces property + +Signature: + +```typescript +namespaces?: string[]; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindoptions.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindoptions.md index 6db16d979f1fe..67e931f0cb3b3 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindoptions.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindoptions.md @@ -8,7 +8,7 @@ Signature: ```typescript -export interface SavedObjectsFindOptions extends SavedObjectsBaseOptions +export interface SavedObjectsFindOptions ``` ## Properties @@ -19,6 +19,7 @@ export interface SavedObjectsFindOptions extends SavedObjectsBaseOptions | [fields](./kibana-plugin-core-server.savedobjectsfindoptions.fields.md) | string[] | An array of fields to include in the results | | [filter](./kibana-plugin-core-server.savedobjectsfindoptions.filter.md) | string | | | [hasReference](./kibana-plugin-core-server.savedobjectsfindoptions.hasreference.md) | {
type: string;
id: string;
} | | +| [namespaces](./kibana-plugin-core-server.savedobjectsfindoptions.namespaces.md) | string[] | | | [page](./kibana-plugin-core-server.savedobjectsfindoptions.page.md) | number | | | [perPage](./kibana-plugin-core-server.savedobjectsfindoptions.perpage.md) | number | | | [preference](./kibana-plugin-core-server.savedobjectsfindoptions.preference.md) | string | An optional ES preference value to be used for the query \* | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindoptions.namespaces.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindoptions.namespaces.md new file mode 100644 index 0000000000000..cae707baa58c0 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsfindoptions.namespaces.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsFindOptions](./kibana-plugin-core-server.savedobjectsfindoptions.md) > [namespaces](./kibana-plugin-core-server.savedobjectsfindoptions.namespaces.md) + +## SavedObjectsFindOptions.namespaces property + +Signature: + +```typescript +namespaces?: string[]; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.find.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.find.md index 8b89c802ec9ce..6c41441302c0b 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.find.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.find.md @@ -7,14 +7,14 @@ Signature: ```typescript -find({ search, defaultSearchOperator, searchFields, hasReference, page, perPage, sortField, sortOrder, fields, namespace, type, filter, preference, }: SavedObjectsFindOptions): Promise>; +find({ search, defaultSearchOperator, searchFields, hasReference, page, perPage, sortField, sortOrder, fields, namespaces, type, filter, preference, }: SavedObjectsFindOptions): Promise>; ``` ## Parameters | Parameter | Type | Description | | --- | --- | --- | -| { search, defaultSearchOperator, searchFields, hasReference, page, perPage, sortField, sortOrder, fields, namespace, type, filter, preference, } | SavedObjectsFindOptions | | +| { search, defaultSearchOperator, searchFields, hasReference, page, perPage, sortField, sortOrder, fields, namespaces, type, filter, preference, } | SavedObjectsFindOptions | | Returns: diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.md index b9a92561f29fb..5b02707a3c0f4 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.md @@ -23,7 +23,7 @@ export declare class SavedObjectsRepository | [delete(type, id, options)](./kibana-plugin-core-server.savedobjectsrepository.delete.md) | | Deletes an object | | [deleteByNamespace(namespace, options)](./kibana-plugin-core-server.savedobjectsrepository.deletebynamespace.md) | | Deletes all objects from the provided namespace. | | [deleteFromNamespaces(type, id, namespaces, options)](./kibana-plugin-core-server.savedobjectsrepository.deletefromnamespaces.md) | | Removes one or more namespaces from a given multi-namespace saved object. If no namespaces remain, the saved object is deleted entirely. This method and \[addToNamespaces\][SavedObjectsRepository.addToNamespaces()](./kibana-plugin-core-server.savedobjectsrepository.addtonamespaces.md) are the only ways to change which Spaces a multi-namespace saved object is shared to. | -| [find({ search, defaultSearchOperator, searchFields, hasReference, page, perPage, sortField, sortOrder, fields, namespace, type, filter, preference, })](./kibana-plugin-core-server.savedobjectsrepository.find.md) | | | +| [find({ search, defaultSearchOperator, searchFields, hasReference, page, perPage, sortField, sortOrder, fields, namespaces, type, filter, preference, })](./kibana-plugin-core-server.savedobjectsrepository.find.md) | | | | [get(type, id, options)](./kibana-plugin-core-server.savedobjectsrepository.get.md) | | Gets a single object | | [incrementCounter(type, id, counterFieldName, options)](./kibana-plugin-core-server.savedobjectsrepository.incrementcounter.md) | | Increases a counter field by one. Creates the document if one doesn't exist for the given id. | | [update(type, id, attributes, options)](./kibana-plugin-core-server.savedobjectsrepository.update.md) | | Updates an object | diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md index 303d005197588..c811209dfa80f 100644 --- a/src/core/public/public.api.md +++ b/src/core/public/public.api.md @@ -1282,7 +1282,7 @@ export interface SavedObjectsCreateOptions { } // @public (undocumented) -export interface SavedObjectsFindOptions extends SavedObjectsBaseOptions { +export interface SavedObjectsFindOptions { // (undocumented) defaultSearchOperator?: 'AND' | 'OR'; fields?: string[]; @@ -1294,6 +1294,8 @@ export interface SavedObjectsFindOptions extends SavedObjectsBaseOptions { id: string; }; // (undocumented) + namespaces?: string[]; + // (undocumented) page?: number; // (undocumented) perPage?: number; diff --git a/src/core/public/saved_objects/saved_objects_client.ts b/src/core/public/saved_objects/saved_objects_client.ts index c4daaf5d7f307..209f489e29139 100644 --- a/src/core/public/saved_objects/saved_objects_client.ts +++ b/src/core/public/saved_objects/saved_objects_client.ts @@ -294,6 +294,7 @@ export class SavedObjectsClient { sortField: 'sort_field', type: 'type', filter: 'filter', + namespaces: 'namespaces', preference: 'preference', }; diff --git a/src/core/server/saved_objects/export/get_sorted_objects_for_export.test.ts b/src/core/server/saved_objects/export/get_sorted_objects_for_export.test.ts index 5da2235828b5c..27c0a5205ae38 100644 --- a/src/core/server/saved_objects/export/get_sorted_objects_for_export.test.ts +++ b/src/core/server/saved_objects/export/get_sorted_objects_for_export.test.ts @@ -107,7 +107,97 @@ describe('getSortedObjectsForExport()', () => { "calls": Array [ Array [ Object { - "namespace": undefined, + "namespaces": undefined, + "perPage": 500, + "search": undefined, + "type": Array [ + "index-pattern", + "search", + ], + }, + ], + ], + "results": Array [ + Object { + "type": "return", + "value": Promise {}, + }, + ], + } + `); + }); + + test('omits the `namespaces` property from the export', async () => { + savedObjectsClient.find.mockResolvedValueOnce({ + total: 2, + saved_objects: [ + { + id: '2', + type: 'search', + attributes: {}, + namespaces: ['foo', 'bar'], + score: 0, + references: [ + { + name: 'name', + type: 'index-pattern', + id: '1', + }, + ], + }, + { + id: '1', + type: 'index-pattern', + attributes: {}, + namespaces: ['foo', 'bar'], + score: 0, + references: [], + }, + ], + per_page: 1, + page: 0, + }); + const exportStream = await exportSavedObjectsToStream({ + savedObjectsClient, + exportSizeLimit: 500, + types: ['index-pattern', 'search'], + }); + + const response = await readStreamToCompletion(exportStream); + + expect(response).toMatchInlineSnapshot(` + Array [ + Object { + "attributes": Object {}, + "id": "1", + "references": Array [], + "type": "index-pattern", + }, + Object { + "attributes": Object {}, + "id": "2", + "references": Array [ + Object { + "id": "1", + "name": "name", + "type": "index-pattern", + }, + ], + "type": "search", + }, + Object { + "exportedCount": 2, + "missingRefCount": 0, + "missingReferences": Array [], + }, + ] + `); + expect(savedObjectsClient.find).toMatchInlineSnapshot(` + [MockFunction] { + "calls": Array [ + Array [ + Object { + "namespaces": undefined, "perPage": 500, "search": undefined, "type": Array [ @@ -257,7 +347,7 @@ describe('getSortedObjectsForExport()', () => { "calls": Array [ Array [ Object { - "namespace": undefined, + "namespaces": undefined, "perPage": 500, "search": "foo", "type": Array [ @@ -346,7 +436,9 @@ describe('getSortedObjectsForExport()', () => { "calls": Array [ Array [ Object { - "namespace": "foo", + "namespaces": Array [ + "foo", + ], "perPage": 500, "search": undefined, "type": Array [ diff --git a/src/core/server/saved_objects/export/get_sorted_objects_for_export.ts b/src/core/server/saved_objects/export/get_sorted_objects_for_export.ts index 6e985c25aeaef..6cfe6f1be5669 100644 --- a/src/core/server/saved_objects/export/get_sorted_objects_for_export.ts +++ b/src/core/server/saved_objects/export/get_sorted_objects_for_export.ts @@ -109,7 +109,7 @@ async function fetchObjectsToExport({ type: types, search, perPage: exportSizeLimit, - namespace, + namespaces: namespace ? [namespace] : undefined, }); if (findResponse.total > exportSizeLimit) { throw Boom.badRequest(`Can't export more than ${exportSizeLimit} objects`); @@ -162,10 +162,15 @@ export async function exportSavedObjectsToStream({ exportedObjects = sortObjects(rootObjects); } + // redact attributes that should not be exported + const redactedObjects = exportedObjects.map>( + ({ namespaces, ...object }) => object + ); + const exportDetails: SavedObjectsExportResultDetails = { exportedCount: exportedObjects.length, missingRefCount: missingReferences.length, missingReferences, }; - return createListStream([...exportedObjects, ...(excludeExportDetails ? [] : [exportDetails])]); + return createListStream([...redactedObjects, ...(excludeExportDetails ? [] : [exportDetails])]); } diff --git a/src/core/server/saved_objects/routes/find.ts b/src/core/server/saved_objects/routes/find.ts index 5c1c2c9a9ab87..6313a95b1fefa 100644 --- a/src/core/server/saved_objects/routes/find.ts +++ b/src/core/server/saved_objects/routes/find.ts @@ -45,11 +45,18 @@ export const registerFindRoute = (router: IRouter) => { ), fields: schema.maybe(schema.oneOf([schema.string(), schema.arrayOf(schema.string())])), filter: schema.maybe(schema.string()), + namespaces: schema.maybe( + schema.oneOf([schema.string(), schema.arrayOf(schema.string())]) + ), }), }, }, router.handleLegacyErrors(async (context, req, res) => { const query = req.query; + + const namespaces = + typeof req.query.namespaces === 'string' ? [req.query.namespaces] : req.query.namespaces; + const result = await context.core.savedObjects.client.find({ perPage: query.per_page, page: query.page, @@ -62,6 +69,7 @@ export const registerFindRoute = (router: IRouter) => { hasReference: query.has_reference, fields: typeof query.fields === 'string' ? [query.fields] : query.fields, filter: query.filter, + namespaces, }); return res.ok({ body: result }); diff --git a/src/core/server/saved_objects/routes/integration_tests/find.test.ts b/src/core/server/saved_objects/routes/integration_tests/find.test.ts index 33e12dd4e517d..d5a7710f04b39 100644 --- a/src/core/server/saved_objects/routes/integration_tests/find.test.ts +++ b/src/core/server/saved_objects/routes/integration_tests/find.test.ts @@ -81,6 +81,7 @@ describe('GET /api/saved_objects/_find', () => { attributes: {}, score: 1, references: [], + namespaces: ['default'], }, { type: 'index-pattern', @@ -91,6 +92,7 @@ describe('GET /api/saved_objects/_find', () => { attributes: {}, score: 1, references: [], + namespaces: ['default'], }, ], }; @@ -241,4 +243,38 @@ describe('GET /api/saved_objects/_find', () => { defaultSearchOperator: 'OR', }); }); + + it('accepts the query parameter namespaces as a string', async () => { + await supertest(httpSetup.server.listener) + .get('/api/saved_objects/_find?type=index-pattern&namespaces=foo') + .expect(200); + + expect(savedObjectsClient.find).toHaveBeenCalledTimes(1); + + const options = savedObjectsClient.find.mock.calls[0][0]; + expect(options).toEqual({ + perPage: 20, + page: 1, + type: ['index-pattern'], + namespaces: ['foo'], + defaultSearchOperator: 'OR', + }); + }); + + it('accepts the query parameter namespaces as an array', async () => { + await supertest(httpSetup.server.listener) + .get('/api/saved_objects/_find?type=index-pattern&namespaces=default&namespaces=foo') + .expect(200); + + expect(savedObjectsClient.find).toHaveBeenCalledTimes(1); + + const options = savedObjectsClient.find.mock.calls[0][0]; + expect(options).toEqual({ + perPage: 20, + page: 1, + type: ['index-pattern'], + namespaces: ['default', 'foo'], + defaultSearchOperator: 'OR', + }); + }); }); diff --git a/src/core/server/saved_objects/service/lib/repository.test.js b/src/core/server/saved_objects/service/lib/repository.test.js index ea749235cbb41..d563edbe66c9b 100644 --- a/src/core/server/saved_objects/service/lib/repository.test.js +++ b/src/core/server/saved_objects/service/lib/repository.test.js @@ -494,6 +494,7 @@ describe('SavedObjectsRepository', () => { ...obj, migrationVersion: { [obj.type]: '1.1.1' }, version: mockVersion, + namespaces: obj.namespaces ?? [obj.namespace ?? 'default'], ...mockTimestampFields, }); @@ -826,9 +827,19 @@ describe('SavedObjectsRepository', () => { // Assert that both raw docs from the ES response are deserialized expect(serializer.rawToSavedObject).toHaveBeenNthCalledWith(1, { ...response.items[0].create, + _source: { + ...response.items[0].create._source, + namespaces: response.items[0].create._source.namespaces, + }, _id: expect.stringMatching(/^myspace:config:[0-9a-f]{8}-([0-9a-f]{4}-){3}[0-9a-f]{12}$/), }); - expect(serializer.rawToSavedObject).toHaveBeenNthCalledWith(2, response.items[1].create); + expect(serializer.rawToSavedObject).toHaveBeenNthCalledWith(2, { + ...response.items[1].create, + _source: { + ...response.items[1].create._source, + namespaces: response.items[1].create._source.namespaces, + }, + }); // Assert that ID's are deserialized to remove the type and namespace expect(result.saved_objects[0].id).toEqual( @@ -985,7 +996,7 @@ describe('SavedObjectsRepository', () => { const expectSuccessResult = ({ type, id }, doc) => ({ type, id, - ...(doc._source.namespaces && { namespaces: doc._source.namespaces }), + namespaces: doc._source.namespaces ?? ['default'], ...(doc._source.updated_at && { updated_at: doc._source.updated_at }), version: encodeHitVersion(doc), attributes: doc._source[type], @@ -1027,12 +1038,12 @@ describe('SavedObjectsRepository', () => { }); }); - it(`includes namespaces property for multi-namespace documents`, async () => { + it(`includes namespaces property for single-namespace and multi-namespace documents`, async () => { const obj = { type: MULTI_NAMESPACE_TYPE, id: 'three' }; const result = await bulkGetSuccess([obj1, obj]); expect(result).toEqual({ saved_objects: [ - expect.not.objectContaining({ namespaces: expect.anything() }), + expect.objectContaining({ namespaces: ['default'] }), expect.objectContaining({ namespaces: expect.any(Array) }), ], }); @@ -1350,12 +1361,13 @@ describe('SavedObjectsRepository', () => { }); describe('returns', () => { - const expectSuccessResult = ({ type, id, attributes, references }) => ({ + const expectSuccessResult = ({ type, id, attributes, references, namespaces }) => ({ type, id, attributes, references, version: mockVersion, + namespaces: namespaces ?? ['default'], ...mockTimestampFields, }); @@ -1389,12 +1401,12 @@ describe('SavedObjectsRepository', () => { }); }); - it(`includes namespaces property for multi-namespace documents`, async () => { + it(`includes namespaces property for single-namespace and multi-namespace documents`, async () => { const obj = { type: MULTI_NAMESPACE_TYPE, id: 'three' }; const result = await bulkUpdateSuccess([obj1, obj]); expect(result).toEqual({ saved_objects: [ - expect.not.objectContaining({ namespaces: expect.anything() }), + expect.objectContaining({ namespaces: expect.any(Array) }), expect.objectContaining({ namespaces: expect.any(Array) }), ], }); @@ -1651,6 +1663,7 @@ describe('SavedObjectsRepository', () => { version: mockVersion, attributes, references, + namespaces: [namespace ?? 'default'], migrationVersion: { [type]: '1.1.1' }, }); }); @@ -1907,7 +1920,7 @@ describe('SavedObjectsRepository', () => { await deleteByNamespaceSuccess(namespace); const allTypes = registry.getAllTypes().map((type) => type.name); expect(getSearchDslNS.getSearchDsl).toHaveBeenCalledWith(mappings, registry, { - namespace, + namespaces: [namespace], type: allTypes.filter((type) => !registry.isNamespaceAgnostic(type)), }); }); @@ -2134,6 +2147,7 @@ describe('SavedObjectsRepository', () => { score: doc._score, attributes: doc._source[doc._source.type], references: [], + namespaces: doc._source.type === NAMESPACE_AGNOSTIC_TYPE ? undefined : ['default'], }); }); }); @@ -2143,7 +2157,7 @@ describe('SavedObjectsRepository', () => { callAdminCluster.mockReturnValue(namespacedSearchResults); const count = namespacedSearchResults.hits.hits.length; - const response = await savedObjectsRepository.find({ type, namespace }); + const response = await savedObjectsRepository.find({ type, namespaces: [namespace] }); expect(response.total).toBe(count); expect(response.saved_objects).toHaveLength(count); @@ -2157,6 +2171,7 @@ describe('SavedObjectsRepository', () => { score: doc._score, attributes: doc._source[doc._source.type], references: [], + namespaces: doc._source.type === NAMESPACE_AGNOSTIC_TYPE ? undefined : [namespace], }); }); }); @@ -2176,7 +2191,7 @@ describe('SavedObjectsRepository', () => { describe('search dsl', () => { it(`passes mappings, registry, search, defaultSearchOperator, searchFields, type, sortField, sortOrder and hasReference to getSearchDsl`, async () => { const relevantOpts = { - namespace, + namespaces: [namespace], search: 'foo*', searchFields: ['foo'], type: [type], @@ -2374,6 +2389,7 @@ describe('SavedObjectsRepository', () => { title: 'Testing', }, references: [], + namespaces: ['default'], }); }); @@ -2384,10 +2400,10 @@ describe('SavedObjectsRepository', () => { }); }); - it(`doesn't include namespaces if type is not multi-namespace`, async () => { + it(`include namespaces if type is not multi-namespace`, async () => { const result = await getSuccess(type, id); - expect(result).not.toMatchObject({ - namespaces: expect.anything(), + expect(result).toMatchObject({ + namespaces: ['default'], }); }); }); @@ -2908,10 +2924,10 @@ describe('SavedObjectsRepository', () => { _id: `${type}:${id}`, ...mockVersionProps, result: 'updated', - ...(registry.isMultiNamespace(type) && { - // don't need the rest of the source for test purposes, just the namespaces attribute - get: { _source: { namespaces: [options?.namespace ?? 'default'] } }, - }), + // don't need the rest of the source for test purposes, just the namespace and namespaces attributes + get: { + _source: { namespaces: [options?.namespace ?? 'default'], namespace: options?.namespace }, + }, }); // this._writeToCluster('update', ...) const result = await savedObjectsRepository.update(type, id, attributes, options); expect(callAdminCluster).toHaveBeenCalledTimes(registry.isMultiNamespace(type) ? 2 : 1); @@ -3011,15 +3027,15 @@ describe('SavedObjectsRepository', () => { it(`includes _sourceIncludes when type is multi-namespace`, async () => { await updateSuccess(MULTI_NAMESPACE_TYPE, id, attributes); - expectClusterCallArgs({ _sourceIncludes: ['namespaces'] }, 2); + expectClusterCallArgs({ _sourceIncludes: ['namespace', 'namespaces'] }, 2); }); - it(`doesn't include _sourceIncludes when type is not multi-namespace`, async () => { + it(`includes _sourceIncludes when type is not multi-namespace`, async () => { await updateSuccess(type, id, attributes); expect(callAdminCluster).toHaveBeenLastCalledWith( expect.any(String), - expect.not.objectContaining({ - _sourceIncludes: expect.anything(), + expect.objectContaining({ + _sourceIncludes: ['namespace', 'namespaces'], }) ); }); @@ -3093,6 +3109,7 @@ describe('SavedObjectsRepository', () => { version: mockVersion, attributes, references, + namespaces: [namespace], }); }); @@ -3103,10 +3120,10 @@ describe('SavedObjectsRepository', () => { }); }); - it(`doesn't include namespaces if type is not multi-namespace`, async () => { + it(`includes namespaces if type is not multi-namespace`, async () => { const result = await updateSuccess(type, id, attributes); - expect(result).not.toMatchObject({ - namespaces: expect.anything(), + expect(result).toMatchObject({ + namespaces: ['default'], }); }); }); diff --git a/src/core/server/saved_objects/service/lib/repository.ts b/src/core/server/saved_objects/service/lib/repository.ts index 880b71e164b5b..7a5ac9204627c 100644 --- a/src/core/server/saved_objects/service/lib/repository.ts +++ b/src/core/server/saved_objects/service/lib/repository.ts @@ -423,7 +423,7 @@ export class SavedObjectsRepository { // When method == 'index' the bulkResponse doesn't include the indexed // _source so we return rawMigratedDoc but have to spread the latest // _seq_no and _primary_term values from the rawResponse. - return this._serializer.rawToSavedObject({ + return this._rawToSavedObject({ ...rawMigratedDoc, ...{ _seq_no: rawResponse._seq_no, _primary_term: rawResponse._primary_term }, }); @@ -554,7 +554,7 @@ export class SavedObjectsRepository { }, conflicts: 'proceed', ...getSearchDsl(this._mappings, this._registry, { - namespace, + namespaces: namespace ? [namespace] : undefined, type: typesToUpdate, }), }, @@ -590,7 +590,7 @@ export class SavedObjectsRepository { sortField, sortOrder, fields, - namespace, + namespaces, type, filter, preference, @@ -651,7 +651,7 @@ export class SavedObjectsRepository { type: allowedTypes, sortField, sortOrder, - namespace, + namespaces, hasReference, kueryNode, }), @@ -768,10 +768,16 @@ export class SavedObjectsRepository { } const time = doc._source.updated_at; + + let namespaces = []; + if (!this._registry.isNamespaceAgnostic(type)) { + namespaces = doc._source.namespaces ?? [getNamespaceString(doc._source.namespace)]; + } + return { id, type, - ...(doc._source.namespaces && { namespaces: doc._source.namespaces }), + namespaces, ...(time && { updated_at: time }), version: encodeHitVersion(doc), attributes: doc._source[type], @@ -817,10 +823,15 @@ export class SavedObjectsRepository { const { updated_at: updatedAt } = response._source; + let namespaces = []; + if (!this._registry.isNamespaceAgnostic(type)) { + namespaces = response._source.namespaces ?? [getNamespaceString(response._source.namespace)]; + } + return { id, type, - ...(response._source.namespaces && { namespaces: response._source.namespaces }), + namespaces, ...(updatedAt && { updated_at: updatedAt }), version: encodeHitVersion(response), attributes: response._source[type], @@ -874,7 +885,7 @@ export class SavedObjectsRepository { body: { doc, }, - ...(this._registry.isMultiNamespace(type) && { _sourceIncludes: ['namespaces'] }), + _sourceIncludes: ['namespace', 'namespaces'], }); if (updateResponse.status === 404) { @@ -882,14 +893,19 @@ export class SavedObjectsRepository { throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); } + let namespaces = []; + if (!this._registry.isNamespaceAgnostic(type)) { + namespaces = updateResponse.get._source.namespaces ?? [ + getNamespaceString(updateResponse.get._source.namespace), + ]; + } + return { id, type, updated_at: time, version: encodeHitVersion(updateResponse), - ...(this._registry.isMultiNamespace(type) && { - namespaces: updateResponse.get._source.namespaces, - }), + namespaces, references, attributes, }; @@ -1142,9 +1158,14 @@ export class SavedObjectsRepository { }, }; } - namespaces = actualResult._source.namespaces; + namespaces = actualResult._source.namespaces ?? [ + getNamespaceString(actualResult._source.namespace), + ]; versionProperties = getExpectedVersionProperties(version, actualResult); } else { + if (this._registry.isSingleNamespace(type)) { + namespaces = [getNamespaceString(namespace)]; + } versionProperties = getExpectedVersionProperties(version); } @@ -1340,12 +1361,12 @@ export class SavedObjectsRepository { return new Date().toISOString(); } - // The internal representation of the saved object that the serializer returns - // includes the namespace, and we use this for migrating documents. However, we don't - // want the namespace to be returned from the repository, as the repository scopes each - // method transparently to the specified namespace. private _rawToSavedObject(raw: SavedObjectsRawDoc): SavedObject { const savedObject = this._serializer.rawToSavedObject(raw); + const { namespace, type } = savedObject; + if (this._registry.isSingleNamespace(type)) { + savedObject.namespaces = [getNamespaceString(namespace)]; + } return omit(savedObject, 'namespace') as SavedObject; } diff --git a/src/core/server/saved_objects/service/lib/search_dsl/query_params.test.ts b/src/core/server/saved_objects/service/lib/search_dsl/query_params.test.ts index a0ffa91f53671..f916638c5251b 100644 --- a/src/core/server/saved_objects/service/lib/search_dsl/query_params.test.ts +++ b/src/core/server/saved_objects/service/lib/search_dsl/query_params.test.ts @@ -196,19 +196,29 @@ describe('#getQueryParams', () => { }); }); - describe('`namespace` parameter', () => { - const createTypeClause = (type: string, namespace?: string) => { + describe('`namespaces` parameter', () => { + const createTypeClause = (type: string, namespaces?: string[]) => { if (registry.isMultiNamespace(type)) { return { bool: { - must: expect.arrayContaining([{ term: { namespaces: namespace ?? 'default' } }]), + must: expect.arrayContaining([{ terms: { namespaces: namespaces ?? ['default'] } }]), must_not: [{ exists: { field: 'namespace' } }], }, }; - } else if (namespace && registry.isSingleNamespace(type)) { + } else if (registry.isSingleNamespace(type)) { + const nonDefaultNamespaces = namespaces?.filter((n) => n !== 'default') ?? []; + const should: any = []; + if (nonDefaultNamespaces.length > 0) { + should.push({ terms: { namespace: nonDefaultNamespaces } }); + } + if (namespaces?.includes('default')) { + should.push({ bool: { must_not: [{ exists: { field: 'namespace' } }] } }); + } return { bool: { - must: expect.arrayContaining([{ term: { namespace } }]), + must: [{ term: { type } }], + should: expect.arrayContaining(should), + minimum_should_match: 1, must_not: [{ exists: { field: 'namespaces' } }], }, }; @@ -229,23 +239,45 @@ describe('#getQueryParams', () => { ); }; - const test = (namespace?: string) => { + const test = (namespaces?: string[]) => { for (const typeOrTypes of ALL_TYPE_SUBSETS) { - const result = getQueryParams({ mappings, registry, type: typeOrTypes, namespace }); + const result = getQueryParams({ mappings, registry, type: typeOrTypes, namespaces }); const types = Array.isArray(typeOrTypes) ? typeOrTypes : [typeOrTypes]; - expectResult(result, ...types.map((x) => createTypeClause(x, namespace))); + expectResult(result, ...types.map((x) => createTypeClause(x, namespaces))); } // also test with no specified type/s - const result = getQueryParams({ mappings, registry, type: undefined, namespace }); - expectResult(result, ...ALL_TYPES.map((x) => createTypeClause(x, namespace))); + const result = getQueryParams({ mappings, registry, type: undefined, namespaces }); + expectResult(result, ...ALL_TYPES.map((x) => createTypeClause(x, namespaces))); }; - it('filters results with "namespace" field when `namespace` is not specified', () => { + it('normalizes and deduplicates provided namespaces', () => { + const result = getQueryParams({ + mappings, + registry, + search: '*', + namespaces: ['foo', '*', 'foo', 'bar', 'default'], + }); + + expectResult( + result, + ...ALL_TYPES.map((x) => createTypeClause(x, ['foo', 'default', 'bar'])) + ); + }); + + it('filters results with "namespace" field when `namespaces` is not specified', () => { test(undefined); }); it('filters results for specified namespace for appropriate type/s', () => { - test('foo-namespace'); + test(['foo-namespace']); + }); + + it('filters results for specified namespaces for appropriate type/s', () => { + test(['foo-namespace', 'default']); + }); + + it('filters results for specified `default` namespace for appropriate type/s', () => { + test(['default']); }); }); }); @@ -353,4 +385,18 @@ describe('#getQueryParams', () => { }); }); }); + + describe('namespaces property', () => { + ALL_TYPES.forEach((type) => { + it(`throws for ${type} when namespaces is an empty array`, () => { + expect(() => + getQueryParams({ + mappings, + registry, + namespaces: [], + }) + ).toThrowError('cannot specify empty namespaces array'); + }); + }); + }); }); diff --git a/src/core/server/saved_objects/service/lib/search_dsl/query_params.ts b/src/core/server/saved_objects/service/lib/search_dsl/query_params.ts index 40485564176a6..164756f9796a5 100644 --- a/src/core/server/saved_objects/service/lib/search_dsl/query_params.ts +++ b/src/core/server/saved_objects/service/lib/search_dsl/query_params.ts @@ -63,25 +63,42 @@ function getFieldsForTypes(types: string[], searchFields?: string[]) { */ function getClauseForType( registry: ISavedObjectTypeRegistry, - namespace: string | undefined, + namespaces: string[] = ['default'], type: string ) { + if (namespaces.length === 0) { + throw new Error('cannot specify empty namespaces array'); + } if (registry.isMultiNamespace(type)) { return { bool: { - must: [{ term: { type } }, { term: { namespaces: namespace ?? 'default' } }], + must: [{ term: { type } }, { terms: { namespaces } }], must_not: [{ exists: { field: 'namespace' } }], }, }; - } else if (namespace && registry.isSingleNamespace(type)) { + } else if (registry.isSingleNamespace(type)) { + const should: Array> = []; + const eligibleNamespaces = namespaces.filter((namespace) => namespace !== 'default'); + if (eligibleNamespaces.length > 0) { + should.push({ terms: { namespace: eligibleNamespaces } }); + } + if (namespaces.includes('default')) { + should.push({ bool: { must_not: [{ exists: { field: 'namespace' } }] } }); + } + if (should.length === 0) { + // This is indicitive of a bug, and not user error. + throw new Error('unhandled search condition: expected at least 1 `should` clause.'); + } return { bool: { - must: [{ term: { type } }, { term: { namespace } }], + must: [{ term: { type } }], + should, + minimum_should_match: 1, must_not: [{ exists: { field: 'namespaces' } }], }, }; } - // isSingleNamespace in the default namespace, or isNamespaceAgnostic + // isNamespaceAgnostic return { bool: { must: [{ term: { type } }], @@ -98,7 +115,7 @@ interface HasReferenceQueryParams { interface QueryParams { mappings: IndexMapping; registry: ISavedObjectTypeRegistry; - namespace?: string; + namespaces?: string[]; type?: string | string[]; search?: string; searchFields?: string[]; @@ -113,7 +130,7 @@ interface QueryParams { export function getQueryParams({ mappings, registry, - namespace, + namespaces, type, search, searchFields, @@ -122,6 +139,22 @@ export function getQueryParams({ kueryNode, }: QueryParams) { const types = getTypes(mappings, type); + + // A de-duplicated set of namespaces makes for a more effecient query. + // + // Additonally, we treat the `*` namespace as the `default` namespace. + // In the Default Distribution, the `*` is automatically expanded to include all available namespaces. + // However, the OSS distribution (and certain configurations of the Default Distribution) can allow the `*` + // to pass through to the SO Repository, and eventually to this module. When this happens, we translate to `default`, + // since that is consistent with how a single-namespace search behaves in the OSS distribution. Leaving the wildcard in place + // would result in no results being returned, as the wildcard is treated as a literal, and not _actually_ as a wildcard. + // We had a good discussion around the tradeoffs here: https://github.com/elastic/kibana/pull/67644#discussion_r441055716 + const normalizedNamespaces = namespaces + ? Array.from( + new Set(namespaces.map((namespace) => (namespace === '*' ? 'default' : namespace))) + ) + : undefined; + const bool: any = { filter: [ ...(kueryNode != null ? [esKuery.toElasticsearchQuery(kueryNode)] : []), @@ -152,7 +185,9 @@ export function getQueryParams({ }, ] : undefined, - should: types.map((shouldType) => getClauseForType(registry, namespace, shouldType)), + should: types.map((shouldType) => + getClauseForType(registry, normalizedNamespaces, shouldType) + ), minimum_should_match: 1, }, }, diff --git a/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.test.ts b/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.test.ts index 95b7ffd117ee9..08ad72397e4a2 100644 --- a/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.test.ts +++ b/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.test.ts @@ -57,9 +57,9 @@ describe('getSearchDsl', () => { }); describe('passes control', () => { - it('passes (mappings, schema, namespace, type, search, searchFields, hasReference) to getQueryParams', () => { + it('passes (mappings, schema, namespaces, type, search, searchFields, hasReference) to getQueryParams', () => { const opts = { - namespace: 'foo-namespace', + namespaces: ['foo-namespace'], type: 'foo', search: 'bar', searchFields: ['baz'], @@ -75,7 +75,7 @@ describe('getSearchDsl', () => { expect(getQueryParams).toHaveBeenCalledWith({ mappings, registry, - namespace: opts.namespace, + namespaces: opts.namespaces, type: opts.type, search: opts.search, searchFields: opts.searchFields, diff --git a/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.ts b/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.ts index 74c25491aff8b..6de868c320240 100644 --- a/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.ts +++ b/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.ts @@ -33,7 +33,7 @@ interface GetSearchDslOptions { searchFields?: string[]; sortField?: string; sortOrder?: string; - namespace?: string; + namespaces?: string[]; hasReference?: { type: string; id: string; @@ -53,7 +53,7 @@ export function getSearchDsl( searchFields, sortField, sortOrder, - namespace, + namespaces, hasReference, kueryNode, } = options; @@ -70,7 +70,7 @@ export function getSearchDsl( ...getQueryParams({ mappings, registry, - namespace, + namespaces, type, search, searchFields, diff --git a/src/core/server/saved_objects/types.ts b/src/core/server/saved_objects/types.ts index 2183b47b732f9..f9301d6598b1d 100644 --- a/src/core/server/saved_objects/types.ts +++ b/src/core/server/saved_objects/types.ts @@ -63,7 +63,7 @@ export interface SavedObjectStatusMeta { * * @public */ -export interface SavedObjectsFindOptions extends SavedObjectsBaseOptions { +export interface SavedObjectsFindOptions { type: string | string[]; page?: number; perPage?: number; @@ -82,6 +82,7 @@ export interface SavedObjectsFindOptions extends SavedObjectsBaseOptions { hasReference?: { type: string; id: string }; defaultSearchOperator?: 'AND' | 'OR'; filter?: string; + namespaces?: string[]; /** An optional ES preference value to be used for the query **/ preference?: string; } diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index 886544a4df317..a0e16602ba4bf 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -2175,7 +2175,7 @@ export interface SavedObjectsExportResultDetails { export type SavedObjectsFieldMapping = SavedObjectsCoreFieldMapping | SavedObjectsComplexFieldMapping; // @public (undocumented) -export interface SavedObjectsFindOptions extends SavedObjectsBaseOptions { +export interface SavedObjectsFindOptions { // (undocumented) defaultSearchOperator?: 'AND' | 'OR'; fields?: string[]; @@ -2187,6 +2187,8 @@ export interface SavedObjectsFindOptions extends SavedObjectsBaseOptions { id: string; }; // (undocumented) + namespaces?: string[]; + // (undocumented) page?: number; // (undocumented) perPage?: number; @@ -2398,7 +2400,7 @@ export class SavedObjectsRepository { deleteByNamespace(namespace: string, options?: SavedObjectsDeleteByNamespaceOptions): Promise; deleteFromNamespaces(type: string, id: string, namespaces: string[], options?: SavedObjectsDeleteFromNamespacesOptions): Promise<{}>; // (undocumented) - find({ search, defaultSearchOperator, searchFields, hasReference, page, perPage, sortField, sortOrder, fields, namespace, type, filter, preference, }: SavedObjectsFindOptions): Promise>; + find({ search, defaultSearchOperator, searchFields, hasReference, page, perPage, sortField, sortOrder, fields, namespaces, type, filter, preference, }: SavedObjectsFindOptions): Promise>; get(type: string, id: string, options?: SavedObjectsBaseOptions): Promise>; incrementCounter(type: string, id: string, counterFieldName: string, options?: SavedObjectsIncrementCounterOptions): Promise<{ id: string; diff --git a/test/api_integration/apis/saved_objects/bulk_create.js b/test/api_integration/apis/saved_objects/bulk_create.js index 6cb9d5dccdc9a..7db968df8357a 100644 --- a/test/api_integration/apis/saved_objects/bulk_create.js +++ b/test/api_integration/apis/saved_objects/bulk_create.js @@ -76,6 +76,7 @@ export default function ({ getService }) { dashboard: resp.body.saved_objects[1].migrationVersion.dashboard, }, references: [], + namespaces: ['default'], }, ], }); @@ -121,6 +122,7 @@ export default function ({ getService }) { title: 'An existing visualization', }, references: [], + namespaces: ['default'], migrationVersion: { visualization: resp.body.saved_objects[0].migrationVersion.visualization, }, @@ -134,6 +136,7 @@ export default function ({ getService }) { title: 'A great new dashboard', }, references: [], + namespaces: ['default'], migrationVersion: { dashboard: resp.body.saved_objects[1].migrationVersion.dashboard, }, diff --git a/test/api_integration/apis/saved_objects/bulk_get.js b/test/api_integration/apis/saved_objects/bulk_get.js index c802d52913065..56ee5a69be23e 100644 --- a/test/api_integration/apis/saved_objects/bulk_get.js +++ b/test/api_integration/apis/saved_objects/bulk_get.js @@ -68,6 +68,7 @@ export default function ({ getService }) { resp.body.saved_objects[0].attributes.kibanaSavedObjectMeta, }, migrationVersion: resp.body.saved_objects[0].migrationVersion, + namespaces: ['default'], references: [ { name: 'kibanaSavedObjectMeta.searchSourceJSON.index', @@ -94,6 +95,7 @@ export default function ({ getService }) { buildNum: 8467, defaultIndex: '91200a00-9efd-11e7-acb3-3dab96693fab', }, + namespaces: ['default'], migrationVersion: resp.body.saved_objects[2].migrationVersion, references: [], }, diff --git a/test/api_integration/apis/saved_objects/bulk_update.js b/test/api_integration/apis/saved_objects/bulk_update.js index e3f994ff224e8..973ce382ea813 100644 --- a/test/api_integration/apis/saved_objects/bulk_update.js +++ b/test/api_integration/apis/saved_objects/bulk_update.js @@ -65,6 +65,7 @@ export default function ({ getService }) { attributes: { title: 'An existing visualization', }, + namespaces: ['default'], }); expect(secondObject) @@ -77,6 +78,7 @@ export default function ({ getService }) { attributes: { title: 'An existing dashboard', }, + namespaces: ['default'], }); }); @@ -233,6 +235,7 @@ export default function ({ getService }) { attributes: { title: 'An existing dashboard', }, + namespaces: ['default'], }); }); }); diff --git a/test/api_integration/apis/saved_objects/create.js b/test/api_integration/apis/saved_objects/create.js index eddda3aded141..c1300125441bc 100644 --- a/test/api_integration/apis/saved_objects/create.js +++ b/test/api_integration/apis/saved_objects/create.js @@ -58,6 +58,7 @@ export default function ({ getService }) { title: 'My favorite vis', }, references: [], + namespaces: ['default'], }); expect(resp.body.migrationVersion).to.be.ok(); }); @@ -104,6 +105,7 @@ export default function ({ getService }) { title: 'My favorite vis', }, references: [], + namespaces: ['default'], }); expect(resp.body.migrationVersion).to.be.ok(); }); diff --git a/test/api_integration/apis/saved_objects/find.js b/test/api_integration/apis/saved_objects/find.js index 7cb5955e4a43d..f129bf22840da 100644 --- a/test/api_integration/apis/saved_objects/find.js +++ b/test/api_integration/apis/saved_objects/find.js @@ -48,6 +48,7 @@ export default function ({ getService }) { }, score: 0, migrationVersion: resp.body.saved_objects[0].migrationVersion, + namespaces: ['default'], references: [ { id: '91200a00-9efd-11e7-acb3-3dab96693fab', @@ -107,6 +108,93 @@ export default function ({ getService }) { })); }); + describe('unknown namespace', () => { + it('should return 200 with empty response', async () => + await supertest + .get('/api/saved_objects/_find?type=visualization&namespaces=foo') + .expect(200) + .then((resp) => { + expect(resp.body).to.eql({ + page: 1, + per_page: 20, + total: 0, + saved_objects: [], + }); + })); + }); + + describe('known namespace', () => { + it('should return 200 with individual responses', async () => + await supertest + .get('/api/saved_objects/_find?type=visualization&fields=title&namespaces=default') + .expect(200) + .then((resp) => { + expect(resp.body).to.eql({ + page: 1, + per_page: 20, + total: 1, + saved_objects: [ + { + type: 'visualization', + id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab', + version: 'WzIsMV0=', + attributes: { + title: 'Count of requests', + }, + migrationVersion: resp.body.saved_objects[0].migrationVersion, + namespaces: ['default'], + score: 0, + references: [ + { + id: '91200a00-9efd-11e7-acb3-3dab96693fab', + name: 'kibanaSavedObjectMeta.searchSourceJSON.index', + type: 'index-pattern', + }, + ], + updated_at: '2017-09-21T18:51:23.794Z', + }, + ], + }); + expect(resp.body.saved_objects[0].migrationVersion).to.be.ok(); + })); + }); + + describe('wildcard namespace', () => { + it('should return 200 with individual responses from the default namespace', async () => + await supertest + .get('/api/saved_objects/_find?type=visualization&fields=title&namespaces=*') + .expect(200) + .then((resp) => { + expect(resp.body).to.eql({ + page: 1, + per_page: 20, + total: 1, + saved_objects: [ + { + type: 'visualization', + id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab', + version: 'WzIsMV0=', + attributes: { + title: 'Count of requests', + }, + migrationVersion: resp.body.saved_objects[0].migrationVersion, + namespaces: ['default'], + score: 0, + references: [ + { + id: '91200a00-9efd-11e7-acb3-3dab96693fab', + name: 'kibanaSavedObjectMeta.searchSourceJSON.index', + type: 'index-pattern', + }, + ], + updated_at: '2017-09-21T18:51:23.794Z', + }, + ], + }); + expect(resp.body.saved_objects[0].migrationVersion).to.be.ok(); + })); + }); + describe('with a filter', () => { it('should return 200 with a valid response', async () => await supertest @@ -135,6 +223,7 @@ export default function ({ getService }) { .searchSourceJSON, }, }, + namespaces: ['default'], score: 0, references: [ { diff --git a/test/api_integration/apis/saved_objects/get.js b/test/api_integration/apis/saved_objects/get.js index 55dfda251a75a..6bb5cf0c8a7ff 100644 --- a/test/api_integration/apis/saved_objects/get.js +++ b/test/api_integration/apis/saved_objects/get.js @@ -56,6 +56,7 @@ export default function ({ getService }) { id: '91200a00-9efd-11e7-acb3-3dab96693fab', }, ], + namespaces: ['default'], }); expect(resp.body.migrationVersion).to.be.ok(); })); diff --git a/test/api_integration/apis/saved_objects/update.js b/test/api_integration/apis/saved_objects/update.js index d613f46878bb5..7803c39897f28 100644 --- a/test/api_integration/apis/saved_objects/update.js +++ b/test/api_integration/apis/saved_objects/update.js @@ -56,6 +56,7 @@ export default function ({ getService }) { attributes: { title: 'My second favorite vis', }, + namespaces: ['default'], }); }); }); diff --git a/test/api_integration/apis/saved_objects_management/find.ts b/test/api_integration/apis/saved_objects_management/find.ts index b5154d619685a..08c4327d7c0c4 100644 --- a/test/api_integration/apis/saved_objects_management/find.ts +++ b/test/api_integration/apis/saved_objects_management/find.ts @@ -49,6 +49,7 @@ export default function ({ getService }: FtrProviderContext) { title: 'Count of requests', }, migrationVersion: resp.body.saved_objects[0].migrationVersion, + namespaces: ['default'], references: [ { id: '91200a00-9efd-11e7-acb3-3dab96693fab', diff --git a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.test.ts b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.test.ts index eea19bb1aa7dd..5d4ea5a6370e4 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.test.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.test.ts @@ -939,6 +939,7 @@ describe('#bulkGet', () => { attrNotSoSecret: 'not-so-secret', attrThree: 'three', }, + namespaces: ['some-ns'], references: [], }, { @@ -950,6 +951,7 @@ describe('#bulkGet', () => { attrNotSoSecret: '*not-so-secret*', attrThree: 'three', }, + namespaces: ['some-ns'], references: [], }, ], @@ -1015,6 +1017,7 @@ describe('#bulkGet', () => { attrNotSoSecret: 'not-so-secret', attrThree: 'three', }, + namespaces: ['some-ns'], references: [], }, { @@ -1026,6 +1029,7 @@ describe('#bulkGet', () => { attrNotSoSecret: '*not-so-secret*', attrThree: 'three', }, + namespaces: ['some-ns'], references: [], }, ], diff --git a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.ts b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.ts index bdc2b6cb2e667..3246457179f68 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.ts @@ -25,6 +25,7 @@ import { } from 'src/core/server'; import { AuthenticatedUser } from '../../../security/common/model'; import { EncryptedSavedObjectsService } from '../crypto'; +import { getDescriptorNamespace } from './get_descriptor_namespace'; interface EncryptedSavedObjectsClientOptions { baseClient: SavedObjectsClientContract; @@ -47,10 +48,6 @@ export class EncryptedSavedObjectsClientWrapper implements SavedObjectsClientCon public readonly errors = options.baseClient.errors ) {} - // only include namespace in AAD descriptor if the specified type is single-namespace - private getDescriptorNamespace = (type: string, namespace?: string) => - this.options.baseTypeRegistry.isSingleNamespace(type) ? namespace : undefined; - public async create( type: string, attributes: T = {} as T, @@ -70,7 +67,11 @@ export class EncryptedSavedObjectsClientWrapper implements SavedObjectsClientCon } const id = generateID(); - const namespace = this.getDescriptorNamespace(type, options.namespace); + const namespace = getDescriptorNamespace( + this.options.baseTypeRegistry, + type, + options.namespace + ); return await this.handleEncryptedAttributesInResponse( await this.options.baseClient.create( type, @@ -109,7 +110,11 @@ export class EncryptedSavedObjectsClientWrapper implements SavedObjectsClientCon } const id = generateID(); - const namespace = this.getDescriptorNamespace(object.type, options?.namespace); + const namespace = getDescriptorNamespace( + this.options.baseTypeRegistry, + object.type, + options?.namespace + ); return { ...object, id, @@ -124,8 +129,7 @@ export class EncryptedSavedObjectsClientWrapper implements SavedObjectsClientCon return await this.handleEncryptedAttributesInBulkResponse( await this.options.baseClient.bulkCreate(encryptedObjects, options), - objects, - options?.namespace + objects ); } @@ -142,7 +146,11 @@ export class EncryptedSavedObjectsClientWrapper implements SavedObjectsClientCon if (!this.options.service.isRegistered(type)) { return object; } - const namespace = this.getDescriptorNamespace(type, options?.namespace); + const namespace = getDescriptorNamespace( + this.options.baseTypeRegistry, + type, + options?.namespace + ); return { ...object, attributes: await this.options.service.encryptAttributes( @@ -156,8 +164,7 @@ export class EncryptedSavedObjectsClientWrapper implements SavedObjectsClientCon return await this.handleEncryptedAttributesInBulkResponse( await this.options.baseClient.bulkUpdate(encryptedObjects, options), - objects, - options?.namespace + objects ); } @@ -168,8 +175,7 @@ export class EncryptedSavedObjectsClientWrapper implements SavedObjectsClientCon public async find(options: SavedObjectsFindOptions) { return await this.handleEncryptedAttributesInBulkResponse( await this.options.baseClient.find(options), - undefined, - options.namespace + undefined ); } @@ -179,8 +185,7 @@ export class EncryptedSavedObjectsClientWrapper implements SavedObjectsClientCon ) { return await this.handleEncryptedAttributesInBulkResponse( await this.options.baseClient.bulkGet(objects, options), - undefined, - options?.namespace + undefined ); } @@ -188,7 +193,7 @@ export class EncryptedSavedObjectsClientWrapper implements SavedObjectsClientCon return await this.handleEncryptedAttributesInResponse( await this.options.baseClient.get(type, id, options), undefined as unknown, - this.getDescriptorNamespace(type, options?.namespace) + getDescriptorNamespace(this.options.baseTypeRegistry, type, options?.namespace) ); } @@ -201,7 +206,11 @@ export class EncryptedSavedObjectsClientWrapper implements SavedObjectsClientCon if (!this.options.service.isRegistered(type)) { return await this.options.baseClient.update(type, id, attributes, options); } - const namespace = this.getDescriptorNamespace(type, options?.namespace); + const namespace = getDescriptorNamespace( + this.options.baseTypeRegistry, + type, + options?.namespace + ); return this.handleEncryptedAttributesInResponse( await this.options.baseClient.update( type, @@ -270,7 +279,6 @@ export class EncryptedSavedObjectsClientWrapper implements SavedObjectsClientCon * response portion isn't registered, it is returned as is. * @param response Raw response returned by the underlying base client. * @param [objects] Optional list of saved objects with original attributes. - * @param [namespace] Optional namespace that was used for the saved objects operation. */ private async handleEncryptedAttributesInBulkResponse< T, @@ -279,12 +287,16 @@ export class EncryptedSavedObjectsClientWrapper implements SavedObjectsClientCon | SavedObjectsFindResponse | SavedObjectsBulkUpdateResponse, O extends Array> | Array> - >(response: R, objects?: O, namespace?: string) { + >(response: R, objects?: O) { for (const [index, savedObject] of response.saved_objects.entries()) { await this.handleEncryptedAttributesInResponse( savedObject, objects?.[index].attributes ?? undefined, - this.getDescriptorNamespace(savedObject.type, namespace) + getDescriptorNamespace( + this.options.baseTypeRegistry, + savedObject.type, + savedObject.namespaces ? savedObject.namespaces[0] : undefined + ) ); } diff --git a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/get_descriptor_namespace.test.ts b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/get_descriptor_namespace.test.ts new file mode 100644 index 0000000000000..7ba90a5a76ab3 --- /dev/null +++ b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/get_descriptor_namespace.test.ts @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { savedObjectsTypeRegistryMock } from 'src/core/server/mocks'; +import { getDescriptorNamespace } from './get_descriptor_namespace'; + +describe('getDescriptorNamespace', () => { + describe('namespace agnostic', () => { + it('returns undefined', () => { + const mockBaseTypeRegistry = savedObjectsTypeRegistryMock.create(); + mockBaseTypeRegistry.isSingleNamespace.mockReturnValue(false); + mockBaseTypeRegistry.isMultiNamespace.mockReturnValue(false); + mockBaseTypeRegistry.isNamespaceAgnostic.mockReturnValue(true); + + expect(getDescriptorNamespace(mockBaseTypeRegistry, 'globaltype', undefined)).toEqual( + undefined + ); + expect(getDescriptorNamespace(mockBaseTypeRegistry, 'globaltype', 'foo-namespace')).toEqual( + undefined + ); + }); + }); + + describe('multi-namespace', () => { + it('returns undefined', () => { + const mockBaseTypeRegistry = savedObjectsTypeRegistryMock.create(); + mockBaseTypeRegistry.isSingleNamespace.mockReturnValue(false); + mockBaseTypeRegistry.isMultiNamespace.mockReturnValue(true); + mockBaseTypeRegistry.isNamespaceAgnostic.mockReturnValue(false); + + expect(getDescriptorNamespace(mockBaseTypeRegistry, 'sharedtype', undefined)).toEqual( + undefined + ); + expect(getDescriptorNamespace(mockBaseTypeRegistry, 'sharedtype', 'foo-namespace')).toEqual( + undefined + ); + }); + }); + + describe('single namespace', () => { + it('returns `undefined` if provided namespace is undefined or `default`', () => { + const mockBaseTypeRegistry = savedObjectsTypeRegistryMock.create(); + mockBaseTypeRegistry.isSingleNamespace.mockReturnValue(true); + mockBaseTypeRegistry.isMultiNamespace.mockReturnValue(false); + mockBaseTypeRegistry.isNamespaceAgnostic.mockReturnValue(false); + + expect(getDescriptorNamespace(mockBaseTypeRegistry, 'singletype', undefined)).toEqual( + undefined + ); + + expect(getDescriptorNamespace(mockBaseTypeRegistry, 'singletype', 'default')).toEqual( + undefined + ); + }); + + it('returns the provided namespace', () => { + const mockBaseTypeRegistry = savedObjectsTypeRegistryMock.create(); + mockBaseTypeRegistry.isSingleNamespace.mockReturnValue(true); + mockBaseTypeRegistry.isMultiNamespace.mockReturnValue(false); + mockBaseTypeRegistry.isNamespaceAgnostic.mockReturnValue(false); + + expect(getDescriptorNamespace(mockBaseTypeRegistry, 'singletype', 'foo-namespace')).toEqual( + 'foo-namespace' + ); + }); + }); +}); diff --git a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/get_descriptor_namespace.ts b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/get_descriptor_namespace.ts new file mode 100644 index 0000000000000..b2842df909a1d --- /dev/null +++ b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/get_descriptor_namespace.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ISavedObjectTypeRegistry } from 'kibana/server'; + +export const getDescriptorNamespace = ( + typeRegistry: ISavedObjectTypeRegistry, + type: string, + namespace?: string +) => { + const descriptorNamespace = typeRegistry.isSingleNamespace(type) ? namespace : undefined; + return descriptorNamespace === 'default' ? undefined : descriptorNamespace; +}; diff --git a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/index.ts b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/index.ts index af00050183b77..0e5be4e4eee5a 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/index.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/index.ts @@ -15,6 +15,7 @@ import { import { SecurityPluginSetup } from '../../../security/server'; import { EncryptedSavedObjectsService } from '../crypto'; import { EncryptedSavedObjectsClientWrapper } from './encrypted_saved_objects_client_wrapper'; +import { getDescriptorNamespace } from './get_descriptor_namespace'; interface SetupSavedObjectsParams { service: PublicMethodsOf; @@ -84,7 +85,7 @@ export function setupSavedObjects({ { type, id, - namespace: typeRegistry.isSingleNamespace(type) ? options?.namespace : undefined, + namespace: getDescriptorNamespace(typeRegistry, type, options?.namespace), }, savedObject.attributes as Record )) as T, diff --git a/x-pack/plugins/security/server/authorization/check_saved_objects_privileges.test.ts b/x-pack/plugins/security/server/authorization/check_saved_objects_privileges.test.ts index 4ab00b511b48b..5e38045b88c74 100644 --- a/x-pack/plugins/security/server/authorization/check_saved_objects_privileges.test.ts +++ b/x-pack/plugins/security/server/authorization/check_saved_objects_privileges.test.ts @@ -43,17 +43,6 @@ describe('#checkSavedObjectsPrivileges', () => { describe('when checking multiple namespaces', () => { const namespaces = [namespace1, namespace2]; - test(`throws an error when Spaces is disabled`, async () => { - mockSpacesService = undefined; - const checkSavedObjectsPrivileges = createFactory(); - - await expect( - checkSavedObjectsPrivileges(actions, namespaces) - ).rejects.toThrowErrorMatchingInlineSnapshot( - `"Can't check saved object privileges for multiple namespaces if Spaces is disabled"` - ); - }); - test(`throws an error when using an empty namespaces array`, async () => { const checkSavedObjectsPrivileges = createFactory(); diff --git a/x-pack/plugins/security/server/authorization/check_saved_objects_privileges.ts b/x-pack/plugins/security/server/authorization/check_saved_objects_privileges.ts index d9b070c72f946..0c2260542bf72 100644 --- a/x-pack/plugins/security/server/authorization/check_saved_objects_privileges.ts +++ b/x-pack/plugins/security/server/authorization/check_saved_objects_privileges.ts @@ -29,21 +29,21 @@ export const checkSavedObjectsPrivilegesWithRequestFactory = ( namespaceOrNamespaces?: string | string[] ) { const spacesService = getSpacesService(); - if (Array.isArray(namespaceOrNamespaces)) { - if (spacesService === undefined) { - throw new Error( - `Can't check saved object privileges for multiple namespaces if Spaces is disabled` - ); - } else if (!namespaceOrNamespaces.length) { + if (!spacesService) { + // Spaces disabled, authorizing globally + return await checkPrivilegesWithRequest(request).globally(actions); + } else if (Array.isArray(namespaceOrNamespaces)) { + // Spaces enabled, authorizing against multiple spaces + if (!namespaceOrNamespaces.length) { throw new Error(`Can't check saved object privileges for 0 namespaces`); } const spaceIds = namespaceOrNamespaces.map((x) => spacesService.namespaceToSpaceId(x)); return await checkPrivilegesWithRequest(request).atSpaces(spaceIds, actions); - } else if (spacesService) { + } else { + // Spaces enabled, authorizing against a single space const spaceId = spacesService.namespaceToSpaceId(namespaceOrNamespaces); return await checkPrivilegesWithRequest(request).atSpace(spaceId, actions); } - return await checkPrivilegesWithRequest(request).globally(actions); }; }; }; diff --git a/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.test.ts b/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.test.ts index c646cd95228f0..1cf879adc5415 100644 --- a/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.test.ts +++ b/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.test.ts @@ -27,6 +27,7 @@ const createSecureSavedObjectsClientWrapperOptions = () => { const errors = ({ decorateForbiddenError: jest.fn().mockReturnValue(forbiddenError), decorateGeneralError: jest.fn().mockReturnValue(generalError), + createBadRequestError: jest.fn().mockImplementation((message) => new Error(message)), isNotFoundError: jest.fn().mockReturnValue(false), } as unknown) as jest.Mocked; const getSpacesService = jest.fn().mockReturnValue(true); @@ -73,7 +74,9 @@ const expectForbiddenError = async (fn: Function, args: Record) => SavedObjectActions['get'] >).mock.calls; const actions = clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mock.calls[0][0]; - const spaceId = args.options?.namespace || 'default'; + const spaceId = args.options?.namespaces + ? args.options?.namespaces[0] + : args.options?.namespace || 'default'; const ACTION = getCalls[0][1]; const types = getCalls.map((x) => x[0]); @@ -100,7 +103,7 @@ const expectSuccess = async (fn: Function, args: Record) => { >).mock.calls; const ACTION = getCalls[0][1]; const types = getCalls.map((x) => x[0]); - const spaceIds = [args.options?.namespace || 'default']; + const spaceIds = args.options?.namespaces || [args.options?.namespace || 'default']; expect(clientOpts.auditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); expect(clientOpts.auditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledTimes(1); @@ -128,7 +131,7 @@ const expectPrivilegeCheck = async (fn: Function, args: Record) => expect(clientOpts.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledTimes(1); expect(clientOpts.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledWith( actions, - args.options?.namespace + args.options?.namespace ?? args.options?.namespaces ); }; @@ -344,7 +347,7 @@ describe('#addToNamespaces', () => { ); }); - test(`checks privileges for user, actions, and namespace`, async () => { + test(`checks privileges for user, actions, and namespaces`, async () => { clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mockImplementationOnce( getMockCheckPrivilegesSuccess // create ); @@ -539,12 +542,12 @@ describe('#find', () => { }); test(`throws decorated ForbiddenError when type's singular and unauthorized`, async () => { - const options = Object.freeze({ type: type1, namespace: 'some-ns' }); + const options = Object.freeze({ type: type1, namespaces: ['some-ns'] }); await expectForbiddenError(client.find, { options }); }); test(`throws decorated ForbiddenError when type's an array and unauthorized`, async () => { - const options = Object.freeze({ type: [type1, type2], namespace: 'some-ns' }); + const options = Object.freeze({ type: [type1, type2], namespaces: ['some-ns'] }); await expectForbiddenError(client.find, { options }); }); @@ -552,18 +555,34 @@ describe('#find', () => { const apiCallReturnValue = { saved_objects: [], foo: 'bar' }; clientOpts.baseClient.find.mockReturnValue(apiCallReturnValue as any); - const options = Object.freeze({ type: type1, namespace: 'some-ns' }); + const options = Object.freeze({ type: type1, namespaces: ['some-ns'] }); const result = await expectSuccess(client.find, { options }); expect(result).toEqual(apiCallReturnValue); }); - test(`checks privileges for user, actions, and namespace`, async () => { - const options = Object.freeze({ type: [type1, type2], namespace: 'some-ns' }); + test(`throws BadRequestError when searching across namespaces when spaces is disabled`, async () => { + clientOpts = createSecureSavedObjectsClientWrapperOptions(); + clientOpts.getSpacesService.mockReturnValue(undefined); + client = new SecureSavedObjectsClientWrapper(clientOpts); + + // succeed privilege checks by default + clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mockImplementation( + getMockCheckPrivilegesSuccess + ); + + const options = Object.freeze({ type: [type1, type2], namespaces: ['some-ns'] }); + await expect(client.find(options)).rejects.toThrowErrorMatchingInlineSnapshot( + `"_find across namespaces is not permitted when the Spaces plugin is disabled."` + ); + }); + + test(`checks privileges for user, actions, and namespaces`, async () => { + const options = Object.freeze({ type: [type1, type2], namespaces: ['some-ns'] }); await expectPrivilegeCheck(client.find, { options }); }); test(`filters namespaces that the user doesn't have access to`, async () => { - const options = Object.freeze({ type: [type1, type2], namespace: 'some-ns' }); + const options = Object.freeze({ type: [type1, type2], namespaces: ['some-ns'] }); await expectObjectsNamespaceFiltering(client.find, { options }); }); }); diff --git a/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.ts b/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.ts index 969344afae5e3..621299a0f025e 100644 --- a/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.ts +++ b/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.ts @@ -99,7 +99,16 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra } public async find(options: SavedObjectsFindOptions) { - await this.ensureAuthorized(options.type, 'find', options.namespace, { options }); + if ( + this.getSpacesService() == null && + Array.isArray(options.namespaces) && + options.namespaces.length > 0 + ) { + throw this.errors.createBadRequestError( + `_find across namespaces is not permitted when the Spaces plugin is disabled.` + ); + } + await this.ensureAuthorized(options.type, 'find', options.namespaces, { options }); const response = await this.baseClient.find(options); return await this.redactSavedObjectsNamespaces(response); @@ -293,7 +302,11 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra private async redactSavedObjectNamespaces( savedObject: T ): Promise { - if (this.getSpacesService() === undefined || savedObject.namespaces == null) { + if ( + this.getSpacesService() === undefined || + savedObject.namespaces == null || + savedObject.namespaces.length === 0 + ) { return savedObject; } diff --git a/x-pack/plugins/spaces/common/model/types.ts b/x-pack/plugins/spaces/common/model/types.ts index 58c36da33dbd7..30004c739ee7a 100644 --- a/x-pack/plugins/spaces/common/model/types.ts +++ b/x-pack/plugins/spaces/common/model/types.ts @@ -4,4 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -export type GetSpacePurpose = 'any' | 'copySavedObjectsIntoSpace'; +export type GetSpacePurpose = 'any' | 'copySavedObjectsIntoSpace' | 'findSavedObjects'; diff --git a/x-pack/plugins/spaces/server/lib/spaces_client/__snapshots__/spaces_client.test.ts.snap b/x-pack/plugins/spaces/server/lib/spaces_client/__snapshots__/spaces_client.test.ts.snap index a0fa3a2c75eab..c2df94a0a2936 100644 --- a/x-pack/plugins/spaces/server/lib/spaces_client/__snapshots__/spaces_client.test.ts.snap +++ b/x-pack/plugins/spaces/server/lib/spaces_client/__snapshots__/spaces_client.test.ts.snap @@ -26,6 +26,8 @@ exports[`#getAll useRbacForRequest is true with purpose='any' throws Boom.forbid exports[`#getAll useRbacForRequest is true with purpose='copySavedObjectsIntoSpace' throws Boom.forbidden when user isn't authorized for any spaces 1`] = `"Forbidden"`; +exports[`#getAll useRbacForRequest is true with purpose='findSavedObjects' throws Boom.forbidden when user isn't authorized for any spaces 1`] = `"Forbidden"`; + exports[`#getAll useRbacForRequest is true with purpose='undefined' throws Boom.forbidden when user isn't authorized for any spaces 1`] = `"Forbidden"`; exports[`#update useRbacForRequest is true throws Boom.forbidden when user isn't authorized at space 1`] = `"Unauthorized to update spaces"`; diff --git a/x-pack/plugins/spaces/server/lib/spaces_client/spaces_client.test.ts b/x-pack/plugins/spaces/server/lib/spaces_client/spaces_client.test.ts index fc2110f15f39d..61b1985c5a0b9 100644 --- a/x-pack/plugins/spaces/server/lib/spaces_client/spaces_client.test.ts +++ b/x-pack/plugins/spaces/server/lib/spaces_client/spaces_client.test.ts @@ -228,15 +228,20 @@ describe('#getAll', () => { mockAuthorization.actions.login, }, { - purpose: 'any', + purpose: 'any' as GetSpacePurpose, expectedPrivilege: (mockAuthorization: SecurityPluginSetup['authz']) => mockAuthorization.actions.login, }, { - purpose: 'copySavedObjectsIntoSpace', + purpose: 'copySavedObjectsIntoSpace' as GetSpacePurpose, expectedPrivilege: (mockAuthorization: SecurityPluginSetup['authz']) => mockAuthorization.actions.ui.get('savedObjectsManagement', 'copyIntoSpace'), }, + { + purpose: 'findSavedObjects' as GetSpacePurpose, + expectedPrivilege: (mockAuthorization: SecurityPluginSetup['authz']) => + mockAuthorization.actions.savedObject.get('config', 'find'), + }, ].forEach((scenario) => { describe(`with purpose='${scenario.purpose}'`, () => { test(`throws Boom.forbidden when user isn't authorized for any spaces`, async () => { @@ -276,9 +281,7 @@ describe('#getAll', () => { mockInternalRepository, request ); - await expect( - client.getAll(scenario.purpose as GetSpacePurpose) - ).rejects.toThrowErrorMatchingSnapshot(); + await expect(client.getAll(scenario.purpose)).rejects.toThrowErrorMatchingSnapshot(); expect(mockInternalRepository.find).toHaveBeenCalledWith({ type: 'space', @@ -290,7 +293,7 @@ describe('#getAll', () => { expect(mockAuthorization.checkPrivilegesWithRequest).toHaveBeenCalledWith(request); expect(mockCheckPrivilegesAtSpaces).toHaveBeenCalledWith( savedObjects.map((savedObject) => savedObject.id), - privilege + [privilege] ); expect(mockAuditLogger.spacesAuthorizationFailure).toHaveBeenCalledWith( username, @@ -336,7 +339,7 @@ describe('#getAll', () => { mockInternalRepository, request ); - const actualSpaces = await client.getAll(scenario.purpose as GetSpacePurpose); + const actualSpaces = await client.getAll(scenario.purpose); expect(actualSpaces).toEqual([expectedSpaces[0]]); expect(mockInternalRepository.find).toHaveBeenCalledWith({ @@ -349,7 +352,7 @@ describe('#getAll', () => { expect(mockAuthorization.checkPrivilegesWithRequest).toHaveBeenCalledWith(request); expect(mockCheckPrivilegesAtSpaces).toHaveBeenCalledWith( savedObjects.map((savedObject) => savedObject.id), - privilege + [privilege] ); expect(mockAuditLogger.spacesAuthorizationFailure).toHaveBeenCalledTimes(0); expect(mockAuditLogger.spacesAuthorizationSuccess).toHaveBeenCalledWith( diff --git a/x-pack/plugins/spaces/server/lib/spaces_client/spaces_client.ts b/x-pack/plugins/spaces/server/lib/spaces_client/spaces_client.ts index 25fc3ad97c0d9..b4b0057a2f5a5 100644 --- a/x-pack/plugins/spaces/server/lib/spaces_client/spaces_client.ts +++ b/x-pack/plugins/spaces/server/lib/spaces_client/spaces_client.ts @@ -13,15 +13,23 @@ import { SpacesAuditLogger } from '../audit_logger'; import { ConfigType } from '../../config'; import { GetSpacePurpose } from '../../../common/model/types'; -const SUPPORTED_GET_SPACE_PURPOSES: GetSpacePurpose[] = ['any', 'copySavedObjectsIntoSpace']; +const SUPPORTED_GET_SPACE_PURPOSES: GetSpacePurpose[] = [ + 'any', + 'copySavedObjectsIntoSpace', + 'findSavedObjects', +]; const PURPOSE_PRIVILEGE_MAP: Record< GetSpacePurpose, - (authorization: SecurityPluginSetup['authz']) => string + (authorization: SecurityPluginSetup['authz']) => string[] > = { - any: (authorization) => authorization.actions.login, - copySavedObjectsIntoSpace: (authorization) => + any: (authorization) => [authorization.actions.login], + copySavedObjectsIntoSpace: (authorization) => [ authorization.actions.ui.get('savedObjectsManagement', 'copyIntoSpace'), + ], + findSavedObjects: (authorization) => { + return [authorization.actions.savedObject.get('config', 'find')]; + }, }; export class SpacesClient { @@ -86,7 +94,7 @@ export class SpacesClient { if (authorized.length === 0) { this.debugLogger( - `SpacesClient.getAll(), using RBAC. returning 403/Forbidden. Not authorized for any spaces.` + `SpacesClient.getAll(), using RBAC. returning 403/Forbidden. Not authorized for any spaces for ${purpose} purpose.` ); this.auditLogger.spacesAuthorizationFailure(username, 'getAll'); throw Boom.forbidden(); diff --git a/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.test.ts b/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.test.ts index 190429d2dacd4..4d0d75cd4595c 100644 --- a/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.test.ts +++ b/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.test.ts @@ -9,6 +9,7 @@ import { SpacesSavedObjectsClient } from './spaces_saved_objects_client'; import { spacesServiceMock } from '../spaces_service/spaces_service.mock'; import { savedObjectsClientMock } from '../../../../../src/core/server/mocks'; import { SavedObjectTypeRegistry } from 'src/core/server'; +import { SpacesClient } from '../lib/spaces_client'; const typeRegistry = new SavedObjectTypeRegistry(); typeRegistry.registerType({ @@ -48,6 +49,7 @@ const createMockResponse = () => ({ timeFieldName: '@timestamp', notExpandable: true, references: [], + score: 0, }); const ERROR_NAMESPACE_SPECIFIED = 'Spaces currently determines the namespaces'; @@ -68,7 +70,7 @@ const ERROR_NAMESPACE_SPECIFIED = 'Spaces currently determines the namespaces'; spacesService, typeRegistry, }); - return { client, baseClient }; + return { client, baseClient, spacesService }; }; describe('#get', () => { @@ -127,14 +129,6 @@ const ERROR_NAMESPACE_SPECIFIED = 'Spaces currently determines the namespaces'; }); describe('#find', () => { - test(`throws error if options.namespace is specified`, async () => { - const { client } = await createSpacesSavedObjectsClient(); - - await expect(client.find({ type: 'foo', namespace: 'bar' })).rejects.toThrow( - ERROR_NAMESPACE_SPECIFIED - ); - }); - test(`passes options.type to baseClient if valid singular type specified`, async () => { const { client, baseClient } = await createSpacesSavedObjectsClient(); const expectedReturnValue = { @@ -151,7 +145,7 @@ const ERROR_NAMESPACE_SPECIFIED = 'Spaces currently determines the namespaces'; expect(actualReturnValue).toBe(expectedReturnValue); expect(baseClient.find).toHaveBeenCalledWith({ type: ['foo'], - namespace: currentSpace.expectedNamespace, + namespaces: [currentSpace.expectedNamespace ?? 'default'], }); }); @@ -171,8 +165,101 @@ const ERROR_NAMESPACE_SPECIFIED = 'Spaces currently determines the namespaces'; expect(actualReturnValue).toBe(expectedReturnValue); expect(baseClient.find).toHaveBeenCalledWith({ type: ['foo', 'bar'], - namespace: currentSpace.expectedNamespace, + namespaces: [currentSpace.expectedNamespace ?? 'default'], + }); + }); + + test(`passes options.namespaces along`, async () => { + const { client, baseClient, spacesService } = await createSpacesSavedObjectsClient(); + const expectedReturnValue = { + saved_objects: [createMockResponse()], + total: 1, + per_page: 0, + page: 0, + }; + baseClient.find.mockReturnValue(Promise.resolve(expectedReturnValue)); + + const spacesClient = (await spacesService.scopedClient(null as any)) as jest.Mocked< + SpacesClient + >; + spacesClient.getAll.mockImplementation(() => + Promise.resolve([ + { id: 'ns-1', name: '', disabledFeatures: [] }, + { id: 'ns-2', name: '', disabledFeatures: [] }, + ]) + ); + + const options = Object.freeze({ type: ['foo', 'bar'], namespaces: ['ns-1', 'ns-2'] }); + const actualReturnValue = await client.find(options); + + expect(actualReturnValue).toBe(expectedReturnValue); + expect(baseClient.find).toHaveBeenCalledWith({ + type: ['foo', 'bar'], + namespaces: ['ns-1', 'ns-2'], + }); + expect(spacesClient.getAll).toHaveBeenCalledWith('findSavedObjects'); + }); + + test(`filters options.namespaces based on authorization`, async () => { + const { client, baseClient, spacesService } = await createSpacesSavedObjectsClient(); + const expectedReturnValue = { + saved_objects: [createMockResponse()], + total: 1, + per_page: 0, + page: 0, + }; + baseClient.find.mockReturnValue(Promise.resolve(expectedReturnValue)); + + const spacesClient = (await spacesService.scopedClient(null as any)) as jest.Mocked< + SpacesClient + >; + spacesClient.getAll.mockImplementation(() => + Promise.resolve([ + { id: 'ns-1', name: '', disabledFeatures: [] }, + { id: 'ns-2', name: '', disabledFeatures: [] }, + ]) + ); + + const options = Object.freeze({ type: ['foo', 'bar'], namespaces: ['ns-1', 'ns-3'] }); + const actualReturnValue = await client.find(options); + + expect(actualReturnValue).toBe(expectedReturnValue); + expect(baseClient.find).toHaveBeenCalledWith({ + type: ['foo', 'bar'], + namespaces: ['ns-1'], + }); + expect(spacesClient.getAll).toHaveBeenCalledWith('findSavedObjects'); + }); + + test(`translates options.namespace: ['*']`, async () => { + const { client, baseClient, spacesService } = await createSpacesSavedObjectsClient(); + const expectedReturnValue = { + saved_objects: [createMockResponse()], + total: 1, + per_page: 0, + page: 0, + }; + baseClient.find.mockReturnValue(Promise.resolve(expectedReturnValue)); + + const spacesClient = (await spacesService.scopedClient(null as any)) as jest.Mocked< + SpacesClient + >; + spacesClient.getAll.mockImplementation(() => + Promise.resolve([ + { id: 'ns-1', name: '', disabledFeatures: [] }, + { id: 'ns-2', name: '', disabledFeatures: [] }, + ]) + ); + + const options = Object.freeze({ type: ['foo', 'bar'], namespaces: ['*'] }); + const actualReturnValue = await client.find(options); + + expect(actualReturnValue).toBe(expectedReturnValue); + expect(baseClient.find).toHaveBeenCalledWith({ + type: ['foo', 'bar'], + namespaces: ['ns-1', 'ns-2'], }); + expect(spacesClient.getAll).toHaveBeenCalledWith('findSavedObjects'); }); }); diff --git a/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.ts b/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.ts index 6611725be8b67..7e2b302d7cff5 100644 --- a/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.ts +++ b/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.ts @@ -19,6 +19,7 @@ import { } from 'src/core/server'; import { SpacesServiceSetup } from '../spaces_service/spaces_service'; import { spaceIdToNamespace } from '../lib/utils/namespace'; +import { SpacesClient } from '../lib/spaces_client'; interface SpacesSavedObjectsClientOptions { baseClient: SavedObjectsClientContract; @@ -45,12 +46,14 @@ export class SpacesSavedObjectsClient implements SavedObjectsClientContract { private readonly client: SavedObjectsClientContract; private readonly spaceId: string; private readonly types: string[]; + private readonly getSpacesClient: Promise; public readonly errors: SavedObjectsClientContract['errors']; constructor(options: SpacesSavedObjectsClientOptions) { const { baseClient, request, spacesService, typeRegistry } = options; this.client = baseClient; + this.getSpacesClient = spacesService.scopedClient(request); this.spaceId = spacesService.getSpaceId(request); this.types = typeRegistry.getAllTypes().map((t) => t.name); this.errors = baseClient.errors; @@ -131,19 +134,40 @@ export class SpacesSavedObjectsClient implements SavedObjectsClientContract { * @property {string} [options.sortField] * @property {string} [options.sortOrder] * @property {Array} [options.fields] - * @property {string} [options.namespace] + * @property {string} [options.namespaces] * @property {object} [options.hasReference] - { type, id } * @returns {promise} - { saved_objects: [{ id, type, version, attributes }], total, per_page, page } */ public async find(options: SavedObjectsFindOptions) { throwErrorIfNamespaceSpecified(options); + let namespaces = options.namespaces; + if (namespaces) { + const spacesClient = await this.getSpacesClient; + const availableSpaces = await spacesClient.getAll('findSavedObjects'); + if (namespaces.includes('*')) { + namespaces = availableSpaces.map((space) => space.id); + } else { + namespaces = namespaces.filter((namespace) => + availableSpaces.some((space) => space.id === namespace) + ); + } + // This forbidden error allows this scenario to be consistent + // with the way the SpacesClient behaves when no spaces are authorized + // there. + if (namespaces.length === 0) { + throw this.errors.decorateForbiddenError(new Error()); + } + } else { + namespaces = [this.spaceId]; + } + return await this.client.find({ ...options, type: (options.type ? coerceToArray(options.type) : this.types).filter( (type) => type !== 'space' ), - namespace: spaceIdToNamespace(this.spaceId), + namespaces, }); } diff --git a/x-pack/test/saved_object_api_integration/common/lib/saved_object_test_utils.ts b/x-pack/test/saved_object_api_integration/common/lib/saved_object_test_utils.ts index de036494caa83..5d08421038d3f 100644 --- a/x-pack/test/saved_object_api_integration/common/lib/saved_object_test_utils.ts +++ b/x-pack/test/saved_object_api_integration/common/lib/saved_object_test_utils.ts @@ -92,9 +92,9 @@ const uniq = (arr: T[]): T[] => Array.from(new Set(arr)); const isNamespaceAgnostic = (type: string) => type === 'globaltype'; const isMultiNamespace = (type: string) => type === 'sharedtype'; export const expectResponses = { - forbidden: (action: string) => (typeOrTypes: string | string[]): ExpectResponseBody => async ( - response: Record - ) => { + forbiddenTypes: (action: string) => ( + typeOrTypes: string | string[] + ): ExpectResponseBody => async (response: Record) => { const types = Array.isArray(typeOrTypes) ? typeOrTypes : [typeOrTypes]; const uniqueSorted = uniq(types).sort(); expect(response.body).to.eql({ @@ -103,6 +103,13 @@ export const expectResponses = { message: `Unable to ${action} ${uniqueSorted.join()}`, }); }, + forbiddenSpaces: (response: Record) => { + expect(response.body).to.eql({ + statusCode: 403, + error: 'Forbidden', + message: `Forbidden`, + }); + }, permitted: async (object: Record, testCase: TestCase) => { const { type, id, failure } = testCase; if (failure) { @@ -189,18 +196,36 @@ export const expectResponses = { */ export const getTestScenarios = (modifiers?: T[]) => { const commonUsers = { - noAccess: { ...NOT_A_KIBANA_USER, description: 'user with no access' }, - superuser: { ...SUPERUSER, description: 'superuser' }, - legacyAll: { ...KIBANA_LEGACY_USER, description: 'legacy user' }, - allGlobally: { ...KIBANA_RBAC_USER, description: 'rbac user with all globally' }, + noAccess: { + ...NOT_A_KIBANA_USER, + description: 'user with no access', + authorizedAtSpaces: [], + }, + superuser: { + ...SUPERUSER, + description: 'superuser', + authorizedAtSpaces: ['*'], + }, + legacyAll: { ...KIBANA_LEGACY_USER, description: 'legacy user', authorizedAtSpaces: [] }, + allGlobally: { + ...KIBANA_RBAC_USER, + description: 'rbac user with all globally', + authorizedAtSpaces: ['*'], + }, readGlobally: { ...KIBANA_RBAC_DASHBOARD_ONLY_USER, description: 'rbac user with read globally', + authorizedAtSpaces: ['*'], + }, + dualAll: { + ...KIBANA_DUAL_PRIVILEGES_USER, + description: 'dual-privileges user', + authorizedAtSpaces: ['*'], }, - dualAll: { ...KIBANA_DUAL_PRIVILEGES_USER, description: 'dual-privileges user' }, dualRead: { ...KIBANA_DUAL_PRIVILEGES_DASHBOARD_ONLY_USER, description: 'dual-privileges readonly user', + authorizedAtSpaces: ['*'], }, }; @@ -236,18 +261,22 @@ export const getTestScenarios = (modifiers?: T[]) => { allAtDefaultSpace: { ...KIBANA_RBAC_DEFAULT_SPACE_ALL_USER, description: 'rbac user with all at default space', + authorizedAtSpaces: ['default'], }, readAtDefaultSpace: { ...KIBANA_RBAC_DEFAULT_SPACE_READ_USER, description: 'rbac user with read at default space', + authorizedAtSpaces: ['default'], }, allAtSpace1: { ...KIBANA_RBAC_SPACE_1_ALL_USER, description: 'rbac user with all at space_1', + authorizedAtSpaces: ['space_1'], }, readAtSpace1: { ...KIBANA_RBAC_SPACE_1_READ_USER, description: 'rbac user with read at space_1', + authorizedAtSpaces: ['space_1'], }, }, }, @@ -260,14 +289,17 @@ export const getTestScenarios = (modifiers?: T[]) => { allAtSpace: { ...KIBANA_RBAC_DEFAULT_SPACE_ALL_USER, description: 'user with all at the space', + authorizedAtSpaces: ['default'], }, readAtSpace: { ...KIBANA_RBAC_DEFAULT_SPACE_READ_USER, description: 'user with read at the space', + authorizedAtSpaces: ['default'], }, allAtOtherSpace: { ...KIBANA_RBAC_SPACE_1_ALL_USER, description: 'user with all at other space', + authorizedAtSpaces: ['space_1'], }, }, }, @@ -275,14 +307,20 @@ export const getTestScenarios = (modifiers?: T[]) => { spaceId: SPACE_1_ID, users: { ...commonUsers, - allAtSpace: { ...KIBANA_RBAC_SPACE_1_ALL_USER, description: 'user with all at the space' }, + allAtSpace: { + ...KIBANA_RBAC_SPACE_1_ALL_USER, + description: 'user with all at the space', + authorizedAtSpaces: ['space_1'], + }, readAtSpace: { ...KIBANA_RBAC_SPACE_1_READ_USER, description: 'user with read at the space', + authorizedAtSpaces: ['space_1'], }, allAtOtherSpace: { ...KIBANA_RBAC_DEFAULT_SPACE_ALL_USER, description: 'user with all at other space', + authorizedAtSpaces: ['default'], }, }, }, diff --git a/x-pack/test/saved_object_api_integration/common/lib/types.ts b/x-pack/test/saved_object_api_integration/common/lib/types.ts index f6e6d391ae905..56e6a992b6b62 100644 --- a/x-pack/test/saved_object_api_integration/common/lib/types.ts +++ b/x-pack/test/saved_object_api_integration/common/lib/types.ts @@ -28,4 +28,5 @@ export interface TestUser { username: string; password: string; description: string; + authorizedAtSpaces: string[]; } diff --git a/x-pack/test/saved_object_api_integration/common/suites/bulk_create.ts b/x-pack/test/saved_object_api_integration/common/suites/bulk_create.ts index dd32c42597c32..bc356927cc0af 100644 --- a/x-pack/test/saved_object_api_integration/common/suites/bulk_create.ts +++ b/x-pack/test/saved_object_api_integration/common/suites/bulk_create.ts @@ -39,7 +39,7 @@ export const TEST_CASES = Object.freeze({ }); export function bulkCreateTestSuiteFactory(es: any, esArchiver: any, supertest: SuperTest) { - const expectForbidden = expectResponses.forbidden('bulk_create'); + const expectForbidden = expectResponses.forbiddenTypes('bulk_create'); const expectResponseBody = ( testCases: BulkCreateTestCase | BulkCreateTestCase[], statusCode: 200 | 403, diff --git a/x-pack/test/saved_object_api_integration/common/suites/bulk_get.ts b/x-pack/test/saved_object_api_integration/common/suites/bulk_get.ts index f5ec5b6560fc9..8de54fe499c07 100644 --- a/x-pack/test/saved_object_api_integration/common/suites/bulk_get.ts +++ b/x-pack/test/saved_object_api_integration/common/suites/bulk_get.ts @@ -28,7 +28,7 @@ const DOES_NOT_EXIST = Object.freeze({ type: 'dashboard', id: 'does-not-exist' } export const TEST_CASES = Object.freeze({ ...CASES, DOES_NOT_EXIST }); export function bulkGetTestSuiteFactory(esArchiver: any, supertest: SuperTest) { - const expectForbidden = expectResponses.forbidden('bulk_get'); + const expectForbidden = expectResponses.forbiddenTypes('bulk_get'); const expectResponseBody = ( testCases: BulkGetTestCase | BulkGetTestCase[], statusCode: 200 | 403 diff --git a/x-pack/test/saved_object_api_integration/common/suites/bulk_update.ts b/x-pack/test/saved_object_api_integration/common/suites/bulk_update.ts index 0073b79a934a5..0b5656004492a 100644 --- a/x-pack/test/saved_object_api_integration/common/suites/bulk_update.ts +++ b/x-pack/test/saved_object_api_integration/common/suites/bulk_update.ts @@ -31,7 +31,7 @@ const DOES_NOT_EXIST = Object.freeze({ type: 'dashboard', id: 'does-not-exist' } export const TEST_CASES = Object.freeze({ ...CASES, DOES_NOT_EXIST }); export function bulkUpdateTestSuiteFactory(esArchiver: any, supertest: SuperTest) { - const expectForbidden = expectResponses.forbidden('bulk_update'); + const expectForbidden = expectResponses.forbiddenTypes('bulk_update'); const expectResponseBody = ( testCases: BulkUpdateTestCase | BulkUpdateTestCase[], statusCode: 200 | 403 diff --git a/x-pack/test/saved_object_api_integration/common/suites/create.ts b/x-pack/test/saved_object_api_integration/common/suites/create.ts index 8a3e4250040cd..2a5ab696c4f53 100644 --- a/x-pack/test/saved_object_api_integration/common/suites/create.ts +++ b/x-pack/test/saved_object_api_integration/common/suites/create.ts @@ -41,7 +41,7 @@ export const TEST_CASES = Object.freeze({ }); export function createTestSuiteFactory(es: any, esArchiver: any, supertest: SuperTest) { - const expectForbidden = expectResponses.forbidden('create'); + const expectForbidden = expectResponses.forbiddenTypes('create'); const expectResponseBody = ( testCase: CreateTestCase, spaceId = SPACES.DEFAULT.spaceId diff --git a/x-pack/test/saved_object_api_integration/common/suites/delete.ts b/x-pack/test/saved_object_api_integration/common/suites/delete.ts index c02b6e9e5cc4b..3179b1b0c9ac5 100644 --- a/x-pack/test/saved_object_api_integration/common/suites/delete.ts +++ b/x-pack/test/saved_object_api_integration/common/suites/delete.ts @@ -28,7 +28,7 @@ const DOES_NOT_EXIST = Object.freeze({ type: 'dashboard', id: 'does-not-exist' } export const TEST_CASES = Object.freeze({ ...CASES, DOES_NOT_EXIST }); export function deleteTestSuiteFactory(esArchiver: any, supertest: SuperTest) { - const expectForbidden = expectResponses.forbidden('delete'); + const expectForbidden = expectResponses.forbiddenTypes('delete'); const expectResponseBody = (testCase: DeleteTestCase): ExpectResponseBody => async ( response: Record ) => { diff --git a/x-pack/test/saved_object_api_integration/common/suites/export.ts b/x-pack/test/saved_object_api_integration/common/suites/export.ts index 394693677699f..ff22cdaeafd06 100644 --- a/x-pack/test/saved_object_api_integration/common/suites/export.ts +++ b/x-pack/test/saved_object_api_integration/common/suites/export.ts @@ -93,8 +93,8 @@ const getTestTitle = ({ failure, title }: ExportTestCase) => { }; export function exportTestSuiteFactory(esArchiver: any, supertest: SuperTest) { - const expectForbiddenBulkGet = expectResponses.forbidden('bulk_get'); - const expectForbiddenFind = expectResponses.forbidden('find'); + const expectForbiddenBulkGet = expectResponses.forbiddenTypes('bulk_get'); + const expectForbiddenFind = expectResponses.forbiddenTypes('find'); const expectResponseBody = (testCase: ExportTestCase): ExpectResponseBody => async ( response: Record ) => { diff --git a/x-pack/test/saved_object_api_integration/common/suites/find.ts b/x-pack/test/saved_object_api_integration/common/suites/find.ts index 13f411fc14fc8..882451c28bfe4 100644 --- a/x-pack/test/saved_object_api_integration/common/suites/find.ts +++ b/x-pack/test/saved_object_api_integration/common/suites/find.ts @@ -7,154 +7,260 @@ import expect from '@kbn/expect'; import { SuperTest } from 'supertest'; import querystring from 'querystring'; +import { Assign } from '@kbn/utility-types'; import { SAVED_OBJECT_TEST_CASES as CASES } from '../lib/saved_object_test_cases'; import { SPACES } from '../lib/spaces'; import { expectResponses, getUrlPrefix } from '../lib/saved_object_test_utils'; -import { ExpectResponseBody, TestCase, TestDefinition, TestSuite } from '../lib/types'; +import { ExpectResponseBody, TestCase, TestDefinition, TestSuite, TestUser } from '../lib/types'; const { DEFAULT: { spaceId: DEFAULT_SPACE_ID }, - SPACE_1: { spaceId: SPACE_1_ID }, - SPACE_2: { spaceId: SPACE_2_ID }, } = SPACES; export interface FindTestDefinition extends TestDefinition { request: { query: string }; } export type FindTestSuite = TestSuite; + +type FindSavedObjectCase = Assign; + export interface FindTestCase { title: string; query: string; successResult?: { - savedObjects?: TestCase | TestCase[]; + savedObjects?: FindSavedObjectCase | FindSavedObjectCase[]; page?: number; perPage?: number; total?: number; }; - failure?: 400 | 403; + failure?: { + statusCode: 400 | 403; + reason: + | 'forbidden_types' + | 'forbidden_namespaces' + | 'cross_namespace_not_permitted' + | 'bad_request'; + }; } -export const getTestCases = (spaceId?: string) => ({ - singleNamespaceType: { - title: 'find single-namespace type', - query: 'type=isolatedtype&fields=title', - successResult: { - savedObjects: - spaceId === SPACE_1_ID - ? CASES.SINGLE_NAMESPACE_SPACE_1 - : spaceId === SPACE_2_ID - ? CASES.SINGLE_NAMESPACE_SPACE_2 - : CASES.SINGLE_NAMESPACE_DEFAULT_SPACE, - }, - } as FindTestCase, - multiNamespaceType: { - title: 'find multi-namespace type', - query: 'type=sharedtype&fields=title', - successResult: { - savedObjects: - spaceId === SPACE_1_ID - ? [CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, CASES.MULTI_NAMESPACE_ONLY_SPACE_1] - : spaceId === SPACE_2_ID - ? CASES.MULTI_NAMESPACE_ONLY_SPACE_2 - : CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, - }, - } as FindTestCase, - namespaceAgnosticType: { - title: 'find namespace-agnostic type', - query: 'type=globaltype&fields=title', - successResult: { savedObjects: CASES.NAMESPACE_AGNOSTIC }, - } as FindTestCase, - hiddenType: { title: 'find hidden type', query: 'type=hiddentype&fields=name' } as FindTestCase, - unknownType: { title: 'find unknown type', query: 'type=wigwags' } as FindTestCase, - pageBeyondTotal: { - title: 'find page beyond total', - query: 'type=isolatedtype&page=100&per_page=100', - successResult: { page: 100, perPage: 100, total: 1, savedObjects: [] }, - } as FindTestCase, - unknownSearchField: { - title: 'find unknown search field', - query: 'type=url&search_fields=a', - } as FindTestCase, - filterWithNamespaceAgnosticType: { - title: 'filter with namespace-agnostic type', - query: 'type=globaltype&filter=globaltype.attributes.title:*global*', - successResult: { savedObjects: CASES.NAMESPACE_AGNOSTIC }, - } as FindTestCase, - filterWithHiddenType: { - title: 'filter with hidden type', - query: `type=hiddentype&fields=name&filter=hiddentype.attributes.title:'hello'`, - } as FindTestCase, - filterWithUnknownType: { - title: 'filter with unknown type', - query: `type=wigwags&filter=wigwags.attributes.title:'unknown'`, - } as FindTestCase, - filterWithDisallowedType: { - title: 'filter with disallowed type', - query: `type=globaltype&filter=dashboard.title:'Requests'`, - failure: 400, - } as FindTestCase, -}); +const TEST_CASES = [ + { ...CASES.SINGLE_NAMESPACE_DEFAULT_SPACE, namespaces: ['default'] }, + { ...CASES.SINGLE_NAMESPACE_SPACE_1, namespaces: ['space_1'] }, + { ...CASES.SINGLE_NAMESPACE_SPACE_2, namespaces: ['space_2'] }, + { ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, namespaces: ['default', 'space_1'] }, + { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, namespaces: ['space_1'] }, + { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, namespaces: ['space_2'] }, + { ...CASES.NAMESPACE_AGNOSTIC, namespaces: undefined }, + { ...CASES.HIDDEN, namespaces: undefined }, +]; + +expect(TEST_CASES.length).to.eql( + Object.values(CASES).length, + 'Unhandled test cases in `find` suite' +); + +export const getTestCases = ( + { currentSpace, crossSpaceSearch }: { currentSpace?: string; crossSpaceSearch?: string[] } = { + currentSpace: undefined, + crossSpaceSearch: undefined, + } +) => { + const crossSpaceIds = crossSpaceSearch?.filter((s) => s !== (currentSpace ?? 'default')) ?? []; + const isCrossSpaceSearch = crossSpaceIds.length > 0; + const isWildcardSearch = crossSpaceIds.includes('*'); + + const namespacesQueryParam = isCrossSpaceSearch + ? `&namespaces=${crossSpaceIds.join('&namespaces=')}` + : ''; + + const buildTitle = (title: string) => + crossSpaceSearch ? `${title} (cross-space ${isWildcardSearch ? 'with wildcard' : ''})` : title; + + type CasePredicate = (testCase: TestCase) => boolean; + const getExpectedSavedObjects = (predicate: CasePredicate) => { + if (isCrossSpaceSearch) { + // all other cross-space tests are written to test that we exclude the current space. + // the wildcard scenario verifies current space functionality + if (isWildcardSearch) { + return TEST_CASES.filter(predicate); + } + + return TEST_CASES.filter((t) => { + const hasOtherNamespaces = + Array.isArray(t.namespaces) && + t.namespaces!.some((ns) => ns !== (currentSpace ?? 'default')); + return hasOtherNamespaces && predicate(t); + }); + } + return TEST_CASES.filter( + (t) => (!t.namespaces || t.namespaces.includes(currentSpace ?? 'default')) && predicate(t) + ); + }; + + return { + singleNamespaceType: { + title: buildTitle('find single-namespace type'), + query: `type=isolatedtype&fields=title${namespacesQueryParam}`, + successResult: { + savedObjects: getExpectedSavedObjects((t) => t.type === 'isolatedtype'), + }, + } as FindTestCase, + multiNamespaceType: { + title: buildTitle('find multi-namespace type'), + query: `type=sharedtype&fields=title${namespacesQueryParam}`, + successResult: { + // expected depends on which spaces the user is authorized against... + savedObjects: getExpectedSavedObjects((t) => t.type === 'sharedtype'), + }, + } as FindTestCase, + namespaceAgnosticType: { + title: buildTitle('find namespace-agnostic type'), + query: `type=globaltype&fields=title${namespacesQueryParam}`, + successResult: { savedObjects: CASES.NAMESPACE_AGNOSTIC }, + } as FindTestCase, + hiddenType: { + title: buildTitle('find hidden type'), + query: `type=hiddentype&fields=name${namespacesQueryParam}`, + } as FindTestCase, + unknownType: { + title: buildTitle('find unknown type'), + query: `type=wigwags${namespacesQueryParam}`, + } as FindTestCase, + pageBeyondTotal: { + title: buildTitle('find page beyond total'), + query: `type=isolatedtype&page=100&per_page=100${namespacesQueryParam}`, + successResult: { + page: 100, + perPage: 100, + total: -1, + savedObjects: [], + }, + } as FindTestCase, + unknownSearchField: { + title: buildTitle('find unknown search field'), + query: `type=url&search_fields=a${namespacesQueryParam}`, + } as FindTestCase, + filterWithNamespaceAgnosticType: { + title: buildTitle('filter with namespace-agnostic type'), + query: `type=globaltype&filter=globaltype.attributes.title:*global*${namespacesQueryParam}`, + successResult: { savedObjects: CASES.NAMESPACE_AGNOSTIC }, + } as FindTestCase, + filterWithHiddenType: { + title: buildTitle('filter with hidden type'), + query: `type=hiddentype&fields=name&filter=hiddentype.attributes.title:'hello'${namespacesQueryParam}`, + } as FindTestCase, + filterWithUnknownType: { + title: buildTitle('filter with unknown type'), + query: `type=wigwags&filter=wigwags.attributes.title:'unknown'${namespacesQueryParam}`, + } as FindTestCase, + filterWithDisallowedType: { + title: buildTitle('filter with disallowed type'), + query: `type=globaltype&filter=dashboard.title:'Requests'${namespacesQueryParam}`, + failure: { + statusCode: 400, + reason: 'bad_request', + }, + } as FindTestCase, + }; +}; + export const createRequest = ({ query }: FindTestCase) => ({ query }); const getTestTitle = ({ failure, title }: FindTestCase) => { let description = 'success'; - if (failure === 400) { + if (failure?.statusCode === 400) { description = 'bad request'; - } else if (failure === 403) { + } else if (failure?.statusCode === 403) { description = 'forbidden'; } return `${description} ["${title}"]`; }; export function findTestSuiteFactory(esArchiver: any, supertest: SuperTest) { - const expectForbidden = expectResponses.forbidden('find'); - const expectResponseBody = (testCase: FindTestCase): ExpectResponseBody => async ( - response: Record - ) => { + const expectForbiddenTypes = expectResponses.forbiddenTypes('find'); + const expectForbiddeNamespaces = expectResponses.forbiddenSpaces; + const expectResponseBody = ( + testCase: FindTestCase, + user?: TestUser + ): ExpectResponseBody => async (response: Record) => { const { failure, successResult = {}, query } = testCase; const parsedQuery = querystring.parse(query); - if (failure === 403) { - const type = parsedQuery.type; - await expectForbidden(type)(response); - } else if (failure === 400) { - const type = (parsedQuery.filter as string).split('.')[0]; - expect(response.body.error).to.eql('Bad Request'); - expect(response.body.statusCode).to.eql(failure); - expect(response.body.message).to.eql(`This type ${type} is not allowed: Bad Request`); + if (failure?.statusCode === 403) { + if (failure?.reason === 'forbidden_types') { + const type = parsedQuery.type; + await expectForbiddenTypes(type)(response); + } else if (failure?.reason === 'forbidden_namespaces') { + await expectForbiddeNamespaces(response); + } else { + throw new Error(`Unexpected failure reason: ${failure?.reason}`); + } + } else if (failure?.statusCode === 400) { + if (failure?.reason === 'bad_request') { + const type = (parsedQuery.filter as string).split('.')[0]; + expect(response.body.error).to.eql('Bad Request'); + expect(response.body.statusCode).to.eql(failure?.statusCode); + expect(response.body.message).to.eql(`This type ${type} is not allowed: Bad Request`); + } else if (failure?.reason === 'cross_namespace_not_permitted') { + expect(response.body.error).to.eql('Bad Request'); + expect(response.body.statusCode).to.eql(failure?.statusCode); + expect(response.body.message).to.eql( + `_find across namespaces is not permitted when the Spaces plugin is disabled.: Bad Request` + ); + } else { + throw new Error(`Unexpected failure reason: ${failure?.reason}`); + } } else { // 2xx expect(response.body).not.to.have.property('error'); const { page = 1, perPage = 20, total, savedObjects = [] } = successResult; const savedObjectsArray = Array.isArray(savedObjects) ? savedObjects : [savedObjects]; + const authorizedSavedObjects = savedObjectsArray.filter( + (so) => + !user || + !so.namespaces || + so.namespaces.some( + (ns) => user.authorizedAtSpaces.includes(ns) || user.authorizedAtSpaces.includes('*') + ) + ); expect(response.body.page).to.eql(page); expect(response.body.per_page).to.eql(perPage); - expect(response.body.total).to.eql(total || savedObjectsArray.length); - for (let i = 0; i < savedObjectsArray.length; i++) { + + // Negative totals are skipped for test simplifications + if (!total || total >= 0) { + expect(response.body.total).to.eql(total || authorizedSavedObjects.length); + } + + authorizedSavedObjects.sort((s1, s2) => (s1.id < s2.id ? -1 : 1)); + response.body.saved_objects.sort((s1: any, s2: any) => (s1.id < s2.id ? -1 : 1)); + + for (let i = 0; i < authorizedSavedObjects.length; i++) { const object = response.body.saved_objects[i]; - const { type: expectedType, id: expectedId } = savedObjectsArray[i]; + const { type: expectedType, id: expectedId } = authorizedSavedObjects[i]; expect(object.type).to.eql(expectedType); expect(object.id).to.eql(expectedId); expect(object.updated_at).to.match(/^[\d-]{10}T[\d:\.]{12}Z$/); + expect(object.namespaces).to.eql(object.namespaces); // don't test attributes, version, or references } } }; const createTestDefinitions = ( testCases: FindTestCase | FindTestCase[], - forbidden: boolean, + failure: FindTestCase['failure'] | false, options?: { + user?: TestUser; responseBodyOverride?: ExpectResponseBody; } ): FindTestDefinition[] => { let cases = Array.isArray(testCases) ? testCases : [testCases]; - if (forbidden) { + if (failure) { // override the expected result in each test case - cases = cases.map((x) => ({ ...x, failure: 403 })); + cases = cases.map((x) => ({ ...x, failure })); } return cases.map((x) => ({ title: getTestTitle(x), - responseStatusCode: x.failure ?? 200, + responseStatusCode: x.failure?.statusCode ?? 200, request: createRequest(x), - responseBody: options?.responseBodyOverride || expectResponseBody(x), + responseBody: options?.responseBodyOverride || expectResponseBody(x, options?.user), })); }; @@ -171,6 +277,7 @@ export function findTestSuiteFactory(esArchiver: any, supertest: SuperTest) for (const test of tests) { it(`should return ${test.responseStatusCode} ${test.title}`, async () => { const query = test.request.query ? `?${test.request.query}` : ''; + await supertest .get(`${getUrlPrefix(spaceId)}/api/saved_objects/_find${query}`) .auth(user?.username, user?.password) diff --git a/x-pack/test/saved_object_api_integration/common/suites/get.ts b/x-pack/test/saved_object_api_integration/common/suites/get.ts index cb29c1fb1ff37..fb03cd548d41a 100644 --- a/x-pack/test/saved_object_api_integration/common/suites/get.ts +++ b/x-pack/test/saved_object_api_integration/common/suites/get.ts @@ -24,7 +24,7 @@ const DOES_NOT_EXIST = Object.freeze({ type: 'dashboard', id: 'does-not-exist' } export const TEST_CASES = Object.freeze({ ...CASES, DOES_NOT_EXIST }); export function getTestSuiteFactory(esArchiver: any, supertest: SuperTest) { - const expectForbidden = expectResponses.forbidden('get'); + const expectForbidden = expectResponses.forbiddenTypes('get'); const expectResponseBody = (testCase: GetTestCase): ExpectResponseBody => async ( response: Record ) => { diff --git a/x-pack/test/saved_object_api_integration/common/suites/import.ts b/x-pack/test/saved_object_api_integration/common/suites/import.ts index a5d2ca238d34e..ed57c6eb16b9a 100644 --- a/x-pack/test/saved_object_api_integration/common/suites/import.ts +++ b/x-pack/test/saved_object_api_integration/common/suites/import.ts @@ -38,7 +38,7 @@ export const TEST_CASES = Object.freeze({ }); export function importTestSuiteFactory(es: any, esArchiver: any, supertest: SuperTest) { - const expectForbidden = expectResponses.forbidden('bulk_create'); + const expectForbidden = expectResponses.forbiddenTypes('bulk_create'); const expectResponseBody = ( testCases: ImportTestCase | ImportTestCase[], statusCode: 200 | 403, diff --git a/x-pack/test/saved_object_api_integration/common/suites/resolve_import_errors.ts b/x-pack/test/saved_object_api_integration/common/suites/resolve_import_errors.ts index cb48f26ed645c..822214cd6dc6a 100644 --- a/x-pack/test/saved_object_api_integration/common/suites/resolve_import_errors.ts +++ b/x-pack/test/saved_object_api_integration/common/suites/resolve_import_errors.ts @@ -43,7 +43,7 @@ export function resolveImportErrorsTestSuiteFactory( esArchiver: any, supertest: SuperTest ) { - const expectForbidden = expectResponses.forbidden('bulk_create'); + const expectForbidden = expectResponses.forbiddenTypes('bulk_create'); const expectResponseBody = ( testCases: ResolveImportErrorsTestCase | ResolveImportErrorsTestCase[], statusCode: 200 | 403, diff --git a/x-pack/test/saved_object_api_integration/common/suites/update.ts b/x-pack/test/saved_object_api_integration/common/suites/update.ts index e480dab151ba9..82f4699babf46 100644 --- a/x-pack/test/saved_object_api_integration/common/suites/update.ts +++ b/x-pack/test/saved_object_api_integration/common/suites/update.ts @@ -31,7 +31,7 @@ const DOES_NOT_EXIST = Object.freeze({ type: 'dashboard', id: 'does-not-exist' } export const TEST_CASES = Object.freeze({ ...CASES, DOES_NOT_EXIST }); export function updateTestSuiteFactory(esArchiver: any, supertest: SuperTest) { - const expectForbidden = expectResponses.forbidden('update'); + const expectForbidden = expectResponses.forbiddenTypes('update'); const expectResponseBody = (testCase: UpdateTestCase): ExpectResponseBody => async ( response: Record ) => { diff --git a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/find.ts b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/find.ts index ada997020ca78..6ac77507df473 100644 --- a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/find.ts +++ b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/find.ts @@ -7,10 +7,11 @@ import { getTestScenarios } from '../../common/lib/saved_object_test_utils'; import { TestUser } from '../../common/lib/types'; import { FtrProviderContext } from '../../common/ftr_provider_context'; -import { findTestSuiteFactory, getTestCases, FindTestDefinition } from '../../common/suites/find'; +import { findTestSuiteFactory, getTestCases } from '../../common/suites/find'; + +const createTestCases = (currentSpace: string, crossSpaceSearch: string[]) => { + const cases = getTestCases({ currentSpace, crossSpaceSearch }); -const createTestCases = (spaceId: string) => { - const cases = getTestCases(spaceId); const normalTypes = [ cases.singleNamespaceType, cases.multiNamespaceType, @@ -35,40 +36,107 @@ export default function ({ getService }: FtrProviderContext) { const esArchiver = getService('esArchiver'); const { addTests, createTestDefinitions } = findTestSuiteFactory(esArchiver, supertest); - const createTests = (spaceId: string) => { - const { normalTypes, hiddenAndUnknownTypes, allTypes } = createTestCases(spaceId); + const createTests = (spaceId: string, user: TestUser) => { + const currentSpaceCases = createTestCases(spaceId, []); + + const explicitCrossSpace = createTestCases(spaceId, ['default', 'space_1', 'space_2']); + const wildcardCrossSpace = createTestCases(spaceId, ['*']); + + if (user.username === 'elastic') { + return { + currentSpace: createTestDefinitions(currentSpaceCases.allTypes, false, { user }), + crossSpace: createTestDefinitions(explicitCrossSpace.allTypes, false, { user }), + }; + } + + const authorizedAtCurrentSpace = + user.authorizedAtSpaces.includes(spaceId) || user.authorizedAtSpaces.includes('*'); + + const authorizedExplicitCrossSpaces = ['default', 'space_1', 'space_2'].filter( + (s) => + user.authorizedAtSpaces.includes('*') || + (s !== spaceId && user.authorizedAtSpaces.includes(s)) + ); + + const authorizedWildcardCrossSpaces = ['default', 'space_1', 'space_2'].filter( + (s) => user.authorizedAtSpaces.includes('*') || user.authorizedAtSpaces.includes(s) + ); + + const explicitCrossSpaceDefinitions = + authorizedExplicitCrossSpaces.length > 0 + ? [ + createTestDefinitions(explicitCrossSpace.normalTypes, false, { user }), + createTestDefinitions( + explicitCrossSpace.hiddenAndUnknownTypes, + { + statusCode: 403, + reason: 'forbidden_types', + }, + { user } + ), + ].flat() + : createTestDefinitions( + explicitCrossSpace.allTypes, + { + statusCode: 403, + reason: 'forbidden_namespaces', + }, + { user } + ); + + const wildcardCrossSpaceDefinitions = + authorizedWildcardCrossSpaces.length > 0 + ? [ + createTestDefinitions(wildcardCrossSpace.normalTypes, false, { user }), + createTestDefinitions( + wildcardCrossSpace.hiddenAndUnknownTypes, + { + statusCode: 403, + reason: 'forbidden_types', + }, + { user } + ), + ].flat() + : createTestDefinitions( + wildcardCrossSpace.allTypes, + { + statusCode: 403, + reason: 'forbidden_namespaces', + }, + { user } + ); + return { - unauthorized: createTestDefinitions(allTypes, true), - authorized: [ - createTestDefinitions(normalTypes, false), - createTestDefinitions(hiddenAndUnknownTypes, true), - ].flat(), - superuser: createTestDefinitions(allTypes, false), + currentSpace: authorizedAtCurrentSpace + ? [ + createTestDefinitions(currentSpaceCases.normalTypes, false, { + user, + }), + createTestDefinitions(currentSpaceCases.hiddenAndUnknownTypes, { + statusCode: 403, + reason: 'forbidden_types', + }), + ].flat() + : createTestDefinitions(currentSpaceCases.allTypes, { + statusCode: 403, + reason: 'forbidden_types', + }), + crossSpace: [...explicitCrossSpaceDefinitions, ...wildcardCrossSpaceDefinitions], }; }; describe('_find', () => { getTestScenarios().securityAndSpaces.forEach(({ spaceId, users }) => { const suffix = ` within the ${spaceId} space`; - const { unauthorized, authorized, superuser } = createTests(spaceId); - const _addTests = (user: TestUser, tests: FindTestDefinition[]) => { - addTests(`${user.description}${suffix}`, { user, spaceId, tests }); - }; - [users.noAccess, users.legacyAll, users.allAtOtherSpace].forEach((user) => { - _addTests(user, unauthorized); - }); - [ - users.dualAll, - users.dualRead, - users.allGlobally, - users.readGlobally, - users.allAtSpace, - users.readAtSpace, - ].forEach((user) => { - _addTests(user, authorized); + Object.values(users).forEach((user) => { + const { currentSpace, crossSpace } = createTests(spaceId, user); + addTests(`${user.description}${suffix}`, { + user, + spaceId, + tests: [...currentSpace, ...crossSpace], + }); }); - _addTests(users.superuser, superuser); }); }); } diff --git a/x-pack/test/saved_object_api_integration/security_only/apis/find.ts b/x-pack/test/saved_object_api_integration/security_only/apis/find.ts index 4ffdb4d477b8b..3a435119436ca 100644 --- a/x-pack/test/saved_object_api_integration/security_only/apis/find.ts +++ b/x-pack/test/saved_object_api_integration/security_only/apis/find.ts @@ -7,10 +7,11 @@ import { getTestScenarios } from '../../common/lib/saved_object_test_utils'; import { TestUser } from '../../common/lib/types'; import { FtrProviderContext } from '../../common/ftr_provider_context'; -import { findTestSuiteFactory, getTestCases, FindTestDefinition } from '../../common/suites/find'; +import { findTestSuiteFactory, getTestCases } from '../../common/suites/find'; + +const createTestCases = (crossSpaceSearch: string[]) => { + const cases = getTestCases({ crossSpaceSearch }); -const createTestCases = () => { - const cases = getTestCases(); const normalTypes = [ cases.singleNamespaceType, cases.multiNamespaceType, @@ -35,39 +36,58 @@ export default function ({ getService }: FtrProviderContext) { const esArchiver = getService('esArchiver'); const { addTests, createTestDefinitions } = findTestSuiteFactory(esArchiver, supertest); - const createTests = () => { - const { normalTypes, hiddenAndUnknownTypes, allTypes } = createTestCases(); + const createTests = (user: TestUser) => { + const defaultCases = createTestCases([]); + const crossSpaceCases = createTestCases(['default', 'space_1', 'space_2']); + + if (user.username === 'elastic') { + return { + defaultCases: createTestDefinitions(defaultCases.allTypes, false, { user }), + crossSpace: createTestDefinitions( + crossSpaceCases.allTypes, + { + statusCode: 400, + reason: 'cross_namespace_not_permitted', + }, + { user } + ), + }; + } + + const authorizedGlobally = user.authorizedAtSpaces.includes('*'); + return { - unauthorized: createTestDefinitions(allTypes, true), - authorized: [ - createTestDefinitions(normalTypes, false), - createTestDefinitions(hiddenAndUnknownTypes, true), - ].flat(), - superuser: createTestDefinitions(allTypes, false), + defaultCases: authorizedGlobally + ? [ + createTestDefinitions(defaultCases.normalTypes, false, { + user, + }), + createTestDefinitions(defaultCases.hiddenAndUnknownTypes, { + statusCode: 403, + reason: 'forbidden_types', + }), + ].flat() + : createTestDefinitions(defaultCases.allTypes, { + statusCode: 403, + reason: 'forbidden_types', + }), + crossSpace: createTestDefinitions( + crossSpaceCases.allTypes, + { + statusCode: 400, + reason: 'cross_namespace_not_permitted', + }, + { user } + ), }; }; describe('_find', () => { getTestScenarios().security.forEach(({ users }) => { - const { unauthorized, authorized, superuser } = createTests(); - const _addTests = (user: TestUser, tests: FindTestDefinition[]) => { - addTests(user.description, { user, tests }); - }; - - [ - users.noAccess, - users.legacyAll, - users.allAtDefaultSpace, - users.readAtDefaultSpace, - users.allAtSpace1, - users.readAtSpace1, - ].forEach((user) => { - _addTests(user, unauthorized); - }); - [users.dualAll, users.dualRead, users.allGlobally, users.readGlobally].forEach((user) => { - _addTests(user, authorized); + Object.values(users).forEach((user) => { + const { defaultCases, crossSpace } = createTests(user); + addTests(`${user.description}`, { user, tests: [...defaultCases, ...crossSpace] }); }); - _addTests(users.superuser, superuser); }); }); } diff --git a/x-pack/test/saved_object_api_integration/spaces_only/apis/find.ts b/x-pack/test/saved_object_api_integration/spaces_only/apis/find.ts index 2fe707df5ce88..1d46985916cd5 100644 --- a/x-pack/test/saved_object_api_integration/spaces_only/apis/find.ts +++ b/x-pack/test/saved_object_api_integration/spaces_only/apis/find.ts @@ -8,8 +8,8 @@ import { getTestScenarios } from '../../common/lib/saved_object_test_utils'; import { FtrProviderContext } from '../../common/ftr_provider_context'; import { findTestSuiteFactory, getTestCases } from '../../common/suites/find'; -const createTestCases = (spaceId: string) => { - const cases = getTestCases(spaceId); +const createTestCases = (spaceId: string, crossSpaceSearch: string[]) => { + const cases = getTestCases({ currentSpace: spaceId, crossSpaceSearch }); return Object.values(cases); }; @@ -18,15 +18,20 @@ export default function ({ getService }: FtrProviderContext) { const esArchiver = getService('esArchiver'); const { addTests, createTestDefinitions } = findTestSuiteFactory(esArchiver, supertest); - const createTests = (spaceId: string) => { - const testCases = createTestCases(spaceId); + const createTests = (spaceId: string, crossSpaceSearch: string[]) => { + const testCases = createTestCases(spaceId, crossSpaceSearch); return createTestDefinitions(testCases, false); }; describe('_find', () => { getTestScenarios().spaces.forEach(({ spaceId }) => { - const tests = createTests(spaceId); - addTests(`within the ${spaceId} space`, { spaceId, tests }); + const currentSpaceTests = createTests(spaceId, []); + const explicitCrossSpaceTests = createTests(spaceId, ['default', 'space_1', 'space_2']); + const wildcardCrossSpaceTests = createTests(spaceId, ['*']); + addTests(`within the ${spaceId} space`, { + spaceId, + tests: [...currentSpaceTests, ...explicitCrossSpaceTests, ...wildcardCrossSpaceTests], + }); }); }); } diff --git a/x-pack/test/spaces_api_integration/common/suites/share_add.ts b/x-pack/test/spaces_api_integration/common/suites/share_add.ts index 35ef8a81c6cfc..219190cb28002 100644 --- a/x-pack/test/spaces_api_integration/common/suites/share_add.ts +++ b/x-pack/test/spaces_api_integration/common/suites/share_add.ts @@ -45,7 +45,7 @@ export function shareAddTestSuiteFactory(esArchiver: any, supertest: SuperTest
({ }); export function shareRemoveTestSuiteFactory(esArchiver: any, supertest: SuperTest) { - const expectForbidden = expectResponses.forbidden('delete'); + const expectForbidden = expectResponses.forbiddenTypes('delete'); const expectResponseBody = (testCase: ShareRemoveTestCase): ExpectResponseBody => async ( response: Record ) => { From 2340f8a59bb7975a7338c40e9483ef4d8e623f75 Mon Sep 17 00:00:00 2001 From: Tim Sullivan Date: Mon, 13 Jul 2020 17:22:01 -0700 Subject: [PATCH 49/66] [Reporting] Formatting fixes for CSV export in Discover, CSV download from Dashboard panel (#67027) * [Reporting] Data formatting fixes for CSV export in Discover, CSV download from Dashboard panel commit e195964deaa3e7e8d94704d6514e01498c913a81 Author: Timothy Sullivan Date: Mon Jul 13 10:17:36 2020 -0700 Squashed commit of the following: commit 87c9c496a6cccaf7a60a44b496f7c0c0423cd2ea Merge: d531101ab3 ed749eb5ad Author: Timothy Sullivan Date: Mon Jul 13 10:17:02 2020 -0700 Merge branch 'data/allow-custom-formatting' into reporting/csv-date-format-consistency commit d531101ab3c2f12628287bd5ad4a02bbf8b5c990 Merge: 400e2ffba4 17dc0439e2 Author: Timothy Sullivan Date: Mon Jul 13 10:15:38 2020 -0700 Merge branch 'master' into reporting/csv-date-format-consistency commit ed749eb5ad92a34cadb619c160b642fc6aebcc64 Author: Timothy Sullivan Date: Mon Jul 13 10:12:28 2020 -0700 move shared code to common commit 4e5eebd93b71d267980dab5eb6b031693540f178 Author: Timothy Sullivan Date: Mon Jul 13 09:07:32 2020 -0700 3td time api doc chagens commit 34df3318bf0a9c509848665d80e50c74291acc48 Merge: 54fa2fe97f 17dc0439e2 Author: Timothy Sullivan Date: Mon Jul 13 08:50:21 2020 -0700 Merge branch 'master' into data/allow-custom-formatting commit 400e2ffba4546cf78c53ce96b45a59878f0df076 Author: Timothy Sullivan Date: Sun Jul 12 21:29:34 2020 -0700 [Reporting] Data formatting fixes for CSV export in Discover, CSV download from Dashboard panel commit 54fa2fe97f15f600b2264d08fe320e1f09d54a38 Merge: 1b6e9e8719 e1253ed047 Author: Elastic Machine Date: Sun Jul 12 22:18:38 2020 -0600 Merge branch 'master' into data/allow-custom-formatting commit 1b6e9e87192630e4ea20b882235af2d2f1852c31 Author: Timothy Sullivan Date: Fri Jul 10 15:03:08 2020 -0700 weird api change needed but no real diff commit fc9ff7be613c565c7dfb59010e5b058fb755c2d9 Merge: 736e9eecdd 66c531d903 Author: Timothy Sullivan Date: Fri Jul 10 14:51:51 2020 -0700 Merge branch 'master' into data/allow-custom-formatting commit 736e9eecddb8b5a037ed6726ef1518e05f056599 Author: Timothy Sullivan Date: Thu Jul 9 17:43:10 2020 -0700 fix path for tests commit 1bebcc83e687d707112d77d03865a28fc74481fe Author: Timothy Sullivan Date: Thu Jul 9 17:25:09 2020 -0700 re-use public code in server, add test commit 1e1d3c58ab766bd4ebce4795115107d7c07c2c8e Author: Timothy Sullivan Date: Thu Jul 9 16:35:30 2020 -0700 rerun api changes commit 231f7939436a06ec5a429d5b3bd5bf3d34577a9b Author: Timothy Sullivan Date: Thu Jul 9 16:31:55 2020 -0700 fix src/plugins/data/public/field_formats/constants.ts commit d42275cfeb5b87b51a8c674c055ce376c3ac1b48 Merge: 206aed6210 8e2277a667 Author: Timothy Sullivan Date: Thu Jul 9 16:01:40 2020 -0700 Merge branch 'master' into data/allow-custom-formatting commit 206aed62102e26ae5db64444b1589b354d3a066a Merge: 5aa2d802ec 09da11047d Author: Timothy Sullivan Date: Thu Jul 9 15:03:12 2020 -0700 Merge branch 'master' into data/allow-custom-formatting commit 5aa2d802ec6539e6428025c3a662e92943195976 Author: Timothy Sullivan Date: Wed Jul 8 12:12:31 2020 -0700 api doc changes commit 76e2c307e73c9c900f41541a15a501af10c8d408 Merge: 1789afcdc9 595e9c2d8d Author: Timothy Sullivan Date: Wed Jul 8 12:04:12 2020 -0700 Merge branch 'master' into data/allow-custom-formatting commit 1789afcdc9d8cace21bed34049d5244e62a8df85 Author: Timothy Sullivan Date: Fri Jul 3 11:23:03 2020 -0700 simplify changes commit 642845587386af39d367eb687acd3f7162202e17 Author: Timothy Sullivan Date: Thu Jul 2 16:05:57 2020 -0700 add more to tests - need help though commit 6aacfbd25dc38ef4717745203b9048168ca68ea3 Author: Timothy Sullivan Date: Thu Jul 2 12:04:28 2020 -0700 [Data Plugin] Allow server-side date formatters to accept custom timezone When Advanced Settings shows the date format timezone to be "Browser," this means nothing to field formatters in the server-side context. The field formatters need a way to accept custom format parameters. This allows a server-side module that creates a FieldFormatMap to set a timezone as a custom parameter. When custom formatting parameters exist, they get combined with the defaults. * comments --- x-pack/plugins/reporting/common/types.ts | 2 + x-pack/plugins/reporting/public/plugin.tsx | 4 +- .../register_csv_reporting.tsx | 43 +- .../register_pdf_png_reporting.tsx | 47 +- .../export_types/csv/server/create_job.ts | 4 +- .../export_types/csv/server/execute_job.ts | 167 +- .../{lib => generate_csv}/cell_has_formula.ts | 0 .../check_cells_for_formulas.test.ts | 0 .../check_cells_for_formulas.ts | 0 .../escape_value.test.ts | 0 .../{lib => generate_csv}/escape_value.ts | 0 .../field_format_map.test.ts | 29 +- .../{lib => generate_csv}/field_format_map.ts | 41 +- .../{lib => generate_csv}/flatten_hit.test.ts | 0 .../{lib => generate_csv}/flatten_hit.ts | 0 .../format_csv_values.test.ts | 0 .../format_csv_values.ts | 7 +- .../server/generate_csv/get_ui_settings.ts | 54 + .../hit_iterator.test.ts | 0 .../{lib => generate_csv}/hit_iterator.ts | 17 +- .../generate_csv.ts => generate_csv/index.ts} | 85 +- .../max_size_string_builder.test.ts | 8 + .../max_size_string_builder.ts | 6 +- .../csv/server/lib/get_request.ts | 55 + .../server/export_types/csv/types.d.ts | 46 +- .../{create_job/index.ts => create_job.ts} | 37 +- .../server/create_job/create_job_search.ts | 49 - .../server/execute_job.ts | 65 +- .../server/lib/generate_csv.ts | 41 - .../server/lib/generate_csv_search.ts | 187 -- .../server/lib/get_csv_job.test.ts | 341 +++ .../server/lib/get_csv_job.ts | 146 ++ .../server/lib/get_data_source.ts | 8 +- .../server/lib/get_fake_request.ts | 51 + .../server/lib/get_filters.ts | 2 +- .../csv_from_savedobject/server/lib/index.ts | 7 - .../csv_from_savedobject/types.d.ts | 19 +- .../generate_from_savedobject_immediate.ts | 2 +- .../lib/get_job_params_from_request.ts | 5 +- x-pack/plugins/reporting/server/types.ts | 9 +- .../translations/translations/ja-JP.json | 3 +- .../translations/translations/zh-CN.json | 3 +- .../reporting/multi_index/data.json.gz | Bin 0 -> 619 bytes .../reporting/multi_index/mappings.json | 92 + .../reporting/multi_index_kibana/data.json.gz | Bin 0 -> 455 bytes .../multi_index_kibana/mappings.json | 2073 +++++++++++++++ .../reporting/scripted_small/data.json.gz | Bin 4038 -> 0 bytes .../reporting/scripted_small/mappings.json | 739 ------ .../reporting/scripted_small2/data.json.gz | Bin 0 -> 4248 bytes .../reporting/scripted_small2/mappings.json | 2217 +++++++++++++++++ .../reporting_api_integration/fixtures.ts | 370 +-- .../reporting/csv_saved_search.ts | 126 +- 52 files changed, 5700 insertions(+), 1507 deletions(-) rename x-pack/plugins/reporting/server/export_types/csv/server/{lib => generate_csv}/cell_has_formula.ts (100%) rename x-pack/plugins/reporting/server/export_types/csv/server/{lib => generate_csv}/check_cells_for_formulas.test.ts (100%) rename x-pack/plugins/reporting/server/export_types/csv/server/{lib => generate_csv}/check_cells_for_formulas.ts (100%) rename x-pack/plugins/reporting/server/export_types/csv/server/{lib => generate_csv}/escape_value.test.ts (100%) rename x-pack/plugins/reporting/server/export_types/csv/server/{lib => generate_csv}/escape_value.ts (100%) rename x-pack/plugins/reporting/server/export_types/csv/server/{lib => generate_csv}/field_format_map.test.ts (74%) rename x-pack/plugins/reporting/server/export_types/csv/server/{lib => generate_csv}/field_format_map.ts (56%) rename x-pack/plugins/reporting/server/export_types/csv/server/{lib => generate_csv}/flatten_hit.test.ts (100%) rename x-pack/plugins/reporting/server/export_types/csv/server/{lib => generate_csv}/flatten_hit.ts (100%) rename x-pack/plugins/reporting/server/export_types/csv/server/{lib => generate_csv}/format_csv_values.test.ts (100%) rename x-pack/plugins/reporting/server/export_types/csv/server/{lib => generate_csv}/format_csv_values.ts (86%) create mode 100644 x-pack/plugins/reporting/server/export_types/csv/server/generate_csv/get_ui_settings.ts rename x-pack/plugins/reporting/server/export_types/csv/server/{lib => generate_csv}/hit_iterator.test.ts (100%) rename x-pack/plugins/reporting/server/export_types/csv/server/{lib => generate_csv}/hit_iterator.ts (82%) rename x-pack/plugins/reporting/server/export_types/csv/server/{lib/generate_csv.ts => generate_csv/index.ts} (55%) rename x-pack/plugins/reporting/server/export_types/csv/server/{lib => generate_csv}/max_size_string_builder.test.ts (91%) rename x-pack/plugins/reporting/server/export_types/csv/server/{lib => generate_csv}/max_size_string_builder.ts (82%) create mode 100644 x-pack/plugins/reporting/server/export_types/csv/server/lib/get_request.ts rename x-pack/plugins/reporting/server/export_types/csv_from_savedobject/server/{create_job/index.ts => create_job.ts} (76%) delete mode 100644 x-pack/plugins/reporting/server/export_types/csv_from_savedobject/server/create_job/create_job_search.ts delete mode 100644 x-pack/plugins/reporting/server/export_types/csv_from_savedobject/server/lib/generate_csv.ts delete mode 100644 x-pack/plugins/reporting/server/export_types/csv_from_savedobject/server/lib/generate_csv_search.ts create mode 100644 x-pack/plugins/reporting/server/export_types/csv_from_savedobject/server/lib/get_csv_job.test.ts create mode 100644 x-pack/plugins/reporting/server/export_types/csv_from_savedobject/server/lib/get_csv_job.ts create mode 100644 x-pack/plugins/reporting/server/export_types/csv_from_savedobject/server/lib/get_fake_request.ts delete mode 100644 x-pack/plugins/reporting/server/export_types/csv_from_savedobject/server/lib/index.ts rename x-pack/plugins/reporting/server/{export_types/csv_from_savedobject/server => routes}/lib/get_job_params_from_request.ts (87%) create mode 100644 x-pack/test/functional/es_archives/reporting/multi_index/data.json.gz create mode 100644 x-pack/test/functional/es_archives/reporting/multi_index/mappings.json create mode 100644 x-pack/test/functional/es_archives/reporting/multi_index_kibana/data.json.gz create mode 100644 x-pack/test/functional/es_archives/reporting/multi_index_kibana/mappings.json delete mode 100644 x-pack/test/functional/es_archives/reporting/scripted_small/data.json.gz delete mode 100644 x-pack/test/functional/es_archives/reporting/scripted_small/mappings.json create mode 100644 x-pack/test/functional/es_archives/reporting/scripted_small2/data.json.gz create mode 100644 x-pack/test/functional/es_archives/reporting/scripted_small2/mappings.json diff --git a/x-pack/plugins/reporting/common/types.ts b/x-pack/plugins/reporting/common/types.ts index 2b9e9299852f5..2819c28cfb54f 100644 --- a/x-pack/plugins/reporting/common/types.ts +++ b/x-pack/plugins/reporting/common/types.ts @@ -6,6 +6,8 @@ // eslint-disable-next-line @kbn/eslint/no-restricted-paths export { ReportingConfigType } from '../server/config'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +export { LayoutInstance } from '../server/export_types/common/layouts'; export type JobId = string; export type JobStatus = diff --git a/x-pack/plugins/reporting/public/plugin.tsx b/x-pack/plugins/reporting/public/plugin.tsx index aad3d9b026c6e..8a25df0a74bbf 100644 --- a/x-pack/plugins/reporting/public/plugin.tsx +++ b/x-pack/plugins/reporting/public/plugin.tsx @@ -26,7 +26,7 @@ import { import { ManagementSectionId, ManagementSetup } from '../../../../src/plugins/management/public'; import { SharePluginSetup } from '../../../../src/plugins/share/public'; import { LicensingPluginSetup } from '../../licensing/public'; -import { ReportingConfigType, JobId, JobStatusBuckets } from '../common/types'; +import { JobId, JobStatusBuckets, ReportingConfigType } from '../common/types'; import { JOB_COMPLETION_NOTIFICATIONS_SESSION_KEY } from '../constants'; import { getGeneralErrorToast } from './components'; import { ReportListing } from './components/report_listing'; @@ -144,7 +144,7 @@ export class ReportingPublicPlugin implements Plugin { uiActions.addTriggerAction(CONTEXT_MENU_TRIGGER, action); - share.register(csvReportingProvider({ apiClient, toasts, license$ })); + share.register(csvReportingProvider({ apiClient, toasts, license$, uiSettings })); share.register( reportingPDFPNGProvider({ apiClient, diff --git a/x-pack/plugins/reporting/public/share_context_menu/register_csv_reporting.tsx b/x-pack/plugins/reporting/public/share_context_menu/register_csv_reporting.tsx index ea4ecaa60ab2c..4ad35fd768825 100644 --- a/x-pack/plugins/reporting/public/share_context_menu/register_csv_reporting.tsx +++ b/x-pack/plugins/reporting/public/share_context_menu/register_csv_reporting.tsx @@ -5,22 +5,29 @@ */ import { i18n } from '@kbn/i18n'; +import moment from 'moment-timezone'; import React from 'react'; - -import { ToastsSetup } from 'src/core/public'; +import { IUiSettingsClient, ToastsSetup } from 'src/core/public'; +import { ShareContext } from '../../../../../src/plugins/share/public'; +import { LicensingPluginSetup } from '../../../licensing/public'; +import { JobParamsDiscoverCsv, SearchRequest } from '../../server/export_types/csv/types'; import { ReportingPanelContent } from '../components/reporting_panel_content'; -import { ReportingAPIClient } from '../lib/reporting_api_client'; import { checkLicense } from '../lib/license_check'; -import { LicensingPluginSetup } from '../../../licensing/public'; -import { ShareContext } from '../../../../../src/plugins/share/public'; +import { ReportingAPIClient } from '../lib/reporting_api_client'; interface ReportingProvider { apiClient: ReportingAPIClient; toasts: ToastsSetup; license$: LicensingPluginSetup['license$']; + uiSettings: IUiSettingsClient; } -export const csvReportingProvider = ({ apiClient, toasts, license$ }: ReportingProvider) => { +export const csvReportingProvider = ({ + apiClient, + toasts, + license$, + uiSettings, +}: ReportingProvider) => { let toolTipContent = ''; let disabled = true; let hasCSVReporting = false; @@ -33,6 +40,14 @@ export const csvReportingProvider = ({ apiClient, toasts, license$ }: ReportingP disabled = !enableLinks; }); + // If the TZ is set to the default "Browser", it will not be useful for + // server-side export. We need to derive the timezone and pass it as a param + // to the export API. + const browserTimezone = + uiSettings.get('dateFormat:tz') === 'Browser' + ? moment.tz.guess() + : uiSettings.get('dateFormat:tz'); + const getShareMenuItems = ({ objectType, objectId, @@ -44,13 +59,19 @@ export const csvReportingProvider = ({ apiClient, toasts, license$ }: ReportingP return []; } - const getJobParams = () => { - return { - ...sharingData, - type: objectType, - }; + const jobParams: JobParamsDiscoverCsv = { + browserTimezone, + objectType, + title: sharingData.title as string, + indexPatternId: sharingData.indexPatternId as string, + searchRequest: sharingData.searchRequest as SearchRequest, + fields: sharingData.fields as string[], + metaFields: sharingData.metaFields as string[], + conflictedTypesFields: sharingData.conflictedTypesFields as string[], }; + const getJobParams = () => jobParams; + const shareActions = []; if (hasCSVReporting) { diff --git a/x-pack/plugins/reporting/public/share_context_menu/register_pdf_png_reporting.tsx b/x-pack/plugins/reporting/public/share_context_menu/register_pdf_png_reporting.tsx index 2343947a6d383..e10d04ea5fc6b 100644 --- a/x-pack/plugins/reporting/public/share_context_menu/register_pdf_png_reporting.tsx +++ b/x-pack/plugins/reporting/public/share_context_menu/register_pdf_png_reporting.tsx @@ -7,12 +7,15 @@ import { i18n } from '@kbn/i18n'; import moment from 'moment-timezone'; import React from 'react'; -import { ToastsSetup, IUiSettingsClient } from 'src/core/public'; -import { ReportingAPIClient } from '../lib/reporting_api_client'; -import { checkLicense } from '../lib/license_check'; -import { ScreenCapturePanelContent } from '../components/screen_capture_panel_content'; -import { LicensingPluginSetup } from '../../../licensing/public'; +import { IUiSettingsClient, ToastsSetup } from 'src/core/public'; import { ShareContext } from '../../../../../src/plugins/share/public'; +import { LicensingPluginSetup } from '../../../licensing/public'; +import { LayoutInstance } from '../../common/types'; +import { JobParamsPNG } from '../../server/export_types/png/types'; +import { JobParamsPDF } from '../../server/export_types/printable_pdf/types'; +import { ScreenCapturePanelContent } from '../components/screen_capture_panel_content'; +import { checkLicense } from '../lib/license_check'; +import { ReportingAPIClient } from '../lib/reporting_api_client'; interface ReportingPDFPNGProvider { apiClient: ReportingAPIClient; @@ -39,6 +42,14 @@ export const reportingPDFPNGProvider = ({ disabled = !enableLinks; }); + // If the TZ is set to the default "Browser", it will not be useful for + // server-side export. We need to derive the timezone and pass it as a param + // to the export API. + const browserTimezone = + uiSettings.get('dateFormat:tz') === 'Browser' + ? moment.tz.guess() + : uiSettings.get('dateFormat:tz'); + const getShareMenuItems = ({ objectType, objectId, @@ -57,7 +68,7 @@ export const reportingPDFPNGProvider = ({ return []; } - const getReportingJobParams = () => { + const getPdfJobParams = (): JobParamsPDF => { // Relative URL must have URL prefix (Spaces ID prefix), but not server basePath // Replace hashes with original RISON values. const relativeUrl = shareableUrl.replace( @@ -65,36 +76,28 @@ export const reportingPDFPNGProvider = ({ '' ); - const browserTimezone = - uiSettings.get('dateFormat:tz') === 'Browser' - ? moment.tz.guess() - : uiSettings.get('dateFormat:tz'); - return { - ...sharingData, objectType, browserTimezone, - relativeUrls: [relativeUrl], + relativeUrls: [relativeUrl], // multi URL for PDF + layout: sharingData.layout as LayoutInstance, + title: sharingData.title as string, }; }; - const getPngJobParams = () => { + const getPngJobParams = (): JobParamsPNG => { // Replace hashes with original RISON values. const relativeUrl = shareableUrl.replace( window.location.origin + apiClient.getServerBasePath(), '' ); - const browserTimezone = - uiSettings.get('dateFormat:tz') === 'Browser' - ? moment.tz.guess() - : uiSettings.get('dateFormat:tz'); - return { - ...sharingData, objectType, browserTimezone, - relativeUrl, + relativeUrl, // single URL for PNG + layout: sharingData.layout as LayoutInstance, + title: sharingData.title as string, }; }; @@ -161,7 +164,7 @@ export const reportingPDFPNGProvider = ({ reportType="printablePdf" objectType={objectType} objectId={objectId} - getJobParams={getReportingJobParams} + getJobParams={getPdfJobParams} isDirty={isDirty} onClose={onClose} /> diff --git a/x-pack/plugins/reporting/server/export_types/csv/server/create_job.ts b/x-pack/plugins/reporting/server/export_types/csv/server/create_job.ts index c4fa1cd8e4fa6..fb2d9bfdc5838 100644 --- a/x-pack/plugins/reporting/server/export_types/csv/server/create_job.ts +++ b/x-pack/plugins/reporting/server/export_types/csv/server/create_job.ts @@ -13,7 +13,6 @@ export const scheduleTaskFnFactory: ScheduleTaskFnFactory> = function createJobFactoryFn(reporting) { const config = reporting.getConfig(); const crypto = cryptoFactory(config.get('encryptionKey')); - const setupDeps = reporting.getPluginSetupDeps(); return async function scheduleTask(jobParams, context, request) { const serializedEncryptedHeaders = await crypto.encrypt(request.headers); @@ -21,13 +20,12 @@ export const scheduleTaskFnFactory: ScheduleTaskFnFactory { + const decryptHeaders = async () => { + try { + if (typeof headers !== 'string') { + throw new Error( + i18n.translate( + 'xpack.reporting.exportTypes.csv.executeJob.missingJobHeadersErrorMessage', + { + defaultMessage: 'Job headers are missing', + } + ) + ); + } + return await crypto.decrypt(headers); + } catch (err) { + logger.error(err); + throw new Error( + i18n.translate( + 'xpack.reporting.exportTypes.csv.executeJob.failedToDecryptReportJobDataErrorMessage', + { + defaultMessage: 'Failed to decrypt report job data. Please ensure that {encryptionKey} is set and re-generate this report. {err}', + values: { encryptionKey: 'xpack.reporting.encryptionKey', err: err.toString() }, + } + ) + ); // prettier-ignore + } + }; + + return KibanaRequest.from({ + headers: await decryptHeaders(), + // This is used by the spaces SavedObjectClientWrapper to determine the existing space. + // We use the basePath from the saved job, which we'll have post spaces being implemented; + // or we use the server base path, which uses the default space + path: '/', + route: { settings: {} }, + url: { href: '/' }, + raw: { req: { url: '/' } }, + } as Hapi.Request); +}; export const runTaskFnFactory: RunTaskFnFactory { - try { - if (typeof headers !== 'string') { - throw new Error( - i18n.translate( - 'xpack.reporting.exportTypes.csv.executeJob.missingJobHeadersErrorMessage', - { - defaultMessage: 'Job headers are missing', - } - ) - ); - } - return await crypto.decrypt(headers); - } catch (err) { - logger.error(err); - throw new Error( - i18n.translate( - 'xpack.reporting.exportTypes.csv.executeJob.failedToDecryptReportJobDataErrorMessage', - { - defaultMessage: 'Failed to decrypt report job data. Please ensure that {encryptionKey} is set and re-generate this report. {err}', - values: { encryptionKey: 'xpack.reporting.encryptionKey', err: err.toString() }, - } - ) - ); // prettier-ignore - } - }; - - const fakeRequest = KibanaRequest.from({ - headers: await decryptHeaders(), - // This is used by the spaces SavedObjectClientWrapper to determine the existing space. - // We use the basePath from the saved job, which we'll have post spaces being implemented; - // or we use the server base path, which uses the default space - getBasePath: () => basePath || serverBasePath, - path: '/', - route: { settings: {} }, - url: { href: '/' }, - raw: { req: { url: '/' } }, - } as Hapi.Request); + const { headers } = job; + const fakeRequest = await getRequest(headers, crypto, logger); const { callAsCurrentUser } = elasticsearch.legacy.client.asScoped(fakeRequest); const callEndpoint = (endpoint: string, clientParams = {}, options = {}) => @@ -87,62 +76,18 @@ export const runTaskFnFactory: RunTaskFnFactory { - const fieldFormats = await getFieldFormats().fieldFormatServiceFactory(client); - return fieldFormatMapFactory(indexPatternSavedObject, fieldFormats); - }; - const getUiSettings = async (client: IUiSettingsClient) => { - const [separator, quoteValues, timezone] = await Promise.all([ - client.get(CSV_SEPARATOR_SETTING), - client.get(CSV_QUOTE_VALUES_SETTING), - client.get('dateFormat:tz'), - ]); - - if (timezone === 'Browser') { - logger.warn( - i18n.translate('xpack.reporting.exportTypes.csv.executeJob.dateFormateSetting', { - defaultMessage: 'Kibana Advanced Setting "{dateFormatTimezone}" is set to "Browser". Dates will be formatted as UTC to avoid ambiguity.', - values: { dateFormatTimezone: 'dateFormat:tz' } - }) - ); // prettier-ignore - } - - return { - separator, - quoteValues, - timezone, - }; - }; - - const [formatsMap, uiSettings] = await Promise.all([ - getFormatsMap(uiSettingsClient), - getUiSettings(uiSettingsClient), - ]); - - const generateCsv = createGenerateCsv(jobLogger); - const bom = config.get('csv', 'useByteOrderMarkEncoding') ? CSV_BOM_CHARS : ''; - - const { content, maxSizeReached, size, csvContainsFormulas, warnings } = await generateCsv({ - searchRequest, - fields, - metaFields, - conflictedTypesFields, + const { content, maxSizeReached, size, csvContainsFormulas, warnings } = await generateCsv( + job, + config, + uiSettingsClient, callEndpoint, - cancellationToken, - formatsMap, - settings: { - ...uiSettings, - checkForFormulas: config.get('csv', 'checkForFormulas'), - maxSizeBytes: config.get('csv', 'maxSizeBytes'), - scroll: config.get('csv', 'scroll'), - escapeFormulaValues: config.get('csv', 'escapeFormulaValues'), - }, - }); + cancellationToken + ); // @TODO: Consolidate these one-off warnings into the warnings array (max-size reached and csv contains formulas) return { - content_type: 'text/csv', - content: bom + content, + content_type: CONTENT_TYPE_CSV, + content, max_size_reached: maxSizeReached, size, csv_contains_formulas: csvContainsFormulas, diff --git a/x-pack/plugins/reporting/server/export_types/csv/server/lib/cell_has_formula.ts b/x-pack/plugins/reporting/server/export_types/csv/server/generate_csv/cell_has_formula.ts similarity index 100% rename from x-pack/plugins/reporting/server/export_types/csv/server/lib/cell_has_formula.ts rename to x-pack/plugins/reporting/server/export_types/csv/server/generate_csv/cell_has_formula.ts diff --git a/x-pack/plugins/reporting/server/export_types/csv/server/lib/check_cells_for_formulas.test.ts b/x-pack/plugins/reporting/server/export_types/csv/server/generate_csv/check_cells_for_formulas.test.ts similarity index 100% rename from x-pack/plugins/reporting/server/export_types/csv/server/lib/check_cells_for_formulas.test.ts rename to x-pack/plugins/reporting/server/export_types/csv/server/generate_csv/check_cells_for_formulas.test.ts diff --git a/x-pack/plugins/reporting/server/export_types/csv/server/lib/check_cells_for_formulas.ts b/x-pack/plugins/reporting/server/export_types/csv/server/generate_csv/check_cells_for_formulas.ts similarity index 100% rename from x-pack/plugins/reporting/server/export_types/csv/server/lib/check_cells_for_formulas.ts rename to x-pack/plugins/reporting/server/export_types/csv/server/generate_csv/check_cells_for_formulas.ts diff --git a/x-pack/plugins/reporting/server/export_types/csv/server/lib/escape_value.test.ts b/x-pack/plugins/reporting/server/export_types/csv/server/generate_csv/escape_value.test.ts similarity index 100% rename from x-pack/plugins/reporting/server/export_types/csv/server/lib/escape_value.test.ts rename to x-pack/plugins/reporting/server/export_types/csv/server/generate_csv/escape_value.test.ts diff --git a/x-pack/plugins/reporting/server/export_types/csv/server/lib/escape_value.ts b/x-pack/plugins/reporting/server/export_types/csv/server/generate_csv/escape_value.ts similarity index 100% rename from x-pack/plugins/reporting/server/export_types/csv/server/lib/escape_value.ts rename to x-pack/plugins/reporting/server/export_types/csv/server/generate_csv/escape_value.ts diff --git a/x-pack/plugins/reporting/server/export_types/csv/server/lib/field_format_map.test.ts b/x-pack/plugins/reporting/server/export_types/csv/server/generate_csv/field_format_map.test.ts similarity index 74% rename from x-pack/plugins/reporting/server/export_types/csv/server/lib/field_format_map.test.ts rename to x-pack/plugins/reporting/server/export_types/csv/server/generate_csv/field_format_map.test.ts index 83aa23de67663..1f0e450da698f 100644 --- a/x-pack/plugins/reporting/server/export_types/csv/server/lib/field_format_map.test.ts +++ b/x-pack/plugins/reporting/server/export_types/csv/server/generate_csv/field_format_map.test.ts @@ -5,25 +5,17 @@ */ import expect from '@kbn/expect'; - -import { - fieldFormats, - FieldFormatsGetConfigFn, - UI_SETTINGS, -} from '../../../../../../../../src/plugins/data/server'; +import { fieldFormats, FieldFormatsGetConfigFn, UI_SETTINGS } from 'src/plugins/data/server'; +import { IndexPatternSavedObject } from '../../types'; import { fieldFormatMapFactory } from './field_format_map'; type ConfigValue = { number: { id: string; params: {} } } | string; describe('field format map', function () { - const indexPatternSavedObject = { - id: 'logstash-*', - type: 'index-pattern', - version: 'abc', + const indexPatternSavedObject: IndexPatternSavedObject = { + timeFieldName: '@timestamp', + title: 'logstash-*', attributes: { - title: 'logstash-*', - timeFieldName: '@timestamp', - notExpandable: true, fields: '[{"name":"field1","type":"number"}, {"name":"field2","type":"number"}]', fieldFormatMap: '{"field1":{"id":"bytes","params":{"pattern":"0,0.[0]b"}}}', }, @@ -35,11 +27,16 @@ describe('field format map', function () { configMock[UI_SETTINGS.FORMAT_NUMBER_DEFAULT_PATTERN] = '0,0.[000]'; const getConfig = ((key: string) => configMock[key]) as FieldFormatsGetConfigFn; const testValue = '4000'; + const mockTimezone = 'Browser'; const fieldFormatsRegistry = new fieldFormats.FieldFormatsRegistry(); fieldFormatsRegistry.init(getConfig, {}, [fieldFormats.BytesFormat, fieldFormats.NumberFormat]); - const formatMap = fieldFormatMapFactory(indexPatternSavedObject, fieldFormatsRegistry); + const formatMap = fieldFormatMapFactory( + indexPatternSavedObject, + fieldFormatsRegistry, + mockTimezone + ); it('should build field format map with entry per index pattern field', function () { expect(formatMap.has('field1')).to.be(true); @@ -48,10 +45,10 @@ describe('field format map', function () { }); it('should create custom FieldFormat for fields with configured field formatter', function () { - expect(formatMap.get('field1').convert(testValue)).to.be('3.9KB'); + expect(formatMap.get('field1')!.convert(testValue)).to.be('3.9KB'); }); it('should create default FieldFormat for fields with no field formatter', function () { - expect(formatMap.get('field2').convert(testValue)).to.be('4,000'); + expect(formatMap.get('field2')!.convert(testValue)).to.be('4,000'); }); }); diff --git a/x-pack/plugins/reporting/server/export_types/csv/server/lib/field_format_map.ts b/x-pack/plugins/reporting/server/export_types/csv/server/generate_csv/field_format_map.ts similarity index 56% rename from x-pack/plugins/reporting/server/export_types/csv/server/lib/field_format_map.ts rename to x-pack/plugins/reporting/server/export_types/csv/server/generate_csv/field_format_map.ts index 6cb4d0bbb1c65..848cf569bc8d7 100644 --- a/x-pack/plugins/reporting/server/export_types/csv/server/lib/field_format_map.ts +++ b/x-pack/plugins/reporting/server/export_types/csv/server/generate_csv/field_format_map.ts @@ -5,19 +5,9 @@ */ import _ from 'lodash'; -import { - FieldFormatConfig, - IFieldFormatsRegistry, -} from '../../../../../../../../src/plugins/data/server'; - -interface IndexPatternSavedObject { - attributes: { - fieldFormatMap: string; - }; - id: string; - type: string; - version: string; -} +import { FieldFormat } from 'src/plugins/data/common'; +import { FieldFormatConfig, IFieldFormatsRegistry } from 'src/plugins/data/server'; +import { IndexPatternSavedObject } from '../../types'; /** * Create a map of FieldFormat instances for index pattern fields @@ -28,30 +18,39 @@ interface IndexPatternSavedObject { */ export function fieldFormatMapFactory( indexPatternSavedObject: IndexPatternSavedObject, - fieldFormatsRegistry: IFieldFormatsRegistry + fieldFormatsRegistry: IFieldFormatsRegistry, + timezone: string | undefined ) { - const formatsMap = new Map(); + const formatsMap = new Map(); + + // From here, the browser timezone can't be determined, so we accept a + // timezone field from job params posted to the API. Here is where it gets used. + const serverDateParams = { timezone }; // Add FieldFormat instances for fields with custom formatters if (_.has(indexPatternSavedObject, 'attributes.fieldFormatMap')) { const fieldFormatMap = JSON.parse(indexPatternSavedObject.attributes.fieldFormatMap); Object.keys(fieldFormatMap).forEach((fieldName) => { const formatConfig: FieldFormatConfig = fieldFormatMap[fieldName]; + const formatParams = { + ...formatConfig.params, + ...serverDateParams, + }; if (!_.isEmpty(formatConfig)) { - formatsMap.set( - fieldName, - fieldFormatsRegistry.getInstance(formatConfig.id, formatConfig.params) - ); + formatsMap.set(fieldName, fieldFormatsRegistry.getInstance(formatConfig.id, formatParams)); } }); } - // Add default FieldFormat instances for all other fields + // Add default FieldFormat instances for non-custom formatted fields const indexFields = JSON.parse(_.get(indexPatternSavedObject, 'attributes.fields', '[]')); indexFields.forEach((field: any) => { if (!formatsMap.has(field.name)) { - formatsMap.set(field.name, fieldFormatsRegistry.getDefaultInstance(field.type)); + formatsMap.set( + field.name, + fieldFormatsRegistry.getDefaultInstance(field.type, [], serverDateParams) + ); } }); diff --git a/x-pack/plugins/reporting/server/export_types/csv/server/lib/flatten_hit.test.ts b/x-pack/plugins/reporting/server/export_types/csv/server/generate_csv/flatten_hit.test.ts similarity index 100% rename from x-pack/plugins/reporting/server/export_types/csv/server/lib/flatten_hit.test.ts rename to x-pack/plugins/reporting/server/export_types/csv/server/generate_csv/flatten_hit.test.ts diff --git a/x-pack/plugins/reporting/server/export_types/csv/server/lib/flatten_hit.ts b/x-pack/plugins/reporting/server/export_types/csv/server/generate_csv/flatten_hit.ts similarity index 100% rename from x-pack/plugins/reporting/server/export_types/csv/server/lib/flatten_hit.ts rename to x-pack/plugins/reporting/server/export_types/csv/server/generate_csv/flatten_hit.ts diff --git a/x-pack/plugins/reporting/server/export_types/csv/server/lib/format_csv_values.test.ts b/x-pack/plugins/reporting/server/export_types/csv/server/generate_csv/format_csv_values.test.ts similarity index 100% rename from x-pack/plugins/reporting/server/export_types/csv/server/lib/format_csv_values.test.ts rename to x-pack/plugins/reporting/server/export_types/csv/server/generate_csv/format_csv_values.test.ts diff --git a/x-pack/plugins/reporting/server/export_types/csv/server/lib/format_csv_values.ts b/x-pack/plugins/reporting/server/export_types/csv/server/generate_csv/format_csv_values.ts similarity index 86% rename from x-pack/plugins/reporting/server/export_types/csv/server/lib/format_csv_values.ts rename to x-pack/plugins/reporting/server/export_types/csv/server/generate_csv/format_csv_values.ts index bb4e2be86f5df..387066415a1bc 100644 --- a/x-pack/plugins/reporting/server/export_types/csv/server/lib/format_csv_values.ts +++ b/x-pack/plugins/reporting/server/export_types/csv/server/generate_csv/format_csv_values.ts @@ -5,13 +5,14 @@ */ import { isNull, isObject, isUndefined } from 'lodash'; +import { FieldFormat } from 'src/plugins/data/common'; import { RawValue } from '../../types'; export function createFormatCsvValues( escapeValue: (value: RawValue, index: number, array: RawValue[]) => string, separator: string, fields: string[], - formatsMap: any + formatsMap: Map ) { return function formatCsvValues(values: Record) { return fields @@ -29,7 +30,9 @@ export function createFormatCsvValues( let formattedValue = value; if (formatsMap.has(field)) { const formatter = formatsMap.get(field); - formattedValue = formatter.convert(value); + if (formatter) { + formattedValue = formatter.convert(value); + } } return formattedValue; diff --git a/x-pack/plugins/reporting/server/export_types/csv/server/generate_csv/get_ui_settings.ts b/x-pack/plugins/reporting/server/export_types/csv/server/generate_csv/get_ui_settings.ts new file mode 100644 index 0000000000000..8f72c467b0711 --- /dev/null +++ b/x-pack/plugins/reporting/server/export_types/csv/server/generate_csv/get_ui_settings.ts @@ -0,0 +1,54 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; +import { IUiSettingsClient } from 'kibana/server'; +import { ReportingConfig } from '../../../..'; +import { LevelLogger } from '../../../../lib'; + +export const getUiSettings = async ( + timezone: string | undefined, + client: IUiSettingsClient, + config: ReportingConfig, + logger: LevelLogger +) => { + // Timezone + let setTimezone: string; + // look for timezone in job params + if (timezone) { + setTimezone = timezone; + } else { + // if empty, look for timezone in settings + setTimezone = await client.get('dateFormat:tz'); + if (setTimezone === 'Browser') { + // if `Browser`, hardcode it to 'UTC' so the export has data that makes sense + logger.warn( + i18n.translate('xpack.reporting.exportTypes.csv.executeJob.dateFormateSetting', { + defaultMessage: + 'Kibana Advanced Setting "{dateFormatTimezone}" is set to "Browser". Dates will be formatted as UTC to avoid ambiguity.', + values: { dateFormatTimezone: 'dateFormat:tz' }, + }) + ); + setTimezone = 'UTC'; + } + } + + // Separator, QuoteValues + const [separator, quoteValues] = await Promise.all([ + client.get('csv:separator'), + client.get('csv:quoteValues'), + ]); + + return { + timezone: setTimezone, + separator, + quoteValues, + escapeFormulaValues: config.get('csv', 'escapeFormulaValues'), + maxSizeBytes: config.get('csv', 'maxSizeBytes'), + scroll: config.get('csv', 'scroll'), + checkForFormulas: config.get('csv', 'checkForFormulas'), + }; +}; diff --git a/x-pack/plugins/reporting/server/export_types/csv/server/lib/hit_iterator.test.ts b/x-pack/plugins/reporting/server/export_types/csv/server/generate_csv/hit_iterator.test.ts similarity index 100% rename from x-pack/plugins/reporting/server/export_types/csv/server/lib/hit_iterator.test.ts rename to x-pack/plugins/reporting/server/export_types/csv/server/generate_csv/hit_iterator.test.ts diff --git a/x-pack/plugins/reporting/server/export_types/csv/server/lib/hit_iterator.ts b/x-pack/plugins/reporting/server/export_types/csv/server/generate_csv/hit_iterator.ts similarity index 82% rename from x-pack/plugins/reporting/server/export_types/csv/server/lib/hit_iterator.ts rename to x-pack/plugins/reporting/server/export_types/csv/server/generate_csv/hit_iterator.ts index 38b28573d602d..b877023064ac6 100644 --- a/x-pack/plugins/reporting/server/export_types/csv/server/lib/hit_iterator.ts +++ b/x-pack/plugins/reporting/server/export_types/csv/server/generate_csv/hit_iterator.ts @@ -10,8 +10,10 @@ import { CancellationToken } from '../../../../../common'; import { LevelLogger } from '../../../../lib'; import { ScrollConfig } from '../../../../types'; -async function parseResponse(request: SearchResponse) { - const response = await request; +export type EndpointCaller = (method: string, params: object) => Promise>; + +function parseResponse(request: SearchResponse) { + const response = request; if (!response || !response._scroll_id) { throw new Error( i18n.translate('xpack.reporting.exportTypes.csv.hitIterator.expectedScrollIdErrorMessage', { @@ -39,14 +41,15 @@ async function parseResponse(request: SearchResponse) { export function createHitIterator(logger: LevelLogger) { return async function* hitIterator( scrollSettings: ScrollConfig, - callEndpoint: Function, + callEndpoint: EndpointCaller, searchRequest: SearchParams, cancellationToken: CancellationToken ) { logger.debug('executing search request'); - function search(index: string | boolean | string[] | undefined, body: object) { + async function search(index: string | boolean | string[] | undefined, body: object) { return parseResponse( - callEndpoint('search', { + await callEndpoint('search', { + ignore_unavailable: true, // ignores if the index pattern contains any aliases that point to closed indices index, body, scroll: scrollSettings.duration, @@ -55,10 +58,10 @@ export function createHitIterator(logger: LevelLogger) { ); } - function scroll(scrollId: string | undefined) { + async function scroll(scrollId: string | undefined) { logger.debug('executing scroll request'); return parseResponse( - callEndpoint('scroll', { + await callEndpoint('scroll', { scrollId, scroll: scrollSettings.duration, }) diff --git a/x-pack/plugins/reporting/server/export_types/csv/server/lib/generate_csv.ts b/x-pack/plugins/reporting/server/export_types/csv/server/generate_csv/index.ts similarity index 55% rename from x-pack/plugins/reporting/server/export_types/csv/server/lib/generate_csv.ts rename to x-pack/plugins/reporting/server/export_types/csv/server/generate_csv/index.ts index 019fa3c9c8e9d..2cb10e291619c 100644 --- a/x-pack/plugins/reporting/server/export_types/csv/server/lib/generate_csv.ts +++ b/x-pack/plugins/reporting/server/export_types/csv/server/generate_csv/index.ts @@ -5,30 +5,68 @@ */ import { i18n } from '@kbn/i18n'; +import { IUiSettingsClient } from 'src/core/server'; +import { getFieldFormats } from '../../../../services'; +import { ReportingConfig } from '../../../..'; +import { CancellationToken } from '../../../../../../../plugins/reporting/common'; +import { CSV_BOM_CHARS } from '../../../../../common/constants'; import { LevelLogger } from '../../../../lib'; -import { GenerateCsvParams, SavedSearchGeneratorResult } from '../../types'; +import { IndexPatternSavedObject, SavedSearchGeneratorResult } from '../../types'; +import { checkIfRowsHaveFormulas } from './check_cells_for_formulas'; +import { createEscapeValue } from './escape_value'; +import { fieldFormatMapFactory } from './field_format_map'; import { createFlattenHit } from './flatten_hit'; import { createFormatCsvValues } from './format_csv_values'; -import { createEscapeValue } from './escape_value'; -import { createHitIterator } from './hit_iterator'; +import { getUiSettings } from './get_ui_settings'; +import { createHitIterator, EndpointCaller } from './hit_iterator'; import { MaxSizeStringBuilder } from './max_size_string_builder'; -import { checkIfRowsHaveFormulas } from './check_cells_for_formulas'; + +interface SearchRequest { + index: string; + body: + | { + _source: { excludes: string[]; includes: string[] }; + docvalue_fields: string[]; + query: { bool: { filter: any[]; must_not: any[]; should: any[]; must: any[] } } | any; + script_fields: any; + sort: Array<{ [key: string]: { order: string } }>; + stored_fields: string[]; + } + | any; +} + +export interface GenerateCsvParams { + jobParams: { + browserTimezone: string; + }; + searchRequest: SearchRequest; + indexPatternSavedObject: IndexPatternSavedObject; + fields: string[]; + metaFields: string[]; + conflictedTypesFields: string[]; +} export function createGenerateCsv(logger: LevelLogger) { const hitIterator = createHitIterator(logger); - return async function generateCsv({ - searchRequest, - fields, - formatsMap, - metaFields, - conflictedTypesFields, - callEndpoint, - cancellationToken, - settings, - }: GenerateCsvParams): Promise { + return async function generateCsv( + job: GenerateCsvParams, + config: ReportingConfig, + uiSettingsClient: IUiSettingsClient, + callEndpoint: EndpointCaller, + cancellationToken: CancellationToken + ): Promise { + const settings = await getUiSettings( + job.jobParams?.browserTimezone, + uiSettingsClient, + config, + logger + ); const escapeValue = createEscapeValue(settings.quoteValues, settings.escapeFormulaValues); - const builder = new MaxSizeStringBuilder(settings.maxSizeBytes); + const bom = config.get('csv', 'useByteOrderMarkEncoding') ? CSV_BOM_CHARS : ''; + const builder = new MaxSizeStringBuilder(settings.maxSizeBytes, bom); + + const { fields, metaFields, conflictedTypesFields } = job; const header = `${fields.map(escapeValue).join(settings.separator)}\n`; const warnings: string[] = []; @@ -41,11 +79,22 @@ export function createGenerateCsv(logger: LevelLogger) { }; } - const iterator = hitIterator(settings.scroll, callEndpoint, searchRequest, cancellationToken); + const iterator = hitIterator( + settings.scroll, + callEndpoint, + job.searchRequest, + cancellationToken + ); let maxSizeReached = false; let csvContainsFormulas = false; const flattenHit = createFlattenHit(fields, metaFields, conflictedTypesFields); + const formatsMap = await getFieldFormats() + .fieldFormatServiceFactory(uiSettingsClient) + .then((fieldFormats) => + fieldFormatMapFactory(job.indexPatternSavedObject, fieldFormats, settings.timezone) + ); + const formatCsvValues = createFormatCsvValues( escapeValue, settings.separator, @@ -76,7 +125,9 @@ export function createGenerateCsv(logger: LevelLogger) { if (!builder.tryAppend(rows + '\n')) { logger.warn('max Size Reached'); maxSizeReached = true; - cancellationToken.cancel(); + if (cancellationToken) { + cancellationToken.cancel(); + } break; } } diff --git a/x-pack/plugins/reporting/server/export_types/csv/server/lib/max_size_string_builder.test.ts b/x-pack/plugins/reporting/server/export_types/csv/server/generate_csv/max_size_string_builder.test.ts similarity index 91% rename from x-pack/plugins/reporting/server/export_types/csv/server/lib/max_size_string_builder.test.ts rename to x-pack/plugins/reporting/server/export_types/csv/server/generate_csv/max_size_string_builder.test.ts index 7a35de1cea19b..e3cd1f32856e6 100644 --- a/x-pack/plugins/reporting/server/export_types/csv/server/lib/max_size_string_builder.test.ts +++ b/x-pack/plugins/reporting/server/export_types/csv/server/generate_csv/max_size_string_builder.test.ts @@ -62,6 +62,14 @@ describe('MaxSizeStringBuilder', function () { builder.tryAppend(str); expect(builder.getString()).to.be('a'); }); + + it('should return string with bom character prepended', function () { + const str = 'a'; // each a is one byte + const builder = new MaxSizeStringBuilder(1, '∆'); + builder.tryAppend(str); + builder.tryAppend(str); + expect(builder.getString()).to.be('∆a'); + }); }); describe('getSizeInBytes', function () { diff --git a/x-pack/plugins/reporting/server/export_types/csv/server/lib/max_size_string_builder.ts b/x-pack/plugins/reporting/server/export_types/csv/server/generate_csv/max_size_string_builder.ts similarity index 82% rename from x-pack/plugins/reporting/server/export_types/csv/server/lib/max_size_string_builder.ts rename to x-pack/plugins/reporting/server/export_types/csv/server/generate_csv/max_size_string_builder.ts index 70bc2030d290c..147031c104c8e 100644 --- a/x-pack/plugins/reporting/server/export_types/csv/server/lib/max_size_string_builder.ts +++ b/x-pack/plugins/reporting/server/export_types/csv/server/generate_csv/max_size_string_builder.ts @@ -8,11 +8,13 @@ export class MaxSizeStringBuilder { private _buffer: Buffer; private _size: number; private _maxSize: number; + private _bom: string; - constructor(maxSizeBytes: number) { + constructor(maxSizeBytes: number, bom = '') { this._buffer = Buffer.alloc(maxSizeBytes); this._size = 0; this._maxSize = maxSizeBytes; + this._bom = bom; } tryAppend(str: string) { @@ -31,6 +33,6 @@ export class MaxSizeStringBuilder { } getString() { - return this._buffer.slice(0, this._size).toString(); + return this._bom + this._buffer.slice(0, this._size).toString(); } } diff --git a/x-pack/plugins/reporting/server/export_types/csv/server/lib/get_request.ts b/x-pack/plugins/reporting/server/export_types/csv/server/lib/get_request.ts new file mode 100644 index 0000000000000..21e49bd62ccc7 --- /dev/null +++ b/x-pack/plugins/reporting/server/export_types/csv/server/lib/get_request.ts @@ -0,0 +1,55 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Crypto } from '@elastic/node-crypto'; +import { i18n } from '@kbn/i18n'; +import Hapi from 'hapi'; +import { KibanaRequest } from '../../../../../../../../src/core/server'; +import { LevelLogger } from '../../../../lib'; + +export const getRequest = async ( + headers: string | undefined, + crypto: Crypto, + logger: LevelLogger +) => { + const decryptHeaders = async () => { + try { + if (typeof headers !== 'string') { + throw new Error( + i18n.translate( + 'xpack.reporting.exportTypes.csv.executeJob.missingJobHeadersErrorMessage', + { + defaultMessage: 'Job headers are missing', + } + ) + ); + } + return await crypto.decrypt(headers); + } catch (err) { + logger.error(err); + throw new Error( + i18n.translate( + 'xpack.reporting.exportTypes.csv.executeJob.failedToDecryptReportJobDataErrorMessage', + { + defaultMessage: 'Failed to decrypt report job data. Please ensure that {encryptionKey} is set and re-generate this report. {err}', + values: { encryptionKey: 'xpack.reporting.encryptionKey', err: err.toString() }, + } + ) + ); // prettier-ignore + } + }; + + return KibanaRequest.from({ + headers: await decryptHeaders(), + // This is used by the spaces SavedObjectClientWrapper to determine the existing space. + // We use the basePath from the saved job, which we'll have post spaces being implemented; + // or we use the server base path, which uses the default space + path: '/', + route: { settings: {} }, + url: { href: '/' }, + raw: { req: { url: '/' } }, + } as Hapi.Request); +}; diff --git a/x-pack/plugins/reporting/server/export_types/csv/types.d.ts b/x-pack/plugins/reporting/server/export_types/csv/types.d.ts index ab3e114c7c995..9e86a5bb254a3 100644 --- a/x-pack/plugins/reporting/server/export_types/csv/types.d.ts +++ b/x-pack/plugins/reporting/server/export_types/csv/types.d.ts @@ -4,8 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { CancellationToken } from '../../../common'; -import { JobParamPostPayload, ScheduledTaskParams, ScrollConfig } from '../../types'; +import { ScheduledTaskParams } from '../../types'; export type RawValue = string | object | null | undefined; @@ -19,17 +18,25 @@ interface SortOptions { unmapped_type: string; } -export interface JobParamPostPayloadDiscoverCsv extends JobParamPostPayload { - state?: { - query: any; - sort: Array>; - docvalue_fields: DocValueField[]; +export interface IndexPatternSavedObject { + title: string; + timeFieldName: string; + fields?: any[]; + attributes: { + fields: string; + fieldFormatMap: string; }; } export interface JobParamsDiscoverCsv { - indexPatternId?: string; - post?: JobParamPostPayloadDiscoverCsv; + browserTimezone: string; + indexPatternId: string; + objectType: string; + title: string; + searchRequest: SearchRequest; + fields: string[]; + metaFields: string[]; + conflictedTypesFields: string[]; } export interface ScheduledTaskParamsCSV extends ScheduledTaskParams { @@ -71,8 +78,6 @@ export interface SearchRequest { | any; } -type EndpointCaller = (method: string, params: any) => Promise; - type FormatsMap = Map< string, { @@ -95,22 +100,3 @@ export interface CsvResultFromSearch { type: string; result: SavedSearchGeneratorResult; } - -export interface GenerateCsvParams { - searchRequest: SearchRequest; - callEndpoint: EndpointCaller; - fields: string[]; - formatsMap: FormatsMap; - metaFields: string[]; - conflictedTypesFields: string[]; - cancellationToken: CancellationToken; - settings: { - separator: string; - quoteValues: boolean; - timezone: string | null; - maxSizeBytes: number; - scroll: ScrollConfig; - checkForFormulas?: boolean; - escapeFormulaValues: boolean; - }; -} diff --git a/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/server/create_job/index.ts b/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/server/create_job.ts similarity index 76% rename from x-pack/plugins/reporting/server/export_types/csv_from_savedobject/server/create_job/index.ts rename to x-pack/plugins/reporting/server/export_types/csv_from_savedobject/server/create_job.ts index da9810b03aff6..96fb2033f0954 100644 --- a/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/server/create_job/index.ts +++ b/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/server/create_job.ts @@ -7,18 +7,18 @@ import { notFound, notImplemented } from 'boom'; import { get } from 'lodash'; import { KibanaRequest, RequestHandlerContext } from 'src/core/server'; -import { CSV_FROM_SAVEDOBJECT_JOB_TYPE } from '../../../../../common/constants'; -import { cryptoFactory } from '../../../../lib'; -import { ScheduleTaskFnFactory, TimeRangeParams } from '../../../../types'; +import { CSV_FROM_SAVEDOBJECT_JOB_TYPE } from '../../../../common/constants'; +import { cryptoFactory } from '../../../lib'; +import { ScheduleTaskFnFactory, TimeRangeParams } from '../../../types'; import { JobParamsPanelCsv, SavedObject, + SavedObjectReference, SavedObjectServiceError, SavedSearchObjectAttributesJSON, SearchPanel, VisObjectAttributesJSON, -} from '../../types'; -import { createJobSearch } from './create_job_search'; +} from '../types'; export type ImmediateCreateJobFn = ( jobParams: JobParamsPanelCsv, @@ -26,7 +26,7 @@ export type ImmediateCreateJobFn = ( context: RequestHandlerContext, req: KibanaRequest ) => Promise<{ - type: string | null; + type: string; title: string; jobParams: JobParamsPanelCsv; }>; @@ -73,7 +73,28 @@ export const scheduleTaskFnFactory: ScheduleTaskFnFactory } // saved search type - return await createJobSearch(timerange, attributes, references, kibanaSavedObjectMeta); + const { searchSource } = kibanaSavedObjectMeta; + if (!searchSource || !references) { + throw new Error('The saved search object is missing configuration fields!'); + } + + const indexPatternMeta = references.find( + (ref: SavedObjectReference) => ref.type === 'index-pattern' + ); + if (!indexPatternMeta) { + throw new Error('Could not find index pattern for the saved search!'); + } + + const sPanel = { + attributes: { + ...attributes, + kibanaSavedObjectMeta: { searchSource }, + }, + indexPatternSavedObjectId: indexPatternMeta.id, + timerange, + }; + + return { panel: sPanel, title: attributes.title, visType: 'search' }; }) .catch((err: Error) => { const boomErr = (err as unknown) as { isBoom: boolean }; @@ -93,7 +114,7 @@ export const scheduleTaskFnFactory: ScheduleTaskFnFactory return { headers: serializedEncryptedHeaders, jobParams: { ...jobParams, panel, visType }, - type: null, + type: visType, title, }; }; diff --git a/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/server/create_job/create_job_search.ts b/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/server/create_job/create_job_search.ts deleted file mode 100644 index 02abfb90091a1..0000000000000 --- a/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/server/create_job/create_job_search.ts +++ /dev/null @@ -1,49 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { TimeRangeParams } from '../../../../types'; -import { - SavedObjectMeta, - SavedObjectReference, - SavedSearchObjectAttributes, - SearchPanel, -} from '../../types'; - -interface SearchPanelData { - title: string; - visType: string; - panel: SearchPanel; -} - -export async function createJobSearch( - timerange: TimeRangeParams, - attributes: SavedSearchObjectAttributes, - references: SavedObjectReference[], - kibanaSavedObjectMeta: SavedObjectMeta -): Promise { - const { searchSource } = kibanaSavedObjectMeta; - if (!searchSource || !references) { - throw new Error('The saved search object is missing configuration fields!'); - } - - const indexPatternMeta = references.find( - (ref: SavedObjectReference) => ref.type === 'index-pattern' - ); - if (!indexPatternMeta) { - throw new Error('Could not find index pattern for the saved search!'); - } - - const sPanel = { - attributes: { - ...attributes, - kibanaSavedObjectMeta: { searchSource }, - }, - indexPatternSavedObjectId: indexPatternMeta.id, - timerange, - }; - - return { panel: sPanel, title: attributes.title, visType: 'search' }; -} diff --git a/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/server/execute_job.ts b/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/server/execute_job.ts index 912ae0809cf92..a7992c34a88f1 100644 --- a/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/server/execute_job.ts +++ b/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/server/execute_job.ts @@ -4,13 +4,14 @@ * you may not use this file except in compliance with the Elastic License. */ -import { i18n } from '@kbn/i18n'; import { KibanaRequest, RequestHandlerContext } from 'src/core/server'; +import { CancellationToken } from '../../../../common'; import { CONTENT_TYPE_CSV, CSV_FROM_SAVEDOBJECT_JOB_TYPE } from '../../../../common/constants'; import { RunTaskFnFactory, ScheduledTaskParams, TaskRunResult } from '../../../types'; -import { CsvResultFromSearch } from '../../csv/types'; +import { createGenerateCsv } from '../../csv/server/generate_csv'; import { JobParamsPanelCsv, SearchPanel } from '../types'; -import { createGenerateCsv } from './lib'; +import { getFakeRequest } from './lib/get_fake_request'; +import { getGenerateCsvParams } from './lib/get_csv_job'; /* * The run function receives the full request which provides the un-encrypted @@ -33,45 +34,47 @@ export const runTaskFnFactory: RunTaskFnFactory = function e reporting, parentLogger ) { + const config = reporting.getConfig(); const logger = parentLogger.clone([CSV_FROM_SAVEDOBJECT_JOB_TYPE, 'execute-job']); - const generateCsv = createGenerateCsv(reporting, parentLogger); - return async function runTask(jobId: string | null, job, context, request) { + return async function runTask(jobId: string | null, jobPayload, context, req) { // There will not be a jobID for "immediate" generation. // jobID is only for "queued" jobs // Use the jobID as a logging tag or "immediate" + const { jobParams } = jobPayload; const jobLogger = logger.clone([jobId === null ? 'immediate' : jobId]); + const generateCsv = createGenerateCsv(jobLogger); + const { isImmediate, panel, visType } = jobParams as JobParamsPanelCsv & { + panel: SearchPanel; + }; - const { jobParams } = job; - const { panel, visType } = jobParams as JobParamsPanelCsv & { panel: SearchPanel }; + jobLogger.debug(`Execute job generating [${visType}] csv`); - if (!panel) { - i18n.translate( - 'xpack.reporting.exportTypes.csv_from_savedobject.executeJob.failedToAccessPanel', - { defaultMessage: 'Failed to access panel metadata for job execution' } - ); + if (isImmediate && req) { + jobLogger.info(`Executing job from Immediate API using request context`); + } else { + jobLogger.info(`Executing job async using encrypted headers`); + req = await getFakeRequest(jobPayload, config.get('encryptionKey')!, jobLogger); } - jobLogger.debug(`Execute job generating [${visType}] csv`); + const savedObjectsClient = context.core.savedObjects.client; + + const uiConfig = await reporting.getUiSettingsServiceFactory(savedObjectsClient); + const job = await getGenerateCsvParams(jobParams, panel, savedObjectsClient, uiConfig); + + const elasticsearch = reporting.getElasticsearchService(); + const { callAsCurrentUser } = elasticsearch.legacy.client.asScoped(req); - let content: string; - let maxSizeReached = false; - let size = 0; - try { - const generateResults: CsvResultFromSearch = await generateCsv( - context, - request, - visType as string, - panel, - jobParams - ); + const { content, maxSizeReached, size, csvContainsFormulas, warnings } = await generateCsv( + job, + config, + uiConfig, + callAsCurrentUser, + new CancellationToken() // can not be cancelled + ); - ({ - result: { content, maxSizeReached, size }, - } = generateResults); - } catch (err) { - jobLogger.error(`Generate CSV Error! ${err}`); - throw err; + if (csvContainsFormulas) { + jobLogger.warn(`CSV may contain formulas whose values have been escaped`); } if (maxSizeReached) { @@ -83,6 +86,8 @@ export const runTaskFnFactory: RunTaskFnFactory = function e content, max_size_reached: maxSizeReached, size, + csv_contains_formulas: csvContainsFormulas, + warnings, }; }; }; diff --git a/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/server/lib/generate_csv.ts b/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/server/lib/generate_csv.ts deleted file mode 100644 index dd0fb34668e9e..0000000000000 --- a/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/server/lib/generate_csv.ts +++ /dev/null @@ -1,41 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { badRequest } from 'boom'; -import { KibanaRequest, RequestHandlerContext } from 'src/core/server'; -import { ReportingCore } from '../../../..'; -import { LevelLogger } from '../../../../lib'; -import { FakeRequest, JobParamsPanelCsv, SearchPanel, VisPanel } from '../../types'; -import { generateCsvSearch } from './generate_csv_search'; - -export function createGenerateCsv(reporting: ReportingCore, logger: LevelLogger) { - return async function generateCsv( - context: RequestHandlerContext, - request: KibanaRequest | FakeRequest, - visType: string, - panel: VisPanel | SearchPanel, - jobParams: JobParamsPanelCsv - ) { - // This should support any vis type that is able to fetch - // and model data on the server-side - - // This structure will not be needed when the vis data just consists of an - // expression that we could run through the interpreter to get csv - switch (visType) { - case 'search': - return await generateCsvSearch( - reporting, - context, - request as KibanaRequest, - panel as SearchPanel, - jobParams, - logger - ); - default: - throw badRequest(`Unsupported or unrecognized saved object type: ${visType}`); - } - }; -} diff --git a/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/server/lib/generate_csv_search.ts b/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/server/lib/generate_csv_search.ts deleted file mode 100644 index aee3e40025ff2..0000000000000 --- a/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/server/lib/generate_csv_search.ts +++ /dev/null @@ -1,187 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { ReportingCore } from '../../../../'; -import { - IUiSettingsClient, - KibanaRequest, - RequestHandlerContext, -} from '../../../../../../../../src/core/server'; -import { - esQuery, - EsQueryConfig, - Filter, - IIndexPattern, - Query, - UI_SETTINGS, -} from '../../../../../../../../src/plugins/data/server'; -import { - CSV_SEPARATOR_SETTING, - CSV_QUOTE_VALUES_SETTING, -} from '../../../../../../../../src/plugins/share/server'; -import { CancellationToken } from '../../../../../common'; -import { LevelLogger } from '../../../../lib'; -import { createGenerateCsv } from '../../../csv/server/lib/generate_csv'; -import { - CsvResultFromSearch, - GenerateCsvParams, - JobParamsDiscoverCsv, - SearchRequest, -} from '../../../csv/types'; -import { IndexPatternField, QueryFilter, SearchPanel, SearchSource } from '../../types'; -import { getDataSource } from './get_data_source'; -import { getFilters } from './get_filters'; - -const getEsQueryConfig = async (config: IUiSettingsClient) => { - const configs = await Promise.all([ - config.get(UI_SETTINGS.QUERY_ALLOW_LEADING_WILDCARDS), - config.get(UI_SETTINGS.QUERY_STRING_OPTIONS), - config.get(UI_SETTINGS.COURIER_IGNORE_FILTER_IF_FIELD_NOT_IN_INDEX), - ]); - const [allowLeadingWildcards, queryStringOptions, ignoreFilterIfFieldNotInIndex] = configs; - return { - allowLeadingWildcards, - queryStringOptions, - ignoreFilterIfFieldNotInIndex, - } as EsQueryConfig; -}; - -const getUiSettings = async (config: IUiSettingsClient) => { - const configs = await Promise.all([ - config.get(CSV_SEPARATOR_SETTING), - config.get(CSV_QUOTE_VALUES_SETTING), - ]); - const [separator, quoteValues] = configs; - return { separator, quoteValues }; -}; - -export async function generateCsvSearch( - reporting: ReportingCore, - context: RequestHandlerContext, - req: KibanaRequest, - searchPanel: SearchPanel, - jobParams: JobParamsDiscoverCsv, - logger: LevelLogger -): Promise { - const savedObjectsClient = context.core.savedObjects.client; - const { indexPatternSavedObjectId, timerange } = searchPanel; - const savedSearchObjectAttr = searchPanel.attributes; - const { indexPatternSavedObject } = await getDataSource( - savedObjectsClient, - indexPatternSavedObjectId - ); - - const uiConfig = await reporting.getUiSettingsServiceFactory(savedObjectsClient); - const esQueryConfig = await getEsQueryConfig(uiConfig); - - const { - kibanaSavedObjectMeta: { - searchSource: { - filter: [searchSourceFilter], - query: searchSourceQuery, - }, - }, - } = savedSearchObjectAttr as { kibanaSavedObjectMeta: { searchSource: SearchSource } }; - - const { - timeFieldName: indexPatternTimeField, - title: esIndex, - fields: indexPatternFields, - } = indexPatternSavedObject; - - let payloadQuery: QueryFilter | undefined; - let payloadSort: any[] = []; - let docValueFields: any[] | undefined; - if (jobParams.post && jobParams.post.state) { - ({ - post: { - state: { query: payloadQuery, sort: payloadSort = [], docvalue_fields: docValueFields }, - }, - } = jobParams); - } - - const { includes, timezone, combinedFilter } = getFilters( - indexPatternSavedObjectId, - indexPatternTimeField, - timerange, - savedSearchObjectAttr, - searchSourceFilter, - payloadQuery - ); - - const savedSortConfigs = savedSearchObjectAttr.sort; - const sortConfig = [...payloadSort]; - savedSortConfigs.forEach(([savedSortField, savedSortOrder]) => { - sortConfig.push({ [savedSortField]: { order: savedSortOrder } }); - }); - const scriptFieldsConfig = indexPatternFields - .filter((f: IndexPatternField) => f.scripted) - .reduce((accum: any, curr: IndexPatternField) => { - return { - ...accum, - [curr.name]: { - script: { - source: curr.script, - lang: curr.lang, - }, - }, - }; - }, {}); - - if (indexPatternTimeField) { - if (docValueFields) { - docValueFields = [indexPatternTimeField].concat(docValueFields); - } else { - docValueFields = [indexPatternTimeField]; - } - } - - const searchRequest: SearchRequest = { - index: esIndex, - body: { - _source: { includes }, - docvalue_fields: docValueFields, - query: esQuery.buildEsQuery( - indexPatternSavedObject as IIndexPattern, - (searchSourceQuery as unknown) as Query, - (combinedFilter as unknown) as Filter, - esQueryConfig - ), - script_fields: scriptFieldsConfig, - sort: sortConfig, - }, - }; - - const config = reporting.getConfig(); - const elasticsearch = reporting.getElasticsearchService(); - const { callAsCurrentUser } = elasticsearch.legacy.client.asScoped(req); - const callCluster = (...params: [string, object]) => callAsCurrentUser(...params); - const uiSettings = await getUiSettings(uiConfig); - - const generateCsvParams: GenerateCsvParams = { - searchRequest, - callEndpoint: callCluster, - fields: includes, - formatsMap: new Map(), // there is no field formatting in this API; this is required for generateCsv - metaFields: [], - conflictedTypesFields: [], - cancellationToken: new CancellationToken(), - settings: { - ...uiSettings, - maxSizeBytes: config.get('csv', 'maxSizeBytes'), - scroll: config.get('csv', 'scroll'), - escapeFormulaValues: config.get('csv', 'escapeFormulaValues'), - timezone, - }, - }; - - const generateCsv = createGenerateCsv(logger); - - return { - type: 'CSV from Saved Search', - result: await generateCsv(generateCsvParams), - }; -} diff --git a/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/server/lib/get_csv_job.test.ts b/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/server/lib/get_csv_job.test.ts new file mode 100644 index 0000000000000..3271c6fdae24d --- /dev/null +++ b/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/server/lib/get_csv_job.test.ts @@ -0,0 +1,341 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { JobParamsPanelCsv, SearchPanel } from '../../types'; +import { getGenerateCsvParams } from './get_csv_job'; + +describe('Get CSV Job', () => { + let mockJobParams: JobParamsPanelCsv; + let mockSearchPanel: SearchPanel; + let mockSavedObjectsClient: any; + let mockUiSettingsClient: any; + beforeEach(() => { + mockJobParams = { isImmediate: true, savedObjectType: 'search', savedObjectId: '234-ididid' }; + mockSearchPanel = { + indexPatternSavedObjectId: '123-indexId', + attributes: { + title: 'my search', + sort: [], + kibanaSavedObjectMeta: { + searchSource: { query: { isSearchSourceQuery: true }, filter: [] }, + }, + uiState: 56, + }, + timerange: { timezone: 'PST', min: 0, max: 100 }, + }; + mockSavedObjectsClient = { + get: () => ({ + attributes: { fields: null, title: null, timeFieldName: null }, + }), + }; + mockUiSettingsClient = { + get: () => ({}), + }; + }); + + it('creates a data structure needed by generateCsv', async () => { + const result = await getGenerateCsvParams( + mockJobParams, + mockSearchPanel, + mockSavedObjectsClient, + mockUiSettingsClient + ); + expect(result).toMatchInlineSnapshot(` + Object { + "conflictedTypesFields": Array [], + "fields": Array [], + "indexPatternSavedObject": Object { + "attributes": Object { + "fields": null, + "timeFieldName": null, + "title": null, + }, + "fields": Array [], + "timeFieldName": null, + "title": null, + }, + "jobParams": Object { + "browserTimezone": "PST", + }, + "metaFields": Array [], + "searchRequest": Object { + "body": Object { + "_source": Object { + "includes": Array [], + }, + "docvalue_fields": undefined, + "query": Object { + "bool": Object { + "filter": Array [], + "must": Array [], + "must_not": Array [], + "should": Array [], + }, + }, + "script_fields": Object {}, + "sort": Array [], + }, + "index": null, + }, + } + `); + }); + + it('uses query and sort from the payload', async () => { + mockJobParams.post = { + state: { + query: ['this is the query'], + sort: ['this is the sort'], + }, + }; + const result = await getGenerateCsvParams( + mockJobParams, + mockSearchPanel, + mockSavedObjectsClient, + mockUiSettingsClient + ); + expect(result).toMatchInlineSnapshot(` + Object { + "conflictedTypesFields": Array [], + "fields": Array [], + "indexPatternSavedObject": Object { + "attributes": Object { + "fields": null, + "timeFieldName": null, + "title": null, + }, + "fields": Array [], + "timeFieldName": null, + "title": null, + }, + "jobParams": Object { + "browserTimezone": "PST", + }, + "metaFields": Array [], + "searchRequest": Object { + "body": Object { + "_source": Object { + "includes": Array [], + }, + "docvalue_fields": undefined, + "query": Object { + "bool": Object { + "filter": Array [ + Object { + "0": "this is the query", + }, + ], + "must": Array [], + "must_not": Array [], + "should": Array [], + }, + }, + "script_fields": Object {}, + "sort": Array [ + "this is the sort", + ], + }, + "index": null, + }, + } + `); + }); + + it('uses timerange timezone from the payload', async () => { + mockJobParams.post = { + timerange: { timezone: 'Africa/Timbuktu', min: 0, max: 9000 }, + }; + const result = await getGenerateCsvParams( + mockJobParams, + mockSearchPanel, + mockSavedObjectsClient, + mockUiSettingsClient + ); + expect(result).toMatchInlineSnapshot(` + Object { + "conflictedTypesFields": Array [], + "fields": Array [], + "indexPatternSavedObject": Object { + "attributes": Object { + "fields": null, + "timeFieldName": null, + "title": null, + }, + "fields": Array [], + "timeFieldName": null, + "title": null, + }, + "jobParams": Object { + "browserTimezone": "Africa/Timbuktu", + }, + "metaFields": Array [], + "searchRequest": Object { + "body": Object { + "_source": Object { + "includes": Array [], + }, + "docvalue_fields": undefined, + "query": Object { + "bool": Object { + "filter": Array [], + "must": Array [], + "must_not": Array [], + "should": Array [], + }, + }, + "script_fields": Object {}, + "sort": Array [], + }, + "index": null, + }, + } + `); + }); + + it('uses timerange min and max (numeric) when index pattern has timefieldName', async () => { + mockJobParams.post = { + timerange: { timezone: 'Africa/Timbuktu', min: 0, max: 900000000 }, + }; + mockSavedObjectsClient = { + get: () => ({ + attributes: { fields: null, title: 'test search', timeFieldName: '@test_time' }, + }), + }; + const result = await getGenerateCsvParams( + mockJobParams, + mockSearchPanel, + mockSavedObjectsClient, + mockUiSettingsClient + ); + expect(result).toMatchInlineSnapshot(` + Object { + "conflictedTypesFields": Array [], + "fields": Array [ + "@test_time", + ], + "indexPatternSavedObject": Object { + "attributes": Object { + "fields": null, + "timeFieldName": "@test_time", + "title": "test search", + }, + "fields": Array [], + "timeFieldName": "@test_time", + "title": "test search", + }, + "jobParams": Object { + "browserTimezone": "Africa/Timbuktu", + }, + "metaFields": Array [], + "searchRequest": Object { + "body": Object { + "_source": Object { + "includes": Array [ + "@test_time", + ], + }, + "docvalue_fields": undefined, + "query": Object { + "bool": Object { + "filter": Array [ + Object { + "range": Object { + "@test_time": Object { + "format": "strict_date_time", + "gte": "1970-01-01T00:00:00Z", + "lte": "1970-01-11T10:00:00Z", + }, + }, + }, + ], + "must": Array [], + "must_not": Array [], + "should": Array [], + }, + }, + "script_fields": Object {}, + "sort": Array [], + }, + "index": "test search", + }, + } + `); + }); + + it('uses timerange min and max (string) when index pattern has timefieldName', async () => { + mockJobParams.post = { + timerange: { + timezone: 'Africa/Timbuktu', + min: '1980-01-01T00:00:00Z', + max: '1990-01-01T00:00:00Z', + }, + }; + mockSavedObjectsClient = { + get: () => ({ + attributes: { fields: null, title: 'test search', timeFieldName: '@test_time' }, + }), + }; + const result = await getGenerateCsvParams( + mockJobParams, + mockSearchPanel, + mockSavedObjectsClient, + mockUiSettingsClient + ); + expect(result).toMatchInlineSnapshot(` + Object { + "conflictedTypesFields": Array [], + "fields": Array [ + "@test_time", + ], + "indexPatternSavedObject": Object { + "attributes": Object { + "fields": null, + "timeFieldName": "@test_time", + "title": "test search", + }, + "fields": Array [], + "timeFieldName": "@test_time", + "title": "test search", + }, + "jobParams": Object { + "browserTimezone": "Africa/Timbuktu", + }, + "metaFields": Array [], + "searchRequest": Object { + "body": Object { + "_source": Object { + "includes": Array [ + "@test_time", + ], + }, + "docvalue_fields": undefined, + "query": Object { + "bool": Object { + "filter": Array [ + Object { + "range": Object { + "@test_time": Object { + "format": "strict_date_time", + "gte": "1980-01-01T00:00:00Z", + "lte": "1990-01-01T00:00:00Z", + }, + }, + }, + ], + "must": Array [], + "must_not": Array [], + "should": Array [], + }, + }, + "script_fields": Object {}, + "sort": Array [], + }, + "index": "test search", + }, + } + `); + }); +}); diff --git a/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/server/lib/get_csv_job.ts b/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/server/lib/get_csv_job.ts new file mode 100644 index 0000000000000..5f1954b80e1bc --- /dev/null +++ b/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/server/lib/get_csv_job.ts @@ -0,0 +1,146 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { IUiSettingsClient, SavedObjectsClientContract } from 'kibana/server'; +import { EsQueryConfig } from 'src/plugins/data/server'; +import { + esQuery, + Filter, + IIndexPattern, + Query, +} from '../../../../../../../../src/plugins/data/server'; +import { + DocValueFields, + IndexPatternField, + JobParamsPanelCsv, + QueryFilter, + SavedSearchObjectAttributes, + SearchPanel, + SearchSource, +} from '../../types'; +import { getDataSource } from './get_data_source'; +import { getFilters } from './get_filters'; +import { GenerateCsvParams } from '../../../csv/server/generate_csv'; + +export const getEsQueryConfig = async (config: IUiSettingsClient) => { + const configs = await Promise.all([ + config.get('query:allowLeadingWildcards'), + config.get('query:queryString:options'), + config.get('courier:ignoreFilterIfFieldNotInIndex'), + ]); + const [allowLeadingWildcards, queryStringOptions, ignoreFilterIfFieldNotInIndex] = configs; + return { + allowLeadingWildcards, + queryStringOptions, + ignoreFilterIfFieldNotInIndex, + } as EsQueryConfig; +}; + +/* + * Create a CSV Job object for CSV From SavedObject to use as a job parameter + * for generateCsv + */ +export const getGenerateCsvParams = async ( + jobParams: JobParamsPanelCsv, + panel: SearchPanel, + savedObjectsClient: SavedObjectsClientContract, + uiConfig: IUiSettingsClient +): Promise => { + let timerange; + if (jobParams.post?.timerange) { + timerange = jobParams.post?.timerange; + } else { + timerange = panel.timerange; + } + const { indexPatternSavedObjectId } = panel; + const savedSearchObjectAttr = panel.attributes as SavedSearchObjectAttributes; + const { indexPatternSavedObject } = await getDataSource( + savedObjectsClient, + indexPatternSavedObjectId + ); + const esQueryConfig = await getEsQueryConfig(uiConfig); + + const { + kibanaSavedObjectMeta: { + searchSource: { + filter: [searchSourceFilter], + query: searchSourceQuery, + }, + }, + } = savedSearchObjectAttr as { kibanaSavedObjectMeta: { searchSource: SearchSource } }; + + const { + timeFieldName: indexPatternTimeField, + title: esIndex, + fields: indexPatternFields, + } = indexPatternSavedObject; + + let payloadQuery: QueryFilter | undefined; + let payloadSort: any[] = []; + let docValueFields: DocValueFields[] | undefined; + if (jobParams.post && jobParams.post.state) { + ({ + post: { + state: { query: payloadQuery, sort: payloadSort = [], docvalue_fields: docValueFields }, + }, + } = jobParams); + } + const { includes, combinedFilter } = getFilters( + indexPatternSavedObjectId, + indexPatternTimeField, + timerange, + savedSearchObjectAttr, + searchSourceFilter, + payloadQuery + ); + + const savedSortConfigs = savedSearchObjectAttr.sort; + const sortConfig = [...payloadSort]; + savedSortConfigs.forEach(([savedSortField, savedSortOrder]) => { + sortConfig.push({ [savedSortField]: { order: savedSortOrder } }); + }); + + const scriptFieldsConfig = + indexPatternFields && + indexPatternFields + .filter((f: IndexPatternField) => f.scripted) + .reduce((accum: any, curr: IndexPatternField) => { + return { + ...accum, + [curr.name]: { + script: { + source: curr.script, + lang: curr.lang, + }, + }, + }; + }, {}); + + const searchRequest = { + index: esIndex, + body: { + _source: { includes }, + docvalue_fields: docValueFields, + query: esQuery.buildEsQuery( + indexPatternSavedObject as IIndexPattern, + (searchSourceQuery as unknown) as Query, + (combinedFilter as unknown) as Filter, + esQueryConfig + ), + script_fields: scriptFieldsConfig, + sort: sortConfig, + }, + }; + + return { + jobParams: { browserTimezone: timerange.timezone }, + indexPatternSavedObject, + searchRequest, + fields: includes, + metaFields: [], + conflictedTypesFields: [], + }; +}; diff --git a/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/server/lib/get_data_source.ts b/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/server/lib/get_data_source.ts index b7e560853e89e..bf915696c8974 100644 --- a/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/server/lib/get_data_source.ts +++ b/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/server/lib/get_data_source.ts @@ -4,12 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import { - IndexPatternSavedObject, - SavedObjectReference, - SavedSearchObjectAttributesJSON, - SearchSource, -} from '../../types'; +import { IndexPatternSavedObject } from '../../../csv/types'; +import { SavedObjectReference, SavedSearchObjectAttributesJSON, SearchSource } from '../../types'; export async function getDataSource( savedObjectsClient: any, diff --git a/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/server/lib/get_fake_request.ts b/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/server/lib/get_fake_request.ts new file mode 100644 index 0000000000000..09c58806de120 --- /dev/null +++ b/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/server/lib/get_fake_request.ts @@ -0,0 +1,51 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; +import { KibanaRequest } from 'kibana/server'; +import { cryptoFactory, LevelLogger } from '../../../../lib'; +import { ScheduledTaskParams } from '../../../../types'; +import { JobParamsPanelCsv } from '../../types'; + +export const getFakeRequest = async ( + job: ScheduledTaskParams, + encryptionKey: string, + jobLogger: LevelLogger +) => { + // TODO remove this block: csv from savedobject download is always "sync" + const crypto = cryptoFactory(encryptionKey); + let decryptedHeaders: KibanaRequest['headers']; + const serializedEncryptedHeaders = job.headers; + try { + if (typeof serializedEncryptedHeaders !== 'string') { + throw new Error( + i18n.translate( + 'xpack.reporting.exportTypes.csv_from_savedobject.executeJob.missingJobHeadersErrorMessage', + { + defaultMessage: 'Job headers are missing', + } + ) + ); + } + decryptedHeaders = (await crypto.decrypt( + serializedEncryptedHeaders + )) as KibanaRequest['headers']; + } catch (err) { + jobLogger.error(err); + throw new Error( + i18n.translate( + 'xpack.reporting.exportTypes.csv_from_savedobject.executeJob.failedToDecryptReportJobDataErrorMessage', + { + defaultMessage: + 'Failed to decrypt report job data. Please ensure that {encryptionKey} is set and re-generate this report. {err}', + values: { encryptionKey: 'xpack.reporting.encryptionKey', err }, + } + ) + ); + } + + return { headers: decryptedHeaders } as KibanaRequest; +}; diff --git a/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/server/lib/get_filters.ts b/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/server/lib/get_filters.ts index 26631548cc797..1258b03d3051b 100644 --- a/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/server/lib/get_filters.ts +++ b/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/server/lib/get_filters.ts @@ -22,7 +22,7 @@ export function getFilters( let timezone: string | null; if (indexPatternTimeField) { - if (!timerange || !timerange.min || !timerange.max) { + if (!timerange || timerange.min == null || timerange.max == null) { throw badRequest( `Time range params are required for index pattern [${indexPatternId}], using time field [${indexPatternTimeField}]` ); diff --git a/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/server/lib/index.ts b/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/server/lib/index.ts deleted file mode 100644 index 90f90ba168a2f..0000000000000 --- a/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/server/lib/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -export { createGenerateCsv } from './generate_csv'; diff --git a/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/types.d.ts b/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/types.d.ts index c182fe49a31f6..0d19a24114f06 100644 --- a/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/types.d.ts +++ b/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/types.d.ts @@ -95,20 +95,6 @@ export interface SavedObject { references: SavedObjectReference[]; } -/* This object is passed to different helpers in different parts of the code - - packages/kbn-es-query/src/es_query/build_es_query - The structure has redundant parts and json-parsed / json-unparsed versions of the same data - */ -export interface IndexPatternSavedObject { - title: string; - timeFieldName: string; - fields: any[]; - attributes: { - fieldFormatMap: string; - fields: string; - }; -} - export interface VisPanel { indexPatternSavedObjectId?: string; savedSearchObjectId?: string; @@ -122,6 +108,11 @@ export interface SearchPanel { timerange: TimeRangeParams; } +export interface DocValueFields { + field: string; + format: string; +} + export interface SearchSourceQuery { isSearchSourceQuery: boolean; } diff --git a/x-pack/plugins/reporting/server/routes/generate_from_savedobject_immediate.ts b/x-pack/plugins/reporting/server/routes/generate_from_savedobject_immediate.ts index 97441bba70984..773295deea954 100644 --- a/x-pack/plugins/reporting/server/routes/generate_from_savedobject_immediate.ts +++ b/x-pack/plugins/reporting/server/routes/generate_from_savedobject_immediate.ts @@ -9,10 +9,10 @@ import { ReportingCore } from '../'; import { API_BASE_GENERATE_V1 } from '../../common/constants'; import { scheduleTaskFnFactory } from '../export_types/csv_from_savedobject/server/create_job'; import { runTaskFnFactory } from '../export_types/csv_from_savedobject/server/execute_job'; -import { getJobParamsFromRequest } from '../export_types/csv_from_savedobject/server/lib/get_job_params_from_request'; import { LevelLogger as Logger } from '../lib'; import { TaskRunResult } from '../types'; import { authorizedUserPreRoutingFactory } from './lib/authorized_user_pre_routing'; +import { getJobParamsFromRequest } from './lib/get_job_params_from_request'; import { HandlerErrorFunction } from './types'; /* diff --git a/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/server/lib/get_job_params_from_request.ts b/x-pack/plugins/reporting/server/routes/lib/get_job_params_from_request.ts similarity index 87% rename from x-pack/plugins/reporting/server/export_types/csv_from_savedobject/server/lib/get_job_params_from_request.ts rename to x-pack/plugins/reporting/server/routes/lib/get_job_params_from_request.ts index 5aed02c10b961..e5c1f38241349 100644 --- a/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/server/lib/get_job_params_from_request.ts +++ b/x-pack/plugins/reporting/server/routes/lib/get_job_params_from_request.ts @@ -5,7 +5,10 @@ */ import { KibanaRequest } from 'src/core/server'; -import { JobParamsPanelCsv, JobParamsPostPayloadPanelCsv } from '../../types'; +import { + JobParamsPanelCsv, + JobParamsPostPayloadPanelCsv, +} from '../../export_types/csv_from_savedobject/types'; export function getJobParamsFromRequest( request: KibanaRequest, diff --git a/x-pack/plugins/reporting/server/types.ts b/x-pack/plugins/reporting/server/types.ts index 96eef81672610..667c1546c6147 100644 --- a/x-pack/plugins/reporting/server/types.ts +++ b/x-pack/plugins/reporting/server/types.ts @@ -50,19 +50,19 @@ export type ReportingRequestPayload = GenerateExportTypePayload | JobParamPostPa export interface TimeRangeParams { timezone: string; - min: Date | string | number | null; - max: Date | string | number | null; + min?: Date | string | number | null; + max?: Date | string | number | null; } export interface JobParamPostPayload { - timerange: TimeRangeParams; + timerange?: TimeRangeParams; } export interface ScheduledTaskParams { headers?: string; // serialized encrypted headers jobParams: JobParamsType; title: string; - type: string | null; + type: string; } export interface JobSource { @@ -80,6 +80,7 @@ export interface TaskRunResult { content_type: string; content: string | null; size: number; + csv_contains_formulas?: boolean; max_size_reached?: boolean; warnings?: string[]; } diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 4050982a6ef99..ef95f5f9c09d8 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -12398,7 +12398,8 @@ "xpack.reporting.errorButton.unableToGenerateReportTitle": "レポートを生成できません", "xpack.reporting.exportTypes.common.failedToDecryptReportJobDataErrorMessage": "レポートジョブデータの解読に失敗しました。{encryptionKey}が設定されていることを確認してこのレポートを再生成してください。{err}", "xpack.reporting.exportTypes.common.missingJobHeadersErrorMessage": "ジョブヘッダーがありません", - "xpack.reporting.exportTypes.csv_from_savedobject.executeJob.failedToAccessPanel": "ジョブ実行のパネルメタデータにアクセスできませんでした", + "xpack.reporting.exportTypes.csv_from_savedobject.executeJob.failedToDecryptReportJobDataErrorMessage": "レポートジョブデータの解読に失敗しました{encryptionKey} が設定されていることを確認してこのレポートを再生成してください。{err}", + "xpack.reporting.exportTypes.csv_from_savedobject.executeJob.missingJobHeadersErrorMessage": "ジョブヘッダーがありません", "xpack.reporting.exportTypes.csv.executeJob.dateFormateSetting": "Kibana の高度な設定「{dateFormatTimezone}」が「ブラウザー」に設定されていますあいまいさを避けるために日付は UTC 形式に変換されます。", "xpack.reporting.exportTypes.csv.executeJob.failedToDecryptReportJobDataErrorMessage": "レポートジョブデータの解読に失敗しました{encryptionKey} が設定されていることを確認してこのレポートを再生成してください。{err}", "xpack.reporting.exportTypes.csv.executeJob.missingJobHeadersErrorMessage": "ジョブヘッダーがありません", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 7fc142a7684a1..108fb4ba32046 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -12404,7 +12404,8 @@ "xpack.reporting.errorButton.unableToGenerateReportTitle": "无法生成报告", "xpack.reporting.exportTypes.common.failedToDecryptReportJobDataErrorMessage": "无法解密报告作业数据。请确保已设置 {encryptionKey},然后重新生成此报告。{err}", "xpack.reporting.exportTypes.common.missingJobHeadersErrorMessage": "作业标头缺失", - "xpack.reporting.exportTypes.csv_from_savedobject.executeJob.failedToAccessPanel": "无法访问用于作业执行的面板元数据", + "xpack.reporting.exportTypes.csv_from_savedobject.executeJob.failedToDecryptReportJobDataErrorMessage": "无法解密报告作业数据。请确保已设置 {encryptionKey},然后重新生成此报告。{err}", + "xpack.reporting.exportTypes.csv_from_savedobject.executeJob.missingJobHeadersErrorMessage": "作业标头缺失", "xpack.reporting.exportTypes.csv.executeJob.dateFormateSetting": "Kibana 高级设置“{dateFormatTimezone}”已设置为“浏览器”。日期将格式化为 UTC 以避免混淆。", "xpack.reporting.exportTypes.csv.executeJob.failedToDecryptReportJobDataErrorMessage": "无法解密报告作业数据。请确保已设置 {encryptionKey},然后重新生成此报告。{err}", "xpack.reporting.exportTypes.csv.executeJob.missingJobHeadersErrorMessage": "作业标头缺失", diff --git a/x-pack/test/functional/es_archives/reporting/multi_index/data.json.gz b/x-pack/test/functional/es_archives/reporting/multi_index/data.json.gz new file mode 100644 index 0000000000000000000000000000000000000000..bb0e05d632f54f278a3427176104a8e05575097d GIT binary patch literal 619 zcmV-x0+jt9iwFpk>GNI!17u-zVJ>QOZ*Bm!l}&HjKoExS{0hXmNX7{SI2BqFEJ3N# z^aH9MCiYlf{ISUP#$KWP_s+UBVJOg4bq)ya>({g1XJ&S`jb^iz>kYPs&6X$K)*B-{ zK%|Var3Ed8XPz!^zm0oSXB-56d)dF74?5S2%5EHqhov#)nB`g9vO2$?WKyN>b1YKc zdXQJ!*_Lg!tzO%{yt6Nc-R{sDtah)F4U#+~m-QsLQYCq+&71G%&%Oj=6YcwMO^Oqs z#sHoyBrWd+fG%~}WM4?OE(Q6t)(SIbbW)O4CLv%S zBytU;JvJKKe@IPGdulp^zozDD(CZw_&Ukt*J0Efo8`Kgsuf60~;WA2JVnCPp`OF!R(=?)pd6`!CTv7wY@{r0`9vv+q|oW1NE@AK{+~e;-Uv7e F002xHGbR84 literal 0 HcmV?d00001 diff --git a/x-pack/test/functional/es_archives/reporting/multi_index/mappings.json b/x-pack/test/functional/es_archives/reporting/multi_index/mappings.json new file mode 100644 index 0000000000000..f28ffce8ce3ce --- /dev/null +++ b/x-pack/test/functional/es_archives/reporting/multi_index/mappings.json @@ -0,0 +1,92 @@ +{ + "type": "index", + "value": { + "aliases": { + }, + "index": "tests-001", + "mappings": { + "properties": { + "@date": { + "type": "date" + }, + "ants": { + "type": "integer" + }, + "country": { + "type": "keyword" + }, + "name": { + "type": "keyword" + } + } + }, + "settings": { + "index": { + "number_of_replicas": "0", + "number_of_shards": "1" + } + } + } +} + +{ + "type": "index", + "value": { + "aliases": { + }, + "index": "tests-002", + "mappings": { + "properties": { + "@date": { + "type": "date" + }, + "ants": { + "type": "integer" + }, + "country": { + "type": "keyword" + }, + "name": { + "type": "keyword" + } + } + }, + "settings": { + "index": { + "number_of_replicas": "0", + "number_of_shards": "1" + } + } + } +} + +{ + "type": "index", + "value": { + "aliases": { + }, + "index": "tests-003", + "mappings": { + "properties": { + "@date": { + "type": "date" + }, + "ants": { + "type": "integer" + }, + "country": { + "type": "keyword" + }, + "name": { + "type": "keyword" + } + } + }, + "settings": { + "index": { + "number_of_replicas": "0", + "number_of_shards": "1" + } + } + } +} diff --git a/x-pack/test/functional/es_archives/reporting/multi_index_kibana/data.json.gz b/x-pack/test/functional/es_archives/reporting/multi_index_kibana/data.json.gz new file mode 100644 index 0000000000000000000000000000000000000000..a6330916d62f77c02dc978c12b512d0b21aa2a0f GIT binary patch literal 455 zcmV;&0XY62iwFp`>GNI!17u-zVJ>QOZ*Bn1mBCKqFc60CeTv9O)K*bpi^z!s;>Zbc zpsmo8I7G4ke?0zVCo}s|k_f-sqR0}VtQ2Dw-l3>i z*@sD(YQ?TL3O^@X@E*xz4vhn^t$|_!g>Hs%dD6u4qUoDngMn6ewj%kJIq79RF@m+x zSSZI?7W<_zP~uW#OL42fhtYT$xubMc&^-pt1#!`;s~}5T86U(njGZLC^{B#h1BFAD z5JJ(eAt>eZ$>{B{c|WJ@|!`tELmg0`GN+_gv&30xsA2SlYW0 zzK9OD7>DjcG~S^N5~a>5cAqCC7hc^S(r+)~dODw`-?I>IkkClvezR!Q)zNM{WH;T> xuC@%WUchtEES;s3bUv9~J{sP`@7La-e005p0;OPJW literal 0 HcmV?d00001 diff --git a/x-pack/test/functional/es_archives/reporting/multi_index_kibana/mappings.json b/x-pack/test/functional/es_archives/reporting/multi_index_kibana/mappings.json new file mode 100644 index 0000000000000..97b9599bc86cc --- /dev/null +++ b/x-pack/test/functional/es_archives/reporting/multi_index_kibana/mappings.json @@ -0,0 +1,2073 @@ +{ + "type": "index", + "value": { + "aliases": { + ".kibana": { + } + }, + "index": ".kibana_1", + "mappings": { + "_meta": { + "migrationMappingPropertyHashes": { + "action": "6e96ac5e648f57523879661ea72525b7", + "action_task_params": "a9d49f184ee89641044be0ca2950fa3a", + "alert": "7b44fba6773e37c806ce290ea9b7024e", + "apm-indices": "9bb9b2bf1fa636ed8619cbab5ce6a1dd", + "apm-telemetry": "3525d7c22c42bc80f5e6e9cb3f2b26a2", + "application_usage_totals": "c897e4310c5f24b07caaff3db53ae2c1", + "application_usage_transactional": "965839e75f809fefe04f92dc4d99722a", + "canvas-element": "7390014e1091044523666d97247392fc", + "canvas-workpad": "b0a1706d356228dbdcb4a17e6b9eb231", + "cases": "32aa96a6d3855ddda53010ae2048ac22", + "cases-comments": "c2061fb929f585df57425102fa928b4b", + "cases-configure": "42711cbb311976c0687853f4c1354572", + "cases-user-actions": "32277330ec6b721abe3b846cfd939a71", + "config": "ae24d22d5986d04124cc6568f771066f", + "dashboard": "d00f614b29a80360e1190193fd333bab", + "file-upload-telemetry": "0ed4d3e1983d1217a30982630897092e", + "graph-workspace": "cd7ba1330e6682e9cc00b78850874be1", + "index-pattern": "66eccb05066c5a89924f48a9e9736499", + "kql-telemetry": "d12a98a6f19a2d273696597547e064ee", + "lens": "d33c68a69ff1e78c9888dedd2164ac22", + "lens-ui-telemetry": "509bfa5978586998e05f9e303c07a327", + "map": "4a05b35c3a3a58fbc72dd0202dc3487f", + "maps": "bfd39d88aadadb4be597ea984d433dbe", + "migrationVersion": "4a1746014a75ade3a714e1db5763276f", + "ml-telemetry": "257fd1d4b4fdbb9cb4b8a3b27da201e9", + "namespace": "2f4316de49999235636386fe51dc06c1", + "namespaces": "2f4316de49999235636386fe51dc06c1", + "query": "11aaeb7f5f7fa5bb43f25e18ce26e7d9", + "references": "7997cf5a56cc02bdc9c93361bde732b0", + "sample-data-telemetry": "7d3cfeb915303c9641c59681967ffeb4", + "search": "181661168bbadd1eff5902361e2a0d5c", + "telemetry": "36a616f7026dfa617d6655df850fe16d", + "timelion-sheet": "9a2a2748877c7a7b582fef201ab1d4cf", + "tsvb-validation-telemetry": "3a37ef6c8700ae6fc97d5c7da00e9215", + "type": "2f4316de49999235636386fe51dc06c1", + "ui-metric": "0d409297dc5ebe1e3a1da691c6ee32e3", + "updated_at": "00da57df13e94e9d98437d13ace4bfe0", + "upgrade-assistant-reindex-operation": "296a89039fc4260292be36b1b005d8f2", + "upgrade-assistant-telemetry": "56702cec857e0a9dacfb696655b4ff7b", + "uptime-dynamic-settings": "fcdb453a30092f022f2642db29523d80", + "url": "b675c3be8d76ecf029294d51dc7ec65d", + "visualization": "52d7a13ad68a150c4525b292d23e12cc" + } + }, + "dynamic": "strict", + "properties": { + "action": { + "properties": { + "actionTypeId": { + "type": "keyword" + }, + "config": { + "enabled": false, + "type": "object" + }, + "name": { + "fields": { + "keyword": { + "type": "keyword" + } + }, + "type": "text" + }, + "secrets": { + "type": "binary" + } + } + }, + "action_task_params": { + "properties": { + "actionId": { + "type": "keyword" + }, + "apiKey": { + "type": "binary" + }, + "params": { + "enabled": false, + "type": "object" + } + } + }, + "alert": { + "properties": { + "actions": { + "properties": { + "actionRef": { + "type": "keyword" + }, + "actionTypeId": { + "type": "keyword" + }, + "group": { + "type": "keyword" + }, + "params": { + "enabled": false, + "type": "object" + } + }, + "type": "nested" + }, + "alertTypeId": { + "type": "keyword" + }, + "apiKey": { + "type": "binary" + }, + "apiKeyOwner": { + "type": "keyword" + }, + "consumer": { + "type": "keyword" + }, + "createdAt": { + "type": "date" + }, + "createdBy": { + "type": "keyword" + }, + "enabled": { + "type": "boolean" + }, + "muteAll": { + "type": "boolean" + }, + "mutedInstanceIds": { + "type": "keyword" + }, + "name": { + "fields": { + "keyword": { + "type": "keyword" + } + }, + "type": "text" + }, + "params": { + "enabled": false, + "type": "object" + }, + "schedule": { + "properties": { + "interval": { + "type": "keyword" + } + } + }, + "scheduledTaskId": { + "type": "keyword" + }, + "tags": { + "type": "keyword" + }, + "throttle": { + "type": "keyword" + }, + "updatedBy": { + "type": "keyword" + } + } + }, + "apm-indices": { + "properties": { + "apm_oss": { + "properties": { + "errorIndices": { + "type": "keyword" + }, + "metricsIndices": { + "type": "keyword" + }, + "onboardingIndices": { + "type": "keyword" + }, + "sourcemapIndices": { + "type": "keyword" + }, + "spanIndices": { + "type": "keyword" + }, + "transactionIndices": { + "type": "keyword" + } + } + } + } + }, + "apm-telemetry": { + "properties": { + "agents": { + "properties": { + "dotnet": { + "properties": { + "agent": { + "properties": { + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "service": { + "properties": { + "framework": { + "properties": { + "composite": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "language": { + "properties": { + "composite": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "runtime": { + "properties": { + "composite": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + } + } + }, + "go": { + "properties": { + "agent": { + "properties": { + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "service": { + "properties": { + "framework": { + "properties": { + "composite": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "language": { + "properties": { + "composite": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "runtime": { + "properties": { + "composite": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + } + } + }, + "java": { + "properties": { + "agent": { + "properties": { + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "service": { + "properties": { + "framework": { + "properties": { + "composite": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "language": { + "properties": { + "composite": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "runtime": { + "properties": { + "composite": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + } + } + }, + "js-base": { + "properties": { + "agent": { + "properties": { + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "service": { + "properties": { + "framework": { + "properties": { + "composite": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "language": { + "properties": { + "composite": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "runtime": { + "properties": { + "composite": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + } + } + }, + "nodejs": { + "properties": { + "agent": { + "properties": { + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "service": { + "properties": { + "framework": { + "properties": { + "composite": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "language": { + "properties": { + "composite": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "runtime": { + "properties": { + "composite": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + } + } + }, + "python": { + "properties": { + "agent": { + "properties": { + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "service": { + "properties": { + "framework": { + "properties": { + "composite": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "language": { + "properties": { + "composite": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "runtime": { + "properties": { + "composite": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + } + } + }, + "ruby": { + "properties": { + "agent": { + "properties": { + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "service": { + "properties": { + "framework": { + "properties": { + "composite": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "language": { + "properties": { + "composite": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "runtime": { + "properties": { + "composite": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + } + } + }, + "rum-js": { + "properties": { + "agent": { + "properties": { + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "service": { + "properties": { + "framework": { + "properties": { + "composite": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "language": { + "properties": { + "composite": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "runtime": { + "properties": { + "composite": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + } + } + } + } + }, + "cardinality": { + "properties": { + "transaction": { + "properties": { + "name": { + "properties": { + "all_agents": { + "properties": { + "1d": { + "type": "long" + } + } + }, + "rum": { + "properties": { + "1d": { + "type": "long" + } + } + } + } + } + } + }, + "user_agent": { + "properties": { + "original": { + "properties": { + "all_agents": { + "properties": { + "1d": { + "type": "long" + } + } + }, + "rum": { + "properties": { + "1d": { + "type": "long" + } + } + } + } + } + } + } + } + }, + "counts": { + "properties": { + "agent_configuration": { + "properties": { + "all": { + "type": "long" + } + } + }, + "error": { + "properties": { + "1d": { + "type": "long" + }, + "all": { + "type": "long" + } + } + }, + "max_error_groups_per_service": { + "properties": { + "1d": { + "type": "long" + } + } + }, + "max_transaction_groups_per_service": { + "properties": { + "1d": { + "type": "long" + } + } + }, + "metric": { + "properties": { + "1d": { + "type": "long" + }, + "all": { + "type": "long" + } + } + }, + "onboarding": { + "properties": { + "1d": { + "type": "long" + }, + "all": { + "type": "long" + } + } + }, + "services": { + "properties": { + "1d": { + "type": "long" + } + } + }, + "sourcemap": { + "properties": { + "1d": { + "type": "long" + }, + "all": { + "type": "long" + } + } + }, + "span": { + "properties": { + "1d": { + "type": "long" + }, + "all": { + "type": "long" + } + } + }, + "traces": { + "properties": { + "1d": { + "type": "long" + } + } + }, + "transaction": { + "properties": { + "1d": { + "type": "long" + }, + "all": { + "type": "long" + } + } + } + } + }, + "has_any_services": { + "type": "boolean" + }, + "indices": { + "properties": { + "all": { + "properties": { + "total": { + "properties": { + "docs": { + "properties": { + "count": { + "type": "long" + } + } + }, + "store": { + "properties": { + "size_in_bytes": { + "type": "long" + } + } + } + } + } + } + }, + "shards": { + "properties": { + "total": { + "type": "long" + } + } + } + } + }, + "integrations": { + "properties": { + "ml": { + "properties": { + "all_jobs_count": { + "type": "long" + } + } + } + } + }, + "retainment": { + "properties": { + "error": { + "properties": { + "ms": { + "type": "long" + } + } + }, + "metric": { + "properties": { + "ms": { + "type": "long" + } + } + }, + "onboarding": { + "properties": { + "ms": { + "type": "long" + } + } + }, + "span": { + "properties": { + "ms": { + "type": "long" + } + } + }, + "transaction": { + "properties": { + "ms": { + "type": "long" + } + } + } + } + }, + "services_per_agent": { + "properties": { + "dotnet": { + "null_value": 0, + "type": "long" + }, + "go": { + "null_value": 0, + "type": "long" + }, + "java": { + "null_value": 0, + "type": "long" + }, + "js-base": { + "null_value": 0, + "type": "long" + }, + "nodejs": { + "null_value": 0, + "type": "long" + }, + "python": { + "null_value": 0, + "type": "long" + }, + "ruby": { + "null_value": 0, + "type": "long" + }, + "rum-js": { + "null_value": 0, + "type": "long" + } + } + }, + "tasks": { + "properties": { + "agent_configuration": { + "properties": { + "took": { + "properties": { + "ms": { + "type": "long" + } + } + } + } + }, + "agents": { + "properties": { + "took": { + "properties": { + "ms": { + "type": "long" + } + } + } + } + }, + "cardinality": { + "properties": { + "took": { + "properties": { + "ms": { + "type": "long" + } + } + } + } + }, + "groupings": { + "properties": { + "took": { + "properties": { + "ms": { + "type": "long" + } + } + } + } + }, + "indices_stats": { + "properties": { + "took": { + "properties": { + "ms": { + "type": "long" + } + } + } + } + }, + "integrations": { + "properties": { + "took": { + "properties": { + "ms": { + "type": "long" + } + } + } + } + }, + "processor_events": { + "properties": { + "took": { + "properties": { + "ms": { + "type": "long" + } + } + } + } + }, + "services": { + "properties": { + "took": { + "properties": { + "ms": { + "type": "long" + } + } + } + } + }, + "versions": { + "properties": { + "took": { + "properties": { + "ms": { + "type": "long" + } + } + } + } + } + } + }, + "version": { + "properties": { + "apm_server": { + "properties": { + "major": { + "type": "long" + }, + "minor": { + "type": "long" + }, + "patch": { + "type": "long" + } + } + } + } + } + } + }, + "application_usage_totals": { + "properties": { + "appId": { + "type": "keyword" + }, + "minutesOnScreen": { + "type": "float" + }, + "numberOfClicks": { + "type": "long" + } + } + }, + "application_usage_transactional": { + "properties": { + "appId": { + "type": "keyword" + }, + "minutesOnScreen": { + "type": "float" + }, + "numberOfClicks": { + "type": "long" + }, + "timestamp": { + "type": "date" + } + } + }, + "canvas-element": { + "dynamic": "false", + "properties": { + "@created": { + "type": "date" + }, + "@timestamp": { + "type": "date" + }, + "content": { + "type": "text" + }, + "help": { + "type": "text" + }, + "image": { + "type": "text" + }, + "name": { + "fields": { + "keyword": { + "type": "keyword" + } + }, + "type": "text" + } + } + }, + "canvas-workpad": { + "dynamic": "false", + "properties": { + "@created": { + "type": "date" + }, + "@timestamp": { + "type": "date" + }, + "name": { + "fields": { + "keyword": { + "type": "keyword" + } + }, + "type": "text" + } + } + }, + "cases": { + "properties": { + "closed_at": { + "type": "date" + }, + "closed_by": { + "properties": { + "email": { + "type": "keyword" + }, + "full_name": { + "type": "keyword" + }, + "username": { + "type": "keyword" + } + } + }, + "connector_id": { + "type": "keyword" + }, + "created_at": { + "type": "date" + }, + "created_by": { + "properties": { + "email": { + "type": "keyword" + }, + "full_name": { + "type": "keyword" + }, + "username": { + "type": "keyword" + } + } + }, + "description": { + "type": "text" + }, + "external_service": { + "properties": { + "connector_id": { + "type": "keyword" + }, + "connector_name": { + "type": "keyword" + }, + "external_id": { + "type": "keyword" + }, + "external_title": { + "type": "text" + }, + "external_url": { + "type": "text" + }, + "pushed_at": { + "type": "date" + }, + "pushed_by": { + "properties": { + "email": { + "type": "keyword" + }, + "full_name": { + "type": "keyword" + }, + "username": { + "type": "keyword" + } + } + } + } + }, + "status": { + "type": "keyword" + }, + "tags": { + "type": "keyword" + }, + "title": { + "type": "keyword" + }, + "updated_at": { + "type": "date" + }, + "updated_by": { + "properties": { + "email": { + "type": "keyword" + }, + "full_name": { + "type": "keyword" + }, + "username": { + "type": "keyword" + } + } + } + } + }, + "cases-comments": { + "properties": { + "comment": { + "type": "text" + }, + "created_at": { + "type": "date" + }, + "created_by": { + "properties": { + "email": { + "type": "keyword" + }, + "full_name": { + "type": "keyword" + }, + "username": { + "type": "keyword" + } + } + }, + "pushed_at": { + "type": "date" + }, + "pushed_by": { + "properties": { + "email": { + "type": "keyword" + }, + "full_name": { + "type": "keyword" + }, + "username": { + "type": "keyword" + } + } + }, + "updated_at": { + "type": "date" + }, + "updated_by": { + "properties": { + "email": { + "type": "keyword" + }, + "full_name": { + "type": "keyword" + }, + "username": { + "type": "keyword" + } + } + } + } + }, + "cases-configure": { + "properties": { + "closure_type": { + "type": "keyword" + }, + "connector_id": { + "type": "keyword" + }, + "connector_name": { + "type": "keyword" + }, + "created_at": { + "type": "date" + }, + "created_by": { + "properties": { + "email": { + "type": "keyword" + }, + "full_name": { + "type": "keyword" + }, + "username": { + "type": "keyword" + } + } + }, + "updated_at": { + "type": "date" + }, + "updated_by": { + "properties": { + "email": { + "type": "keyword" + }, + "full_name": { + "type": "keyword" + }, + "username": { + "type": "keyword" + } + } + } + } + }, + "cases-user-actions": { + "properties": { + "action": { + "type": "keyword" + }, + "action_at": { + "type": "date" + }, + "action_by": { + "properties": { + "email": { + "type": "keyword" + }, + "full_name": { + "type": "keyword" + }, + "username": { + "type": "keyword" + } + } + }, + "action_field": { + "type": "keyword" + }, + "new_value": { + "type": "text" + }, + "old_value": { + "type": "text" + } + } + }, + "config": { + "dynamic": "true", + "properties": { + "buildNum": { + "type": "keyword" + }, + "defaultIndex": { + "fields": { + "keyword": { + "ignore_above": 256, + "type": "keyword" + } + }, + "type": "text" + } + } + }, + "dashboard": { + "properties": { + "description": { + "type": "text" + }, + "hits": { + "type": "integer" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "optionsJSON": { + "type": "text" + }, + "panelsJSON": { + "type": "text" + }, + "refreshInterval": { + "properties": { + "display": { + "type": "keyword" + }, + "pause": { + "type": "boolean" + }, + "section": { + "type": "integer" + }, + "value": { + "type": "integer" + } + } + }, + "timeFrom": { + "type": "keyword" + }, + "timeRestore": { + "type": "boolean" + }, + "timeTo": { + "type": "keyword" + }, + "title": { + "type": "text" + }, + "version": { + "type": "integer" + } + } + }, + "file-upload-telemetry": { + "properties": { + "filesUploadedTotalCount": { + "type": "long" + } + } + }, + "graph-workspace": { + "properties": { + "description": { + "type": "text" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "numLinks": { + "type": "integer" + }, + "numVertices": { + "type": "integer" + }, + "title": { + "type": "text" + }, + "version": { + "type": "integer" + }, + "wsState": { + "type": "text" + } + } + }, + "index-pattern": { + "properties": { + "fieldFormatMap": { + "type": "text" + }, + "fields": { + "type": "text" + }, + "intervalName": { + "type": "keyword" + }, + "notExpandable": { + "type": "boolean" + }, + "sourceFilters": { + "type": "text" + }, + "timeFieldName": { + "type": "keyword" + }, + "title": { + "type": "text" + }, + "type": { + "type": "keyword" + }, + "typeMeta": { + "type": "keyword" + } + } + }, + "kql-telemetry": { + "properties": { + "optInCount": { + "type": "long" + }, + "optOutCount": { + "type": "long" + } + } + }, + "lens": { + "properties": { + "description": { + "type": "text" + }, + "expression": { + "index": false, + "type": "keyword" + }, + "state": { + "type": "flattened" + }, + "title": { + "type": "text" + }, + "visualizationType": { + "type": "keyword" + } + } + }, + "lens-ui-telemetry": { + "properties": { + "count": { + "type": "integer" + }, + "date": { + "type": "date" + }, + "name": { + "type": "keyword" + }, + "type": { + "type": "keyword" + } + } + }, + "map": { + "properties": { + "description": { + "type": "text" + }, + "layerListJSON": { + "type": "text" + }, + "mapStateJSON": { + "type": "text" + }, + "title": { + "type": "text" + }, + "uiStateJSON": { + "type": "text" + }, + "version": { + "type": "integer" + } + } + }, + "maps": { + "properties": { + "attributesPerMap": { + "properties": { + "dataSourcesCount": { + "properties": { + "avg": { + "type": "long" + }, + "max": { + "type": "long" + }, + "min": { + "type": "long" + } + } + }, + "emsVectorLayersCount": { + "dynamic": "true", + "type": "object" + }, + "layerTypesCount": { + "dynamic": "true", + "type": "object" + }, + "layersCount": { + "properties": { + "avg": { + "type": "long" + }, + "max": { + "type": "long" + }, + "min": { + "type": "long" + } + } + } + } + }, + "indexPatternsWithGeoFieldCount": { + "type": "long" + }, + "indexPatternsWithGeoPointFieldCount": { + "type": "long" + }, + "indexPatternsWithGeoShapeFieldCount": { + "type": "long" + }, + "mapsTotalCount": { + "type": "long" + }, + "settings": { + "properties": { + "showMapVisualizationTypes": { + "type": "boolean" + } + } + }, + "timeCaptured": { + "type": "date" + } + } + }, + "migrationVersion": { + "dynamic": "true", + "properties": { + "config": { + "fields": { + "keyword": { + "ignore_above": 256, + "type": "keyword" + } + }, + "type": "text" + }, + "index-pattern": { + "fields": { + "keyword": { + "ignore_above": 256, + "type": "keyword" + } + }, + "type": "text" + } + } + }, + "ml-telemetry": { + "properties": { + "file_data_visualizer": { + "properties": { + "index_creation_count": { + "type": "long" + } + } + } + } + }, + "namespace": { + "type": "keyword" + }, + "namespaces": { + "type": "keyword" + }, + "query": { + "properties": { + "description": { + "type": "text" + }, + "filters": { + "enabled": false, + "type": "object" + }, + "query": { + "properties": { + "language": { + "type": "keyword" + }, + "query": { + "index": false, + "type": "keyword" + } + } + }, + "timefilter": { + "enabled": false, + "type": "object" + }, + "title": { + "type": "text" + } + } + }, + "references": { + "properties": { + "id": { + "type": "keyword" + }, + "name": { + "type": "keyword" + }, + "type": { + "type": "keyword" + } + }, + "type": "nested" + }, + "sample-data-telemetry": { + "properties": { + "installCount": { + "type": "long" + }, + "unInstallCount": { + "type": "long" + } + } + }, + "search": { + "properties": { + "columns": { + "type": "keyword" + }, + "description": { + "type": "text" + }, + "hits": { + "type": "integer" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "sort": { + "type": "keyword" + }, + "title": { + "type": "text" + }, + "version": { + "type": "integer" + } + } + }, + "telemetry": { + "properties": { + "allowChangingOptInStatus": { + "type": "boolean" + }, + "enabled": { + "type": "boolean" + }, + "lastReported": { + "type": "date" + }, + "lastVersionChecked": { + "type": "keyword" + }, + "reportFailureCount": { + "type": "integer" + }, + "reportFailureVersion": { + "type": "keyword" + }, + "sendUsageFrom": { + "type": "keyword" + }, + "userHasSeenNotice": { + "type": "boolean" + } + } + }, + "timelion-sheet": { + "properties": { + "description": { + "type": "text" + }, + "hits": { + "type": "integer" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "timelion_chart_height": { + "type": "integer" + }, + "timelion_columns": { + "type": "integer" + }, + "timelion_interval": { + "type": "keyword" + }, + "timelion_other_interval": { + "type": "keyword" + }, + "timelion_rows": { + "type": "integer" + }, + "timelion_sheet": { + "type": "text" + }, + "title": { + "type": "text" + }, + "version": { + "type": "integer" + } + } + }, + "tsvb-validation-telemetry": { + "properties": { + "failedRequests": { + "type": "long" + } + } + }, + "type": { + "type": "keyword" + }, + "ui-metric": { + "properties": { + "count": { + "type": "integer" + } + } + }, + "updated_at": { + "type": "date" + }, + "upgrade-assistant-reindex-operation": { + "properties": { + "errorMessage": { + "type": "keyword" + }, + "indexName": { + "type": "keyword" + }, + "lastCompletedStep": { + "type": "integer" + }, + "locked": { + "type": "date" + }, + "newIndexName": { + "type": "keyword" + }, + "reindexOptions": { + "properties": { + "openAndClose": { + "type": "boolean" + }, + "queueSettings": { + "properties": { + "queuedAt": { + "type": "long" + }, + "startedAt": { + "type": "long" + } + } + } + } + }, + "reindexTaskId": { + "type": "keyword" + }, + "reindexTaskPercComplete": { + "type": "float" + }, + "runningReindexCount": { + "type": "integer" + }, + "status": { + "type": "integer" + } + } + }, + "upgrade-assistant-telemetry": { + "properties": { + "features": { + "properties": { + "deprecation_logging": { + "properties": { + "enabled": { + "null_value": true, + "type": "boolean" + } + } + } + } + }, + "ui_open": { + "properties": { + "cluster": { + "null_value": 0, + "type": "long" + }, + "indices": { + "null_value": 0, + "type": "long" + }, + "overview": { + "null_value": 0, + "type": "long" + } + } + }, + "ui_reindex": { + "properties": { + "close": { + "null_value": 0, + "type": "long" + }, + "open": { + "null_value": 0, + "type": "long" + }, + "start": { + "null_value": 0, + "type": "long" + }, + "stop": { + "null_value": 0, + "type": "long" + } + } + } + } + }, + "uptime-dynamic-settings": { + "properties": { + "certAgeThreshold": { + "type": "long" + }, + "certExpirationThreshold": { + "type": "long" + }, + "heartbeatIndices": { + "type": "keyword" + } + } + }, + "url": { + "properties": { + "accessCount": { + "type": "long" + }, + "accessDate": { + "type": "date" + }, + "createDate": { + "type": "date" + }, + "url": { + "fields": { + "keyword": { + "type": "keyword" + } + }, + "type": "text" + } + } + }, + "visualization": { + "properties": { + "description": { + "type": "text" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "savedSearchRefName": { + "type": "keyword" + }, + "title": { + "type": "text" + }, + "uiStateJSON": { + "type": "text" + }, + "version": { + "type": "integer" + }, + "visState": { + "type": "text" + } + } + } + } + }, + "settings": { + "index": { + "auto_expand_replicas": "0-1", + "number_of_replicas": "0", + "number_of_shards": "1" + } + } + } +} \ No newline at end of file diff --git a/x-pack/test/functional/es_archives/reporting/scripted_small/data.json.gz b/x-pack/test/functional/es_archives/reporting/scripted_small/data.json.gz deleted file mode 100644 index 2d6bbce42cc15c292f42726b3b78c9a59568ca3d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4038 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 diff --git a/x-pack/test/functional/es_archives/reporting/scripted_small/mappings.json b/x-pack/test/functional/es_archives/reporting/scripted_small/mappings.json deleted file mode 100644 index 8c192b21f822a..0000000000000 --- a/x-pack/test/functional/es_archives/reporting/scripted_small/mappings.json +++ /dev/null @@ -1,739 +0,0 @@ -{ - "type": "index", - "value": { - "aliases": { - ".kibana": { - } - }, - "index": ".kibana_1", - "mappings": { - "_meta": { - "migrationMappingPropertyHashes": { - "apm-telemetry": "0383a570af33654a51c8a1352417bc6b", - "canvas-workpad": "b0a1706d356228dbdcb4a17e6b9eb231", - "config": "87aca8fdb053154f11383fce3dbf3edf", - "dashboard": "eb3789e1af878e73f85304333240f65f", - "graph-workspace": "cd7ba1330e6682e9cc00b78850874be1", - "index-pattern": "66eccb05066c5a89924f48a9e9736499", - "infrastructure-ui-source": "10acdf67d9a06d462e198282fd6d4b81", - "kql-telemetry": "d12a98a6f19a2d273696597547e064ee", - "map": "23d7aa4a720d4938ccde3983f87bd58d", - "maps-telemetry": "a4229f8b16a6820c6d724b7e0c1f729d", - "migrationVersion": "4a1746014a75ade3a714e1db5763276f", - "ml-telemetry": "257fd1d4b4fdbb9cb4b8a3b27da201e9", - "namespace": "2f4316de49999235636386fe51dc06c1", - "references": "7997cf5a56cc02bdc9c93361bde732b0", - "sample-data-telemetry": "7d3cfeb915303c9641c59681967ffeb4", - "search": "181661168bbadd1eff5902361e2a0d5c", - "server": "ec97f1c5da1a19609a60874e5af1100c", - "space": "0d5011d73a0ef2f0f615bb42f26f187e", - "telemetry": "e1c8bc94e443aefd9458932cc0697a4d", - "timelion-sheet": "9a2a2748877c7a7b582fef201ab1d4cf", - "type": "2f4316de49999235636386fe51dc06c1", - "updated_at": "00da57df13e94e9d98437d13ace4bfe0", - "upgrade-assistant-reindex-operation": "a53a20fe086b72c9a86da3cc12dad8a6", - "upgrade-assistant-telemetry": "56702cec857e0a9dacfb696655b4ff7b", - "url": "c7f66a0df8b1b52f17c28c4adb111105", - "user-action": "0d409297dc5ebe1e3a1da691c6ee32e3", - "visualization": "52d7a13ad68a150c4525b292d23e12cc" - } - }, - "dynamic": "strict", - "properties": { - "apm-telemetry": { - "properties": { - "has_any_services": { - "type": "boolean" - }, - "services_per_agent": { - "properties": { - "go": { - "null_value": 0, - "type": "long" - }, - "java": { - "null_value": 0, - "type": "long" - }, - "js-base": { - "null_value": 0, - "type": "long" - }, - "nodejs": { - "null_value": 0, - "type": "long" - }, - "python": { - "null_value": 0, - "type": "long" - }, - "ruby": { - "null_value": 0, - "type": "long" - }, - "rum-js": { - "null_value": 0, - "type": "long" - } - } - } - } - }, - "canvas-workpad": { - "dynamic": "false", - "properties": { - "@created": { - "type": "date" - }, - "@timestamp": { - "type": "date" - }, - "name": { - "fields": { - "keyword": { - "type": "keyword" - } - }, - "type": "text" - } - } - }, - "config": { - "dynamic": "true", - "properties": { - "buildNum": { - "type": "keyword" - }, - "dateFormat:tz": { - "fields": { - "keyword": { - "ignore_above": 256, - "type": "keyword" - } - }, - "type": "text" - }, - "defaultIndex": { - "fields": { - "keyword": { - "ignore_above": 256, - "type": "keyword" - } - }, - "type": "text" - }, - "search:queryLanguage": { - "fields": { - "keyword": { - "ignore_above": 256, - "type": "keyword" - } - }, - "type": "text" - } - } - }, - "dashboard": { - "properties": { - "description": { - "type": "text" - }, - "hits": { - "type": "integer" - }, - "kibanaSavedObjectMeta": { - "properties": { - "searchSourceJSON": { - "type": "text" - } - } - }, - "optionsJSON": { - "type": "text" - }, - "panelsJSON": { - "type": "text" - }, - "refreshInterval": { - "properties": { - "display": { - "type": "keyword" - }, - "pause": { - "type": "boolean" - }, - "section": { - "type": "integer" - }, - "value": { - "type": "integer" - } - } - }, - "timeFrom": { - "type": "keyword" - }, - "timeRestore": { - "type": "boolean" - }, - "timeTo": { - "type": "keyword" - }, - "title": { - "type": "text" - }, - "uiStateJSON": { - "type": "text" - }, - "version": { - "type": "integer" - } - } - }, - "graph-workspace": { - "properties": { - "description": { - "type": "text" - }, - "kibanaSavedObjectMeta": { - "properties": { - "searchSourceJSON": { - "type": "text" - } - } - }, - "numLinks": { - "type": "integer" - }, - "numVertices": { - "type": "integer" - }, - "title": { - "type": "text" - }, - "version": { - "type": "integer" - }, - "wsState": { - "type": "text" - } - } - }, - "index-pattern": { - "properties": { - "fieldFormatMap": { - "type": "text" - }, - "fields": { - "type": "text" - }, - "intervalName": { - "type": "keyword" - }, - "notExpandable": { - "type": "boolean" - }, - "sourceFilters": { - "type": "text" - }, - "timeFieldName": { - "type": "keyword" - }, - "title": { - "type": "text" - }, - "type": { - "type": "keyword" - }, - "typeMeta": { - "type": "keyword" - } - } - }, - "infrastructure-ui-source": { - "properties": { - "description": { - "type": "text" - }, - "fields": { - "properties": { - "container": { - "type": "keyword" - }, - "host": { - "type": "keyword" - }, - "pod": { - "type": "keyword" - }, - "tiebreaker": { - "type": "keyword" - }, - "timestamp": { - "type": "keyword" - } - } - }, - "logAlias": { - "type": "keyword" - }, - "metricAlias": { - "type": "keyword" - }, - "name": { - "type": "text" - } - } - }, - "kql-telemetry": { - "properties": { - "optInCount": { - "type": "long" - }, - "optOutCount": { - "type": "long" - } - } - }, - "map": { - "properties": { - "bounds": { - "type": "geo_shape" - }, - "description": { - "type": "text" - }, - "layerListJSON": { - "type": "text" - }, - "mapStateJSON": { - "type": "text" - }, - "title": { - "type": "text" - }, - "uiStateJSON": { - "type": "text" - }, - "version": { - "type": "integer" - } - } - }, - "maps-telemetry": { - "properties": { - "attributesPerMap": { - "properties": { - "dataSourcesCount": { - "properties": { - "avg": { - "type": "long" - }, - "max": { - "type": "long" - }, - "min": { - "type": "long" - } - } - }, - "emsVectorLayersCount": { - "dynamic": "true", - "type": "object" - }, - "layerTypesCount": { - "dynamic": "true", - "type": "object" - }, - "layersCount": { - "properties": { - "avg": { - "type": "long" - }, - "max": { - "type": "long" - }, - "min": { - "type": "long" - } - } - } - } - }, - "mapsTotalCount": { - "type": "long" - }, - "timeCaptured": { - "type": "date" - } - } - }, - "migrationVersion": { - "dynamic": "true", - "properties": { - "dashboard": { - "fields": { - "keyword": { - "ignore_above": 256, - "type": "keyword" - } - }, - "type": "text" - }, - "index-pattern": { - "fields": { - "keyword": { - "ignore_above": 256, - "type": "keyword" - } - }, - "type": "text" - }, - "search": { - "fields": { - "keyword": { - "ignore_above": 256, - "type": "keyword" - } - }, - "type": "text" - }, - "visualization": { - "fields": { - "keyword": { - "ignore_above": 256, - "type": "keyword" - } - }, - "type": "text" - } - } - }, - "ml-telemetry": { - "properties": { - "file_data_visualizer": { - "properties": { - "index_creation_count": { - "type": "long" - } - } - } - } - }, - "namespace": { - "type": "keyword" - }, - "references": { - "properties": { - "id": { - "type": "keyword" - }, - "name": { - "type": "keyword" - }, - "type": { - "type": "keyword" - } - }, - "type": "nested" - }, - "sample-data-telemetry": { - "properties": { - "installCount": { - "type": "long" - }, - "unInstallCount": { - "type": "long" - } - } - }, - "search": { - "properties": { - "columns": { - "type": "keyword" - }, - "description": { - "type": "text" - }, - "hits": { - "type": "integer" - }, - "kibanaSavedObjectMeta": { - "properties": { - "searchSourceJSON": { - "type": "text" - } - } - }, - "sort": { - "type": "keyword" - }, - "title": { - "type": "text" - }, - "version": { - "type": "integer" - } - } - }, - "server": { - "properties": { - "uuid": { - "type": "keyword" - } - } - }, - "space": { - "properties": { - "_reserved": { - "type": "boolean" - }, - "disabledFeatures": { - "type": "keyword" - }, - "color": { - "type": "keyword" - }, - "description": { - "type": "text" - }, - "disabledFeatures": { - "type": "keyword" - }, - "initials": { - "type": "keyword" - }, - "name": { - "fields": { - "keyword": { - "ignore_above": 2048, - "type": "keyword" - } - }, - "type": "text" - } - } - }, - "telemetry": { - "properties": { - "enabled": { - "type": "boolean" - } - } - }, - "timelion-sheet": { - "properties": { - "description": { - "type": "text" - }, - "hits": { - "type": "integer" - }, - "kibanaSavedObjectMeta": { - "properties": { - "searchSourceJSON": { - "type": "text" - } - } - }, - "timelion_chart_height": { - "type": "integer" - }, - "timelion_columns": { - "type": "integer" - }, - "timelion_interval": { - "type": "keyword" - }, - "timelion_other_interval": { - "type": "keyword" - }, - "timelion_rows": { - "type": "integer" - }, - "timelion_sheet": { - "type": "text" - }, - "title": { - "type": "text" - }, - "version": { - "type": "integer" - } - } - }, - "type": { - "type": "keyword" - }, - "updated_at": { - "type": "date" - }, - "upgrade-assistant-reindex-operation": { - "dynamic": "true", - "properties": { - "indexName": { - "type": "keyword" - }, - "status": { - "type": "integer" - } - } - }, - "upgrade-assistant-telemetry": { - "properties": { - "features": { - "properties": { - "deprecation_logging": { - "properties": { - "enabled": { - "null_value": true, - "type": "boolean" - } - } - } - } - }, - "ui_open": { - "properties": { - "cluster": { - "null_value": 0, - "type": "long" - }, - "indices": { - "null_value": 0, - "type": "long" - }, - "overview": { - "null_value": 0, - "type": "long" - } - } - }, - "ui_reindex": { - "properties": { - "close": { - "null_value": 0, - "type": "long" - }, - "open": { - "null_value": 0, - "type": "long" - }, - "start": { - "null_value": 0, - "type": "long" - }, - "stop": { - "null_value": 0, - "type": "long" - } - } - } - } - }, - "url": { - "properties": { - "accessCount": { - "type": "long" - }, - "accessDate": { - "type": "date" - }, - "createDate": { - "type": "date" - }, - "url": { - "fields": { - "keyword": { - "ignore_above": 2048, - "type": "keyword" - } - }, - "type": "text" - } - } - }, - "user-action": { - "properties": { - "count": { - "type": "integer" - } - } - }, - "visualization": { - "properties": { - "description": { - "type": "text" - }, - "kibanaSavedObjectMeta": { - "properties": { - "searchSourceJSON": { - "type": "text" - } - } - }, - "savedSearchRefName": { - "type": "keyword" - }, - "title": { - "type": "text" - }, - "uiStateJSON": { - "type": "text" - }, - "version": { - "type": "integer" - }, - "visState": { - "type": "text" - } - } - } - } - }, - "settings": { - "index": { - "auto_expand_replicas": "0-1", - "number_of_replicas": "0", - "number_of_shards": "1" - } - } - } -} - -{ - "type": "index", - "value": { - "aliases": { - }, - "index": "babynames", - "mappings": { - "properties": { - "date": { - "type": "date" - }, - "gender": { - "type": "keyword" - }, - "name": { - "type": "keyword" - }, - "percent": { - "type": "float" - }, - "value": { - "type": "integer" - }, - "year": { - "type": "integer" - } - } - }, - "settings": { - "index": { - "number_of_replicas": "0", - "number_of_shards": "1" - } - } - } -} diff --git a/x-pack/test/functional/es_archives/reporting/scripted_small2/data.json.gz b/x-pack/test/functional/es_archives/reporting/scripted_small2/data.json.gz new file mode 100644 index 0000000000000000000000000000000000000000..5e421015770b35b3d28e55aa861d306ba7abc559 GIT binary patch literal 4248 zcmV;J5NGcniwFp-+T&gV17u-zVJ>QOZ*BnPU0ZY8$d!Kgui!EdNvcG;?-%Y<;wfjn z8IPx8%_NnvO9hfm5f%tA0H_SdrT@OC0g5C@2!uqmn_E%IHrb8K>95b_JEt4)*H^t> zKVL6Rf7I(wlS%)|Hrxl%%C>xkFYq;-+TLs#Ow4F%X2B}Ti{orpJT<@C-r-$14&vak zJxf;UWOoT@NzKfpCZ3oKT7TKJe!hC_F0Q_JJT%>;CNt^1v3JkYmATq=O_C@{?QMUD z(0Dec{k^`a$tG#I%)=zMM_kXttOvhqkf^tjzE|AszxL0HgGGVC+s*hkJr7#}A3-Vw z-8Qe5(;zp~n;^HH1YtcubU^qu)*~iISPiMf|12-}@XG#qc=hUQd(?9inT5&I^=SL4 zbL@9luHbLtU>t4He-e;GxlVw>@d(SIP~;3y?*0`GcED7=Js+Iv=@kT$W&UB6ze!fH z9SA;x;1sT#!PE)Zgc3m`LPl5*sn%zpG)dxFcso)?F+~H8FdblQ^k6(Oa=@m+ zcrsRFGb3#O{LBRDWIp=W%B1V>gZOq8+!km=tBHwC|E06E8Sts;5!ZHZ_y~(34F8Pj zSp>^$Pm8NxpFdv*=U3(}$n!KDuX2<9Xwvt=veH}5Sn#p9V)m%)`~CI`_f{3qRkd{y z{94xc`hBoAY3&`WCq(wRYXG(Fv5rRZs^*sE4E+cQn>5b; z9_I7EnB;9}qUoay=)}Q4CSjaE>x_@{U}>H_!z#jck_S;;XJ+y|jBm3;4anxnzd7WufgqQH^FW4sXv0R;S~P; z)Q?w-u}MGmuRiscK^iQwvKKyQX8Kbam0y!Iyba^xgn6DXN3UN;$s~y8NtTbeAZT-b zKDRWeIP7(>T)v(s_8WAKgUo;u&Wo;3{fF{sD|pd@t{}vm`hn9P9`^lsF-klC{a--D z?VWGz$lwNcriz9PoL78Xv`wr^LnDIECCf7whC)MaQxO>~Yk|gsGJfg@x3{Ue1qWAY zO-(R;n}v6FN;;$)K~HC1u;6yQ1HJT@N4^Vr;4phB&y674s7 z5UG^oLT1dmsj4vBmPb*D3sG!WRgzl+s+^t#27eoA$`2=G`9{br4QQ*q;R&;1-$tAw zAe|KE8)0LE&LzpR34qTNXmgjcr8vj3`QD%hB;c3d+spp`^Kb`y6j(j9iV=iyWU{Pi z?ZP#D?jMk+g2er>DLq)vP(2`zijHwGURxBhV;;2#Z_|RT{S2(PtayAqAdh<#qYu0Y za>19fvFhd8kufo%XsG2mi4ri8D4Z0dyjf+C?A_#P5NE-}wgk~gBQYCKr)UsN46*bq z7%0qV1F8)sGo=GHAQXU@e#L3Rx{<+Mh$nB5d0AA^tc?jVUsUAre^bSDp&} z{V;n2x4W|wfDe=;%Y((T7Fr~S46&Ef>r-QFJH-__98NL5gejf|*?gP?DNxrF6D^qL zbnmBe(%FCBtZ$JVO>iKU8h32M+v`_n@}NMxKa2A)&ki!?@+KdHdo%qo{$eKidy@x8 z>2nbr$`Z=|e*EymCS@y1RAmPC_|c|Mn;2v3Rz=HQ*=Mt=Ss3Lun2mn<{Lrt>xsyWh zZ0|0s%)Ad$$nUaU44Q{iNZ4W%UE3hCi$ME>UE2Qbg=`LXUj~svsG@9G$a_;8!=BfhfBy~tQX6n>a!4$F42$z;Y9_~OHX9(Sf&o)zG5`yn4yF_%q2$!)Ab89q zL5_E2qSg9DIM09yO{X^Yz9~v~R=(}^{$^%BYzdxixUmUnB>;oDq$ZocivjWb19LCn z0bq}o9`x&K)|kmDt@zFC%f zhLd~F>Q1AZ-gv;mN3-*M^fMRZ_Iz|LQ5qgHqhUGbT1Q^ZFB|;%cD!FJTLM;#xH`T^ z=_+^IlzaL$F}>1c>jK9uMFFDPTvpb0@_xSZ!8PaM?L2}%`JbX_#{+kaZqv|`l9p?! zN1W8MJ3C4H?CLr54i~IvwONwpm8tt&$7{-q_L5nzEQhq@I0rjw&pA*fK~+4UdF@sf#Ng#)`l8BRv!(IEqZ_W>;GK8|Ng2Mg?Fa+7c;p_{@8mnhaAJaRzS&z3?m9Owf8ZY1!=fB ztNUF-8z{De-NSd=bl{So+BoBSLRwlLz68r_$GXvwRH*ck*@^#Tv-|Fm#TGS>4M%zx z|CB~A-Tw4)Z71Jc+p$(;@D}`nSr6VG4RyDA&`>DL&GtgJUV*O>8Wq2GU4K_(c;q^E zqvWn}w|CNtuxo#0T1C;@{wO3si#gL0Nht;29{D&dyp1d+{j&L^BIf`@y8?zf1O_8b zH4%(+Eu`YRTJR1SSpy6`2N+ZU6P}F=Iun=hX;?TS1XD#hXbu;I9HF8~4JzmyRB%VA zV0Wl6#xW+6NkO!r$Dl}60}4LhxrE+*OA{!RVXVNpAPJuoK1Kz(@hqY7?pZ?fG%iF@ zOsNLH%dI)dV|8K7jb~YV_bg93T#PB=L?S>&5>ELsCK_rL2 zkx(^JNC`J)Iy3QBgoz zq)TR3u(5!|ga-%JKXXi^wOFb1DXh|++}F~yTkcuW_#V3)Y~ zaxiQE4rYCk4rcA&!JJSS#2A94fNEQm!L1DGdJ*i49fb;1^2Gak0Eo~Oi6j=lRm8UR*{IF?OX1>SEcTo)I|wJr~q$^nNU<5S6(o4!shP1TTvq{fCvL+;fM=R7)EP$ zr6MO0dVK+v?{!ld6JcARW2D$BB{{1pWKt*FqK$Rp&CFq!!Gs##X&p?6VJY-6ylQhcNE$Zbg)C}Ooa&=H%JLg35L+D z6d_7*+0N;*vptKMnmqR%r#DhGY7GFyu}uL8ld_%KobO}`xf0)+Q%cc@4&st2iV;^t z6VVDC)&V-1P8RV!%DSDIVz z&z4vbnqoL)l1xG}DFvmnW2B;yQG4T%%SoU{bye2ocjWAA*}%FvKe zGQt!n4T2EGD6O$2LS1Zfi2ZMJh?_P!0L#;oY5{4sc8GMAJqG&=w{&0hAQMHEDu&g^jrcY#MjVgU%?1 zAVt7}Yod=ehfx=0Ju6q}`{{~Oh|5}j!`*>{-GT$LTS9uNxv+0mIaVAZyLQE_J8YOk zY#2jYaKf$*aRQIkWCr_bw0t|Hr3UOam-fNr|Kaa5xx~sHs>{M4d30*gJ!1fCYjP z@E6TNO$4!vaL2GPH*`1izFpCZ(2xe$2|MlJKvC2Ci1N?AVTB&s5E2)ih(&vrIqCF z!!+v(80Hq3Z~mgL0+{tBR5aJk)m~q3F^hi-{X1YQL*x5;Ic3tmeU-v*-3tNTFquxE zVNRj(u*7dug4D#}#P^e3r4WsE8BDamK=T-|8zoyUOP-wiqT)Mfr;TxmQ7V|CN)ubV2^?H`!NwE=I= zCGfpEa2gsft4SbMY}VerOx*Y%1aZa{fMVYzV_%y>+dUQ7fuD(ue}ig-2HV0W;*=0h urQJE-?!~no7*uTh8&o4S1QKa(AM6rJ+QqkHt)ZvORR0HqtD5u)l>h)b&f { + it('With filters and timebased data, explicit UTC format', async () => { + // load test data that contains a saved search and documents + await esArchiver.load('reporting/logs'); + await esArchiver.load('logstash_functional'); + + const res = (await generateAPI.getCsvFromSavedSearch( + 'search:d7a79750-3edd-11e9-99cc-4d80163ee9e7', + { + timerange: { + timezone: 'UTC', + min: '2015-09-19T10:00:00.000Z', + max: '2015-09-21T10:00:00.000Z', + }, + state: {}, + } + )) as supertest.Response; + const { status: resStatus, text: resText, type: resType } = res; + + expect(resStatus).to.eql(200); + expect(resType).to.eql('text/csv'); + expect(resText).to.eql(fixtures.CSV_RESULT_TIMEBASED_UTC); + + await esArchiver.unload('reporting/logs'); + await esArchiver.unload('logstash_functional'); + }); + + it('With filters and timebased data, default to UTC', async () => { + // load test data that contains a saved search and documents + await esArchiver.load('reporting/logs'); + await esArchiver.load('logstash_functional'); + + const res = (await generateAPI.getCsvFromSavedSearch( + 'search:d7a79750-3edd-11e9-99cc-4d80163ee9e7', + { + // @ts-expect-error: timerange.timezone is missing from post params + timerange: { + min: '2015-09-19T10:00:00.000Z', + max: '2015-09-21T10:00:00.000Z', + }, + state: {}, + } + )) as supertest.Response; + const { status: resStatus, text: resText, type: resType } = res; + + expect(resStatus).to.eql(200); + expect(resType).to.eql('text/csv'); + expect(resText).to.eql(fixtures.CSV_RESULT_TIMEBASED_UTC); + + await esArchiver.unload('reporting/logs'); + await esArchiver.unload('logstash_functional'); + }); + + it('With filters and timebased data, custom timezone', async () => { // load test data that contains a saved search and documents await esArchiver.load('reporting/logs'); await esArchiver.load('logstash_functional'); - // TODO: check headers for inline filename const { status: resStatus, text: resText, @@ -66,7 +108,7 @@ export default function ({ getService }: FtrProviderContext) { 'search:d7a79750-3edd-11e9-99cc-4d80163ee9e7', { timerange: { - timezone: 'UTC', + timezone: 'America/Phoenix', min: '2015-09-19T10:00:00.000Z', max: '2015-09-21T10:00:00.000Z', }, @@ -76,7 +118,7 @@ export default function ({ getService }: FtrProviderContext) { expect(resStatus).to.eql(200); expect(resType).to.eql('text/csv'); - expect(resText).to.eql(CSV_RESULT_TIMEBASED); + expect(resText).to.eql(fixtures.CSV_RESULT_TIMEBASED_CUSTOM); await esArchiver.unload('reporting/logs'); await esArchiver.unload('logstash_functional'); @@ -99,21 +141,21 @@ export default function ({ getService }: FtrProviderContext) { expect(resStatus).to.eql(200); expect(resType).to.eql('text/csv'); - expect(resText).to.eql(CSV_RESULT_TIMELESS); + expect(resText).to.eql(fixtures.CSV_RESULT_TIMELESS); await esArchiver.unload('reporting/sales'); }); it('With scripted fields and field formatters', async () => { // load test data that contains a saved search and documents - await esArchiver.load('reporting/scripted_small'); + await esArchiver.load('reporting/scripted_small2'); const { status: resStatus, text: resText, type: resType, } = (await generateAPI.getCsvFromSavedSearch( - 'search:f34bf440-5014-11e9-bce7-4dabcb8bef24', + 'search:a6d51430-ace2-11ea-815f-39e12f89a8c2', { timerange: { timezone: 'UTC', @@ -126,12 +168,33 @@ export default function ({ getService }: FtrProviderContext) { expect(resStatus).to.eql(200); expect(resType).to.eql('text/csv'); - expect(resText).to.eql(CSV_RESULT_SCRIPTED); + expect(resText).to.eql(fixtures.CSV_RESULT_SCRIPTED); + + await esArchiver.unload('reporting/scripted_small2'); + }); + + it('Formatted date_nanos data, UTC timezone', async () => { + await esArchiver.load('reporting/nanos'); + + const { + status: resStatus, + text: resText, + type: resType, + } = (await generateAPI.getCsvFromSavedSearch( + 'search:e4035040-a295-11e9-a900-ef10e0ac769e', + { + state: {}, + } + )) as supertest.Response; + + expect(resStatus).to.eql(200); + expect(resType).to.eql('text/csv'); + expect(resText).to.eql(fixtures.CSV_RESULT_NANOS); - await esArchiver.unload('reporting/scripted_small'); + await esArchiver.unload('reporting/nanos'); }); - it('Formatted date_nanos data', async () => { + it('Formatted date_nanos data, custom time zone', async () => { await esArchiver.load('reporting/nanos'); const { @@ -142,12 +205,13 @@ export default function ({ getService }: FtrProviderContext) { 'search:e4035040-a295-11e9-a900-ef10e0ac769e', { state: {}, + timerange: { timezone: 'America/New_York' }, } )) as supertest.Response; expect(resStatus).to.eql(200); expect(resType).to.eql('text/csv'); - expect(resText).to.eql(CSV_RESULT_NANOS); + expect(resText).to.eql(fixtures.CSV_RESULT_NANOS_CUSTOM); await esArchiver.unload('reporting/nanos'); }); @@ -214,7 +278,7 @@ export default function ({ getService }: FtrProviderContext) { expect(resStatus).to.eql(200); expect(resType).to.eql('text/csv'); - expect(resText).to.eql(CSV_RESULT_HUGE); + expect(resText).to.eql(fixtures.CSV_RESULT_HUGE); await esArchiver.unload('reporting/hugedata'); }); @@ -223,13 +287,13 @@ export default function ({ getService }: FtrProviderContext) { describe('Merge user state into the query', () => { it('for query', async () => { // load test data that contains a saved search and documents - await esArchiver.load('reporting/scripted_small'); + await esArchiver.load('reporting/scripted_small2'); const params = { - searchId: 'search:f34bf440-5014-11e9-bce7-4dabcb8bef24', + searchId: 'search:a6d51430-ace2-11ea-815f-39e12f89a8c2', postPayload: { timerange: { timezone: 'UTC', min: '1979-01-01T10:00:00Z', max: '1981-01-01T10:00:00Z' }, // prettier-ignore - state: { query: { bool: { filter: [ { bool: { filter: [ { bool: { minimum_should_match: 1, should: [{ query_string: { fields: ['name'], query: 'Fe*' } }] } } ] } } ] } } } // prettier-ignore + state: { query: { bool: { filter: [ { bool: { filter: [ { bool: { minimum_should_match: 1, should: [{ query_string: { fields: ['name'], query: 'Fel*' } }] } } ] } } ] } } } // prettier-ignore }, isImmediate: true, }; @@ -245,9 +309,9 @@ export default function ({ getService }: FtrProviderContext) { expect(resStatus).to.eql(200); expect(resType).to.eql('text/csv'); - expect(resText).to.eql(CSV_RESULT_SCRIPTED_REQUERY); + expect(resText).to.eql(fixtures.CSV_RESULT_SCRIPTED_REQUERY); - await esArchiver.unload('reporting/scripted_small'); + await esArchiver.unload('reporting/scripted_small2'); }); it('for sort', async () => { @@ -272,7 +336,7 @@ export default function ({ getService }: FtrProviderContext) { expect(resStatus).to.eql(200); expect(resType).to.eql('text/csv'); - expect(resText).to.eql(CSV_RESULT_SCRIPTED_RESORTED); + expect(resText).to.eql(fixtures.CSV_RESULT_SCRIPTED_RESORTED); await esArchiver.unload('reporting/hugedata'); }); @@ -333,7 +397,7 @@ export default function ({ getService }: FtrProviderContext) { expect(resStatus).to.eql(200); expect(resType).to.eql('text/csv'); - expect(resText).to.eql(CSV_RESULT_DOCVALUE); + expect(resText).to.eql(fixtures.CSV_RESULT_DOCVALUE); await esArchiver.unload('reporting/ecommerce'); await esArchiver.unload('reporting/ecommerce_kibana'); From 8325222c0a86f0b6e09e1380ca55f93c26f1017f Mon Sep 17 00:00:00 2001 From: Michael Olorunnisola Date: Mon, 13 Jul 2020 20:52:25 -0400 Subject: [PATCH 50/66] initial telemetry setup (#69330) --- .../security_solution/server/plugin.ts | 1 + .../server/usage/collector.ts | 45 ++++- .../{ => detections}/detections.mocks.ts | 2 +- .../usage/{ => detections}/detections.test.ts | 16 +- .../{ => detections}/detections_helpers.ts | 14 +- .../{detections.ts => detections/index.ts} | 4 +- .../server/usage/endpoints/endpoint.mocks.ts | 131 +++++++++++++++ .../server/usage/endpoints/endpoint.test.ts | 116 +++++++++++++ .../usage/endpoints/fleet_saved_objects.ts | 37 ++++ .../server/usage/endpoints/index.ts | 159 ++++++++++++++++++ .../security_solution/server/usage/types.ts | 3 +- .../schema/xpack_plugins.json | 43 +++++ 12 files changed, 546 insertions(+), 25 deletions(-) rename x-pack/plugins/security_solution/server/usage/{ => detections}/detections.mocks.ts (98%) rename x-pack/plugins/security_solution/server/usage/{ => detections}/detections.test.ts (83%) rename x-pack/plugins/security_solution/server/usage/{ => detections}/detections_helpers.ts (91%) rename x-pack/plugins/security_solution/server/usage/{detections.ts => detections/index.ts} (89%) create mode 100644 x-pack/plugins/security_solution/server/usage/endpoints/endpoint.mocks.ts create mode 100644 x-pack/plugins/security_solution/server/usage/endpoints/endpoint.test.ts create mode 100644 x-pack/plugins/security_solution/server/usage/endpoints/fleet_saved_objects.ts create mode 100644 x-pack/plugins/security_solution/server/usage/endpoints/index.ts diff --git a/x-pack/plugins/security_solution/server/plugin.ts b/x-pack/plugins/security_solution/server/plugin.ts index ebd95fe79ebf5..137c57f04367d 100644 --- a/x-pack/plugins/security_solution/server/plugin.ts +++ b/x-pack/plugins/security_solution/server/plugin.ts @@ -114,6 +114,7 @@ export class Plugin implements IPlugin void; export interface UsageData { detections: DetectionsUsage; + endpoints: EndpointUsage; } -export const registerCollector: RegisterCollector = ({ kibanaIndex, ml, usageCollection }) => { +export async function getInternalSavedObjectsClient(core: CoreSetup) { + return core.getStartServices().then(async ([coreStart]) => { + return coreStart.savedObjects.createInternalRepository(); + }); +} + +export const registerCollector: RegisterCollector = ({ + core, + kibanaIndex, + ml, + usageCollection, +}) => { if (!usageCollection) { return; } - const collector = usageCollection.makeUsageCollector({ type: 'security_solution', schema: { @@ -43,11 +55,32 @@ export const registerCollector: RegisterCollector = ({ kibanaIndex, ml, usageCol }, }, }, + endpoints: { + total_installed: { type: 'long' }, + active_within_last_24_hours: { type: 'long' }, + os: { + full_name: { type: 'keyword' }, + platform: { type: 'keyword' }, + version: { type: 'keyword' }, + count: { type: 'long' }, + }, + policies: { + malware: { + success: { type: 'long' }, + warning: { type: 'long' }, + failure: { type: 'long' }, + }, + }, + }, }, isReady: () => kibanaIndex.length > 0, - fetch: async (callCluster: LegacyAPICaller): Promise => ({ - detections: await fetchDetectionsUsage(kibanaIndex, callCluster, ml), - }), + fetch: async (callCluster: LegacyAPICaller): Promise => { + const savedObjectsClient = await getInternalSavedObjectsClient(core); + return { + detections: await fetchDetectionsUsage(kibanaIndex, callCluster, ml), + endpoints: await getEndpointTelemetryFromFleet(savedObjectsClient), + }; + }, }); usageCollection.registerCollector(collector); diff --git a/x-pack/plugins/security_solution/server/usage/detections.mocks.ts b/x-pack/plugins/security_solution/server/usage/detections/detections.mocks.ts similarity index 98% rename from x-pack/plugins/security_solution/server/usage/detections.mocks.ts rename to x-pack/plugins/security_solution/server/usage/detections/detections.mocks.ts index c80dc6936ec7b..e59b1092978da 100644 --- a/x-pack/plugins/security_solution/server/usage/detections.mocks.ts +++ b/x-pack/plugins/security_solution/server/usage/detections/detections.mocks.ts @@ -3,7 +3,7 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { INTERNAL_IMMUTABLE_KEY } from '../../common/constants'; +import { INTERNAL_IMMUTABLE_KEY } from '../../../common/constants'; export const getMockJobSummaryResponse = () => [ { diff --git a/x-pack/plugins/security_solution/server/usage/detections.test.ts b/x-pack/plugins/security_solution/server/usage/detections/detections.test.ts similarity index 83% rename from x-pack/plugins/security_solution/server/usage/detections.test.ts rename to x-pack/plugins/security_solution/server/usage/detections/detections.test.ts index 7fd2d3eb9ff27..0fc23f90a0ebf 100644 --- a/x-pack/plugins/security_solution/server/usage/detections.test.ts +++ b/x-pack/plugins/security_solution/server/usage/detections/detections.test.ts @@ -4,20 +4,20 @@ * you may not use this file except in compliance with the Elastic License. */ -import { LegacyAPICaller } from '../../../../../src/core/server'; -import { elasticsearchServiceMock } from '../../../../../src/core/server/mocks'; -import { jobServiceProvider } from '../../../ml/server/models/job_service'; -import { DataRecognizer } from '../../../ml/server/models/data_recognizer'; -import { mlServicesMock } from '../lib/machine_learning/mocks'; +import { LegacyAPICaller } from '../../../../../../src/core/server'; +import { elasticsearchServiceMock } from '../../../../../../src/core/server/mocks'; +import { jobServiceProvider } from '../../../../ml/server/models/job_service'; +import { DataRecognizer } from '../../../../ml/server/models/data_recognizer'; +import { mlServicesMock } from '../../lib/machine_learning/mocks'; import { getMockJobSummaryResponse, getMockListModulesResponse, getMockRulesResponse, } from './detections.mocks'; -import { fetchDetectionsUsage } from './detections'; +import { fetchDetectionsUsage } from './index'; -jest.mock('../../../ml/server/models/job_service'); -jest.mock('../../../ml/server/models/data_recognizer'); +jest.mock('../../../../ml/server/models/job_service'); +jest.mock('../../../../ml/server/models/data_recognizer'); describe('Detections Usage', () => { describe('fetchDetectionsUsage()', () => { diff --git a/x-pack/plugins/security_solution/server/usage/detections_helpers.ts b/x-pack/plugins/security_solution/server/usage/detections/detections_helpers.ts similarity index 91% rename from x-pack/plugins/security_solution/server/usage/detections_helpers.ts rename to x-pack/plugins/security_solution/server/usage/detections/detections_helpers.ts index 18a90b12991b2..3d04c24bab55a 100644 --- a/x-pack/plugins/security_solution/server/usage/detections_helpers.ts +++ b/x-pack/plugins/security_solution/server/usage/detections/detections_helpers.ts @@ -6,15 +6,15 @@ import { SearchParams } from 'elasticsearch'; -import { LegacyAPICaller, SavedObjectsClient } from '../../../../../src/core/server'; +import { LegacyAPICaller, SavedObjectsClient } from '../../../../../../src/core/server'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { jobServiceProvider } from '../../../ml/server/models/job_service'; +import { jobServiceProvider } from '../../../../ml/server/models/job_service'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { DataRecognizer } from '../../../ml/server/models/data_recognizer'; -import { MlPluginSetup } from '../../../ml/server'; -import { SIGNALS_ID, INTERNAL_IMMUTABLE_KEY } from '../../common/constants'; -import { DetectionRulesUsage, MlJobsUsage } from './detections'; -import { isJobStarted } from '../../common/machine_learning/helpers'; +import { DataRecognizer } from '../../../../ml/server/models/data_recognizer'; +import { MlPluginSetup } from '../../../../ml/server'; +import { SIGNALS_ID, INTERNAL_IMMUTABLE_KEY } from '../../../common/constants'; +import { DetectionRulesUsage, MlJobsUsage } from './index'; +import { isJobStarted } from '../../../common/machine_learning/helpers'; interface DetectionsMetric { isElastic: boolean; diff --git a/x-pack/plugins/security_solution/server/usage/detections.ts b/x-pack/plugins/security_solution/server/usage/detections/index.ts similarity index 89% rename from x-pack/plugins/security_solution/server/usage/detections.ts rename to x-pack/plugins/security_solution/server/usage/detections/index.ts index 1475a8ae34625..dd50e79e22cc9 100644 --- a/x-pack/plugins/security_solution/server/usage/detections.ts +++ b/x-pack/plugins/security_solution/server/usage/detections/index.ts @@ -4,9 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { LegacyAPICaller } from '../../../../../src/core/server'; +import { LegacyAPICaller } from '../../../../../../src/core/server'; import { getMlJobsUsage, getRulesUsage } from './detections_helpers'; -import { MlPluginSetup } from '../../../ml/server'; +import { MlPluginSetup } from '../../../../ml/server'; interface FeatureUsage { enabled: number; diff --git a/x-pack/plugins/security_solution/server/usage/endpoints/endpoint.mocks.ts b/x-pack/plugins/security_solution/server/usage/endpoints/endpoint.mocks.ts new file mode 100644 index 0000000000000..f41cfb773736d --- /dev/null +++ b/x-pack/plugins/security_solution/server/usage/endpoints/endpoint.mocks.ts @@ -0,0 +1,131 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { SavedObjectsFindResponse } from 'src/core/server'; +import { AgentEventSOAttributes } from './../../../../ingest_manager/common/types/models/agent'; +import { + AGENT_SAVED_OBJECT_TYPE, + AGENT_EVENT_SAVED_OBJECT_TYPE, +} from '../../../../ingest_manager/common/constants/agent'; +import { Agent } from '../../../../ingest_manager/common'; +import { FLEET_ENDPOINT_PACKAGE_CONSTANT } from './fleet_saved_objects'; + +const testAgentId = 'testAgentId'; +const testConfigId = 'testConfigId'; + +/** Mock OS Platform for endpoint telemetry */ +export const MockOSPlatform = 'somePlatform'; +/** Mock OS Name for endpoint telemetry */ +export const MockOSName = 'somePlatformName'; +/** Mock OS Version for endpoint telemetry */ +export const MockOSVersion = '1'; +/** Mock OS Full Name for endpoint telemetry */ +export const MockOSFullName = 'somePlatformFullName'; + +/** + * + * @param lastCheckIn - the last time the agent checked in. Defaults to current ISO time. + * @description We request the install and OS related telemetry information from the 'fleet-agents' saved objects in ingest_manager. This mocks that response + */ +export const mockFleetObjectsResponse = ( + lastCheckIn = new Date().toISOString() +): SavedObjectsFindResponse => ({ + page: 1, + per_page: 20, + total: 1, + saved_objects: [ + { + type: AGENT_SAVED_OBJECT_TYPE, + id: testAgentId, + attributes: { + active: true, + id: testAgentId, + config_id: 'randoConfigId', + type: 'PERMANENT', + user_provided_metadata: {}, + enrolled_at: lastCheckIn, + current_error_events: [], + local_metadata: { + elastic: { + agent: { + id: testAgentId, + }, + }, + host: { + hostname: 'testDesktop', + name: 'testDesktop', + id: 'randoHostId', + }, + os: { + platform: MockOSPlatform, + version: MockOSVersion, + name: MockOSName, + full: MockOSFullName, + }, + }, + packages: [FLEET_ENDPOINT_PACKAGE_CONSTANT, 'system'], + last_checkin: lastCheckIn, + }, + references: [], + updated_at: lastCheckIn, + version: 'WzI4MSwxXQ==', + score: 0, + }, + ], +}); + +/** + * + * @param running - allows us to set whether the mocked endpoint is in an active or disabled/failed state + * @param updatedDate - the last time the endpoint was updated. Defaults to current ISO time. + * @description We request the events triggered by the agent and get the most recent endpoint event to confirm it is still running. This allows us to mock both scenarios + */ +export const mockFleetEventsObjectsResponse = ( + running?: boolean, + updatedDate = new Date().toISOString() +): SavedObjectsFindResponse => { + return { + page: 1, + per_page: 20, + total: 2, + saved_objects: [ + { + type: AGENT_EVENT_SAVED_OBJECT_TYPE, + id: 'id1', + attributes: { + agent_id: testAgentId, + type: running ? 'STATE' : 'ERROR', + timestamp: updatedDate, + subtype: running ? 'RUNNING' : 'FAILED', + message: `Application: endpoint-security--8.0.0[d8f7f6e8-9375-483c-b456-b479f1d7a4f2]: State changed to ${ + running ? 'RUNNING' : 'FAILED' + }: `, + config_id: testConfigId, + }, + references: [], + updated_at: updatedDate, + version: 'WzExOCwxXQ==', + score: 0, + }, + { + type: AGENT_EVENT_SAVED_OBJECT_TYPE, + id: 'id2', + attributes: { + agent_id: testAgentId, + type: 'STATE', + timestamp: updatedDate, + subtype: 'STARTING', + message: + 'Application: endpoint-security--8.0.0[d8f7f6e8-9375-483c-b456-b479f1d7a4f2]: State changed to STARTING: Starting', + config_id: testConfigId, + }, + references: [], + updated_at: updatedDate, + version: 'WzExNywxXQ==', + score: 0, + }, + ], + }; +}; diff --git a/x-pack/plugins/security_solution/server/usage/endpoints/endpoint.test.ts b/x-pack/plugins/security_solution/server/usage/endpoints/endpoint.test.ts new file mode 100644 index 0000000000000..0b2f4e4ed9dbe --- /dev/null +++ b/x-pack/plugins/security_solution/server/usage/endpoints/endpoint.test.ts @@ -0,0 +1,116 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { savedObjectsRepositoryMock } from 'src/core/server/mocks'; +import { + mockFleetObjectsResponse, + mockFleetEventsObjectsResponse, + MockOSFullName, + MockOSPlatform, + MockOSVersion, +} from './endpoint.mocks'; +import { ISavedObjectsRepository, SavedObjectsFindResponse } from 'src/core/server'; +import { AgentEventSOAttributes } from '../../../../ingest_manager/common/types/models/agent'; +import { Agent } from '../../../../ingest_manager/common'; +import * as endpointTelemetry from './index'; +import * as fleetSavedObjects from './fleet_saved_objects'; + +describe('test security solution endpoint telemetry', () => { + let mockSavedObjectsRepository: jest.Mocked; + let getFleetSavedObjectsMetadataSpy: jest.SpyInstance>>; + let getFleetEventsSavedObjectsSpy: jest.SpyInstance + >>; + + beforeAll(() => { + getFleetEventsSavedObjectsSpy = jest.spyOn(fleetSavedObjects, 'getFleetEventsSavedObjects'); + getFleetSavedObjectsMetadataSpy = jest.spyOn(fleetSavedObjects, 'getFleetSavedObjectsMetadata'); + mockSavedObjectsRepository = savedObjectsRepositoryMock.create(); + }); + + afterAll(() => { + jest.resetAllMocks(); + }); + + it('should have a default shape', () => { + expect(endpointTelemetry.getDefaultEndpointTelemetry()).toMatchInlineSnapshot(` + Object { + "active_within_last_24_hours": 0, + "os": Array [], + "total_installed": 0, + } + `); + }); + + describe('when an agent has not been installed', () => { + it('should return the default shape if no agents are found', async () => { + getFleetSavedObjectsMetadataSpy.mockImplementation(() => + Promise.resolve({ saved_objects: [], total: 0, per_page: 0, page: 0 }) + ); + + const emptyEndpointTelemetryData = await endpointTelemetry.getEndpointTelemetryFromFleet( + mockSavedObjectsRepository + ); + expect(getFleetSavedObjectsMetadataSpy).toHaveBeenCalled(); + expect(emptyEndpointTelemetryData).toEqual({ + total_installed: 0, + active_within_last_24_hours: 0, + os: [], + }); + }); + }); + + describe('when an agent has been installed', () => { + it('should show one enpoint installed but it is inactive', async () => { + getFleetSavedObjectsMetadataSpy.mockImplementation(() => + Promise.resolve(mockFleetObjectsResponse()) + ); + getFleetEventsSavedObjectsSpy.mockImplementation(() => + Promise.resolve(mockFleetEventsObjectsResponse()) + ); + + const emptyEndpointTelemetryData = await endpointTelemetry.getEndpointTelemetryFromFleet( + mockSavedObjectsRepository + ); + expect(emptyEndpointTelemetryData).toEqual({ + total_installed: 1, + active_within_last_24_hours: 0, + os: [ + { + full_name: MockOSFullName, + platform: MockOSPlatform, + version: MockOSVersion, + count: 1, + }, + ], + }); + }); + + it('should show one endpoint installed and it is active', async () => { + getFleetSavedObjectsMetadataSpy.mockImplementation(() => + Promise.resolve(mockFleetObjectsResponse()) + ); + getFleetEventsSavedObjectsSpy.mockImplementation(() => + Promise.resolve(mockFleetEventsObjectsResponse(true)) + ); + + const emptyEndpointTelemetryData = await endpointTelemetry.getEndpointTelemetryFromFleet( + mockSavedObjectsRepository + ); + expect(emptyEndpointTelemetryData).toEqual({ + total_installed: 1, + active_within_last_24_hours: 1, + os: [ + { + full_name: MockOSFullName, + platform: MockOSPlatform, + version: MockOSVersion, + count: 1, + }, + ], + }); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/server/usage/endpoints/fleet_saved_objects.ts b/x-pack/plugins/security_solution/server/usage/endpoints/fleet_saved_objects.ts new file mode 100644 index 0000000000000..70657ed9f08f7 --- /dev/null +++ b/x-pack/plugins/security_solution/server/usage/endpoints/fleet_saved_objects.ts @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ISavedObjectsRepository } from 'src/core/server'; +import { AgentEventSOAttributes } from './../../../../ingest_manager/common/types/models/agent'; +import { + AGENT_SAVED_OBJECT_TYPE, + AGENT_EVENT_SAVED_OBJECT_TYPE, +} from './../../../../ingest_manager/common/constants/agent'; +import { Agent, DefaultPackages as FleetDefaultPackages } from '../../../../ingest_manager/common'; + +export const FLEET_ENDPOINT_PACKAGE_CONSTANT = FleetDefaultPackages.endpoint; + +export const getFleetSavedObjectsMetadata = async (savedObjectsClient: ISavedObjectsRepository) => + savedObjectsClient.find({ + type: AGENT_SAVED_OBJECT_TYPE, + fields: ['packages', 'last_checkin', 'local_metadata'], + filter: `${AGENT_SAVED_OBJECT_TYPE}.attributes.packages: ${FLEET_ENDPOINT_PACKAGE_CONSTANT}`, + sortField: 'enrolled_at', + sortOrder: 'desc', + }); + +export const getFleetEventsSavedObjects = async ( + savedObjectsClient: ISavedObjectsRepository, + agentId: string +) => + savedObjectsClient.find({ + type: AGENT_EVENT_SAVED_OBJECT_TYPE, + filter: `${AGENT_EVENT_SAVED_OBJECT_TYPE}.attributes.agent_id: ${agentId} and ${AGENT_EVENT_SAVED_OBJECT_TYPE}.attributes.message: "${FLEET_ENDPOINT_PACKAGE_CONSTANT}"`, + sortField: 'timestamp', + sortOrder: 'desc', + search: agentId, + searchFields: ['agent_id'], + }); diff --git a/x-pack/plugins/security_solution/server/usage/endpoints/index.ts b/x-pack/plugins/security_solution/server/usage/endpoints/index.ts new file mode 100644 index 0000000000000..576d248613d1e --- /dev/null +++ b/x-pack/plugins/security_solution/server/usage/endpoints/index.ts @@ -0,0 +1,159 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ISavedObjectsRepository } from 'src/core/server'; +import { AgentMetadata } from '../../../../ingest_manager/common/types/models/agent'; +import { + getFleetSavedObjectsMetadata, + getFleetEventsSavedObjects, + FLEET_ENDPOINT_PACKAGE_CONSTANT, +} from './fleet_saved_objects'; + +export interface AgentOSMetadataTelemetry { + full_name: string; + platform: string; + version: string; + count: number; +} + +export interface PoliciesTelemetry { + malware: { + success: number; + warning: number; + failure: number; + }; +} + +export interface EndpointUsage { + total_installed: number; + active_within_last_24_hours: number; + os: AgentOSMetadataTelemetry[]; + policies?: PoliciesTelemetry; // TODO: make required when able to enable policy information +} + +export interface AgentLocalMetadata extends AgentMetadata { + elastic: { + agent: { + id: string; + }; + }; + host: { + id: string; + }; + os: { + name: string; + platform: string; + version: string; + full: string; + }; +} + +export type OSTracker = Record; +/** + * @description returns an empty telemetry object to be incrmented and updated within the `getEndpointTelemetryFromFleet` fn + */ +export const getDefaultEndpointTelemetry = (): EndpointUsage => ({ + total_installed: 0, + active_within_last_24_hours: 0, + os: [], +}); + +export const trackEndpointOSTelemetry = ( + os: AgentLocalMetadata['os'], + osTracker: OSTracker +): OSTracker => { + const updatedOSTracker = { ...osTracker }; + const { version: osVersion, platform: osPlatform, full: osFullName } = os; + if (osFullName && osVersion) { + if (updatedOSTracker[osFullName]) updatedOSTracker[osFullName].count += 1; + else { + updatedOSTracker[osFullName] = { + full_name: osFullName, + platform: osPlatform, + version: osVersion, + count: 1, + }; + } + } + + return updatedOSTracker; +}; + +/** + * @description This aggregates the telemetry details from the two fleet savedObject sources, `fleet-agents` and `fleet-agent-events` to populate + * the telemetry details for endpoint. Since we cannot access our own indices due to `kibana_system` not having access, this is the best alternative. + * Once the data is requested, we iterate over all agents with endpoints registered, and then request the events for each active agent (within last 24 hours) + * to confirm whether or not the endpoint is still active + */ +export const getEndpointTelemetryFromFleet = async ( + savedObjectsClient: ISavedObjectsRepository +): Promise => { + // Retrieve every agent that references the endpoint as an installed package. It will not be listed if it was never installed + const { saved_objects: endpointAgents } = await getFleetSavedObjectsMetadata(savedObjectsClient); + const endpointTelemetry = getDefaultEndpointTelemetry(); + + // If there are no installed endpoints return the default telemetry object + if (!endpointAgents || endpointAgents.length < 1) return endpointTelemetry; + + // Use unique hosts to prevent any potential duplicates + const uniqueHostIds: Set = new Set(); + // Need unique agents to get events data for those that have run in last 24 hours + const uniqueAgentIds: Set = new Set(); + + const aDayAgo = new Date(); + aDayAgo.setDate(aDayAgo.getDate() - 1); + let osTracker: OSTracker = {}; + + const endpointMetadataTelemetry = endpointAgents.reduce( + (metadataTelemetry, { attributes: metadataAttributes }) => { + const { last_checkin: lastCheckin, local_metadata: localMetadata } = metadataAttributes; + // The extended AgentMetadata is just an empty blob, so cast to account for our specific use case + const { host, os, elastic } = localMetadata as AgentLocalMetadata; + + if (lastCheckin && new Date(lastCheckin) > aDayAgo) { + // Get agents that have checked in within the last 24 hours to later see if their endpoints are running + uniqueAgentIds.add(elastic.agent.id); + } + if (host && uniqueHostIds.has(host.id)) { + return metadataTelemetry; + } else { + uniqueHostIds.add(host.id); + osTracker = trackEndpointOSTelemetry(os, osTracker); + return metadataTelemetry; + } + }, + endpointTelemetry + ); + + // All unique agents with an endpoint installed. You can technically install a new agent on a host, so relying on most recently installed. + endpointTelemetry.total_installed = uniqueHostIds.size; + + // Get the objects to populate our OS Telemetry + endpointMetadataTelemetry.os = Object.values(osTracker); + + // Check for agents running in the last 24 hours whose endpoints are still active + for (const agentId of uniqueAgentIds) { + const { saved_objects: agentEvents } = await getFleetEventsSavedObjects( + savedObjectsClient, + agentId + ); + const lastEndpointStatus = agentEvents.find((agentEvent) => + agentEvent.attributes.message.includes(FLEET_ENDPOINT_PACKAGE_CONSTANT) + ); + + /* + We can assume that if the last status of the endpoint is RUNNING and the agent has checked in within the last 24 hours + then the endpoint has still been running within the last 24 hours. If / when we get the policy response, then we can use that + instead + */ + const endpointIsActive = lastEndpointStatus?.attributes.subtype === 'RUNNING'; + if (endpointIsActive) { + endpointMetadataTelemetry.active_within_last_24_hours += 1; + } + } + + return endpointMetadataTelemetry; +}; diff --git a/x-pack/plugins/security_solution/server/usage/types.ts b/x-pack/plugins/security_solution/server/usage/types.ts index 955a4eaf4be5a..9f8ebf80b65b5 100644 --- a/x-pack/plugins/security_solution/server/usage/types.ts +++ b/x-pack/plugins/security_solution/server/usage/types.ts @@ -4,9 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ +import { CoreSetup } from 'src/core/server'; import { SetupPlugins } from '../plugin'; -export type CollectorDependencies = { kibanaIndex: string } & Pick< +export type CollectorDependencies = { kibanaIndex: string; core: CoreSetup } & Pick< SetupPlugins, 'ml' | 'usageCollection' >; diff --git a/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json b/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json index c5d528cbcce23..a7bc29f9efae2 100644 --- a/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json +++ b/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json @@ -217,6 +217,49 @@ } } } + }, + "endpoints": { + "properties": { + "total_installed": { + "type": "long" + }, + "active_within_last_24_hours": { + "type": "long" + }, + "os": { + "properties": { + "full_name": { + "type": "keyword" + }, + "platform": { + "type": "keyword" + }, + "version": { + "type": "keyword" + }, + "count": { + "type": "long" + } + } + }, + "policies": { + "properties": { + "malware": { + "properties": { + "success": { + "type": "long" + }, + "warning": { + "type": "long" + }, + "failure": { + "type": "long" + } + } + } + } + } + } } } }, From 473806c3c818b15f7ff97004218b1873beb99c7e Mon Sep 17 00:00:00 2001 From: Frank Hassanabad Date: Mon, 13 Jul 2020 19:07:35 -0600 Subject: [PATCH 51/66] [SIEM][Detection Engine][Lists] Adds the ability for exception lists to be multi-list queried. (#71540) ## Summary * Adds the ability for exception lists to be multi-list queried * Fixes a bunch of script issues where I did not update everywhere I needed to use `ip_list` and deletes an old list that now lives within the new/lists folder * Fixes a few io-ts issues with Encode Decode while I was in there. * Adds two more types and their tests for supporting converting between comma separated strings and arrays for GET calls. * Fixes one weird circular dep issue while adding more types. You now send into the find an optional comma separated list of exception lists their namespace type and any filters like so: ```ts GET /api/exception_lists/items/_find?list_id=simple_list,endpoint_list&namespace_type=single,agnostic&filtering=filter1,filter2" ``` And this will return the results of both together with each filter applied to each list. If you use a sort field and ordering it will order across the lists together as if they are one list. Filter is optional like before. If you provide less filters than there are lists, the lists will only apply the filters to each list until it runs out of filters and then not filter the other lists. If at least one list is found this will _not_ return a 404 but it will _only_ query the list(s) it did find. If none of the lists are found, then this will return a 404 not found exception. **Script testing** See these files for more information: * find_exception_list_items.sh * find_exception_list_items_by_filter.sh But basically you can create two lists and an item for each of the lists: ```ts ./post_exception_list.sh ./exception_lists/new/exception_list.json ./post_exception_list_item.sh ./exception_lists/new/exception_list_item.json ./post_exception_list.sh ./exception_lists/new/exception_list_agnostic.json ./post_exception_list_item.sh ./exception_lists/new/exception_list_item_agnostic.json ``` And then you can query these two lists together: ```ts ./find_exception_list_items.sh simple_list,endpoint_list single,agnostic ``` Or for filtering you can query both and add a filter for each one: ```ts ./find_exception_list_items_by_filter.sh simple_list,endpoint_list "exception-list.attributes.name:%20Sample%20Endpoint%20Exception%20List,exception-list-agnostic.attributes.name:%20Sample%20Endpoint%20Exception%20List" single,agnostic ``` ### Checklist Delete any items that are not applicable to this PR. - [x] [Unit or functional tests](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#cross-browser-compatibility) were updated or added to match the most common scenarios --- x-pack/plugins/lists/README.md | 8 +- .../lists/common/schemas/common/schemas.ts | 1 - .../create_exception_list_item_schema.ts | 8 +- .../request/create_exception_list_schema.ts | 2 +- .../delete_exception_list_item_schema.ts | 3 +- .../request/delete_exception_list_schema.ts | 3 +- .../find_exception_list_item_schema.ts | 30 +++--- .../request/find_exception_list_schema.ts | 3 +- .../read_exception_list_item_schema.ts | 3 +- .../request/read_exception_list_schema.ts | 3 +- .../update_exception_list_item_schema.ts | 2 +- .../request/update_exception_list_schema.ts | 2 +- .../common/schemas/types/default_namespace.ts | 13 +-- .../types/default_namespace_array.test.ts | 99 +++++++++++++++++++ .../schemas/types/default_namespace_array.ts | 45 +++++++++ .../schemas/types/empty_string_array.test.ts | 79 +++++++++++++++ .../schemas/types/empty_string_array.ts | 45 +++++++++ .../types/non_empty_string_array.test.ts | 94 ++++++++++++++++++ .../schemas/types/non_empty_string_array.ts | 41 ++++++++ .../routes/find_exception_list_item_route.ts | 42 ++++---- .../scripts/delete_all_exception_lists.sh | 2 +- .../exception_lists/new/exception_list.json | 4 +- .../new/exception_list_item.json | 4 +- .../new/exception_list_item_with_list.json | 2 +- .../scripts/export_list_items_to_file.sh | 2 +- .../scripts/find_exception_list_items.sh | 19 +++- .../find_exception_list_items_by_filter.sh | 24 +++-- .../lists/server/scripts/find_list_items.sh | 4 +- .../scripts/find_list_items_with_cursor.sh | 4 +- .../scripts/find_list_items_with_sort.sh | 4 +- .../find_list_items_with_sort_cursor.sh | 4 +- .../lists/server/scripts/import_list_items.sh | 4 +- .../scripts/lists/new/list_ip_item.json | 5 - .../create_exception_list_item.ts | 2 +- .../exception_lists/exception_list_client.ts | 24 +++++ .../exception_list_client_types.ts | 13 +++ .../find_exception_list_item.ts | 50 ++-------- .../find_exception_list_items.test.ts | 94 ++++++++++++++++++ .../find_exception_list_items.ts | 94 ++++++++++++++++++ .../get_exception_list_item.ts | 3 +- .../server/services/exception_lists/index.ts | 10 +- .../server/services/exception_lists/utils.ts | 31 ++++-- 42 files changed, 786 insertions(+), 143 deletions(-) create mode 100644 x-pack/plugins/lists/common/schemas/types/default_namespace_array.test.ts create mode 100644 x-pack/plugins/lists/common/schemas/types/default_namespace_array.ts create mode 100644 x-pack/plugins/lists/common/schemas/types/empty_string_array.test.ts create mode 100644 x-pack/plugins/lists/common/schemas/types/empty_string_array.ts create mode 100644 x-pack/plugins/lists/common/schemas/types/non_empty_string_array.test.ts create mode 100644 x-pack/plugins/lists/common/schemas/types/non_empty_string_array.ts delete mode 100644 x-pack/plugins/lists/server/scripts/lists/new/list_ip_item.json create mode 100644 x-pack/plugins/lists/server/services/exception_lists/find_exception_list_items.test.ts create mode 100644 x-pack/plugins/lists/server/services/exception_lists/find_exception_list_items.ts diff --git a/x-pack/plugins/lists/README.md b/x-pack/plugins/lists/README.md index b6061368f6b13..dac6e8bb78fa5 100644 --- a/x-pack/plugins/lists/README.md +++ b/x-pack/plugins/lists/README.md @@ -57,7 +57,7 @@ which will: - Delete any existing exception list items you have - Delete any existing mapping, policies, and templates, you might have previously had. - Add the latest list and list item index and its mappings using your settings from `kibana.dev.yml` environment variable of `xpack.lists.listIndex` and `xpack.lists.listItemIndex`. -- Posts the sample list from `./lists/new/list_ip.json` +- Posts the sample list from `./lists/new/ip_list.json` Now you can run @@ -69,7 +69,7 @@ You should see the new list created like so: ```sh { - "id": "list_ip", + "id": "ip_list", "created_at": "2020-05-28T19:15:22.344Z", "created_by": "yo", "description": "This list describes bad internet ip", @@ -96,7 +96,7 @@ You should see the new list item created and attached to the above list like so: "value": "127.0.0.1", "created_at": "2020-05-28T19:15:49.790Z", "created_by": "yo", - "list_id": "list_ip", + "list_id": "ip_list", "tie_breaker_id": "a881bf2e-1e17-4592-bba8-d567cb07d234", "updated_at": "2020-05-28T19:15:49.790Z", "updated_by": "yo" @@ -195,7 +195,7 @@ You can then do find for each one like so: "cursor": "WzIwLFsiYzU3ZWZiYzQtNDk3Ny00YTMyLTk5NWYtY2ZkMjk2YmVkNTIxIl1d", "data": [ { - "id": "list_ip", + "id": "ip_list", "created_at": "2020-05-28T19:15:22.344Z", "created_by": "yo", "description": "This list describes bad internet ip", diff --git a/x-pack/plugins/lists/common/schemas/common/schemas.ts b/x-pack/plugins/lists/common/schemas/common/schemas.ts index 6bb6ee05034cb..6199a5f16f109 100644 --- a/x-pack/plugins/lists/common/schemas/common/schemas.ts +++ b/x-pack/plugins/lists/common/schemas/common/schemas.ts @@ -273,7 +273,6 @@ export const cursorOrUndefined = t.union([cursor, t.undefined]); export type CursorOrUndefined = t.TypeOf; export const namespace_type = DefaultNamespace; -export type NamespaceType = t.TypeOf; export const operator = t.keyof({ excluded: null, included: null }); export type Operator = t.TypeOf; diff --git a/x-pack/plugins/lists/common/schemas/request/create_exception_list_item_schema.ts b/x-pack/plugins/lists/common/schemas/request/create_exception_list_item_schema.ts index fb452ac89576d..4b7db3eee35bc 100644 --- a/x-pack/plugins/lists/common/schemas/request/create_exception_list_item_schema.ts +++ b/x-pack/plugins/lists/common/schemas/request/create_exception_list_item_schema.ts @@ -10,7 +10,6 @@ import * as t from 'io-ts'; import { ItemId, - NamespaceType, Tags, _Tags, _tags, @@ -23,7 +22,12 @@ import { tags, } from '../common/schemas'; import { Identity, RequiredKeepUndefined } from '../../types'; -import { CreateCommentsArray, DefaultCreateCommentsArray, DefaultEntryArray } from '../types'; +import { + CreateCommentsArray, + DefaultCreateCommentsArray, + DefaultEntryArray, + NamespaceType, +} from '../types'; import { EntriesArray } from '../types/entries'; import { DefaultUuid } from '../../siem_common_deps'; diff --git a/x-pack/plugins/lists/common/schemas/request/create_exception_list_schema.ts b/x-pack/plugins/lists/common/schemas/request/create_exception_list_schema.ts index a0aaa91c81427..66cca4ab9ca53 100644 --- a/x-pack/plugins/lists/common/schemas/request/create_exception_list_schema.ts +++ b/x-pack/plugins/lists/common/schemas/request/create_exception_list_schema.ts @@ -10,7 +10,6 @@ import * as t from 'io-ts'; import { ListId, - NamespaceType, Tags, _Tags, _tags, @@ -23,6 +22,7 @@ import { } from '../common/schemas'; import { Identity, RequiredKeepUndefined } from '../../types'; import { DefaultUuid } from '../../siem_common_deps'; +import { NamespaceType } from '../types'; export const createExceptionListSchema = t.intersection([ t.exact( diff --git a/x-pack/plugins/lists/common/schemas/request/delete_exception_list_item_schema.ts b/x-pack/plugins/lists/common/schemas/request/delete_exception_list_item_schema.ts index 4c5b70d9a4073..909960c9fffc0 100644 --- a/x-pack/plugins/lists/common/schemas/request/delete_exception_list_item_schema.ts +++ b/x-pack/plugins/lists/common/schemas/request/delete_exception_list_item_schema.ts @@ -8,7 +8,8 @@ import * as t from 'io-ts'; -import { NamespaceType, id, item_id, namespace_type } from '../common/schemas'; +import { id, item_id, namespace_type } from '../common/schemas'; +import { NamespaceType } from '../types'; export const deleteExceptionListItemSchema = t.exact( t.partial({ diff --git a/x-pack/plugins/lists/common/schemas/request/delete_exception_list_schema.ts b/x-pack/plugins/lists/common/schemas/request/delete_exception_list_schema.ts index 2577d867031f0..3bf5e7a4d0782 100644 --- a/x-pack/plugins/lists/common/schemas/request/delete_exception_list_schema.ts +++ b/x-pack/plugins/lists/common/schemas/request/delete_exception_list_schema.ts @@ -8,7 +8,8 @@ import * as t from 'io-ts'; -import { NamespaceType, id, list_id, namespace_type } from '../common/schemas'; +import { id, list_id, namespace_type } from '../common/schemas'; +import { NamespaceType } from '../types'; export const deleteExceptionListSchema = t.exact( t.partial({ diff --git a/x-pack/plugins/lists/common/schemas/request/find_exception_list_item_schema.ts b/x-pack/plugins/lists/common/schemas/request/find_exception_list_item_schema.ts index 31eb4925eb6d6..826da972fe7a3 100644 --- a/x-pack/plugins/lists/common/schemas/request/find_exception_list_item_schema.ts +++ b/x-pack/plugins/lists/common/schemas/request/find_exception_list_item_schema.ts @@ -8,27 +8,26 @@ import * as t from 'io-ts'; -import { - NamespaceType, - filter, - list_id, - namespace_type, - sort_field, - sort_order, -} from '../common/schemas'; +import { sort_field, sort_order } from '../common/schemas'; import { RequiredKeepUndefined } from '../../types'; import { StringToPositiveNumber } from '../types/string_to_positive_number'; +import { + DefaultNamespaceArray, + DefaultNamespaceArrayTypeDecoded, +} from '../types/default_namespace_array'; +import { NonEmptyStringArray } from '../types/non_empty_string_array'; +import { EmptyStringArray, EmptyStringArrayDecoded } from '../types/empty_string_array'; export const findExceptionListItemSchema = t.intersection([ t.exact( t.type({ - list_id, + list_id: NonEmptyStringArray, }) ), t.exact( t.partial({ - filter, // defaults to undefined if not set during decode - namespace_type, // defaults to 'single' if not set during decode + filter: EmptyStringArray, // defaults to undefined if not set during decode + namespace_type: DefaultNamespaceArray, // defaults to ['single'] if not set during decode page: StringToPositiveNumber, // defaults to undefined if not set during decode per_page: StringToPositiveNumber, // defaults to undefined if not set during decode sort_field, // defaults to undefined if not set during decode @@ -37,14 +36,15 @@ export const findExceptionListItemSchema = t.intersection([ ), ]); -export type FindExceptionListItemSchemaPartial = t.TypeOf; +export type FindExceptionListItemSchemaPartial = t.OutputOf; // This type is used after a decode since some things are defaults after a decode. export type FindExceptionListItemSchemaPartialDecoded = Omit< - FindExceptionListItemSchemaPartial, - 'namespace_type' + t.TypeOf, + 'namespace_type' | 'filter' > & { - namespace_type: NamespaceType; + filter: EmptyStringArrayDecoded; + namespace_type: DefaultNamespaceArrayTypeDecoded; }; // This type is used after a decode since some things are defaults after a decode. diff --git a/x-pack/plugins/lists/common/schemas/request/find_exception_list_schema.ts b/x-pack/plugins/lists/common/schemas/request/find_exception_list_schema.ts index fa00c5b0dafb1..8b9b08ed387b1 100644 --- a/x-pack/plugins/lists/common/schemas/request/find_exception_list_schema.ts +++ b/x-pack/plugins/lists/common/schemas/request/find_exception_list_schema.ts @@ -8,9 +8,10 @@ import * as t from 'io-ts'; -import { NamespaceType, filter, namespace_type, sort_field, sort_order } from '../common/schemas'; +import { filter, namespace_type, sort_field, sort_order } from '../common/schemas'; import { RequiredKeepUndefined } from '../../types'; import { StringToPositiveNumber } from '../types/string_to_positive_number'; +import { NamespaceType } from '../types'; export const findExceptionListSchema = t.exact( t.partial({ diff --git a/x-pack/plugins/lists/common/schemas/request/read_exception_list_item_schema.ts b/x-pack/plugins/lists/common/schemas/request/read_exception_list_item_schema.ts index 93a372ba383b0..d8864a6fc66e5 100644 --- a/x-pack/plugins/lists/common/schemas/request/read_exception_list_item_schema.ts +++ b/x-pack/plugins/lists/common/schemas/request/read_exception_list_item_schema.ts @@ -8,8 +8,9 @@ import * as t from 'io-ts'; -import { NamespaceType, id, item_id, namespace_type } from '../common/schemas'; +import { id, item_id, namespace_type } from '../common/schemas'; import { RequiredKeepUndefined } from '../../types'; +import { NamespaceType } from '../types'; export const readExceptionListItemSchema = t.exact( t.partial({ diff --git a/x-pack/plugins/lists/common/schemas/request/read_exception_list_schema.ts b/x-pack/plugins/lists/common/schemas/request/read_exception_list_schema.ts index 3947c88bf4c9c..613fb22a99d61 100644 --- a/x-pack/plugins/lists/common/schemas/request/read_exception_list_schema.ts +++ b/x-pack/plugins/lists/common/schemas/request/read_exception_list_schema.ts @@ -8,8 +8,9 @@ import * as t from 'io-ts'; -import { NamespaceType, id, list_id, namespace_type } from '../common/schemas'; +import { id, list_id, namespace_type } from '../common/schemas'; import { RequiredKeepUndefined } from '../../types'; +import { NamespaceType } from '../types'; export const readExceptionListSchema = t.exact( t.partial({ diff --git a/x-pack/plugins/lists/common/schemas/request/update_exception_list_item_schema.ts b/x-pack/plugins/lists/common/schemas/request/update_exception_list_item_schema.ts index 582fabdc160f9..20a63e0fc7dac 100644 --- a/x-pack/plugins/lists/common/schemas/request/update_exception_list_item_schema.ts +++ b/x-pack/plugins/lists/common/schemas/request/update_exception_list_item_schema.ts @@ -9,7 +9,6 @@ import * as t from 'io-ts'; import { - NamespaceType, Tags, _Tags, _tags, @@ -26,6 +25,7 @@ import { DefaultEntryArray, DefaultUpdateCommentsArray, EntriesArray, + NamespaceType, UpdateCommentsArray, } from '../types'; diff --git a/x-pack/plugins/lists/common/schemas/request/update_exception_list_schema.ts b/x-pack/plugins/lists/common/schemas/request/update_exception_list_schema.ts index 76160c3419449..0b5f3a8a01794 100644 --- a/x-pack/plugins/lists/common/schemas/request/update_exception_list_schema.ts +++ b/x-pack/plugins/lists/common/schemas/request/update_exception_list_schema.ts @@ -9,7 +9,6 @@ import * as t from 'io-ts'; import { - NamespaceType, Tags, _Tags, _tags, @@ -21,6 +20,7 @@ import { tags, } from '../common/schemas'; import { Identity, RequiredKeepUndefined } from '../../types'; +import { NamespaceType } from '../types'; export const updateExceptionListSchema = t.intersection([ t.exact( diff --git a/x-pack/plugins/lists/common/schemas/types/default_namespace.ts b/x-pack/plugins/lists/common/schemas/types/default_namespace.ts index 8f8f8d105b624..ecc45d3c84313 100644 --- a/x-pack/plugins/lists/common/schemas/types/default_namespace.ts +++ b/x-pack/plugins/lists/common/schemas/types/default_namespace.ts @@ -8,23 +8,18 @@ import * as t from 'io-ts'; import { Either } from 'fp-ts/lib/Either'; export const namespaceType = t.keyof({ agnostic: null, single: null }); - -type NamespaceType = t.TypeOf; - -export type DefaultNamespaceC = t.Type; +export type NamespaceType = t.TypeOf; /** * Types the DefaultNamespace as: * - If null or undefined, then a default string/enumeration of "single" will be used. */ -export const DefaultNamespace: DefaultNamespaceC = new t.Type< - NamespaceType, - NamespaceType, - unknown ->( +export const DefaultNamespace = new t.Type( 'DefaultNamespace', namespaceType.is, (input, context): Either => input == null ? t.success('single') : namespaceType.validate(input, context), t.identity ); + +export type DefaultNamespaceC = typeof DefaultNamespace; diff --git a/x-pack/plugins/lists/common/schemas/types/default_namespace_array.test.ts b/x-pack/plugins/lists/common/schemas/types/default_namespace_array.test.ts new file mode 100644 index 0000000000000..055f93069950e --- /dev/null +++ b/x-pack/plugins/lists/common/schemas/types/default_namespace_array.test.ts @@ -0,0 +1,99 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { pipe } from 'fp-ts/lib/pipeable'; +import { left } from 'fp-ts/lib/Either'; + +import { foldLeftRight, getPaths } from '../../siem_common_deps'; + +import { DefaultNamespaceArray, DefaultNamespaceArrayTypeEncoded } from './default_namespace_array'; + +describe('default_namespace_array', () => { + test('it should validate "null" single item as an array with a "single" value', () => { + const payload: DefaultNamespaceArrayTypeEncoded = null; + const decoded = DefaultNamespaceArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(['single']); + }); + + test('it should NOT validate a numeric value', () => { + const payload = 5; + const decoded = DefaultNamespaceArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "5" supplied to "DefaultNamespaceArray"', + ]); + expect(message.schema).toEqual({}); + }); + + test('it should validate "undefined" item as an array with a "single" value', () => { + const payload: DefaultNamespaceArrayTypeEncoded = undefined; + const decoded = DefaultNamespaceArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(['single']); + }); + + test('it should validate "single" as an array of a "single" value', () => { + const payload: DefaultNamespaceArrayTypeEncoded = 'single'; + const decoded = DefaultNamespaceArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual([payload]); + }); + + test('it should validate "agnostic" as an array of a "agnostic" value', () => { + const payload: DefaultNamespaceArrayTypeEncoded = 'agnostic'; + const decoded = DefaultNamespaceArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual([payload]); + }); + + test('it should validate "single,agnostic" as an array of 2 values of ["single", "agnostic"] values', () => { + const payload: DefaultNamespaceArrayTypeEncoded = 'agnostic,single'; + const decoded = DefaultNamespaceArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(['agnostic', 'single']); + }); + + test('it should validate 3 elements of "single,agnostic,single" as an array of 3 values of ["single", "agnostic", "single"] values', () => { + const payload: DefaultNamespaceArrayTypeEncoded = 'single,agnostic,single'; + const decoded = DefaultNamespaceArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(['single', 'agnostic', 'single']); + }); + + test('it should validate 3 elements of "single,agnostic, single" as an array of 3 values of ["single", "agnostic", "single"] values when there are spaces', () => { + const payload: DefaultNamespaceArrayTypeEncoded = ' single, agnostic, single '; + const decoded = DefaultNamespaceArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(['single', 'agnostic', 'single']); + }); + + test('it should not validate 3 elements of "single,agnostic,junk" since the 3rd value is junk', () => { + const payload: DefaultNamespaceArrayTypeEncoded = 'single,agnostic,junk'; + const decoded = DefaultNamespaceArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "junk" supplied to "DefaultNamespaceArray"', + ]); + expect(message.schema).toEqual({}); + }); +}); diff --git a/x-pack/plugins/lists/common/schemas/types/default_namespace_array.ts b/x-pack/plugins/lists/common/schemas/types/default_namespace_array.ts new file mode 100644 index 0000000000000..c4099a48ffbcc --- /dev/null +++ b/x-pack/plugins/lists/common/schemas/types/default_namespace_array.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as t from 'io-ts'; +import { Either } from 'fp-ts/lib/Either'; + +import { namespaceType } from './default_namespace'; + +export const namespaceTypeArray = t.array(namespaceType); +export type NamespaceTypeArray = t.TypeOf; + +/** + * Types the DefaultNamespaceArray as: + * - If null or undefined, then a default string array of "single" will be used. + * - If it contains a string, then it is split along the commas and puts them into an array and validates it + */ +export const DefaultNamespaceArray = new t.Type< + NamespaceTypeArray, + string | undefined | null, + unknown +>( + 'DefaultNamespaceArray', + namespaceTypeArray.is, + (input, context): Either => { + if (input == null) { + return t.success(['single']); + } else if (typeof input === 'string') { + const commaSeparatedValues = input + .trim() + .split(',') + .map((value) => value.trim()); + return namespaceTypeArray.validate(commaSeparatedValues, context); + } + return t.failure(input, context); + }, + String +); + +export type DefaultNamespaceC = typeof DefaultNamespaceArray; + +export type DefaultNamespaceArrayTypeEncoded = t.OutputOf; +export type DefaultNamespaceArrayTypeDecoded = t.TypeOf; diff --git a/x-pack/plugins/lists/common/schemas/types/empty_string_array.test.ts b/x-pack/plugins/lists/common/schemas/types/empty_string_array.test.ts new file mode 100644 index 0000000000000..b14afab327fb0 --- /dev/null +++ b/x-pack/plugins/lists/common/schemas/types/empty_string_array.test.ts @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { pipe } from 'fp-ts/lib/pipeable'; +import { left } from 'fp-ts/lib/Either'; + +import { foldLeftRight, getPaths } from '../../siem_common_deps'; + +import { EmptyStringArray, EmptyStringArrayEncoded } from './empty_string_array'; + +describe('empty_string_array', () => { + test('it should validate "null" and create an empty array', () => { + const payload: EmptyStringArrayEncoded = null; + const decoded = EmptyStringArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual([]); + }); + + test('it should validate "undefined" and create an empty array', () => { + const payload: EmptyStringArrayEncoded = undefined; + const decoded = EmptyStringArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual([]); + }); + + test('it should validate a single value of "a" into an array of size 1 of ["a"]', () => { + const payload: EmptyStringArrayEncoded = 'a'; + const decoded = EmptyStringArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(['a']); + }); + + test('it should validate 2 values of "a,b" into an array of size 2 of ["a", "b"]', () => { + const payload: EmptyStringArrayEncoded = 'a,b'; + const decoded = EmptyStringArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(['a', 'b']); + }); + + test('it should validate 3 values of "a,b,c" into an array of size 3 of ["a", "b", "c"]', () => { + const payload: EmptyStringArrayEncoded = 'a,b,c'; + const decoded = EmptyStringArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(['a', 'b', 'c']); + }); + + test('it should NOT validate a number', () => { + const payload: number = 5; + const decoded = EmptyStringArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "5" supplied to "EmptyStringArray"', + ]); + expect(message.schema).toEqual({}); + }); + + test('it should validate 3 values of " a, b, c " into an array of size 3 of ["a", "b", "c"] even though they have spaces', () => { + const payload: EmptyStringArrayEncoded = ' a, b, c '; + const decoded = EmptyStringArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(['a', 'b', 'c']); + }); +}); diff --git a/x-pack/plugins/lists/common/schemas/types/empty_string_array.ts b/x-pack/plugins/lists/common/schemas/types/empty_string_array.ts new file mode 100644 index 0000000000000..389dc4a410cc9 --- /dev/null +++ b/x-pack/plugins/lists/common/schemas/types/empty_string_array.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as t from 'io-ts'; +import { Either } from 'fp-ts/lib/Either'; + +/** + * Types the EmptyStringArray as: + * - A value that can be undefined, or null (which will be turned into an empty array) + * - A comma separated string that can turn into an array by splitting on it + * - Example input converted to output: undefined -> [] + * - Example input converted to output: null -> [] + * - Example input converted to output: "a,b,c" -> ["a", "b", "c"] + */ +export const EmptyStringArray = new t.Type( + 'EmptyStringArray', + t.array(t.string).is, + (input, context): Either => { + if (input == null) { + return t.success([]); + } else if (typeof input === 'string' && input.trim() !== '') { + const arrayValues = input + .trim() + .split(',') + .map((value) => value.trim()); + const emptyValueFound = arrayValues.some((value) => value === ''); + if (emptyValueFound) { + return t.failure(input, context); + } else { + return t.success(arrayValues); + } + } else { + return t.failure(input, context); + } + }, + String +); + +export type EmptyStringArrayC = typeof EmptyStringArray; + +export type EmptyStringArrayEncoded = t.OutputOf; +export type EmptyStringArrayDecoded = t.TypeOf; diff --git a/x-pack/plugins/lists/common/schemas/types/non_empty_string_array.test.ts b/x-pack/plugins/lists/common/schemas/types/non_empty_string_array.test.ts new file mode 100644 index 0000000000000..6124487cdd7fb --- /dev/null +++ b/x-pack/plugins/lists/common/schemas/types/non_empty_string_array.test.ts @@ -0,0 +1,94 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { pipe } from 'fp-ts/lib/pipeable'; +import { left } from 'fp-ts/lib/Either'; + +import { foldLeftRight, getPaths } from '../../siem_common_deps'; + +import { NonEmptyStringArray, NonEmptyStringArrayEncoded } from './non_empty_string_array'; + +describe('non_empty_string_array', () => { + test('it should NOT validate "null"', () => { + const payload: NonEmptyStringArrayEncoded | null = null; + const decoded = NonEmptyStringArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "null" supplied to "NonEmptyStringArray"', + ]); + expect(message.schema).toEqual({}); + }); + + test('it should NOT validate "undefined"', () => { + const payload: NonEmptyStringArrayEncoded | undefined = undefined; + const decoded = NonEmptyStringArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "undefined" supplied to "NonEmptyStringArray"', + ]); + expect(message.schema).toEqual({}); + }); + + test('it should NOT validate a single value of an empty string ""', () => { + const payload: NonEmptyStringArrayEncoded = ''; + const decoded = NonEmptyStringArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "" supplied to "NonEmptyStringArray"', + ]); + expect(message.schema).toEqual({}); + }); + + test('it should validate a single value of "a" into an array of size 1 of ["a"]', () => { + const payload: NonEmptyStringArrayEncoded = 'a'; + const decoded = NonEmptyStringArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(['a']); + }); + + test('it should validate 2 values of "a,b" into an array of size 2 of ["a", "b"]', () => { + const payload: NonEmptyStringArrayEncoded = 'a,b'; + const decoded = NonEmptyStringArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(['a', 'b']); + }); + + test('it should validate 3 values of "a,b,c" into an array of size 3 of ["a", "b", "c"]', () => { + const payload: NonEmptyStringArrayEncoded = 'a,b,c'; + const decoded = NonEmptyStringArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(['a', 'b', 'c']); + }); + + test('it should NOT validate a number', () => { + const payload: number = 5; + const decoded = NonEmptyStringArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "5" supplied to "NonEmptyStringArray"', + ]); + expect(message.schema).toEqual({}); + }); + + test('it should validate 3 values of " a, b, c " into an array of size 3 of ["a", "b", "c"] even though they have spaces', () => { + const payload: NonEmptyStringArrayEncoded = ' a, b, c '; + const decoded = NonEmptyStringArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(['a', 'b', 'c']); + }); +}); diff --git a/x-pack/plugins/lists/common/schemas/types/non_empty_string_array.ts b/x-pack/plugins/lists/common/schemas/types/non_empty_string_array.ts new file mode 100644 index 0000000000000..c4a640e7cdbad --- /dev/null +++ b/x-pack/plugins/lists/common/schemas/types/non_empty_string_array.ts @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as t from 'io-ts'; +import { Either } from 'fp-ts/lib/Either'; + +/** + * Types the NonEmptyStringArray as: + * - A string that is not empty (which will be turned into an array of size 1) + * - A comma separated string that can turn into an array by splitting on it + * - Example input converted to output: "a,b,c" -> ["a", "b", "c"] + */ +export const NonEmptyStringArray = new t.Type( + 'NonEmptyStringArray', + t.array(t.string).is, + (input, context): Either => { + if (typeof input === 'string' && input.trim() !== '') { + const arrayValues = input + .trim() + .split(',') + .map((value) => value.trim()); + const emptyValueFound = arrayValues.some((value) => value === ''); + if (emptyValueFound) { + return t.failure(input, context); + } else { + return t.success(arrayValues); + } + } else { + return t.failure(input, context); + } + }, + String +); + +export type NonEmptyStringArrayC = typeof NonEmptyStringArray; + +export type NonEmptyStringArrayEncoded = t.OutputOf; +export type NonEmptyStringArrayDecoded = t.TypeOf; diff --git a/x-pack/plugins/lists/server/routes/find_exception_list_item_route.ts b/x-pack/plugins/lists/server/routes/find_exception_list_item_route.ts index a6c2a18bb8c8a..a318d653450c7 100644 --- a/x-pack/plugins/lists/server/routes/find_exception_list_item_route.ts +++ b/x-pack/plugins/lists/server/routes/find_exception_list_item_route.ts @@ -44,26 +44,34 @@ export const findExceptionListItemRoute = (router: IRouter): void => { sort_field: sortField, sort_order: sortOrder, } = request.query; - const exceptionListItems = await exceptionLists.findExceptionListItem({ - filter, - listId, - namespaceType, - page, - perPage, - sortField, - sortOrder, - }); - if (exceptionListItems == null) { + + if (listId.length !== namespaceType.length) { return siemResponse.error({ - body: `list id: "${listId}" does not exist`, - statusCode: 404, + body: `list_id and namespace_id need to have the same comma separated number of values. Expected list_id length: ${listId.length} to equal namespace_type length: ${namespaceType.length}`, + statusCode: 400, }); - } - const [validated, errors] = validate(exceptionListItems, foundExceptionListItemSchema); - if (errors != null) { - return siemResponse.error({ body: errors, statusCode: 500 }); } else { - return response.ok({ body: validated ?? {} }); + const exceptionListItems = await exceptionLists.findExceptionListsItem({ + filter, + listId, + namespaceType, + page, + perPage, + sortField, + sortOrder, + }); + if (exceptionListItems == null) { + return siemResponse.error({ + body: `list id: "${listId}" does not exist`, + statusCode: 404, + }); + } + const [validated, errors] = validate(exceptionListItems, foundExceptionListItemSchema); + if (errors != null) { + return siemResponse.error({ body: errors, statusCode: 500 }); + } else { + return response.ok({ body: validated ?? {} }); + } } } catch (err) { const error = transformError(err); diff --git a/x-pack/plugins/lists/server/scripts/delete_all_exception_lists.sh b/x-pack/plugins/lists/server/scripts/delete_all_exception_lists.sh index bb431800c56c3..3241bb8411916 100755 --- a/x-pack/plugins/lists/server/scripts/delete_all_exception_lists.sh +++ b/x-pack/plugins/lists/server/scripts/delete_all_exception_lists.sh @@ -7,7 +7,7 @@ set -e ./check_env_variables.sh -# Example: ./delete_all_alerts.sh +# Example: ./delete_all_exception_lists.sh # https://www.elastic.co/guide/en/elasticsearch/reference/current/docs-delete-by-query.html curl -s -k \ -H "Content-Type: application/json" \ diff --git a/x-pack/plugins/lists/server/scripts/exception_lists/new/exception_list.json b/x-pack/plugins/lists/server/scripts/exception_lists/new/exception_list.json index 520bc4ddf1e09..19027ac189a47 100644 --- a/x-pack/plugins/lists/server/scripts/exception_lists/new/exception_list.json +++ b/x-pack/plugins/lists/server/scripts/exception_lists/new/exception_list.json @@ -1,8 +1,8 @@ { - "list_id": "endpoint_list", + "list_id": "simple_list", "_tags": ["endpoint", "process", "malware", "os:linux"], "tags": ["user added string for a tag", "malware"], - "type": "endpoint", + "type": "detection", "description": "This is a sample endpoint type exception", "name": "Sample Endpoint Exception List" } diff --git a/x-pack/plugins/lists/server/scripts/exception_lists/new/exception_list_item.json b/x-pack/plugins/lists/server/scripts/exception_lists/new/exception_list_item.json index 8663be5d649e5..eede855aab199 100644 --- a/x-pack/plugins/lists/server/scripts/exception_lists/new/exception_list_item.json +++ b/x-pack/plugins/lists/server/scripts/exception_lists/new/exception_list_item.json @@ -1,6 +1,6 @@ { - "list_id": "endpoint_list", - "item_id": "endpoint_list_item", + "list_id": "simple_list", + "item_id": "simple_list_item", "_tags": ["endpoint", "process", "malware", "os:linux"], "tags": ["user added string for a tag", "malware"], "type": "simple", diff --git a/x-pack/plugins/lists/server/scripts/exception_lists/new/exception_list_item_with_list.json b/x-pack/plugins/lists/server/scripts/exception_lists/new/exception_list_item_with_list.json index 3d6253fcb58ad..e0d401eff9269 100644 --- a/x-pack/plugins/lists/server/scripts/exception_lists/new/exception_list_item_with_list.json +++ b/x-pack/plugins/lists/server/scripts/exception_lists/new/exception_list_item_with_list.json @@ -18,7 +18,7 @@ "field": "source.ip", "operator": "excluded", "type": "list", - "list": { "id": "list-ip", "type": "ip" } + "list": { "id": "ip_list", "type": "ip" } } ] } diff --git a/x-pack/plugins/lists/server/scripts/export_list_items_to_file.sh b/x-pack/plugins/lists/server/scripts/export_list_items_to_file.sh index 5efad01e9a68e..ba8f1cd0477a1 100755 --- a/x-pack/plugins/lists/server/scripts/export_list_items_to_file.sh +++ b/x-pack/plugins/lists/server/scripts/export_list_items_to_file.sh @@ -21,6 +21,6 @@ pushd ${FOLDER} > /dev/null curl -s -k -OJ \ -H 'kbn-xsrf: 123' \ -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ - -X POST "${KIBANA_URL}${SPACE_URL}/api/lists/items/_export?list_id=list-ip" + -X POST "${KIBANA_URL}${SPACE_URL}/api/lists/items/_export?list_id=ip_list" popd > /dev/null diff --git a/x-pack/plugins/lists/server/scripts/find_exception_list_items.sh b/x-pack/plugins/lists/server/scripts/find_exception_list_items.sh index e3f21da56d1b7..ff720afba4157 100755 --- a/x-pack/plugins/lists/server/scripts/find_exception_list_items.sh +++ b/x-pack/plugins/lists/server/scripts/find_exception_list_items.sh @@ -9,12 +9,23 @@ set -e ./check_env_variables.sh -LIST_ID=${1:-endpoint_list} +LIST_ID=${1:-simple_list} NAMESPACE_TYPE=${2-single} -# Example: ./find_exception_list_items.sh {list-id} -# Example: ./find_exception_list_items.sh {list-id} single -# Example: ./find_exception_list_items.sh {list-id} agnostic +# First, post two different lists and two list items for the example to work +# ./post_exception_list.sh ./exception_lists/new/exception_list.json +# ./post_exception_list_item.sh ./exception_lists/new/exception_list_item.json +# +# ./post_exception_list.sh ./exception_lists/new/exception_list_agnostic.json +# ./post_exception_list_item.sh ./exception_lists/new/exception_list_item_agnostic.json + +# Querying a single list item aginst each type +# Example: ./find_exception_list_items.sh simple_list +# Example: ./find_exception_list_items.sh simple_list single +# Example: ./find_exception_list_items.sh endpoint_list agnostic +# +# Finding multiple list id's across multiple spaces +# Example: ./find_exception_list_items.sh simple_list,endpoint_list single,agnostic curl -s -k \ -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ -X GET "${KIBANA_URL}${SPACE_URL}/api/exception_lists/items/_find?list_id=${LIST_ID}&namespace_type=${NAMESPACE_TYPE}" | jq . diff --git a/x-pack/plugins/lists/server/scripts/find_exception_list_items_by_filter.sh b/x-pack/plugins/lists/server/scripts/find_exception_list_items_by_filter.sh index 57313275ccd0e..79e66be42e441 100755 --- a/x-pack/plugins/lists/server/scripts/find_exception_list_items_by_filter.sh +++ b/x-pack/plugins/lists/server/scripts/find_exception_list_items_by_filter.sh @@ -9,7 +9,7 @@ set -e ./check_env_variables.sh -LIST_ID=${1:-endpoint_list} +LIST_ID=${1:-simple_list} FILTER=${2:-'exception-list.attributes.name:%20Sample%20Endpoint%20Exception%20List'} NAMESPACE_TYPE=${3-single} @@ -17,13 +17,23 @@ NAMESPACE_TYPE=${3-single} # The %22 is just an encoded quote of " # Table of them for testing if needed: https://www.w3schools.com/tags/ref_urlencode.asp -# Example: ./find_exception_list_items_by_filter.sh endpoint_list exception-list.attributes.name:%20Sample%20Endpoint%20Exception%20List -# Example: ./find_exception_list_items_by_filter.sh endpoint_list exception-list.attributes.name:%20Sample%20Endpoint%20Exception%20List single -# Example: ./find_exception_list_items_by_filter.sh endpoint_list exception-list.attributes.name:%20Sample%20Endpoint%20Exception%20List agnostic +# First, post two different lists and two list items for the example to work +# ./post_exception_list.sh ./exception_lists/new/exception_list.json +# ./post_exception_list_item.sh ./exception_lists/new/exception_list_item.json # -# Example: ./find_exception_list_items_by_filter.sh endpoint_list exception-list.attributes.entries.field:actingProcess.file.signer -# Example: ./find_exception_list_items_by_filter.sh endpoint_list "exception-list.attributes.entries.field:actingProcess.file.signe*" -# Example: ./find_exception_list_items_by_filter.sh endpoint_list "exception-list.attributes.entries.match:Elastic*%20AND%20exception-list.attributes.entries.field:actingProcess.file.signe*" +# ./post_exception_list.sh ./exception_lists/new/exception_list_agnostic.json +# ./post_exception_list_item.sh ./exception_lists/new/exception_list_item_agnostic.json + +# Example: ./find_exception_list_items_by_filter.sh simple_list exception-list.attributes.name:%20Sample%20Endpoint%20Exception%20List +# Example: ./find_exception_list_items_by_filter.sh simple_list exception-list.attributes.name:%20Sample%20Endpoint%20Exception%20List single +# Example: ./find_exception_list_items_by_filter.sh endpoint_list exception-list-agnostic.attributes.name:%20Sample%20Endpoint%20Exception%20List agnostic +# +# Example: ./find_exception_list_items_by_filter.sh simple_list exception-list.attributes.entries.field:actingProcess.file.signer +# Example: ./find_exception_list_items_by_filter.sh simple_list "exception-list.attributes.entries.field:actingProcess.file.signe*" +# Example: ./find_exception_list_items_by_filter.sh simple_list "exception-list.attributes.entries.field:actingProcess.file.signe*%20AND%20exception-list.attributes.entries.field:actingProcess.file.signe*" +# +# Example with multiplie lists, and multiple filters +# Example: ./find_exception_list_items_by_filter.sh simple_list,endpoint_list "exception-list.attributes.name:%20Sample%20Endpoint%20Exception%20List,exception-list-agnostic.attributes.name:%20Sample%20Endpoint%20Exception%20List" single,agnostic curl -s -k \ -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ -X GET "${KIBANA_URL}${SPACE_URL}/api/exception_lists/items/_find?list_id=${LIST_ID}&filter=${FILTER}&namespace_type=${NAMESPACE_TYPE}" | jq . diff --git a/x-pack/plugins/lists/server/scripts/find_list_items.sh b/x-pack/plugins/lists/server/scripts/find_list_items.sh index 9c8bfd2d5a490..d475da3db61f1 100755 --- a/x-pack/plugins/lists/server/scripts/find_list_items.sh +++ b/x-pack/plugins/lists/server/scripts/find_list_items.sh @@ -9,11 +9,11 @@ set -e ./check_env_variables.sh -LIST_ID=${1-list-ip} +LIST_ID=${1-ip_list} PAGE=${2-1} PER_PAGE=${3-20} -# Example: ./find_list_items.sh list-ip 1 20 +# Example: ./find_list_items.sh ip_list 1 20 curl -s -k \ -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ -X GET "${KIBANA_URL}${SPACE_URL}/api/lists/items/_find?list_id=${LIST_ID}&page=${PAGE}&per_page=${PER_PAGE}" | jq . diff --git a/x-pack/plugins/lists/server/scripts/find_list_items_with_cursor.sh b/x-pack/plugins/lists/server/scripts/find_list_items_with_cursor.sh index 8924012cf62cf..38cef7c98994b 100755 --- a/x-pack/plugins/lists/server/scripts/find_list_items_with_cursor.sh +++ b/x-pack/plugins/lists/server/scripts/find_list_items_with_cursor.sh @@ -9,7 +9,7 @@ set -e ./check_env_variables.sh -LIST_ID=${1-list-ip} +LIST_ID=${1-ip_list} PAGE=${2-1} PER_PAGE=${3-20} CURSOR=${4-invalid} @@ -17,7 +17,7 @@ CURSOR=${4-invalid} # Example: # ./find_list_items.sh 1 20 | jq .cursor # Copy the cursor into the argument below like so -# ./find_list_items_with_cursor.sh list-ip 1 10 eyJwYWdlX2luZGV4IjoyMCwic2VhcmNoX2FmdGVyIjpbIjAyZDZlNGY3LWUzMzAtNGZkYi1iNTY0LTEzZjNiOTk1MjRiYSJdfQ== +# ./find_list_items_with_cursor.sh ip_list 1 10 eyJwYWdlX2luZGV4IjoyMCwic2VhcmNoX2FmdGVyIjpbIjAyZDZlNGY3LWUzMzAtNGZkYi1iNTY0LTEzZjNiOTk1MjRiYSJdfQ== curl -s -k \ -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ -X GET "${KIBANA_URL}${SPACE_URL}/api/lists/items/_find?list_id=${LIST_ID}&page=${PAGE}&per_page=${PER_PAGE}&cursor=${CURSOR}" | jq . diff --git a/x-pack/plugins/lists/server/scripts/find_list_items_with_sort.sh b/x-pack/plugins/lists/server/scripts/find_list_items_with_sort.sh index 37d80c3dd3f28..eb4b23236b7d4 100755 --- a/x-pack/plugins/lists/server/scripts/find_list_items_with_sort.sh +++ b/x-pack/plugins/lists/server/scripts/find_list_items_with_sort.sh @@ -9,13 +9,13 @@ set -e ./check_env_variables.sh -LIST_ID=${1-list-ip} +LIST_ID=${1-ip_list} PAGE=${2-1} PER_PAGE=${3-20} SORT_FIELD=${4-value} SORT_ORDER=${4-asc} -# Example: ./find_list_items_with_sort.sh list-ip 1 20 value asc +# Example: ./find_list_items_with_sort.sh ip_list 1 20 value asc curl -s -k \ -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ -X GET "${KIBANA_URL}${SPACE_URL}/api/lists/items/_find?list_id=${LIST_ID}&page=${PAGE}&per_page=${PER_PAGE}&sort_field=${SORT_FIELD}&sort_order=${SORT_ORDER}" | jq . diff --git a/x-pack/plugins/lists/server/scripts/find_list_items_with_sort_cursor.sh b/x-pack/plugins/lists/server/scripts/find_list_items_with_sort_cursor.sh index 27d8deb2fc95a..289f9be82f209 100755 --- a/x-pack/plugins/lists/server/scripts/find_list_items_with_sort_cursor.sh +++ b/x-pack/plugins/lists/server/scripts/find_list_items_with_sort_cursor.sh @@ -9,14 +9,14 @@ set -e ./check_env_variables.sh -LIST_ID=${1-list-ip} +LIST_ID=${1-ip_list} PAGE=${2-1} PER_PAGE=${3-20} SORT_FIELD=${4-value} SORT_ORDER=${5-asc} CURSOR=${6-invalid} -# Example: ./find_list_items_with_sort_cursor.sh list-ip 1 20 value asc +# Example: ./find_list_items_with_sort_cursor.sh ip_list 1 20 value asc curl -s -k \ -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ -X GET "${KIBANA_URL}${SPACE_URL}/api/lists/items/_find?list_id=${LIST_ID}&page=${PAGE}&per_page=${PER_PAGE}&sort_field=${SORT_FIELD}&sort_order=${SORT_ORDER}&cursor=${CURSOR}" | jq . diff --git a/x-pack/plugins/lists/server/scripts/import_list_items.sh b/x-pack/plugins/lists/server/scripts/import_list_items.sh index a39409cd08267..2ef01fdeed343 100755 --- a/x-pack/plugins/lists/server/scripts/import_list_items.sh +++ b/x-pack/plugins/lists/server/scripts/import_list_items.sh @@ -10,10 +10,10 @@ set -e ./check_env_variables.sh # Uses a defaults if no argument is specified -LIST_ID=${1:-list-ip} +LIST_ID=${1:-ip_list} FILE=${2:-./lists/files/ips.txt} -# ./import_list_items.sh list-ip ./lists/files/ips.txt +# ./import_list_items.sh ip_list ./lists/files/ips.txt curl -s -k \ -H 'kbn-xsrf: 123' \ -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ diff --git a/x-pack/plugins/lists/server/scripts/lists/new/list_ip_item.json b/x-pack/plugins/lists/server/scripts/lists/new/list_ip_item.json deleted file mode 100644 index d150cfaecc202..0000000000000 --- a/x-pack/plugins/lists/server/scripts/lists/new/list_ip_item.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "id": "hand_inserted_item_id", - "list_id": "list-ip", - "value": "10.4.3.11" -} diff --git a/x-pack/plugins/lists/server/services/exception_lists/create_exception_list_item.ts b/x-pack/plugins/lists/server/services/exception_lists/create_exception_list_item.ts index a731371a6ffac..1acc880c851a6 100644 --- a/x-pack/plugins/lists/server/services/exception_lists/create_exception_list_item.ts +++ b/x-pack/plugins/lists/server/services/exception_lists/create_exception_list_item.ts @@ -82,5 +82,5 @@ export const createExceptionListItem = async ({ type, updated_by: user, }); - return transformSavedObjectToExceptionListItem({ namespaceType, savedObject }); + return transformSavedObjectToExceptionListItem({ savedObject }); }; diff --git a/x-pack/plugins/lists/server/services/exception_lists/exception_list_client.ts b/x-pack/plugins/lists/server/services/exception_lists/exception_list_client.ts index 73c52fb8b3ec9..62afda52bd79d 100644 --- a/x-pack/plugins/lists/server/services/exception_lists/exception_list_client.ts +++ b/x-pack/plugins/lists/server/services/exception_lists/exception_list_client.ts @@ -21,6 +21,7 @@ import { DeleteExceptionListOptions, FindExceptionListItemOptions, FindExceptionListOptions, + FindExceptionListsItemOptions, GetExceptionListItemOptions, GetExceptionListOptions, UpdateExceptionListItemOptions, @@ -36,6 +37,7 @@ import { deleteExceptionList } from './delete_exception_list'; import { deleteExceptionListItem } from './delete_exception_list_item'; import { findExceptionListItem } from './find_exception_list_item'; import { findExceptionList } from './find_exception_list'; +import { findExceptionListsItem } from './find_exception_list_items'; export class ExceptionListClient { private readonly user: string; @@ -229,6 +231,28 @@ export class ExceptionListClient { }); }; + public findExceptionListsItem = async ({ + listId, + filter, + perPage, + page, + sortField, + sortOrder, + namespaceType, + }: FindExceptionListsItemOptions): Promise => { + const { savedObjectsClient } = this; + return findExceptionListsItem({ + filter, + listId, + namespaceType, + page, + perPage, + savedObjectsClient, + sortField, + sortOrder, + }); + }; + public findExceptionList = async ({ filter, perPage, diff --git a/x-pack/plugins/lists/server/services/exception_lists/exception_list_client_types.ts b/x-pack/plugins/lists/server/services/exception_lists/exception_list_client_types.ts index 3eff2c7e202e7..b3070f2d4a70d 100644 --- a/x-pack/plugins/lists/server/services/exception_lists/exception_list_client_types.ts +++ b/x-pack/plugins/lists/server/services/exception_lists/exception_list_client_types.ts @@ -6,6 +6,9 @@ import { SavedObjectsClientContract } from 'kibana/server'; +import { NamespaceTypeArray } from '../../../common/schemas/types/default_namespace_array'; +import { NonEmptyStringArrayDecoded } from '../../../common/schemas/types/non_empty_string_array'; +import { EmptyStringArrayDecoded } from '../../../common/schemas/types/empty_string_array'; import { CreateCommentsArray, Description, @@ -127,6 +130,16 @@ export interface FindExceptionListItemOptions { sortOrder: SortOrderOrUndefined; } +export interface FindExceptionListsItemOptions { + listId: NonEmptyStringArrayDecoded; + namespaceType: NamespaceTypeArray; + filter: EmptyStringArrayDecoded; + perPage: PerPageOrUndefined; + page: PageOrUndefined; + sortField: SortFieldOrUndefined; + sortOrder: SortOrderOrUndefined; +} + export interface FindExceptionListOptions { namespaceType: NamespaceType; filter: FilterOrUndefined; diff --git a/x-pack/plugins/lists/server/services/exception_lists/find_exception_list_item.ts b/x-pack/plugins/lists/server/services/exception_lists/find_exception_list_item.ts index 1c3103ad1db7e..e997ff5f9adf1 100644 --- a/x-pack/plugins/lists/server/services/exception_lists/find_exception_list_item.ts +++ b/x-pack/plugins/lists/server/services/exception_lists/find_exception_list_item.ts @@ -7,7 +7,6 @@ import { SavedObjectsClientContract } from 'kibana/server'; import { - ExceptionListSoSchema, FilterOrUndefined, FoundExceptionListItemSchema, ListId, @@ -17,10 +16,8 @@ import { SortFieldOrUndefined, SortOrderOrUndefined, } from '../../../common/schemas'; -import { SavedObjectType } from '../../saved_objects'; -import { getSavedObjectType, transformSavedObjectsToFoundExceptionListItem } from './utils'; -import { getExceptionList } from './get_exception_list'; +import { findExceptionListsItem } from './find_exception_list_items'; interface FindExceptionListItemOptions { listId: ListId; @@ -43,43 +40,14 @@ export const findExceptionListItem = async ({ sortField, sortOrder, }: FindExceptionListItemOptions): Promise => { - const savedObjectType = getSavedObjectType({ namespaceType }); - const exceptionList = await getExceptionList({ - id: undefined, - listId, - namespaceType, + return findExceptionListsItem({ + filter: filter != null ? [filter] : [], + listId: [listId], + namespaceType: [namespaceType], + page, + perPage, savedObjectsClient, + sortField, + sortOrder, }); - if (exceptionList == null) { - return null; - } else { - const savedObjectsFindResponse = await savedObjectsClient.find({ - filter: getExceptionListItemFilter({ filter, listId, savedObjectType }), - page, - perPage, - sortField, - sortOrder, - type: savedObjectType, - }); - return transformSavedObjectsToFoundExceptionListItem({ - namespaceType, - savedObjectsFindResponse, - }); - } -}; - -export const getExceptionListItemFilter = ({ - filter, - listId, - savedObjectType, -}: { - listId: ListId; - filter: FilterOrUndefined; - savedObjectType: SavedObjectType; -}): string => { - if (filter == null) { - return `${savedObjectType}.attributes.list_type: item AND ${savedObjectType}.attributes.list_id: ${listId}`; - } else { - return `${savedObjectType}.attributes.list_type: item AND ${savedObjectType}.attributes.list_id: ${listId} AND ${filter}`; - } }; diff --git a/x-pack/plugins/lists/server/services/exception_lists/find_exception_list_items.test.ts b/x-pack/plugins/lists/server/services/exception_lists/find_exception_list_items.test.ts new file mode 100644 index 0000000000000..a2fbb39103769 --- /dev/null +++ b/x-pack/plugins/lists/server/services/exception_lists/find_exception_list_items.test.ts @@ -0,0 +1,94 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { LIST_ID } from '../../../common/constants.mock'; + +import { getExceptionListsItemFilter } from './find_exception_list_items'; + +describe('find_exception_list_items', () => { + describe('getExceptionListsItemFilter', () => { + test('It should create a filter with a single listId with an empty filter', () => { + const filter = getExceptionListsItemFilter({ + filter: [], + listId: [LIST_ID], + savedObjectType: ['exception-list'], + }); + expect(filter).toEqual( + '(exception-list.attributes.list_type: item AND exception-list.attributes.list_id: some-list-id)' + ); + }); + + test('It should create a filter with a single listId with a single filter', () => { + const filter = getExceptionListsItemFilter({ + filter: ['exception-list.attributes.name: "Sample Endpoint Exception List"'], + listId: [LIST_ID], + savedObjectType: ['exception-list'], + }); + expect(filter).toEqual( + '((exception-list.attributes.list_type: item AND exception-list.attributes.list_id: some-list-id) AND exception-list.attributes.name: "Sample Endpoint Exception List")' + ); + }); + + test('It should create a filter with 2 listIds and an empty filter', () => { + const filter = getExceptionListsItemFilter({ + filter: [], + listId: ['list-1', 'list-2'], + savedObjectType: ['exception-list', 'exception-list-agnostic'], + }); + expect(filter).toEqual( + '(exception-list.attributes.list_type: item AND exception-list.attributes.list_id: list-1) OR (exception-list-agnostic.attributes.list_type: item AND exception-list-agnostic.attributes.list_id: list-2)' + ); + }); + + test('It should create a filter with 2 listIds and a single filter', () => { + const filter = getExceptionListsItemFilter({ + filter: ['exception-list.attributes.name: "Sample Endpoint Exception List"'], + listId: ['list-1', 'list-2'], + savedObjectType: ['exception-list', 'exception-list-agnostic'], + }); + expect(filter).toEqual( + '((exception-list.attributes.list_type: item AND exception-list.attributes.list_id: list-1) AND exception-list.attributes.name: "Sample Endpoint Exception List") OR (exception-list-agnostic.attributes.list_type: item AND exception-list-agnostic.attributes.list_id: list-2)' + ); + }); + + test('It should create a filter with 3 listIds and an empty filter', () => { + const filter = getExceptionListsItemFilter({ + filter: [], + listId: ['list-1', 'list-2', 'list-3'], + savedObjectType: ['exception-list', 'exception-list-agnostic', 'exception-list-agnostic'], + }); + expect(filter).toEqual( + '(exception-list.attributes.list_type: item AND exception-list.attributes.list_id: list-1) OR (exception-list-agnostic.attributes.list_type: item AND exception-list-agnostic.attributes.list_id: list-2) OR (exception-list-agnostic.attributes.list_type: item AND exception-list-agnostic.attributes.list_id: list-3)' + ); + }); + + test('It should create a filter with 3 listIds and a single filter for the first item', () => { + const filter = getExceptionListsItemFilter({ + filter: ['exception-list.attributes.name: "Sample Endpoint Exception List"'], + listId: ['list-1', 'list-2', 'list-3'], + savedObjectType: ['exception-list', 'exception-list-agnostic', 'exception-list-agnostic'], + }); + expect(filter).toEqual( + '((exception-list.attributes.list_type: item AND exception-list.attributes.list_id: list-1) AND exception-list.attributes.name: "Sample Endpoint Exception List") OR (exception-list-agnostic.attributes.list_type: item AND exception-list-agnostic.attributes.list_id: list-2) OR (exception-list-agnostic.attributes.list_type: item AND exception-list-agnostic.attributes.list_id: list-3)' + ); + }); + + test('It should create a filter with 3 listIds and 3 filters for each', () => { + const filter = getExceptionListsItemFilter({ + filter: [ + 'exception-list.attributes.name: "Sample Endpoint Exception List 1"', + 'exception-list.attributes.name: "Sample Endpoint Exception List 2"', + 'exception-list.attributes.name: "Sample Endpoint Exception List 3"', + ], + listId: ['list-1', 'list-2', 'list-3'], + savedObjectType: ['exception-list', 'exception-list-agnostic', 'exception-list-agnostic'], + }); + expect(filter).toEqual( + '((exception-list.attributes.list_type: item AND exception-list.attributes.list_id: list-1) AND exception-list.attributes.name: "Sample Endpoint Exception List 1") OR ((exception-list-agnostic.attributes.list_type: item AND exception-list-agnostic.attributes.list_id: list-2) AND exception-list.attributes.name: "Sample Endpoint Exception List 2") OR ((exception-list-agnostic.attributes.list_type: item AND exception-list-agnostic.attributes.list_id: list-3) AND exception-list.attributes.name: "Sample Endpoint Exception List 3")' + ); + }); + }); +}); diff --git a/x-pack/plugins/lists/server/services/exception_lists/find_exception_list_items.ts b/x-pack/plugins/lists/server/services/exception_lists/find_exception_list_items.ts new file mode 100644 index 0000000000000..47a0d809cce67 --- /dev/null +++ b/x-pack/plugins/lists/server/services/exception_lists/find_exception_list_items.ts @@ -0,0 +1,94 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { SavedObjectsClientContract } from 'kibana/server'; + +import { EmptyStringArrayDecoded } from '../../../common/schemas/types/empty_string_array'; +import { NamespaceTypeArray } from '../../../common/schemas/types/default_namespace_array'; +import { NonEmptyStringArrayDecoded } from '../../../common/schemas/types/non_empty_string_array'; +import { + ExceptionListSoSchema, + FoundExceptionListItemSchema, + PageOrUndefined, + PerPageOrUndefined, + SortFieldOrUndefined, + SortOrderOrUndefined, +} from '../../../common/schemas'; +import { SavedObjectType } from '../../saved_objects'; + +import { getSavedObjectTypes, transformSavedObjectsToFoundExceptionListItem } from './utils'; +import { getExceptionList } from './get_exception_list'; + +interface FindExceptionListItemsOptions { + listId: NonEmptyStringArrayDecoded; + namespaceType: NamespaceTypeArray; + savedObjectsClient: SavedObjectsClientContract; + filter: EmptyStringArrayDecoded; + perPage: PerPageOrUndefined; + page: PageOrUndefined; + sortField: SortFieldOrUndefined; + sortOrder: SortOrderOrUndefined; +} + +export const findExceptionListsItem = async ({ + listId, + namespaceType, + savedObjectsClient, + filter, + page, + perPage, + sortField, + sortOrder, +}: FindExceptionListItemsOptions): Promise => { + const savedObjectType = getSavedObjectTypes({ namespaceType }); + const exceptionLists = ( + await Promise.all( + listId.map((singleListId, index) => { + return getExceptionList({ + id: undefined, + listId: singleListId, + namespaceType: namespaceType[index], + savedObjectsClient, + }); + }) + ) + ).filter((list) => list != null); + if (exceptionLists.length === 0) { + return null; + } else { + const savedObjectsFindResponse = await savedObjectsClient.find({ + filter: getExceptionListsItemFilter({ filter, listId, savedObjectType }), + page, + perPage, + sortField, + sortOrder, + type: savedObjectType, + }); + return transformSavedObjectsToFoundExceptionListItem({ + savedObjectsFindResponse, + }); + } +}; + +export const getExceptionListsItemFilter = ({ + filter, + listId, + savedObjectType, +}: { + listId: NonEmptyStringArrayDecoded; + filter: EmptyStringArrayDecoded; + savedObjectType: SavedObjectType[]; +}): string => { + return listId.reduce((accum, singleListId, index) => { + const listItemAppend = `(${savedObjectType[index]}.attributes.list_type: item AND ${savedObjectType[index]}.attributes.list_id: ${singleListId})`; + const listItemAppendWithFilter = + filter[index] != null ? `(${listItemAppend} AND ${filter[index]})` : listItemAppend; + if (accum === '') { + return listItemAppendWithFilter; + } else { + return `${accum} OR ${listItemAppendWithFilter}`; + } + }, ''); +}; diff --git a/x-pack/plugins/lists/server/services/exception_lists/get_exception_list_item.ts b/x-pack/plugins/lists/server/services/exception_lists/get_exception_list_item.ts index d7efdc054c48c..d68863c02148f 100644 --- a/x-pack/plugins/lists/server/services/exception_lists/get_exception_list_item.ts +++ b/x-pack/plugins/lists/server/services/exception_lists/get_exception_list_item.ts @@ -35,7 +35,7 @@ export const getExceptionListItem = async ({ if (id != null) { try { const savedObject = await savedObjectsClient.get(savedObjectType, id); - return transformSavedObjectToExceptionListItem({ namespaceType, savedObject }); + return transformSavedObjectToExceptionListItem({ savedObject }); } catch (err) { if (SavedObjectsErrorHelpers.isNotFoundError(err)) { return null; @@ -55,7 +55,6 @@ export const getExceptionListItem = async ({ }); if (savedObject.saved_objects[0] != null) { return transformSavedObjectToExceptionListItem({ - namespaceType, savedObject: savedObject.saved_objects[0], }); } else { diff --git a/x-pack/plugins/lists/server/services/exception_lists/index.ts b/x-pack/plugins/lists/server/services/exception_lists/index.ts index a66f00819605b..510b2c70c6c94 100644 --- a/x-pack/plugins/lists/server/services/exception_lists/index.ts +++ b/x-pack/plugins/lists/server/services/exception_lists/index.ts @@ -4,13 +4,15 @@ * you may not use this file except in compliance with the Elastic License. */ -export * from './create_exception_list_item'; export * from './create_exception_list'; -export * from './delete_exception_list_item'; +export * from './create_exception_list_item'; export * from './delete_exception_list'; +export * from './delete_exception_list_item'; +export * from './delete_exception_list_items_by_list'; export * from './find_exception_list'; export * from './find_exception_list_item'; -export * from './get_exception_list_item'; +export * from './find_exception_list_items'; export * from './get_exception_list'; -export * from './update_exception_list_item'; +export * from './get_exception_list_item'; export * from './update_exception_list'; +export * from './update_exception_list_item'; diff --git a/x-pack/plugins/lists/server/services/exception_lists/utils.ts b/x-pack/plugins/lists/server/services/exception_lists/utils.ts index ab54647430b9b..ad1e1a3439d7c 100644 --- a/x-pack/plugins/lists/server/services/exception_lists/utils.ts +++ b/x-pack/plugins/lists/server/services/exception_lists/utils.ts @@ -6,6 +6,7 @@ import { SavedObject, SavedObjectsFindResponse, SavedObjectsUpdateResponse } from 'kibana/server'; +import { NamespaceTypeArray } from '../../../common/schemas/types/default_namespace_array'; import { ErrorWithStatusCode } from '../../error_with_status_code'; import { Comments, @@ -42,6 +43,28 @@ export const getSavedObjectType = ({ } }; +export const getExceptionListType = ({ + savedObjectType, +}: { + savedObjectType: string; +}): NamespaceType => { + if (savedObjectType === exceptionListAgnosticSavedObjectType) { + return 'agnostic'; + } else { + return 'single'; + } +}; + +export const getSavedObjectTypes = ({ + namespaceType, +}: { + namespaceType: NamespaceTypeArray; +}): SavedObjectType[] => { + return namespaceType.map((singleNamespaceType) => + getSavedObjectType({ namespaceType: singleNamespaceType }) + ); +}; + export const transformSavedObjectToExceptionList = ({ savedObject, namespaceType, @@ -126,10 +149,8 @@ export const transformSavedObjectUpdateToExceptionList = ({ export const transformSavedObjectToExceptionListItem = ({ savedObject, - namespaceType, }: { savedObject: SavedObject; - namespaceType: NamespaceType; }): ExceptionListItemSchema => { const dateNow = new Date().toISOString(); const { @@ -167,7 +188,7 @@ export const transformSavedObjectToExceptionListItem = ({ list_id, meta, name, - namespace_type: namespaceType, + namespace_type: getExceptionListType({ savedObjectType: savedObject.type }), tags, tie_breaker_id, type: exceptionListItemType.is(type) ? type : 'simple', @@ -229,14 +250,12 @@ export const transformSavedObjectUpdateToExceptionListItem = ({ export const transformSavedObjectsToFoundExceptionListItem = ({ savedObjectsFindResponse, - namespaceType, }: { savedObjectsFindResponse: SavedObjectsFindResponse; - namespaceType: NamespaceType; }): FoundExceptionListItemSchema => { return { data: savedObjectsFindResponse.saved_objects.map((savedObject) => - transformSavedObjectToExceptionListItem({ namespaceType, savedObject }) + transformSavedObjectToExceptionListItem({ savedObject }) ), page: savedObjectsFindResponse.page, per_page: savedObjectsFindResponse.per_page, From 56a2437a6c8353a1fb96e5d3ce588735dab96541 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yulia=20=C4=8Cech?= <6585477+yuliacech@users.noreply.github.com> Date: Tue, 14 Jul 2020 03:10:07 +0200 Subject: [PATCH 52/66] [ILM] Fix alignment of the timing field (#71273) --- .../sections/edit_policy/components/min_age_input.js | 4 ++-- .../components/snapshot_policies/snapshot_policies.tsx | 1 - 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/min_age_input.js b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/min_age_input.js index cd690c768a326..d90ad9378efd4 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/min_age_input.js +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/min_age_input.js @@ -179,7 +179,7 @@ export const MinAgeInput = (props) => { return ( - + { /> - + = ({ value, onChan Date: Tue, 14 Jul 2020 02:14:29 +0100 Subject: [PATCH 53/66] [test] Skips test preventing promotion of ES snapshot #71555 --- .../security_and_spaces/tests/create_rules.ts | 3 ++- .../security_and_spaces/tests/create_rules_bulk.ts | 3 +-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_rules.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_rules.ts index c763be1c2c3ec..73d39b600cf11 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_rules.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_rules.ts @@ -31,7 +31,8 @@ export default ({ getService }: FtrProviderContext) => { const supertest = getService('supertest'); const es = getService('es'); - describe('create_rules', () => { + // Preventing ES promotion: https://github.com/elastic/kibana/issues/71555 + describe.skip('create_rules', () => { describe('validation errors', () => { it('should give an error that the index must exist first if it does not exist before creating a rule', async () => { const { body } = await supertest diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_rules_bulk.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_rules_bulk.ts index 897738d0919f2..52865e43be750 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_rules_bulk.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_rules_bulk.ts @@ -29,8 +29,7 @@ export default ({ getService }: FtrProviderContext): void => { const supertest = getService('supertest'); const es = getService('es'); - // Preventing ES promotion: https://github.com/elastic/kibana/issues/71555 - describe.skip('create_rules_bulk', () => { + describe('create_rules_bulk', () => { describe('validation errors', () => { it('should give a 200 even if the index does not exist as all bulks return a 200 but have an error of 409 bad request in the body', async () => { const { body } = await supertest From 683fb42df73e5ca92be299f8112d29c0a4037bab Mon Sep 17 00:00:00 2001 From: Tiago Costa Date: Tue, 14 Jul 2020 02:33:00 +0100 Subject: [PATCH 54/66] [test] Skips test preventing promotion of ES snapshot #71582 --- .../security_and_spaces/tests/alerting/alerts.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/alerts.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/alerts.ts index ab58a205f9d47..dce809f0b7be9 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/alerts.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/alerts.ts @@ -26,7 +26,8 @@ export default function alertTests({ getService }: FtrProviderContext) { const esTestIndexTool = new ESTestIndexTool(es, retry); const taskManagerUtils = new TaskManagerUtils(es, retry); - describe('alerts', () => { + // Failing ES promotion: https://github.com/elastic/kibana/issues/71582 + describe.skip('alerts', () => { const authorizationIndex = '.kibana-test-authorization'; const objectRemover = new ObjectRemover(supertest); From 835c13dd6abdb39280784ce6dc1f170ae9894533 Mon Sep 17 00:00:00 2001 From: Ryland Herrick Date: Mon, 13 Jul 2020 21:11:08 -0500 Subject: [PATCH 55/66] [SIEM][Detections] Value Lists Management Modal (#67068) * Add Frontend components for Value Lists Management Modal Imports and uses the hooks provided by the lists plugin. Tests coming next. * Update value list components to use newest Lists API * uses useEffect on a task's state instead of promise chaining * handles the fact that API calls can be rejected with strings * uses exportList function instead of hook * Close modal on outside click * Add hook for using a cursor with paged API calls. For e.g. findLists, we can send along a cursor to optimize our query. On the backend, this cursor is used as part of a search_after query. * Better implementation of useCursor * Does not require args for setCursor as they're already passed to the hook * Finds nearest cursor for the same page size Eventually this logic will also include sortField as part of the hash/lookup, but we do not currently use that on the frontend. * Fixes useCursor hook functionality We were previously storing the cursor on the _current_ page, when it's only truly valid for the _next_ page (and beyond). This was causing a few issues, but now that it's fixed everything works great. * Add cursor to lists query This allows us to search_after a previous page's search, if available. * Do not validate response of export This is just a blob, so we have nothing to validate. * Fix double callback post-import After uploading a list, the modal was being shown twice. Declaring the constituent state dependencies separately fixed the issue. * Update ValueListsForm to manually abort import request These hooks no longer care about/expose an abort function. In this one case where we need that functionality, we can do it ourselves relatively simply. * Default modal table to five rows * Update translation keys following plugin rename * Try to fit table contents on a single row Dates were wrapping (and raw), and so were wrapped in a FormattedDate component. However, since this component didn't wrap, we needed to shrink/truncate the uploaded_by field as well as allow the fileName to truncate. * Add helper function to prevent tests from logging errors https://github.com/enzymejs/enzyme/issues/2073 seems to be an ongoing issue, and causes components with useEffect to update after the test is completed. waitForUpdates ensures that updates have completed within an act() before continuing on. * Add jest tests for our form, table, and modal components * Fix translation conflict * Add more waitForUpdates to new overview page tests Each of these logs a console.error without them. * Fix bad merge resolution That resulted in duplicate exports. * Make cursor an optional parameter to findLists This param is an optimization and not required for basic functionality. * Tweaking Table column sizes Makes actions column smaller, leaving more room for everything else. * Fix bug where onSuccess is called upon pagination change Because fetchLists changes when pagination does, and handleUploadSuccess changes with fetchLists, our useEffect in Form was being fired on every pagination change due to its onSuccess changing. The solution in this instance is to remove fetchLists from handleUploadSuccess's dependencies, as we merely want to invoke fetchLists from it, not change our reference. * Fix failing test It looks like this broke because EuiTable's pagination changed from a button to an anchor tag. * Hide page size options on ValueLists modal table These have style issues, and anything above 5 rows causes the modal to scroll, so we're going to disable it for now. * Update error callbacks now that we have Errors We don't display the nice errors in the case of an ApiError right now, but this is better than it was. * Synchronize delete with the subsequent fetch Our start() no longer resolves in a meaningful way, so we instead need to perform the refetch in an effect watching the result of our delete. * Cast our unknown error to an Error useAsync generally does not know how what its tasks are going to be rejected with, hence the unknown. For these API calls we know that it will be an Error, but I don't currently have a way to type that generally. For now, we'll cast it where we use it. * Import lists code from our new, standardized modules Co-authored-by: Elastic Machine --- x-pack/plugins/lists/common/shared_exports.ts | 1 + .../public/common/hooks/use_cursor.test.ts | 118 ++++++++++++ .../lists/public/common/hooks/use_cursor.ts | 43 +++++ x-pack/plugins/lists/public/lists/api.test.ts | 100 +++++----- x-pack/plugins/lists/public/lists/api.ts | 7 +- x-pack/plugins/lists/public/lists/types.ts | 1 + x-pack/plugins/lists/public/shared_exports.ts | 2 + .../common/shared_imports.ts | 1 + .../public/common/lib/kibana/hooks.ts | 13 +- .../public/common/utils/test_utils.ts | 16 ++ .../form.test.tsx | 109 +++++++++++ .../value_lists_management_modal/form.tsx | 172 ++++++++++++++++++ .../value_lists_management_modal/index.tsx | 7 + .../modal.test.tsx | 63 +++++++ .../value_lists_management_modal/modal.tsx | 164 +++++++++++++++++ .../table.test.tsx | 113 ++++++++++++ .../value_lists_management_modal/table.tsx | 103 +++++++++++ .../translations.ts | 138 ++++++++++++++ .../pages/detection_engine/rules/index.tsx | 14 ++ .../detection_engine/rules/translations.ts | 7 + .../public/overview/pages/overview.test.tsx | 32 +++- .../public/shared_imports.ts | 4 + 22 files changed, 1157 insertions(+), 71 deletions(-) create mode 100644 x-pack/plugins/lists/public/common/hooks/use_cursor.test.ts create mode 100644 x-pack/plugins/lists/public/common/hooks/use_cursor.ts create mode 100644 x-pack/plugins/security_solution/public/common/utils/test_utils.ts create mode 100644 x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/form.test.tsx create mode 100644 x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/form.tsx create mode 100644 x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/index.tsx create mode 100644 x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/modal.test.tsx create mode 100644 x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/modal.tsx create mode 100644 x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/table.test.tsx create mode 100644 x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/table.tsx create mode 100644 x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/translations.ts diff --git a/x-pack/plugins/lists/common/shared_exports.ts b/x-pack/plugins/lists/common/shared_exports.ts index 2ad7e63d38c04..7bb565792969c 100644 --- a/x-pack/plugins/lists/common/shared_exports.ts +++ b/x-pack/plugins/lists/common/shared_exports.ts @@ -39,4 +39,5 @@ export { entriesList, namespaceType, ExceptionListType, + Type, } from './schemas'; diff --git a/x-pack/plugins/lists/public/common/hooks/use_cursor.test.ts b/x-pack/plugins/lists/public/common/hooks/use_cursor.test.ts new file mode 100644 index 0000000000000..b8967086ef956 --- /dev/null +++ b/x-pack/plugins/lists/public/common/hooks/use_cursor.test.ts @@ -0,0 +1,118 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { act, renderHook } from '@testing-library/react-hooks'; + +import { UseCursorProps, useCursor } from './use_cursor'; + +describe('useCursor', () => { + it('returns undefined cursor if no values have been set', () => { + const { result } = renderHook((props: UseCursorProps) => useCursor(props), { + initialProps: { pageIndex: 0, pageSize: 0 }, + }); + + expect(result.current[0]).toBeUndefined(); + }); + + it('retrieves a cursor for the next page of a given page size', () => { + const { rerender, result } = renderHook((props: UseCursorProps) => useCursor(props), { + initialProps: { pageIndex: 0, pageSize: 0 }, + }); + rerender({ pageIndex: 1, pageSize: 1 }); + act(() => { + result.current[1]('new_cursor'); + }); + + expect(result.current[0]).toBeUndefined(); + + rerender({ pageIndex: 2, pageSize: 1 }); + expect(result.current[0]).toEqual('new_cursor'); + }); + + it('returns undefined cursor for an unknown search', () => { + const { rerender, result } = renderHook((props: UseCursorProps) => useCursor(props), { + initialProps: { pageIndex: 0, pageSize: 0 }, + }); + act(() => { + result.current[1]('new_cursor'); + }); + + rerender({ pageIndex: 1, pageSize: 2 }); + expect(result.current[0]).toBeUndefined(); + }); + + it('remembers cursor through rerenders', () => { + const { rerender, result } = renderHook((props: UseCursorProps) => useCursor(props), { + initialProps: { pageIndex: 0, pageSize: 0 }, + }); + + rerender({ pageIndex: 1, pageSize: 1 }); + act(() => { + result.current[1]('new_cursor'); + }); + + rerender({ pageIndex: 2, pageSize: 1 }); + expect(result.current[0]).toEqual('new_cursor'); + + rerender({ pageIndex: 0, pageSize: 0 }); + expect(result.current[0]).toBeUndefined(); + + rerender({ pageIndex: 2, pageSize: 1 }); + expect(result.current[0]).toEqual('new_cursor'); + }); + + it('remembers multiple cursors', () => { + const { rerender, result } = renderHook((props: UseCursorProps) => useCursor(props), { + initialProps: { pageIndex: 0, pageSize: 0 }, + }); + + rerender({ pageIndex: 1, pageSize: 1 }); + act(() => { + result.current[1]('new_cursor'); + }); + rerender({ pageIndex: 2, pageSize: 2 }); + act(() => { + result.current[1]('another_cursor'); + }); + + rerender({ pageIndex: 2, pageSize: 1 }); + expect(result.current[0]).toEqual('new_cursor'); + + rerender({ pageIndex: 3, pageSize: 2 }); + expect(result.current[0]).toEqual('another_cursor'); + }); + + it('returns the "nearest" cursor for the given page size', () => { + const { rerender, result } = renderHook((props: UseCursorProps) => useCursor(props), { + initialProps: { pageIndex: 0, pageSize: 0 }, + }); + + rerender({ pageIndex: 1, pageSize: 2 }); + act(() => { + result.current[1]('cursor1'); + }); + rerender({ pageIndex: 2, pageSize: 2 }); + act(() => { + result.current[1]('cursor2'); + }); + rerender({ pageIndex: 3, pageSize: 2 }); + act(() => { + result.current[1]('cursor3'); + }); + + rerender({ pageIndex: 2, pageSize: 2 }); + expect(result.current[0]).toEqual('cursor1'); + + rerender({ pageIndex: 3, pageSize: 2 }); + expect(result.current[0]).toEqual('cursor2'); + + rerender({ pageIndex: 4, pageSize: 2 }); + expect(result.current[0]).toEqual('cursor3'); + + rerender({ pageIndex: 6, pageSize: 2 }); + expect(result.current[0]).toEqual('cursor3'); + }); +}); diff --git a/x-pack/plugins/lists/public/common/hooks/use_cursor.ts b/x-pack/plugins/lists/public/common/hooks/use_cursor.ts new file mode 100644 index 0000000000000..2409436ff3137 --- /dev/null +++ b/x-pack/plugins/lists/public/common/hooks/use_cursor.ts @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { useCallback, useState } from 'react'; + +export interface UseCursorProps { + pageIndex: number; + pageSize: number; +} +type Cursor = string | undefined; +type SetCursor = (cursor: Cursor) => void; +type UseCursor = (props: UseCursorProps) => [Cursor, SetCursor]; + +const hash = (props: UseCursorProps): string => JSON.stringify(props); + +export const useCursor: UseCursor = ({ pageIndex, pageSize }) => { + const [cache, setCache] = useState>({}); + + const setCursor = useCallback( + (cursor) => { + setCache({ + ...cache, + [hash({ pageIndex: pageIndex + 1, pageSize })]: cursor, + }); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [pageIndex, pageSize] + ); + + let cursor: Cursor; + for (let i = pageIndex; i >= 0; i--) { + const currentProps = { pageIndex: i, pageSize }; + cursor = cache[hash(currentProps)]; + if (cursor) { + break; + } + } + + return [cursor, setCursor]; +}; diff --git a/x-pack/plugins/lists/public/lists/api.test.ts b/x-pack/plugins/lists/public/lists/api.test.ts index d54a3ca654943..d79dc86802399 100644 --- a/x-pack/plugins/lists/public/lists/api.test.ts +++ b/x-pack/plugins/lists/public/lists/api.test.ts @@ -114,6 +114,7 @@ describe('Value Lists API', () => { it('sends pagination as query parameters', async () => { const abortCtrl = new AbortController(); await findLists({ + cursor: 'cursor', http: httpMock, pageIndex: 1, pageSize: 10, @@ -123,14 +124,21 @@ describe('Value Lists API', () => { expect(httpMock.fetch).toHaveBeenCalledWith( '/api/lists/_find', expect.objectContaining({ - query: { page: 1, per_page: 10 }, + query: { + cursor: 'cursor', + page: 1, + per_page: 10, + }, }) ); }); it('rejects with an error if request payload is invalid (and does not make API call)', async () => { const abortCtrl = new AbortController(); - const payload: ApiPayload = { pageIndex: 10, pageSize: 0 }; + const payload: ApiPayload = { + pageIndex: 10, + pageSize: 0, + }; await expect( findLists({ @@ -144,7 +152,10 @@ describe('Value Lists API', () => { it('rejects with an error if response payload is invalid', async () => { const abortCtrl = new AbortController(); - const payload: ApiPayload = { pageIndex: 1, pageSize: 10 }; + const payload: ApiPayload = { + pageIndex: 1, + pageSize: 10, + }; const badResponse = { ...getFoundListSchemaMock(), cursor: undefined }; httpMock.fetch.mockResolvedValue(badResponse); @@ -269,7 +280,7 @@ describe('Value Lists API', () => { describe('exportList', () => { beforeEach(() => { - httpMock.fetch.mockResolvedValue(getListResponseMock()); + httpMock.fetch.mockResolvedValue({}); }); it('POSTs to the export endpoint', async () => { @@ -319,66 +330,49 @@ describe('Value Lists API', () => { ).rejects.toEqual(new Error('Invalid value "23" supplied to "list_id"')); expect(httpMock.fetch).not.toHaveBeenCalled(); }); + }); + + describe('readListIndex', () => { + beforeEach(() => { + httpMock.fetch.mockResolvedValue(getListItemIndexExistSchemaResponseMock()); + }); - it('rejects with an error if response payload is invalid', async () => { + it('GETs the list index', async () => { const abortCtrl = new AbortController(); - const payload: ApiPayload = { - listId: 'list-id', - }; - const badResponse = { ...getListResponseMock(), id: undefined }; - httpMock.fetch.mockResolvedValue(badResponse); + await readListIndex({ + http: httpMock, + signal: abortCtrl.signal, + }); - await expect( - exportList({ - http: httpMock, - ...payload, - signal: abortCtrl.signal, + expect(httpMock.fetch).toHaveBeenCalledWith( + '/api/lists/index', + expect.objectContaining({ + method: 'GET', }) - ).rejects.toEqual(new Error('Invalid value "undefined" supplied to "id"')); + ); }); - describe('readListIndex', () => { - beforeEach(() => { - httpMock.fetch.mockResolvedValue(getListItemIndexExistSchemaResponseMock()); + it('returns the response when valid', async () => { + const abortCtrl = new AbortController(); + const result = await readListIndex({ + http: httpMock, + signal: abortCtrl.signal, }); - it('GETs the list index', async () => { - const abortCtrl = new AbortController(); - await readListIndex({ - http: httpMock, - signal: abortCtrl.signal, - }); - - expect(httpMock.fetch).toHaveBeenCalledWith( - '/api/lists/index', - expect.objectContaining({ - method: 'GET', - }) - ); - }); + expect(result).toEqual(getListItemIndexExistSchemaResponseMock()); + }); + + it('rejects with an error if response payload is invalid', async () => { + const abortCtrl = new AbortController(); + const badResponse = { ...getListItemIndexExistSchemaResponseMock(), list_index: undefined }; + httpMock.fetch.mockResolvedValue(badResponse); - it('returns the response when valid', async () => { - const abortCtrl = new AbortController(); - const result = await readListIndex({ + await expect( + readListIndex({ http: httpMock, signal: abortCtrl.signal, - }); - - expect(result).toEqual(getListItemIndexExistSchemaResponseMock()); - }); - - it('rejects with an error if response payload is invalid', async () => { - const abortCtrl = new AbortController(); - const badResponse = { ...getListItemIndexExistSchemaResponseMock(), list_index: undefined }; - httpMock.fetch.mockResolvedValue(badResponse); - - await expect( - readListIndex({ - http: httpMock, - signal: abortCtrl.signal, - }) - ).rejects.toEqual(new Error('Invalid value "undefined" supplied to "list_index"')); - }); + }) + ).rejects.toEqual(new Error('Invalid value "undefined" supplied to "list_index"')); }); }); diff --git a/x-pack/plugins/lists/public/lists/api.ts b/x-pack/plugins/lists/public/lists/api.ts index a1efae2af877a..606109f1910c4 100644 --- a/x-pack/plugins/lists/public/lists/api.ts +++ b/x-pack/plugins/lists/public/lists/api.ts @@ -59,6 +59,7 @@ const findLists = async ({ }; const findListsWithValidation = async ({ + cursor, http, pageIndex, pageSize, @@ -66,8 +67,9 @@ const findListsWithValidation = async ({ }: FindListsParams): Promise => pipe( { - page: String(pageIndex), - per_page: String(pageSize), + cursor: cursor?.toString(), + page: pageIndex?.toString(), + per_page: pageSize?.toString(), }, (payload) => fromEither(validateEither(findListSchema, payload)), chain((payload) => tryCatch(() => findLists({ http, signal, ...payload }), toError)), @@ -170,7 +172,6 @@ const exportListWithValidation = async ({ { list_id: listId }, (payload) => fromEither(validateEither(exportListItemQuerySchema, payload)), chain((payload) => tryCatch(() => exportList({ http, signal, ...payload }), toError)), - chain((response) => fromEither(validateEither(listSchema, response))), flow(toPromise) ); diff --git a/x-pack/plugins/lists/public/lists/types.ts b/x-pack/plugins/lists/public/lists/types.ts index 6421ad174d4d9..95a21820536e4 100644 --- a/x-pack/plugins/lists/public/lists/types.ts +++ b/x-pack/plugins/lists/public/lists/types.ts @@ -14,6 +14,7 @@ export interface ApiParams { export type ApiPayload = Omit; export interface FindListsParams extends ApiParams { + cursor?: string | undefined; pageSize: number | undefined; pageIndex: number | undefined; } diff --git a/x-pack/plugins/lists/public/shared_exports.ts b/x-pack/plugins/lists/public/shared_exports.ts index dc2e28634e1e8..57fb2f90b6404 100644 --- a/x-pack/plugins/lists/public/shared_exports.ts +++ b/x-pack/plugins/lists/public/shared_exports.ts @@ -13,6 +13,8 @@ export { useExceptionList } from './exceptions/hooks/use_exception_list'; export { useFindLists } from './lists/hooks/use_find_lists'; export { useImportList } from './lists/hooks/use_import_list'; export { useDeleteList } from './lists/hooks/use_delete_list'; +export { exportList } from './lists/api'; +export { useCursor } from './common/hooks/use_cursor'; export { useExportList } from './lists/hooks/use_export_list'; export { useReadListIndex } from './lists/hooks/use_read_list_index'; export { useCreateListIndex } from './lists/hooks/use_create_list_index'; diff --git a/x-pack/plugins/security_solution/common/shared_imports.ts b/x-pack/plugins/security_solution/common/shared_imports.ts index f56f184a5a467..a607906e1b92a 100644 --- a/x-pack/plugins/security_solution/common/shared_imports.ts +++ b/x-pack/plugins/security_solution/common/shared_imports.ts @@ -39,4 +39,5 @@ export { entriesList, namespaceType, ExceptionListType, + Type, } from '../../lists/common'; diff --git a/x-pack/plugins/security_solution/public/common/lib/kibana/hooks.ts b/x-pack/plugins/security_solution/public/common/lib/kibana/hooks.ts index 184aa4d8e673c..2e0ac826c6947 100644 --- a/x-pack/plugins/security_solution/public/common/lib/kibana/hooks.ts +++ b/x-pack/plugins/security_solution/public/common/lib/kibana/hooks.ts @@ -8,12 +8,13 @@ import moment from 'moment-timezone'; import { useCallback, useEffect, useState } from 'react'; import { i18n } from '@kbn/i18n'; + import { DEFAULT_DATE_FORMAT, DEFAULT_DATE_FORMAT_TZ } from '../../../../common/constants'; -import { useUiSetting, useKibana } from './kibana_react'; import { errorToToaster, useStateToaster } from '../../components/toasters'; import { AuthenticatedUser } from '../../../../../security/common/model'; import { convertToCamelCase } from '../../../cases/containers/utils'; import { StartServices } from '../../../types'; +import { useUiSetting, useKibana } from './kibana_react'; export const useDateFormat = (): string => useUiSetting(DEFAULT_DATE_FORMAT); @@ -24,6 +25,11 @@ export const useTimeZone = (): string => { export const useBasePath = (): string => useKibana().services.http.basePath.get(); +export const useToasts = (): StartServices['notifications']['toasts'] => + useKibana().services.notifications.toasts; + +export const useHttp = (): StartServices['http'] => useKibana().services.http; + interface UserRealm { name: string; type: string; @@ -125,8 +131,3 @@ export const useGetUserSavedObjectPermissions = () => { return savedObjectsPermissions; }; - -export const useToasts = (): StartServices['notifications']['toasts'] => - useKibana().services.notifications.toasts; - -export const useHttp = (): StartServices['http'] => useKibana().services.http; diff --git a/x-pack/plugins/security_solution/public/common/utils/test_utils.ts b/x-pack/plugins/security_solution/public/common/utils/test_utils.ts new file mode 100644 index 0000000000000..5a3cddb74657d --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/utils/test_utils.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ReactWrapper } from 'enzyme'; +import { act } from 'react-dom/test-utils'; + +// Temporary fix for https://github.com/enzymejs/enzyme/issues/2073 +export const waitForUpdates = async

(wrapper: ReactWrapper

) => { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + wrapper.update(); + }); +}; diff --git a/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/form.test.tsx b/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/form.test.tsx new file mode 100644 index 0000000000000..ce5d19259e9ee --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/form.test.tsx @@ -0,0 +1,109 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React, { FormEvent } from 'react'; +import { mount, ReactWrapper } from 'enzyme'; +import { act } from 'react-dom/test-utils'; + +import { waitForUpdates } from '../../../common/utils/test_utils'; +import { TestProviders } from '../../../common/mock'; +import { ValueListsForm } from './form'; +import { useImportList } from '../../../shared_imports'; + +jest.mock('../../../shared_imports'); +const mockUseImportList = useImportList as jest.Mock; + +const mockFile = ({ + name: 'foo.csv', + path: '/home/foo.csv', +} as unknown) as File; + +const mockSelectFile:

(container: ReactWrapper

, file: File) => Promise = async ( + container, + file +) => { + const fileChange = container.find('EuiFilePicker').prop('onChange'); + act(() => { + if (fileChange) { + fileChange(([file] as unknown) as FormEvent); + } + }); + await waitForUpdates(container); + expect( + container.find('button[data-test-subj="value-lists-form-import-action"]').prop('disabled') + ).not.toEqual(true); +}; + +describe('ValueListsForm', () => { + let mockImportList: jest.Mock; + + beforeEach(() => { + mockImportList = jest.fn(); + mockUseImportList.mockImplementation(() => ({ + start: mockImportList, + })); + }); + + it('disables upload button when file is absent', () => { + const container = mount( + + + + ); + + expect( + container.find('button[data-test-subj="value-lists-form-import-action"]').prop('disabled') + ).toEqual(true); + }); + + it('calls importList when upload is clicked', async () => { + const container = mount( + + + + ); + + await mockSelectFile(container, mockFile); + + container.find('button[data-test-subj="value-lists-form-import-action"]').simulate('click'); + await waitForUpdates(container); + + expect(mockImportList).toHaveBeenCalledWith(expect.objectContaining({ file: mockFile })); + }); + + it('calls onError if import fails', async () => { + mockUseImportList.mockImplementation(() => ({ + start: jest.fn(), + error: 'whoops', + })); + + const onError = jest.fn(); + const container = mount( + + + + ); + await waitForUpdates(container); + + expect(onError).toHaveBeenCalledWith('whoops'); + }); + + it('calls onSuccess if import succeeds', async () => { + mockUseImportList.mockImplementation(() => ({ + start: jest.fn(), + result: { mockResult: true }, + })); + + const onSuccess = jest.fn(); + const container = mount( + + + + ); + await waitForUpdates(container); + + expect(onSuccess).toHaveBeenCalledWith({ mockResult: true }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/form.tsx b/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/form.tsx new file mode 100644 index 0000000000000..b8416c3242e4a --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/form.tsx @@ -0,0 +1,172 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useCallback, useState, ReactNode, useEffect, useRef } from 'react'; +import styled from 'styled-components'; +import { + EuiButton, + EuiButtonEmpty, + EuiForm, + EuiFormRow, + EuiFilePicker, + EuiFlexGroup, + EuiFlexItem, + EuiRadioGroup, +} from '@elastic/eui'; + +import { useImportList, ListSchema, Type } from '../../../shared_imports'; +import * as i18n from './translations'; +import { useKibana } from '../../../common/lib/kibana'; + +const InlineRadioGroup = styled(EuiRadioGroup)` + display: flex; + + .euiRadioGroup__item + .euiRadioGroup__item { + margin: 0 0 0 12px; + } +`; + +interface ListTypeOptions { + id: Type; + label: ReactNode; +} + +const options: ListTypeOptions[] = [ + { + id: 'keyword', + label: i18n.KEYWORDS_RADIO, + }, + { + id: 'ip', + label: i18n.IP_RADIO, + }, +]; + +const defaultListType: Type = 'keyword'; + +export interface ValueListsFormProps { + onError: (error: Error) => void; + onSuccess: (response: ListSchema) => void; +} + +export const ValueListsFormComponent: React.FC = ({ onError, onSuccess }) => { + const ctrl = useRef(new AbortController()); + const [files, setFiles] = useState(null); + const [type, setType] = useState(defaultListType); + const filePickerRef = useRef(null); + const { http } = useKibana().services; + const { start: importList, ...importState } = useImportList(); + + // EuiRadioGroup's onChange only infers 'string' from our options + const handleRadioChange = useCallback((t: string) => setType(t as Type), [setType]); + + const resetForm = useCallback(() => { + if (filePickerRef.current?.fileInput) { + filePickerRef.current.fileInput.value = ''; + filePickerRef.current.handleChange(); + } + setFiles(null); + setType(defaultListType); + }, [setType]); + + const handleCancel = useCallback(() => { + ctrl.current.abort(); + }, []); + + const handleSuccess = useCallback( + (response: ListSchema) => { + resetForm(); + onSuccess(response); + }, + [resetForm, onSuccess] + ); + const handleError = useCallback( + (error: Error) => { + onError(error); + }, + [onError] + ); + + const handleImport = useCallback(() => { + if (!importState.loading && files && files.length) { + ctrl.current = new AbortController(); + importList({ + file: files[0], + listId: undefined, + http, + signal: ctrl.current.signal, + type, + }); + } + }, [importState.loading, files, importList, http, type]); + + useEffect(() => { + if (!importState.loading && importState.result) { + handleSuccess(importState.result); + } else if (!importState.loading && importState.error) { + handleError(importState.error as Error); + } + }, [handleError, handleSuccess, importState.error, importState.loading, importState.result]); + + useEffect(() => { + return handleCancel; + }, [handleCancel]); + + return ( + + + + + + + + + + + + + + + + {importState.loading && ( + {i18n.CANCEL_BUTTON} + )} + + + + {i18n.UPLOAD_BUTTON} + + + + + + + + + ); +}; + +ValueListsFormComponent.displayName = 'ValueListsFormComponent'; + +export const ValueListsForm = React.memo(ValueListsFormComponent); + +ValueListsForm.displayName = 'ValueListsForm'; diff --git a/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/index.tsx b/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/index.tsx new file mode 100644 index 0000000000000..1fbe0e312bd8a --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/index.tsx @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { ValueListsModal } from './modal'; diff --git a/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/modal.test.tsx b/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/modal.test.tsx new file mode 100644 index 0000000000000..daf1cbd68df91 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/modal.test.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { mount } from 'enzyme'; + +import { TestProviders } from '../../../common/mock'; +import { ValueListsModal } from './modal'; +import { waitForUpdates } from '../../../common/utils/test_utils'; + +describe('ValueListsModal', () => { + it('renders nothing if showModal is false', () => { + const container = mount( + + + + ); + + expect(container.find('EuiModal')).toHaveLength(0); + }); + + it('renders modal if showModal is true', async () => { + const container = mount( + + + + ); + await waitForUpdates(container); + + expect(container.find('EuiModal')).toHaveLength(1); + }); + + it('calls onClose when modal is closed', async () => { + const onClose = jest.fn(); + const container = mount( + + + + ); + + container.find('button[data-test-subj="value-lists-modal-close-action"]').simulate('click'); + + await waitForUpdates(container); + + expect(onClose).toHaveBeenCalled(); + }); + + it('renders ValueListsForm and ValueListsTable', async () => { + const container = mount( + + + + ); + + await waitForUpdates(container); + + expect(container.find('ValueListsForm')).toHaveLength(1); + expect(container.find('ValueListsTable')).toHaveLength(1); + }); +}); diff --git a/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/modal.tsx b/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/modal.tsx new file mode 100644 index 0000000000000..0a935a9cdb1c4 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/modal.tsx @@ -0,0 +1,164 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useCallback, useEffect, useState } from 'react'; +import { + EuiButton, + EuiModal, + EuiModalBody, + EuiModalFooter, + EuiModalHeader, + EuiModalHeaderTitle, + EuiOverlayMask, + EuiSpacer, +} from '@elastic/eui'; + +import { + ListSchema, + exportList, + useFindLists, + useDeleteList, + useCursor, +} from '../../../shared_imports'; +import { useToasts, useKibana } from '../../../common/lib/kibana'; +import { GenericDownloader } from '../../../common/components/generic_downloader'; +import * as i18n from './translations'; +import { ValueListsTable } from './table'; +import { ValueListsForm } from './form'; + +interface ValueListsModalProps { + onClose: () => void; + showModal: boolean; +} + +export const ValueListsModalComponent: React.FC = ({ + onClose, + showModal, +}) => { + const [pageIndex, setPageIndex] = useState(0); + const [pageSize, setPageSize] = useState(5); + const [cursor, setCursor] = useCursor({ pageIndex, pageSize }); + const { http } = useKibana().services; + const { start: findLists, ...lists } = useFindLists(); + const { start: deleteList, result: deleteResult } = useDeleteList(); + const [exportListId, setExportListId] = useState(); + const toasts = useToasts(); + + const fetchLists = useCallback(() => { + findLists({ cursor, http, pageIndex: pageIndex + 1, pageSize }); + }, [cursor, http, findLists, pageIndex, pageSize]); + + const handleDelete = useCallback( + ({ id }: { id: string }) => { + deleteList({ http, id }); + }, + [deleteList, http] + ); + + useEffect(() => { + if (deleteResult != null) { + fetchLists(); + } + }, [deleteResult, fetchLists]); + + const handleExport = useCallback( + async ({ ids }: { ids: string[] }) => + exportList({ http, listId: ids[0], signal: new AbortController().signal }), + [http] + ); + const handleExportClick = useCallback(({ id }: { id: string }) => setExportListId(id), []); + const handleExportComplete = useCallback(() => setExportListId(undefined), []); + + const handleTableChange = useCallback( + ({ page: { index, size } }: { page: { index: number; size: number } }) => { + setPageIndex(index); + setPageSize(size); + }, + [setPageIndex, setPageSize] + ); + const handleUploadError = useCallback( + (error: Error) => { + if (error.name !== 'AbortError') { + toasts.addError(error, { title: i18n.UPLOAD_ERROR }); + } + }, + [toasts] + ); + const handleUploadSuccess = useCallback( + (response: ListSchema) => { + toasts.addSuccess({ + text: i18n.uploadSuccessMessage(response.name), + title: i18n.UPLOAD_SUCCESS_TITLE, + }); + fetchLists(); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [toasts] + ); + + useEffect(() => { + if (showModal) { + fetchLists(); + } + }, [showModal, fetchLists]); + + useEffect(() => { + if (!lists.loading && lists.result?.cursor) { + setCursor(lists.result.cursor); + } + }, [lists.loading, lists.result, setCursor]); + + if (!showModal) { + return null; + } + + const pagination = { + pageIndex, + pageSize, + totalItemCount: lists.result?.total ?? 0, + hidePerPageOptions: true, + }; + + return ( + + + + {i18n.MODAL_TITLE} + + + + + + + + + {i18n.CLOSE_BUTTON} + + + + + + ); +}; + +ValueListsModalComponent.displayName = 'ValueListsModalComponent'; + +export const ValueListsModal = React.memo(ValueListsModalComponent); + +ValueListsModal.displayName = 'ValueListsModal'; diff --git a/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/table.test.tsx b/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/table.test.tsx new file mode 100644 index 0000000000000..d0ed41ea58588 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/table.test.tsx @@ -0,0 +1,113 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { mount } from 'enzyme'; +import { act } from 'react-dom/test-utils'; + +import { getListResponseMock } from '../../../../../lists/common/schemas/response/list_schema.mock'; +import { ListSchema } from '../../../../../lists/common/schemas/response'; +import { TestProviders } from '../../../common/mock'; +import { ValueListsTable } from './table'; + +describe('ValueListsTable', () => { + it('renders a row for each list', () => { + const lists = Array(3).fill(getListResponseMock()); + const container = mount( + + + + ); + + expect(container.find('tbody tr')).toHaveLength(3); + }); + + it('calls onChange when pagination is modified', () => { + const lists = Array(6).fill(getListResponseMock()); + const onChange = jest.fn(); + const container = mount( + + + + ); + + act(() => { + container.find('a[data-test-subj="pagination-button-next"]').simulate('click'); + }); + + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ page: expect.objectContaining({ index: 1 }) }) + ); + }); + + it('calls onExport when export is clicked', () => { + const lists = Array(3).fill(getListResponseMock()); + const onExport = jest.fn(); + const container = mount( + + + + ); + + act(() => { + container + .find('tbody tr') + .first() + .find('button[data-test-subj="action-export-value-list"]') + .simulate('click'); + }); + + expect(onExport).toHaveBeenCalledWith(expect.objectContaining({ id: 'some-list-id' })); + }); + + it('calls onDelete when delete is clicked', () => { + const lists = Array(3).fill(getListResponseMock()); + const onDelete = jest.fn(); + const container = mount( + + + + ); + + act(() => { + container + .find('tbody tr') + .first() + .find('button[data-test-subj="action-delete-value-list"]') + .simulate('click'); + }); + + expect(onDelete).toHaveBeenCalledWith(expect.objectContaining({ id: 'some-list-id' })); + }); +}); diff --git a/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/table.tsx b/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/table.tsx new file mode 100644 index 0000000000000..07d52603a6fd1 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/table.tsx @@ -0,0 +1,103 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { EuiBasicTable, EuiBasicTableProps, EuiText, EuiPanel } from '@elastic/eui'; + +import { ListSchema } from '../../../../../lists/common/schemas/response'; +import { FormattedDate } from '../../../common/components/formatted_date'; +import * as i18n from './translations'; + +type TableProps = EuiBasicTableProps; +type ActionCallback = (item: ListSchema) => void; + +export interface ValueListsTableProps { + lists: TableProps['items']; + loading: boolean; + onChange: TableProps['onChange']; + onExport: ActionCallback; + onDelete: ActionCallback; + pagination: Exclude; +} + +const buildColumns = ( + onExport: ActionCallback, + onDelete: ActionCallback +): TableProps['columns'] => [ + { + field: 'name', + name: i18n.COLUMN_FILE_NAME, + truncateText: true, + }, + { + field: 'created_at', + name: i18n.COLUMN_UPLOAD_DATE, + /* eslint-disable-next-line react/display-name */ + render: (value: ListSchema['created_at']) => ( + + ), + width: '30%', + }, + { + field: 'created_by', + name: i18n.COLUMN_CREATED_BY, + truncateText: true, + width: '20%', + }, + { + name: i18n.COLUMN_ACTIONS, + actions: [ + { + name: i18n.ACTION_EXPORT_NAME, + description: i18n.ACTION_EXPORT_DESCRIPTION, + icon: 'exportAction', + type: 'icon', + onClick: onExport, + 'data-test-subj': 'action-export-value-list', + }, + { + name: i18n.ACTION_DELETE_NAME, + description: i18n.ACTION_DELETE_DESCRIPTION, + icon: 'trash', + type: 'icon', + onClick: onDelete, + 'data-test-subj': 'action-delete-value-list', + }, + ], + width: '15%', + }, +]; + +export const ValueListsTableComponent: React.FC = ({ + lists, + loading, + onChange, + onExport, + onDelete, + pagination, +}) => { + const columns = buildColumns(onExport, onDelete); + return ( + + +

{i18n.TABLE_TITLE}

+ + + + ); +}; + +ValueListsTableComponent.displayName = 'ValueListsTableComponent'; + +export const ValueListsTable = React.memo(ValueListsTableComponent); + +ValueListsTable.displayName = 'ValueListsTable'; diff --git a/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/translations.ts b/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/translations.ts new file mode 100644 index 0000000000000..dca6e43a98143 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/translations.ts @@ -0,0 +1,138 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; + +export const MODAL_TITLE = i18n.translate('xpack.securitySolution.lists.uploadValueListTitle', { + defaultMessage: 'Upload value lists', +}); + +export const FILE_PICKER_LABEL = i18n.translate( + 'xpack.securitySolution.lists.uploadValueListDescription', + { + defaultMessage: 'Upload single value lists to use while writing rules or rule exceptions.', + } +); + +export const FILE_PICKER_PROMPT = i18n.translate( + 'xpack.securitySolution.lists.uploadValueListPrompt', + { + defaultMessage: 'Select or drag and drop a file', + } +); + +export const CLOSE_BUTTON = i18n.translate( + 'xpack.securitySolution.lists.closeValueListsModalTitle', + { + defaultMessage: 'Close', + } +); + +export const CANCEL_BUTTON = i18n.translate( + 'xpack.securitySolution.lists.cancelValueListsUploadTitle', + { + defaultMessage: 'Cancel upload', + } +); + +export const UPLOAD_BUTTON = i18n.translate('xpack.securitySolution.lists.valueListsUploadButton', { + defaultMessage: 'Upload list', +}); + +export const UPLOAD_SUCCESS_TITLE = i18n.translate( + 'xpack.securitySolution.lists.valueListsUploadSuccessTitle', + { + defaultMessage: 'Value list uploaded', + } +); + +export const UPLOAD_ERROR = i18n.translate('xpack.securitySolution.lists.valueListsUploadError', { + defaultMessage: 'There was an error uploading the value list.', +}); + +export const uploadSuccessMessage = (fileName: string) => + i18n.translate('xpack.securitySolution.lists.valueListsUploadSuccess', { + defaultMessage: "Value list '{fileName}' was uploaded", + values: { fileName }, + }); + +export const COLUMN_FILE_NAME = i18n.translate( + 'xpack.securitySolution.lists.valueListsTable.fileNameColumn', + { + defaultMessage: 'Filename', + } +); + +export const COLUMN_UPLOAD_DATE = i18n.translate( + 'xpack.securitySolution.lists.valueListsTable.uploadDateColumn', + { + defaultMessage: 'Upload Date', + } +); + +export const COLUMN_CREATED_BY = i18n.translate( + 'xpack.securitySolution.lists.valueListsTable.createdByColumn', + { + defaultMessage: 'Created by', + } +); + +export const COLUMN_ACTIONS = i18n.translate( + 'xpack.securitySolution.lists.valueListsTable.actionsColumn', + { + defaultMessage: 'Actions', + } +); + +export const ACTION_EXPORT_NAME = i18n.translate( + 'xpack.securitySolution.lists.valueListsTable.exportActionName', + { + defaultMessage: 'Export', + } +); + +export const ACTION_EXPORT_DESCRIPTION = i18n.translate( + 'xpack.securitySolution.lists.valueListsTable.exportActionDescription', + { + defaultMessage: 'Export value list', + } +); + +export const ACTION_DELETE_NAME = i18n.translate( + 'xpack.securitySolution.lists.valueListsTable.deleteActionName', + { + defaultMessage: 'Remove', + } +); + +export const ACTION_DELETE_DESCRIPTION = i18n.translate( + 'xpack.securitySolution.lists.valueListsTable.deleteActionDescription', + { + defaultMessage: 'Remove value list', + } +); + +export const TABLE_TITLE = i18n.translate('xpack.securitySolution.lists.valueListsTable.title', { + defaultMessage: 'Value lists', +}); + +export const LIST_TYPES_RADIO_LABEL = i18n.translate( + 'xpack.securitySolution.lists.valueListsForm.listTypesRadioLabel', + { + defaultMessage: 'Type of value list', + } +); + +export const IP_RADIO = i18n.translate('xpack.securitySolution.lists.valueListsForm.ipRadioLabel', { + defaultMessage: 'IP addresses', +}); + +export const KEYWORDS_RADIO = i18n.translate( + 'xpack.securitySolution.lists.valueListsForm.keywordsRadioLabel', + { + defaultMessage: 'Keywords', + } +); diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/index.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/index.tsx index 84c34f2bed93c..0fce9e5ea3a44 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/index.tsx @@ -22,6 +22,7 @@ import { useUserInfo } from '../../../components/user_info'; import { AllRules } from './all'; import { ImportDataModal } from '../../../../common/components/import_data_modal'; import { ReadOnlyCallOut } from '../../../components/rules/read_only_callout'; +import { ValueListsModal } from '../../../components/value_lists_management_modal'; import { UpdatePrePackagedRulesCallOut } from '../../../components/rules/pre_packaged_rules/update_callout'; import { getPrePackagedRuleStatus, redirectToDetections, userHasNoPermissions } from './helpers'; import * as i18n from './translations'; @@ -34,6 +35,9 @@ type Func = (refreshPrePackagedRule?: boolean) => void; const RulesPageComponent: React.FC = () => { const history = useHistory(); const [showImportModal, setShowImportModal] = useState(false); + const [isValueListsModalShown, setIsValueListsModalShown] = useState(false); + const showValueListsModal = useCallback(() => setIsValueListsModalShown(true), []); + const hideValueListsModal = useCallback(() => setIsValueListsModalShown(false), []); const refreshRulesData = useRef(null); const { loading: userInfoLoading, @@ -117,6 +121,7 @@ const RulesPageComponent: React.FC = () => { return ( <> {userHasNoPermissions(canUserCRUD) && } + setShowImportModal(false)} @@ -167,6 +172,15 @@ const RulesPageComponent: React.FC = () => {
)} + + + {i18n.UPLOAD_VALUE_LISTS} + + { mockuseMessagesStorage.mockImplementation(() => endpointNoticeMessage(false)); }); - it('renders the Setup Instructions text', () => { + it('renders the Setup Instructions text', async () => { const wrapper = mount( @@ -69,10 +70,11 @@ describe('Overview', () => { ); + await waitForUpdates(wrapper); expect(wrapper.find('[data-test-subj="empty-page"]').exists()).toBe(true); }); - it('does not show Endpoint get ready button when ingest is not enabled', () => { + it('does not show Endpoint get ready button when ingest is not enabled', async () => { const wrapper = mount( @@ -80,10 +82,11 @@ describe('Overview', () => { ); + await waitForUpdates(wrapper); expect(wrapper.find('[data-test-subj="empty-page-secondary-action"]').exists()).toBe(false); }); - it('shows Endpoint get ready button when ingest is enabled', () => { + it('shows Endpoint get ready button when ingest is enabled', async () => { (useIngestEnabledCheck as jest.Mock).mockReturnValue({ allEnabled: true }); const wrapper = mount( @@ -92,11 +95,12 @@ describe('Overview', () => { ); + await waitForUpdates(wrapper); expect(wrapper.find('[data-test-subj="empty-page-secondary-action"]').exists()).toBe(true); }); }); - it('it DOES NOT render the Getting started text when an index is available', () => { + it('it DOES NOT render the Getting started text when an index is available', async () => { (useWithSource as jest.Mock).mockReturnValue({ indicesExist: true, indexPattern: {}, @@ -113,10 +117,12 @@ describe('Overview', () => { ); + await waitForUpdates(wrapper); + expect(wrapper.find('[data-test-subj="empty-page"]').exists()).toBe(false); }); - test('it DOES render the Endpoint banner when the endpoint index is NOT available AND storage is NOT set', () => { + test('it DOES render the Endpoint banner when the endpoint index is NOT available AND storage is NOT set', async () => { (useWithSource as jest.Mock).mockReturnValueOnce({ indicesExist: true, indexPattern: {}, @@ -138,10 +144,12 @@ describe('Overview', () => { ); + await waitForUpdates(wrapper); + expect(wrapper.find('[data-test-subj="endpoint-prompt-banner"]').exists()).toBe(true); }); - test('it does NOT render the Endpoint banner when the endpoint index is NOT available but storage is set', () => { + test('it does NOT render the Endpoint banner when the endpoint index is NOT available but storage is set', async () => { (useWithSource as jest.Mock).mockReturnValueOnce({ indicesExist: true, indexPattern: {}, @@ -163,10 +171,12 @@ describe('Overview', () => { ); + await waitForUpdates(wrapper); + expect(wrapper.find('[data-test-subj="endpoint-prompt-banner"]').exists()).toBe(false); }); - test('it does NOT render the Endpoint banner when the endpoint index is available AND storage is set', () => { + test('it does NOT render the Endpoint banner when the endpoint index is available AND storage is set', async () => { (useWithSource as jest.Mock).mockReturnValue({ indicesExist: true, indexPattern: {}, @@ -183,10 +193,12 @@ describe('Overview', () => { ); + await waitForUpdates(wrapper); + expect(wrapper.find('[data-test-subj="endpoint-prompt-banner"]').exists()).toBe(false); }); - test('it does NOT render the Endpoint banner when an index IS available but storage is NOT set', () => { + test('it does NOT render the Endpoint banner when an index IS available but storage is NOT set', async () => { (useWithSource as jest.Mock).mockReturnValue({ indicesExist: true, indexPattern: {}, @@ -206,7 +218,7 @@ describe('Overview', () => { expect(wrapper.find('[data-test-subj="endpoint-prompt-banner"]').exists()).toBe(false); }); - test('it does NOT render the Endpoint banner when Ingest is NOT available', () => { + test('it does NOT render the Endpoint banner when Ingest is NOT available', async () => { (useWithSource as jest.Mock).mockReturnValue({ indicesExist: true, indexPattern: {}, @@ -223,6 +235,8 @@ describe('Overview', () => { ); + await waitForUpdates(wrapper); + expect(wrapper.find('[data-test-subj="endpoint-prompt-banner"]').exists()).toBe(false); }); }); diff --git a/x-pack/plugins/security_solution/public/shared_imports.ts b/x-pack/plugins/security_solution/public/shared_imports.ts index 93edc484c3569..fcd23ff9df4d8 100644 --- a/x-pack/plugins/security_solution/public/shared_imports.ts +++ b/x-pack/plugins/security_solution/public/shared_imports.ts @@ -27,12 +27,16 @@ export { fieldValidators } from '../../../../src/plugins/es_ui_shared/static/for export { ERROR_CODE } from '../../../../src/plugins/es_ui_shared/static/forms/helpers/field_validators/types'; export { + exportList, useIsMounted, + useCursor, useApi, useExceptionList, usePersistExceptionItem, usePersistExceptionList, useFindLists, + useDeleteList, + useImportList, useCreateListIndex, useReadListIndex, useReadListPrivileges, From 2009447ab8baf75255fea6334c392a53dee2f7bd Mon Sep 17 00:00:00 2001 From: Yuliia Naumenko Date: Mon, 13 Jul 2020 19:53:37 -0700 Subject: [PATCH 56/66] Added help text where needed on connectors and alert actions UI (#69601) * Added help text where needed on connectors and alert actions UI * fixed ui form * Added index action type examples, fixed slack link * Fixed email connector docs and links * Additional cleanup on email * Removed autofocus to avoid twice link click for opening in the new page * Extended documentation for es index action type * Fixed tests * Fixed doc link * fixed due to comments * fixed due to comments * Update docs/user/alerting/action-types/email.asciidoc Co-authored-by: gchaps <33642766+gchaps@users.noreply.github.com> * Update docs/user/alerting/action-types/email.asciidoc Co-authored-by: gchaps <33642766+gchaps@users.noreply.github.com> * Update docs/user/alerting/action-types/email.asciidoc Co-authored-by: gchaps <33642766+gchaps@users.noreply.github.com> * Update docs/user/alerting/action-types/email.asciidoc Co-authored-by: gchaps <33642766+gchaps@users.noreply.github.com> * Update docs/user/alerting/action-types/email.asciidoc Co-authored-by: gchaps <33642766+gchaps@users.noreply.github.com> * Update docs/user/alerting/action-types/email.asciidoc Co-authored-by: gchaps <33642766+gchaps@users.noreply.github.com> * Update docs/user/alerting/action-types/email.asciidoc Co-authored-by: gchaps <33642766+gchaps@users.noreply.github.com> * Update x-pack/plugins/actions/README.md Co-authored-by: gchaps <33642766+gchaps@users.noreply.github.com> * Update x-pack/plugins/actions/README.md Co-authored-by: gchaps <33642766+gchaps@users.noreply.github.com> * Update x-pack/plugins/triggers_actions_ui/README.md Co-authored-by: gchaps <33642766+gchaps@users.noreply.github.com> * Update docs/user/alerting/action-types/email.asciidoc Co-authored-by: gchaps <33642766+gchaps@users.noreply.github.com> * Update docs/user/alerting/action-types/email.asciidoc Co-authored-by: gchaps <33642766+gchaps@users.noreply.github.com> * Update docs/user/alerting/action-types/email.asciidoc Co-authored-by: gchaps <33642766+gchaps@users.noreply.github.com> * Update docs/user/alerting/action-types/email.asciidoc Co-authored-by: gchaps <33642766+gchaps@users.noreply.github.com> * Update docs/user/alerting/action-types/index.asciidoc Co-authored-by: gchaps <33642766+gchaps@users.noreply.github.com> * Update docs/user/alerting/action-types/slack.asciidoc Co-authored-by: gchaps <33642766+gchaps@users.noreply.github.com> * Fixed due to comments Co-authored-by: gchaps <33642766+gchaps@users.noreply.github.com> --- .../user/alerting/action-types/email.asciidoc | 119 ++++++++++++++++++ .../user/alerting/action-types/index.asciidoc | 38 +++++- .../user/alerting/action-types/slack.asciidoc | 20 +++ .../images/slack-add-webhook-integration.png | Bin 0 -> 109011 bytes .../images/slack-copy-webhook-url.png | Bin 0 -> 42332 bytes x-pack/plugins/actions/README.md | 19 ++- x-pack/plugins/triggers_actions_ui/README.md | 18 ++- .../email/email_connector.tsx | 15 ++- .../email/email_params.test.tsx | 2 + .../es_index/es_index_connector.tsx | 25 +++- .../es_index/es_index_params.test.tsx | 2 + .../es_index/es_index_params.tsx | 52 +++++--- .../pagerduty/pagerduty_params.test.tsx | 2 + .../server_log/server_log_params.test.tsx | 3 + .../servicenow/servicenow_params.test.tsx | 2 + .../slack/slack_connectors.tsx | 4 +- .../slack/slack_params.test.tsx | 2 + .../webhook/webhook_params.test.tsx | 2 + .../json_editor_with_message_variables.tsx | 3 + .../action_connector_form.tsx | 1 - .../action_connector_form/action_form.tsx | 1 + .../triggers_actions_ui/public/types.ts | 1 + 22 files changed, 288 insertions(+), 43 deletions(-) create mode 100644 docs/user/alerting/images/slack-add-webhook-integration.png create mode 100644 docs/user/alerting/images/slack-copy-webhook-url.png diff --git a/docs/user/alerting/action-types/email.asciidoc b/docs/user/alerting/action-types/email.asciidoc index 4fb8a816d1ec9..f6a02b9038c02 100644 --- a/docs/user/alerting/action-types/email.asciidoc +++ b/docs/user/alerting/action-types/email.asciidoc @@ -77,3 +77,122 @@ Email actions have the following configuration properties: To, CC, BCC:: Each is a list of addresses. Addresses can be specified in `user@host-name` format, or in `name ` format. One of To, CC, or BCC must contain an entry. Subject:: The subject line of the email. Message:: The message text of the email. Markdown format is supported. + +[[configuring-email]] +==== Configuring email accounts + +The email action can send email using many popular SMTP email services. + +You configure the email action to send emails using the connector form. +For more information about configuring the email connector to work with different email +systems, refer to: + +* <> +* <> +* <> +* <> + +[float] +[[gmail]] +===== Sending email from Gmail + +Use the following email account settings to send email from the +https://mail.google.com[Gmail] SMTP service: + +[source,text] +-------------------------------------------------- + config: + host: smtp.gmail.com + port: 465 + secure: true + secrets: + user: + password: +-------------------------------------------------- +// CONSOLE + +If you get an authentication error that indicates that you need to continue the +sign-in process from a web browser when the action attempts to send email, you need +to configure Gmail to https://support.google.com/accounts/answer/6010255?hl=en[allow +less secure apps to access your account]. + +If two-step verification is enabled for your account, you must generate and use +a unique App Password to send email from {watcher}. See +https://support.google.com/accounts/answer/185833?hl=en[Sign in using App Passwords] +for more information. + +[float] +[[outlook]] +===== Sending email from Outlook.com + +Use the following email account settings to send email action from the +https://www.outlook.com/[Outlook.com] SMTP service: + +[source,text] +-------------------------------------------------- +config: + host: smtp-mail.outlook.com + port: 465 + secure: true +secrets: + user: + password: +-------------------------------------------------- + +When sending emails, you must provide a from address, either as the default +in your account configuration or as part of the email action in the watch. + +NOTE: You must use a unique App Password if two-step verification is enabled. + See http://windows.microsoft.com/en-us/windows/app-passwords-two-step-verification[App + passwords and two-step verification] for more information. + +[float] +[[amazon-ses]] +===== Sending email from Amazon SES (Simple Email Service) + +Use the following email account settings to send email from the +http://aws.amazon.com/ses[Amazon Simple Email Service] (SES) SMTP service: + +[source,text] +-------------------------------------------------- +config: + host: email-smtp.us-east-1.amazonaws.com <1> + port: 465 + secure: true +secrets: + user: + password: +-------------------------------------------------- +<1> `smtp.host` varies depending on the region + +NOTE: You must use your Amazon SES SMTP credentials to send email through + Amazon SES. For more information, see + http://docs.aws.amazon.com/ses/latest/DeveloperGuide/smtp-credentials.html[Obtaining + Your Amazon SES SMTP Credentials]. You might also need to verify + https://docs.aws.amazon.com/ses/latest/DeveloperGuide/verify-email-addresses.html[your email address] + or https://docs.aws.amazon.com/ses/latest/DeveloperGuide/verify-domains.html[your whole domain] + at AWS. + +[float] +[[exchange]] +===== Sending email from Microsoft Exchange + +Use the following email account settings to send email action from Microsoft +Exchange: + +[source,text] +-------------------------------------------------- +config: + host: + port: 465 + secure: true + from: <1> +secrets: + user: <2> + password: +-------------------------------------------------- +<1> Some organizations configure Exchange to validate that the `from` field is a + valid local email account. +<2> Many organizations support use of your email address as your username. + Check with your system administrator if you receive + authentication-related failures. diff --git a/docs/user/alerting/action-types/index.asciidoc b/docs/user/alerting/action-types/index.asciidoc index 115423086bae3..3a57c44494394 100644 --- a/docs/user/alerting/action-types/index.asciidoc +++ b/docs/user/alerting/action-types/index.asciidoc @@ -2,7 +2,7 @@ [[index-action-type]] === Index action -The index action type will index a document into {es}. +The index action type will index a document into {es}. See also the {ref}/indices-create-index.html[create index API]. [float] [[index-connector-configuration]] @@ -53,4 +53,38 @@ Execution time field:: This field will be automatically set to the time the ale Index actions have the following properties: -Document:: The document to index in json format. +Document:: The document to index in JSON format. + +Example of the index document for Index Threshold alert: + +[source,text] +-------------------------------------------------- +{ + "alert_id": "{{alertId}}", + "alert_name": "{{alertName}}", + "alert_instance_id": "{{alertInstanceId}}", + "context_message": "{{context.message}}" +} +-------------------------------------------------- + +Example of create test index using the API. + +[source,text] +-------------------------------------------------- +PUT test +{ + "settings" : { + "number_of_shards" : 1 + }, + "mappings" : { + "_doc" : { + "properties" : { + "alert_id" : { "type" : "text" }, + "alert_name" : { "type" : "text" }, + "alert_instance_id" : { "type" : "text" }, + "context_message": { "type" : "text" } + } + } + } +} +-------------------------------------------------- diff --git a/docs/user/alerting/action-types/slack.asciidoc b/docs/user/alerting/action-types/slack.asciidoc index 5bad8a53f898c..99bf73c0f5597 100644 --- a/docs/user/alerting/action-types/slack.asciidoc +++ b/docs/user/alerting/action-types/slack.asciidoc @@ -38,3 +38,23 @@ Webhook URL:: The URL of the incoming webhook. See https://api.slack.com/messa Slack actions have the following properties: Message:: The message text, converted to the `text` field in the Webhook JSON payload. Currently only the text field is supported. Markdown, images, and other advanced formatting are not yet supported. + +[[configuring-slack]] +==== Configuring Slack Accounts + +You configure the accounts Slack action type can use to communicate with Slack in the +connector form. + +You need a https://api.slack.com/incoming-webhooks[Slack webhook URL] to +configure a Slack account. To create a webhook +URL, set up an an **Incoming Webhook Integration** through the Slack console: + +. Log in to http://slack.com[slack.com] as a team administrator. +. Go to https://my.slack.com/services/new/incoming-webhook. +. Select a default channel for the integration. ++ +image::images/slack-add-webhook-integration.png[] +. Click *Add Incoming Webhook Integration*. +. Copy the generated webhook URL so you can paste it into your Slack connector form. ++ +image::images/slack-copy-webhook-url.png[] diff --git a/docs/user/alerting/images/slack-add-webhook-integration.png b/docs/user/alerting/images/slack-add-webhook-integration.png new file mode 100644 index 0000000000000000000000000000000000000000..347822ddd9fac4c88cd0c13aed8d991680c1b993 GIT binary patch literal 109011 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

|Do`&HRr1N={~j%nG_^Wb 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 literal 0 HcmV?d00001 diff --git a/docs/user/alerting/images/slack-copy-webhook-url.png b/docs/user/alerting/images/slack-copy-webhook-url.png new file mode 100644 index 0000000000000000000000000000000000000000..0acc9488e22a335e31a9e429bcc033425f11baac GIT binary patch literal 42332 zcmeFYWmp}-mNtq*aCawIaCi6M5D4xYcXti$0fM``y9al7ciFfGzdL7U&dhzj@7!m8 z+@GhP?%lP!q`OvCziX|xR);GoNFl-F!-Ii=A<0OKtAK&Q+Jn-0a4?|X8U~#+Ffc?Z z3o$Vz88IpFPaij5HlMm$AG^&apA<*d z)A7KZ%RE#pkjoH+t3E4Y;}DPIr~e!!7XXJNg@j>-kR#S-1Pl#Ig44V``i-^|OyiHL z;ZQD3-F~RuL5cE^fPKc^bq>hLM!5C`yE5wf_6rtlhTyca_)sRvoxdN3HHEZag>v%S zo)M+O_dWO|c~L^x-Au4J3;ry646t*m60?I_HnP|UhA>@9h*~HxBG!IzZ!|?P2dI8A zM<;4JmIg@VldWr$_aHVO|Ae72!k$yDZ=%R(V}(2&79VDO@6G#%G|$G%y&@PR|yPnPi9z9fnu*B^hzsYQ7RG&QIzuSfXoc4mDqTq*^pG34Rg9E8g<*2WKo#vp&evxwGujU#o-W@ z>G1De_J=wh55LZ3(fAaC%HAZ8ydQ8#QwoBLN!@7u@}i&kYO|$iNC+16BZYC%%c_Xl z=@UO&o(`-Qdh|NrZ!OJcXhaJrU>*LTyH`g|AHCh<=yDe()`_$9<=0BXNza+YP@LM0 zj!-)a!ttl+0Vnf^#n2(7WV=HAhWfS2)CyQ~w|yDN8g1fm{UHTfL;?Gvv(A9*h#S56nyyr-=YD zp2zwaBo1Z#=)COY!Z|w`M9L6UiZDZQsdXrnp0_I+ccj2Rq{l&@=lVBOMfb1f6#V2m z)D0N6is(2az9pg1l0mMxWOAxb&yO5?u5k;HD1&cgLi>ZMEuZXrrFx{T`B4WS{Iz1J zoB;Pijvx6Umu*$G`7J6+qoiL^2X>?sZK!Qx!Lb2~-IsB^OLqZXjxeUaQBzMJr;>R=yJ0_#gzcT;j^~Ap*Oj0L#dn`aXQKz^mgy2KHq-z%G%68FsM; zd~pwkKY#=dBG&*j4Ez%*wA>F}?B08NNY!3MW}HNS)*8eLm?Q(pWyGc+T>~a_sL&n< zdwdALybyhIBh%JDA?-yl|Ic;^YbDig$r_#_%kW+?g#YEio~ADLX8&?|6fJgLZ>;!{*2& zk!&P;UD&S#m1vPzn8=W5I!gH!wlKL&suGxFZ`ICIXcN^!P%4rKx77zb##D3tq=62MY{M+lS65?dSW5HVvY z%wEH_jc2;sMNZEF08p z8N6@L_Ikl?u}H{g%I(P&r`vLNny(t0+G5*MacEn){;n9yupl{owWB4$mmdVC2c4%$hVha+$OdgA?-(kq((hY&{WmGHl;(pKs+S z93{l(XfQY5n@;Yt4k<*|7CalTsjecf;;GWsxkO$f5wvulxjNjb-pE-0W2)X$O5;9~S3r);Gtk2)c56HUD%GBf3v(Q`61;>-7%727~UOV|q7H}<3R%le@DcmPuu*6JDbe-IuM z)-#ab&L;(uicBx&m|07?VtY})0BWpi=>G^`dBrf9Q5|W(VWShP;LqZhk)+{j;XRX% zf1ziqV65OzRsBNCLF-~=In^@lP(I`m z^@yv_`aTHt@(CJaQl33Eh3)$4bEZs(%*!mC#fSyptl+JYJ4Z*Bv#qC#Cs%f#exDy1 z9C`)1GyGdH-H$2OS2lGM{;9huozxXtc+8~8wthl~!ar*2;)@{_k1H%IYjrP5U zemc!LqI|uhT=|)hFhPwYXPdw)D_d}`>RnusHOmm0Xp=;7^XaCDAF{=;yTo2;{5$_R z%5e4Q2Q!DoX%|E%giP;%g~A-Ryq7wz`X8XPH?N=P7~&Y7lNo=;NLH%_HY=Tv`j|Cv zWuTt#@W<4OU@|Y4*9D10l>}YH=A>-al8&`HfAOE|*Q+NN%2mp`pXms%*wz|hOqfj8 zy8B(`=XuqIfwQ-OD!>6C8|M}$`vdTY39I{#VMJrLZQkkR8PE^-3E=t#%ed7gYA3N1 zF+?X-ZLKk&rPg+OekxNwl@v>}RTI)CSzFXeUY2K3Ik5_`68awTrTRPc_wy>wO1*Dy zzL^FaN%tZ^AXkbF&DxbkzWZLK{7yQ)<(cIu7Y5gnoo^eZ#aOC-r(flUWnH5+Fxlp( z&H371Q+CCDS>=Xdo3RCvR&fb%oyYg+K$omN?Ns-a>)iB$aSif;=Hc~8d)1EXLGr?8 z<*2jTxBLA46fz335J|ePA>=|*+1N|>GOTvmTN=u%;N7E5C_RcA5hs7g9o)V2 zb$nNTu#{JfOsosh6#sh*KxJ$L!&p&=kLSnkJ2sEkl(rOqqWd0^Zw3&)OGBI z;GvN3wfOtKlhn29^W2RY_LOCIyMWL<-lE6Tl!KpZM_kwO`Ijfz%j(l)WTwXtqPxd| zi`osMYLn_`Jtkk{SJSmMG&^liBQN5PF`uQEuDhV?&~uVbp{%FG*Aval`Kf{_EPcXm zcE9LX&uXUTYQ@on>u=iNh{)^|kp4?++k#}!V1s}bKip!$qf#j4Ylh>?G~`AEf?m?I?)R!))st^E&MAyJX(SKrdq$uu?pKrn;`K zO&1>@{iYrhJ4k!$h-dFoOmcn#YebbipkyG=HtZ8uQi?w@@JO*2Om)3Lw1H-2L zdw|QRe7*t$1JAPfrs1q1FUMECt@jqF^U1<1+&-sqp(e>^9^-QvIRWb5>w-2!!x>2D4b3nMerKidXX z<^Ow?SINR1V67o;VFR#r0^LK9g@>7){~s0pkF5W`<^QOv`9G?1vi{GS|0Cx=Yw|Pw z?ZN-((SL~RA6G&05`^bx`lsv#;c;zdKtl#bXd$ln4RnV1J2s$A8FbM6=NXj#R^_2j z$p{7}3??Hk^35ImEE6^bYhX4cGQ(+-Ck675zS}^A}lYAoIJuGN*Pn4mk>>& zHv>z#$!W9t zd9B$lS-|UI-$+&`>0eu+q}Tz!gAruI(J2dbS{+rItXDK5FP3g;wl9{FV__BlYlhY^ zeX7u?6$NG2F`)#!P=TY9{$KY12$>wYR92OI$!N%%b=qy>xa1^iiT`RPZVRfyUAshy zj8?y^Bk_yc*9c77pIhw*4>JGCPR{8;U+(m3wq9$J=d@j?@p`;Sf>i4-^w&19bI9WU zFH>1q;S;IN&1e!G^sY^#7ZeWlKPv?Q#(v=`cEe+RA+%2t)6@MIT?R*ACV?XzHtGvOqGHpl)M|VP6;t?w zOS?@K6@l>cpCBhp70}SYzEW=%J71xhV3FXV`Y&zk9zjxn=h3lbrsTgv18VoN7j1uw zk#_r#WNn%u*2^YCE`xi9?c3My+o!E6botH7?V3hr@G5SC4-MCPwU-X(r zO^YoKd;IICU0d^l81~toH?$r*!8qhl)1m7kuq2=RLQ#@E?@uL)h6t7$t&$N5xs|#y z?h4PAYsJ#oEy%?}5aY#A_}Dkg;-8N9ae;EL)_Eo-CgMf<9tTOYFEN|(*B$shU6S40 ztM#pDu~Bi6OkK-=J9a*0%jEZVpKP{3yCp9ZQEGPZ=DiQfjH_C5xz88~#?hsrkczpJ z8>^0YwOJd9z@p<%zw~u-YuyPU;Gf%#_U3!iKAFjvnm77*Iu%kW{F+p9@?F;>NiLm} z!Lp4n*Zq1|rAbGVR==vl!}e;t!D3GGbg>f1h|L>yblzed8=3ae6NDoD@^GGCXp_QP9$-?{lA|<8;rVJSP8UV`<>bk;TAoJfby|CvlJ$$@r7F`7ZAH9yp{! z1YtWzC7=JxV|RqQK($ywd5$OGFB~9$2KkRD1?3}7WdEG;|L?@I{wJlLi9Nc|k|kp; zB8qbL${Xue=aX4hn`d?rX9IFq-=kY-3&%GYa2!!v^2OW5tef|jOE2t7joLh6!l0a_ zB}s6u>hKV|SO{D0bPXr_Ew}SkwdeawoHktObtU!;kSD4H2B*}+0EUxVEd4KJ8FvGc z_F*68=~{Dj6bYB0<=Ea(EY6)O{uJtp`|oRNxPF7~`?k*HyVHd+As$QbsVo7E#O9L`g1IUHJn}v04 zv*`Z|QvUhg{zpPM;_rx};84*soZTN~}Iklj$aHRdR zi>~~8b-H zN2B?|SCs;tlW#oq#6#e620)T}N@Ml3Z`>~?v?^WW;NC_29RH1T(hLxM$(6qZ@YeE? z%k0BKrUbNnHVqZxAZHSG*d_NC?>1Vd@ zGp+fr)Q5X6ec1#mdC?%ZI8;g(tv*-!u&Ki!%KV6}NOx%0p1mf%nv0G(&E>+*!tLpt6 zzeZ099;vT%dX&$%-CCBqPnOcUSiju%?ia}|XHW5M)?R8gDo$r>jy5u+&pLCmEZ<=#h zoR^;EiyROy2howNWRqmqWNOpY`dqP6k&9Do{3au#+93;(ZRsh~=3S^I9=^B8(F!yh z3*4^9-)Ac<)_GQIlB@-sZ#JlL`rJn1F;?D}?UF?=@qMpKVi0<__*L#6W9mkyvy9mO z!Rb?e^bXvrllQpXtkL{RwD|2S$)?U|wu?7SbF}`O_2!c+UvLB#2eN0dZ1ViGu+5&1 z@zKD`M!Qoor|<{4SEC@fR5$AoAz{mC2$L`K7}R7GOYK>BDDe7QRIT`3MbcC^oz+&_ z=K}>kty9E#w?h1jAtKLe@e1jkaVJK#--K@AFpc8y1KmP9a1e*?rmpllRFr88Q~Q;V z7T2B}Gyh5DJ=R;6JL|b0kY|D*wx=vN3&tkyl3%ikjpdj$2p_k{BcmYFSXh+-0b^xfJJge2!&t5sS zd>~6Q!$1FNDl77svTRudh{Q7blL#T1TBj(9za&1OQ; zbg%EpUy6UN)hJqUuRCo|IA0(OsY3tZM_)n1uh7=oADj1Dht{uK)pdruZgs{p@GFg; zotVl;UFi2#sm3&Ziw>vb-#EEK4Hq}qJ<2xu(hH@7>kd648$}-568TieTsqwzzt=w+ z&)_E&o;ieuWJylfW3^ogrUkq>ZM^8q40a405=A_MT`bjU{o3oy{2o(_VCN^krN)*w zS$AhRQP7*;gF3zIMa-2^leWFF6Y?@ug#o`T#j_=6Q(1ZojhTN; z+xL0QiQC-_yHZDFsWj)nPQKS&fxhwuvhKXvqX>E0WW6TkKue)1*5ztFmwx$qjee!k zGV^S{F2_6xUPYz*O_@sV)Z4V{jH`GkeF=E3Q>(t%#Wpg3=}NC>!Kq*2N$GS^Lh)IP z*zW0xNz+}bs7UGMf>E+x{!T;rDBVk$WUogl_iaR_{j$*kqyq?Wlqa!;#kYSbQj1r< zzM*W%X7ak^6|H@u`(Bl}7bBpsrk6*cf5z%_aYoU+{&U6NviQl5&+U{Rq(krJY~TDU z4-S&`ALPcy(BNAXtqsn!zs6oXbZ@@4^OT3fz{oUdNQHNyvL+ZuaQ#%bIc&P zg8xJfG9fTYAwC-X+pmR$QU?cqG7w1OhoKKdhcutB=0wKQeQQE!%k6c3Uw{OJ`Z_NX zIVSj;H+_X4nSGHO3t{R?{(6*6%VF2PULgz4Mf>B$d*_b04^VxJRO_=zzK13Emq$+X zaRh5+z<3xD`4Hm^)?j_y<)hs;y$;c+v;H$C*F^)D~ftNqE zK3Gf@xrU9Bv9hcLwCDZ)`an2LW?4(;ZjCScvx{t2*F9z?bAEmtyP6407mwpkQl?oh z`P)wwjwdTBZ-bHJ^kD3B?phmg)VWsZ@hGyZE)PC#D z2JrpSFYnQOHTrRk(a6u8jJbJS(k} zDcg>YOZ9!Ia&umSi9X%!K%jjkU)9fuqcXTmNZEbeSTh6VZt9MymP@@-wwClnA-47F z&og562TXZ0X`LU^a?0)}kW6?Ycx$%&t|hp|;o@$IMq?{xBW~H>PMt&MJKiGBo?3CWuKxh*Y)rLG5Zt@s1V;3e!|3A_A=BTv z7Ul!C3(!~|@fN}-1aa|3Orb(E2i~%y=ub&#v`Ra^@n5o+I%>WYuq734R6tbm#`K2M z2|?-1v*odJK2LB~Pnx6QARTgNZXd-ZX4+nQ+a2bb@v%|?+{G>=-Zq0O%00SI<7;(?(Q>eWurVx- zGDzj2biWkFF=EdZ?mOW>wpZ@iG|B_;EvU<*S>8WFpe|V+`(q@=D3H#E&1betyT16V z*GiLjmoN$Q>pjl+)eneg63F%hlZIR%>jdFKUoaC!Vrg#x%C_#9XiJr=!qcK}M5*BC zxhgfmaRL#bWW>8KRvHGv0=50pE%4pR9+o;UO)+xKm+HI23ba`@2G(@j<$tJS0rYNi z2B|psnviA?k+xAYg2Z*gJT+{Am-S~<6L#bfj1Ql#5l#ruJhTpex*p=9*&v|7K=97m zi$ayWR&&OmYsY_z@2$;13>2qY8UdS9O!%&bh!Td0f^L7^QH`JQG|uIFpm(byi9E_x zvs>CMS3sAwQ)CxnqLrS>h)^|UYZ6PHBMGHrqtAdw{biM(Pk|~fQ}+7^ze}9>AdGnZ z_s)u-M8USgvsrWZu%j@&P_@>)pl{vp5L+we4Ovy$u8|SVTCTgkRSvh5Du>HrP`XkT zctXmkwGq;GVMfDn@f8DJOJd%7&POypi%oFkwB>pm%3n%?;K(mf!$Y3Jntsd)>%q5h zqnI?q@lju8Y&<THtXMCl-KU|u1(23wRYd*5xFgf1X@_KGNAkO+5C>*}27%xRk>jGRzQWSjeuV3G{qm~VVLE@h~ z8xAG$rD?vqk~|iLdp}sxFR)xFb7h%gQki%Md4z|TQzw3-nXNZd{@wox zMAJftfkCgo-5T{7du*yfHukzSvwK?Ds}P@E2}_vtKRiX|&n~I-YzSS#dd*|nGlT~| zrl+&!ZqIyKHtTquSu)uO-I5M&qLw~?*ERlKGe$lExK$*HZ9RKdWZymC8K=>MYcSt; zwmGQ3G{@{0XDjVB_Q}g5tYvHh7b6)!Ra5W0>cWeq-WFYl2Sy_1jRZ0VMRO-xLsIq+ zt`M1mnV?VXN2fSy1IoMVY*75;&VPKGlh(aYyDeC4JZi0rI(_c8MgZ>8=Zh zL;e-T^qdOcBiw6(W8JR>McMW@ID`V_qLMm88;~Wzx3i|`!uH`kKX6N&VY7Wx{7BCw zcIVS|Q}3Qz2(Qq#S5Hxxyz__1*b1D^I6~XwIj=Nzr~5RU>z#ji4+nL@lrsi{T_9A; zH!S;&mlk!B^U2Sj$@!}<5{84_U_go!Cx)>|FlQ>)!FIM`BEX`<1_njks;m{Q1u6TS zsgV7=jf3CGzO09Ye#J9nL&5jWcBx_c{ep!2W%gFC-KB*zL6SATB>rC2-QbNnLEaUW z&sEW~ z0|I@z)t6u1ULKRlB_iC5*5aALxda$=Js&TKD5+NONi~Gt|Li%AmKHC|u=s7c?;*9? z9s3Z+vzZ68O>&D76TaunS7qux!D~%s{z_g`Q18j)a?8@O?}ZLLwqCku{}6PN->?Wr zytIS30HMYHx>H@JjYCY!lIc_`!$1VqZ3Zi}4hnM2Unhha!~i}{A#}O-sGjzIXs=`1 zikO+His!}FhbV5HKc(dQ6Np=Xq)LLjeYC23_!P9#V%$7yv~#s$zc**=yUD5>OM zU$DJ35Ad#_D5!Vd1;8_>B4tS9SievDVY1bW++z$Cv1u{#f;SN*LodqhnL(CP!`ogG0@D-dB(3%9CNWnjDRC)#k z*gi(Vsq&gOudhUjGrr>tdhzUcaFMcv~^kTKR9qD=~ ze*TR+Q&yE?{6xA-gf>tTw30Y`O>RHa6IJ4IcZoOz!;SIT(t~z8P-+hcRw5i+1cEUJ zJy4bcczJeJfc-y0a)(I;L5&0q%P8qV^khhP>mb`$W5rJ~zz`(3YJQo9x^Ep!yf z7ZoIyG`1#tPr%qvFNgV(fpE!8+l00fnpoxwiNOs?iqs@iSb>ZiuGQhzDN|bsU_dnD z6MuZ=)P6{RD+v;*Zzh44qwe^VRSaEfRY@d`*1}y}Q^$k_t;1!*^Ftf1<>mKZo7Aux zttb+f`ynL?Vs*-n99y=B0|>0Q-b_u?%~cBDrAh#s{c?%YbQAn&GGT=gMIjUUNzrrD z>!t4+mMA~n0_Hb+#ztSpYv7_{k_D#y?e*6v4+{`~%}!&9@|UU(H@~046|(KPJ1=Ll z9qM{Nv?^68j3D%xpV^hXC3fgXySb)bAvSZxGKsNuai*hyS+n?qgznvJyYb}_x%K;C zEXgG!z7wwK6#`QiDlQS;=k3k)J=3Q{pj}>`S&%D)^4*D97IxUWF9NzfU&fq>V0upL z*nNgE9@LR?Z1hji8crx)ckGYZHuuXt&`iY$XEs76b5kBX(}C(*2U||MCp1P6qUTT1cyK^+sGY8P;A?DgrySv#KJ6hY9~<{I10& z7G#4VzZkxSvDOGSlEBNzm{l|qg%5#>(~cM(8eJHS^fLP@^aJS=^rK%zij&iKPwl9R-Q^TQ)v>FCs43+Q;uYV!m55MAFiaC7VMEl!MQ#knwO+j?nVyIw0;R zOI6gz_?-h%9y2Rb`7%ITElOZLW-zK}K=k;?Wsj>{I&}{@{u#wga(Fc<9s(Ds07SO3 z&MsR)k20h6RRKRHWWkptJz&Epm+kn$aZc*cC?k*B2h0>DCXs-vCF4fV3bxOZ&S_he zV*0~!c2U6#ucE+^sqQ7|ajh)R?|Bf6i@Lnl4Gt|<&x4Ruy6%U1yB||@;5eoJ%3NQ( zc6ZXq@6@Ma&iUXlHhj}&whpx9B_i6i*(#HvJL#->4dCi01PQTl(JY}9v4RYbtFf0> z5XGwlE;pBkePc8N#%tzb6M?xb%L#&~|S$8kjU$7K3$*hM3>GQWG zM^K$d=kaAsWA&LWbR`~tmg9gD>0Es|y?q5=wQxw)fw|RAWS{;@4z2ULvZlC16Wgy~gSC{rtSM?<+ z@{>R`gsmXDUY&b*hWl=~vRBLXAVH!2hp+M+Q}>x;tp9R{htt!Ikbc_1wA*>2U9tP( z(|hRUz*Y~h&$Ba6v6wS2I$W=Onh6)2@Lk98@5ue5Y`+I<42p#^3$vBBvr~?8zeKsnnS`i;?{}TQqC> zRVbSbV+-L^*tXm1mrM4D9B);9@B4WLaAUO=4&UO{0CwhRgCkOAw4}gy&h7y?qsv8` zx~LQBdr9z*w3@E6;geBRe9Rba)UCo=ulLRO2U7obKqn0d6elf)xcF7d1{m1eYxK*g za4YpZNEYm9ohMq|C=f(6P9X@OU5V`gvHuBo6~_st_#T1pdR%Xh-vf$i@k1)rxL-b( zt^ncZ49tpKyVJX~O`khPQx1Hp4N~)xMpJzOr--d1x|Ei z=EN2%ovSb^ZZEBPlOqnsej}Ut$FO!6fVaYuqF@OzCj7uScnoALb6oKVvGpw~iO(uE zCo%*xQ1OA!>-}{mqfm)U$6RZ7*QpEsmd!{<@NlhRT4Jr(CgwESrATuGXd?Mrgp1Su zs%Fzjf$vH|)_piaxI0jjR1-pkrNE1%g*~bi!3i?q*2S|osVL?M=e8D-9fKg6@Eo&E zUAtbS$JD{kaL#{%$nYVI3b#o0@~#<`A@|gnx~iv+OwPY^u-%MRMRw`U6yuIIk~)wV zkN3ahNt75k+UpzaTkp>t$~uU?S;0~Jz)D(l<| z9OrFnvRgff_O3JdTagI4BFK2$ytKEqx_bqiqMfpKN|5o3!km)6KTE?}+@Aj~KBl!-+mPnA9y!k0y$5zgEg1Wd75q@QRGe=8YE7|#k+{5Kw!Q)GaJeg zc{Y+IMAZVpcp4k#9+dj72RW*nxFJ14RBmS-)>DF#BRH!=k?(N1`)0#B!jG{r8S!@T zt*7zrYhUZANonsO#5i|f$rYH@U7(~%xfqT0&UtK_AiFUU!LRzGABGtivx7r*;+yxj zt^U4tqfd!|(e%uY$*V}H^E@^p8p)x(OYr#g4}t_SVhslhqqi>n)p ze5G&qi5qv#)*)dBaA(@z`Mg~XQwkeuy;uafu4u~oT)WZy;AZx?6&x*;_A0|?)24_~ z`#J2e2>Fy}I}WYDVW`GEl(Hmk?2&rhRV3{Y@w^7a%au7aS>(vZs#Zz@bj&S zYi;2X@7Z_AXV4f!cK%s*01URP?3u4@r>C&JuM8^(zDm!{A)-yXfj^;rU7Wcp~L86^Y7hKO+O>m|=`YmCdu)zy2L zE}R*KTI>Gu!`tHA&E`LYVSr;XqK!b(_Fx|&#=^H{!POW(6b1Ww z-UnxD2_!CW(YZORR2-9&W^=7me^-ij`sZDVvU7J(AOvx)&Lj#t@r>VT0aGW+7nL<~ z$3-l7yY_R=)=oE5Lm}5Q4ZP_sZ3{wf`yA5y8Y^iW{@tDN-?IvKvkl?iZ}&^C^RHd^ zi`tG-mwfzlHS1S9eFrUjbp#*N`j#KQZ_sb2Z4WneOtQem$_*JwM1r?n^C@<|*<3ML zi?yoC4DXwfI;SIrIh2cOuh3CYsMI<6Ax>#p_scuq+))J|MHt1QSX{8X=j;ge$cz+; z&8+aA1_{A^N+{*8bD^AWXW$}I5OJSF$JK9yEDP!KwWCQvh+GU&-e>mr+bN-g+9Bcu zec$(idU|pPvbd!Z{D6^fo_&13j z39Xwhw|lHBM1&CcptB1f1rQ8)q+;^zOG50LpnK!A!5D7(-}F>JxpUfeC=~e-OJU-uGV%CM6|g4 z;Krqm!IaiA(>9e;<@x)x8CgFbJFb{Ft&2Uz8}t0J@mtSOD5qe}j5PY^oNX~jhi+=X z&@R>?abOFyGObu=`3!R)FxU%8v)>vrny_Wjc8?-Ppt`E@Anuqc zPbmHn9ZjBDJl7wO5ZSd-vpR2{6@w=Pj!s-_pe_Th#r)4#Am=>4_A=*>x?LBmlw;WM zItX9!!Vls{y;j7Sr0|uubCjN^i%h*=$fI-A(MWbpK%Sq$b=Uy9{Zi*+68+Mk*1FgM zN9GrNnB7nA@krF%adUfIlQOl0i5OxT3lAG z6DGiD_fCbp(;duffy-M+t6@PQ;uPDKVvROo1FuFLWhpS&z_Xb%0WAB*b^ks@{9r@# z`RIcRBMFE^2Aarg-+;ocXC-m^es25{gX`I(wBWjTG9!bLZ8M6=uQWo+@4w~lf_}-< zB;!XDoAm#wSQuBdO%X9~ek;xGMNrmqXGBZ=@wUs`^8^>Y7M>zQU7QEIs93Z!3$I00 zXdNYJZ&RRKdzw^OS~TT6!9Nun&4n~U(|+WJXKgyvj_g6UOeKJhyAw$_MHFF_F8(3vkU=*r)MxSs>5aXgj za$?_n-0r=XER=S$xdmEfD69woy>%YRk6icUVI`;m6}fU^x}55qxOA)s>OH{}6XaMk zq>1_OP-sh9DgKeq6B!E4ct-;{(zY+Q{jR5oi!`)8Td=8}O`Ok5zhqm6(mT1xRSw&M zTHrXB<@f?j92Wi8U9@EYOsEd#Aex4oRyX;y<3T1QWbN_0=;#~pkoTWx_ekl*v>m-P zzeuAGwwYT>F86TRr*NP5q5$C?!0+9!@o2)enQf;US_%>c;tX6{a_T(;ofa=VkHyV; z%i6)``tO{0sHDQZq}#i?-FNRe^3OXrr_({w4`CK>1+Bi_B1#ehzwu8A7#bJ@1WT4W ztCer_2U8t8ug4zY`wN)sV^EKU+SIel$BiB~e{+4xV8ga#C(lE0u-owhzb7|7#hq2U z^x(3J@M%&KqAgz9BIbkAfookf`qaYXvy_h<>AYG)TFX59QOs(6Wo{R%5q=qcmxYq< zLYL`hy5GJg7j`|h*chQ+S%AmRx-_&~uKzY47J)m+#ouIM6aL`Qq5LAqiS|cxh2xC> z6~z1Oz7Y7x<&OvC3V=-(wyRgE_R>!@$mbwyK5ky9oKBzlbJ9mTHEn542;pO)HLx&c zQGVGTEJNrGAVhoiU5jMoXe7p4#5PQxlJ}Xn?R$eBYe*+yM;o{2w!G8vyEb!k=%%}l zDzM;#IL#>`Ak~HsF&y??P|D1*egnPh)o_Fk%2PpLh_C=FQ~fr2kV@NZHeKVK?)qUz zOWY^04(@ZSSXt2oyT+7iqdS{w%ver;c z4d#mmZRJqVPZl-?OP?2_UB6^0h7=A^up(|Mf12RUOgyXO{%}d<+n>2um2&lmvHc0I ztwce$yK8!`>)UY0tKL-TH&B@7J}9_y&NXs8M1p?#6!(1T;cYxQQy1+m;~9D|z0K+y zaKmj)A_~`I@Wjmhxua62wJ!Vt3B93a*G0FA&F5e>dcInJ3(mlmuv-MJMVev#@uDl> z?VeG)Np2G8p0&NM(+<*>2Qh+r4m{F5Hmen-{I}z55F+b#BBBo`#o3J2T@PhuGubg-lr>*twd8v#n}(=g-@;LAlEfk=v~A1;^Ka} z6cpp=eRnN2TVp6TbxqCp^D6}7a=VXswQjRIHoXQZo7rUI(oi?(X{^I}1;l$O&;^HS zwqcZa*#CNAqNJMO&8se%(L?3eIcL{)2^UK2!sFf@7C~$k?-b8Fo^qx>gVEkHKS~L7 z{YSaKY=`3uQfc`d%oz9&0>syT3G66QFf+`?ZrRKorMYZ3p2XV!IHLU_ z#hyhGoRfZmMz^yEyLous;U3Lhf`&PGXWVuI`b<@x`9SIL>lbYEJ<{jzs<9jt*!@;# zwZK&U=wIp8d1$k8!4e_{Jl=2PLwPPWVN)~9TWzsFjY2G5w?MGix3|*v6nau;GVBz>ga0(WeDoJ*V17GCg9YG% zIW%PHN!<+{Hg~iwzMl^ggoZNn2OCn|mq-%gHXEBsh$~<(hJ0y`-%KUKa~5iFc-u{N z_&Ghy9p;8wG!2X0k$n#P>iTxvWF#BsZ$+N9-C9YqW=-FS=yisxtjev0K*~Nw!MFu(E(ws| zZ0A_l@%!!OE{i!6#RzMD5t!1@nKdpsYK+;i zvoCd=*OeTDpWz@A8*Y9)aGx>F{w6OZoE`2Bpi!

xC)M&#l`e)|gwE**g_!EThN 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 = ({ action, editActionConfig, editActionSecrets, errors }) => { +>> = ({ action, editActionConfig, editActionSecrets, errors, docLinks }) => { const { from, host, port, secure } = action.config; const { user, password } = action.secrets; @@ -38,6 +40,17 @@ export const EmailActionConnectorFields: React.FunctionComponent + + + } > { @@ -22,6 +23,7 @@ describe('EmailParamsFields renders', () => { errors={{ to: [], cc: [], bcc: [], subject: [], message: [] }} editAction={() => {}} index={0} + docLinks={{ ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart} /> ); expect(wrapper.find('[data-test-subj="toEmailAddressInput"]').length > 0).toBeTruthy(); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index_connector.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index_connector.tsx index b5aa42cfd539a..6fb078f3c808f 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index_connector.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index_connector.tsx @@ -13,6 +13,7 @@ import { EuiSelect, EuiTitle, EuiIconTip, + EuiLink, } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; @@ -28,7 +29,7 @@ import { const IndexActionConnectorFields: React.FunctionComponent> = ({ action, editActionConfig, errors, http }) => { +>> = ({ action, editActionConfig, errors, http, docLinks }) => { const { index, refresh, executionTimeField } = action.config; const [hasTimeFieldCheckbox, setTimeFieldCheckboxState] = useState( executionTimeField != null @@ -77,10 +78,22 @@ const IndexActionConnectorFields: React.FunctionComponent 0 && index !== undefined} error={errors.index} helpText={ - + <> + + + + + + } > } /> - {hasTimeFieldCheckbox ? ( <> + { test('all params fields is rendered', () => { @@ -18,6 +19,7 @@ describe('IndexParamsFields renders', () => { errors={{ index: [] }} editAction={() => {}} index={0} + docLinks={{ ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart} /> ); expect(wrapper.find('[data-test-subj="documentsJsonEditor"]').first().prop('value')).toBe(`{ diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index_params.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index_params.tsx index fd6a3d64bd4be..e8e8cc582512e 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index_params.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index_params.tsx @@ -4,7 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ import React from 'react'; +import { EuiLink } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; import { ActionParamsProps } from '../../../../types'; import { IndexActionParams } from '.././types'; import { JsonEditorWithMessageVariables } from '../../json_editor_with_message_variables'; @@ -14,6 +16,7 @@ export const IndexParamsFields = ({ index, editAction, messageVariables, + docLinks, }: ActionParamsProps) => { const { documents } = actionParams; @@ -26,26 +29,39 @@ export const IndexParamsFields = ({ }; return ( - 0 ? ((documents[0] as unknown) as string) : '' - } - label={i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.indexAction.documentsFieldLabel', - { - defaultMessage: 'Document to index', + <> + 0 ? ((documents[0] as unknown) as string) : '' } - )} - aria-label={i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.indexAction.jsonDocAriaLabel', - { - defaultMessage: 'Code editor', + label={i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.indexAction.documentsFieldLabel', + { + defaultMessage: 'Document to index', + } + )} + aria-label={i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.indexAction.jsonDocAriaLabel', + { + defaultMessage: 'Code editor', + } + )} + onDocumentsChange={onDocumentsChange} + helpText={ + + + } - )} - onDocumentsChange={onDocumentsChange} - /> + /> + ); }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty_params.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty_params.test.tsx index 1b26b1157add9..9e37047ccda50 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty_params.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty_params.test.tsx @@ -7,6 +7,7 @@ import React from 'react'; import { mountWithIntl } from 'test_utils/enzyme_helpers'; import { EventActionOptions, SeverityActionOptions } from '.././types'; import PagerDutyParamsFields from './pagerduty_params'; +import { DocLinksStart } from 'kibana/public'; describe('PagerDutyParamsFields renders', () => { test('all params fields is rendered', () => { @@ -27,6 +28,7 @@ describe('PagerDutyParamsFields renders', () => { errors={{ summary: [], timestamp: [] }} editAction={() => {}} index={0} + docLinks={{ ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart} /> ); expect(wrapper.find('[data-test-subj="severitySelect"]').length > 0).toBeTruthy(); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/server_log/server_log_params.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/server_log/server_log_params.test.tsx index 1849a7ec9817a..3a015cddcd335 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/server_log/server_log_params.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/server_log/server_log_params.test.tsx @@ -7,6 +7,7 @@ import React from 'react'; import { mountWithIntl } from 'test_utils/enzyme_helpers'; import { ServerLogLevelOptions } from '.././types'; import ServerLogParamsFields from './server_log_params'; +import { DocLinksStart } from 'kibana/public'; describe('ServerLogParamsFields renders', () => { test('all params fields is rendered', () => { @@ -21,6 +22,7 @@ describe('ServerLogParamsFields renders', () => { editAction={() => {}} index={0} defaultMessage={'test default message'} + docLinks={{ ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart} /> ); expect(wrapper.find('[data-test-subj="loggingLevelSelect"]').length > 0).toBeTruthy(); @@ -41,6 +43,7 @@ describe('ServerLogParamsFields renders', () => { errors={{ message: [] }} editAction={() => {}} index={0} + docLinks={{ ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart} /> ); expect(wrapper.find('[data-test-subj="loggingLevelSelect"]').length > 0).toBeTruthy(); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_params.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_params.test.tsx index 57d50cf7e5bdd..3ea628cd65473 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_params.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_params.test.tsx @@ -6,6 +6,7 @@ import React from 'react'; import { mountWithIntl } from 'test_utils/enzyme_helpers'; import ServiceNowParamsFields from './servicenow_params'; +import { DocLinksStart } from 'kibana/public'; describe('ServiceNowParamsFields renders', () => { test('all params fields is rendered', () => { @@ -29,6 +30,7 @@ describe('ServiceNowParamsFields renders', () => { editAction={() => {}} index={0} messageVariables={[]} + docLinks={{ ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart} /> ); expect(wrapper.find('[data-test-subj="urgencySelect"]').length > 0).toBeTruthy(); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack_connectors.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack_connectors.tsx index 311ae587bbe13..b6efd9fa93266 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack_connectors.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack_connectors.tsx @@ -12,7 +12,7 @@ import { SlackActionConnector } from '../types'; const SlackActionFields: React.FunctionComponent> = ({ action, editActionSecrets, errors }) => { +>> = ({ action, editActionSecrets, errors, docLinks }) => { const { webhookUrl } = action.secrets; return ( @@ -22,7 +22,7 @@ const SlackActionFields: React.FunctionComponent { test('all params fields is rendered', () => { @@ -18,6 +19,7 @@ describe('SlackParamsFields renders', () => { errors={{ message: [] }} editAction={() => {}} index={0} + docLinks={{ ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart} /> ); expect(wrapper.find('[data-test-subj="messageTextArea"]').length > 0).toBeTruthy(); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/webhook_params.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/webhook_params.test.tsx index 9e57d7ae608cc..825c1372dfaf7 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/webhook_params.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/webhook_params.test.tsx @@ -6,6 +6,7 @@ import React from 'react'; import { mountWithIntl } from 'test_utils/enzyme_helpers'; import WebhookParamsFields from './webhook_params'; +import { DocLinksStart } from 'kibana/public'; describe('WebhookParamsFields renders', () => { test('all params fields is rendered', () => { @@ -18,6 +19,7 @@ describe('WebhookParamsFields renders', () => { errors={{ body: [] }} editAction={() => {}} index={0} + docLinks={{ ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart} /> ); expect(wrapper.find('[data-test-subj="bodyJsonEditor"]').length > 0).toBeTruthy(); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/json_editor_with_message_variables.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/json_editor_with_message_variables.tsx index 2aac389dce5ec..473c0fe9609ce 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/json_editor_with_message_variables.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/json_editor_with_message_variables.tsx @@ -18,6 +18,7 @@ interface Props { errors?: string[]; areaLabel?: string; onDocumentsChange: (data: string) => void; + helpText?: JSX.Element; } export const JsonEditorWithMessageVariables: React.FunctionComponent = ({ @@ -28,6 +29,7 @@ export const JsonEditorWithMessageVariables: React.FunctionComponent = ({ errors, areaLabel, onDocumentsChange, + helpText, }) => { const [cursorPosition, setCursorPosition] = useState(null); @@ -65,6 +67,7 @@ export const JsonEditorWithMessageVariables: React.FunctionComponent = ({ paramsProperty={paramsProperty} /> } + helpText={helpText} > 0 && connector.name !== undefined} name="name" placeholder="Untitled" diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.tsx index 7f400ee9a5db1..9182d5a687eb5 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.tsx @@ -313,6 +313,7 @@ export const ActionForm = ({ editAction={setActionParamsProperty} messageVariables={messageVariables} defaultMessage={defaultActionMessage ?? undefined} + docLinks={docLinks} /> ) : null} diff --git a/x-pack/plugins/triggers_actions_ui/public/types.ts b/x-pack/plugins/triggers_actions_ui/public/types.ts index a4a13d7ec849c..fe3bf98b03230 100644 --- a/x-pack/plugins/triggers_actions_ui/public/types.ts +++ b/x-pack/plugins/triggers_actions_ui/public/types.ts @@ -42,6 +42,7 @@ export interface ActionParamsProps { errors: IErrorObject; messageVariables?: string[]; defaultMessage?: string; + docLinks: DocLinksStart; } export interface Pagination { From c86ad7bbec30e9d0e5bbf8fa2b9ef64fa1204551 Mon Sep 17 00:00:00 2001 From: Marshall Main <55718608+marshallmain@users.noreply.github.com> Date: Mon, 13 Jul 2020 23:06:48 -0400 Subject: [PATCH 57/66] Change signal.rule.risk score mapping from keyword to float (#71126) * Change risk_score mapping from keyword to float * Change default alert histogram option * Add version to signals template * Fix test * Undo histogram order change Co-authored-by: Elastic Machine --- .../lib/detection_engine/routes/index/get_signals_template.ts | 1 + .../lib/detection_engine/routes/index/signals_mapping.json | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/get_signals_template.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/get_signals_template.ts index 01d7182e253ce..cc22f34560c71 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/get_signals_template.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/get_signals_template.ts @@ -25,6 +25,7 @@ export const getSignalsTemplate = (index: string) => { }, index_patterns: [`${index}-*`], mappings: ecsMapping.mappings, + version: 1, }; return template; }; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/signals_mapping.json b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/signals_mapping.json index aa4166e93f4a1..d600bae2746d9 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/signals_mapping.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/signals_mapping.json @@ -68,7 +68,7 @@ "type": "keyword" }, "risk_score": { - "type": "keyword" + "type": "float" }, "risk_score_mapping": { "properties": { From f4091df289d3c64cf9f70edfa70ee8e04a8ba627 Mon Sep 17 00:00:00 2001 From: Pedro Jaramillo Date: Tue, 14 Jul 2020 05:39:58 +0200 Subject: [PATCH 58/66] [Security Solution][Exceptions] Exception modal bulk close alerts that match exception attributes (#71321) * progress on bulk close * works but could be slow * clean up, add tests * fix reduce types * address 'event.' fields * remove duplicate import * don't replace nested fields * my best friend typescript --- .../build_exceptions_query.test.ts | 1285 ++++++++++------- .../build_exceptions_query.ts | 57 +- .../detection_engine/get_query_filter.test.ts | 90 ++ .../detection_engine/get_query_filter.ts | 15 +- .../exceptions/add_exception_modal/index.tsx | 28 +- .../add_exception_modal/translations.ts | 8 + .../exceptions/edit_exception_modal/index.tsx | 12 +- .../edit_exception_modal/translations.ts | 8 + .../components/exceptions/helpers.test.tsx | 62 + .../common/components/exceptions/helpers.tsx | 30 + .../exceptions/use_add_exception.test.tsx | 99 ++ .../exceptions/use_add_exception.tsx | 29 +- 12 files changed, 1143 insertions(+), 580 deletions(-) diff --git a/x-pack/plugins/security_solution/common/detection_engine/build_exceptions_query.test.ts b/x-pack/plugins/security_solution/common/detection_engine/build_exceptions_query.test.ts index ed0344207d18f..26a219507c3ae 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/build_exceptions_query.test.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/build_exceptions_query.test.ts @@ -22,10 +22,82 @@ import { EntryMatch, EntryMatchAny, EntriesArray, + Operator, } from '../../../lists/common/schemas'; import { getExceptionListItemSchemaMock } from '../../../lists/common/schemas/response/exception_list_item_schema.mock'; describe('build_exceptions_query', () => { + let exclude: boolean; + const makeMatchEntry = ({ + field, + value = 'value-1', + operator = 'included', + }: { + field: string; + value?: string; + operator?: Operator; + }): EntryMatch => { + return { + field, + operator, + type: 'match', + value, + }; + }; + const makeMatchAnyEntry = ({ + field, + operator = 'included', + value = ['value-1', 'value-2'], + }: { + field: string; + operator?: Operator; + value?: string[]; + }): EntryMatchAny => { + return { + field, + operator, + value, + type: 'match_any', + }; + }; + const makeExistsEntry = ({ + field, + operator = 'included', + }: { + field: string; + operator?: Operator; + }): EntryExists => { + return { + field, + operator, + type: 'exists', + }; + }; + const matchEntryWithIncluded: EntryMatch = makeMatchEntry({ + field: 'host.name', + value: 'suricata', + }); + const matchEntryWithExcluded: EntryMatch = makeMatchEntry({ + field: 'host.name', + value: 'suricata', + operator: 'excluded', + }); + const matchAnyEntryWithIncludedAndTwoValues: EntryMatchAny = makeMatchAnyEntry({ + field: 'host.name', + value: ['suricata', 'auditd'], + }); + const existsEntryWithIncluded: EntryExists = makeExistsEntry({ + field: 'host.name', + }); + const existsEntryWithExcluded: EntryExists = makeExistsEntry({ + field: 'host.name', + operator: 'excluded', + }); + + beforeEach(() => { + exclude = true; + }); + describe('getLanguageBooleanOperator', () => { test('it returns value as uppercase if language is "lucene"', () => { const result = getLanguageBooleanOperator({ language: 'lucene', value: 'not' }); @@ -41,239 +113,376 @@ describe('build_exceptions_query', () => { }); describe('operatorBuilder', () => { - describe('kuery', () => { - test('it returns "not " when operator is "included"', () => { - const operator = operatorBuilder({ operator: 'included', language: 'kuery' }); - - expect(operator).toEqual('not '); + describe("when 'exclude' is true", () => { + describe('and langauge is kuery', () => { + test('it returns "not " when operator is "included"', () => { + const operator = operatorBuilder({ operator: 'included', language: 'kuery', exclude }); + expect(operator).toEqual('not '); + }); + test('it returns empty string when operator is "excluded"', () => { + const operator = operatorBuilder({ operator: 'excluded', language: 'kuery', exclude }); + expect(operator).toEqual(''); + }); }); - test('it returns empty string when operator is "excluded"', () => { - const operator = operatorBuilder({ operator: 'excluded', language: 'kuery' }); - - expect(operator).toEqual(''); + describe('and language is lucene', () => { + test('it returns "NOT " when operator is "included"', () => { + const operator = operatorBuilder({ operator: 'included', language: 'lucene', exclude }); + expect(operator).toEqual('NOT '); + }); + test('it returns empty string when operator is "excluded"', () => { + const operator = operatorBuilder({ operator: 'excluded', language: 'lucene', exclude }); + expect(operator).toEqual(''); + }); }); }); - - describe('lucene', () => { - test('it returns "NOT " when operator is "included"', () => { - const operator = operatorBuilder({ operator: 'included', language: 'lucene' }); - - expect(operator).toEqual('NOT '); + describe("when 'exclude' is false", () => { + beforeEach(() => { + exclude = false; }); - test('it returns empty string when operator is "excluded"', () => { - const operator = operatorBuilder({ operator: 'excluded', language: 'lucene' }); + describe('and language is kuery', () => { + test('it returns empty string when operator is "included"', () => { + const operator = operatorBuilder({ operator: 'included', language: 'kuery', exclude }); + expect(operator).toEqual(''); + }); + test('it returns "not " when operator is "excluded"', () => { + const operator = operatorBuilder({ operator: 'excluded', language: 'kuery', exclude }); + expect(operator).toEqual('not '); + }); + }); - expect(operator).toEqual(''); + describe('and language is lucene', () => { + test('it returns empty string when operator is "included"', () => { + const operator = operatorBuilder({ operator: 'included', language: 'lucene', exclude }); + expect(operator).toEqual(''); + }); + test('it returns "NOT " when operator is "excluded"', () => { + const operator = operatorBuilder({ operator: 'excluded', language: 'lucene', exclude }); + expect(operator).toEqual('NOT '); + }); }); }); }); describe('buildExists', () => { - describe('kuery', () => { - test('it returns formatted wildcard string when operator is "excluded"', () => { - const query = buildExists({ - item: { type: 'exists', operator: 'excluded', field: 'host.name' }, - language: 'kuery', + describe("when 'exclude' is true", () => { + describe('kuery', () => { + test('it returns formatted wildcard string when operator is "excluded"', () => { + const query = buildExists({ + item: existsEntryWithExcluded, + language: 'kuery', + exclude, + }); + expect(query).toEqual('host.name:*'); + }); + test('it returns formatted wildcard string when operator is "included"', () => { + const query = buildExists({ + item: existsEntryWithIncluded, + language: 'kuery', + exclude, + }); + expect(query).toEqual('not host.name:*'); }); - - expect(query).toEqual('host.name:*'); }); - test('it returns formatted wildcard string when operator is "included"', () => { - const query = buildExists({ - item: { type: 'exists', operator: 'included', field: 'host.name' }, - language: 'kuery', + describe('lucene', () => { + test('it returns formatted wildcard string when operator is "excluded"', () => { + const query = buildExists({ + item: existsEntryWithExcluded, + language: 'lucene', + exclude, + }); + expect(query).toEqual('_exists_host.name'); + }); + test('it returns formatted wildcard string when operator is "included"', () => { + const query = buildExists({ + item: existsEntryWithIncluded, + language: 'lucene', + exclude, + }); + expect(query).toEqual('NOT _exists_host.name'); }); - - expect(query).toEqual('not host.name:*'); }); }); - describe('lucene', () => { - test('it returns formatted wildcard string when operator is "excluded"', () => { - const query = buildExists({ - item: { type: 'exists', operator: 'excluded', field: 'host.name' }, - language: 'lucene', - }); - - expect(query).toEqual('_exists_host.name'); + describe("when 'exclude' is false", () => { + beforeEach(() => { + exclude = false; }); - test('it returns formatted wildcard string when operator is "included"', () => { - const query = buildExists({ - item: { type: 'exists', operator: 'included', field: 'host.name' }, - language: 'lucene', + describe('kuery', () => { + test('it returns formatted wildcard string when operator is "excluded"', () => { + const query = buildExists({ + item: existsEntryWithExcluded, + language: 'kuery', + exclude, + }); + expect(query).toEqual('not host.name:*'); + }); + test('it returns formatted wildcard string when operator is "included"', () => { + const query = buildExists({ + item: existsEntryWithIncluded, + language: 'kuery', + exclude, + }); + expect(query).toEqual('host.name:*'); }); + }); - expect(query).toEqual('NOT _exists_host.name'); + describe('lucene', () => { + test('it returns formatted wildcard string when operator is "excluded"', () => { + const query = buildExists({ + item: existsEntryWithExcluded, + language: 'lucene', + exclude, + }); + expect(query).toEqual('NOT _exists_host.name'); + }); + test('it returns formatted wildcard string when operator is "included"', () => { + const query = buildExists({ + item: existsEntryWithIncluded, + language: 'lucene', + exclude, + }); + expect(query).toEqual('_exists_host.name'); + }); }); }); }); describe('buildMatch', () => { - describe('kuery', () => { - test('it returns formatted string when operator is "included"', () => { - const query = buildMatch({ - item: { - type: 'match', - operator: 'included', - field: 'host.name', - value: 'suricata', - }, - language: 'kuery', + describe("when 'exclude' is true", () => { + describe('kuery', () => { + test('it returns formatted string when operator is "included"', () => { + const query = buildMatch({ + item: matchEntryWithIncluded, + language: 'kuery', + exclude, + }); + expect(query).toEqual('not host.name:suricata'); + }); + test('it returns formatted string when operator is "excluded"', () => { + const query = buildMatch({ + item: matchEntryWithExcluded, + language: 'kuery', + exclude, + }); + expect(query).toEqual('host.name:suricata'); }); - - expect(query).toEqual('not host.name:suricata'); }); - test('it returns formatted string when operator is "excluded"', () => { - const query = buildMatch({ - item: { - type: 'match', - operator: 'excluded', - field: 'host.name', - value: 'suricata', - }, - language: 'kuery', + describe('lucene', () => { + test('it returns formatted string when operator is "included"', () => { + const query = buildMatch({ + item: matchEntryWithIncluded, + language: 'lucene', + exclude, + }); + expect(query).toEqual('NOT host.name:suricata'); + }); + test('it returns formatted string when operator is "excluded"', () => { + const query = buildMatch({ + item: matchEntryWithExcluded, + language: 'lucene', + exclude, + }); + expect(query).toEqual('host.name:suricata'); }); - - expect(query).toEqual('host.name:suricata'); }); }); - describe('lucene', () => { - test('it returns formatted string when operator is "included"', () => { - const query = buildMatch({ - item: { - type: 'match', - operator: 'included', - field: 'host.name', - value: 'suricata', - }, - language: 'lucene', - }); - - expect(query).toEqual('NOT host.name:suricata'); + describe("when 'exclude' is false", () => { + beforeEach(() => { + exclude = false; }); - test('it returns formatted string when operator is "excluded"', () => { - const query = buildMatch({ - item: { - type: 'match', - operator: 'excluded', - field: 'host.name', - value: 'suricata', - }, - language: 'lucene', + describe('kuery', () => { + test('it returns formatted string when operator is "included"', () => { + const query = buildMatch({ + item: matchEntryWithIncluded, + language: 'kuery', + exclude, + }); + expect(query).toEqual('host.name:suricata'); }); + test('it returns formatted string when operator is "excluded"', () => { + const query = buildMatch({ + item: matchEntryWithExcluded, + language: 'kuery', + exclude, + }); + expect(query).toEqual('not host.name:suricata'); + }); + }); - expect(query).toEqual('host.name:suricata'); + describe('lucene', () => { + test('it returns formatted string when operator is "included"', () => { + const query = buildMatch({ + item: matchEntryWithIncluded, + language: 'lucene', + exclude, + }); + expect(query).toEqual('host.name:suricata'); + }); + test('it returns formatted string when operator is "excluded"', () => { + const query = buildMatch({ + item: matchEntryWithExcluded, + language: 'lucene', + exclude, + }); + expect(query).toEqual('NOT host.name:suricata'); + }); }); }); }); describe('buildMatchAny', () => { - describe('kuery', () => { - test('it returns empty string if given an empty array for "values"', () => { - const exceptionSegment = buildMatchAny({ - item: { - operator: 'included', - field: 'host.name', - value: [], - type: 'match_any', - }, - language: 'kuery', - }); - - expect(exceptionSegment).toEqual(''); - }); + const entryWithIncludedAndNoValues: EntryMatchAny = makeMatchAnyEntry({ + field: 'host.name', + value: [], + }); + const entryWithIncludedAndOneValue: EntryMatchAny = makeMatchAnyEntry({ + field: 'host.name', + value: ['suricata'], + }); + const entryWithExcludedAndTwoValues: EntryMatchAny = makeMatchAnyEntry({ + field: 'host.name', + value: ['suricata', 'auditd'], + operator: 'excluded', + }); - test('it returns formatted string when "values" includes only one item', () => { - const exceptionSegment = buildMatchAny({ - item: { - operator: 'included', - field: 'host.name', - value: ['suricata'], - type: 'match_any', - }, - language: 'kuery', + describe("when 'exclude' is true", () => { + describe('kuery', () => { + test('it returns empty string if given an empty array for "values"', () => { + const exceptionSegment = buildMatchAny({ + item: entryWithIncludedAndNoValues, + language: 'kuery', + exclude, + }); + expect(exceptionSegment).toEqual(''); }); - - expect(exceptionSegment).toEqual('not host.name:(suricata)'); - }); - - test('it returns formatted string when operator is "included"', () => { - const exceptionSegment = buildMatchAny({ - item: { - operator: 'included', - field: 'host.name', - value: ['suricata', 'auditd'], - type: 'match_any', - }, - language: 'kuery', + test('it returns formatted string when "values" includes only one item', () => { + const exceptionSegment = buildMatchAny({ + item: entryWithIncludedAndOneValue, + language: 'kuery', + exclude, + }); + expect(exceptionSegment).toEqual('not host.name:(suricata)'); + }); + test('it returns formatted string when operator is "included"', () => { + const exceptionSegment = buildMatchAny({ + item: matchAnyEntryWithIncludedAndTwoValues, + language: 'kuery', + exclude, + }); + expect(exceptionSegment).toEqual('not host.name:(suricata or auditd)'); }); - expect(exceptionSegment).toEqual('not host.name:(suricata or auditd)'); + test('it returns formatted string when operator is "excluded"', () => { + const exceptionSegment = buildMatchAny({ + item: entryWithExcludedAndTwoValues, + language: 'kuery', + exclude, + }); + expect(exceptionSegment).toEqual('host.name:(suricata or auditd)'); + }); }); - test('it returns formatted string when operator is "excluded"', () => { - const exceptionSegment = buildMatchAny({ - item: { - operator: 'excluded', - field: 'host.name', - value: ['suricata', 'auditd'], - type: 'match_any', - }, - language: 'kuery', + describe('lucene', () => { + test('it returns formatted string when operator is "included"', () => { + const exceptionSegment = buildMatchAny({ + item: matchAnyEntryWithIncludedAndTwoValues, + language: 'lucene', + exclude, + }); + expect(exceptionSegment).toEqual('NOT host.name:(suricata OR auditd)'); + }); + test('it returns formatted string when operator is "excluded"', () => { + const exceptionSegment = buildMatchAny({ + item: entryWithExcludedAndTwoValues, + language: 'lucene', + exclude, + }); + expect(exceptionSegment).toEqual('host.name:(suricata OR auditd)'); + }); + test('it returns formatted string when "values" includes only one item', () => { + const exceptionSegment = buildMatchAny({ + item: entryWithIncludedAndOneValue, + language: 'lucene', + exclude, + }); + expect(exceptionSegment).toEqual('NOT host.name:(suricata)'); }); - - expect(exceptionSegment).toEqual('host.name:(suricata or auditd)'); }); }); - describe('lucene', () => { - test('it returns formatted string when operator is "included"', () => { - const exceptionSegment = buildMatchAny({ - item: { - operator: 'included', - field: 'host.name', - value: ['suricata', 'auditd'], - type: 'match_any', - }, - language: 'lucene', - }); - - expect(exceptionSegment).toEqual('NOT host.name:(suricata OR auditd)'); + describe("when 'exclude' is false", () => { + beforeEach(() => { + exclude = false; }); - test('it returns formatted string when operator is "excluded"', () => { - const exceptionSegment = buildMatchAny({ - item: { - operator: 'excluded', - field: 'host.name', - value: ['suricata', 'auditd'], - type: 'match_any', - }, - language: 'lucene', + describe('kuery', () => { + test('it returns empty string if given an empty array for "values"', () => { + const exceptionSegment = buildMatchAny({ + item: entryWithIncludedAndNoValues, + language: 'kuery', + exclude, + }); + expect(exceptionSegment).toEqual(''); + }); + test('it returns formatted string when "values" includes only one item', () => { + const exceptionSegment = buildMatchAny({ + item: entryWithIncludedAndOneValue, + language: 'kuery', + exclude, + }); + expect(exceptionSegment).toEqual('host.name:(suricata)'); + }); + test('it returns formatted string when operator is "included"', () => { + const exceptionSegment = buildMatchAny({ + item: matchAnyEntryWithIncludedAndTwoValues, + language: 'kuery', + exclude, + }); + expect(exceptionSegment).toEqual('host.name:(suricata or auditd)'); }); - expect(exceptionSegment).toEqual('host.name:(suricata OR auditd)'); + test('it returns formatted string when operator is "excluded"', () => { + const exceptionSegment = buildMatchAny({ + item: entryWithExcludedAndTwoValues, + language: 'kuery', + exclude, + }); + expect(exceptionSegment).toEqual('not host.name:(suricata or auditd)'); + }); }); - test('it returns formatted string when "values" includes only one item', () => { - const exceptionSegment = buildMatchAny({ - item: { - operator: 'included', - field: 'host.name', - value: ['suricata'], - type: 'match_any', - }, - language: 'lucene', + describe('lucene', () => { + test('it returns formatted string when operator is "included"', () => { + const exceptionSegment = buildMatchAny({ + item: matchAnyEntryWithIncludedAndTwoValues, + language: 'lucene', + exclude, + }); + expect(exceptionSegment).toEqual('host.name:(suricata OR auditd)'); + }); + test('it returns formatted string when operator is "excluded"', () => { + const exceptionSegment = buildMatchAny({ + item: entryWithExcludedAndTwoValues, + language: 'lucene', + exclude, + }); + expect(exceptionSegment).toEqual('NOT host.name:(suricata OR auditd)'); + }); + test('it returns formatted string when "values" includes only one item', () => { + const exceptionSegment = buildMatchAny({ + item: entryWithIncludedAndOneValue, + language: 'lucene', + exclude, + }); + expect(exceptionSegment).toEqual('host.name:(suricata)'); }); - - expect(exceptionSegment).toEqual('NOT host.name:(suricata)'); }); }); }); @@ -284,18 +493,11 @@ describe('build_exceptions_query', () => { const item: EntryNested = { field: 'parent', type: 'nested', - entries: [ - { - field: 'nestedField', - operator: 'excluded', - type: 'match', - value: 'value-3', - }, - ], + entries: [makeMatchEntry({ field: 'nestedField', operator: 'excluded' })], }; const result = buildNested({ item, language: 'kuery' }); - expect(result).toEqual('parent:{ nestedField:value-3 }'); + expect(result).toEqual('parent:{ nestedField:value-1 }'); }); test('it returns formatted query when multiple items in nested entry', () => { @@ -303,23 +505,13 @@ describe('build_exceptions_query', () => { field: 'parent', type: 'nested', entries: [ - { - field: 'nestedField', - operator: 'excluded', - type: 'match', - value: 'value-3', - }, - { - field: 'nestedFieldB', - operator: 'excluded', - type: 'match', - value: 'value-4', - }, + makeMatchEntry({ field: 'nestedField', operator: 'excluded' }), + makeMatchEntry({ field: 'nestedFieldB', operator: 'excluded', value: 'value-2' }), ], }; const result = buildNested({ item, language: 'kuery' }); - expect(result).toEqual('parent:{ nestedField:value-3 and nestedFieldB:value-4 }'); + expect(result).toEqual('parent:{ nestedField:value-1 and nestedFieldB:value-2 }'); }); }); @@ -329,18 +521,11 @@ describe('build_exceptions_query', () => { const item: EntryNested = { field: 'parent', type: 'nested', - entries: [ - { - field: 'nestedField', - operator: 'excluded', - type: 'match', - value: 'value-3', - }, - ], + entries: [makeMatchEntry({ field: 'nestedField', operator: 'excluded' })], }; const result = buildNested({ item, language: 'lucene' }); - expect(result).toEqual('parent:{ nestedField:value-3 }'); + expect(result).toEqual('parent:{ nestedField:value-1 }'); }); test('it returns formatted query when multiple items in nested entry', () => { @@ -348,129 +533,157 @@ describe('build_exceptions_query', () => { field: 'parent', type: 'nested', entries: [ - { - field: 'nestedField', - operator: 'excluded', - type: 'match', - value: 'value-3', - }, - { - field: 'nestedFieldB', - operator: 'excluded', - type: 'match', - value: 'value-4', - }, + makeMatchEntry({ field: 'nestedField', operator: 'excluded' }), + makeMatchEntry({ field: 'nestedFieldB', operator: 'excluded', value: 'value-2' }), ], }; const result = buildNested({ item, language: 'lucene' }); - expect(result).toEqual('parent:{ nestedField:value-3 AND nestedFieldB:value-4 }'); + expect(result).toEqual('parent:{ nestedField:value-1 AND nestedFieldB:value-2 }'); }); }); }); describe('evaluateValues', () => { - describe('kuery', () => { - test('it returns formatted wildcard string when "type" is "exists"', () => { - const list: EntryExists = { - operator: 'included', - type: 'exists', - field: 'host.name', - }; - const result = evaluateValues({ - item: list, - language: 'kuery', + describe("when 'exclude' is true", () => { + describe('kuery', () => { + test('it returns formatted wildcard string when "type" is "exists"', () => { + const result = evaluateValues({ + item: existsEntryWithIncluded, + language: 'kuery', + exclude, + }); + expect(result).toEqual('not host.name:*'); }); - - expect(result).toEqual('not host.name:*'); - }); - - test('it returns formatted string when "type" is "match"', () => { - const list: EntryMatch = { - operator: 'included', - type: 'match', - field: 'host.name', - value: 'suricata', - }; - const result = evaluateValues({ - item: list, - language: 'kuery', + test('it returns formatted string when "type" is "match"', () => { + const result = evaluateValues({ + item: matchEntryWithIncluded, + language: 'kuery', + exclude, + }); + expect(result).toEqual('not host.name:suricata'); + }); + test('it returns formatted string when "type" is "match_any"', () => { + const result = evaluateValues({ + item: matchAnyEntryWithIncludedAndTwoValues, + language: 'kuery', + exclude, + }); + expect(result).toEqual('not host.name:(suricata or auditd)'); }); - - expect(result).toEqual('not host.name:suricata'); }); - test('it returns formatted string when "type" is "match_any"', () => { - const list: EntryMatchAny = { - operator: 'included', - type: 'match_any', - field: 'host.name', - value: ['suricata', 'auditd'], - }; - - const result = evaluateValues({ - item: list, - language: 'kuery', + describe('lucene', () => { + describe('kuery', () => { + test('it returns formatted wildcard string when "type" is "exists"', () => { + const result = evaluateValues({ + item: existsEntryWithIncluded, + language: 'lucene', + exclude, + }); + expect(result).toEqual('NOT _exists_host.name'); + }); + test('it returns formatted string when "type" is "match"', () => { + const result = evaluateValues({ + item: matchEntryWithIncluded, + language: 'lucene', + exclude, + }); + expect(result).toEqual('NOT host.name:suricata'); + }); + test('it returns formatted string when "type" is "match_any"', () => { + const result = evaluateValues({ + item: matchAnyEntryWithIncludedAndTwoValues, + language: 'lucene', + exclude, + }); + expect(result).toEqual('NOT host.name:(suricata OR auditd)'); + }); }); - - expect(result).toEqual('not host.name:(suricata or auditd)'); }); }); - describe('lucene', () => { + describe("when 'exclude' is false", () => { + beforeEach(() => { + exclude = false; + }); + describe('kuery', () => { test('it returns formatted wildcard string when "type" is "exists"', () => { - const list: EntryExists = { - operator: 'included', - type: 'exists', - field: 'host.name', - }; const result = evaluateValues({ - item: list, - language: 'lucene', + item: existsEntryWithIncluded, + language: 'kuery', + exclude, }); - - expect(result).toEqual('NOT _exists_host.name'); + expect(result).toEqual('host.name:*'); }); - test('it returns formatted string when "type" is "match"', () => { - const list: EntryMatch = { - operator: 'included', - type: 'match', - field: 'host.name', - value: 'suricata', - }; const result = evaluateValues({ - item: list, - language: 'lucene', + item: matchEntryWithIncluded, + language: 'kuery', + exclude, }); - - expect(result).toEqual('NOT host.name:suricata'); + expect(result).toEqual('host.name:suricata'); }); - test('it returns formatted string when "type" is "match_any"', () => { - const list: EntryMatchAny = { - operator: 'included', - type: 'match_any', - field: 'host.name', - value: ['suricata', 'auditd'], - }; - const result = evaluateValues({ - item: list, - language: 'lucene', + item: matchAnyEntryWithIncludedAndTwoValues, + language: 'kuery', + exclude, }); + expect(result).toEqual('host.name:(suricata or auditd)'); + }); + }); - expect(result).toEqual('NOT host.name:(suricata OR auditd)'); + describe('lucene', () => { + describe('kuery', () => { + test('it returns formatted wildcard string when "type" is "exists"', () => { + const result = evaluateValues({ + item: existsEntryWithIncluded, + language: 'lucene', + exclude, + }); + expect(result).toEqual('_exists_host.name'); + }); + test('it returns formatted string when "type" is "match"', () => { + const result = evaluateValues({ + item: matchEntryWithIncluded, + language: 'lucene', + exclude, + }); + expect(result).toEqual('host.name:suricata'); + }); + test('it returns formatted string when "type" is "match_any"', () => { + const result = evaluateValues({ + item: matchAnyEntryWithIncludedAndTwoValues, + language: 'lucene', + exclude, + }); + expect(result).toEqual('host.name:(suricata OR auditd)'); + }); }); }); }); }); describe('formatQuery', () => { + describe('when query is empty string', () => { + test('it returns query if "exceptions" is empty array', () => { + const formattedQuery = formatQuery({ exceptions: [], query: '', language: 'kuery' }); + expect(formattedQuery).toEqual(''); + }); + test('it returns expected query string when single exception in array', () => { + const formattedQuery = formatQuery({ + exceptions: ['b:(value-1 or value-2) and not c:*'], + query: '', + language: 'kuery', + }); + expect(formattedQuery).toEqual('(b:(value-1 or value-2) and not c:*)'); + }); + }); + test('it returns query if "exceptions" is empty array', () => { const formattedQuery = formatQuery({ exceptions: [], query: 'a:*', language: 'kuery' }); - expect(formattedQuery).toEqual('a:*'); }); @@ -480,7 +693,6 @@ describe('build_exceptions_query', () => { query: 'a:*', language: 'kuery', }); - expect(formattedQuery).toEqual('(a:* and b:(value-1 or value-2) and not c:*)'); }); @@ -490,7 +702,6 @@ describe('build_exceptions_query', () => { query: 'a:*', language: 'kuery', }); - expect(formattedQuery).toEqual( '(a:* and b:(value-1 or value-2) and not c:*) or (a:* and not d:*)' ); @@ -502,6 +713,7 @@ describe('build_exceptions_query', () => { const query = buildExceptionItemEntries({ language: 'kuery', lists: [], + exclude, }); expect(query).toEqual(''); @@ -511,22 +723,13 @@ describe('build_exceptions_query', () => { // Equal to query && !(b && !c) -> (query AND NOT b) OR (query AND c) // https://www.dcode.fr/boolean-expressions-calculator const payload: EntriesArray = [ - { - field: 'b', - operator: 'included', - type: 'match_any', - value: ['value-1', 'value-2'], - }, - { - field: 'c', - operator: 'excluded', - type: 'match', - value: 'value-3', - }, + makeMatchAnyEntry({ field: 'b' }), + makeMatchEntry({ field: 'c', operator: 'excluded', value: 'value-3' }), ]; const query = buildExceptionItemEntries({ language: 'kuery', lists: payload, + exclude, }); const expectedQuery = 'not b:(value-1 or value-2) and c:value-3'; @@ -537,28 +740,19 @@ describe('build_exceptions_query', () => { // Equal to query && !(b || !c) -> (query AND NOT b AND c) // https://www.dcode.fr/boolean-expressions-calculator const lists: EntriesArray = [ - { - field: 'b', - operator: 'included', - type: 'match_any', - value: ['value-1', 'value-2'], - }, + makeMatchAnyEntry({ field: 'b' }), { field: 'parent', type: 'nested', entries: [ - { - field: 'nestedField', - operator: 'excluded', - type: 'match', - value: 'value-3', - }, + makeMatchEntry({ field: 'nestedField', operator: 'excluded', value: 'value-3' }), ], }, ]; const query = buildExceptionItemEntries({ language: 'kuery', lists, + exclude, }); const expectedQuery = 'not b:(value-1 or value-2) and parent:{ nestedField:value-3 }'; @@ -569,33 +763,20 @@ describe('build_exceptions_query', () => { // Equal to query && !((b || !c) && d) -> (query AND NOT b AND c) OR (query AND NOT d) // https://www.dcode.fr/boolean-expressions-calculator const lists: EntriesArray = [ - { - field: 'b', - operator: 'included', - type: 'match_any', - value: ['value-1', 'value-2'], - }, + makeMatchAnyEntry({ field: 'b' }), { field: 'parent', type: 'nested', entries: [ - { - field: 'nestedField', - operator: 'excluded', - type: 'match', - value: 'value-3', - }, + makeMatchEntry({ field: 'nestedField', operator: 'excluded', value: 'value-3' }), ], }, - { - field: 'd', - operator: 'included', - type: 'exists', - }, + makeExistsEntry({ field: 'd' }), ]; const query = buildExceptionItemEntries({ language: 'kuery', lists, + exclude, }); const expectedQuery = 'not b:(value-1 or value-2) and parent:{ nestedField:value-3 } and not d:*'; @@ -606,72 +787,151 @@ describe('build_exceptions_query', () => { // Equal to query && !((b || !c) && !d) -> (query AND NOT b AND c) OR (query AND d) // https://www.dcode.fr/boolean-expressions-calculator const lists: EntriesArray = [ - { - field: 'b', - operator: 'included', - type: 'match_any', - value: ['value-1', 'value-2'], - }, + makeMatchAnyEntry({ field: 'b' }), { field: 'parent', type: 'nested', entries: [ - { - field: 'nestedField', - operator: 'excluded', - type: 'match', - value: 'value-3', - }, + makeMatchEntry({ field: 'nestedField', operator: 'excluded', value: 'value-3' }), ], }, - { - field: 'e', - operator: 'excluded', - type: 'exists', - }, + makeExistsEntry({ field: 'e', operator: 'excluded' }), ]; const query = buildExceptionItemEntries({ language: 'lucene', lists, + exclude, }); const expectedQuery = 'NOT b:(value-1 OR value-2) AND parent:{ nestedField:value-3 } AND _exists_e'; expect(query).toEqual(expectedQuery); }); - describe('exists', () => { - test('it returns expected query when list includes single list item with operator of "included"', () => { - // Equal to query && !(b) -> (query AND NOT b) + describe('when "exclude" is false', () => { + beforeEach(() => { + exclude = false; + }); + + test('it returns empty string if empty lists array passed in', () => { + const query = buildExceptionItemEntries({ + language: 'kuery', + lists: [], + exclude, + }); + + expect(query).toEqual(''); + }); + test('it returns expected query when more than one item in list', () => { + // Equal to query && !(b && !c) -> (query AND NOT b) OR (query AND c) + // https://www.dcode.fr/boolean-expressions-calculator + const payload: EntriesArray = [ + makeMatchAnyEntry({ field: 'b' }), + makeMatchEntry({ field: 'c', operator: 'excluded', value: 'value-3' }), + ]; + const query = buildExceptionItemEntries({ + language: 'kuery', + lists: payload, + exclude, + }); + const expectedQuery = 'b:(value-1 or value-2) and not c:value-3'; + + expect(query).toEqual(expectedQuery); + }); + + test('it returns expected query when list item includes nested value', () => { + // Equal to query && !(b || !c) -> (query AND NOT b AND c) // https://www.dcode.fr/boolean-expressions-calculator const lists: EntriesArray = [ + makeMatchAnyEntry({ field: 'b' }), { - field: 'b', - operator: 'included', - type: 'exists', + field: 'parent', + type: 'nested', + entries: [ + makeMatchEntry({ field: 'nestedField', operator: 'excluded', value: 'value-3' }), + ], }, ]; const query = buildExceptionItemEntries({ language: 'kuery', lists, + exclude, }); - const expectedQuery = 'not b:*'; + const expectedQuery = 'b:(value-1 or value-2) and parent:{ nestedField:value-3 }'; expect(query).toEqual(expectedQuery); }); - test('it returns expected query when list includes single list item with operator of "excluded"', () => { - // Equal to query && !(!b) -> (query AND b) + test('it returns expected query when list includes multiple items and nested "and" values', () => { + // Equal to query && !((b || !c) && d) -> (query AND NOT b AND c) OR (query AND NOT d) // https://www.dcode.fr/boolean-expressions-calculator const lists: EntriesArray = [ + makeMatchAnyEntry({ field: 'b' }), { - field: 'b', - operator: 'excluded', - type: 'exists', + field: 'parent', + type: 'nested', + entries: [ + makeMatchEntry({ field: 'nestedField', operator: 'excluded', value: 'value-3' }), + ], }, + makeExistsEntry({ field: 'd' }), ]; const query = buildExceptionItemEntries({ language: 'kuery', lists, + exclude, + }); + const expectedQuery = 'b:(value-1 or value-2) and parent:{ nestedField:value-3 } and d:*'; + expect(query).toEqual(expectedQuery); + }); + + test('it returns expected query when language is "lucene"', () => { + // Equal to query && !((b || !c) && !d) -> (query AND NOT b AND c) OR (query AND d) + // https://www.dcode.fr/boolean-expressions-calculator + const lists: EntriesArray = [ + makeMatchAnyEntry({ field: 'b' }), + { + field: 'parent', + type: 'nested', + entries: [ + makeMatchEntry({ field: 'nestedField', operator: 'excluded', value: 'value-3' }), + ], + }, + makeExistsEntry({ field: 'e', operator: 'excluded' }), + ]; + const query = buildExceptionItemEntries({ + language: 'lucene', + lists, + exclude, + }); + const expectedQuery = + 'b:(value-1 OR value-2) AND parent:{ nestedField:value-3 } AND NOT _exists_e'; + expect(query).toEqual(expectedQuery); + }); + }); + + describe('exists', () => { + test('it returns expected query when list includes single list item with operator of "included"', () => { + // Equal to query && !(b) -> (query AND NOT b) + // https://www.dcode.fr/boolean-expressions-calculator + const lists: EntriesArray = [makeExistsEntry({ field: 'b' })]; + const query = buildExceptionItemEntries({ + language: 'kuery', + lists, + exclude, + }); + const expectedQuery = 'not b:*'; + + expect(query).toEqual(expectedQuery); + }); + + test('it returns expected query when list includes single list item with operator of "excluded"', () => { + // Equal to query && !(!b) -> (query AND b) + // https://www.dcode.fr/boolean-expressions-calculator + const lists: EntriesArray = [makeExistsEntry({ field: 'b', operator: 'excluded' })]; + const query = buildExceptionItemEntries({ + language: 'kuery', + lists, + exclude, }); const expectedQuery = 'b:*'; @@ -682,27 +942,17 @@ describe('build_exceptions_query', () => { // Equal to query && !(!b || !c) -> (query AND b AND c) // https://www.dcode.fr/boolean-expressions-calculator const lists: EntriesArray = [ - { - field: 'b', - operator: 'excluded', - type: 'exists', - }, + makeExistsEntry({ field: 'b', operator: 'excluded' }), { field: 'parent', type: 'nested', - entries: [ - { - field: 'c', - operator: 'excluded', - type: 'match', - value: 'value-1', - }, - ], + entries: [makeMatchEntry({ field: 'c', operator: 'excluded', value: 'value-1' })], }, ]; const query = buildExceptionItemEntries({ language: 'kuery', lists, + exclude, }); const expectedQuery = 'b:* and parent:{ c:value-1 }'; @@ -713,38 +963,21 @@ describe('build_exceptions_query', () => { // Equal to query && !((b || !c || d) && e) -> (query AND NOT b AND c AND NOT d) OR (query AND NOT e) // https://www.dcode.fr/boolean-expressions-calculator const lists: EntriesArray = [ - { - field: 'b', - operator: 'included', - type: 'exists', - }, + makeExistsEntry({ field: 'b' }), { field: 'parent', type: 'nested', entries: [ - { - field: 'c', - operator: 'excluded', - type: 'match', - value: 'value-1', - }, - { - field: 'd', - operator: 'included', - type: 'match', - value: 'value-2', - }, + makeMatchEntry({ field: 'c', operator: 'excluded', value: 'value-1' }), + makeMatchEntry({ field: 'd', value: 'value-2' }), ], }, - { - field: 'e', - operator: 'included', - type: 'exists', - }, + makeExistsEntry({ field: 'e' }), ]; const query = buildExceptionItemEntries({ language: 'kuery', lists, + exclude, }); const expectedQuery = 'not b:* and parent:{ c:value-1 and d:value-2 } and not e:*'; @@ -756,17 +989,11 @@ describe('build_exceptions_query', () => { test('it returns expected query when list includes single list item with operator of "included"', () => { // Equal to query && !(b) -> (query AND NOT b) // https://www.dcode.fr/boolean-expressions-calculator - const lists: EntriesArray = [ - { - field: 'b', - operator: 'included', - type: 'match', - value: 'value', - }, - ]; + const lists: EntriesArray = [makeMatchEntry({ field: 'b', value: 'value' })]; const query = buildExceptionItemEntries({ language: 'kuery', lists, + exclude, }); const expectedQuery = 'not b:value'; @@ -777,16 +1004,12 @@ describe('build_exceptions_query', () => { // Equal to query && !(!b) -> (query AND b) // https://www.dcode.fr/boolean-expressions-calculator const lists: EntriesArray = [ - { - field: 'b', - operator: 'excluded', - type: 'match', - value: 'value', - }, + makeMatchEntry({ field: 'b', operator: 'excluded', value: 'value' }), ]; const query = buildExceptionItemEntries({ language: 'kuery', lists, + exclude, }); const expectedQuery = 'b:value'; @@ -797,28 +1020,17 @@ describe('build_exceptions_query', () => { // Equal to query && !(!b || !c) -> (query AND b AND c) // https://www.dcode.fr/boolean-expressions-calculator const lists: EntriesArray = [ - { - field: 'b', - operator: 'excluded', - type: 'match', - value: 'value', - }, + makeMatchEntry({ field: 'b', operator: 'excluded', value: 'value' }), { field: 'parent', type: 'nested', - entries: [ - { - field: 'c', - operator: 'excluded', - type: 'match', - value: 'valueC', - }, - ], + entries: [makeMatchEntry({ field: 'c', operator: 'excluded', value: 'valueC' })], }, ]; const query = buildExceptionItemEntries({ language: 'kuery', lists, + exclude, }); const expectedQuery = 'b:value and parent:{ c:valueC }'; @@ -829,42 +1041,23 @@ describe('build_exceptions_query', () => { // Equal to query && !((b || !c || d) && e) -> (query AND NOT b AND c AND NOT d) OR (query AND NOT e) // https://www.dcode.fr/boolean-expressions-calculator const lists: EntriesArray = [ - { - field: 'b', - operator: 'included', - type: 'match', - value: 'value', - }, + makeMatchEntry({ field: 'b', value: 'value' }), { field: 'parent', type: 'nested', entries: [ - { - field: 'c', - operator: 'excluded', - type: 'match', - value: 'valueC', - }, - { - field: 'd', - operator: 'excluded', - type: 'match', - value: 'valueC', - }, + makeMatchEntry({ field: 'c', operator: 'excluded', value: 'valueC' }), + makeMatchEntry({ field: 'd', operator: 'excluded', value: 'valueD' }), ], }, - { - field: 'e', - operator: 'included', - type: 'match', - value: 'valueC', - }, + makeMatchEntry({ field: 'e', value: 'valueE' }), ]; const query = buildExceptionItemEntries({ language: 'kuery', lists, + exclude, }); - const expectedQuery = 'not b:value and parent:{ c:valueC and d:valueC } and not e:valueC'; + const expectedQuery = 'not b:value and parent:{ c:valueC and d:valueD } and not e:valueE'; expect(query).toEqual(expectedQuery); }); @@ -874,19 +1067,13 @@ describe('build_exceptions_query', () => { test('it returns expected query when list includes single list item with operator of "included"', () => { // Equal to query && !(b) -> (query AND NOT b) // https://www.dcode.fr/boolean-expressions-calculator - const lists: EntriesArray = [ - { - field: 'b', - operator: 'included', - type: 'match_any', - value: ['value', 'value-1'], - }, - ]; + const lists: EntriesArray = [makeMatchAnyEntry({ field: 'b' })]; const query = buildExceptionItemEntries({ language: 'kuery', lists, + exclude, }); - const expectedQuery = 'not b:(value or value-1)'; + const expectedQuery = 'not b:(value-1 or value-2)'; expect(query).toEqual(expectedQuery); }); @@ -894,19 +1081,13 @@ describe('build_exceptions_query', () => { test('it returns expected query when list includes single list item with operator of "excluded"', () => { // Equal to query && !(!b) -> (query AND b) // https://www.dcode.fr/boolean-expressions-calculator - const lists: EntriesArray = [ - { - field: 'b', - operator: 'excluded', - type: 'match_any', - value: ['value', 'value-1'], - }, - ]; + const lists: EntriesArray = [makeMatchAnyEntry({ field: 'b', operator: 'excluded' })]; const query = buildExceptionItemEntries({ language: 'kuery', lists, + exclude, }); - const expectedQuery = 'b:(value or value-1)'; + const expectedQuery = 'b:(value-1 or value-2)'; expect(query).toEqual(expectedQuery); }); @@ -915,30 +1096,19 @@ describe('build_exceptions_query', () => { // Equal to query && !(!b || c) -> (query AND b AND NOT c) // https://www.dcode.fr/boolean-expressions-calculator const lists: EntriesArray = [ - { - field: 'b', - operator: 'excluded', - type: 'match_any', - value: ['value', 'value-1'], - }, + makeMatchAnyEntry({ field: 'b', operator: 'excluded' }), { field: 'parent', type: 'nested', - entries: [ - { - field: 'c', - operator: 'excluded', - type: 'match', - value: 'valueC', - }, - ], + entries: [makeMatchEntry({ field: 'c', operator: 'excluded', value: 'valueC' })], }, ]; const query = buildExceptionItemEntries({ language: 'kuery', lists, + exclude, }); - const expectedQuery = 'b:(value or value-1) and parent:{ c:valueC }'; + const expectedQuery = 'b:(value-1 or value-2) and parent:{ c:valueC }'; expect(query).toEqual(expectedQuery); }); @@ -947,24 +1117,15 @@ describe('build_exceptions_query', () => { // Equal to query && !((b || !c || d) && e) -> ((query AND NOT b AND c AND NOT d) OR (query AND NOT e) // https://www.dcode.fr/boolean-expressions-calculator const lists: EntriesArray = [ - { - field: 'b', - operator: 'included', - type: 'match_any', - value: ['value', 'value-1'], - }, - { - field: 'e', - operator: 'included', - type: 'match_any', - value: ['valueE', 'value-4'], - }, + makeMatchAnyEntry({ field: 'b' }), + makeMatchAnyEntry({ field: 'c' }), ]; const query = buildExceptionItemEntries({ language: 'kuery', lists, + exclude, }); - const expectedQuery = 'not b:(value or value-1) and not e:(valueE or value-4)'; + const expectedQuery = 'not b:(value-1 or value-2) and not c:(value-1 or value-2)'; expect(query).toEqual(expectedQuery); }); @@ -985,36 +1146,16 @@ describe('build_exceptions_query', () => { const payload = getExceptionListItemSchemaMock(); const payload2 = getExceptionListItemSchemaMock(); payload2.entries = [ - { - field: 'b', - operator: 'included', - type: 'match_any', - value: ['value', 'value-1'], - }, + makeMatchAnyEntry({ field: 'b' }), { field: 'parent', type: 'nested', entries: [ - { - field: 'c', - operator: 'excluded', - type: 'match', - value: 'valueC', - }, - { - field: 'd', - operator: 'excluded', - type: 'match', - value: 'valueD', - }, + makeMatchEntry({ field: 'c', operator: 'excluded', value: 'valueC' }), + makeMatchEntry({ field: 'd', operator: 'excluded', value: 'valueD' }), ], }, - { - field: 'e', - operator: 'included', - type: 'match_any', - value: ['valueE', 'value-4'], - }, + makeMatchAnyEntry({ field: 'e' }), ]; const query = buildQueryExceptions({ query: 'a:*', @@ -1022,7 +1163,7 @@ describe('build_exceptions_query', () => { lists: [payload, payload2], }); const expectedQuery = - '(a:* and some.parentField:{ nested.field:some value } and not some.not.nested.field:some value) or (a:* and not b:(value or value-1) and parent:{ c:valueC and d:valueD } and not e:(valueE or value-4))'; + '(a:* and some.parentField:{ nested.field:some value } and not some.not.nested.field:some value) or (a:* and not b:(value-1 or value-2) and parent:{ c:valueC and d:valueD } and not e:(value-1 or value-2))'; expect(query).toEqual([{ query: expectedQuery, language: 'kuery' }]); }); @@ -1033,36 +1174,16 @@ describe('build_exceptions_query', () => { const payload = getExceptionListItemSchemaMock(); const payload2 = getExceptionListItemSchemaMock(); payload2.entries = [ - { - field: 'b', - operator: 'included', - type: 'match_any', - value: ['value', 'value-1'], - }, + makeMatchAnyEntry({ field: 'b' }), { field: 'parent', type: 'nested', entries: [ - { - field: 'c', - operator: 'excluded', - type: 'match', - value: 'valueC', - }, - { - field: 'd', - operator: 'excluded', - type: 'match', - value: 'valueD', - }, + makeMatchEntry({ field: 'c', operator: 'excluded', value: 'valueC' }), + makeMatchEntry({ field: 'd', operator: 'excluded', value: 'valueD' }), ], }, - { - field: 'e', - operator: 'included', - type: 'match_any', - value: ['valueE', 'value-4'], - }, + makeMatchAnyEntry({ field: 'e' }), ]; const query = buildQueryExceptions({ query: 'a:*', @@ -1070,9 +1191,85 @@ describe('build_exceptions_query', () => { lists: [payload, payload2], }); const expectedQuery = - '(a:* AND some.parentField:{ nested.field:some value } AND NOT some.not.nested.field:some value) OR (a:* AND NOT b:(value OR value-1) AND parent:{ c:valueC AND d:valueD } AND NOT e:(valueE OR value-4))'; + '(a:* AND some.parentField:{ nested.field:some value } AND NOT some.not.nested.field:some value) OR (a:* AND NOT b:(value-1 OR value-2) AND parent:{ c:valueC AND d:valueD } AND NOT e:(value-1 OR value-2))'; expect(query).toEqual([{ query: expectedQuery, language: 'lucene' }]); }); + + describe('when "exclude" is false', () => { + beforeEach(() => { + exclude = false; + }); + + test('it returns original query if lists is empty array', () => { + const query = buildQueryExceptions({ + query: 'host.name: *', + language: 'kuery', + lists: [], + exclude, + }); + const expectedQuery = 'host.name: *'; + + expect(query).toEqual([{ query: expectedQuery, language: 'kuery' }]); + }); + + test('it returns expected query when lists exist and language is "kuery"', () => { + // Equal to query && !((b || !c || d) && e) -> ((query AND NOT b AND c AND NOT d) OR (query AND NOT e) + // https://www.dcode.fr/boolean-expressions-calculator + const payload = getExceptionListItemSchemaMock(); + const payload2 = getExceptionListItemSchemaMock(); + payload2.entries = [ + makeMatchAnyEntry({ field: 'b' }), + { + field: 'parent', + type: 'nested', + entries: [ + makeMatchEntry({ field: 'c', operator: 'excluded', value: 'valueC' }), + makeMatchEntry({ field: 'd', operator: 'excluded', value: 'valueD' }), + ], + }, + makeMatchAnyEntry({ field: 'e' }), + ]; + const query = buildQueryExceptions({ + query: 'a:*', + language: 'kuery', + lists: [payload, payload2], + exclude, + }); + const expectedQuery = + '(a:* and some.parentField:{ nested.field:some value } and some.not.nested.field:some value) or (a:* and b:(value-1 or value-2) and parent:{ c:valueC and d:valueD } and e:(value-1 or value-2))'; + + expect(query).toEqual([{ query: expectedQuery, language: 'kuery' }]); + }); + + test('it returns expected query when lists exist and language is "lucene"', () => { + // Equal to query && !((b || !c || d) && e) -> ((query AND NOT b AND c AND NOT d) OR (query AND NOT e) + // https://www.dcode.fr/boolean-expressions-calculator + const payload = getExceptionListItemSchemaMock(); + const payload2 = getExceptionListItemSchemaMock(); + payload2.entries = [ + makeMatchAnyEntry({ field: 'b' }), + { + field: 'parent', + type: 'nested', + entries: [ + makeMatchEntry({ field: 'c', operator: 'excluded', value: 'valueC' }), + makeMatchEntry({ field: 'd', operator: 'excluded', value: 'valueD' }), + ], + }, + makeMatchAnyEntry({ field: 'e' }), + ]; + const query = buildQueryExceptions({ + query: 'a:*', + language: 'lucene', + lists: [payload, payload2], + exclude, + }); + const expectedQuery = + '(a:* AND some.parentField:{ nested.field:some value } AND some.not.nested.field:some value) OR (a:* AND b:(value-1 OR value-2) AND parent:{ c:valueC AND d:valueD } AND e:(value-1 OR value-2))'; + + expect(query).toEqual([{ query: expectedQuery, language: 'lucene' }]); + }); + }); }); }); diff --git a/x-pack/plugins/security_solution/common/detection_engine/build_exceptions_query.ts b/x-pack/plugins/security_solution/common/detection_engine/build_exceptions_query.ts index d3ac5d1490703..a70e6a6638589 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/build_exceptions_query.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/build_exceptions_query.ts @@ -17,6 +17,7 @@ import { entriesMatch, entriesNested, ExceptionListItemSchema, + CreateExceptionListItemSchema, } from '../shared_imports'; import { Language, Query } from './schemas/common/schemas'; @@ -45,32 +46,35 @@ export const getLanguageBooleanOperator = ({ export const operatorBuilder = ({ operator, language, + exclude, }: { operator: Operator; language: Language; + exclude: boolean; }): string => { const not = getLanguageBooleanOperator({ language, value: 'not', }); - switch (operator) { - case 'included': - return `${not} `; - default: - return ''; + if ((exclude && operator === 'included') || (!exclude && operator === 'excluded')) { + return `${not} `; + } else { + return ''; } }; export const buildExists = ({ item, language, + exclude, }: { item: EntryExists; language: Language; + exclude: boolean; }): string => { const { operator, field } = item; - const exceptionOperator = operatorBuilder({ operator, language }); + const exceptionOperator = operatorBuilder({ operator, language, exclude }); switch (language) { case 'kuery': @@ -85,12 +89,14 @@ export const buildExists = ({ export const buildMatch = ({ item, language, + exclude, }: { item: EntryMatch; language: Language; + exclude: boolean; }): string => { const { value, operator, field } = item; - const exceptionOperator = operatorBuilder({ operator, language }); + const exceptionOperator = operatorBuilder({ operator, language, exclude }); return `${exceptionOperator}${field}:${value}`; }; @@ -98,9 +104,11 @@ export const buildMatch = ({ export const buildMatchAny = ({ item, language, + exclude, }: { item: EntryMatchAny; language: Language; + exclude: boolean; }): string => { const { value, operator, field } = item; @@ -109,7 +117,7 @@ export const buildMatchAny = ({ return ''; default: const or = getLanguageBooleanOperator({ language, value: 'or' }); - const exceptionOperator = operatorBuilder({ operator, language }); + const exceptionOperator = operatorBuilder({ operator, language, exclude }); const matchAnyValues = value.map((v) => v); return `${exceptionOperator}${field}:(${matchAnyValues.join(` ${or} `)})`; @@ -133,16 +141,18 @@ export const buildNested = ({ export const evaluateValues = ({ item, language, + exclude, }: { item: Entry | EntryNested; language: Language; + exclude: boolean; }): string => { if (entriesExists.is(item)) { - return buildExists({ item, language }); + return buildExists({ item, language, exclude }); } else if (entriesMatch.is(item)) { - return buildMatch({ item, language }); + return buildMatch({ item, language, exclude }); } else if (entriesMatchAny.is(item)) { - return buildMatchAny({ item, language }); + return buildMatchAny({ item, language, exclude }); } else if (entriesNested.is(item)) { return buildNested({ item, language }); } else { @@ -163,7 +173,11 @@ export const formatQuery = ({ const or = getLanguageBooleanOperator({ language, value: 'or' }); const and = getLanguageBooleanOperator({ language, value: 'and' }); const formattedExceptions = exceptions.map((exception) => { - return `(${query} ${and} ${exception})`; + if (query === '') { + return `(${exception})`; + } else { + return `(${query} ${and} ${exception})`; + } }); return formattedExceptions.join(` ${or} `); @@ -175,15 +189,17 @@ export const formatQuery = ({ export const buildExceptionItemEntries = ({ lists, language, + exclude, }: { lists: EntriesArray; language: Language; + exclude: boolean; }): string => { const and = getLanguageBooleanOperator({ language, value: 'and' }); const exceptionItem = lists .filter(({ type }) => type !== 'list') .reduce((accum, listItem) => { - const exceptionSegment = evaluateValues({ item: listItem, language }); + const exceptionSegment = evaluateValues({ item: listItem, language, exclude }); return [...accum, exceptionSegment]; }, []); @@ -194,15 +210,22 @@ export const buildQueryExceptions = ({ query, language, lists, + exclude = true, }: { query: Query; language: Language; - lists: ExceptionListItemSchema[] | undefined; + lists: Array | undefined; + exclude?: boolean; }): DataQuery[] => { if (lists != null) { - const exceptions = lists.map((exceptionItem) => - buildExceptionItemEntries({ lists: exceptionItem.entries, language }) - ); + const exceptions = lists.reduce((acc, exceptionItem) => { + return [ + ...acc, + ...(exceptionItem.entries !== undefined + ? [buildExceptionItemEntries({ lists: exceptionItem.entries, language, exclude })] + : []), + ]; + }, []); const formattedQuery = formatQuery({ exceptions, language, query }); return [ { diff --git a/x-pack/plugins/security_solution/common/detection_engine/get_query_filter.test.ts b/x-pack/plugins/security_solution/common/detection_engine/get_query_filter.test.ts index 6edd2489e90c9..c19ef45605f83 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/get_query_filter.test.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/get_query_filter.test.ts @@ -456,6 +456,96 @@ describe('get_filter', () => { }); }); + describe('when "excludeExceptions" is false', () => { + test('it should work with a list', () => { + const esQuery = getQueryFilter( + 'host.name: linux', + 'kuery', + [], + ['auditbeat-*'], + [getExceptionListItemSchemaMock()], + false + ); + expect(esQuery).toEqual({ + bool: { + filter: [ + { + bool: { + filter: [ + { + bool: { + minimum_should_match: 1, + should: [ + { + match: { + 'host.name': 'linux', + }, + }, + ], + }, + }, + { + bool: { + filter: [ + { + nested: { + path: 'some.parentField', + query: { + bool: { + minimum_should_match: 1, + should: [ + { + match: { + 'some.parentField.nested.field': 'some value', + }, + }, + ], + }, + }, + score_mode: 'none', + }, + }, + { + bool: { + minimum_should_match: 1, + should: [ + { + match: { + 'some.not.nested.field': 'some value', + }, + }, + ], + }, + }, + ], + }, + }, + ], + }, + }, + ], + must: [], + must_not: [], + should: [], + }, + }); + }); + + test('it should work with an empty list', () => { + const esQuery = getQueryFilter('host.name: linux', 'kuery', [], ['auditbeat-*'], [], false); + expect(esQuery).toEqual({ + bool: { + filter: [ + { bool: { minimum_should_match: 1, should: [{ match: { 'host.name': 'linux' } }] } }, + ], + must: [], + must_not: [], + should: [], + }, + }); + }); + }); + test('it should work with a nested object queries', () => { const esQuery = getQueryFilter( 'category:{ name:Frank and trusted:true }', diff --git a/x-pack/plugins/security_solution/common/detection_engine/get_query_filter.ts b/x-pack/plugins/security_solution/common/detection_engine/get_query_filter.ts index ef390c3b44939..6584373b806d8 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/get_query_filter.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/get_query_filter.ts @@ -11,7 +11,10 @@ import { buildEsQuery, Query as DataQuery, } from '../../../../../src/plugins/data/common'; -import { ExceptionListItemSchema } from '../../../lists/common/schemas'; +import { + ExceptionListItemSchema, + CreateExceptionListItemSchema, +} from '../../../lists/common/schemas'; import { buildQueryExceptions } from './build_exceptions_query'; import { Query, Language, Index } from './schemas/common/schemas'; @@ -20,14 +23,20 @@ export const getQueryFilter = ( language: Language, filters: Array>, index: Index, - lists: ExceptionListItemSchema[] + lists: Array, + excludeExceptions: boolean = true ) => { const indexPattern: IIndexPattern = { fields: [], title: index.join(), }; - const queries: DataQuery[] = buildQueryExceptions({ query, language, lists }); + const queries: DataQuery[] = buildQueryExceptions({ + query, + language, + lists, + exclude: excludeExceptions, + }); const config = { allowLeadingWildcards: true, diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.tsx index 10d510c5f56c3..d5eeef0f1e768 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.tsx @@ -251,13 +251,19 @@ export const AddExceptionModal = memo(function AddExceptionModal({ const onAddExceptionConfirm = useCallback(() => { if (addOrUpdateExceptionItems !== null) { - if (shouldCloseAlert && alertData) { - addOrUpdateExceptionItems(enrichExceptionItems(), alertData.ecsData._id); - } else { - addOrUpdateExceptionItems(enrichExceptionItems()); - } + const alertIdToClose = shouldCloseAlert && alertData ? alertData.ecsData._id : undefined; + const bulkCloseIndex = + shouldBulkCloseAlert && signalIndexName !== null ? [signalIndexName] : undefined; + addOrUpdateExceptionItems(enrichExceptionItems(), alertIdToClose, bulkCloseIndex); } - }, [addOrUpdateExceptionItems, enrichExceptionItems, shouldCloseAlert, alertData]); + }, [ + addOrUpdateExceptionItems, + enrichExceptionItems, + shouldCloseAlert, + shouldBulkCloseAlert, + alertData, + signalIndexName, + ]); const isSubmitButtonDisabled = useCallback( () => fetchOrCreateListError || exceptionItemsToAdd.length === 0, @@ -330,7 +336,7 @@ export const AddExceptionModal = memo(function AddExceptionModal({ {alertData !== undefined && ( - + )} - + { if (addOrUpdateExceptionItems !== null) { - addOrUpdateExceptionItems(enrichExceptionItems()); + const bulkCloseIndex = + shouldBulkCloseAlert && signalIndexName !== null ? [signalIndexName] : undefined; + addOrUpdateExceptionItems(enrichExceptionItems(), undefined, bulkCloseIndex); } - }, [addOrUpdateExceptionItems, enrichExceptionItems]); + }, [addOrUpdateExceptionItems, enrichExceptionItems, shouldBulkCloseAlert, signalIndexName]); const indexPatternConfig = useCallback(() => { if (exceptionListType === 'endpoint') { @@ -239,10 +241,12 @@ export const EditExceptionModal = memo(function EditExceptionModal({ - + { expect(result).toEqual(true); }); }); + + describe('#prepareExceptionItemsForBulkClose', () => { + test('it should return no exceptionw when passed in an empty array', () => { + const payload: ExceptionListItemSchema[] = []; + const result = prepareExceptionItemsForBulkClose(payload); + expect(result).toEqual([]); + }); + + test("should not make any updates when the exception entries don't contain 'event.'", () => { + const payload = [getExceptionListItemSchemaMock(), getExceptionListItemSchemaMock()]; + const result = prepareExceptionItemsForBulkClose(payload); + expect(result).toEqual(payload); + }); + + test("should update entry fields when they start with 'event.'", () => { + const payload = [ + { + ...getExceptionListItemSchemaMock(), + entries: [ + { + ...getEntryMatchMock(), + field: 'event.kind', + }, + getEntryMatchMock(), + ], + }, + { + ...getExceptionListItemSchemaMock(), + entries: [ + { + ...getEntryMatchMock(), + field: 'event.module', + }, + ], + }, + ]; + const expected = [ + { + ...getExceptionListItemSchemaMock(), + entries: [ + { + ...getEntryMatchMock(), + field: 'signal.original_event.kind', + }, + getEntryMatchMock(), + ], + }, + { + ...getExceptionListItemSchemaMock(), + entries: [ + { + ...getEntryMatchMock(), + field: 'signal.original_event.module', + }, + ], + }, + ]; + const result = prepareExceptionItemsForBulkClose(payload); + expect(result).toEqual(expected); + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.tsx index 481b2736b7597..3d028431de8ff 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.tsx @@ -36,6 +36,7 @@ import { exceptionListItemSchema, UpdateExceptionListItemSchema, ExceptionListType, + EntryNested, } from '../../../lists_plugin_deps'; import { IFieldType, IIndexPattern } from '../../../../../../../src/plugins/data/common'; import { TimelineNonEcsData } from '../../../graphql/types'; @@ -380,6 +381,35 @@ export const formatExceptionItemForUpdate = ( }; }; +/** + * Maps "event." fields to "signal.original_event.". This is because when a rule is created + * the "event" field is copied over to "original_event". When the user creates an exception, + * they expect it to match against the original_event's fields, not the signal event's. + * @param exceptionItems new or existing ExceptionItem[] + */ +export const prepareExceptionItemsForBulkClose = ( + exceptionItems: Array +): Array => { + return exceptionItems.map((item: ExceptionListItemSchema | CreateExceptionListItemSchema) => { + if (item.entries !== undefined) { + const newEntries = item.entries.map((itemEntry: Entry | EntryNested) => { + return { + ...itemEntry, + field: itemEntry.field.startsWith('event.') + ? itemEntry.field.replace(/^event./, 'signal.original_event.') + : itemEntry.field, + }; + }); + return { + ...item, + entries: newEntries, + }; + } else { + return item; + } + }); +}; + /** * Adds new and existing comments to all new exceptionItems if not present already * @param exceptionItems new or existing ExceptionItem[] diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/use_add_exception.test.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/use_add_exception.test.tsx index 018ca1d29c369..bf07ff21823eb 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/use_add_exception.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/use_add_exception.test.tsx @@ -9,6 +9,8 @@ import { KibanaServices } from '../../../common/lib/kibana'; import * as alertsApi from '../../../detections/containers/detection_engine/alerts/api'; import * as listsApi from '../../../../../lists/public/exceptions/api'; +import * as getQueryFilterHelper from '../../../../common/detection_engine/get_query_filter'; +import * as buildAlertStatusFilterHelper from '../../../detections/components/alerts_table/default_config'; import { getExceptionListItemSchemaMock } from '../../../../../lists/common/schemas/response/exception_list_item_schema.mock'; import { getCreateExceptionListItemSchemaMock } from '../../../../../lists/common/schemas/request/create_exception_list_item_schema.mock'; import { getUpdateExceptionListItemSchemaMock } from '../../../../../lists/common/schemas/request/update_exception_list_item_schema.mock'; @@ -38,11 +40,16 @@ describe('useAddOrUpdateException', () => { let updateExceptionListItem: jest.SpyInstance>; + let getQueryFilter: jest.SpyInstance>; + let buildAlertStatusFilter: jest.SpyInstance>; let addOrUpdateItemsArgs: Parameters; let render: () => RenderHookResult; const onError = jest.fn(); const onSuccess = jest.fn(); const alertIdToClose = 'idToClose'; + const bulkCloseIndex = ['.signals']; const itemsToAdd: CreateExceptionListItemSchema[] = [ { ...getCreateExceptionListItemSchemaMock(), @@ -113,6 +120,10 @@ describe('useAddOrUpdateException', () => { .spyOn(listsApi, 'updateExceptionListItem') .mockResolvedValue(getExceptionListItemSchemaMock()); + getQueryFilter = jest.spyOn(getQueryFilterHelper, 'getQueryFilter'); + + buildAlertStatusFilter = jest.spyOn(buildAlertStatusFilterHelper, 'buildAlertStatusFilter'); + addOrUpdateItemsArgs = [itemsToAddOrUpdate]; render = () => renderHook(() => @@ -244,4 +255,92 @@ describe('useAddOrUpdateException', () => { }); }); }); + + describe('when bulkCloseIndex is passed in', () => { + beforeEach(() => { + addOrUpdateItemsArgs = [itemsToAddOrUpdate, undefined, bulkCloseIndex]; + }); + it('should update the status of only alerts that are open', async () => { + await act(async () => { + const { rerender, result, waitForNextUpdate } = render(); + const addOrUpdateItems = await waitForAddOrUpdateFunc({ + rerender, + result, + waitForNextUpdate, + }); + if (addOrUpdateItems) { + addOrUpdateItems(...addOrUpdateItemsArgs); + } + await waitForNextUpdate(); + expect(buildAlertStatusFilter).toHaveBeenCalledTimes(1); + expect(buildAlertStatusFilter.mock.calls[0][0]).toEqual('open'); + }); + }); + it('should generate the query filter using exceptions', async () => { + await act(async () => { + const { rerender, result, waitForNextUpdate } = render(); + const addOrUpdateItems = await waitForAddOrUpdateFunc({ + rerender, + result, + waitForNextUpdate, + }); + if (addOrUpdateItems) { + addOrUpdateItems(...addOrUpdateItemsArgs); + } + await waitForNextUpdate(); + expect(getQueryFilter).toHaveBeenCalledTimes(1); + expect(getQueryFilter.mock.calls[0][4]).toEqual(itemsToAddOrUpdate); + expect(getQueryFilter.mock.calls[0][5]).toEqual(false); + }); + }); + it('should update the alert status', async () => { + await act(async () => { + const { rerender, result, waitForNextUpdate } = render(); + const addOrUpdateItems = await waitForAddOrUpdateFunc({ + rerender, + result, + waitForNextUpdate, + }); + if (addOrUpdateItems) { + addOrUpdateItems(...addOrUpdateItemsArgs); + } + await waitForNextUpdate(); + expect(updateAlertStatus).toHaveBeenCalledTimes(1); + }); + }); + it('creates new items', async () => { + await act(async () => { + const { rerender, result, waitForNextUpdate } = render(); + const addOrUpdateItems = await waitForAddOrUpdateFunc({ + rerender, + result, + waitForNextUpdate, + }); + if (addOrUpdateItems) { + addOrUpdateItems(...addOrUpdateItemsArgs); + } + await waitForNextUpdate(); + expect(addExceptionListItem).toHaveBeenCalledTimes(2); + expect(addExceptionListItem.mock.calls[1][0].listItem).toEqual(itemsToAdd[1]); + }); + }); + it('updates existing items', async () => { + await act(async () => { + const { rerender, result, waitForNextUpdate } = render(); + const addOrUpdateItems = await waitForAddOrUpdateFunc({ + rerender, + result, + waitForNextUpdate, + }); + if (addOrUpdateItems) { + addOrUpdateItems(...addOrUpdateItemsArgs); + } + await waitForNextUpdate(); + expect(updateExceptionListItem).toHaveBeenCalledTimes(2); + expect(updateExceptionListItem.mock.calls[1][0].listItem).toEqual( + itemsToUpdateFormatted[1] + ); + }); + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/use_add_exception.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/use_add_exception.tsx index 267a9afd9cf6d..55c3ea35716d5 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/use_add_exception.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/use_add_exception.tsx @@ -16,18 +16,23 @@ import { } from '../../../lists_plugin_deps'; import { updateAlertStatus } from '../../../detections/containers/detection_engine/alerts/api'; import { getUpdateAlertsQuery } from '../../../detections/components/alerts_table/actions'; -import { formatExceptionItemForUpdate } from './helpers'; +import { buildAlertStatusFilter } from '../../../detections/components/alerts_table/default_config'; +import { getQueryFilter } from '../../../../common/detection_engine/get_query_filter'; +import { Index } from '../../../../common/detection_engine/schemas/common/schemas'; +import { formatExceptionItemForUpdate, prepareExceptionItemsForBulkClose } from './helpers'; /** * Adds exception items to the list. Also optionally closes alerts. * * @param exceptionItemsToAddOrUpdate array of ExceptionListItemSchema to add or update * @param alertIdToClose - optional string representing alert to close + * @param bulkCloseIndex - optional index used to create bulk close query * */ export type AddOrUpdateExceptionItemsFunc = ( exceptionItemsToAddOrUpdate: Array, - alertIdToClose?: string + alertIdToClose?: string, + bulkCloseIndex?: Index ) => Promise; export type ReturnUseAddOrUpdateException = [ @@ -100,7 +105,8 @@ export const useAddOrUpdateException = ({ const addOrUpdateExceptionItems: AddOrUpdateExceptionItemsFunc = async ( exceptionItemsToAddOrUpdate, - alertIdToClose + alertIdToClose, + bulkCloseIndex ) => { try { setIsLoading(true); @@ -111,6 +117,23 @@ export const useAddOrUpdateException = ({ }); } + if (bulkCloseIndex != null) { + const filter = getQueryFilter( + '', + 'kuery', + buildAlertStatusFilter('open'), + bulkCloseIndex, + prepareExceptionItemsForBulkClose(exceptionItemsToAddOrUpdate), + false + ); + await updateAlertStatus({ + query: { + query: filter, + }, + status: 'closed', + }); + } + await addOrUpdateItems(exceptionItemsToAddOrUpdate); if (isSubscribed) { From b7a6cff74d84afe51887830d4b2faf5aad57aa14 Mon Sep 17 00:00:00 2001 From: Candace Park <56409205+parkiino@users.noreply.github.com> Date: Tue, 14 Jul 2020 00:00:29 -0400 Subject: [PATCH 59/66] [Security Solution] Add 3rd level breadcrumb to admin page (#71275) [Endpoint Security] Add 3rd level (hosts / policies) breadcrumb to admin page --- .../security_solution/common/constants.ts | 2 +- .../cypress/integration/navigation.spec.ts | 4 +- .../cypress/screens/security_header.ts | 2 +- .../public/app/home/home_navigations.tsx | 6 +-- .../navigation/breadcrumbs/index.ts | 27 +++++++++++ .../components/navigation/index.test.tsx | 12 ++--- .../common/components/navigation/types.ts | 2 +- .../common/components/url_state/constants.ts | 2 +- .../common/components/url_state/helpers.ts | 2 + .../common/components/url_state/types.ts | 2 +- .../public/common/utils/route/types.ts | 7 ++- .../public/management/common/constants.ts | 10 ++--- .../public/management/common/routing.ts | 10 ++--- .../public/management/common/translations.ts | 15 +++++++ .../components/management_page_view.tsx | 16 +++---- .../view/details/host_details.tsx | 4 +- .../endpoint_hosts/view/details/index.tsx | 2 +- .../pages/endpoint_hosts/view/index.tsx | 10 ++--- .../public/management/pages/index.tsx | 45 +++++++++++++++++-- .../pages/policy/view/policy_details.test.tsx | 2 +- .../pages/policy/view/policy_details.tsx | 6 +-- .../pages/policy/view/policy_list.tsx | 4 +- .../public/management/types.ts | 6 +-- .../security_solution/public/plugin.tsx | 2 +- .../security_solution/server/plugin.ts | 2 +- 25 files changed, 145 insertions(+), 57 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/management/common/translations.ts diff --git a/x-pack/plugins/security_solution/common/constants.ts b/x-pack/plugins/security_solution/common/constants.ts index 4e9514feec74f..516ee19dd3b03 100644 --- a/x-pack/plugins/security_solution/common/constants.ts +++ b/x-pack/plugins/security_solution/common/constants.ts @@ -42,7 +42,7 @@ export enum SecurityPageName { network = 'network', timelines = 'timelines', case = 'case', - management = 'management', + administration = 'administration', } export const APP_OVERVIEW_PATH = `${APP_PATH}/overview`; diff --git a/x-pack/plugins/security_solution/cypress/integration/navigation.spec.ts b/x-pack/plugins/security_solution/cypress/integration/navigation.spec.ts index e4f0ec2c4828f..792eee3660429 100644 --- a/x-pack/plugins/security_solution/cypress/integration/navigation.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/navigation.spec.ts @@ -7,7 +7,7 @@ import { CASES, DETECTIONS, HOSTS, - MANAGEMENT, + ADMINISTRATION, NETWORK, OVERVIEW, TIMELINES, @@ -73,7 +73,7 @@ describe('top-level navigation common to all pages in the Security app', () => { }); it('navigates to the Administration page', () => { - navigateFromHeaderTo(MANAGEMENT); + navigateFromHeaderTo(ADMINISTRATION); cy.url().should('include', ADMINISTRATION_URL); }); }); diff --git a/x-pack/plugins/security_solution/cypress/screens/security_header.ts b/x-pack/plugins/security_solution/cypress/screens/security_header.ts index 20fcae60415ae..a337db7a9bfaa 100644 --- a/x-pack/plugins/security_solution/cypress/screens/security_header.ts +++ b/x-pack/plugins/security_solution/cypress/screens/security_header.ts @@ -14,7 +14,7 @@ export const HOSTS = '[data-test-subj="navigation-hosts"]'; export const KQL_INPUT = '[data-test-subj="queryInput"]'; -export const MANAGEMENT = '[data-test-subj="navigation-management"]'; +export const ADMINISTRATION = '[data-test-subj="navigation-administration"]'; export const NETWORK = '[data-test-subj="navigation-network"]'; diff --git a/x-pack/plugins/security_solution/public/app/home/home_navigations.tsx b/x-pack/plugins/security_solution/public/app/home/home_navigations.tsx index 543a4634ceecc..9f0f5351d8a54 100644 --- a/x-pack/plugins/security_solution/public/app/home/home_navigations.tsx +++ b/x-pack/plugins/security_solution/public/app/home/home_navigations.tsx @@ -61,11 +61,11 @@ export const navTabs: SiemNavTab = { disabled: false, urlKey: 'case', }, - [SecurityPageName.management]: { - id: SecurityPageName.management, + [SecurityPageName.administration]: { + id: SecurityPageName.administration, name: i18n.ADMINISTRATION, href: APP_MANAGEMENT_PATH, disabled: false, - urlKey: SecurityPageName.management, + urlKey: SecurityPageName.administration, }, }; 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 dc5324adbac7d..845ef580ddbe2 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,12 +15,14 @@ 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 getAdminBreadcrumbs } from '../../../../management/pages'; import { SecurityPageName } from '../../../../app/types'; import { RouteSpyState, HostRouteSpyState, NetworkRouteSpyState, TimelineRouteSpyState, + AdministrationRouteSpyState, } from '../../../utils/route/types'; import { getAppOverviewUrl } from '../../link_to'; @@ -61,6 +63,10 @@ const isCaseRoutes = (spyState: RouteSpyState): spyState is RouteSpyState => const isAlertsRoutes = (spyState: RouteSpyState) => spyState != null && spyState.pageName === SecurityPageName.detections; +const isAdminRoutes = (spyState: RouteSpyState): spyState is AdministrationRouteSpyState => + spyState != null && spyState.pageName === SecurityPageName.administration; + +// eslint-disable-next-line complexity export const getBreadcrumbsForRoute = ( object: RouteSpyState & TabNavigationProps, getUrlForApp: GetUrlForApp @@ -159,6 +165,27 @@ export const getBreadcrumbsForRoute = ( ), ]; } + + if (isAdminRoutes(spyState) && object.navTabs) { + const tempNav: SearchNavTab = { urlKey: 'administration', isDetailPage: false }; + let urlStateKeys = [getOr(tempNav, spyState.pageName, object.navTabs)]; + if (spyState.tabName != null) { + urlStateKeys = [...urlStateKeys, getOr(tempNav, spyState.tabName, object.navTabs)]; + } + + return [ + ...siemRootBreadcrumb, + ...getAdminBreadcrumbs( + spyState, + urlStateKeys.reduce( + (acc: string[], item: SearchNavTab) => [...acc, getSearch(item, object)], + [] + ), + getUrlForApp + ), + ]; + } + if ( spyState != null && object.navTabs && diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/navigation/index.test.tsx index 229e2d2402298..c60feb63241fb 100644 --- a/x-pack/plugins/security_solution/public/common/components/navigation/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/navigation/index.test.tsx @@ -106,12 +106,12 @@ describe('SIEM Navigation', () => { name: 'Cases', urlKey: 'case', }, - management: { + administration: { disabled: false, href: '/app/security/administration', - id: 'management', + id: 'administration', name: 'Administration', - urlKey: 'management', + urlKey: 'administration', }, hosts: { disabled: false, @@ -218,12 +218,12 @@ describe('SIEM Navigation', () => { name: 'Hosts', urlKey: 'host', }, - management: { + administration: { disabled: false, href: '/app/security/administration', - id: 'management', + id: 'administration', name: 'Administration', - urlKey: 'management', + urlKey: 'administration', }, network: { disabled: false, diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/types.ts b/x-pack/plugins/security_solution/public/common/components/navigation/types.ts index 0489ebba738c8..c17abaad525a2 100644 --- a/x-pack/plugins/security_solution/public/common/components/navigation/types.ts +++ b/x-pack/plugins/security_solution/public/common/components/navigation/types.ts @@ -48,7 +48,7 @@ export type SiemNavTabKey = | SecurityPageName.detections | SecurityPageName.timelines | SecurityPageName.case - | SecurityPageName.management; + | SecurityPageName.administration; export type SiemNavTab = Record; 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 1faff2594ce80..5a4aec93dd9aa 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 @@ -30,4 +30,4 @@ export type UrlStateType = | 'network' | 'overview' | 'timeline' - | 'management'; + | 'administration'; diff --git a/x-pack/plugins/security_solution/public/common/components/url_state/helpers.ts b/x-pack/plugins/security_solution/public/common/components/url_state/helpers.ts index 6febf95aae01d..5e40cd00fa69e 100644 --- a/x-pack/plugins/security_solution/public/common/components/url_state/helpers.ts +++ b/x-pack/plugins/security_solution/public/common/components/url_state/helpers.ts @@ -96,6 +96,8 @@ export const getUrlType = (pageName: string): UrlStateType => { return 'timeline'; } else if (pageName === SecurityPageName.case) { return 'case'; + } else if (pageName === SecurityPageName.administration) { + return 'administration'; } return 'overview'; }; 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 8881a82e5cd1c..f383e18132385 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 @@ -46,7 +46,7 @@ export const URL_STATE_KEYS: Record = { CONSTANTS.timerange, CONSTANTS.timeline, ], - management: [], + administration: [], network: [ CONSTANTS.appQuery, CONSTANTS.filters, 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 8656f20c92959..13eb03b07353d 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 @@ -12,9 +12,10 @@ import { TimelineType } from '../../../../common/types/timeline'; import { HostsTableType } from '../../../hosts/store/model'; import { NetworkRouteType } from '../../../network/pages/navigation/types'; +import { AdministrationSubTab as AdministrationType } from '../../../management/types'; import { FlowTarget } from '../../../graphql/types'; -export type SiemRouteType = HostsTableType | NetworkRouteType | TimelineType; +export type SiemRouteType = HostsTableType | NetworkRouteType | TimelineType | AdministrationType; export interface RouteSpyState { pageName: string; detailName: string | undefined; @@ -38,6 +39,10 @@ export interface TimelineRouteSpyState extends RouteSpyState { tabName: TimelineType | undefined; } +export interface AdministrationRouteSpyState extends RouteSpyState { + tabName: AdministrationType | undefined; +} + export type RouteSpyAction = | { type: 'updateSearch'; diff --git a/x-pack/plugins/security_solution/public/management/common/constants.ts b/x-pack/plugins/security_solution/public/management/common/constants.ts index 4bc586bdee8a9..b07c47a398049 100644 --- a/x-pack/plugins/security_solution/public/management/common/constants.ts +++ b/x-pack/plugins/security_solution/public/management/common/constants.ts @@ -3,16 +3,16 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { ManagementStoreGlobalNamespace, ManagementSubTab } from '../types'; +import { ManagementStoreGlobalNamespace, AdministrationSubTab } from '../types'; import { APP_ID } from '../../../common/constants'; import { SecurityPageName } from '../../app/types'; // --[ ROUTING ]--------------------------------------------------------------------------- -export const MANAGEMENT_APP_ID = `${APP_ID}:${SecurityPageName.management}`; +export const MANAGEMENT_APP_ID = `${APP_ID}:${SecurityPageName.administration}`; export const MANAGEMENT_ROUTING_ROOT_PATH = ''; -export const MANAGEMENT_ROUTING_HOSTS_PATH = `${MANAGEMENT_ROUTING_ROOT_PATH}/:tabName(${ManagementSubTab.hosts})`; -export const MANAGEMENT_ROUTING_POLICIES_PATH = `${MANAGEMENT_ROUTING_ROOT_PATH}/:tabName(${ManagementSubTab.policies})`; -export const MANAGEMENT_ROUTING_POLICY_DETAILS_PATH = `${MANAGEMENT_ROUTING_ROOT_PATH}/:tabName(${ManagementSubTab.policies})/:policyId`; +export const MANAGEMENT_ROUTING_HOSTS_PATH = `${MANAGEMENT_ROUTING_ROOT_PATH}/:tabName(${AdministrationSubTab.hosts})`; +export const MANAGEMENT_ROUTING_POLICIES_PATH = `${MANAGEMENT_ROUTING_ROOT_PATH}/:tabName(${AdministrationSubTab.policies})`; +export const MANAGEMENT_ROUTING_POLICY_DETAILS_PATH = `${MANAGEMENT_ROUTING_ROOT_PATH}/:tabName(${AdministrationSubTab.policies})/:policyId`; // --[ STORE ]--------------------------------------------------------------------------- /** The SIEM global store namespace where the management state will be mounted */ diff --git a/x-pack/plugins/security_solution/public/management/common/routing.ts b/x-pack/plugins/security_solution/public/management/common/routing.ts index 5add6b753a7a9..3636358ebe842 100644 --- a/x-pack/plugins/security_solution/public/management/common/routing.ts +++ b/x-pack/plugins/security_solution/public/management/common/routing.ts @@ -14,7 +14,7 @@ import { MANAGEMENT_ROUTING_POLICIES_PATH, MANAGEMENT_ROUTING_POLICY_DETAILS_PATH, } from './constants'; -import { ManagementSubTab } from '../types'; +import { AdministrationSubTab } from '../types'; import { appendSearch } from '../../common/components/link_to/helpers'; import { HostIndexUIQueryParams } from '../pages/endpoint_hosts/types'; @@ -47,7 +47,7 @@ export const getHostListPath = ( if (name === 'hostList') { return `${generatePath(MANAGEMENT_ROUTING_HOSTS_PATH, { - tabName: ManagementSubTab.hosts, + tabName: AdministrationSubTab.hosts, })}${appendSearch(`${urlQueryParams ? `${urlQueryParams}${urlSearch}` : urlSearch}`)}`; } return `${appendSearch(`${urlQueryParams ? `${urlQueryParams}${urlSearch}` : urlSearch}`)}`; @@ -65,17 +65,17 @@ export const getHostDetailsPath = ( const urlSearch = `${urlQueryParams && !isEmpty(search) ? '&' : ''}${search ?? ''}`; return `${generatePath(MANAGEMENT_ROUTING_HOSTS_PATH, { - tabName: ManagementSubTab.hosts, + tabName: AdministrationSubTab.hosts, })}${appendSearch(`${urlQueryParams ? `${urlQueryParams}${urlSearch}` : urlSearch}`)}`; }; export const getPoliciesPath = (search?: string) => `${generatePath(MANAGEMENT_ROUTING_POLICIES_PATH, { - tabName: ManagementSubTab.policies, + tabName: AdministrationSubTab.policies, })}${appendSearch(search)}`; export const getPolicyDetailPath = (policyId: string, search?: string) => `${generatePath(MANAGEMENT_ROUTING_POLICY_DETAILS_PATH, { - tabName: ManagementSubTab.policies, + tabName: AdministrationSubTab.policies, policyId, })}${appendSearch(search)}`; diff --git a/x-pack/plugins/security_solution/public/management/common/translations.ts b/x-pack/plugins/security_solution/public/management/common/translations.ts new file mode 100644 index 0000000000000..70ccf715eaa09 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/common/translations.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; + +export const HOSTS_TAB = i18n.translate('xpack.securitySolution.hostsTab', { + defaultMessage: 'Hosts', +}); + +export const POLICIES_TAB = i18n.translate('xpack.securitySolution.policiesTab', { + defaultMessage: 'Policies', +}); diff --git a/x-pack/plugins/security_solution/public/management/components/management_page_view.tsx b/x-pack/plugins/security_solution/public/management/components/management_page_view.tsx index 8495628709d2a..42341b524362d 100644 --- a/x-pack/plugins/security_solution/public/management/components/management_page_view.tsx +++ b/x-pack/plugins/security_solution/public/management/components/management_page_view.tsx @@ -8,15 +8,15 @@ import React, { memo, useMemo } from 'react'; import { i18n } from '@kbn/i18n'; import { useParams } from 'react-router-dom'; import { PageView, PageViewProps } from '../../common/components/endpoint/page_view'; -import { ManagementSubTab } from '../types'; +import { AdministrationSubTab } from '../types'; import { SecurityPageName } from '../../app/types'; import { useFormatUrl } from '../../common/components/link_to'; import { getHostListPath, getPoliciesPath } from '../common/routing'; import { useNavigateByRouterEventHandler } from '../../common/hooks/endpoint/use_navigate_by_router_event_handler'; export const ManagementPageView = memo>((options) => { - const { formatUrl, search } = useFormatUrl(SecurityPageName.management); - const { tabName } = useParams<{ tabName: ManagementSubTab }>(); + const { formatUrl, search } = useFormatUrl(SecurityPageName.administration); + const { tabName } = useParams<{ tabName: AdministrationSubTab }>(); const goToEndpoint = useNavigateByRouterEventHandler( getHostListPath({ name: 'hostList' }, search) @@ -30,11 +30,11 @@ export const ManagementPageView = memo>((options) => } return [ { - name: i18n.translate('xpack.securitySolution.managementTabs.endpoints', { + name: i18n.translate('xpack.securitySolution.managementTabs.hosts', { defaultMessage: 'Hosts', }), - id: ManagementSubTab.hosts, - isSelected: tabName === ManagementSubTab.hosts, + id: AdministrationSubTab.hosts, + isSelected: tabName === AdministrationSubTab.hosts, href: formatUrl(getHostListPath({ name: 'hostList' })), onClick: goToEndpoint, }, @@ -42,8 +42,8 @@ export const ManagementPageView = memo>((options) => name: i18n.translate('xpack.securitySolution.managementTabs.policies', { defaultMessage: 'Policies', }), - id: ManagementSubTab.policies, - isSelected: tabName === ManagementSubTab.policies, + id: AdministrationSubTab.policies, + isSelected: tabName === AdministrationSubTab.policies, href: formatUrl(getPoliciesPath()), onClick: goToPolicies, }, diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/host_details.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/host_details.tsx index 10ea271139e49..62efa621e6e3b 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/host_details.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/host_details.tsx @@ -61,7 +61,7 @@ export const HostDetails = memo(({ details }: { details: HostMetadata }) => { const policyStatus = useHostSelector( policyResponseStatus ) as keyof typeof POLICY_STATUS_TO_HEALTH_COLOR; - const { formatUrl } = useFormatUrl(SecurityPageName.management); + const { formatUrl } = useFormatUrl(SecurityPageName.administration); const detailsResultsUpper = useMemo(() => { return [ @@ -106,7 +106,7 @@ export const HostDetails = memo(({ details }: { details: HostMetadata }) => { path: agentDetailsWithFlyoutPath, state: { onDoneNavigateTo: [ - 'securitySolution:management', + 'securitySolution:administration', { path: getHostDetailsPath({ name: 'hostDetails', selected_host: details.host.id }), }, diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/index.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/index.tsx index e29d796325bd6..71b3885308558 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/index.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/index.tsx @@ -118,7 +118,7 @@ const PolicyResponseFlyoutPanel = memo<{ const responseAttentionCount = useHostSelector(policyResponseFailedOrWarningActionCount); const loading = useHostSelector(policyResponseLoading); const error = useHostSelector(policyResponseError); - const { formatUrl } = useFormatUrl(SecurityPageName.management); + const { formatUrl } = useFormatUrl(SecurityPageName.administration); const [detailsUri, detailsRoutePath] = useMemo( () => [ formatUrl( diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx index 6c6ab3930d7ab..c5d47e87c3e1b 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx @@ -89,7 +89,7 @@ export const HostList = () => { policyItemsLoading, endpointPackageVersion, } = useHostSelector(selector); - const { formatUrl, search } = useFormatUrl(SecurityPageName.management); + const { formatUrl, search } = useFormatUrl(SecurityPageName.administration); const dispatch = useDispatch<(a: HostAction) => void>(); @@ -127,12 +127,12 @@ export const HostList = () => { }`, state: { onCancelNavigateTo: [ - 'securitySolution:management', + 'securitySolution:administration', { path: getHostListPath({ name: 'hostList' }) }, ], onCancelUrl: formatUrl(getHostListPath({ name: 'hostList' })), onSaveNavigateTo: [ - 'securitySolution:management', + 'securitySolution:administration', { path: getHostListPath({ name: 'hostList' }) }, ], }, @@ -145,7 +145,7 @@ export const HostList = () => { path: `#/configs/${selectedPolicyId}?openEnrollmentFlyout=true`, state: { onDoneNavigateTo: [ - 'securitySolution:management', + 'securitySolution:administration', { path: getHostListPath({ name: 'hostList' }) }, ], }, @@ -422,7 +422,7 @@ export const HostList = () => { )} {renderTableOrEmptyState} - + ); }; diff --git a/x-pack/plugins/security_solution/public/management/pages/index.tsx b/x-pack/plugins/security_solution/public/management/pages/index.tsx index 30800234ab24c..3e1c0743fb4f1 100644 --- a/x-pack/plugins/security_solution/public/management/pages/index.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/index.tsx @@ -4,9 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ +import { isEmpty } from 'lodash/fp'; import React, { memo } from 'react'; import { useHistory, Route, Switch } from 'react-router-dom'; +import { ChromeBreadcrumb } from 'kibana/public'; import { EuiText, EuiEmptyPrompt } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { PolicyContainer } from './policy'; @@ -18,10 +20,47 @@ import { import { NotFoundPage } from '../../app/404'; import { HostsContainer } from './endpoint_hosts'; import { getHostListPath } from '../common/routing'; +import { APP_ID, SecurityPageName } from '../../../common/constants'; +import { GetUrlForApp } from '../../common/components/navigation/types'; +import { AdministrationRouteSpyState } from '../../common/utils/route/types'; +import { ADMINISTRATION } from '../../app/home/translations'; +import { AdministrationSubTab } from '../types'; +import { HOSTS_TAB, POLICIES_TAB } from '../common/translations'; import { SpyRoute } from '../../common/utils/route/spy_routes'; -import { SecurityPageName } from '../../app/types'; import { useIngestEnabledCheck } from '../../common/hooks/endpoint/ingest_enabled'; +const TabNameMappedToI18nKey: Record = { + [AdministrationSubTab.hosts]: HOSTS_TAB, + [AdministrationSubTab.policies]: POLICIES_TAB, +}; + +export const getBreadcrumbs = ( + params: AdministrationRouteSpyState, + search: string[], + getUrlForApp: GetUrlForApp +): ChromeBreadcrumb[] => { + let breadcrumb = [ + { + text: ADMINISTRATION, + href: getUrlForApp(`${APP_ID}:${SecurityPageName.administration}`, { + path: !isEmpty(search[0]) ? search[0] : '', + }), + }, + ]; + + const tabName = params?.tabName; + if (!tabName) return breadcrumb; + + breadcrumb = [ + ...breadcrumb, + { + text: TabNameMappedToI18nKey[tabName], + href: '', + }, + ]; + return breadcrumb; +}; + const NoPermissions = memo(() => { return ( <> @@ -40,14 +79,14 @@ const NoPermissions = memo(() => {

} /> - + ); }); diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_details.test.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_details.test.tsx index ca4d0929f7a7a..8612b15f89857 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_details.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_details.test.tsx @@ -172,7 +172,7 @@ describe('Policy Details', () => { cancelbutton.simulate('click', { button: 0 }); const navigateToAppMockedCalls = coreStart.application.navigateToApp.mock.calls; expect(navigateToAppMockedCalls[navigateToAppMockedCalls.length - 1]).toEqual([ - 'securitySolution:management', + 'securitySolution:administration', { path: policyListPathUrl }, ]); }); diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_details.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_details.tsx index b5861b68a0756..8fbc167670b41 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_details.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_details.tsx @@ -55,7 +55,7 @@ export const PolicyDetails = React.memo(() => { application: { navigateToApp }, }, } = useKibana(); - const { formatUrl, search } = useFormatUrl(SecurityPageName.management); + const { formatUrl, search } = useFormatUrl(SecurityPageName.administration); const { state: locationRouteState } = useLocation(); // Store values @@ -149,7 +149,7 @@ export const PolicyDetails = React.memo(() => { {policyApiError?.message} ) : null} - + ); } @@ -251,7 +251,7 @@ export const PolicyDetails = React.memo(() => { - + ); }); diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_list.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_list.tsx index 8a77264c354ad..8dbfbeeb5d8d6 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_list.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_list.tsx @@ -127,7 +127,7 @@ export const PolicyList = React.memo(() => { const { services, notifications } = useKibana(); const history = useHistory(); const location = useLocation(); - const { formatUrl, search } = useFormatUrl(SecurityPageName.management); + const { formatUrl, search } = useFormatUrl(SecurityPageName.administration); const [showDelete, setShowDelete] = useState(false); const [policyIdToDelete, setPolicyIdToDelete] = useState(''); @@ -477,7 +477,7 @@ export const PolicyList = React.memo(() => { handleTableChange, paginationSetup, ])} - + ); diff --git a/x-pack/plugins/security_solution/public/management/types.ts b/x-pack/plugins/security_solution/public/management/types.ts index cb21a236ddd7e..86959caaba4f4 100644 --- a/x-pack/plugins/security_solution/public/management/types.ts +++ b/x-pack/plugins/security_solution/public/management/types.ts @@ -24,7 +24,7 @@ export type ManagementState = CombinedState<{ /** * The management list of sub-tabs. Changes to these will impact the Router routes. */ -export enum ManagementSubTab { +export enum AdministrationSubTab { hosts = 'hosts', policies = 'policy', } @@ -33,8 +33,8 @@ export enum ManagementSubTab { * The URL route params for the Management Policy List section */ export interface ManagementRoutePolicyListParams { - pageName: SecurityPageName.management; - tabName: ManagementSubTab.policies; + pageName: SecurityPageName.administration; + tabName: AdministrationSubTab.policies; } /** diff --git a/x-pack/plugins/security_solution/public/plugin.tsx b/x-pack/plugins/security_solution/public/plugin.tsx index 62328bd767748..98ea2efe8721e 100644 --- a/x-pack/plugins/security_solution/public/plugin.tsx +++ b/x-pack/plugins/security_solution/public/plugin.tsx @@ -281,7 +281,7 @@ export class Plugin implements IPlugin { From 24d29a31b8ee8d6eaa05cbd2c255350ef8b47148 Mon Sep 17 00:00:00 2001 From: Matthias Wilhelm Date: Tue, 14 Jul 2020 07:43:02 +0200 Subject: [PATCH 60/66] [Discover] Add caused_by.type and caused_by.reason to error toast modal (#70404) --- .../notifications/toasts/error_toast.tsx | 29 ++++++++++++++++--- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/src/core/public/notifications/toasts/error_toast.tsx b/src/core/public/notifications/toasts/error_toast.tsx index 6b53719839b0f..df8214ce771af 100644 --- a/src/core/public/notifications/toasts/error_toast.tsx +++ b/src/core/public/notifications/toasts/error_toast.tsx @@ -31,8 +31,7 @@ import { } from '@elastic/eui'; import { EuiSpacer } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; - -import { OverlayStart } from '../../overlays'; +import { OverlayStart } from 'kibana/public'; import { I18nStart } from '../../i18n'; interface ErrorToastProps { @@ -43,6 +42,17 @@ interface ErrorToastProps { i18nContext: () => I18nStart['Context']; } +interface RequestError extends Error { + body?: { attributes?: { error: { caused_by: { type: string; reason: string } } } }; +} + +const isRequestError = (e: Error | RequestError): e is RequestError => { + if ('body' in e) { + return e.body?.attributes?.error?.caused_by !== undefined; + } + return false; +}; + /** * This should instead be replaced by the overlay service once it's available. * This does not use React portals so that if the parent toast times out, this modal @@ -56,6 +66,17 @@ function showErrorDialog({ i18nContext, }: Pick) { const I18nContext = i18nContext(); + let text = ''; + + if (isRequestError(error)) { + text += `${error?.body?.attributes?.error?.caused_by.type}\n`; + text += `${error?.body?.attributes?.error?.caused_by.reason}\n\n`; + } + + if (error.stack) { + text += error.stack; + } + const modal = openModal( mount( @@ -65,11 +86,11 @@ function showErrorDialog({ - {error.stack && ( + {text && ( - {error.stack} + {text} )} From 169397cec84ade939eafd540cb45ffb79de12f01 Mon Sep 17 00:00:00 2001 From: Oliver Gupte Date: Mon, 13 Jul 2020 23:10:02 -0700 Subject: [PATCH 61/66] [APM] Bug fixes from ML integration testing (#71564) * fixes bug where the anomaly detection setup link was showing alert incorrectly, adds unit tests * Fixes typo in getMlBucketSize query, uses terminate_after * Improve readbility of helper function to show alerts and unit tests --- .../apm/AnomalyDetectionSetupLink.test.tsx | 43 +++++++++++++++++++ .../Links/apm/AnomalyDetectionSetupLink.tsx | 19 +++++--- .../get_anomaly_data/get_ml_bucket_size.ts | 2 +- 3 files changed, 58 insertions(+), 6 deletions(-) create mode 100644 x-pack/plugins/apm/public/components/shared/Links/apm/AnomalyDetectionSetupLink.test.tsx diff --git a/x-pack/plugins/apm/public/components/shared/Links/apm/AnomalyDetectionSetupLink.test.tsx b/x-pack/plugins/apm/public/components/shared/Links/apm/AnomalyDetectionSetupLink.test.tsx new file mode 100644 index 0000000000000..268d8bd7ea823 --- /dev/null +++ b/x-pack/plugins/apm/public/components/shared/Links/apm/AnomalyDetectionSetupLink.test.tsx @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { showAlert } from './AnomalyDetectionSetupLink'; + +describe('#showAlert', () => { + describe('when an environment is selected', () => { + it('should return true when there are no jobs', () => { + const result = showAlert([], 'testing'); + expect(result).toBe(true); + }); + it('should return true when environment is not included in the jobs', () => { + const result = showAlert( + [{ environment: 'staging' }, { environment: 'production' }], + 'testing' + ); + expect(result).toBe(true); + }); + it('should return false when environment is included in the jobs', () => { + const result = showAlert( + [{ environment: 'staging' }, { environment: 'production' }], + 'staging' + ); + expect(result).toBe(false); + }); + }); + describe('there is no environment selected (All)', () => { + it('should return true when there are no jobs', () => { + const result = showAlert([], undefined); + expect(result).toBe(true); + }); + it('should return false when there are any number of jobs', () => { + const result = showAlert( + [{ environment: 'staging' }, { environment: 'production' }], + undefined + ); + expect(result).toBe(false); + }); + }); +}); diff --git a/x-pack/plugins/apm/public/components/shared/Links/apm/AnomalyDetectionSetupLink.tsx b/x-pack/plugins/apm/public/components/shared/Links/apm/AnomalyDetectionSetupLink.tsx index 88d15239b8fba..6f3a5df480d7e 100644 --- a/x-pack/plugins/apm/public/components/shared/Links/apm/AnomalyDetectionSetupLink.tsx +++ b/x-pack/plugins/apm/public/components/shared/Links/apm/AnomalyDetectionSetupLink.tsx @@ -23,16 +23,12 @@ export function AnomalyDetectionSetupLink() { ); const isFetchSuccess = status === FETCH_STATUS.SUCCESS; - // Show alert if there are no jobs OR if no job matches the current environment - const showAlert = - isFetchSuccess && !data.jobs.some((job) => environment === job.environment); - return ( {ANOMALY_DETECTION_LINK_LABEL} - {showAlert && ( + {isFetchSuccess && showAlert(data.jobs, environment) && ( @@ -61,3 +57,16 @@ const ANOMALY_DETECTION_LINK_LABEL = i18n.translate( 'xpack.apm.anomalyDetectionSetup.linkLabel', { defaultMessage: `Anomaly detection` } ); + +export function showAlert( + jobs: Array<{ environment: string }> = [], + environment: string | undefined +) { + return ( + // No job exists, or + jobs.length === 0 || + // no job exists for the selected environment + (environment !== undefined && + jobs.every((job) => environment !== job.environment)) + ); +} diff --git a/x-pack/plugins/apm/server/lib/transactions/charts/get_anomaly_data/get_ml_bucket_size.ts b/x-pack/plugins/apm/server/lib/transactions/charts/get_anomaly_data/get_ml_bucket_size.ts index 2f5e703251c03..154821b261fd1 100644 --- a/x-pack/plugins/apm/server/lib/transactions/charts/get_anomaly_data/get_ml_bucket_size.ts +++ b/x-pack/plugins/apm/server/lib/transactions/charts/get_anomaly_data/get_ml_bucket_size.ts @@ -31,7 +31,7 @@ export async function getMlBucketSize({ body: { _source: 'bucket_span', size: 1, - terminateAfter: 1, + terminate_after: 1, query: { bool: { filter: [ From 0f143a38c6d1f93c3beb263f2d7b3959bca2ceaa Mon Sep 17 00:00:00 2001 From: Kevin Qualters <56408403+kqualters-elastic@users.noreply.github.com> Date: Tue, 14 Jul 2020 03:39:39 -0400 Subject: [PATCH 62/66] [Security Solution] Add hook for reading/writing resolver query params (#70809) * Move resolver query param logic into shared hook * Store document location in state * Rename documentLocation to resolverComponentInstanceID * Use undefined for initial resolverComponentID value * Update type for initial state of component id --- .../public/resolver/store/data/action.ts | 1 + .../public/resolver/store/data/reducer.ts | 2 + .../resolver/store/data/selectors.test.ts | 21 ++++-- .../public/resolver/store/data/selectors.ts | 7 ++ .../public/resolver/store/selectors.ts | 5 ++ .../public/resolver/types.ts | 1 + .../public/resolver/view/index.tsx | 12 +++- .../public/resolver/view/map.tsx | 8 ++- .../public/resolver/view/panel.tsx | 43 ++----------- .../view/panels/panel_content_utilities.tsx | 4 +- .../resolver/view/process_event_dot.tsx | 35 +--------- .../view/use_resolver_query_params.ts | 64 +++++++++++++++++++ .../view/use_state_syncing_actions.ts | 6 +- .../components/graph_overlay/index.tsx | 5 +- 14 files changed, 131 insertions(+), 83 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/resolver/view/use_resolver_query_params.ts diff --git a/x-pack/plugins/security_solution/public/resolver/store/data/action.ts b/x-pack/plugins/security_solution/public/resolver/store/data/action.ts index 0d2a6936b4873..b6edf68aa7dc2 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/data/action.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/data/action.ts @@ -75,6 +75,7 @@ interface AppReceivedNewExternalProperties { * the `_id` of an ES document. This defines the origin of the Resolver graph. */ databaseDocumentID?: string; + resolverComponentInstanceID: string; }; } diff --git a/x-pack/plugins/security_solution/public/resolver/store/data/reducer.ts b/x-pack/plugins/security_solution/public/resolver/store/data/reducer.ts index 19b743374b8ed..c43182ddbf835 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/data/reducer.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/data/reducer.ts @@ -11,6 +11,7 @@ import { ResolverAction } from '../actions'; const initialState: DataState = { relatedEvents: new Map(), relatedEventsReady: new Map(), + resolverComponentInstanceID: undefined, }; export const dataReducer: Reducer = (state = initialState, action) => { @@ -18,6 +19,7 @@ export const dataReducer: Reducer = (state = initialS const nextState: DataState = { ...state, databaseDocumentID: action.payload.databaseDocumentID, + resolverComponentInstanceID: action.payload.resolverComponentInstanceID, }; return nextState; } else if (action.type === 'appRequestedResolverData') { diff --git a/x-pack/plugins/security_solution/public/resolver/store/data/selectors.test.ts b/x-pack/plugins/security_solution/public/resolver/store/data/selectors.test.ts index 630dfe555548f..cf23596db6134 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/data/selectors.test.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/data/selectors.test.ts @@ -53,11 +53,12 @@ describe('data state', () => { describe('when there is a databaseDocumentID but no pending request', () => { const databaseDocumentID = 'databaseDocumentID'; + const resolverComponentInstanceID = 'resolverComponentInstanceID'; beforeEach(() => { actions = [ { type: 'appReceivedNewExternalProperties', - payload: { databaseDocumentID }, + payload: { databaseDocumentID, resolverComponentInstanceID }, }, ]; }); @@ -104,11 +105,12 @@ describe('data state', () => { }); describe('when there is a pending request for the current databaseDocumentID', () => { const databaseDocumentID = 'databaseDocumentID'; + const resolverComponentInstanceID = 'resolverComponentInstanceID'; beforeEach(() => { actions = [ { type: 'appReceivedNewExternalProperties', - payload: { databaseDocumentID }, + payload: { databaseDocumentID, resolverComponentInstanceID }, }, { type: 'appRequestedResolverData', @@ -160,12 +162,17 @@ describe('data state', () => { describe('when there is a pending request for a different databaseDocumentID than the current one', () => { const firstDatabaseDocumentID = 'first databaseDocumentID'; const secondDatabaseDocumentID = 'second databaseDocumentID'; + const resolverComponentInstanceID1 = 'resolverComponentInstanceID1'; + const resolverComponentInstanceID2 = 'resolverComponentInstanceID2'; beforeEach(() => { actions = [ // receive the document ID, this would cause the middleware to starts the request { type: 'appReceivedNewExternalProperties', - payload: { databaseDocumentID: firstDatabaseDocumentID }, + payload: { + databaseDocumentID: firstDatabaseDocumentID, + resolverComponentInstanceID: resolverComponentInstanceID1, + }, }, // this happens when the middleware starts the request { @@ -175,7 +182,10 @@ describe('data state', () => { // receive a different databaseDocumentID. this should cause the middleware to abort the existing request and start a new one { type: 'appReceivedNewExternalProperties', - payload: { databaseDocumentID: secondDatabaseDocumentID }, + payload: { + databaseDocumentID: secondDatabaseDocumentID, + resolverComponentInstanceID: resolverComponentInstanceID2, + }, }, ]; }); @@ -188,6 +198,9 @@ describe('data state', () => { it('should need to abort the request for the databaseDocumentID', () => { expect(selectors.databaseDocumentIDToFetch(state())).toBe(secondDatabaseDocumentID); }); + it('should use the correct location for the second resolver', () => { + expect(selectors.resolverComponentInstanceID(state())).toBe(resolverComponentInstanceID2); + }); it('should not have an error, more children, or more ancestors.', () => { expect(viewAsAString(state())).toMatchInlineSnapshot(` "is loading: true diff --git a/x-pack/plugins/security_solution/public/resolver/store/data/selectors.ts b/x-pack/plugins/security_solution/public/resolver/store/data/selectors.ts index 990b911e5dbd0..9f425217a8d3e 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/data/selectors.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/data/selectors.ts @@ -41,6 +41,13 @@ export function isLoading(state: DataState): boolean { return state.pendingRequestDatabaseDocumentID !== undefined; } +/** + * A string for uniquely identifying the instance of resolver within the app. + */ +export function resolverComponentInstanceID(state: DataState): string { + return state.resolverComponentInstanceID ? state.resolverComponentInstanceID : ''; +} + /** * If a request was made and it threw an error or returned a failure response code. */ diff --git a/x-pack/plugins/security_solution/public/resolver/store/selectors.ts b/x-pack/plugins/security_solution/public/resolver/store/selectors.ts index 6e512cfe13f62..64921d214cc1b 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/selectors.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/selectors.ts @@ -69,6 +69,11 @@ export const databaseDocumentIDToAbort = composeSelectors( dataSelectors.databaseDocumentIDToAbort ); +export const resolverComponentInstanceID = composeSelectors( + dataStateSelector, + dataSelectors.resolverComponentInstanceID +); + export const processAdjacencies = composeSelectors( dataStateSelector, dataSelectors.processAdjacencies diff --git a/x-pack/plugins/security_solution/public/resolver/types.ts b/x-pack/plugins/security_solution/public/resolver/types.ts index 2025762a0605c..064634472bbbe 100644 --- a/x-pack/plugins/security_solution/public/resolver/types.ts +++ b/x-pack/plugins/security_solution/public/resolver/types.ts @@ -177,6 +177,7 @@ export interface DataState { * The id used for the pending request, if there is one. */ readonly pendingRequestDatabaseDocumentID?: string; + readonly resolverComponentInstanceID: string | undefined; /** * The parameters and response from the last successful request. diff --git a/x-pack/plugins/security_solution/public/resolver/view/index.tsx b/x-pack/plugins/security_solution/public/resolver/view/index.tsx index 205180a40d62a..c1ffa42d02abb 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/index.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/index.tsx @@ -18,6 +18,7 @@ import { useKibana } from '../../../../../../src/plugins/kibana_react/public'; export const Resolver = React.memo(function ({ className, databaseDocumentID, + resolverComponentInstanceID, }: { /** * Used by `styled-components`. @@ -28,6 +29,11 @@ export const Resolver = React.memo(function ({ * Used as the origin of the Resolver graph. */ databaseDocumentID?: string; + /** + * A string literal describing where in the app resolver is located, + * used to prevent collisions in things like query params + */ + resolverComponentInstanceID: string; }) { const context = useKibana(); const store = useMemo(() => { @@ -40,7 +46,11 @@ export const Resolver = React.memo(function ({ */ return ( - + ); }); diff --git a/x-pack/plugins/security_solution/public/resolver/view/map.tsx b/x-pack/plugins/security_solution/public/resolver/view/map.tsx index 3fc62fc318284..000bf23c5f49d 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/map.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/map.tsx @@ -29,6 +29,7 @@ import { SideEffectContext } from './side_effect_context'; export const ResolverMap = React.memo(function ({ className, databaseDocumentID, + resolverComponentInstanceID, }: { /** * Used by `styled-components`. @@ -39,12 +40,17 @@ export const ResolverMap = React.memo(function ({ * Used as the origin of the Resolver graph. */ databaseDocumentID?: string; + /** + * A string literal describing where in the app resolver is located, + * used to prevent collisions in things like query params + */ + resolverComponentInstanceID: string; }) { /** * This is responsible for dispatching actions that include any external data. * `databaseDocumentID` */ - useStateSyncingActions({ databaseDocumentID }); + useStateSyncingActions({ databaseDocumentID, resolverComponentInstanceID }); const { timestamp } = useContext(SideEffectContext); const { processNodePositions, connectingEdgeLineSegments } = useSelector( diff --git a/x-pack/plugins/security_solution/public/resolver/view/panel.tsx b/x-pack/plugins/security_solution/public/resolver/view/panel.tsx index f4fe4fe520c92..061531b82d935 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/panel.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/panel.tsx @@ -4,11 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { memo, useCallback, useMemo, useContext, useLayoutEffect, useState } from 'react'; +import React, { memo, useMemo, useContext, useLayoutEffect, useState } from 'react'; import { useSelector } from 'react-redux'; -import { useHistory, useLocation } from 'react-router-dom'; -// eslint-disable-next-line import/no-nodejs-modules -import querystring from 'querystring'; import { EuiPanel } from '@elastic/eui'; import { displayNameRecord } from './process_event_dot'; import * as selectors from '../store/selectors'; @@ -21,7 +18,7 @@ import { EventCountsForProcess } from './panels/panel_content_related_counts'; import { ProcessDetails } from './panels/panel_content_process_detail'; import { ProcessListWithCounts } from './panels/panel_content_process_list'; import { RelatedEventDetail } from './panels/panel_content_related_detail'; -import { CrumbInfo } from './panels/panel_content_utilities'; +import { useResolverQueryParams } from './use_resolver_query_params'; /** * The team decided to use this table to determine which breadcrumbs/view to display: @@ -39,14 +36,11 @@ import { CrumbInfo } from './panels/panel_content_utilities'; * @returns {JSX.Element} The "right" table content to show based on the query params as described above */ const PanelContent = memo(function PanelContent() { - const history = useHistory(); - const urlSearch = useLocation().search; const dispatch = useResolverDispatch(); const { timestamp } = useContext(SideEffectContext); - const queryParams: CrumbInfo = useMemo(() => { - return { crumbId: '', crumbEvent: '', ...querystring.parse(urlSearch.slice(1)) }; - }, [urlSearch]); + + const { pushToQueryParams, queryParams } = useResolverQueryParams(); const graphableProcesses = useSelector(selectors.graphableProcesses); const graphableProcessEntityIds = useMemo(() => { @@ -104,35 +98,6 @@ const PanelContent = memo(function PanelContent() { } }, [dispatch, uiSelectedEvent, paramsSelectedEvent, lastUpdatedProcess, timestamp]); - /** - * This updates the breadcrumb nav and the panel view. It's supplied to each - * panel content view to allow them to dispatch transitions to each other. - */ - const pushToQueryParams = useCallback( - (newCrumbs: CrumbInfo) => { - // Construct a new set of params from the current set (minus empty params) - // by assigning the new set of params provided in `newCrumbs` - const crumbsToPass = { - ...querystring.parse(urlSearch.slice(1)), - ...newCrumbs, - }; - - // If either was passed in as empty, remove it from the record - if (crumbsToPass.crumbId === '') { - delete crumbsToPass.crumbId; - } - if (crumbsToPass.crumbEvent === '') { - delete crumbsToPass.crumbEvent; - } - - const relativeURL = { search: querystring.stringify(crumbsToPass) }; - // We probably don't want to nuke the user's history with a huge - // trail of these, thus `.replace` instead of `.push` - return history.replace(relativeURL); - }, - [history, urlSearch] - ); - const relatedEventStats = useSelector(selectors.relatedEventsStats); const { crumbId, crumbEvent } = queryParams; const relatedStatsForIdFromParams: ResolverNodeStats | undefined = diff --git a/x-pack/plugins/security_solution/public/resolver/view/panels/panel_content_utilities.tsx b/x-pack/plugins/security_solution/public/resolver/view/panels/panel_content_utilities.tsx index 374c4c94c7768..4dedafe55bb2c 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/panels/panel_content_utilities.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/panels/panel_content_utilities.tsx @@ -27,8 +27,8 @@ const BetaHeader = styled(`header`)` * The two query parameters we read/write on to control which view the table presents: */ export interface CrumbInfo { - readonly crumbId: string; - readonly crumbEvent: string; + crumbId: string; + crumbEvent: string; } const ThemedBreadcrumbs = styled(EuiBreadcrumbs)<{ background: string; text: string }>` diff --git a/x-pack/plugins/security_solution/public/resolver/view/process_event_dot.tsx b/x-pack/plugins/security_solution/public/resolver/view/process_event_dot.tsx index 6442735abc8cd..17e7d3df42931 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/process_event_dot.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/process_event_dot.tsx @@ -10,9 +10,6 @@ import React, { useCallback, useMemo } from 'react'; import styled from 'styled-components'; import { i18n } from '@kbn/i18n'; import { htmlIdGenerator, EuiButton, EuiI18nNumber, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; -import { useHistory } from 'react-router-dom'; -// eslint-disable-next-line import/no-nodejs-modules -import querystring from 'querystring'; import { useSelector } from 'react-redux'; import { NodeSubMenu, subMenuAssets } from './submenu'; import { applyMatrix3 } from '../models/vector2'; @@ -22,7 +19,7 @@ import { ResolverEvent, ResolverNodeStats } from '../../../common/endpoint/types import { useResolverDispatch } from './use_resolver_dispatch'; import * as eventModel from '../../../common/endpoint/models/event'; import * as selectors from '../store/selectors'; -import { CrumbInfo } from './panels/panel_content_utilities'; +import { useResolverQueryParams } from './use_resolver_query_params'; /** * A record of all known event types (in schema format) to translations @@ -403,35 +400,7 @@ const UnstyledProcessEventDot = React.memo( }); }, [dispatch, selfId]); - const history = useHistory(); - const urlSearch = history.location.search; - - /** - * This updates the breadcrumb nav, the table view - */ - const pushToQueryParams = useCallback( - (newCrumbs: CrumbInfo) => { - // Construct a new set of params from the current set (minus empty params) - // by assigning the new set of params provided in `newCrumbs` - const crumbsToPass = { - ...querystring.parse(urlSearch.slice(1)), - ...newCrumbs, - }; - - // If either was passed in as empty, remove it from the record - if (crumbsToPass.crumbId === '') { - delete crumbsToPass.crumbId; - } - if (crumbsToPass.crumbEvent === '') { - delete crumbsToPass.crumbEvent; - } - - const relativeURL = { search: querystring.stringify(crumbsToPass) }; - - return history.replace(relativeURL); - }, - [history, urlSearch] - ); + const { pushToQueryParams } = useResolverQueryParams(); const handleClick = useCallback(() => { if (animationTarget.current !== null) { diff --git a/x-pack/plugins/security_solution/public/resolver/view/use_resolver_query_params.ts b/x-pack/plugins/security_solution/public/resolver/view/use_resolver_query_params.ts new file mode 100644 index 0000000000000..70baef5fa88ea --- /dev/null +++ b/x-pack/plugins/security_solution/public/resolver/view/use_resolver_query_params.ts @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { useCallback, useMemo } from 'react'; +// eslint-disable-next-line import/no-nodejs-modules +import querystring from 'querystring'; +import { useSelector } from 'react-redux'; +import { useHistory, useLocation } from 'react-router-dom'; +import * as selectors from '../store/selectors'; +import { CrumbInfo } from './panels/panel_content_utilities'; + +export function useResolverQueryParams() { + /** + * This updates the breadcrumb nav and the panel view. It's supplied to each + * panel content view to allow them to dispatch transitions to each other. + */ + const history = useHistory(); + const urlSearch = useLocation().search; + const resolverComponentInstanceID = useSelector(selectors.resolverComponentInstanceID); + const uniqueCrumbIdKey: string = `${resolverComponentInstanceID}CrumbId`; + const uniqueCrumbEventKey: string = `${resolverComponentInstanceID}CrumbEvent`; + const pushToQueryParams = useCallback( + (newCrumbs: CrumbInfo) => { + // Construct a new set of params from the current set (minus empty params) + // by assigning the new set of params provided in `newCrumbs` + const crumbsToPass = { + ...querystring.parse(urlSearch.slice(1)), + [uniqueCrumbIdKey]: newCrumbs.crumbId, + [uniqueCrumbEventKey]: newCrumbs.crumbEvent, + }; + + // If either was passed in as empty, remove it from the record + if (newCrumbs.crumbId === '') { + delete crumbsToPass[uniqueCrumbIdKey]; + } + if (newCrumbs.crumbEvent === '') { + delete crumbsToPass[uniqueCrumbEventKey]; + } + + const relativeURL = { search: querystring.stringify(crumbsToPass) }; + // We probably don't want to nuke the user's history with a huge + // trail of these, thus `.replace` instead of `.push` + return history.replace(relativeURL); + }, + [history, urlSearch, uniqueCrumbIdKey, uniqueCrumbEventKey] + ); + const queryParams: CrumbInfo = useMemo(() => { + const parsed = querystring.parse(urlSearch.slice(1)); + const crumbEvent = parsed[uniqueCrumbEventKey]; + const crumbId = parsed[uniqueCrumbIdKey]; + return { + crumbEvent: Array.isArray(crumbEvent) ? crumbEvent[0] : crumbEvent, + crumbId: Array.isArray(crumbId) ? crumbId[0] : crumbId, + }; + }, [urlSearch, uniqueCrumbIdKey, uniqueCrumbEventKey]); + + return { + pushToQueryParams, + queryParams, + }; +} diff --git a/x-pack/plugins/security_solution/public/resolver/view/use_state_syncing_actions.ts b/x-pack/plugins/security_solution/public/resolver/view/use_state_syncing_actions.ts index b8ea2049f5c49..642a054e8c519 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/use_state_syncing_actions.ts +++ b/x-pack/plugins/security_solution/public/resolver/view/use_state_syncing_actions.ts @@ -13,17 +13,19 @@ import { useResolverDispatch } from './use_resolver_dispatch'; */ export function useStateSyncingActions({ databaseDocumentID, + resolverComponentInstanceID, }: { /** * The `_id` of an event in ES. Used to determine the origin of the Resolver graph. */ databaseDocumentID?: string; + resolverComponentInstanceID: string; }) { const dispatch = useResolverDispatch(); useLayoutEffect(() => { dispatch({ type: 'appReceivedNewExternalProperties', - payload: { databaseDocumentID }, + payload: { databaseDocumentID, resolverComponentInstanceID }, }); - }, [dispatch, databaseDocumentID]); + }, [dispatch, databaseDocumentID, resolverComponentInstanceID]); } diff --git a/x-pack/plugins/security_solution/public/timelines/components/graph_overlay/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/graph_overlay/index.tsx index fd5e8bc2434f3..0b5b51d6f1fb2 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/graph_overlay/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/graph_overlay/index.tsx @@ -118,7 +118,10 @@ const GraphOverlayComponent = ({ - + Date: Tue, 14 Jul 2020 09:40:27 +0200 Subject: [PATCH 63/66] Fix ScopedHistory mock and adapt usages (#71404) * Fix mock and adapt usages * fix snapshots * add comment about forcecast * remove mock overrides --- .../public/application/scoped_history.mock.ts | 13 +++--- .../embeddable_state_transfer.test.ts | 42 ++++--------------- .../helpers/setup_environment.tsx | 7 ++-- .../account_management_app.test.ts | 4 +- .../access_agreement_app.test.ts | 4 +- .../logged_out/logged_out_app.test.ts | 4 +- .../authentication/login/login_app.test.ts | 4 +- .../authentication/logout/logout_app.test.ts | 4 +- .../overwritten_session_app.test.ts | 4 +- .../api_keys/api_keys_management_app.test.tsx | 3 +- .../edit_role_mapping_page.test.tsx | 3 +- .../role_mappings_grid_page.test.tsx | 2 +- .../role_mappings_management_app.test.tsx | 3 +- .../roles/edit_role/edit_role_page.test.tsx | 4 +- .../roles/roles_grid/roles_grid_page.test.tsx | 9 ++-- .../roles/roles_management_app.test.tsx | 4 +- .../users/edit_user/edit_user_page.test.tsx | 3 +- .../users/users_grid/users_grid_page.test.tsx | 2 +- .../users/users_management_app.test.tsx | 3 +- .../helpers/setup_environment.tsx | 7 ++-- .../edit_space/manage_space_page.test.tsx | 3 +- .../spaces_grid/spaces_grid_pages.test.tsx | 3 +- .../management/spaces_management_app.test.tsx | 3 +- .../actions_connectors_list.test.tsx | 11 +++-- .../components/alerts_list.test.tsx | 9 ++-- .../helpers/app_context.mock.tsx | 7 ++-- 26 files changed, 63 insertions(+), 102 deletions(-) diff --git a/src/core/public/application/scoped_history.mock.ts b/src/core/public/application/scoped_history.mock.ts index 41c72306a99f9..3b954313700f2 100644 --- a/src/core/public/application/scoped_history.mock.ts +++ b/src/core/public/application/scoped_history.mock.ts @@ -20,16 +20,16 @@ import { Location } from 'history'; import { ScopedHistory } from './scoped_history'; -type ScopedHistoryMock = jest.Mocked>; +export type ScopedHistoryMock = jest.Mocked; + const createMock = ({ pathname = '/', search = '', hash = '', key, state, - ...overrides -}: Partial = {}) => { - const mock: ScopedHistoryMock = { +}: Partial = {}) => { + const mock: jest.Mocked> = { block: jest.fn(), createHref: jest.fn(), createSubHistory: jest.fn(), @@ -39,7 +39,6 @@ const createMock = ({ listen: jest.fn(), push: jest.fn(), replace: jest.fn(), - ...overrides, action: 'PUSH', length: 1, location: { @@ -51,7 +50,9 @@ const createMock = ({ }, }; - return mock; + // jest.Mocked still expects private methods and properties to be present, even + // if not part of the public contract. + return mock as ScopedHistoryMock; }; export const scopedHistoryMock = { diff --git a/src/plugins/embeddable/public/lib/state_transfer/embeddable_state_transfer.test.ts b/src/plugins/embeddable/public/lib/state_transfer/embeddable_state_transfer.test.ts index b7dd95ccba32c..42adb9d770e8a 100644 --- a/src/plugins/embeddable/public/lib/state_transfer/embeddable_state_transfer.test.ts +++ b/src/plugins/embeddable/public/lib/state_transfer/embeddable_state_transfer.test.ts @@ -19,7 +19,7 @@ import { coreMock, scopedHistoryMock } from '../../../../../core/public/mocks'; import { EmbeddableStateTransfer } from '.'; -import { ApplicationStart, ScopedHistory } from '../../../../../core/public'; +import { ApplicationStart } from '../../../../../core/public'; function mockHistoryState(state: unknown) { return scopedHistoryMock.create({ state }); @@ -46,10 +46,7 @@ describe('embeddable state transfer', () => { it('can send an outgoing originating app state in append mode', async () => { const historyMock = mockHistoryState({ kibanaIsNowForSports: 'extremeSportsKibana' }); - stateTransfer = new EmbeddableStateTransfer( - application.navigateToApp, - (historyMock as unknown) as ScopedHistory - ); + stateTransfer = new EmbeddableStateTransfer(application.navigateToApp, historyMock); await stateTransfer.navigateToEditor(destinationApp, { state: { originatingApp }, appendToExistingState: true, @@ -74,10 +71,7 @@ describe('embeddable state transfer', () => { it('can send an outgoing embeddable package state in append mode', async () => { const historyMock = mockHistoryState({ kibanaIsNowForSports: 'extremeSportsKibana' }); - stateTransfer = new EmbeddableStateTransfer( - application.navigateToApp, - (historyMock as unknown) as ScopedHistory - ); + stateTransfer = new EmbeddableStateTransfer(application.navigateToApp, historyMock); await stateTransfer.navigateToWithEmbeddablePackage(destinationApp, { state: { type: 'coolestType', id: '150' }, appendToExistingState: true, @@ -90,40 +84,28 @@ describe('embeddable state transfer', () => { it('can fetch an incoming originating app state', async () => { const historyMock = mockHistoryState({ originatingApp: 'extremeSportsKibana' }); - stateTransfer = new EmbeddableStateTransfer( - application.navigateToApp, - (historyMock as unknown) as ScopedHistory - ); + stateTransfer = new EmbeddableStateTransfer(application.navigateToApp, historyMock); const fetchedState = stateTransfer.getIncomingEditorState(); expect(fetchedState).toEqual({ originatingApp: 'extremeSportsKibana' }); }); it('returns undefined with originating app state is not in the right shape', async () => { const historyMock = mockHistoryState({ kibanaIsNowForSports: 'extremeSportsKibana' }); - stateTransfer = new EmbeddableStateTransfer( - application.navigateToApp, - (historyMock as unknown) as ScopedHistory - ); + stateTransfer = new EmbeddableStateTransfer(application.navigateToApp, historyMock); const fetchedState = stateTransfer.getIncomingEditorState(); expect(fetchedState).toBeUndefined(); }); it('can fetch an incoming embeddable package state', async () => { const historyMock = mockHistoryState({ type: 'skisEmbeddable', id: '123' }); - stateTransfer = new EmbeddableStateTransfer( - application.navigateToApp, - (historyMock as unknown) as ScopedHistory - ); + stateTransfer = new EmbeddableStateTransfer(application.navigateToApp, historyMock); const fetchedState = stateTransfer.getIncomingEmbeddablePackage(); expect(fetchedState).toEqual({ type: 'skisEmbeddable', id: '123' }); }); it('returns undefined when embeddable package is not in the right shape', async () => { const historyMock = mockHistoryState({ kibanaIsNowForSports: 'extremeSportsKibana' }); - stateTransfer = new EmbeddableStateTransfer( - application.navigateToApp, - (historyMock as unknown) as ScopedHistory - ); + stateTransfer = new EmbeddableStateTransfer(application.navigateToApp, historyMock); const fetchedState = stateTransfer.getIncomingEmbeddablePackage(); expect(fetchedState).toBeUndefined(); }); @@ -135,10 +117,7 @@ describe('embeddable state transfer', () => { test1: 'test1', test2: 'test2', }); - stateTransfer = new EmbeddableStateTransfer( - application.navigateToApp, - (historyMock as unknown) as ScopedHistory - ); + stateTransfer = new EmbeddableStateTransfer(application.navigateToApp, historyMock); stateTransfer.getIncomingEmbeddablePackage({ keysToRemoveAfterFetch: ['type', 'id'] }); expect(historyMock.replace).toHaveBeenCalledWith( expect.objectContaining({ state: { test1: 'test1', test2: 'test2' } }) @@ -152,10 +131,7 @@ describe('embeddable state transfer', () => { test1: 'test1', test2: 'test2', }); - stateTransfer = new EmbeddableStateTransfer( - application.navigateToApp, - (historyMock as unknown) as ScopedHistory - ); + stateTransfer = new EmbeddableStateTransfer(application.navigateToApp, historyMock); stateTransfer.getIncomingEmbeddablePackage(); expect(historyMock.location.state).toEqual({ type: 'skisEmbeddable', diff --git a/x-pack/plugins/ingest_pipelines/__jest__/client_integration/helpers/setup_environment.tsx b/x-pack/plugins/ingest_pipelines/__jest__/client_integration/helpers/setup_environment.tsx index fa8c4f82c1b68..a5796c10f8d93 100644 --- a/x-pack/plugins/ingest_pipelines/__jest__/client_integration/helpers/setup_environment.tsx +++ b/x-pack/plugins/ingest_pipelines/__jest__/client_integration/helpers/setup_environment.tsx @@ -6,7 +6,6 @@ /* eslint-disable @kbn/eslint/no-restricted-paths */ import React from 'react'; import { LocationDescriptorObject } from 'history'; -import { ScopedHistory } from 'kibana/public'; import { KibanaContextProvider } from '../../../../../../src/plugins/kibana_react/public'; import { notificationServiceMock, @@ -35,10 +34,10 @@ const httpServiceSetupMock = new HttpService().setup({ fatalErrors: fatalErrorsServiceMock.createSetupContract(), }); -const history = (scopedHistoryMock.create() as unknown) as ScopedHistory; -history.createHref = (location: LocationDescriptorObject) => { +const history = scopedHistoryMock.create(); +history.createHref.mockImplementation((location: LocationDescriptorObject) => { return `${location.pathname}?${location.search}`; -}; +}); const appServices = { breadcrumbs: breadcrumbService, diff --git a/x-pack/plugins/security/public/account_management/account_management_app.test.ts b/x-pack/plugins/security/public/account_management/account_management_app.test.ts index bac98d5639755..37b97a8472310 100644 --- a/x-pack/plugins/security/public/account_management/account_management_app.test.ts +++ b/x-pack/plugins/security/public/account_management/account_management_app.test.ts @@ -6,7 +6,7 @@ jest.mock('./account_management_page'); -import { AppMount, AppNavLinkStatus, ScopedHistory } from 'src/core/public'; +import { AppMount, AppNavLinkStatus } from 'src/core/public'; import { UserAPIClient } from '../management'; import { accountManagementApp } from './account_management_app'; @@ -54,7 +54,7 @@ describe('accountManagementApp', () => { element: containerMock, appBasePath: '', onAppLeave: jest.fn(), - history: (scopedHistoryMock.create() as unknown) as ScopedHistory, + history: scopedHistoryMock.create(), }); expect(coreStartMock.chrome.setBreadcrumbs).toHaveBeenCalledTimes(1); diff --git a/x-pack/plugins/security/public/authentication/access_agreement/access_agreement_app.test.ts b/x-pack/plugins/security/public/authentication/access_agreement/access_agreement_app.test.ts index add2db6a3c170..0e262e9089842 100644 --- a/x-pack/plugins/security/public/authentication/access_agreement/access_agreement_app.test.ts +++ b/x-pack/plugins/security/public/authentication/access_agreement/access_agreement_app.test.ts @@ -6,7 +6,7 @@ jest.mock('./access_agreement_page'); -import { AppMount, ScopedHistory } from 'src/core/public'; +import { AppMount } from 'src/core/public'; import { accessAgreementApp } from './access_agreement_app'; import { coreMock, scopedHistoryMock } from '../../../../../../src/core/public/mocks'; @@ -48,7 +48,7 @@ describe('accessAgreementApp', () => { element: containerMock, appBasePath: '', onAppLeave: jest.fn(), - history: (scopedHistoryMock.create() as unknown) as ScopedHistory, + history: scopedHistoryMock.create(), }); const mockRenderApp = jest.requireMock('./access_agreement_page').renderAccessAgreementPage; diff --git a/x-pack/plugins/security/public/authentication/logged_out/logged_out_app.test.ts b/x-pack/plugins/security/public/authentication/logged_out/logged_out_app.test.ts index f0c18a3f1408e..15d55136b405d 100644 --- a/x-pack/plugins/security/public/authentication/logged_out/logged_out_app.test.ts +++ b/x-pack/plugins/security/public/authentication/logged_out/logged_out_app.test.ts @@ -6,7 +6,7 @@ jest.mock('./logged_out_page'); -import { AppMount, ScopedHistory } from 'src/core/public'; +import { AppMount } from 'src/core/public'; import { loggedOutApp } from './logged_out_app'; import { coreMock, scopedHistoryMock } from '../../../../../../src/core/public/mocks'; @@ -46,7 +46,7 @@ describe('loggedOutApp', () => { element: containerMock, appBasePath: '', onAppLeave: jest.fn(), - history: (scopedHistoryMock.create() as unknown) as ScopedHistory, + history: scopedHistoryMock.create(), }); const mockRenderApp = jest.requireMock('./logged_out_page').renderLoggedOutPage; diff --git a/x-pack/plugins/security/public/authentication/login/login_app.test.ts b/x-pack/plugins/security/public/authentication/login/login_app.test.ts index b7119d179b0b6..a6e5a321ef6ec 100644 --- a/x-pack/plugins/security/public/authentication/login/login_app.test.ts +++ b/x-pack/plugins/security/public/authentication/login/login_app.test.ts @@ -6,7 +6,7 @@ jest.mock('./login_page'); -import { AppMount, ScopedHistory } from 'src/core/public'; +import { AppMount } from 'src/core/public'; import { loginApp } from './login_app'; import { coreMock, scopedHistoryMock } from '../../../../../../src/core/public/mocks'; @@ -51,7 +51,7 @@ describe('loginApp', () => { element: containerMock, appBasePath: '', onAppLeave: jest.fn(), - history: (scopedHistoryMock.create() as unknown) as ScopedHistory, + history: scopedHistoryMock.create(), }); const mockRenderApp = jest.requireMock('./login_page').renderLoginPage; diff --git a/x-pack/plugins/security/public/authentication/logout/logout_app.test.ts b/x-pack/plugins/security/public/authentication/logout/logout_app.test.ts index 279500d14f211..46b1083a2ed14 100644 --- a/x-pack/plugins/security/public/authentication/logout/logout_app.test.ts +++ b/x-pack/plugins/security/public/authentication/logout/logout_app.test.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { AppMount, ScopedHistory } from 'src/core/public'; +import { AppMount } from 'src/core/public'; import { logoutApp } from './logout_app'; import { coreMock, scopedHistoryMock } from '../../../../../../src/core/public/mocks'; @@ -52,7 +52,7 @@ describe('logoutApp', () => { element: containerMock, appBasePath: '', onAppLeave: jest.fn(), - history: (scopedHistoryMock.create() as unknown) as ScopedHistory, + history: scopedHistoryMock.create(), }); expect(window.sessionStorage.clear).toHaveBeenCalledTimes(1); diff --git a/x-pack/plugins/security/public/authentication/overwritten_session/overwritten_session_app.test.ts b/x-pack/plugins/security/public/authentication/overwritten_session/overwritten_session_app.test.ts index 96e72ead22990..0eed1382c270b 100644 --- a/x-pack/plugins/security/public/authentication/overwritten_session/overwritten_session_app.test.ts +++ b/x-pack/plugins/security/public/authentication/overwritten_session/overwritten_session_app.test.ts @@ -6,7 +6,7 @@ jest.mock('./overwritten_session_page'); -import { AppMount, ScopedHistory } from 'src/core/public'; +import { AppMount } from 'src/core/public'; import { overwrittenSessionApp } from './overwritten_session_app'; import { coreMock, scopedHistoryMock } from '../../../../../../src/core/public/mocks'; @@ -53,7 +53,7 @@ describe('overwrittenSessionApp', () => { element: containerMock, appBasePath: '', onAppLeave: jest.fn(), - history: (scopedHistoryMock.create() as unknown) as ScopedHistory, + history: scopedHistoryMock.create(), }); const mockRenderApp = jest.requireMock('./overwritten_session_page') diff --git a/x-pack/plugins/security/public/management/api_keys/api_keys_management_app.test.tsx b/x-pack/plugins/security/public/management/api_keys/api_keys_management_app.test.tsx index 5f07b14ee71ef..30c5f8a361b42 100644 --- a/x-pack/plugins/security/public/management/api_keys/api_keys_management_app.test.tsx +++ b/x-pack/plugins/security/public/management/api_keys/api_keys_management_app.test.tsx @@ -7,7 +7,6 @@ jest.mock('./api_keys_grid', () => ({ APIKeysGridPage: (props: any) => `Page: ${JSON.stringify(props)}`, })); -import { ScopedHistory } from 'src/core/public'; import { apiKeysManagementApp } from './api_keys_management_app'; import { coreMock, scopedHistoryMock } from '../../../../../../src/core/public/mocks'; @@ -37,7 +36,7 @@ describe('apiKeysManagementApp', () => { basePath: '/some-base-path', element: container, setBreadcrumbs, - history: (scopedHistoryMock.create() as unknown) as ScopedHistory, + history: scopedHistoryMock.create(), }); expect(setBreadcrumbs).toHaveBeenCalledTimes(1); diff --git a/x-pack/plugins/security/public/management/role_mappings/edit_role_mapping/edit_role_mapping_page.test.tsx b/x-pack/plugins/security/public/management/role_mappings/edit_role_mapping/edit_role_mapping_page.test.tsx index b4e755507f8c5..04dc9c6dfa950 100644 --- a/x-pack/plugins/security/public/management/role_mappings/edit_role_mapping/edit_role_mapping_page.test.tsx +++ b/x-pack/plugins/security/public/management/role_mappings/edit_role_mapping/edit_role_mapping_page.test.tsx @@ -12,7 +12,6 @@ import { findTestSubject } from 'test_utils/find_test_subject'; // This is not required for the tests to pass, but it rather suppresses lengthy // warnings in the console which adds unnecessary noise to the test output. import 'test_utils/stub_web_worker'; -import { ScopedHistory } from 'kibana/public'; import { EditRoleMappingPage } from '.'; import { NoCompatibleRealms, SectionLoading, PermissionDenied } from '../components'; @@ -28,7 +27,7 @@ import { rolesAPIClientMock } from '../../roles/roles_api_client.mock'; import { RoleComboBox } from '../../role_combo_box'; describe('EditRoleMappingPage', () => { - const history = (scopedHistoryMock.create() as unknown) as ScopedHistory; + const history = scopedHistoryMock.create(); let rolesAPI: PublicMethodsOf; beforeEach(() => { diff --git a/x-pack/plugins/security/public/management/role_mappings/role_mappings_grid/role_mappings_grid_page.test.tsx b/x-pack/plugins/security/public/management/role_mappings/role_mappings_grid/role_mappings_grid_page.test.tsx index fb81ddb641e1f..727d7bf56e9e2 100644 --- a/x-pack/plugins/security/public/management/role_mappings/role_mappings_grid/role_mappings_grid_page.test.tsx +++ b/x-pack/plugins/security/public/management/role_mappings/role_mappings_grid/role_mappings_grid_page.test.tsx @@ -24,7 +24,7 @@ describe('RoleMappingsGridPage', () => { let coreStart: CoreStart; beforeEach(() => { - history = (scopedHistoryMock.create() as unknown) as ScopedHistory; + history = scopedHistoryMock.create(); coreStart = coreMock.createStart(); }); diff --git a/x-pack/plugins/security/public/management/role_mappings/role_mappings_management_app.test.tsx b/x-pack/plugins/security/public/management/role_mappings/role_mappings_management_app.test.tsx index c95d78f90f51a..e65310ba399ea 100644 --- a/x-pack/plugins/security/public/management/role_mappings/role_mappings_management_app.test.tsx +++ b/x-pack/plugins/security/public/management/role_mappings/role_mappings_management_app.test.tsx @@ -12,7 +12,6 @@ jest.mock('./edit_role_mapping', () => ({ EditRoleMappingPage: (props: any) => `Role Mapping Edit Page: ${JSON.stringify(props)}`, })); -import { ScopedHistory } from 'src/core/public'; import { roleMappingsManagementApp } from './role_mappings_management_app'; import { coreMock, scopedHistoryMock } from '../../../../../../src/core/public/mocks'; @@ -26,7 +25,7 @@ async function mountApp(basePath: string, pathname: string) { basePath, element: container, setBreadcrumbs, - history: (scopedHistoryMock.create({ pathname }) as unknown) as ScopedHistory, + history: scopedHistoryMock.create({ pathname }), }); return { unmount, container, setBreadcrumbs }; diff --git a/x-pack/plugins/security/public/management/roles/edit_role/edit_role_page.test.tsx b/x-pack/plugins/security/public/management/roles/edit_role/edit_role_page.test.tsx index 43387d913e6fc..f6fe2f394fd36 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/edit_role_page.test.tsx +++ b/x-pack/plugins/security/public/management/roles/edit_role/edit_role_page.test.tsx @@ -8,7 +8,7 @@ import { ReactWrapper } from 'enzyme'; import React from 'react'; import { act } from '@testing-library/react'; import { mountWithIntl, nextTick } from 'test_utils/enzyme_helpers'; -import { Capabilities, ScopedHistory } from 'src/core/public'; +import { Capabilities } from 'src/core/public'; import { Feature } from '../../../../../features/public'; import { Role } from '../../../../common/model'; import { DocumentationLinksService } from '../documentation_links'; @@ -187,7 +187,7 @@ function getProps({ docLinks: new DocumentationLinksService(docLinks), fatalErrors, uiCapabilities: buildUICapabilities(canManageSpaces), - history: (scopedHistoryMock.create() as unknown) as ScopedHistory, + history: scopedHistoryMock.create(), }; } diff --git a/x-pack/plugins/security/public/management/roles/roles_grid/roles_grid_page.test.tsx b/x-pack/plugins/security/public/management/roles/roles_grid/roles_grid_page.test.tsx index d83d5ef3f6468..005eebbfbf3bb 100644 --- a/x-pack/plugins/security/public/management/roles/roles_grid/roles_grid_page.test.tsx +++ b/x-pack/plugins/security/public/management/roles/roles_grid/roles_grid_page.test.tsx @@ -16,7 +16,6 @@ import { coreMock, scopedHistoryMock } from '../../../../../../../src/core/publi import { rolesAPIClientMock } from '../index.mock'; import { ReservedBadge, DisabledBadge } from '../../badges'; import { findTestSubject } from 'test_utils/find_test_subject'; -import { ScopedHistory } from 'kibana/public'; const mock403 = () => ({ body: { statusCode: 403 } }); @@ -42,12 +41,12 @@ const waitForRender = async ( describe('', () => { let apiClientMock: jest.Mocked>; - let history: ScopedHistory; + let history: ReturnType; beforeEach(() => { - history = (scopedHistoryMock.create({ - createHref: jest.fn((location) => location.pathname!), - }) as unknown) as ScopedHistory; + history = scopedHistoryMock.create(); + history.createHref.mockImplementation((location) => location.pathname!); + apiClientMock = rolesAPIClientMock.create(); apiClientMock.getRoles.mockResolvedValue([ { diff --git a/x-pack/plugins/security/public/management/roles/roles_management_app.test.tsx b/x-pack/plugins/security/public/management/roles/roles_management_app.test.tsx index e7f38c86b045e..c45528399db99 100644 --- a/x-pack/plugins/security/public/management/roles/roles_management_app.test.tsx +++ b/x-pack/plugins/security/public/management/roles/roles_management_app.test.tsx @@ -14,8 +14,6 @@ jest.mock('./edit_role', () => ({ EditRolePage: (props: any) => `Role Edit Page: ${JSON.stringify(props)}`, })); -import { ScopedHistory } from 'src/core/public'; - import { rolesManagementApp } from './roles_management_app'; import { coreMock, scopedHistoryMock } from '../../../../../../src/core/public/mocks'; @@ -40,7 +38,7 @@ async function mountApp(basePath: string, pathname: string) { basePath, element: container, setBreadcrumbs, - history: (scopedHistoryMock.create({ pathname }) as unknown) as ScopedHistory, + history: scopedHistoryMock.create({ pathname }), }); return { unmount, container, setBreadcrumbs }; diff --git a/x-pack/plugins/security/public/management/users/edit_user/edit_user_page.test.tsx b/x-pack/plugins/security/public/management/users/edit_user/edit_user_page.test.tsx index 7ee33357b9af4..40ffc508f086b 100644 --- a/x-pack/plugins/security/public/management/users/edit_user/edit_user_page.test.tsx +++ b/x-pack/plugins/security/public/management/users/edit_user/edit_user_page.test.tsx @@ -5,7 +5,6 @@ */ import { act } from '@testing-library/react'; -import { ScopedHistory } from 'kibana/public'; import { mountWithIntl, nextTick } from 'test_utils/enzyme_helpers'; import { EditUserPage } from './edit_user_page'; import React from 'react'; @@ -104,7 +103,7 @@ function expectMissingSaveButton(wrapper: ReactWrapper) { } describe('EditUserPage', () => { - const history = (scopedHistoryMock.create() as unknown) as ScopedHistory; + const history = scopedHistoryMock.create(); it('allows reserved users to be viewed', async () => { const user = createUser('reserved_user'); diff --git a/x-pack/plugins/security/public/management/users/users_grid/users_grid_page.test.tsx b/x-pack/plugins/security/public/management/users/users_grid/users_grid_page.test.tsx index edce7409e28d5..df8fe8cee7699 100644 --- a/x-pack/plugins/security/public/management/users/users_grid/users_grid_page.test.tsx +++ b/x-pack/plugins/security/public/management/users/users_grid/users_grid_page.test.tsx @@ -22,7 +22,7 @@ describe('UsersGridPage', () => { let coreStart: CoreStart; beforeEach(() => { - history = (scopedHistoryMock.create() as unknown) as ScopedHistory; + history = scopedHistoryMock.create(); history.createHref = (location: LocationDescriptorObject) => { return `${location.pathname}${location.search ? '?' + location.search : ''}`; }; diff --git a/x-pack/plugins/security/public/management/users/users_management_app.test.tsx b/x-pack/plugins/security/public/management/users/users_management_app.test.tsx index 98906f560e6cb..06bd2eff6aa1e 100644 --- a/x-pack/plugins/security/public/management/users/users_management_app.test.tsx +++ b/x-pack/plugins/security/public/management/users/users_management_app.test.tsx @@ -12,7 +12,6 @@ jest.mock('./edit_user', () => ({ EditUserPage: (props: any) => `User Edit Page: ${JSON.stringify(props)}`, })); -import { ScopedHistory } from 'src/core/public'; import { usersManagementApp } from './users_management_app'; import { coreMock, scopedHistoryMock } from '../../../../../../src/core/public/mocks'; @@ -31,7 +30,7 @@ async function mountApp(basePath: string, pathname: string) { basePath, element: container, setBreadcrumbs, - history: (scopedHistoryMock.create({ pathname }) as unknown) as ScopedHistory, + history: scopedHistoryMock.create({ pathname }), }); return { unmount, container, setBreadcrumbs }; diff --git a/x-pack/plugins/snapshot_restore/__jest__/client_integration/helpers/setup_environment.tsx b/x-pack/plugins/snapshot_restore/__jest__/client_integration/helpers/setup_environment.tsx index e3c0ab0be9bd2..2cfffb3572dde 100644 --- a/x-pack/plugins/snapshot_restore/__jest__/client_integration/helpers/setup_environment.tsx +++ b/x-pack/plugins/snapshot_restore/__jest__/client_integration/helpers/setup_environment.tsx @@ -9,7 +9,6 @@ import axios from 'axios'; import axiosXhrAdapter from 'axios/lib/adapters/xhr'; import { i18n } from '@kbn/i18n'; import { LocationDescriptorObject } from 'history'; -import { ScopedHistory } from 'kibana/public'; import { coreMock, scopedHistoryMock } from 'src/core/public/mocks'; import { setUiMetricService, httpService } from '../../../public/application/services/http'; @@ -25,10 +24,10 @@ import { documentationLinksService } from '../../../public/application/services/ const mockHttpClient = axios.create({ adapter: axiosXhrAdapter }); -const history = (scopedHistoryMock.create() as unknown) as ScopedHistory; -history.createHref = (location: LocationDescriptorObject) => { +const history = scopedHistoryMock.create(); +history.createHref.mockImplementation((location: LocationDescriptorObject) => { return `${location.pathname}?${location.search}`; -}; +}); export const services = { uiMetricService: new UiMetricService('snapshot_restore'), diff --git a/x-pack/plugins/spaces/public/management/edit_space/manage_space_page.test.tsx b/x-pack/plugins/spaces/public/management/edit_space/manage_space_page.test.tsx index b0103800d4105..b573848f0c84a 100644 --- a/x-pack/plugins/spaces/public/management/edit_space/manage_space_page.test.tsx +++ b/x-pack/plugins/spaces/public/management/edit_space/manage_space_page.test.tsx @@ -7,7 +7,6 @@ import { EuiButton, EuiLink, EuiSwitch } from '@elastic/eui'; import { ReactWrapper } from 'enzyme'; import React from 'react'; -import { ScopedHistory } from 'kibana/public'; import { mountWithIntl } from 'test_utils/enzyme_helpers'; import { ConfirmAlterActiveSpaceModal } from './confirm_alter_active_space_modal'; @@ -46,7 +45,7 @@ featuresStart.getFeatures.mockResolvedValue([ describe('ManageSpacePage', () => { const getUrlForApp = (appId: string) => appId; - const history = (scopedHistoryMock.create() as unknown) as ScopedHistory; + const history = scopedHistoryMock.create(); it('allows a space to be created', async () => { const spacesManager = spacesManagerMock.create(); diff --git a/x-pack/plugins/spaces/public/management/spaces_grid/spaces_grid_pages.test.tsx b/x-pack/plugins/spaces/public/management/spaces_grid/spaces_grid_pages.test.tsx index 1868823823a1a..607570eedc787 100644 --- a/x-pack/plugins/spaces/public/management/spaces_grid/spaces_grid_pages.test.tsx +++ b/x-pack/plugins/spaces/public/management/spaces_grid/spaces_grid_pages.test.tsx @@ -5,7 +5,6 @@ */ import React from 'react'; -import { ScopedHistory } from 'kibana/public'; import { mountWithIntl, shallowWithIntl, nextTick } from 'test_utils/enzyme_helpers'; import { SpaceAvatar } from '../../space_avatar'; import { spacesManagerMock } from '../../spaces_manager/mocks'; @@ -54,7 +53,7 @@ featuresStart.getFeatures.mockResolvedValue([ describe('SpacesGridPage', () => { const getUrlForApp = (appId: string) => appId; - const history = (scopedHistoryMock.create() as unknown) as ScopedHistory; + const history = scopedHistoryMock.create(); it('renders as expected', () => { const httpStart = httpServiceMock.createStartContract(); diff --git a/x-pack/plugins/spaces/public/management/spaces_management_app.test.tsx b/x-pack/plugins/spaces/public/management/spaces_management_app.test.tsx index 834bfb73d8f46..1e8520a2617dd 100644 --- a/x-pack/plugins/spaces/public/management/spaces_management_app.test.tsx +++ b/x-pack/plugins/spaces/public/management/spaces_management_app.test.tsx @@ -17,7 +17,6 @@ jest.mock('./edit_space', () => ({ }, })); -import { ScopedHistory } from 'src/core/public'; import { spacesManagementApp } from './spaces_management_app'; import { coreMock, scopedHistoryMock } from '../../../../../src/core/public/mocks'; @@ -58,7 +57,7 @@ async function mountApp(basePath: string, pathname: string, spaceId?: string) { basePath, element: container, setBreadcrumbs, - history: (scopedHistoryMock.create({ pathname }) as unknown) as ScopedHistory, + history: scopedHistoryMock.create({ pathname }), }); return { unmount, container, setBreadcrumbs }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.test.tsx index 40505ac3fe76c..23a7223f9c21b 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.test.tsx @@ -5,7 +5,6 @@ */ import * as React from 'react'; import { mountWithIntl, nextTick } from 'test_utils/enzyme_helpers'; -import { ScopedHistory } from 'kibana/public'; import { ActionsConnectorsList } from './actions_connectors_list'; import { coreMock, scopedHistoryMock } from '../../../../../../../../src/core/public/mocks'; @@ -68,7 +67,7 @@ describe('actions_connectors_list component empty', () => { 'actions:delete': true, }, }, - history: (scopedHistoryMock.create() as unknown) as ScopedHistory, + history: scopedHistoryMock.create(), setBreadcrumbs: jest.fn(), actionTypeRegistry: actionTypeRegistry as any, alertTypeRegistry: {} as any, @@ -175,7 +174,7 @@ describe('actions_connectors_list component with items', () => { 'actions:delete': true, }, }, - history: (scopedHistoryMock.create() as unknown) as ScopedHistory, + history: scopedHistoryMock.create(), setBreadcrumbs: jest.fn(), actionTypeRegistry: { get() { @@ -263,7 +262,7 @@ describe('actions_connectors_list component empty with show only capability', () 'actions:delete': false, }, }, - history: (scopedHistoryMock.create() as unknown) as ScopedHistory, + history: scopedHistoryMock.create(), setBreadcrumbs: jest.fn(), actionTypeRegistry: { get() { @@ -352,7 +351,7 @@ describe('actions_connectors_list with show only capability', () => { 'actions:delete': false, }, }, - history: (scopedHistoryMock.create() as unknown) as ScopedHistory, + history: scopedHistoryMock.create(), setBreadcrumbs: jest.fn(), actionTypeRegistry: { get() { @@ -453,7 +452,7 @@ describe('actions_connectors_list component with disabled items', () => { 'actions:delete': true, }, }, - history: (scopedHistoryMock.create() as unknown) as ScopedHistory, + history: scopedHistoryMock.create(), setBreadcrumbs: jest.fn(), actionTypeRegistry: { get() { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alerts_list.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alerts_list.test.tsx index dc2c1f972a5db..69b0856297bb5 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alerts_list.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alerts_list.test.tsx @@ -4,7 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ import * as React from 'react'; -import { ScopedHistory } from 'kibana/public'; import { mountWithIntl, nextTick } from 'test_utils/enzyme_helpers'; import { coreMock, scopedHistoryMock } from '../../../../../../../../src/core/public/mocks'; @@ -103,7 +102,7 @@ describe('alerts_list component empty', () => { 'alerting:delete': true, }, }, - history: (scopedHistoryMock.create() as unknown) as ScopedHistory, + history: scopedHistoryMock.create(), setBreadcrumbs: jest.fn(), actionTypeRegistry: actionTypeRegistry as any, alertTypeRegistry: alertTypeRegistry as any, @@ -222,7 +221,7 @@ describe('alerts_list component with items', () => { 'alerting:delete': true, }, }, - history: (scopedHistoryMock.create() as unknown) as ScopedHistory, + history: scopedHistoryMock.create(), setBreadcrumbs: jest.fn(), actionTypeRegistry: actionTypeRegistry as any, alertTypeRegistry: alertTypeRegistry as any, @@ -304,7 +303,7 @@ describe('alerts_list component empty with show only capability', () => { 'alerting:delete': false, }, }, - history: (scopedHistoryMock.create() as unknown) as ScopedHistory, + history: scopedHistoryMock.create(), setBreadcrumbs: jest.fn(), actionTypeRegistry: { get() { @@ -419,7 +418,7 @@ describe('alerts_list with show only capability', () => { 'alerting:delete': false, }, }, - history: (scopedHistoryMock.create() as unknown) as ScopedHistory, + history: scopedHistoryMock.create(), setBreadcrumbs: jest.fn(), actionTypeRegistry: actionTypeRegistry as any, alertTypeRegistry: alertTypeRegistry as any, diff --git a/x-pack/plugins/watcher/__jest__/client_integration/helpers/app_context.mock.tsx b/x-pack/plugins/watcher/__jest__/client_integration/helpers/app_context.mock.tsx index 142504ee163b7..3db3cf5c66011 100644 --- a/x-pack/plugins/watcher/__jest__/client_integration/helpers/app_context.mock.tsx +++ b/x-pack/plugins/watcher/__jest__/client_integration/helpers/app_context.mock.tsx @@ -8,7 +8,6 @@ import React from 'react'; import { of } from 'rxjs'; import { ComponentType } from 'enzyme'; import { LocationDescriptorObject } from 'history'; -import { ScopedHistory } from 'src/core/public'; import { docLinksServiceMock, uiSettingsServiceMock, @@ -31,10 +30,10 @@ class MockTimeBuckets { } } -const history = (scopedHistoryMock.create() as unknown) as ScopedHistory; -history.createHref = (location: LocationDescriptorObject) => { +const history = scopedHistoryMock.create(); +history.createHref.mockImplementation((location: LocationDescriptorObject) => { return `${location.pathname}${location.search ? '?' + location.search : ''}`; -}; +}); export const mockContextValue = { licenseStatus$: of({ valid: true }), From 35fc222bdced50cbd2143d675ddeacfdd4e4f431 Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Tue, 14 Jul 2020 09:43:39 +0200 Subject: [PATCH 64/66] adjust vislib bar opacity (#71421) --- .../vis_type_vislib/public/vislib/lib/layout/_layout.scss | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/plugins/vis_type_vislib/public/vislib/lib/layout/_layout.scss b/src/plugins/vis_type_vislib/public/vislib/lib/layout/_layout.scss index 6d96fa39e7c34..96c72bd5956d2 100644 --- a/src/plugins/vis_type_vislib/public/vislib/lib/layout/_layout.scss +++ b/src/plugins/vis_type_vislib/public/vislib/lib/layout/_layout.scss @@ -304,11 +304,14 @@ .series > path, .series > rect { - fill-opacity: .8; stroke-opacity: 1; stroke-width: 0; } + .series > path { + fill-opacity: .8; + } + .blur_shape { // sass-lint:disable-block no-important opacity: .3 !important; From 831e427682303ee05be2c91c1de737184218e235 Mon Sep 17 00:00:00 2001 From: patrykkopycinski Date: Tue, 14 Jul 2020 10:57:51 +0200 Subject: [PATCH 65/66] [Security] Add Timeline improvements (#71506) --- .../cypress/tasks/timeline.ts | 3 ++ .../__snapshots__/providers.test.tsx.snap | 53 ++++++++++++++----- .../add_data_provider_popover.tsx | 33 ++++++++---- .../timeline/data_providers/providers.tsx | 27 ++++------ .../timelines/components/timeline/index.tsx | 4 +- 5 files changed, 78 insertions(+), 42 deletions(-) diff --git a/x-pack/plugins/security_solution/cypress/tasks/timeline.ts b/x-pack/plugins/security_solution/cypress/tasks/timeline.ts index 37ce9094dc594..761fd2c1e6a0b 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/timeline.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/timeline.ts @@ -27,6 +27,8 @@ import { import { drag, drop } from '../tasks/common'; +export const hostExistsQuery = 'host.name: *'; + export const addDescriptionToTimeline = (description: string) => { cy.get(TIMELINE_DESCRIPTION).type(`${description}{enter}`); cy.get(DATE_PICKER_APPLY_BUTTON_TIMELINE).click().invoke('text').should('not.equal', 'Updating'); @@ -77,6 +79,7 @@ export const openTimelineSettings = () => { }; export const populateTimeline = () => { + executeTimelineKQL(hostExistsQuery); cy.get(SERVER_SIDE_EVENT_COUNT) .invoke('text') .then((strCount) => { diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/__snapshots__/providers.test.tsx.snap b/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/__snapshots__/providers.test.tsx.snap index a227f39494b61..a86c99cbc094a 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/__snapshots__/providers.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/__snapshots__/providers.test.tsx.snap @@ -9,10 +9,11 @@ exports[`Providers rendering renders correctly against snapshot 1`] = ` - - + @@ -58,7 +59,9 @@ exports[`Providers rendering renders correctly against snapshot 1`] = ` - + @@ -106,7 +109,9 @@ exports[`Providers rendering renders correctly against snapshot 1`] = ` - + @@ -154,7 +159,9 @@ exports[`Providers rendering renders correctly against snapshot 1`] = ` - + @@ -202,7 +209,9 @@ exports[`Providers rendering renders correctly against snapshot 1`] = ` - + @@ -250,7 +259,9 @@ exports[`Providers rendering renders correctly against snapshot 1`] = ` - + @@ -298,7 +309,9 @@ exports[`Providers rendering renders correctly against snapshot 1`] = ` - + @@ -346,7 +359,9 @@ exports[`Providers rendering renders correctly against snapshot 1`] = ` - + @@ -394,7 +409,9 @@ exports[`Providers rendering renders correctly against snapshot 1`] = ` - + @@ -442,7 +459,9 @@ exports[`Providers rendering renders correctly against snapshot 1`] = ` - + @@ -490,7 +509,9 @@ exports[`Providers rendering renders correctly against snapshot 1`] = ` - + @@ -527,6 +548,10 @@ exports[`Providers rendering renders correctly against snapshot 1`] = ` ) +
`; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/add_data_provider_popover.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/add_data_provider_popover.tsx index 8e1c02bad50a3..71cf81c00dc09 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/add_data_provider_popover.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/add_data_provider_popover.tsx @@ -7,6 +7,7 @@ import React, { useCallback, useMemo, useState } from 'react'; import { EuiButton, + EuiButtonEmpty, EuiContextMenu, EuiText, EuiPopover, @@ -139,21 +140,33 @@ const AddDataProviderPopoverComponent: React.FC = ( [browserFields, handleDataProviderEdited, timelineId, timelineType] ); - const button = useMemo( - () => ( - { + if (timelineType === TimelineType.template) { + return ( + + {ADD_FIELD_LABEL} + + ); + } + + return ( + - {ADD_FIELD_LABEL} - - ), - [handleOpenPopover] - ); + {`+ ${ADD_FIELD_LABEL}`} + + ); + }, [handleOpenPopover, timelineType]); const content = useMemo(() => { if (timelineType === TimelineType.template) { diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/providers.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/providers.tsx index c9dd906cee59b..1142bbc214d74 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/providers.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/providers.tsx @@ -82,10 +82,10 @@ const Parens = styled.span` `} `; -const AndOrBadgeContainer = styled.div` - width: 121px; - display: flex; - justify-content: flex-end; +const AndOrBadgeContainer = styled.div<{ hideBadge: boolean }>` + span { + visibility: ${({ hideBadge }) => (hideBadge ? 'hidden' : 'inherit')}; + } `; const LastAndOrBadgeInGroup = styled.div` @@ -113,10 +113,6 @@ const ParensContainer = styled(EuiFlexItem)` align-self: center; `; -const AddDataProviderContainer = styled.div` - padding-right: 9px; -`; - const getDataProviderValue = (dataProvider: DataProvidersAnd) => dataProvider.queryMatch.displayValue ?? dataProvider.queryMatch.value; @@ -152,15 +148,9 @@ export const Providers = React.memo( - {groupIndex === 0 ? ( - - - - ) : ( - - - - )} + + + {'('} @@ -300,6 +290,9 @@ export const Providers = React.memo( {')'} + {groupIndex === dataProviderGroups.length - 1 && ( + + )} ))} diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/index.tsx index 5265efc8109a4..c4d89fa29cb32 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/index.tsx @@ -266,7 +266,9 @@ const makeMapStateToProps = () => { // return events on empty search const kqlQueryExpression = - isEmpty(dataProviders) && isEmpty(kqlQueryTimeline) ? ' ' : kqlQueryTimeline; + isEmpty(dataProviders) && isEmpty(kqlQueryTimeline) && timelineType === 'template' + ? ' ' + : kqlQueryTimeline; return { columns, dataProviders, From 3374b2d3b041143f87b8af1d35beea9d5f7bd93d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cau=C3=AA=20Marcondes?= <55978943+cauemarcondes@users.noreply.github.com> Date: Tue, 14 Jul 2020 10:05:48 +0100 Subject: [PATCH 66/66] [Observability] Change appLink passing the date range (#71259) * changing apm appLink * changing apm appLink * removing title from api * adding absolute and relative times * addressing pr comments * addressing pr comments * addressing pr comments * fixing TS issues * addressing pr comments Co-authored-by: Elastic Machine --- x-pack/plugins/apm/public/plugin.ts | 8 +-- ....test.ts => apm_overview_fetchers.test.ts} | 43 +++++++------- ..._dashboard.ts => apm_overview_fetchers.ts} | 24 ++++---- .../get_service_count.ts | 0 .../get_transaction_coordinates.ts | 0 .../has_data.ts | 0 .../apm/server/routes/create_apm_api.ts | 10 ++-- ...dashboard.ts => observability_overview.ts} | 14 ++--- .../metrics_overview_fetchers.test.ts.snap | 3 +- .../public/metrics_overview_fetchers.test.ts | 12 +++- .../infra/public/metrics_overview_fetchers.ts | 27 ++++----- .../public/utils/logs_overview_fetchers.ts | 23 +++----- .../components/app/section/alerts/index.tsx | 14 +++-- .../components/app/section/apm/index.test.tsx | 15 +++-- .../components/app/section/apm/index.tsx | 34 +++++++---- .../app/section/apm/mock_data/apm.mock.ts | 2 - .../components/app/section/index.test.tsx | 4 +- .../public/components/app/section/index.tsx | 24 ++++---- .../components/app/section/logs/index.tsx | 34 +++++++---- .../components/app/section/metrics/index.tsx | 32 +++++++---- .../components/app/section/uptime/index.tsx | 36 ++++++++---- .../observability/public/data_handler.test.ts | 11 +++- .../public/pages/overview/index.tsx | 57 ++++++++++--------- .../public/pages/overview/mock/apm.mock.ts | 2 - .../public/pages/overview/mock/logs.mock.ts | 2 - .../pages/overview/mock/metrics.mock.ts | 2 - .../public/pages/overview/mock/uptime.mock.ts | 2 - .../typings/fetch_overview_data/index.ts | 8 +-- .../observability/public/utils/date.ts | 10 ++-- .../public/apps/uptime_overview_fetcher.ts | 23 ++++---- 30 files changed, 255 insertions(+), 221 deletions(-) rename x-pack/plugins/apm/public/services/rest/{observability.dashboard.test.ts => apm_overview_fetchers.test.ts} (78%) rename x-pack/plugins/apm/public/services/rest/{observability_dashboard.ts => apm_overview_fetchers.ts} (70%) rename x-pack/plugins/apm/server/lib/{observability_dashboard => observability_overview}/get_service_count.ts (100%) rename x-pack/plugins/apm/server/lib/{observability_dashboard => observability_overview}/get_transaction_coordinates.ts (100%) rename x-pack/plugins/apm/server/lib/{observability_dashboard => observability_overview}/has_data.ts (100%) rename x-pack/plugins/apm/server/routes/{observability_dashboard.ts => observability_overview.ts} (74%) diff --git a/x-pack/plugins/apm/public/plugin.ts b/x-pack/plugins/apm/public/plugin.ts index 6e3a29d9f3dbc..f264ae6cd9852 100644 --- a/x-pack/plugins/apm/public/plugin.ts +++ b/x-pack/plugins/apm/public/plugin.ts @@ -39,9 +39,9 @@ import { toggleAppLinkInNav } from './toggleAppLinkInNav'; import { setReadonlyBadge } from './updateBadge'; import { createStaticIndexPattern } from './services/rest/index_pattern'; import { - fetchLandingPageData, + fetchOverviewPageData, hasData, -} from './services/rest/observability_dashboard'; +} from './services/rest/apm_overview_fetchers'; export type ApmPluginSetup = void; export type ApmPluginStart = void; @@ -81,9 +81,7 @@ export class ApmPlugin implements Plugin { if (plugins.observability) { plugins.observability.dashboard.register({ appName: 'apm', - fetchData: async (params) => { - return fetchLandingPageData(params); - }, + fetchData: fetchOverviewPageData, hasData, }); } diff --git a/x-pack/plugins/apm/public/services/rest/observability.dashboard.test.ts b/x-pack/plugins/apm/public/services/rest/apm_overview_fetchers.test.ts similarity index 78% rename from x-pack/plugins/apm/public/services/rest/observability.dashboard.test.ts rename to x-pack/plugins/apm/public/services/rest/apm_overview_fetchers.test.ts index fd407a8bf72ad..8b3ed38e25319 100644 --- a/x-pack/plugins/apm/public/services/rest/observability.dashboard.test.ts +++ b/x-pack/plugins/apm/public/services/rest/apm_overview_fetchers.test.ts @@ -4,11 +4,23 @@ * you may not use this file except in compliance with the Elastic License. */ -import { fetchLandingPageData, hasData } from './observability_dashboard'; +import moment from 'moment'; +import { fetchOverviewPageData, hasData } from './apm_overview_fetchers'; import * as createCallApmApi from './createCallApmApi'; describe('Observability dashboard data', () => { const callApmApiMock = jest.spyOn(createCallApmApi, 'callApmApi'); + const params = { + absoluteTime: { + start: moment('2020-07-02T13:25:11.629Z').valueOf(), + end: moment('2020-07-09T14:25:11.629Z').valueOf(), + }, + relativeTime: { + start: 'now-15m', + end: 'now', + }, + bucketSize: '600s', + }; afterEach(() => { callApmApiMock.mockClear(); }); @@ -25,7 +37,7 @@ describe('Observability dashboard data', () => { }); }); - describe('fetchLandingPageData', () => { + describe('fetchOverviewPageData', () => { it('returns APM data with series and stats', async () => { callApmApiMock.mockImplementation(() => Promise.resolve({ @@ -37,14 +49,9 @@ describe('Observability dashboard data', () => { ], }) ); - const response = await fetchLandingPageData({ - startTime: '1', - endTime: '2', - bucketSize: '3', - }); + const response = await fetchOverviewPageData(params); expect(response).toEqual({ - title: 'APM', - appLink: '/app/apm', + appLink: '/app/apm#/services?rangeFrom=now-15m&rangeTo=now', stats: { services: { type: 'number', @@ -73,14 +80,9 @@ describe('Observability dashboard data', () => { transactionCoordinates: [], }) ); - const response = await fetchLandingPageData({ - startTime: '1', - endTime: '2', - bucketSize: '3', - }); + const response = await fetchOverviewPageData(params); expect(response).toEqual({ - title: 'APM', - appLink: '/app/apm', + appLink: '/app/apm#/services?rangeFrom=now-15m&rangeTo=now', stats: { services: { type: 'number', @@ -105,14 +107,9 @@ describe('Observability dashboard data', () => { transactionCoordinates: [{ x: 1 }, { x: 2 }, { x: 3 }], }) ); - const response = await fetchLandingPageData({ - startTime: '1', - endTime: '2', - bucketSize: '3', - }); + const response = await fetchOverviewPageData(params); expect(response).toEqual({ - title: 'APM', - appLink: '/app/apm', + appLink: '/app/apm#/services?rangeFrom=now-15m&rangeTo=now', stats: { services: { type: 'number', diff --git a/x-pack/plugins/apm/public/services/rest/observability_dashboard.ts b/x-pack/plugins/apm/public/services/rest/apm_overview_fetchers.ts similarity index 70% rename from x-pack/plugins/apm/public/services/rest/observability_dashboard.ts rename to x-pack/plugins/apm/public/services/rest/apm_overview_fetchers.ts index 409cec8b9ce10..78f3a0a0aaa80 100644 --- a/x-pack/plugins/apm/public/services/rest/observability_dashboard.ts +++ b/x-pack/plugins/apm/public/services/rest/apm_overview_fetchers.ts @@ -4,7 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -import { i18n } from '@kbn/i18n'; import { mean } from 'lodash'; import { ApmFetchDataResponse, @@ -12,23 +11,26 @@ import { } from '../../../../observability/public'; import { callApmApi } from './createCallApmApi'; -export const fetchLandingPageData = async ({ - startTime, - endTime, +export const fetchOverviewPageData = async ({ + absoluteTime, + relativeTime, bucketSize, }: FetchDataParams): Promise => { const data = await callApmApi({ - pathname: '/api/apm/observability_dashboard', - params: { query: { start: startTime, end: endTime, bucketSize } }, + pathname: '/api/apm/observability_overview', + params: { + query: { + start: new Date(absoluteTime.start).toISOString(), + end: new Date(absoluteTime.end).toISOString(), + bucketSize, + }, + }, }); const { serviceCount, transactionCoordinates } = data; return { - title: i18n.translate('xpack.apm.observabilityDashboard.title', { - defaultMessage: 'APM', - }), - appLink: '/app/apm', + appLink: `/app/apm#/services?rangeFrom=${relativeTime.start}&rangeTo=${relativeTime.end}`, stats: { services: { type: 'number', @@ -54,6 +56,6 @@ export const fetchLandingPageData = async ({ export async function hasData() { return await callApmApi({ - pathname: '/api/apm/observability_dashboard/has_data', + pathname: '/api/apm/observability_overview/has_data', }); } diff --git a/x-pack/plugins/apm/server/lib/observability_dashboard/get_service_count.ts b/x-pack/plugins/apm/server/lib/observability_overview/get_service_count.ts similarity index 100% rename from x-pack/plugins/apm/server/lib/observability_dashboard/get_service_count.ts rename to x-pack/plugins/apm/server/lib/observability_overview/get_service_count.ts diff --git a/x-pack/plugins/apm/server/lib/observability_dashboard/get_transaction_coordinates.ts b/x-pack/plugins/apm/server/lib/observability_overview/get_transaction_coordinates.ts similarity index 100% rename from x-pack/plugins/apm/server/lib/observability_dashboard/get_transaction_coordinates.ts rename to x-pack/plugins/apm/server/lib/observability_overview/get_transaction_coordinates.ts diff --git a/x-pack/plugins/apm/server/lib/observability_dashboard/has_data.ts b/x-pack/plugins/apm/server/lib/observability_overview/has_data.ts similarity index 100% rename from x-pack/plugins/apm/server/lib/observability_dashboard/has_data.ts rename to x-pack/plugins/apm/server/lib/observability_overview/has_data.ts diff --git a/x-pack/plugins/apm/server/routes/create_apm_api.ts b/x-pack/plugins/apm/server/routes/create_apm_api.ts index 513c44904683e..0a4295fea3997 100644 --- a/x-pack/plugins/apm/server/routes/create_apm_api.ts +++ b/x-pack/plugins/apm/server/routes/create_apm_api.ts @@ -79,9 +79,9 @@ import { rumServicesRoute, } from './rum_client'; import { - observabilityDashboardHasDataRoute, - observabilityDashboardDataRoute, -} from './observability_dashboard'; + observabilityOverviewHasDataRoute, + observabilityOverviewRoute, +} from './observability_overview'; import { anomalyDetectionJobsRoute, createAnomalyDetectionJobsRoute, @@ -176,8 +176,8 @@ const createApmApi = () => { .add(rumServicesRoute) // Observability dashboard - .add(observabilityDashboardHasDataRoute) - .add(observabilityDashboardDataRoute) + .add(observabilityOverviewHasDataRoute) + .add(observabilityOverviewRoute) // Anomaly detection .add(anomalyDetectionJobsRoute) diff --git a/x-pack/plugins/apm/server/routes/observability_dashboard.ts b/x-pack/plugins/apm/server/routes/observability_overview.ts similarity index 74% rename from x-pack/plugins/apm/server/routes/observability_dashboard.ts rename to x-pack/plugins/apm/server/routes/observability_overview.ts index 10c74295fe3e4..d5bb3b49c2f4c 100644 --- a/x-pack/plugins/apm/server/routes/observability_dashboard.ts +++ b/x-pack/plugins/apm/server/routes/observability_overview.ts @@ -5,22 +5,22 @@ */ import * as t from 'io-ts'; import { setupRequest } from '../lib/helpers/setup_request'; -import { hasData } from '../lib/observability_dashboard/has_data'; +import { getServiceCount } from '../lib/observability_overview/get_service_count'; +import { getTransactionCoordinates } from '../lib/observability_overview/get_transaction_coordinates'; +import { hasData } from '../lib/observability_overview/has_data'; import { createRoute } from './create_route'; import { rangeRt } from './default_api_types'; -import { getServiceCount } from '../lib/observability_dashboard/get_service_count'; -import { getTransactionCoordinates } from '../lib/observability_dashboard/get_transaction_coordinates'; -export const observabilityDashboardHasDataRoute = createRoute(() => ({ - path: '/api/apm/observability_dashboard/has_data', +export const observabilityOverviewHasDataRoute = createRoute(() => ({ + path: '/api/apm/observability_overview/has_data', handler: async ({ context, request }) => { const setup = await setupRequest(context, request); return await hasData({ setup }); }, })); -export const observabilityDashboardDataRoute = createRoute(() => ({ - path: '/api/apm/observability_dashboard', +export const observabilityOverviewRoute = createRoute(() => ({ + path: '/api/apm/observability_overview', params: { query: t.intersection([rangeRt, t.type({ bucketSize: t.string })]), }, diff --git a/x-pack/plugins/infra/public/__snapshots__/metrics_overview_fetchers.test.ts.snap b/x-pack/plugins/infra/public/__snapshots__/metrics_overview_fetchers.test.ts.snap index 4680414493a2c..d71e1feb575e4 100644 --- a/x-pack/plugins/infra/public/__snapshots__/metrics_overview_fetchers.test.ts.snap +++ b/x-pack/plugins/infra/public/__snapshots__/metrics_overview_fetchers.test.ts.snap @@ -2,7 +2,7 @@ exports[`Metrics UI Observability Homepage Functions createMetricsFetchData() should just work 1`] = ` Object { - "appLink": "/app/metrics", + "appLink": "/app/metrics/inventory?waffleTime=(currentTime:1593696311629,isAutoReloading:!f)", "series": Object { "inboundTraffic": Object { "coordinates": Array [ @@ -203,6 +203,5 @@ Object { "value": 3, }, }, - "title": "Metrics", } `; diff --git a/x-pack/plugins/infra/public/metrics_overview_fetchers.test.ts b/x-pack/plugins/infra/public/metrics_overview_fetchers.test.ts index 24c51598ad257..88bc426e9a0f7 100644 --- a/x-pack/plugins/infra/public/metrics_overview_fetchers.test.ts +++ b/x-pack/plugins/infra/public/metrics_overview_fetchers.test.ts @@ -53,12 +53,18 @@ describe('Metrics UI Observability Homepage Functions', () => { const { core, mockedGetStartServices } = setup(); core.http.post.mockResolvedValue(FAKE_SNAPSHOT_RESPONSE); const fetchData = createMetricsFetchData(mockedGetStartServices); - const endTime = moment(); + const endTime = moment('2020-07-02T13:25:11.629Z'); const startTime = endTime.clone().subtract(1, 'h'); const bucketSize = '300s'; const response = await fetchData({ - startTime: startTime.toISOString(), - endTime: endTime.toISOString(), + absoluteTime: { + start: startTime.valueOf(), + end: endTime.valueOf(), + }, + relativeTime: { + start: 'now-15m', + end: 'now', + }, bucketSize, }); expect(core.http.post).toHaveBeenCalledTimes(1); diff --git a/x-pack/plugins/infra/public/metrics_overview_fetchers.ts b/x-pack/plugins/infra/public/metrics_overview_fetchers.ts index 25b334d03c4f7..4eaf903e17608 100644 --- a/x-pack/plugins/infra/public/metrics_overview_fetchers.ts +++ b/x-pack/plugins/infra/public/metrics_overview_fetchers.ts @@ -4,15 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ -import moment from 'moment'; -import { sum, isFinite, isNumber } from 'lodash'; -import { i18n } from '@kbn/i18n'; -import { MetricsFetchDataResponse, FetchDataParams } from '../../observability/public'; +import { isFinite, isNumber, sum } from 'lodash'; +import { FetchDataParams, MetricsFetchDataResponse } from '../../observability/public'; import { - SnapshotRequest, SnapshotMetricInput, SnapshotNode, SnapshotNodeResponse, + SnapshotRequest, } from '../common/http_api/snapshot_api'; import { SnapshotMetricType } from '../common/inventory_models/types'; import { InfraClientCoreSetup } from './types'; @@ -77,13 +75,12 @@ export const combineNodeTimeseriesBy = ( export const createMetricsFetchData = ( getStartServices: InfraClientCoreSetup['getStartServices'] -) => async ({ - startTime, - endTime, - bucketSize, -}: FetchDataParams): Promise => { +) => async ({ absoluteTime, bucketSize }: FetchDataParams): Promise => { const [coreServices] = await getStartServices(); const { http } = coreServices; + + const { start, end } = absoluteTime; + const snapshotRequest: SnapshotRequest = { sourceId: 'default', metrics: ['cpu', 'memory', 'rx', 'tx'].map((type) => ({ type })) as SnapshotMetricInput[], @@ -91,8 +88,8 @@ export const createMetricsFetchData = ( nodeType: 'host', includeTimeseries: true, timerange: { - from: moment(startTime).valueOf(), - to: moment(endTime).valueOf(), + from: start, + to: end, interval: bucketSize, forceInterval: true, ignoreLookback: true, @@ -102,12 +99,8 @@ export const createMetricsFetchData = ( const results = await http.post('/api/metrics/snapshot', { body: JSON.stringify(snapshotRequest), }); - return { - title: i18n.translate('xpack.infra.observabilityHomepage.metrics.title', { - defaultMessage: 'Metrics', - }), - appLink: '/app/metrics', + appLink: `/app/metrics/inventory?waffleTime=(currentTime:${end},isAutoReloading:!f)`, stats: { hosts: { type: 'number', diff --git a/x-pack/plugins/infra/public/utils/logs_overview_fetchers.ts b/x-pack/plugins/infra/public/utils/logs_overview_fetchers.ts index 5a0a996287959..53f7e00a3354c 100644 --- a/x-pack/plugins/infra/public/utils/logs_overview_fetchers.ts +++ b/x-pack/plugins/infra/public/utils/logs_overview_fetchers.ts @@ -5,18 +5,17 @@ */ import { encode } from 'rison-node'; -import { i18n } from '@kbn/i18n'; import { SearchResponse } from 'src/plugins/data/public'; -import { DEFAULT_SOURCE_ID } from '../../common/constants'; -import { InfraClientCoreSetup, InfraClientStartDeps } from '../types'; import { FetchData, - LogsFetchDataResponse, - HasData, FetchDataParams, + HasData, + LogsFetchDataResponse, } from '../../../observability/public'; +import { DEFAULT_SOURCE_ID } from '../../common/constants'; import { callFetchLogSourceConfigurationAPI } from '../containers/logs/log_source/api/fetch_log_source_configuration'; import { callFetchLogSourceStatusAPI } from '../containers/logs/log_source/api/fetch_log_source_status'; +import { InfraClientCoreSetup, InfraClientStartDeps } from '../types'; interface StatsAggregation { buckets: Array<{ key: string; doc_count: number }>; @@ -69,15 +68,11 @@ export function getLogsOverviewDataFetcher( data ); - const timeSpanInMinutes = - (Date.parse(params.endTime).valueOf() - Date.parse(params.startTime).valueOf()) / (1000 * 60); + const timeSpanInMinutes = (params.absoluteTime.end - params.absoluteTime.start) / (1000 * 60); return { - title: i18n.translate('xpack.infra.logs.logOverview.logOverviewTitle', { - defaultMessage: 'Logs', - }), - appLink: `/app/logs/stream?logPosition=(end:${encode(params.endTime)},start:${encode( - params.startTime + appLink: `/app/logs/stream?logPosition=(end:${encode(params.relativeTime.end)},start:${encode( + params.relativeTime.start )})`, stats: normalizeStats(stats, timeSpanInMinutes), series: normalizeSeries(series), @@ -122,8 +117,8 @@ function buildLogOverviewQuery(logParams: LogParams, params: FetchDataParams) { return { range: { [logParams.timestampField]: { - gt: params.startTime, - lte: params.endTime, + gt: new Date(params.absoluteTime.start).toISOString(), + lte: new Date(params.absoluteTime.end).toISOString(), format: 'strict_date_optional_time', }, }, diff --git a/x-pack/plugins/observability/public/components/app/section/alerts/index.tsx b/x-pack/plugins/observability/public/components/app/section/alerts/index.tsx index 4c80195d33ace..c0dc67b3373b1 100644 --- a/x-pack/plugins/observability/public/components/app/section/alerts/index.tsx +++ b/x-pack/plugins/observability/public/components/app/section/alerts/index.tsx @@ -44,12 +44,16 @@ export const AlertsSection = ({ alerts }: Props) => { return ( diff --git a/x-pack/plugins/observability/public/components/app/section/apm/index.test.tsx b/x-pack/plugins/observability/public/components/app/section/apm/index.test.tsx index d4b8236e0ef49..7b9d7276dd1c5 100644 --- a/x-pack/plugins/observability/public/components/app/section/apm/index.test.tsx +++ b/x-pack/plugins/observability/public/components/app/section/apm/index.test.tsx @@ -8,6 +8,7 @@ import * as fetcherHook from '../../../../hooks/use_fetcher'; import { render } from '../../../../utils/test_helper'; import { APMSection } from './'; import { response } from './mock_data/apm.mock'; +import moment from 'moment'; describe('APMSection', () => { it('renders with transaction series and stats', () => { @@ -18,8 +19,11 @@ describe('APMSection', () => { }); const { getByText, queryAllByTestId } = render( ); @@ -38,8 +42,11 @@ describe('APMSection', () => { }); const { getByText, queryAllByText, getByTestId } = render( ); diff --git a/x-pack/plugins/observability/public/components/app/section/apm/index.tsx b/x-pack/plugins/observability/public/components/app/section/apm/index.tsx index 697d4adfa0b75..dce80ed324456 100644 --- a/x-pack/plugins/observability/public/components/app/section/apm/index.tsx +++ b/x-pack/plugins/observability/public/components/app/section/apm/index.tsx @@ -21,8 +21,8 @@ import { StyledStat } from '../../styled_stat'; import { onBrushEnd } from '../helper'; interface Props { - startTime?: string; - endTime?: string; + absoluteTime: { start?: number; end?: number }; + relativeTime: { start: string; end: string }; bucketSize?: string; } @@ -30,20 +30,25 @@ function formatTpm(value?: number) { return numeral(value).format('0.00a'); } -export const APMSection = ({ startTime, endTime, bucketSize }: Props) => { +export const APMSection = ({ absoluteTime, relativeTime, bucketSize }: Props) => { const theme = useContext(ThemeContext); const history = useHistory(); + const { start, end } = absoluteTime; const { data, status } = useFetcher(() => { - if (startTime && endTime && bucketSize) { - return getDataHandler('apm')?.fetchData({ startTime, endTime, bucketSize }); + if (start && end && bucketSize) { + return getDataHandler('apm')?.fetchData({ + absoluteTime: { start, end }, + relativeTime, + bucketSize, + }); } - }, [startTime, endTime, bucketSize]); + }, [start, end, bucketSize]); - const { title = 'APM', appLink, stats, series } = data || {}; + const { appLink, stats, series } = data || {}; - const min = moment.utc(startTime).valueOf(); - const max = moment.utc(endTime).valueOf(); + const min = moment.utc(absoluteTime.start).valueOf(); + const max = moment.utc(absoluteTime.end).valueOf(); const formatter = niceTimeFormatter([min, max]); @@ -53,8 +58,15 @@ export const APMSection = ({ startTime, endTime, bucketSize }: Props) => { return ( diff --git a/x-pack/plugins/observability/public/components/app/section/apm/mock_data/apm.mock.ts b/x-pack/plugins/observability/public/components/app/section/apm/mock_data/apm.mock.ts index 5857021b1537f..edc236c714d32 100644 --- a/x-pack/plugins/observability/public/components/app/section/apm/mock_data/apm.mock.ts +++ b/x-pack/plugins/observability/public/components/app/section/apm/mock_data/apm.mock.ts @@ -7,8 +7,6 @@ import { ApmFetchDataResponse } from '../../../../../typings'; export const response: ApmFetchDataResponse = { - title: 'APM', - appLink: '/app/apm', stats: { services: { value: 11, type: 'number' }, diff --git a/x-pack/plugins/observability/public/components/app/section/index.test.tsx b/x-pack/plugins/observability/public/components/app/section/index.test.tsx index 49cb175d0c094..708a5e468dc7c 100644 --- a/x-pack/plugins/observability/public/components/app/section/index.test.tsx +++ b/x-pack/plugins/observability/public/components/app/section/index.test.tsx @@ -20,13 +20,13 @@ describe('SectionContainer', () => { }); it('renders section with app link', () => { const component = render( - +
I am a very nice component
); expect(component.getByText('I am a very nice component')).toBeInTheDocument(); expect(component.getByText('Foo')).toBeInTheDocument(); - expect(component.getByText('View in app')).toBeInTheDocument(); + expect(component.getByText('foo')).toBeInTheDocument(); }); it('renders section with error', () => { const component = render( diff --git a/x-pack/plugins/observability/public/components/app/section/index.tsx b/x-pack/plugins/observability/public/components/app/section/index.tsx index 3556e8c01ab30..9ba524259ea1c 100644 --- a/x-pack/plugins/observability/public/components/app/section/index.tsx +++ b/x-pack/plugins/observability/public/components/app/section/index.tsx @@ -4,21 +4,23 @@ * you may not use this file except in compliance with the Elastic License. */ import { EuiAccordion, EuiLink, EuiPanel, EuiSpacer, EuiText, EuiTitle } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; import React from 'react'; import { ErrorPanel } from './error_panel'; import { usePluginContext } from '../../../hooks/use_plugin_context'; +interface AppLink { + label: string; + href?: string; +} + interface Props { title: string; hasError: boolean; children: React.ReactNode; - minHeight?: number; - appLink?: string; - appLinkName?: string; + appLink?: AppLink; } -export const SectionContainer = ({ title, appLink, children, hasError, appLinkName }: Props) => { +export const SectionContainer = ({ title, appLink, children, hasError }: Props) => { const { core } = usePluginContext(); return ( } extraAction={ - appLink && ( - - - {appLinkName - ? appLinkName - : i18n.translate('xpack.observability.chart.viewInAppLabel', { - defaultMessage: 'View in app', - })} - + appLink?.href && ( + + {appLink.label} ) } diff --git a/x-pack/plugins/observability/public/components/app/section/logs/index.tsx b/x-pack/plugins/observability/public/components/app/section/logs/index.tsx index f3ba2ef6fa83a..9b232ea33cbfb 100644 --- a/x-pack/plugins/observability/public/components/app/section/logs/index.tsx +++ b/x-pack/plugins/observability/public/components/app/section/logs/index.tsx @@ -25,8 +25,8 @@ import { StyledStat } from '../../styled_stat'; import { onBrushEnd } from '../helper'; interface Props { - startTime?: string; - endTime?: string; + absoluteTime: { start?: number; end?: number }; + relativeTime: { start: string; end: string }; bucketSize?: string; } @@ -45,21 +45,26 @@ function getColorPerItem(series?: LogsFetchDataResponse['series']) { return colorsPerItem; } -export const LogsSection = ({ startTime, endTime, bucketSize }: Props) => { +export const LogsSection = ({ absoluteTime, relativeTime, bucketSize }: Props) => { const history = useHistory(); + const { start, end } = absoluteTime; const { data, status } = useFetcher(() => { - if (startTime && endTime && bucketSize) { - return getDataHandler('infra_logs')?.fetchData({ startTime, endTime, bucketSize }); + if (start && end && bucketSize) { + return getDataHandler('infra_logs')?.fetchData({ + absoluteTime: { start, end }, + relativeTime, + bucketSize, + }); } - }, [startTime, endTime, bucketSize]); + }, [start, end, bucketSize]); - const min = moment.utc(startTime).valueOf(); - const max = moment.utc(endTime).valueOf(); + const min = moment.utc(absoluteTime.start).valueOf(); + const max = moment.utc(absoluteTime.end).valueOf(); const formatter = niceTimeFormatter([min, max]); - const { title, appLink, stats, series } = data || {}; + const { appLink, stats, series } = data || {}; const colorsPerItem = getColorPerItem(series); @@ -67,8 +72,15 @@ export const LogsSection = ({ startTime, endTime, bucketSize }: Props) => { return ( diff --git a/x-pack/plugins/observability/public/components/app/section/metrics/index.tsx b/x-pack/plugins/observability/public/components/app/section/metrics/index.tsx index 6276e1ba1baca..9e5fdadaf4e5f 100644 --- a/x-pack/plugins/observability/public/components/app/section/metrics/index.tsx +++ b/x-pack/plugins/observability/public/components/app/section/metrics/index.tsx @@ -18,8 +18,8 @@ import { ChartContainer } from '../../chart_container'; import { StyledStat } from '../../styled_stat'; interface Props { - startTime?: string; - endTime?: string; + absoluteTime: { start?: number; end?: number }; + relativeTime: { start: string; end: string }; bucketSize?: string; } @@ -46,17 +46,23 @@ const StyledProgress = styled.div<{ color?: string }>` } `; -export const MetricsSection = ({ startTime, endTime, bucketSize }: Props) => { +export const MetricsSection = ({ absoluteTime, relativeTime, bucketSize }: Props) => { const theme = useContext(ThemeContext); + + const { start, end } = absoluteTime; const { data, status } = useFetcher(() => { - if (startTime && endTime && bucketSize) { - return getDataHandler('infra_metrics')?.fetchData({ startTime, endTime, bucketSize }); + if (start && end && bucketSize) { + return getDataHandler('infra_metrics')?.fetchData({ + absoluteTime: { start, end }, + relativeTime, + bucketSize, + }); } - }, [startTime, endTime, bucketSize]); + }, [start, end, bucketSize]); const isLoading = status === FETCH_STATUS.LOADING; - const { title = 'Metrics', appLink, stats, series } = data || {}; + const { appLink, stats, series } = data || {}; const cpuColor = theme.eui.euiColorVis7; const memoryColor = theme.eui.euiColorVis0; @@ -65,9 +71,15 @@ export const MetricsSection = ({ startTime, endTime, bucketSize }: Props) => { return ( diff --git a/x-pack/plugins/observability/public/components/app/section/uptime/index.tsx b/x-pack/plugins/observability/public/components/app/section/uptime/index.tsx index 1f8ca6e61f132..73a566460a593 100644 --- a/x-pack/plugins/observability/public/components/app/section/uptime/index.tsx +++ b/x-pack/plugins/observability/public/components/app/section/uptime/index.tsx @@ -30,37 +30,49 @@ import { StyledStat } from '../../styled_stat'; import { onBrushEnd } from '../helper'; interface Props { - startTime?: string; - endTime?: string; + absoluteTime: { start?: number; end?: number }; + relativeTime: { start: string; end: string }; bucketSize?: string; } -export const UptimeSection = ({ startTime, endTime, bucketSize }: Props) => { +export const UptimeSection = ({ absoluteTime, relativeTime, bucketSize }: Props) => { const theme = useContext(ThemeContext); const history = useHistory(); + const { start, end } = absoluteTime; const { data, status } = useFetcher(() => { - if (startTime && endTime && bucketSize) { - return getDataHandler('uptime')?.fetchData({ startTime, endTime, bucketSize }); + if (start && end && bucketSize) { + return getDataHandler('uptime')?.fetchData({ + absoluteTime: { start, end }, + relativeTime, + bucketSize, + }); } - }, [startTime, endTime, bucketSize]); + }, [start, end, bucketSize]); + + const min = moment.utc(absoluteTime.start).valueOf(); + const max = moment.utc(absoluteTime.end).valueOf(); - const min = moment.utc(startTime).valueOf(); - const max = moment.utc(endTime).valueOf(); const formatter = niceTimeFormatter([min, max]); const isLoading = status === FETCH_STATUS.LOADING; - const { title = 'Uptime', appLink, stats, series } = data || {}; + const { appLink, stats, series } = data || {}; const downColor = theme.eui.euiColorVis2; const upColor = theme.eui.euiColorLightShade; return ( diff --git a/x-pack/plugins/observability/public/data_handler.test.ts b/x-pack/plugins/observability/public/data_handler.test.ts index 71c2c942239fd..7170ffe1486dc 100644 --- a/x-pack/plugins/observability/public/data_handler.test.ts +++ b/x-pack/plugins/observability/public/data_handler.test.ts @@ -4,10 +4,17 @@ * you may not use this file except in compliance with the Elastic License. */ import { registerDataHandler, getDataHandler } from './data_handler'; +import moment from 'moment'; const params = { - startTime: '0', - endTime: '1', + absoluteTime: { + start: moment('2020-07-02T13:25:11.629Z').valueOf(), + end: moment('2020-07-09T13:25:11.629Z').valueOf(), + }, + relativeTime: { + start: 'now-15m', + end: 'now', + }, bucketSize: '10s', }; diff --git a/x-pack/plugins/observability/public/pages/overview/index.tsx b/x-pack/plugins/observability/public/pages/overview/index.tsx index 3674e69ab5702..088fab032d930 100644 --- a/x-pack/plugins/observability/public/pages/overview/index.tsx +++ b/x-pack/plugins/observability/public/pages/overview/index.tsx @@ -4,7 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ import { EuiFlexGrid, EuiFlexGroup, EuiFlexItem, EuiHorizontalRule, EuiSpacer } from '@elastic/eui'; -import moment from 'moment'; import React, { useContext } from 'react'; import { ThemeContext } from 'styled-components'; import { EmptySection } from '../../components/app/empty_section'; @@ -23,7 +22,7 @@ import { UI_SETTINGS, useKibanaUISettings } from '../../hooks/use_kibana_ui_sett import { usePluginContext } from '../../hooks/use_plugin_context'; import { RouteParams } from '../../routes'; import { getObservabilityAlerts } from '../../services/get_observability_alerts'; -import { getParsedDate } from '../../utils/date'; +import { getAbsoluteTime } from '../../utils/date'; import { getBucketSize } from '../../utils/get_bucket_size'; import { getEmptySections } from './empty_section'; import { LoadingObservability } from './loading_observability'; @@ -33,13 +32,9 @@ interface Props { routeParams: RouteParams<'/overview'>; } -function calculatetBucketSize({ startTime, endTime }: { startTime?: string; endTime?: string }) { - if (startTime && endTime) { - return getBucketSize({ - start: moment.utc(startTime).valueOf(), - end: moment.utc(endTime).valueOf(), - minInterval: '60s', - }); +function calculatetBucketSize({ start, end }: { start?: number; end?: number }) { + if (start && end) { + return getBucketSize({ start, end, minInterval: '60s' }); } } @@ -62,16 +57,22 @@ export const OverviewPage = ({ routeParams }: Props) => { return ; } - const { - rangeFrom = timePickerTime.from, - rangeTo = timePickerTime.to, - refreshInterval = 10000, - refreshPaused = true, - } = routeParams.query; + const { refreshInterval = 10000, refreshPaused = true } = routeParams.query; - const startTime = getParsedDate(rangeFrom); - const endTime = getParsedDate(rangeTo, { roundUp: true }); - const bucketSize = calculatetBucketSize({ startTime, endTime }); + const relativeTime = { + start: routeParams.query.rangeFrom ?? timePickerTime.from, + end: routeParams.query.rangeTo ?? timePickerTime.to, + }; + + const absoluteTime = { + start: getAbsoluteTime(relativeTime.start), + end: getAbsoluteTime(relativeTime.end, { roundUp: true }), + }; + + const bucketSize = calculatetBucketSize({ + start: absoluteTime.start, + end: absoluteTime.end, + }); const appEmptySections = getEmptySections({ core }).filter(({ id }) => { if (id === 'alert') { @@ -93,8 +94,8 @@ export const OverviewPage = ({ routeParams }: Props) => { @@ -116,8 +117,8 @@ export const OverviewPage = ({ routeParams }: Props) => { {hasData.infra_logs && ( @@ -125,8 +126,8 @@ export const OverviewPage = ({ routeParams }: Props) => { {hasData.infra_metrics && ( @@ -134,8 +135,8 @@ export const OverviewPage = ({ routeParams }: Props) => { {hasData.apm && ( @@ -143,8 +144,8 @@ export const OverviewPage = ({ routeParams }: Props) => { {hasData.uptime && ( diff --git a/x-pack/plugins/observability/public/pages/overview/mock/apm.mock.ts b/x-pack/plugins/observability/public/pages/overview/mock/apm.mock.ts index 7303b78cc0132..6a0e1a64aa115 100644 --- a/x-pack/plugins/observability/public/pages/overview/mock/apm.mock.ts +++ b/x-pack/plugins/observability/public/pages/overview/mock/apm.mock.ts @@ -10,7 +10,6 @@ export const fetchApmData: FetchData = () => { }; const response: ApmFetchDataResponse = { - title: 'APM', appLink: '/app/apm', stats: { services: { @@ -607,7 +606,6 @@ const response: ApmFetchDataResponse = { }; export const emptyResponse: ApmFetchDataResponse = { - title: 'APM', appLink: '/app/apm', stats: { services: { diff --git a/x-pack/plugins/observability/public/pages/overview/mock/logs.mock.ts b/x-pack/plugins/observability/public/pages/overview/mock/logs.mock.ts index 5bea1fbf19ace..8d1fb4d59c2cc 100644 --- a/x-pack/plugins/observability/public/pages/overview/mock/logs.mock.ts +++ b/x-pack/plugins/observability/public/pages/overview/mock/logs.mock.ts @@ -11,7 +11,6 @@ export const fetchLogsData: FetchData = () => { }; const response: LogsFetchDataResponse = { - title: 'Logs', appLink: "/app/logs/stream?logPosition=(end:'2020-06-30T21:30:00.000Z',start:'2020-06-27T22:00:00.000Z')", stats: { @@ -2319,7 +2318,6 @@ const response: LogsFetchDataResponse = { }; export const emptyResponse: LogsFetchDataResponse = { - title: 'Logs', appLink: '/app/logs', stats: {}, series: {}, diff --git a/x-pack/plugins/observability/public/pages/overview/mock/metrics.mock.ts b/x-pack/plugins/observability/public/pages/overview/mock/metrics.mock.ts index 37233b4f6342c..d5a7992ceabd8 100644 --- a/x-pack/plugins/observability/public/pages/overview/mock/metrics.mock.ts +++ b/x-pack/plugins/observability/public/pages/overview/mock/metrics.mock.ts @@ -11,7 +11,6 @@ export const fetchMetricsData: FetchData = () => { }; const response: MetricsFetchDataResponse = { - title: 'Metrics', appLink: '/app/apm', stats: { hosts: { value: 11, type: 'number' }, @@ -113,7 +112,6 @@ const response: MetricsFetchDataResponse = { }; export const emptyResponse: MetricsFetchDataResponse = { - title: 'Metrics', appLink: '/app/apm', stats: { hosts: { value: 0, type: 'number' }, diff --git a/x-pack/plugins/observability/public/pages/overview/mock/uptime.mock.ts b/x-pack/plugins/observability/public/pages/overview/mock/uptime.mock.ts index ab5874f8bfcd4..c4fa09ceb11f7 100644 --- a/x-pack/plugins/observability/public/pages/overview/mock/uptime.mock.ts +++ b/x-pack/plugins/observability/public/pages/overview/mock/uptime.mock.ts @@ -10,7 +10,6 @@ export const fetchUptimeData: FetchData = () => { }; const response: UptimeFetchDataResponse = { - title: 'Uptime', appLink: '/app/uptime#/', stats: { monitors: { @@ -1191,7 +1190,6 @@ const response: UptimeFetchDataResponse = { }; export const emptyResponse: UptimeFetchDataResponse = { - title: 'Uptime', appLink: '/app/uptime#/', stats: { monitors: { diff --git a/x-pack/plugins/observability/public/typings/fetch_overview_data/index.ts b/x-pack/plugins/observability/public/typings/fetch_overview_data/index.ts index 2dafd70896cc5..a3d7308ff9e4a 100644 --- a/x-pack/plugins/observability/public/typings/fetch_overview_data/index.ts +++ b/x-pack/plugins/observability/public/typings/fetch_overview_data/index.ts @@ -21,11 +21,8 @@ export interface Series { } export interface FetchDataParams { - // The start timestamp in milliseconds of the queried time interval - startTime: string; - // The end timestamp in milliseconds of the queried time interval - endTime: string; - // The aggregation bucket size in milliseconds if applicable to the data source + absoluteTime: { start: number; end: number }; + relativeTime: { start: string; end: string }; bucketSize: string; } @@ -41,7 +38,6 @@ export interface DataHandler { } export interface FetchDataResponse { - title: string; appLink: string; } diff --git a/x-pack/plugins/observability/public/utils/date.ts b/x-pack/plugins/observability/public/utils/date.ts index fc0bbdae20cb9..bdc89ad6e8fc0 100644 --- a/x-pack/plugins/observability/public/utils/date.ts +++ b/x-pack/plugins/observability/public/utils/date.ts @@ -5,11 +5,9 @@ */ import datemath from '@elastic/datemath'; -export function getParsedDate(range?: string, opts = {}) { - if (range) { - const parsed = datemath.parse(range, opts); - if (parsed) { - return parsed.toISOString(); - } +export function getAbsoluteTime(range: string, opts = {}) { + const parsed = datemath.parse(range, opts); + if (parsed) { + return parsed.valueOf(); } } diff --git a/x-pack/plugins/uptime/public/apps/uptime_overview_fetcher.ts b/x-pack/plugins/uptime/public/apps/uptime_overview_fetcher.ts index 89720b275c63d..d1e394dd4da6b 100644 --- a/x-pack/plugins/uptime/public/apps/uptime_overview_fetcher.ts +++ b/x-pack/plugins/uptime/public/apps/uptime_overview_fetcher.ts @@ -5,27 +5,24 @@ */ import { fetchPingHistogram, fetchSnapshotCount } from '../state/api'; -import { UptimeFetchDataResponse } from '../../../observability/public'; +import { UptimeFetchDataResponse, FetchDataParams } from '../../../observability/public'; export async function fetchUptimeOverviewData({ - startTime, - endTime, + absoluteTime, + relativeTime, bucketSize, -}: { - startTime: string; - endTime: string; - bucketSize: string; -}) { +}: FetchDataParams) { + const start = new Date(absoluteTime.start).toISOString(); + const end = new Date(absoluteTime.end).toISOString(); const snapshot = await fetchSnapshotCount({ - dateRangeStart: startTime, - dateRangeEnd: endTime, + dateRangeStart: start, + dateRangeEnd: end, }); - const pings = await fetchPingHistogram({ dateStart: startTime, dateEnd: endTime, bucketSize }); + const pings = await fetchPingHistogram({ dateStart: start, dateEnd: end, bucketSize }); const response: UptimeFetchDataResponse = { - title: 'Uptime', - appLink: '/app/uptime#/', + appLink: `/app/uptime#/?dateRangeStart=${relativeTime.start}&dateRangeEnd=${relativeTime.end}`, stats: { monitors: { type: 'number',