+
+
+
+
+
+
+
+ }
+ />
+ >
+ }
+ widgetKey="sessionsPercentage"
+ indexPattern={indexPattern}
+ globalFilter={globalFilter}
+ dataValueMap={{
+ true: {
+ name: i18n.translate('xpack.kubernetesSecurity.sessionsChart.interactive', {
+ defaultMessage: 'Interactive',
+ }),
+ fieldName: ENTRY_LEADER_INTERACTIVE,
+ color: euiThemeVars.euiColorVis0,
+ },
+ false: {
+ name: i18n.translate('xpack.kubernetesSecurity.sessionsChart.nonInteractive', {
+ defaultMessage: 'Non-interactive',
+ }),
+ fieldName: ENTRY_LEADER_INTERACTIVE,
+ color: euiThemeVars.euiColorVis1,
+ },
+ }}
+ groupedBy={ENTRY_LEADER_INTERACTIVE}
+ countBy={ENTRY_LEADER_ENTITY_ID}
+ onReduce={onReduceInteractiveAggs}
+ />
+
+
+
+
+
+
+
+ }
+ />
+ >
+ }
+ widgetKey="rootLoginPercentage"
+ indexPattern={indexPattern}
+ globalFilter={globalFilter}
+ dataValueMap={{
+ '0': {
+ name: i18n.translate('xpack.kubernetesSecurity.userLoginChart.root', {
+ defaultMessage: 'Root',
+ }),
+ fieldName: ENTRY_LEADER_USER_ID,
+ color: euiThemeVars.euiColorVis2,
+ },
+ nonRoot: {
+ name: i18n.translate('xpack.kubernetesSecurity.userLoginChart.nonRoot', {
+ defaultMessage: 'Non-root',
+ }),
+ fieldName: ENTRY_LEADER_USER_ID,
+ color: euiThemeVars.euiColorVis3,
+ shouldHideFilter: true,
+ },
+ }}
+ groupedBy={ENTRY_LEADER_USER_ID}
+ countBy={ENTRY_LEADER_ENTITY_ID}
+ onReduce={onReduceRootAggs}
+ />
+
+
+
diff --git a/x-pack/plugins/kubernetes_security/public/components/kubernetes_security_routes/styles.ts b/x-pack/plugins/kubernetes_security/public/components/kubernetes_security_routes/styles.ts
new file mode 100644
index 0000000000000..a6725007fb5f7
--- /dev/null
+++ b/x-pack/plugins/kubernetes_security/public/components/kubernetes_security_routes/styles.ts
@@ -0,0 +1,56 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import { useMemo } from 'react';
+import { CSSObject } from '@emotion/react';
+import { useEuiTheme } from '../../hooks';
+
+export const useStyles = () => {
+ const { euiTheme } = useEuiTheme();
+
+ const cached = useMemo(() => {
+ const { size, font } = euiTheme;
+
+ const widgetBadge: CSSObject = {
+ position: 'absolute',
+ bottom: size.base,
+ left: size.base,
+ width: `calc(100% - ${size.xl})`,
+ fontSize: size.m,
+ lineHeight: '18px',
+ padding: `${size.xs} ${size.s}`,
+ display: 'flex',
+ };
+
+ const treeViewContainer: CSSObject = {
+ position: 'relative',
+ border: euiTheme.border.thin,
+ borderRadius: euiTheme.border.radius.medium,
+ padding: size.base,
+ height: '500px',
+ };
+
+ const percentageWidgets: CSSObject = {
+ marginBottom: size.l,
+ };
+
+ const percentageChartTitle: CSSObject = {
+ marginRight: size.xs,
+ display: 'inline',
+ fontWeight: font.weight.bold,
+ };
+
+ return {
+ widgetBadge,
+ treeViewContainer,
+ percentageWidgets,
+ percentageChartTitle,
+ };
+ }, [euiTheme]);
+
+ return cached;
+};
diff --git a/x-pack/plugins/kubernetes_security/public/components/percent_widget/hooks.ts b/x-pack/plugins/kubernetes_security/public/components/percent_widget/hooks.ts
new file mode 100644
index 0000000000000..decdb0c6e27a9
--- /dev/null
+++ b/x-pack/plugins/kubernetes_security/public/components/percent_widget/hooks.ts
@@ -0,0 +1,46 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+import { useQuery } from 'react-query';
+import { CoreStart } from '@kbn/core/public';
+import { useKibana } from '@kbn/kibana-react-plugin/public';
+import { QUERY_KEY_PERCENT_WIDGET, AGGREGATE_ROUTE } from '../../../common/constants';
+import { AggregateResult } from '../../../common/types/aggregate';
+
+export const useFetchPercentWidgetData = (
+ onReduce: (result: AggregateResult[]) => Record
,
+ filterQuery: string,
+ widgetKey: string,
+ groupBy: string,
+ countBy?: string,
+ index?: string
+) => {
+ const { http } = useKibana().services;
+ const cachingKeys = [QUERY_KEY_PERCENT_WIDGET, widgetKey, filterQuery, groupBy, countBy, index];
+ const query = useQuery(
+ cachingKeys,
+ async (): Promise> => {
+ const res = await http.get(AGGREGATE_ROUTE, {
+ query: {
+ query: filterQuery,
+ groupBy,
+ countBy,
+ page: 0,
+ index,
+ },
+ });
+
+ return onReduce(res);
+ },
+ {
+ refetchOnWindowFocus: false,
+ refetchOnMount: false,
+ refetchOnReconnect: false,
+ }
+ );
+
+ return query;
+};
diff --git a/x-pack/plugins/kubernetes_security/public/components/percent_widget/index.test.tsx b/x-pack/plugins/kubernetes_security/public/components/percent_widget/index.test.tsx
new file mode 100644
index 0000000000000..234256e2d7dd4
--- /dev/null
+++ b/x-pack/plugins/kubernetes_security/public/components/percent_widget/index.test.tsx
@@ -0,0 +1,120 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import React from 'react';
+import { ENTRY_LEADER_INTERACTIVE } from '../../../common/constants';
+import { AppContextTestRender, createAppRootMockRenderer } from '../../test';
+import { GlobalFilter } from '../../types';
+import { PercentWidget, LOADING_TEST_ID, PERCENT_DATA_TEST_ID } from '.';
+import { useFetchPercentWidgetData } from './hooks';
+
+const MOCK_DATA: Record = {
+ false: 47,
+ true: 1,
+};
+const TITLE = 'Percent Widget Title';
+const GLOBAL_FILTER: GlobalFilter = {
+ endDate: '2022-06-15T14:15:25.777Z',
+ filterQuery: '{"bool":{"must":[],"filter":[],"should":[],"must_not":[]}}',
+ startDate: '2022-05-15T14:15:25.777Z',
+};
+const DATA_VALUE_MAP = {
+ true: {
+ name: 'Interactive',
+ fieldName: ENTRY_LEADER_INTERACTIVE,
+ color: 'red',
+ },
+ false: {
+ name: 'Non-interactive',
+ fieldName: ENTRY_LEADER_INTERACTIVE,
+ color: 'blue',
+ },
+};
+
+jest.mock('../../hooks/use_filter', () => ({
+ useSetFilter: () => ({
+ getFilterForValueButton: jest.fn(),
+ getFilterOutValueButton: jest.fn(),
+ filterManager: {},
+ }),
+}));
+
+jest.mock('./hooks');
+const mockUseFetchData = useFetchPercentWidgetData as jest.Mock;
+
+describe('PercentWidget component', () => {
+ let renderResult: ReturnType;
+ const mockedContext = createAppRootMockRenderer();
+ const render: () => ReturnType = () =>
+ (renderResult = mockedContext.render(
+
+ ));
+
+ describe('When PercentWidget is mounted', () => {
+ describe('with data', () => {
+ beforeEach(() => {
+ mockUseFetchData.mockImplementation(() => ({
+ data: MOCK_DATA,
+ isLoading: false,
+ }));
+ });
+
+ it('should show title', async () => {
+ render();
+
+ expect(renderResult.getByText(TITLE)).toBeVisible();
+ });
+ it('should show data value names and value', async () => {
+ render();
+
+ expect(renderResult.getByText('Interactive')).toBeVisible();
+ expect(renderResult.getByText('Non-interactive')).toBeVisible();
+ expect(renderResult.queryByTestId(LOADING_TEST_ID)).toBeNull();
+ expect(renderResult.getByText(47)).toBeVisible();
+ expect(renderResult.getByText(1)).toBeVisible();
+ });
+ it('should show same number of data items as the number of records provided in dataValueMap', async () => {
+ render();
+
+ expect(renderResult.getAllByTestId(PERCENT_DATA_TEST_ID)).toHaveLength(2);
+ });
+ });
+
+ describe('without data ', () => {
+ it('should show data value names and zeros as values when loading', async () => {
+ mockUseFetchData.mockImplementation(() => ({
+ data: undefined,
+ isLoading: true,
+ }));
+ render();
+
+ expect(renderResult.getByText('Interactive')).toBeVisible();
+ expect(renderResult.getByText('Non-interactive')).toBeVisible();
+ expect(renderResult.getByTestId(LOADING_TEST_ID)).toBeVisible();
+ expect(renderResult.getAllByText(0)).toHaveLength(2);
+ });
+ it('should show zeros as values if no data returned', async () => {
+ mockUseFetchData.mockImplementation(() => ({
+ data: undefined,
+ isLoading: false,
+ }));
+ render();
+
+ expect(renderResult.getByText('Interactive')).toBeVisible();
+ expect(renderResult.getByText('Non-interactive')).toBeVisible();
+ expect(renderResult.getAllByText(0)).toHaveLength(2);
+ });
+ });
+ });
+});
diff --git a/x-pack/plugins/kubernetes_security/public/components/percent_widget/index.tsx b/x-pack/plugins/kubernetes_security/public/components/percent_widget/index.tsx
new file mode 100644
index 0000000000000..a5eac7bca92b3
--- /dev/null
+++ b/x-pack/plugins/kubernetes_security/public/components/percent_widget/index.tsx
@@ -0,0 +1,161 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import React, { ReactNode, useMemo, useState } from 'react';
+import { EuiFlexGroup, EuiFlexItem, EuiProgress, EuiText } from '@elastic/eui';
+import { useStyles } from './styles';
+import type { IndexPattern, GlobalFilter } from '../../types';
+import { useSetFilter } from '../../hooks';
+import { addTimerangeToQuery } from '../../utils/add_timerange_to_query';
+import { AggregateResult } from '../../../common/types/aggregate';
+import { useFetchPercentWidgetData } from './hooks';
+
+export const LOADING_TEST_ID = 'kubernetesSecurity:percent-widget-loading';
+export const PERCENT_DATA_TEST_ID = 'kubernetesSecurity:percentage-widget-data';
+
+export interface PercenWidgetDataValueMap {
+ name: string;
+ fieldName: string;
+ color: string;
+ shouldHideFilter?: boolean;
+}
+
+export interface PercentWidgetDeps {
+ title: ReactNode;
+ dataValueMap: Record;
+ widgetKey: string;
+ indexPattern?: IndexPattern;
+ globalFilter: GlobalFilter;
+ groupedBy: string;
+ countBy?: string;
+ onReduce: (result: AggregateResult[]) => Record;
+}
+
+interface FilterButtons {
+ filterForButtons: ReactNode[];
+ filterOutButtons: ReactNode[];
+}
+
+export const PercentWidget = ({
+ title,
+ dataValueMap,
+ widgetKey,
+ indexPattern,
+ globalFilter,
+ groupedBy,
+ countBy,
+ onReduce,
+}: PercentWidgetDeps) => {
+ const [hoveredFilter, setHoveredFilter] = useState(null);
+ const styles = useStyles();
+
+ const filterQueryWithTimeRange = useMemo(() => {
+ return addTimerangeToQuery(
+ globalFilter.filterQuery,
+ globalFilter.startDate,
+ globalFilter.endDate
+ );
+ }, [globalFilter.filterQuery, globalFilter.startDate, globalFilter.endDate]);
+
+ const { data, isLoading } = useFetchPercentWidgetData(
+ onReduce,
+ filterQueryWithTimeRange,
+ widgetKey,
+ groupedBy,
+ countBy,
+ indexPattern?.title
+ );
+
+ const { getFilterForValueButton, getFilterOutValueButton, filterManager } = useSetFilter();
+ const dataValueSum = useMemo(
+ () => (data ? Object.keys(data).reduce((sumSoFar, current) => sumSoFar + data[current], 0) : 0),
+ [data]
+ );
+ const filterButtons = useMemo(() => {
+ const result: FilterButtons = {
+ filterForButtons: [],
+ filterOutButtons: [],
+ };
+ Object.keys(dataValueMap).forEach((groupedByValue) => {
+ if (!dataValueMap[groupedByValue].shouldHideFilter) {
+ result.filterForButtons.push(
+ getFilterForValueButton({
+ field: dataValueMap[groupedByValue].fieldName,
+ filterManager,
+ size: 'xs',
+ onClick: () => {},
+ onFilterAdded: () => {},
+ ownFocus: false,
+ showTooltip: true,
+ value: [groupedByValue],
+ })
+ );
+ result.filterOutButtons.push(
+ getFilterOutValueButton({
+ field: dataValueMap[groupedByValue].fieldName,
+ filterManager,
+ size: 'xs',
+ onClick: () => {},
+ onFilterAdded: () => {},
+ ownFocus: false,
+ showTooltip: true,
+ value: [groupedByValue],
+ })
+ );
+ }
+ });
+
+ return result;
+ }, [dataValueMap, filterManager, getFilterForValueButton, getFilterOutValueButton]);
+
+ return (
+
+ {isLoading && (
+
+ )}
+
{title}
+
+ {Object.keys(dataValueMap).map((groupedByValue, idx) => {
+ const value = data?.[groupedByValue] || 0;
+ return (
+ setHoveredFilter(idx)}
+ onMouseLeave={() => setHoveredFilter(null)}
+ data-test-subj={PERCENT_DATA_TEST_ID}
+ >
+
+ {dataValueMap[groupedByValue].name}
+ {hoveredFilter === idx && (
+
+ {filterButtons.filterForButtons[idx]}
+ {filterButtons.filterOutButtons[idx]}
+
+ )}
+ {value}
+
+
+
+ );
+ })}
+
+
+ );
+};
diff --git a/x-pack/plugins/kubernetes_security/public/components/percent_widget/styles.ts b/x-pack/plugins/kubernetes_security/public/components/percent_widget/styles.ts
new file mode 100644
index 0000000000000..5e90d7c946f92
--- /dev/null
+++ b/x-pack/plugins/kubernetes_security/public/components/percent_widget/styles.ts
@@ -0,0 +1,77 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import { useMemo } from 'react';
+import { CSSObject } from '@emotion/react';
+import { useEuiTheme } from '../../hooks';
+
+export const useStyles = () => {
+ const { euiTheme } = useEuiTheme();
+
+ const cached = useMemo(() => {
+ const { size, colors, font, border } = euiTheme;
+
+ const container: CSSObject = {
+ padding: size.base,
+ border: euiTheme.border.thin,
+ borderRadius: euiTheme.border.radius.medium,
+ overflow: 'auto',
+ position: 'relative',
+ };
+
+ const title: CSSObject = {
+ marginBottom: size.m,
+ };
+
+ const dataInfo: CSSObject = {
+ marginBottom: size.xs,
+ display: 'flex',
+ alignItems: 'center',
+ height: '18px',
+ };
+
+ const dataValue: CSSObject = {
+ fontWeight: font.weight.semiBold,
+ marginLeft: 'auto',
+ };
+
+ const filters: CSSObject = {
+ marginLeft: size.s,
+ };
+
+ const percentageBackground: CSSObject = {
+ position: 'relative',
+ backgroundColor: colors.lightShade,
+ height: size.xs,
+ borderRadius: border.radius.small,
+ };
+
+ const percentageBar: CSSObject = {
+ position: 'absolute',
+ height: size.xs,
+ borderRadius: border.radius.small,
+ };
+
+ const loadingSpinner: CSSObject = {
+ alignItems: 'center',
+ margin: `${size.xs} auto ${size.xl} auto`,
+ };
+
+ return {
+ container,
+ title,
+ dataInfo,
+ dataValue,
+ filters,
+ percentageBackground,
+ percentageBar,
+ loadingSpinner,
+ };
+ }, [euiTheme]);
+
+ return cached;
+};
diff --git a/x-pack/plugins/kubernetes_security/public/hooks/index.ts b/x-pack/plugins/kubernetes_security/public/hooks/index.ts
new file mode 100644
index 0000000000000..1f63ff5b670e5
--- /dev/null
+++ b/x-pack/plugins/kubernetes_security/public/hooks/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
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+export { useEuiTheme } from './use_eui_theme';
+export { useSetFilter } from './use_filter';
diff --git a/x-pack/plugins/kubernetes_security/public/hooks/use_eui_theme.ts b/x-pack/plugins/kubernetes_security/public/hooks/use_eui_theme.ts
new file mode 100644
index 0000000000000..02f7dd479d2ac
--- /dev/null
+++ b/x-pack/plugins/kubernetes_security/public/hooks/use_eui_theme.ts
@@ -0,0 +1,46 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import { shade, useEuiTheme as useEuiThemeHook } from '@elastic/eui';
+import { euiLightVars, euiDarkVars } from '@kbn/ui-theme';
+import { useMemo } from 'react';
+
+type EuiThemeProps = Parameters;
+type ExtraEuiVars = {
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ euiColorVis6_asText: string;
+ buttonsBackgroundNormalDefaultPrimary: string;
+};
+type EuiVars = typeof euiLightVars & ExtraEuiVars;
+type EuiThemeReturn = ReturnType & { euiVars: EuiVars };
+
+// Not all Eui Tokens were fully migrated to @elastic/eui/useEuiTheme yet, so
+// this hook overrides the default useEuiTheme hook to provide a custom hook that
+// allows the use the euiVars tokens from the euiLightVars and euiDarkVars
+export const useEuiTheme = (...props: EuiThemeProps): EuiThemeReturn => {
+ const euiThemeHook = useEuiThemeHook(...props);
+
+ const euiVars = useMemo(() => {
+ const themeVars = euiThemeHook.colorMode === 'DARK' ? euiDarkVars : euiLightVars;
+
+ const extraEuiVars: ExtraEuiVars = {
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ euiColorVis6_asText: shade(themeVars.euiColorVis6, 0.335),
+ buttonsBackgroundNormalDefaultPrimary: '#006DE4',
+ };
+
+ return {
+ ...themeVars,
+ ...extraEuiVars,
+ };
+ }, [euiThemeHook.colorMode]);
+
+ return {
+ ...euiThemeHook,
+ euiVars,
+ };
+};
diff --git a/x-pack/plugins/kubernetes_security/public/hooks/use_filter.ts b/x-pack/plugins/kubernetes_security/public/hooks/use_filter.ts
new file mode 100644
index 0000000000000..b12839bc332e2
--- /dev/null
+++ b/x-pack/plugins/kubernetes_security/public/hooks/use_filter.ts
@@ -0,0 +1,24 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import { useMemo } from 'react';
+import { useKibana } from '@kbn/kibana-react-plugin/public';
+import type { CoreStart } from '@kbn/core/public';
+import type { StartPlugins } from '../types';
+
+export const useSetFilter = () => {
+ const { data, timelines } = useKibana().services;
+ const { getFilterForValueButton, getFilterOutValueButton } = timelines.getHoverActions();
+
+ const filterManager = useMemo(() => data.query.filterManager, [data.query.filterManager]);
+
+ return {
+ getFilterForValueButton,
+ getFilterOutValueButton,
+ filterManager,
+ };
+};
diff --git a/x-pack/plugins/kubernetes_security/public/test/index.tsx b/x-pack/plugins/kubernetes_security/public/test/index.tsx
new file mode 100644
index 0000000000000..6174925b6003c
--- /dev/null
+++ b/x-pack/plugins/kubernetes_security/public/test/index.tsx
@@ -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
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import React, { memo, ReactNode, useMemo } from 'react';
+import { createMemoryHistory, MemoryHistory } from 'history';
+import { render as reactRender, RenderOptions, RenderResult } from '@testing-library/react';
+import { QueryClient, QueryClientProvider, setLogger } from 'react-query';
+import { Router } from 'react-router-dom';
+import { History } from 'history';
+import useObservable from 'react-use/lib/useObservable';
+import { I18nProvider } from '@kbn/i18n-react';
+import { CoreStart } from '@kbn/core/public';
+import { coreMock } from '@kbn/core/public/mocks';
+import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public';
+import { EuiThemeProvider } from '@kbn/kibana-react-plugin/common';
+
+type UiRender = (ui: React.ReactElement, options?: RenderOptions) => RenderResult;
+
+// hide react-query output in console
+setLogger({
+ error: () => {},
+ // eslint-disable-next-line no-console
+ log: console.log,
+ // eslint-disable-next-line no-console
+ warn: console.warn,
+});
+
+/**
+ * Mocked app root context renderer
+ */
+export interface AppContextTestRender {
+ history: ReturnType;
+ coreStart: ReturnType;
+ /**
+ * A wrapper around `AppRootContext` component. Uses the mocked modules as input to the
+ * `AppRootContext`
+ */
+ AppWrapper: React.FC;
+ /**
+ * Renders the given UI within the created `AppWrapper` providing the given UI a mocked
+ * endpoint runtime context environment
+ */
+ render: UiRender;
+}
+
+const createCoreStartMock = (
+ history: MemoryHistory
+): ReturnType => {
+ const coreStart = coreMock.createStart({ basePath: '/mock' });
+
+ // Mock the certain APP Ids returned by `application.getUrlForApp()`
+ coreStart.application.getUrlForApp.mockImplementation((appId) => {
+ switch (appId) {
+ case 'sessionView':
+ return '/app/sessionView';
+ default:
+ return `${appId} not mocked!`;
+ }
+ });
+
+ coreStart.application.navigateToUrl.mockImplementation((url) => {
+ history.push(url.replace('/app/sessionView', ''));
+ return Promise.resolve();
+ });
+
+ return coreStart;
+};
+
+const AppRootProvider = memo<{
+ history: History;
+ coreStart: CoreStart;
+ children: ReactNode | ReactNode[];
+}>(({ history, coreStart: { http, notifications, uiSettings, application }, children }) => {
+ const isDarkMode = useObservable(uiSettings.get$('theme:darkMode'));
+ const services = useMemo(
+ () => ({ http, notifications, application }),
+ [application, http, notifications]
+ );
+ return (
+
+
+
+ {children}
+
+
+
+ );
+});
+
+AppRootProvider.displayName = 'AppRootProvider';
+
+/**
+ * Creates a mocked app context custom renderer that can be used to render
+ * component that depend upon the application's surrounding context providers.
+ * Factory also returns the content that was used to create the custom renderer, allowing
+ * for further customization.
+ */
+
+export const createAppRootMockRenderer = (): AppContextTestRender => {
+ const history = createMemoryHistory();
+ const coreStart = createCoreStartMock(history);
+
+ const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: {
+ // turns retries off
+ retry: false,
+ // prevent jest did not exit errors
+ cacheTime: Infinity,
+ },
+ },
+ });
+
+ const AppWrapper: React.FC<{ children: React.ReactElement }> = ({ children }) => (
+
+ {children}
+
+ );
+
+ const render: UiRender = (ui, options = {}) => {
+ return reactRender(ui, {
+ wrapper: AppWrapper,
+ ...options,
+ });
+ };
+
+ return {
+ history,
+ coreStart,
+ AppWrapper,
+ render,
+ };
+};
diff --git a/x-pack/plugins/kubernetes_security/public/types.ts b/x-pack/plugins/kubernetes_security/public/types.ts
index 65a25868a0655..d6313b25bf011 100644
--- a/x-pack/plugins/kubernetes_security/public/types.ts
+++ b/x-pack/plugins/kubernetes_security/public/types.ts
@@ -6,11 +6,34 @@
*/
import React from 'react';
import { CoreStart } from '@kbn/core/public';
+import type { DataPublicPluginStart } from '@kbn/data-plugin/public';
+import type { FieldSpec } from '@kbn/data-plugin/common';
+import type { TimelinesUIStart } from '@kbn/timelines-plugin/public';
+import type { SessionViewStart } from '@kbn/session-view-plugin/public';
-export type KubernetesSecurityServices = CoreStart;
+export interface StartPlugins {
+ data: DataPublicPluginStart;
+ timelines: TimelinesUIStart;
+ sessionView: SessionViewStart;
+}
+
+export type KubernetesSecurityServices = CoreStart & StartPlugins;
+
+export interface IndexPattern {
+ fields: FieldSpec[];
+ title: string;
+}
+
+export interface GlobalFilter {
+ filterQuery?: string;
+ startDate: string;
+ endDate: string;
+}
export interface KubernetesSecurityDeps {
filter: React.ReactNode;
+ indexPattern?: IndexPattern;
+ globalFilter: GlobalFilter;
}
export interface KubernetesSecurityStart {
diff --git a/x-pack/plugins/kubernetes_security/public/utils/add_timerange_to_query.test.ts b/x-pack/plugins/kubernetes_security/public/utils/add_timerange_to_query.test.ts
new file mode 100644
index 0000000000000..20e5e36cb3951
--- /dev/null
+++ b/x-pack/plugins/kubernetes_security/public/utils/add_timerange_to_query.test.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
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import { DEFAULT_QUERY } from '../../common/constants';
+import { addTimerangeToQuery } from './add_timerange_to_query';
+
+const TEST_QUERY =
+ '{"bool":{"must":[],"filter":[{"bool":{"should":[{"match":{"process.entry_leader.same_as_process":true}}],"minimum_should_match":1}}],"should":[],"must_not":[]}}';
+const TEST_INVALID_QUERY = '{"bool":{"must":[';
+const TEST_EMPTY_STRING = '';
+const TEST_DATE = '2022-06-09T22:36:46.628Z';
+const VALID_RESULT =
+ '{"bool":{"must":[],"filter":[{"bool":{"should":[{"match":{"process.entry_leader.same_as_process":true}}],"minimum_should_match":1}},{"range":{"@timestamp":{"gte":"2022-06-09T22:36:46.628Z","lte":"2022-06-09T22:36:46.628Z"}}}],"should":[],"must_not":[]}}';
+
+describe('addTimerangeToQuery(query, startDate, endDate)', () => {
+ it('works for valid query, startDate, and endDate', () => {
+ expect(addTimerangeToQuery(TEST_QUERY, TEST_DATE, TEST_DATE)).toEqual(VALID_RESULT);
+ });
+ it('works with missing filter in bool', () => {
+ expect(addTimerangeToQuery('{"bool":{}}', TEST_DATE, TEST_DATE)).toEqual(
+ '{"bool":{"filter":[{"range":{"@timestamp":{"gte":"2022-06-09T22:36:46.628Z","lte":"2022-06-09T22:36:46.628Z"}}}]}}'
+ );
+ });
+ it('returns default query with invalid JSON query', () => {
+ expect(addTimerangeToQuery(TEST_INVALID_QUERY, TEST_DATE, TEST_DATE)).toEqual(DEFAULT_QUERY);
+ expect(addTimerangeToQuery(TEST_EMPTY_STRING, TEST_DATE, TEST_DATE)).toEqual(DEFAULT_QUERY);
+ expect(addTimerangeToQuery('{}', TEST_DATE, TEST_DATE)).toEqual(DEFAULT_QUERY);
+ });
+ it('returns default query with invalid startDate or endDate', () => {
+ expect(addTimerangeToQuery(TEST_QUERY, TEST_EMPTY_STRING, TEST_DATE)).toEqual(DEFAULT_QUERY);
+ expect(addTimerangeToQuery(TEST_QUERY, TEST_DATE, TEST_EMPTY_STRING)).toEqual(DEFAULT_QUERY);
+ });
+});
diff --git a/x-pack/plugins/kubernetes_security/public/utils/add_timerange_to_query.ts b/x-pack/plugins/kubernetes_security/public/utils/add_timerange_to_query.ts
new file mode 100644
index 0000000000000..0eb1239435483
--- /dev/null
+++ b/x-pack/plugins/kubernetes_security/public/utils/add_timerange_to_query.ts
@@ -0,0 +1,56 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import { DEFAULT_QUERY } from '../../common/constants';
+
+/**
+ * Add startDate and endDate filter for '@timestamp' field into query.
+ *
+ * Used by frontend components
+ *
+ * @param {String | undefined} query Example: '{"bool":{"must":[],"filter":[],"should":[],"must_not":[]}}'
+ * @param {String} startDate Example: '2022-06-08T18:52:15.532Z'
+ * @param {String} endDate Example: '2022-06-09T17:52:15.532Z'
+ * @return {String} Add startDate and endDate as a '@timestamp' range filter in query and return.
+ * If startDate or endDate is invalid Date string, or that query is not
+ * in the right format, return a default query.
+ */
+
+export const addTimerangeToQuery = (
+ query: string | undefined,
+ startDate: string,
+ endDate: string
+) => {
+ if (!(query && !isNaN(Date.parse(startDate)) && !isNaN(Date.parse(endDate)))) {
+ return DEFAULT_QUERY;
+ }
+
+ try {
+ const parsedQuery = JSON.parse(query);
+ if (!parsedQuery.bool) {
+ throw new Error("Field 'bool' does not exist in query.");
+ }
+
+ const range = {
+ range: {
+ '@timestamp': {
+ gte: startDate,
+ lte: endDate,
+ },
+ },
+ };
+ if (parsedQuery.bool.filter) {
+ parsedQuery.bool.filter = [...parsedQuery.bool.filter, range];
+ } else {
+ parsedQuery.bool.filter = [range];
+ }
+
+ return JSON.stringify(parsedQuery);
+ } catch {
+ return DEFAULT_QUERY;
+ }
+};
diff --git a/x-pack/plugins/kubernetes_security/server/routes/aggregate.ts b/x-pack/plugins/kubernetes_security/server/routes/aggregate.ts
index 8f90a8ee8ba50..252b20a458a78 100644
--- a/x-pack/plugins/kubernetes_security/server/routes/aggregate.ts
+++ b/x-pack/plugins/kubernetes_security/server/routes/aggregate.ts
@@ -4,6 +4,7 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
+import type { SortCombinations } from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import { schema } from '@kbn/config-schema';
import type { ElasticsearchClient } from '@kbn/core/server';
import { IRouter } from '@kbn/core/server';
@@ -14,6 +15,10 @@ import {
AGGREGATE_MAX_BUCKETS,
} from '../../common/constants';
+// sort by values
+const ASC = 'asc';
+const DESC = 'desc';
+
export const registerAggregateRoute = (router: IRouter) => {
router.get(
{
@@ -21,18 +26,20 @@ export const registerAggregateRoute = (router: IRouter) => {
validate: {
query: schema.object({
query: schema.string(),
+ countBy: schema.maybe(schema.string()),
groupBy: schema.string(),
page: schema.number(),
index: schema.maybe(schema.string()),
+ sortByCount: schema.maybe(schema.string()),
}),
},
},
async (context, request, response) => {
const client = (await context.core).elasticsearch.client.asCurrentUser;
- const { query, groupBy, page, index } = request.query;
+ const { query, countBy, sortByCount, groupBy, page, index } = request.query;
try {
- const body = await doSearch(client, query, groupBy, page, index);
+ const body = await doSearch(client, query, groupBy, page, index, countBy, sortByCount);
return response.ok({ body });
} catch (err) {
@@ -47,10 +54,27 @@ export const doSearch = async (
query: string,
groupBy: string,
page: number, // zero based
- index?: string
+ index?: string,
+ countBy?: string,
+ sortByCount?: string
) => {
const queryDSL = JSON.parse(query);
+ const countByAggs = countBy
+ ? {
+ count_by_aggs: {
+ cardinality: {
+ field: countBy,
+ },
+ },
+ }
+ : undefined;
+
+ let sort: SortCombinations = { _key: { order: ASC } };
+ if (sortByCount === ASC || sortByCount === DESC) {
+ sort = { 'count_by_aggs.value': { order: sortByCount } };
+ }
+
const search = await client.search({
index: [index || PROCESS_EVENTS_INDEX],
body: {
@@ -63,9 +87,10 @@ export const doSearch = async (
size: AGGREGATE_MAX_BUCKETS,
},
aggs: {
+ ...countByAggs,
bucket_sort: {
bucket_sort: {
- sort: [{ _key: { order: 'asc' } }], // defaulting to alphabetic sort
+ sort: [sort], // defaulting to alphabetic sort
size: AGGREGATE_PAGE_SIZE,
from: AGGREGATE_PAGE_SIZE * page,
},
diff --git a/x-pack/plugins/lens/public/datatable_visualization/components/cell_value.test.tsx b/x-pack/plugins/lens/public/datatable_visualization/components/cell_value.test.tsx
index 54321d5005f47..94a55a393814c 100644
--- a/x-pack/plugins/lens/public/datatable_visualization/components/cell_value.test.tsx
+++ b/x-pack/plugins/lens/public/datatable_visualization/components/cell_value.test.tsx
@@ -89,6 +89,63 @@ describe('datatable cell renderer', () => {
expect(cell.find('.lnsTableCell--right').exists()).toBeTruthy();
});
+ it('does not set multiline class for regular height tables', () => {
+ const cell = mountWithIntl(
+
+ {}}
+ isExpandable={false}
+ isDetails={false}
+ isExpanded={false}
+ />
+
+ );
+ expect(cell.find('.lnsTableCell--multiline').exists()).toBeFalsy();
+ });
+
+ it('set multiline class for auto height tables', () => {
+ const MultiLineCellRenderer = createGridCell(
+ {
+ a: { convert: (x) => `formatted ${x}` } as FieldFormat,
+ },
+ { columns: [], sortingColumnId: '', sortingDirection: 'none' },
+ DataContext,
+ { get: jest.fn() } as unknown as IUiSettingsClient,
+ true
+ );
+ const cell = mountWithIntl(
+
+ {}}
+ isExpandable={false}
+ isDetails={false}
+ isExpanded={false}
+ />
+
+ );
+ expect(cell.find('.lnsTableCell--multiline').exists()).toBeTruthy();
+ });
+
describe('dynamic coloring', () => {
const paletteRegistry = chartPluginMock.createPaletteRegistry();
const customPalette = paletteRegistry.get('custom');
diff --git a/x-pack/plugins/lens/public/datatable_visualization/components/cell_value.tsx b/x-pack/plugins/lens/public/datatable_visualization/components/cell_value.tsx
index 1bf58ae30a13e..e43c08aec6375 100644
--- a/x-pack/plugins/lens/public/datatable_visualization/components/cell_value.tsx
+++ b/x-pack/plugins/lens/public/datatable_visualization/components/cell_value.tsx
@@ -8,6 +8,7 @@
import React, { useContext, useEffect } from 'react';
import type { EuiDataGridCellValueElementProps } from '@elastic/eui';
import type { IUiSettingsClient } from '@kbn/core/public';
+import classNames from 'classnames';
import type { FormatFactory } from '../../../common';
import { getOriginalId } from '../../../common/expressions';
import type { ColumnConfig } from '../../../common/expressions';
@@ -18,7 +19,8 @@ export const createGridCell = (
formatters: Record>,
columnConfig: ColumnConfig,
DataContext: React.Context,
- uiSettings: IUiSettingsClient
+ uiSettings: IUiSettingsClient,
+ fitRowToContent?: boolean
) => {
// Changing theme requires a full reload of the page, so we can cache here
const IS_DARK_THEME = uiSettings.get('theme:darkMode');
@@ -74,7 +76,10 @@ export const createGridCell = (
*/
dangerouslySetInnerHTML={{ __html: content }} // eslint-disable-line react/no-danger
data-test-subj="lnsTableCellContent"
- className={`lnsTableCell--${currentAlignment}`}
+ className={classNames({
+ 'lnsTableCell--multiline': fitRowToContent,
+ [`lnsTableCell--${currentAlignment}`]: true,
+ })}
/>
);
};
diff --git a/x-pack/plugins/lens/public/datatable_visualization/components/table_basic.scss b/x-pack/plugins/lens/public/datatable_visualization/components/table_basic.scss
index f8cb27cf1d66c..0f0ed53a10021 100644
--- a/x-pack/plugins/lens/public/datatable_visualization/components/table_basic.scss
+++ b/x-pack/plugins/lens/public/datatable_visualization/components/table_basic.scss
@@ -2,6 +2,10 @@
height: 100%;
}
+.lnsTableCell--multiline {
+ white-space: pre-wrap;
+}
+
.lnsTableCell--left {
text-align: left;
}
diff --git a/x-pack/plugins/lens/public/datatable_visualization/components/table_basic.tsx b/x-pack/plugins/lens/public/datatable_visualization/components/table_basic.tsx
index e9be1a9d74f00..28f4b4dbe9f2b 100644
--- a/x-pack/plugins/lens/public/datatable_visualization/components/table_basic.tsx
+++ b/x-pack/plugins/lens/public/datatable_visualization/components/table_basic.tsx
@@ -337,8 +337,15 @@ export const DatatableComponent = (props: DatatableRenderProps) => {
]);
const renderCellValue = useMemo(
- () => createGridCell(formatters, columnConfig, DataContext, props.uiSettings),
- [formatters, columnConfig, props.uiSettings]
+ () =>
+ createGridCell(
+ formatters,
+ columnConfig,
+ DataContext,
+ props.uiSettings,
+ props.args.fitRowToContent
+ ),
+ [formatters, columnConfig, props.uiSettings, props.args.fitRowToContent]
);
const columnVisibility = useMemo(
diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.tsx
index abd6da25c52ea..c97e5232af898 100644
--- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.tsx
+++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.tsx
@@ -58,10 +58,10 @@ import {
selectIsFullscreenDatasource,
selectSearchSessionId,
selectActiveDatasourceId,
- selectActiveData,
selectDatasourceStates,
selectChangesApplied,
applyChanges,
+ selectStagedActiveData,
} from '../../state_management';
import { DONT_CLOSE_DIMENSION_CONTAINER_ON_CLICK_CLASS } from './config_panel/dimension_container';
@@ -191,7 +191,7 @@ export function SuggestionPanel({
}: SuggestionPanelProps) {
const dispatchLens = useLensDispatch();
const activeDatasourceId = useLensSelector(selectActiveDatasourceId);
- const activeData = useLensSelector(selectActiveData);
+ const activeData = useLensSelector(selectStagedActiveData);
const datasourceStates = useLensSelector(selectDatasourceStates);
const existsStagedPreview = useLensSelector((state) => Boolean(state.lens.stagedPreview));
const currentVisualization = useLensSelector(selectCurrentVisualization);
@@ -300,7 +300,7 @@ export function SuggestionPanel({
activeDatasourceId,
datasourceMap,
visualizationMap,
- frame.activeData,
+ activeData,
]);
const context: ExecutionContextSearch = useLensSelector(selectExecutionContextSearch);
diff --git a/x-pack/plugins/lens/public/state_management/lens_slice.ts b/x-pack/plugins/lens/public/state_management/lens_slice.ts
index df728e2bf8501..7ea8053caa588 100644
--- a/x-pack/plugins/lens/public/state_management/lens_slice.ts
+++ b/x-pack/plugins/lens/public/state_management/lens_slice.ts
@@ -376,6 +376,7 @@ export const makeLensReducer = (storeDeps: LensStoreDeps) => {
: state.stagedPreview || {
datasourceStates: state.datasourceStates,
visualization: state.visualization,
+ activeData: state.activeData,
},
};
},
diff --git a/x-pack/plugins/lens/public/state_management/selectors.ts b/x-pack/plugins/lens/public/state_management/selectors.ts
index 57cb43452232a..9217c66863c5c 100644
--- a/x-pack/plugins/lens/public/state_management/selectors.ts
+++ b/x-pack/plugins/lens/public/state_management/selectors.ts
@@ -19,6 +19,8 @@ export const selectFilters = (state: LensState) => state.lens.filters;
export const selectResolvedDateRange = (state: LensState) => state.lens.resolvedDateRange;
export const selectVisualization = (state: LensState) => state.lens.visualization;
export const selectStagedPreview = (state: LensState) => state.lens.stagedPreview;
+export const selectStagedActiveData = (state: LensState) =>
+ state.lens.stagedPreview?.activeData || state.lens.activeData;
export const selectAutoApplyEnabled = (state: LensState) => !state.lens.autoApplyDisabled;
export const selectChangesApplied = (state: LensState) =>
!state.lens.autoApplyDisabled || Boolean(state.lens.changesApplied);
diff --git a/x-pack/plugins/lens/public/state_management/types.ts b/x-pack/plugins/lens/public/state_management/types.ts
index efba6312e8ba6..c11215d4a9f8e 100644
--- a/x-pack/plugins/lens/public/state_management/types.ts
+++ b/x-pack/plugins/lens/public/state_management/types.ts
@@ -29,6 +29,7 @@ export type DatasourceStates = Record {
const fetcher = jest
.fn()
.mockImplementationOnce(async () => {
- await delay(100);
+ await delay(2);
return firstLicense;
})
.mockImplementationOnce(async () => {
- await delay(100);
+ await delay(2);
return secondLicense;
});
diff --git a/x-pack/plugins/licensing/common/license_update.ts b/x-pack/plugins/licensing/common/license_update.ts
index 227886879e4e5..b674f4a066ce3 100644
--- a/x-pack/plugins/licensing/common/license_update.ts
+++ b/x-pack/plugins/licensing/common/license_update.ts
@@ -5,11 +5,21 @@
* 2.0.
*/
-import { ConnectableObservable, Observable, Subject, from, merge, firstValueFrom } from 'rxjs';
+import { type Observable, Subject, merge, firstValueFrom } from 'rxjs';
-import { filter, map, pairwise, exhaustMap, publishReplay, share, takeUntil } from 'rxjs/operators';
+import {
+ filter,
+ map,
+ pairwise,
+ exhaustMap,
+ share,
+ shareReplay,
+ takeUntil,
+ finalize,
+ startWith,
+} from 'rxjs/operators';
import { hasLicenseInfoChanged } from './has_license_info_changed';
-import { ILicense } from './types';
+import type { ILicense } from './types';
export function createLicenseUpdate(
triggerRefresh$: Observable,
@@ -18,26 +28,36 @@ export function createLicenseUpdate(
initialValues?: ILicense
) {
const manuallyRefresh$ = new Subject();
- const fetched$ = merge(triggerRefresh$, manuallyRefresh$).pipe(exhaustMap(fetcher), share());
- const cached$ = fetched$.pipe(
+ const fetched$ = merge(triggerRefresh$, manuallyRefresh$).pipe(
takeUntil(stop$),
- publishReplay(1)
- // have to cast manually as pipe operator cannot return ConnectableObservable
- // https://github.com/ReactiveX/rxjs/issues/2972
- ) as ConnectableObservable;
-
- const cachedSubscription = cached$.connect();
- stop$.subscribe({ complete: () => cachedSubscription.unsubscribe() });
+ exhaustMap(fetcher),
+ share()
+ );
- const initialValues$ = initialValues ? from([undefined, initialValues]) : from([undefined]);
+ // provide a first, empty license, so that we can compare in the filter below
+ const startWithArgs = initialValues ? [undefined, initialValues] : [undefined];
- const license$: Observable = merge(initialValues$, cached$).pipe(
+ const license$: Observable = fetched$.pipe(
+ shareReplay(1),
+ startWith(...startWithArgs),
pairwise(),
filter(([previous, next]) => hasLicenseInfoChanged(previous, next!)),
map(([, next]) => next!)
);
+ // start periodic license fetch right away
+ const licenseSub = license$.subscribe();
+
+ stop$
+ .pipe(
+ finalize(() => {
+ manuallyRefresh$.complete();
+ licenseSub.unsubscribe();
+ })
+ )
+ .subscribe();
+
return {
license$,
refreshManually() {
diff --git a/x-pack/plugins/licensing/server/plugin.ts b/x-pack/plugins/licensing/server/plugin.ts
index aaeeb4e058008..0d21cd689bf46 100644
--- a/x-pack/plugins/licensing/server/plugin.ts
+++ b/x-pack/plugins/licensing/server/plugin.ts
@@ -5,15 +5,16 @@
* 2.0.
*/
-import { Observable, Subject, Subscription, timer } from 'rxjs';
+import type { Observable, Subject, Subscription } from 'rxjs';
+import { ReplaySubject, timer } from 'rxjs';
import moment from 'moment';
import { createHash } from 'crypto';
import stringify from 'json-stable-stringify';
import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
-import { MaybePromise } from '@kbn/utility-types';
+import type { MaybePromise } from '@kbn/utility-types';
import { isPromise } from '@kbn/std';
-import {
+import type {
CoreSetup,
Logger,
Plugin,
@@ -22,22 +23,22 @@ import {
} from '@kbn/core/server';
import { registerAnalyticsContextProvider } from '../common/register_analytics_context_provider';
-import {
+import type {
ILicense,
PublicLicense,
PublicFeatures,
LicenseType,
LicenseStatus,
} from '../common/types';
-import { LicensingPluginSetup, LicensingPluginStart } from './types';
+import type { LicensingPluginSetup, LicensingPluginStart } from './types';
import { License } from '../common/license';
import { createLicenseUpdate } from '../common/license_update';
-import { ElasticsearchError } from './types';
+import type { ElasticsearchError } from './types';
import { registerRoutes } from './routes';
import { FeatureUsageService } from './services';
-import { LicenseConfigType } from './licensing_config';
+import type { LicenseConfigType } from './licensing_config';
import { createRouteHandlerContext } from './licensing_route_handler_context';
import { createOnPreResponseHandler } from './on_pre_response_handler';
import { getPluginStatus$ } from './plugin_status';
@@ -94,7 +95,7 @@ function sign({
* current Kibana instance.
*/
export class LicensingPlugin implements Plugin {
- private stop$ = new Subject();
+ private stop$: Subject = new ReplaySubject(1);
private readonly logger: Logger;
private readonly config: LicenseConfigType;
private loggingSubscription?: Subscription;
diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/page.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/page.tsx
index 524556e12a9af..c35ad5bacf371 100644
--- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/page.tsx
+++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/page.tsx
@@ -108,7 +108,7 @@ export const Page: FC<{
/>
) : null}
{jobIdToUse !== undefined && (
-
+
>;
+ setAnalyticsId: (update: AnalyticsSelectorIds) => void;
jobsOnly?: boolean;
setIsIdSelectorFlyoutVisible: React.Dispatch>;
}
diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/job_map/page.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/job_map/page.tsx
index 78561d11dcd1b..cae309ebcb761 100644
--- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/job_map/page.tsx
+++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/job_map/page.tsx
@@ -5,7 +5,7 @@
* 2.0.
*/
-import React, { FC, useState, useEffect } from 'react';
+import React, { FC, useState, useEffect, useCallback } from 'react';
import { EuiEmptyPrompt } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
@@ -27,16 +27,28 @@ import { AnalyticsEmptyPrompt } from '../analytics_management/components/empty_p
export const Page: FC = () => {
const [globalState, setGlobalState] = useUrlState('_g');
- const mapJobId = globalState?.ml?.jobId;
- const mapModelId = globalState?.ml?.modelId;
+ const jobId = globalState?.ml?.jobId;
+ const modelId = globalState?.ml?.modelId;
const [isLoading, setIsLoading] = useState(false);
const [isIdSelectorFlyoutVisible, setIsIdSelectorFlyoutVisible] = useState(
- !mapJobId && !mapModelId
+ !jobId && !modelId
);
const [jobsExist, setJobsExist] = useState(true);
const { refresh } = useRefreshAnalyticsList({ isLoading: setIsLoading });
- const [analyticsId, setAnalyticsId] = useState();
+
+ const setAnalyticsId = useCallback(
+ (analyticsId: AnalyticsSelectorIds) => {
+ setGlobalState({
+ ml: {
+ ...(analyticsId.job_id && !analyticsId.model_id ? { jobId: analyticsId.job_id } : {}),
+ ...(analyticsId.model_id ? { modelId: analyticsId.model_id } : {}),
+ },
+ });
+ },
+ [setGlobalState]
+ );
+
const {
services: { docLinks },
} = useMlKibana();
@@ -59,20 +71,6 @@ export const Page: FC = () => {
checkJobsExist();
}, []);
- useEffect(
- function updateUrl() {
- if (analyticsId !== undefined) {
- setGlobalState({
- ml: {
- ...(analyticsId.job_id && !analyticsId.model_id ? { jobId: analyticsId.job_id } : {}),
- ...(analyticsId.model_id ? { modelId: analyticsId.model_id } : {}),
- },
- });
- }
- },
- [analyticsId?.job_id, analyticsId?.model_id]
- );
-
const getEmptyState = () => {
if (jobsExist === false) {
return ;
@@ -95,9 +93,6 @@ export const Page: FC = () => {
);
};
- const jobId = mapJobId ?? analyticsId?.job_id;
- const modelId = mapModelId ?? analyticsId?.model_id;
-
return (
<>
{
/>
) : null}
- {jobId !== undefined ? (
-
+ {jobId !== undefined && modelId === undefined ? (
+
{
/>
) : null}
- {modelId !== undefined ? (
+ {modelId !== undefined && jobId === undefined ? (
{
- {jobId ?? modelId ? (
+ {jobId || modelId ? (
{
+ _id: string;
+ _index: string;
+ _source: T & { [ALERT_UUID]: string };
+}
+
+export type GenericAlert830 = AlertWithCommonFields800;
+
+// This is the type of the final generated alert including base fields, common fields
+// added by the alertWithPersistence function, and arbitrary fields copied from source documents
+export type DetectionAlert830 = GenericAlert830 | EqlShellAlert800 | EqlBuildingBlockAlert800;
+
+export interface EqlShellFields830 extends BaseFields830 {
+ [ALERT_GROUP_ID]: string;
+ [ALERT_UUID]: string;
+}
+
+export interface EqlBuildingBlockFields830 extends BaseFields830 {
+ [ALERT_GROUP_ID]: string;
+ [ALERT_GROUP_INDEX]: number;
+ [ALERT_BUILDING_BLOCK_TYPE]: 'default';
+}
diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/alerts/index.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/alerts/index.ts
index 51b5c505b817a..15b80f441e77c 100644
--- a/x-pack/plugins/security_solution/common/detection_engine/schemas/alerts/index.ts
+++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/alerts/index.ts
@@ -6,23 +6,25 @@
*/
import type {
- Ancestor800,
- BaseFields800,
- DetectionAlert800,
- WrappedFields800,
- EqlBuildingBlockFields800,
- EqlShellFields800,
-} from './8.0.0';
+ EqlBuildingBlockFields830,
+ EqlShellFields830,
+ WrappedFields830,
+ DetectionAlert830,
+ BaseFields830,
+ Ancestor830,
+} from './8.3.0';
+
+import type { DetectionAlert800 } from './8.0.0';
// When new Alert schemas are created for new Kibana versions, add the DetectionAlert type from the new version
// here, e.g. `export type DetectionAlert = DetectionAlert800 | DetectionAlert820` if a new schema is created in 8.2.0
-export type DetectionAlert = DetectionAlert800;
+export type DetectionAlert = DetectionAlert800 | DetectionAlert830;
export type {
- Ancestor800 as AncestorLatest,
- BaseFields800 as BaseFieldsLatest,
- DetectionAlert800 as DetectionAlertLatest,
- WrappedFields800 as WrappedFieldsLatest,
- EqlBuildingBlockFields800 as EqlBuildingBlockFieldsLatest,
- EqlShellFields800 as EqlShellFieldsLatest,
+ Ancestor830 as AncestorLatest,
+ BaseFields830 as BaseFieldsLatest,
+ DetectionAlert830 as DetectionAlertLatest,
+ WrappedFields830 as WrappedFieldsLatest,
+ EqlBuildingBlockFields830 as EqlBuildingBlockFieldsLatest,
+ EqlShellFields830 as EqlShellFieldsLatest,
};
diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/common/schemas.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/common/schemas.ts
index a9edba9e6f41c..74fc8bbbc9331 100644
--- a/x-pack/plugins/security_solution/common/detection_engine/schemas/common/schemas.ts
+++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/common/schemas.ts
@@ -123,6 +123,12 @@ export type IdOrUndefined = t.TypeOf;
export const index = t.array(t.string);
export type Index = t.TypeOf;
+export const data_view_id = t.string;
+export type DataViewId = t.TypeOf;
+
+export const dataViewIdOrUndefined = t.union([data_view_id, t.undefined]);
+export type DataViewIdOrUndefined = t.TypeOf;
+
export const indexOrUndefined = t.union([index, t.undefined]);
export type IndexOrUndefined = t.TypeOf;
@@ -462,14 +468,17 @@ const bulkActionEditPayloadTags = t.type({
export type BulkActionEditPayloadTags = t.TypeOf;
-const bulkActionEditPayloadIndexPatterns = t.type({
- type: t.union([
- t.literal(BulkActionEditType.add_index_patterns),
- t.literal(BulkActionEditType.delete_index_patterns),
- t.literal(BulkActionEditType.set_index_patterns),
- ]),
- value: index,
-});
+const bulkActionEditPayloadIndexPatterns = t.intersection([
+ t.type({
+ type: t.union([
+ t.literal(BulkActionEditType.add_index_patterns),
+ t.literal(BulkActionEditType.delete_index_patterns),
+ t.literal(BulkActionEditType.set_index_patterns),
+ ]),
+ value: index,
+ }),
+ t.exact(t.partial({ overwriteDataViews: t.boolean })),
+]);
export type BulkActionEditPayloadIndexPatterns = t.TypeOf<
typeof bulkActionEditPayloadIndexPatterns
diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/add_prepackaged_rules_schema.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/add_prepackaged_rules_schema.ts
index 9341860a7a012..72c48a139ca1d 100644
--- a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/add_prepackaged_rules_schema.ts
+++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/add_prepackaged_rules_schema.ts
@@ -50,6 +50,7 @@ import {
anomaly_threshold,
filters,
index,
+ data_view_id,
saved_id,
timeline_id,
timeline_title,
@@ -114,6 +115,7 @@ export const addPrepackagedRulesSchema = t.intersection([
filters, // defaults to undefined if not set during decode
from: DefaultFromString, // defaults to "now-6m" if not set during decode
index, // defaults to undefined if not set during decode
+ data_view_id, // defaults to undefined if not set during decode
interval: DefaultIntervalString, // defaults to "5m" if not set during decode
query, // defaults to undefined if not set during decode
language, // defaults to undefined if not set during decode
diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/import_rules_schema.test.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/import_rules_schema.test.ts
index 07c2a2d93c3a4..d3efcaf0f5df5 100644
--- a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/import_rules_schema.test.ts
+++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/import_rules_schema.test.ts
@@ -1773,4 +1773,124 @@ describe('import rules schema', () => {
expect(message.schema).toEqual(expected);
});
});
+
+ describe('data_view_id', () => {
+ test('Defined data_view_id and empty index does validate', () => {
+ const payload: ImportRulesSchema = {
+ rule_id: 'rule-1',
+ risk_score: 50,
+ description: 'some description',
+ from: 'now-5m',
+ to: 'now',
+ name: 'some-name',
+ severity: 'low',
+ type: 'query',
+ query: 'some query',
+ data_view_id: 'logs-*',
+ index: [],
+ interval: '5m',
+ };
+
+ const decoded = importRulesSchema.decode(payload);
+ const checked = exactCheck(payload, decoded);
+ const message = pipe(checked, foldLeftRight);
+ expect(getPaths(left(message.errors))).toEqual([]);
+ const expected: ImportRulesSchemaDecoded = {
+ author: [],
+ severity_mapping: [],
+ risk_score_mapping: [],
+ rule_id: 'rule-1',
+ risk_score: 50,
+ description: 'some description',
+ from: 'now-5m',
+ to: 'now',
+ name: 'some-name',
+ severity: 'low',
+ type: 'query',
+ query: 'some query',
+ index: [],
+ data_view_id: 'logs-*',
+ interval: '5m',
+ references: [],
+ actions: [],
+ enabled: true,
+ false_positives: [],
+ max_signals: DEFAULT_MAX_SIGNALS,
+ tags: [],
+ threat: [],
+ throttle: null,
+ version: 1,
+ exceptions_list: [],
+ immutable: false,
+ };
+ expect(message.schema).toEqual(expected);
+ });
+
+ // Both can be defined, but if a data_view_id is defined, rule will use that one
+ test('Defined data_view_id and index does validate', () => {
+ const payload: ImportRulesSchema = {
+ rule_id: 'rule-1',
+ risk_score: 50,
+ description: 'some description',
+ from: 'now-5m',
+ to: 'now',
+ name: 'some-name',
+ severity: 'low',
+ type: 'query',
+ query: 'some query',
+ data_view_id: 'logs-*',
+ index: ['auditbeat-*'],
+ interval: '5m',
+ };
+
+ const decoded = importRulesSchema.decode(payload);
+ const checked = exactCheck(payload, decoded);
+ const message = pipe(checked, foldLeftRight);
+ expect(getPaths(left(message.errors))).toEqual([]);
+ const expected: ImportRulesSchemaDecoded = {
+ author: [],
+ severity_mapping: [],
+ risk_score_mapping: [],
+ rule_id: 'rule-1',
+ risk_score: 50,
+ description: 'some description',
+ from: 'now-5m',
+ to: 'now',
+ name: 'some-name',
+ severity: 'low',
+ type: 'query',
+ query: 'some query',
+ index: ['auditbeat-*'],
+ data_view_id: 'logs-*',
+ interval: '5m',
+ references: [],
+ actions: [],
+ enabled: true,
+ false_positives: [],
+ max_signals: DEFAULT_MAX_SIGNALS,
+ tags: [],
+ threat: [],
+ throttle: null,
+ version: 1,
+ exceptions_list: [],
+ immutable: false,
+ };
+ expect(message.schema).toEqual(expected);
+ });
+
+ test('data_view_id cannot be a number', () => {
+ const payload: Omit & { data_view_id: number } = {
+ ...getImportRulesSchemaMock(),
+ data_view_id: 5,
+ };
+
+ const decoded = importRulesSchema.decode(payload);
+ const checked = exactCheck(payload, decoded);
+ const message = pipe(checked, foldLeftRight);
+ expect(getPaths(left(message.errors))).toEqual([
+ 'Invalid value "5" supplied to "data_view_id"',
+ ]);
+ expect(message.schema).toEqual({});
+ });
+ });
});
diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/import_rules_schema.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/import_rules_schema.ts
index e684cc30c17a5..6e419e97775e7 100644
--- a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/import_rules_schema.ts
+++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/import_rules_schema.ts
@@ -53,6 +53,7 @@ import {
filters,
RuleId,
index,
+ data_view_id,
output_index,
saved_id,
timeline_id,
@@ -123,6 +124,7 @@ export const importRulesSchema = t.intersection([
filters, // defaults to undefined if not set during decode
from: DefaultFromString, // defaults to "now-6m" if not set during decode
index, // defaults to undefined if not set during decode
+ data_view_id, // defaults to undefined if not set during decode
immutable: OnlyFalseAllowed, // defaults to "false" if not set during decode
interval: DefaultIntervalString, // defaults to "5m" if not set during decode
query, // defaults to undefined if not set during decode
diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/patch_rules_schema.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/patch_rules_schema.ts
index f37a4147ff559..80a267ab6c2da 100644
--- a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/patch_rules_schema.ts
+++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/patch_rules_schema.ts
@@ -38,6 +38,7 @@ import {
anomaly_threshold,
filters,
index,
+ data_view_id,
output_index,
saved_id,
timeline_id,
@@ -89,6 +90,7 @@ export const patchRulesSchema = t.exact(
from,
rule_id,
index,
+ data_view_id,
interval,
query,
language,
diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/rule_schemas.mock.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/rule_schemas.mock.ts
index 68371bca04eeb..994f8dc643425 100644
--- a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/rule_schemas.mock.ts
+++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/rule_schemas.mock.ts
@@ -29,6 +29,18 @@ export const getCreateRulesSchemaMock = (ruleId = 'rule-1'): QueryCreateSchema =
rule_id: ruleId,
});
+export const getCreateRulesSchemaMockWithDataView = (ruleId = 'rule-1'): QueryCreateSchema => ({
+ data_view_id: 'logs-*',
+ description: 'Detecting root and admin users',
+ name: 'Query with a rule id',
+ query: 'user.name: root or user.name: admin',
+ severity: 'high',
+ type: 'query',
+ risk_score: 55,
+ language: 'kuery',
+ rule_id: ruleId,
+});
+
export const getCreateSavedQueryRulesSchemaMock = (ruleId = 'rule-1'): SavedQueryCreateSchema => ({
description: 'Detecting root and admin users',
name: 'Query with a rule id',
@@ -56,7 +68,7 @@ export const getCreateThreatMatchRulesSchemaMock = (
language: 'kuery',
rule_id: ruleId,
threat_query: '*:*',
- threat_index: ['list-index'],
+ threat_index: ['auditbeat-*'],
threat_indicator_path: DEFAULT_INDICATOR_SOURCE_PATH,
interval: '5m',
from: 'now-6m',
diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/rule_schemas.test.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/rule_schemas.test.ts
index 4000b8af4829d..4342a37620d78 100644
--- a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/rule_schemas.test.ts
+++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/rule_schemas.test.ts
@@ -14,6 +14,8 @@ import {
getCreateThreatMatchRulesSchemaMock,
getCreateRulesSchemaMock,
getCreateThresholdRulesSchemaMock,
+ getCreateRulesSchemaMockWithDataView,
+ getCreateMachineLearningRulesSchemaMock,
} from './rule_schemas.mock';
import { getListArrayMock } from '../types/lists.mock';
@@ -1199,4 +1201,89 @@ describe('create rules schema', () => {
expect(message.schema).toEqual({});
});
});
+
+ describe('data_view_id', () => {
+ test('validates when "data_view_id" and index are defined', () => {
+ const payload = { ...getCreateRulesSchemaMockWithDataView(), index: ['auditbeat-*'] };
+ const decoded = createRulesSchema.decode(payload);
+ const checked = exactCheck(payload, decoded);
+ const message = pipe(checked, foldLeftRight);
+ expect(getPaths(left(message.errors))).toEqual([]);
+ expect(message.schema).toEqual(payload);
+ });
+
+ test('"data_view_id" cannot be a number', () => {
+ const payload: Omit & { data_view_id: number } = {
+ ...getCreateRulesSchemaMockWithDataView(),
+ data_view_id: 5,
+ };
+
+ const decoded = createRulesSchema.decode(payload);
+ const checked = exactCheck(payload, decoded);
+ const message = pipe(checked, foldLeftRight);
+ expect(getPaths(left(message.errors))).toEqual([
+ 'Invalid value "5" supplied to "data_view_id"',
+ ]);
+ expect(message.schema).toEqual({});
+ });
+
+ test('it should validate a type of "query" with "data_view_id" defined', () => {
+ const payload = getCreateRulesSchemaMockWithDataView();
+
+ const decoded = createRulesSchema.decode(payload);
+ const checked = exactCheck(payload, decoded);
+ const message = pipe(checked, foldLeftRight);
+ const expected = getCreateRulesSchemaMockWithDataView();
+
+ expect(getPaths(left(message.errors))).toEqual([]);
+ expect(message.schema).toEqual(expected);
+ });
+
+ test('it should validate a type of "saved_query" with "data_view_id" defined', () => {
+ const payload = { ...getCreateSavedQueryRulesSchemaMock(), data_view_id: 'logs-*' };
+
+ const decoded = createRulesSchema.decode(payload);
+ const checked = exactCheck(payload, decoded);
+ const message = pipe(checked, foldLeftRight);
+ const expected = { ...getCreateSavedQueryRulesSchemaMock(), data_view_id: 'logs-*' };
+
+ expect(getPaths(left(message.errors))).toEqual([]);
+ expect(message.schema).toEqual(expected);
+ });
+
+ test('it should validate a type of "threat_match" with "data_view_id" defined', () => {
+ const payload = { ...getCreateThreatMatchRulesSchemaMock(), data_view_id: 'logs-*' };
+
+ const decoded = createRulesSchema.decode(payload);
+ const checked = exactCheck(payload, decoded);
+ const message = pipe(checked, foldLeftRight);
+ const expected = { ...getCreateThreatMatchRulesSchemaMock(), data_view_id: 'logs-*' };
+
+ expect(getPaths(left(message.errors))).toEqual([]);
+ expect(message.schema).toEqual(expected);
+ });
+
+ test('it should validate a type of "threshold" with "data_view_id" defined', () => {
+ const payload = { ...getCreateThresholdRulesSchemaMock(), data_view_id: 'logs-*' };
+
+ const decoded = createRulesSchema.decode(payload);
+ const checked = exactCheck(payload, decoded);
+ const message = pipe(checked, foldLeftRight);
+ const expected = { ...getCreateThresholdRulesSchemaMock(), data_view_id: 'logs-*' };
+
+ expect(getPaths(left(message.errors))).toEqual([]);
+ expect(message.schema).toEqual(expected);
+ });
+
+ test('it should NOT validate a type of "machine_learning" with "data_view_id" defined', () => {
+ const payload = { ...getCreateMachineLearningRulesSchemaMock(), data_view_id: 'logs-*' };
+
+ const decoded = createRulesSchema.decode(payload);
+ const checked = exactCheck(payload, decoded);
+ const message = pipe(checked, foldLeftRight);
+
+ expect(message.schema).toEqual({});
+ expect(getPaths(left(message.errors))).toEqual(['invalid keys "data_view_id"']);
+ });
+ });
});
diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/rule_schemas.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/rule_schemas.ts
index 970032ee051da..33127dc434d82 100644
--- a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/rule_schemas.ts
+++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/rule_schemas.ts
@@ -32,6 +32,7 @@ import { version } from '@kbn/securitysolution-io-ts-types';
import {
id,
index,
+ data_view_id,
filters,
timestamp_field,
event_category_override,
@@ -214,6 +215,7 @@ const eqlRuleParams = {
},
optional: {
index,
+ data_view_id,
filters,
timestamp_field,
event_category_override,
@@ -238,6 +240,7 @@ const threatMatchRuleParams = {
},
optional: {
index,
+ data_view_id,
filters,
saved_id,
threat_filters,
@@ -263,6 +266,7 @@ const queryRuleParams = {
},
optional: {
index,
+ data_view_id,
filters,
saved_id,
},
@@ -288,6 +292,7 @@ const savedQueryRuleParams = {
// Having language, query, and filters possibly defined adds more code confusion and probably user confusion
// if the saved object gets deleted for some reason
index,
+ data_view_id,
query,
filters,
},
@@ -311,6 +316,7 @@ const thresholdRuleParams = {
},
optional: {
index,
+ data_view_id,
filters,
saved_id,
},
diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/response/rules_schema.mocks.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/response/rules_schema.mocks.ts
index eeaab6dc50021..1e532e29fcc3e 100644
--- a/x-pack/plugins/security_solution/common/detection_engine/schemas/response/rules_schema.mocks.ts
+++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/response/rules_schema.mocks.ts
@@ -152,7 +152,7 @@ export const getThreatMatchingSchemaPartialMock = (enabled = false): Partial {
test('should return two fields for a rule of type "query"', () => {
const fields = addQueryFields({ type: 'query' });
- expect(fields.length).toEqual(2);
+ expect(fields.length).toEqual(3);
});
test('should return two fields for a rule of type "threshold"', () => {
const fields = addQueryFields({ type: 'threshold' });
- expect(fields.length).toEqual(2);
+ expect(fields.length).toEqual(3);
});
test('should return two fields for a rule of type "saved_query"', () => {
const fields = addQueryFields({ type: 'saved_query' });
- expect(fields.length).toEqual(2);
+ expect(fields.length).toEqual(3);
});
test('should return two fields for a rule of type "threat_match"', () => {
const fields = addQueryFields({ type: 'threat_match' });
- expect(fields.length).toEqual(2);
+ expect(fields.length).toEqual(3);
});
});
@@ -777,7 +777,7 @@ describe('rules_schema', () => {
test('should return nine (9) fields for a rule of type "threat_match"', () => {
const fields = addThreatMatchFields({ type: 'threat_match' });
- expect(fields.length).toEqual(9);
+ expect(fields.length).toEqual(10);
});
});
@@ -790,7 +790,78 @@ describe('rules_schema', () => {
test('should return 3 fields for a rule of type "eql"', () => {
const fields = addEqlFields({ type: 'eql' });
- expect(fields.length).toEqual(5);
+ expect(fields.length).toEqual(6);
+ });
+ });
+
+ describe('data_view_id', () => {
+ test('it should validate a type of "query" with "data_view_id" defined', () => {
+ const payload = { ...getRulesSchemaMock(), data_view_id: 'logs-*' };
+
+ const decoded = rulesSchema.decode(payload);
+ const checked = exactCheck(payload, decoded);
+ const message = pipe(checked, foldLeftRight);
+ const expected = { ...getRulesSchemaMock(), data_view_id: 'logs-*' };
+
+ expect(getPaths(left(message.errors))).toEqual([]);
+ expect(message.schema).toEqual(expected);
+ });
+
+ test('it should validate a type of "saved_query" with "data_view_id" defined', () => {
+ const payload = getRulesSchemaMock();
+ payload.type = 'saved_query';
+ payload.saved_id = 'save id 123';
+ payload.data_view_id = 'logs-*';
+
+ const decoded = rulesSchema.decode(payload);
+ const checked = exactCheck(payload, decoded);
+ const message = pipe(checked, foldLeftRight);
+ const expected = getRulesSchemaMock();
+
+ expected.type = 'saved_query';
+ expected.saved_id = 'save id 123';
+ expected.data_view_id = 'logs-*';
+
+ expect(getPaths(left(message.errors))).toEqual([]);
+ expect(message.schema).toEqual(expected);
+ });
+
+ test('it should validate a type of "eql" with "data_view_id" defined', () => {
+ const payload = { ...getRulesEqlSchemaMock(), data_view_id: 'logs-*' };
+
+ const dependents = getDependents(payload);
+ const decoded = dependents.decode(payload);
+ const checked = exactCheck(payload, decoded);
+ const message = pipe(checked, foldLeftRight);
+ const expected = { ...getRulesEqlSchemaMock(), data_view_id: 'logs-*' };
+
+ expect(getPaths(left(message.errors))).toEqual([]);
+ expect(message.schema).toEqual(expected);
+ });
+
+ test('it should validate a type of "threat_match" with "data_view_id" defined', () => {
+ const payload = { ...getThreatMatchingSchemaMock(), data_view_id: 'logs-*' };
+
+ const dependents = getDependents(payload);
+ const decoded = dependents.decode(payload);
+ const checked = exactCheck(payload, decoded);
+ const message = pipe(checked, foldLeftRight);
+ const expected = { ...getThreatMatchingSchemaMock(), data_view_id: 'logs-*' };
+
+ expect(getPaths(left(message.errors))).toEqual([]);
+ expect(message.schema).toEqual(expected);
+ });
+
+ test('it should NOT validate a type of "machine_learning" with "data_view_id" defined', () => {
+ const payload = { ...getRulesMlSchemaMock(), data_view_id: 'logs-*' };
+
+ const dependents = getDependents(payload);
+ const decoded = dependents.decode(payload);
+ const checked = exactCheck(payload, decoded);
+ const message = pipe(checked, foldLeftRight);
+
+ expect(getPaths(left(message.errors))).toEqual(['invalid keys "data_view_id"']);
+ expect(message.schema).toEqual({});
});
});
});
diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/response/rules_schema.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/response/rules_schema.ts
index b02235a1bc018..b48ef59f132e8 100644
--- a/x-pack/plugins/security_solution/common/detection_engine/schemas/response/rules_schema.ts
+++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/response/rules_schema.ts
@@ -39,6 +39,7 @@ import { isMlRule } from '../../../machine_learning/helpers';
import { isThresholdRule } from '../../utils';
import {
anomaly_threshold,
+ data_view_id,
description,
enabled,
timestamp_field,
@@ -128,6 +129,9 @@ export type RequiredRulesSchema = t.TypeOf;
* check_type_dependents file for whichever REST flow it is going through.
*/
export const dependentRulesSchema = t.partial({
+ // All but ML
+ data_view_id,
+
// query fields
language,
query,
@@ -243,6 +247,7 @@ export const addQueryFields = (typeAndTimelineOnly: TypeAndTimelineOnly): t.Mixe
return [
t.exact(t.type({ query: dependentRulesSchema.props.query })),
t.exact(t.type({ language: dependentRulesSchema.props.language })),
+ t.exact(t.partial({ data_view_id: dependentRulesSchema.props.data_view_id })),
];
} else {
return [];
@@ -267,6 +272,7 @@ export const addThresholdFields = (typeAndTimelineOnly: TypeAndTimelineOnly): t.
return [
t.exact(t.type({ threshold: dependentRulesSchema.props.threshold })),
t.exact(t.partial({ saved_id: dependentRulesSchema.props.saved_id })),
+ t.exact(t.partial({ data_view_id: dependentRulesSchema.props.data_view_id })),
];
} else {
return [];
@@ -283,6 +289,7 @@ export const addEqlFields = (typeAndTimelineOnly: TypeAndTimelineOnly): t.Mixed[
t.exact(t.partial({ tiebreaker_field: dependentRulesSchema.props.tiebreaker_field })),
t.exact(t.type({ query: dependentRulesSchema.props.query })),
t.exact(t.type({ language: dependentRulesSchema.props.language })),
+ t.exact(t.partial({ data_view_id: dependentRulesSchema.props.data_view_id })),
];
} else {
return [];
@@ -292,6 +299,7 @@ export const addEqlFields = (typeAndTimelineOnly: TypeAndTimelineOnly): t.Mixed[
export const addThreatMatchFields = (typeAndTimelineOnly: TypeAndTimelineOnly): t.Mixed[] => {
if (typeAndTimelineOnly.type === 'threat_match') {
return [
+ t.exact(t.partial({ data_view_id: dependentRulesSchema.props.data_view_id })),
t.exact(t.type({ threat_query: dependentRulesSchema.props.threat_query })),
t.exact(t.type({ threat_index: dependentRulesSchema.props.threat_index })),
t.exact(t.type({ threat_mapping: dependentRulesSchema.props.threat_mapping })),
diff --git a/x-pack/plugins/security_solution/common/detection_engine/utils.test.ts b/x-pack/plugins/security_solution/common/detection_engine/utils.test.ts
index 52cd4e79e6a6b..b26bb85a263a1 100644
--- a/x-pack/plugins/security_solution/common/detection_engine/utils.test.ts
+++ b/x-pack/plugins/security_solution/common/detection_engine/utils.test.ts
@@ -11,6 +11,7 @@ import {
isThreatMatchRule,
normalizeMachineLearningJobIds,
normalizeThresholdField,
+ isMlRule,
} from './utils';
import { hasLargeValueList } from '@kbn/securitysolution-list-utils';
@@ -124,6 +125,16 @@ describe('#hasNestedEntry', () => {
});
});
+describe('isMlRule', () => {
+ test('it returns true if a ML rule', () => {
+ expect(isMlRule('machine_learning')).toEqual(true);
+ });
+
+ test('it returns false if not a Ml rule', () => {
+ expect(isMlRule('query')).toEqual(false);
+ });
+});
+
describe('#hasEqlSequenceQuery', () => {
describe('when a non-sequence query is passed', () => {
const query = 'process where process.name == "regsvr32.exe"';
diff --git a/x-pack/plugins/security_solution/common/detection_engine/utils.ts b/x-pack/plugins/security_solution/common/detection_engine/utils.ts
index de5a05edc2f8a..5d81e1af7ea4a 100644
--- a/x-pack/plugins/security_solution/common/detection_engine/utils.ts
+++ b/x-pack/plugins/security_solution/common/detection_engine/utils.ts
@@ -37,12 +37,14 @@ export const hasEqlSequenceQuery = (ruleQuery: string | undefined): boolean => {
return false;
};
+// these functions should be typeguards and accept an entire rule.
export const isEqlRule = (ruleType: Type | undefined): boolean => ruleType === 'eql';
export const isThresholdRule = (ruleType: Type | undefined): boolean => ruleType === 'threshold';
export const isQueryRule = (ruleType: Type | undefined): boolean =>
ruleType === 'query' || ruleType === 'saved_query';
export const isThreatMatchRule = (ruleType: Type | undefined): boolean =>
ruleType === 'threat_match';
+export const isMlRule = (ruleType: Type | undefined): boolean => ruleType === 'machine_learning';
export const normalizeThresholdField = (
thresholdField: string | string[] | null | undefined
diff --git a/x-pack/plugins/security_solution/common/endpoint/schema/actions.test.ts b/x-pack/plugins/security_solution/common/endpoint/schema/actions.test.ts
index 47e78cdab4a53..60cab431a5444 100644
--- a/x-pack/plugins/security_solution/common/endpoint/schema/actions.test.ts
+++ b/x-pack/plugins/security_solution/common/endpoint/schema/actions.test.ts
@@ -10,7 +10,7 @@ import uuid from 'uuid';
import {
EndpointActionListRequestSchema,
HostIsolationRequestSchema,
- KillProcessRequestSchema,
+ KillOrSuspendProcessRequestSchema,
} from './actions';
describe('actions schemas', () => {
@@ -190,7 +190,7 @@ describe('actions schemas', () => {
});
});
- describe('KillProcessRequestSchema', () => {
+ describe('KillOrSuspendProcessRequestSchema', () => {
it('should require at least 1 Endpoint ID', () => {
expect(() => {
HostIsolationRequestSchema.body.validate({});
@@ -199,7 +199,7 @@ describe('actions schemas', () => {
it('should accept pid', () => {
expect(() => {
- KillProcessRequestSchema.body.validate({
+ KillOrSuspendProcessRequestSchema.body.validate({
endpoint_ids: ['ABC-XYZ-000'],
parameters: {
pid: 1234,
@@ -210,7 +210,7 @@ describe('actions schemas', () => {
it('should accept entity_id', () => {
expect(() => {
- KillProcessRequestSchema.body.validate({
+ KillOrSuspendProcessRequestSchema.body.validate({
endpoint_ids: ['ABC-XYZ-000'],
parameters: {
entity_id: 5678,
@@ -221,7 +221,7 @@ describe('actions schemas', () => {
it('should reject pid and entity_id together', () => {
expect(() => {
- KillProcessRequestSchema.body.validate({
+ KillOrSuspendProcessRequestSchema.body.validate({
endpoint_ids: ['ABC-XYZ-000'],
parameters: {
pid: 1234,
@@ -233,7 +233,7 @@ describe('actions schemas', () => {
it('should reject if no pid or entity_id', () => {
expect(() => {
- KillProcessRequestSchema.body.validate({
+ KillOrSuspendProcessRequestSchema.body.validate({
endpoint_ids: ['ABC-XYZ-000'],
comment: 'a user comment',
parameters: {},
@@ -243,7 +243,7 @@ describe('actions schemas', () => {
it('should accept a comment', () => {
expect(() => {
- KillProcessRequestSchema.body.validate({
+ KillOrSuspendProcessRequestSchema.body.validate({
endpoint_ids: ['ABC-XYZ-000'],
comment: 'a user comment',
parameters: {
diff --git a/x-pack/plugins/security_solution/common/endpoint/schema/actions.ts b/x-pack/plugins/security_solution/common/endpoint/schema/actions.ts
index 944b21b9b910d..c4dfa7a5b434c 100644
--- a/x-pack/plugins/security_solution/common/endpoint/schema/actions.ts
+++ b/x-pack/plugins/security_solution/common/endpoint/schema/actions.ts
@@ -22,7 +22,7 @@ export const HostIsolationRequestSchema = {
body: schema.object({ ...BaseActionRequestSchema }),
};
-export const KillProcessRequestSchema = {
+export const KillOrSuspendProcessRequestSchema = {
body: schema.object({
...BaseActionRequestSchema,
parameters: schema.oneOf([
@@ -34,7 +34,7 @@ export const KillProcessRequestSchema = {
export const ResponseActionBodySchema = schema.oneOf([
HostIsolationRequestSchema.body,
- KillProcessRequestSchema.body,
+ KillOrSuspendProcessRequestSchema.body,
]);
export const EndpointActionLogRequestSchema = {
diff --git a/x-pack/plugins/security_solution/common/endpoint/service/authz/authz.test.ts b/x-pack/plugins/security_solution/common/endpoint/service/authz/authz.test.ts
index b9fe201a1cddf..0389ac8e216ae 100644
--- a/x-pack/plugins/security_solution/common/endpoint/service/authz/authz.test.ts
+++ b/x-pack/plugins/security_solution/common/endpoint/service/authz/authz.test.ts
@@ -29,6 +29,7 @@ describe('Endpoint Authz service', () => {
['canIsolateHost'],
['canUnIsolateHost'],
['canKillProcess'],
+ ['canSuspendProcess'],
])('should set `%s` to `true`', (authProperty) => {
expect(calculateEndpointAuthz(licenseService, fleetAuthz, userRoles)[authProperty]).toBe(
true
@@ -51,6 +52,14 @@ describe('Endpoint Authz service', () => {
);
});
+ it('should set `canSuspendProcess` to false if not proper license', () => {
+ licenseService.isPlatinumPlus.mockReturnValue(false);
+
+ expect(
+ calculateEndpointAuthz(licenseService, fleetAuthz, userRoles).canSuspendProcess
+ ).toBe(false);
+ });
+
it('should set `canUnIsolateHost` to true even if not proper license', () => {
licenseService.isPlatinumPlus.mockReturnValue(false);
@@ -72,6 +81,7 @@ describe('Endpoint Authz service', () => {
['canIsolateHost'],
['canUnIsolateHost'],
['canKillProcess'],
+ ['canSuspendProcess'],
])('should set `%s` to `false`', (authProperty) => {
expect(calculateEndpointAuthz(licenseService, fleetAuthz, userRoles)[authProperty]).toBe(
false
@@ -97,6 +107,7 @@ describe('Endpoint Authz service', () => {
canUnIsolateHost: true,
canCreateArtifactsByPolicy: false,
canKillProcess: false,
+ canSuspendProcess: false,
});
});
});
diff --git a/x-pack/plugins/security_solution/common/endpoint/service/authz/authz.ts b/x-pack/plugins/security_solution/common/endpoint/service/authz/authz.ts
index 7c515cf1a3595..5acf3e5df1975 100644
--- a/x-pack/plugins/security_solution/common/endpoint/service/authz/authz.ts
+++ b/x-pack/plugins/security_solution/common/endpoint/service/authz/authz.ts
@@ -33,6 +33,7 @@ export const calculateEndpointAuthz = (
canIsolateHost: isPlatinumPlusLicense && hasAllAccessToFleet,
canUnIsolateHost: hasAllAccessToFleet,
canKillProcess: hasAllAccessToFleet && isPlatinumPlusLicense,
+ canSuspendProcess: hasAllAccessToFleet && isPlatinumPlusLicense,
};
};
@@ -44,5 +45,6 @@ export const getEndpointAuthzInitialState = (): EndpointAuthz => {
canIsolateHost: false,
canUnIsolateHost: true,
canKillProcess: false,
+ canSuspendProcess: false,
};
};
diff --git a/x-pack/plugins/security_solution/common/endpoint/types/actions.ts b/x-pack/plugins/security_solution/common/endpoint/types/actions.ts
index 38d5bdca028b8..2dd82d7609de4 100644
--- a/x-pack/plugins/security_solution/common/endpoint/types/actions.ts
+++ b/x-pack/plugins/security_solution/common/endpoint/types/actions.ts
@@ -14,7 +14,7 @@ import {
export type ISOLATION_ACTIONS = 'isolate' | 'unisolate';
-export type ResponseActions = ISOLATION_ACTIONS | 'kill-process';
+export type ResponseActions = ISOLATION_ACTIONS | 'kill-process' | 'suspend-process';
export const ActivityLogItemTypes = {
ACTION: 'action' as const,
@@ -76,19 +76,23 @@ export interface LogsEndpointActionResponse {
error?: EcsError;
}
-interface KillProcessWithPid {
+interface ResponseActionParametersWithPid {
pid: number;
entity_id?: never;
}
-interface KillProcessWithEntityId {
+interface ResponseActionParametersWithEntityId {
pid?: never;
entity_id: number;
}
-export type KillProcessParameters = KillProcessWithPid | KillProcessWithEntityId;
+export type ResponseActionParametersWithPidOrEntityId =
+ | ResponseActionParametersWithPid
+ | ResponseActionParametersWithEntityId;
-export type EndpointActionDataParameterTypes = undefined | KillProcessParameters;
+export type EndpointActionDataParameterTypes =
+ | undefined
+ | ResponseActionParametersWithPidOrEntityId;
export interface EndpointActionData {
command: ResponseActions;
@@ -194,7 +198,7 @@ export interface HostIsolationResponse {
}
export interface ResponseActionApiResponse {
- action?: string;
+ action?: string; // only if command is isolate or release
data: ActionDetails;
}
diff --git a/x-pack/plugins/security_solution/common/endpoint/types/authz.ts b/x-pack/plugins/security_solution/common/endpoint/types/authz.ts
index 3b07bc5e9b162..3f7a50537177f 100644
--- a/x-pack/plugins/security_solution/common/endpoint/types/authz.ts
+++ b/x-pack/plugins/security_solution/common/endpoint/types/authz.ts
@@ -22,6 +22,8 @@ export interface EndpointAuthz {
canUnIsolateHost: boolean;
/** If user has permissions to kill process on hosts */
canKillProcess: boolean;
+ /** If user has permissions to suspend process on hosts */
+ canSuspendProcess: boolean;
}
export type EndpointAuthzKeyList = Array;
diff --git a/x-pack/plugins/security_solution/common/field_maps/field_names.ts b/x-pack/plugins/security_solution/common/field_maps/field_names.ts
index 164f87b4e7b5c..29ed0f5985ec5 100644
--- a/x-pack/plugins/security_solution/common/field_maps/field_names.ts
+++ b/x-pack/plugins/security_solution/common/field_maps/field_names.ts
@@ -38,3 +38,4 @@ export const ALERT_RULE_THROTTLE = `${ALERT_RULE_NAMESPACE}.throttle` as const;
export const ALERT_RULE_TIMELINE_ID = `${ALERT_RULE_NAMESPACE}.timeline_id` as const;
export const ALERT_RULE_TIMELINE_TITLE = `${ALERT_RULE_NAMESPACE}.timeline_title` as const;
export const ALERT_RULE_TIMESTAMP_OVERRIDE = `${ALERT_RULE_NAMESPACE}.timestamp_override` as const;
+export const ALERT_RULE_INDICES = `${ALERT_RULE_NAMESPACE}.indices` as const;
diff --git a/x-pack/plugins/security_solution/cypress/screens/rule_details.ts b/x-pack/plugins/security_solution/cypress/screens/rule_details.ts
index a9134e5b124eb..c7726ac40e83d 100644
--- a/x-pack/plugins/security_solution/cypress/screens/rule_details.ts
+++ b/x-pack/plugins/security_solution/cypress/screens/rule_details.ts
@@ -39,6 +39,8 @@ export const FALSE_POSITIVES_DETAILS = 'False positive examples';
export const INDEX_PATTERNS_DETAILS = 'Index patterns';
+export const DATA_VIEW_DETAILS = 'Data View';
+
export const INDICATOR_INDEX_PATTERNS = 'Indicator index patterns';
export const INDICATOR_INDEX_QUERY = 'Indicator index query';
diff --git a/x-pack/plugins/security_solution/cypress/tasks/create_new_rule.ts b/x-pack/plugins/security_solution/cypress/tasks/create_new_rule.ts
index ed69f1d18d5e6..23602c6493da3 100644
--- a/x-pack/plugins/security_solution/cypress/tasks/create_new_rule.ts
+++ b/x-pack/plugins/security_solution/cypress/tasks/create_new_rule.ts
@@ -255,6 +255,7 @@ export const fillDefineCustomRuleWithImportedQueryAndContinue = (
cy.get(IMPORT_QUERY_FROM_SAVED_TIMELINE_LINK).click();
cy.get(TIMELINE(rule.timeline.id)).click();
cy.get(CUSTOM_QUERY_INPUT).should('have.value', rule.customQuery);
+
cy.get(DEFINE_CONTINUE_BUTTON).should('exist').click({ force: true });
cy.get(CUSTOM_QUERY_INPUT).should('not.exist');
@@ -316,6 +317,7 @@ export const fillDefineEqlRuleAndContinue = (rule: CustomRule) => {
if (rule.customQuery == null) {
throw new TypeError('The rule custom query should never be undefined or null ');
}
+
cy.get(RULES_CREATION_FORM).find(EQL_QUERY_INPUT).should('exist');
cy.get(RULES_CREATION_FORM).find(EQL_QUERY_INPUT).should('be.visible');
cy.get(RULES_CREATION_FORM).find(EQL_QUERY_INPUT).type(rule.customQuery);
@@ -394,6 +396,7 @@ export const fillIndexAndIndicatorIndexPattern = (
indicatorIndex?: string[]
) => {
getIndexPatternClearButton().click();
+
getIndicatorIndex().type(`${indexPattern}{enter}`);
getIndicatorIndicatorIndex().type(`{backspace}{enter}${indicatorIndex}{enter}`);
};
@@ -442,7 +445,9 @@ export const getAboutContinueButton = () => cy.get(ABOUT_CONTINUE_BTN);
export const getDefineContinueButton = () => cy.get(DEFINE_CONTINUE_BUTTON);
/** Returns the indicator index pattern */
-export const getIndicatorIndex = () => cy.get(THREAT_MATCH_INDICATOR_INDEX).eq(0);
+export const getIndicatorIndex = () => {
+ return cy.get(THREAT_MATCH_INDICATOR_INDEX).eq(0);
+};
/** Returns the indicator's indicator index */
export const getIndicatorIndicatorIndex = () =>
diff --git a/x-pack/plugins/security_solution/cypress/tasks/rule_details.ts b/x-pack/plugins/security_solution/cypress/tasks/rule_details.ts
index 267988462cfb8..7fda21016205a 100644
--- a/x-pack/plugins/security_solution/cypress/tasks/rule_details.ts
+++ b/x-pack/plugins/security_solution/cypress/tasks/rule_details.ts
@@ -19,6 +19,7 @@ import {
import {
ALERTS_TAB,
BACK_TO_RULES,
+ DATA_VIEW_DETAILS,
EXCEPTIONS_TAB,
FIELDS_BROWSER_BTN,
REFRESH_BUTTON,
@@ -71,7 +72,7 @@ export const openExceptionFlyoutFromRuleSettings = () => {
export const addsExceptionFromRuleSettings = (exception: Exception) => {
openExceptionFlyoutFromRuleSettings();
- cy.get(FIELD_INPUT).type(`${exception.field}{enter}`);
+ cy.get(FIELD_INPUT).type(`${exception.field}{downArrow}{enter}`);
cy.get(OPERATOR_INPUT).type(`${exception.operator}{enter}`);
exception.values.forEach((value) => {
cy.get(VALUES_INPUT).type(`${value}{enter}`);
@@ -121,3 +122,9 @@ export const hasIndexPatterns = (indexPatterns: string) => {
getDetails(INDEX_PATTERNS_DETAILS).should('have.text', indexPatterns);
});
};
+
+export const doesNotHaveDataView = () => {
+ cy.get(DEFINITION_DETAILS).within(() => {
+ cy.get(DETAILS_TITLE).within(() => cy.get(DATA_VIEW_DETAILS).should('not.exist'));
+ });
+};
diff --git a/x-pack/plugins/security_solution/public/app/home/template_wrapper/bottom_bar/index.tsx b/x-pack/plugins/security_solution/public/app/home/template_wrapper/bottom_bar/index.tsx
index 54ffea7edf6d6..853c4b634d348 100644
--- a/x-pack/plugins/security_solution/public/app/home/template_wrapper/bottom_bar/index.tsx
+++ b/x-pack/plugins/security_solution/public/app/home/template_wrapper/bottom_bar/index.tsx
@@ -10,7 +10,6 @@
import React from 'react';
import { KibanaPageTemplateProps } from '@kbn/shared-ux-components';
import { AppLeaveHandler } from '@kbn/core/public';
-import { useShowTimeline } from '../../../../common/utils/timeline/use_show_timeline';
import { TimelineId } from '../../../../../common/types/timeline';
import { AutoSaveWarningMsg } from '../../../../timelines/components/timeline/auto_save_warning';
import { Flyout } from '../../../../timelines/components/flyout';
@@ -20,15 +19,13 @@ export const BOTTOM_BAR_CLASSNAME = 'timeline-bottom-bar';
export const SecuritySolutionBottomBar = React.memo(
({ onAppLeave }: { onAppLeave: (handler: AppLeaveHandler) => void }) => {
- const [showTimeline] = useShowTimeline();
-
useResolveRedirect();
- return showTimeline ? (
+ return (
<>
>
- ) : null;
+ );
}
);
diff --git a/x-pack/plugins/security_solution/public/app/home/template_wrapper/index.test.tsx b/x-pack/plugins/security_solution/public/app/home/template_wrapper/index.test.tsx
index 005e671bb48c7..24a42dbd6ee03 100644
--- a/x-pack/plugins/security_solution/public/app/home/template_wrapper/index.test.tsx
+++ b/x-pack/plugins/security_solution/public/app/home/template_wrapper/index.test.tsx
@@ -11,9 +11,10 @@ import React from 'react';
import { TestProviders } from '../../../common/mock';
import { SecuritySolutionTemplateWrapper } from '.';
+const mockUseShowTimeline = jest.fn((): [boolean] => [false]);
jest.mock('../../../common/utils/timeline/use_show_timeline', () => ({
...jest.requireActual('../../../common/utils/timeline/use_show_timeline'),
- useShowTimeline: () => [true],
+ useShowTimeline: () => mockUseShowTimeline(),
}));
jest.mock('./bottom_bar', () => ({
@@ -21,25 +22,6 @@ jest.mock('./bottom_bar', () => ({
SecuritySolutionBottomBar: () => {'Bottom Bar'}
,
}));
-const mockSiemUserCanCrud = jest.fn();
-jest.mock('../../../common/lib/kibana', () => {
- const original = jest.requireActual('../../../common/lib/kibana');
-
- return {
- ...original,
- useKibana: () => ({
- services: {
- ...original.useKibana().services,
- application: {
- capabilities: {
- siem: mockSiemUserCanCrud(),
- },
- },
- },
- }),
- };
-});
-
jest.mock('../../../common/components/navigation/use_security_solution_navigation', () => {
return {
useSecuritySolutionNavigation: () => ({
@@ -82,8 +64,8 @@ describe('SecuritySolutionTemplateWrapper', () => {
jest.clearAllMocks();
});
- it('Should render to the page with bottom bar if user has SIEM show', async () => {
- mockSiemUserCanCrud.mockReturnValue({ show: true });
+ it('Should render with bottom bar when user allowed', async () => {
+ mockUseShowTimeline.mockReturnValue([true]);
const { getByText } = renderComponent();
await waitFor(() => {
@@ -92,8 +74,8 @@ describe('SecuritySolutionTemplateWrapper', () => {
});
});
- it('Should not show bottom bar if user does not have SIEM show', async () => {
- mockSiemUserCanCrud.mockReturnValue({ show: false });
+ it('Should not show bottom bar when user not allowed', async () => {
+ mockUseShowTimeline.mockReturnValue([false]);
const { getByText, queryByText } = renderComponent();
diff --git a/x-pack/plugins/security_solution/public/app/home/template_wrapper/index.tsx b/x-pack/plugins/security_solution/public/app/home/template_wrapper/index.tsx
index 8d7d9daad550d..6888a75da2006 100644
--- a/x-pack/plugins/security_solution/public/app/home/template_wrapper/index.tsx
+++ b/x-pack/plugins/security_solution/public/app/home/template_wrapper/index.tsx
@@ -23,7 +23,6 @@ import {
} from './bottom_bar';
import { useShowTimeline } from '../../../common/utils/timeline/use_show_timeline';
import { gutterTimeline } from '../../../common/lib/helpers';
-import { useKibana } from '../../../common/lib/kibana';
import { useShowPagesWithEmptyView } from '../../../common/utils/empty_view/use_show_pages_with_empty_view';
import { useIsPolicySettingsBarVisible } from '../../../management/pages/policy/view/policy_hooks';
import { useIsGroupedNavigationEnabled } from '../../../common/components/navigation/helpers';
@@ -91,7 +90,6 @@ export const SecuritySolutionTemplateWrapper: React.FC
+ isTimelineBottomBarVisible &&
}
paddingSize="none"
solutionNav={solutionNav}
diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/alert_summary_view.test.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/alert_summary_view.test.tsx
index 2e7763c169d0d..3e6da12efe8e8 100644
--- a/x-pack/plugins/security_solution/public/common/components/event_details/alert_summary_view.test.tsx
+++ b/x-pack/plugins/security_solution/public/common/components/event_details/alert_summary_view.test.tsx
@@ -6,7 +6,7 @@
*/
import React from 'react';
-import { waitFor, render } from '@testing-library/react';
+import { waitFor, render, act } from '@testing-library/react';
import { AlertSummaryView } from './alert_summary_view';
import { mockAlertDetailsData } from './__mocks__';
@@ -44,59 +44,69 @@ describe('AlertSummaryView', () => {
},
});
});
- test('render correct items', () => {
- const { getByTestId } = render(
-
-
-
- );
- expect(getByTestId('summary-view')).toBeInTheDocument();
+ test('render correct items', async () => {
+ await act(async () => {
+ const { getByTestId } = render(
+
+
+
+ );
+ expect(getByTestId('summary-view')).toBeInTheDocument();
+ });
});
- test('it renders the action cell by default', () => {
- const { getAllByTestId } = render(
-
-
-
- );
- expect(getAllByTestId('hover-actions-filter-for').length).toBeGreaterThan(0);
+ test('it renders the action cell by default', async () => {
+ await act(async () => {
+ const { getAllByTestId } = render(
+
+
+
+ );
+ expect(getAllByTestId('hover-actions-filter-for').length).toBeGreaterThan(0);
+ });
});
- test('Renders the correct global fields', () => {
- const { getByText } = render(
-
-
-
- );
-
- [
- 'host.name',
- 'user.name',
- i18n.RULE_TYPE,
- 'query',
- i18n.SOURCE_EVENT_ID,
- i18n.SESSION_ID,
- ].forEach((fieldId) => {
- expect(getByText(fieldId));
+ test('Renders the correct global fields', async () => {
+ await act(async () => {
+ const { getByText } = render(
+
+
+
+ );
+
+ [
+ 'host.name',
+ 'user.name',
+ i18n.RULE_TYPE,
+ 'query',
+ i18n.SOURCE_EVENT_ID,
+ i18n.SESSION_ID,
+ ].forEach((fieldId) => {
+ expect(getByText(fieldId));
+ });
});
});
- test('it does NOT render the action cell for the active timeline', () => {
- const { queryAllByTestId } = render(
-
-
-
- );
- expect(queryAllByTestId('hover-actions-filter-for').length).toEqual(0);
+ test('it does NOT render the action cell for the active timeline', async () => {
+ await act(async () => {
+ const { queryAllByTestId } = render(
+
+
+
+ );
+ expect(queryAllByTestId('hover-actions-filter-for').length).toEqual(0);
+ });
});
- test('it does NOT render the action cell when readOnly is passed', () => {
- const { queryAllByTestId } = render(
-
-
-
- );
- expect(queryAllByTestId('hover-actions-filter-for').length).toEqual(0);
+ test('it does NOT render the action cell when readOnly is passed', async () => {
+ await act(async () => {
+ const { queryAllByTestId } = render(
+
+
+
+ );
+ expect(queryAllByTestId('hover-actions-filter-for').length).toEqual(0);
+ });
});
test("render no investigation guide if it doesn't exist", async () => {
@@ -105,16 +115,18 @@ describe('AlertSummaryView', () => {
note: null,
},
});
- const { queryByTestId } = render(
-
-
-
- );
- await waitFor(() => {
- expect(queryByTestId('summary-view-guide')).not.toBeInTheDocument();
+ await act(async () => {
+ const { queryByTestId } = render(
+
+
+
+ );
+ await waitFor(() => {
+ expect(queryByTestId('summary-view-guide')).not.toBeInTheDocument();
+ });
});
});
- test('Network event renders the correct summary rows', () => {
+ test('Network event renders the correct summary rows', async () => {
const renderProps = {
...props,
data: mockAlertDetailsData.map((item) => {
@@ -128,25 +140,27 @@ describe('AlertSummaryView', () => {
return item;
}) as TimelineEventsDetailsItem[],
};
- const { getByText } = render(
-
-
-
- );
-
- [
- 'host.name',
- 'user.name',
- 'destination.address',
- 'source.address',
- 'source.port',
- 'process.name',
- ].forEach((fieldId) => {
- expect(getByText(fieldId));
+ await act(async () => {
+ const { getByText } = render(
+
+
+
+ );
+
+ [
+ 'host.name',
+ 'user.name',
+ 'destination.address',
+ 'source.address',
+ 'source.port',
+ 'process.name',
+ ].forEach((fieldId) => {
+ expect(getByText(fieldId));
+ });
});
});
- test('DNS network event renders the correct summary rows', () => {
+ test('DNS network event renders the correct summary rows', async () => {
const renderProps = {
...props,
data: [
@@ -168,18 +182,20 @@ describe('AlertSummaryView', () => {
} as TimelineEventsDetailsItem,
],
};
- const { getByText } = render(
-
-
-
- );
-
- ['dns.question.name', 'process.name'].forEach((fieldId) => {
- expect(getByText(fieldId));
+ await act(async () => {
+ const { getByText } = render(
+
+
+
+ );
+
+ ['dns.question.name', 'process.name'].forEach((fieldId) => {
+ expect(getByText(fieldId));
+ });
});
});
- test('Memory event code renders additional summary rows', () => {
+ test('Memory event code renders additional summary rows', async () => {
const renderProps = {
...props,
data: mockAlertDetailsData.map((item) => {
@@ -193,16 +209,18 @@ describe('AlertSummaryView', () => {
return item;
}) as TimelineEventsDetailsItem[],
};
- const { getByText } = render(
-
-
-
- );
- ['host.name', 'user.name', 'Target.process.executable'].forEach((fieldId) => {
- expect(getByText(fieldId));
+ await act(async () => {
+ const { getByText } = render(
+
+
+
+ );
+ ['host.name', 'user.name', 'Target.process.executable'].forEach((fieldId) => {
+ expect(getByText(fieldId));
+ });
});
});
- test('Behavior event code renders additional summary rows', () => {
+ test('Behavior event code renders additional summary rows', async () => {
const actualRuleDescription = 'The actual rule description';
const renderProps = {
...props,
@@ -232,17 +250,19 @@ describe('AlertSummaryView', () => {
},
] as TimelineEventsDetailsItem[],
};
- const { getByText } = render(
-
-
-
- );
- ['host.name', 'user.name', 'process.name', actualRuleDescription].forEach((fieldId) => {
- expect(getByText(fieldId));
+ await act(async () => {
+ const { getByText } = render(
+
+
+
+ );
+ ['host.name', 'user.name', 'process.name', actualRuleDescription].forEach((fieldId) => {
+ expect(getByText(fieldId));
+ });
});
});
- test('Malware event category shows file fields', () => {
+ test('Malware event category shows file fields', async () => {
const enhancedData = [
...mockAlertDetailsData.map((item) => {
if (item.category === 'event' && item.field === 'event.category') {
@@ -265,17 +285,19 @@ describe('AlertSummaryView', () => {
...props,
data: enhancedData,
};
- const { getByText } = render(
-
-
-
- );
- ['host.name', 'user.name', 'file.name', 'file.hash.sha256'].forEach((fieldId) => {
- expect(getByText(fieldId));
+ await act(async () => {
+ const { getByText } = render(
+
+
+
+ );
+ ['host.name', 'user.name', 'file.name', 'file.hash.sha256'].forEach((fieldId) => {
+ expect(getByText(fieldId));
+ });
});
});
- test('Ransomware event code shows correct fields', () => {
+ test('Ransomware event code shows correct fields', async () => {
const enhancedData = [
...mockAlertDetailsData.map((item) => {
if (item.category === 'event' && item.field === 'event.code') {
@@ -298,17 +320,19 @@ describe('AlertSummaryView', () => {
...props,
data: enhancedData,
};
- const { getByText } = render(
-
-
-
- );
- ['process.hash.sha256', 'Ransomware.feature'].forEach((fieldId) => {
- expect(getByText(fieldId));
+ await act(async () => {
+ const { getByText } = render(
+
+
+
+ );
+ ['process.hash.sha256', 'Ransomware.feature'].forEach((fieldId) => {
+ expect(getByText(fieldId));
+ });
});
});
- test('Machine learning events show correct fields', () => {
+ test('Machine learning events show correct fields', async () => {
const enhancedData = [
...mockAlertDetailsData.map((item) => {
if (item.category === 'kibana' && item.field === 'kibana.alert.rule.type') {
@@ -331,17 +355,21 @@ describe('AlertSummaryView', () => {
...props,
data: enhancedData,
};
- const { getByText } = render(
-
-
-
- );
- ['i_am_the_ml_job_id', 'kibana.alert.rule.parameters.anomaly_threshold'].forEach((fieldId) => {
- expect(getByText(fieldId));
+ await act(async () => {
+ const { getByText } = render(
+
+
+
+ );
+ ['i_am_the_ml_job_id', 'kibana.alert.rule.parameters.anomaly_threshold'].forEach(
+ (fieldId) => {
+ expect(getByText(fieldId));
+ }
+ );
});
});
- test('[legacy] Machine learning events show correct fields', () => {
+ test('[legacy] Machine learning events show correct fields', async () => {
const enhancedData = [
...mockAlertDetailsData.map((item) => {
if (item.category === 'kibana' && item.field === 'kibana.alert.rule.type') {
@@ -364,17 +392,19 @@ describe('AlertSummaryView', () => {
...props,
data: enhancedData,
};
- const { getByText } = render(
-
-
-
- );
- ['i_am_the_ml_job_id', 'signal.rule.anomaly_threshold'].forEach((fieldId) => {
- expect(getByText(fieldId));
+ await act(async () => {
+ const { getByText } = render(
+
+
+
+ );
+ ['i_am_the_ml_job_id', 'signal.rule.anomaly_threshold'].forEach((fieldId) => {
+ expect(getByText(fieldId));
+ });
});
});
- test('Threat match events show correct fields', () => {
+ test('Threat match events show correct fields', async () => {
const enhancedData = [
...mockAlertDetailsData.map((item) => {
if (item.category === 'kibana' && item.field === 'kibana.alert.rule.type') {
@@ -401,17 +431,19 @@ describe('AlertSummaryView', () => {
...props,
data: enhancedData,
};
- const { getByText } = render(
-
-
-
- );
- ['threat_index*', '*query*'].forEach((fieldId) => {
- expect(getByText(fieldId));
+ await act(async () => {
+ const { getByText } = render(
+
+
+
+ );
+ ['threat_index*', '*query*'].forEach((fieldId) => {
+ expect(getByText(fieldId));
+ });
});
});
- test('[legacy] Threat match events show correct fields', () => {
+ test('[legacy] Threat match events show correct fields', async () => {
const enhancedData = [
...mockAlertDetailsData.map((item) => {
if (item.category === 'kibana' && item.field === 'kibana.alert.rule.type') {
@@ -438,17 +470,19 @@ describe('AlertSummaryView', () => {
...props,
data: enhancedData,
};
- const { getByText } = render(
-
-
-
- );
- ['threat_index*', '*query*'].forEach((fieldId) => {
- expect(getByText(fieldId));
+ await act(async () => {
+ const { getByText } = render(
+
+
+
+ );
+ ['threat_index*', '*query*'].forEach((fieldId) => {
+ expect(getByText(fieldId));
+ });
});
});
- test('Ransomware event code resolves fields from the source event', () => {
+ test('Ransomware event code resolves fields from the source event', async () => {
const renderProps = {
...props,
data: mockAlertDetailsData.map((item) => {
@@ -469,17 +503,19 @@ describe('AlertSummaryView', () => {
return item;
}) as TimelineEventsDetailsItem[],
};
- const { getByText } = render(
-
-
-
- );
- ['host.name', 'user.name', 'process.name'].forEach((fieldId) => {
- expect(getByText(fieldId));
+ await act(async () => {
+ const { getByText } = render(
+
+
+
+ );
+ ['host.name', 'user.name', 'process.name'].forEach((fieldId) => {
+ expect(getByText(fieldId));
+ });
});
});
- test('Threshold events have special fields', () => {
+ test('Threshold events have special fields', async () => {
const enhancedData = [
...mockAlertDetailsData.map((item) => {
if (item.category === 'kibana' && item.field === 'kibana.alert.rule.type') {
@@ -526,24 +562,26 @@ describe('AlertSummaryView', () => {
...props,
data: enhancedData,
};
- const { getByText } = render(
-
-
-
- );
-
- [
- 'Threshold Count',
- 'host.name [threshold]',
- 'host.id [threshold]',
- 'Threshold Cardinality',
- 'count(host.name) >= 9001',
- ].forEach((fieldId) => {
- expect(getByText(fieldId));
+ await act(async () => {
+ const { getByText } = render(
+
+
+
+ );
+
+ [
+ 'Threshold Count',
+ 'host.name [threshold]',
+ 'host.id [threshold]',
+ 'Threshold Cardinality',
+ 'count(host.name) >= 9001',
+ ].forEach((fieldId) => {
+ expect(getByText(fieldId));
+ });
});
});
- test('Threshold fields are not shown when data is malformated', () => {
+ test('Threshold fields are not shown when data is malformated', async () => {
const enhancedData = [
...mockAlertDetailsData.map((item) => {
if (item.category === 'kibana' && item.field === 'kibana.alert.rule.type') {
@@ -592,27 +630,29 @@ describe('AlertSummaryView', () => {
...props,
data: enhancedData,
};
- const { getByText } = render(
-
-
-
- );
-
- ['Threshold Count'].forEach((fieldId) => {
- expect(getByText(fieldId));
- });
-
- [
- 'host.name [threshold]',
- 'host.id [threshold]',
- 'Threshold Cardinality',
- 'count(host.name) >= 9001',
- ].forEach((fieldText) => {
- expect(() => getByText(fieldText)).toThrow();
+ await act(async () => {
+ const { getByText } = render(
+
+
+
+ );
+
+ ['Threshold Count'].forEach((fieldId) => {
+ expect(getByText(fieldId));
+ });
+
+ [
+ 'host.name [threshold]',
+ 'host.id [threshold]',
+ 'Threshold Cardinality',
+ 'count(host.name) >= 9001',
+ ].forEach((fieldText) => {
+ expect(() => getByText(fieldText)).toThrow();
+ });
});
});
- test('Threshold fields are not shown when data is partially missing', () => {
+ test('Threshold fields are not shown when data is partially missing', async () => {
const enhancedData = [
...mockAlertDetailsData.map((item) => {
if (item.category === 'kibana' && item.field === 'kibana.alert.rule.type') {
@@ -642,21 +682,23 @@ describe('AlertSummaryView', () => {
...props,
data: enhancedData,
};
- const { getByText } = render(
-
-
-
- );
-
- // The `value` fields are missing here, so the enriched field info cannot be calculated correctly
- ['host.id [threshold]', 'Threshold Cardinality', 'count(host.name) >= 9001'].forEach(
- (fieldText) => {
- expect(() => getByText(fieldText)).toThrow();
- }
- );
+ await act(async () => {
+ const { getByText } = render(
+
+
+
+ );
+
+ // The `value` fields are missing here, so the enriched field info cannot be calculated correctly
+ ['host.id [threshold]', 'Threshold Cardinality', 'count(host.name) >= 9001'].forEach(
+ (fieldText) => {
+ expect(() => getByText(fieldText)).toThrow();
+ }
+ );
+ });
});
- test("doesn't render empty fields", () => {
+ test("doesn't render empty fields", async () => {
const renderProps = {
...props,
data: mockAlertDetailsData.map((item) => {
@@ -672,12 +714,14 @@ describe('AlertSummaryView', () => {
}) as TimelineEventsDetailsItem[],
};
- const { queryByTestId } = render(
-
-
-
- );
+ await act(async () => {
+ const { queryByTestId } = render(
+
+
+
+ );
- expect(queryByTestId('event-field-kibana.alert.rule.name')).not.toBeInTheDocument();
+ expect(queryByTestId('event-field-kibana.alert.rule.name')).not.toBeInTheDocument();
+ });
});
});
diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/related_cases.test.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/related_cases.test.tsx
index 9aa301d4aedd0..c76f6f42a9e11 100644
--- a/x-pack/plugins/security_solution/public/common/components/event_details/related_cases.test.tsx
+++ b/x-pack/plugins/security_solution/public/common/components/event_details/related_cases.test.tsx
@@ -5,7 +5,7 @@
* 2.0.
*/
-import { act, render, screen } from '@testing-library/react';
+import { render, screen } from '@testing-library/react';
import React from 'react';
import { TestProviders } from '../../mock';
@@ -45,13 +45,11 @@ describe('Related Cases', () => {
(useGetUserCasesPermissions as jest.Mock).mockReturnValue({
read: false,
});
- act(() => {
- render(
-
-
-
- );
- });
+ render(
+
+
+
+ );
expect(screen.queryByText('cases')).toBeNull();
});
@@ -66,13 +64,11 @@ describe('Related Cases', () => {
describe('When related cases are unable to be retrieved', () => {
test('should show 0 related cases when there are none', async () => {
mockGetRelatedCases.mockReturnValue([]);
- act(() => {
- render(
-
-
-
- );
- });
+ render(
+
+
+
+ );
expect(await screen.findByText('0 cases.')).toBeInTheDocument();
});
@@ -81,14 +77,11 @@ describe('Related Cases', () => {
describe('When 1 related case is retrieved', () => {
test('should show 1 related case', async () => {
mockGetRelatedCases.mockReturnValue([{ id: '789', title: 'Test Case' }]);
- act(() => {
- render(
-
-
-
- );
- });
-
+ render(
+
+
+
+ );
expect(await screen.findByText('1 case:')).toBeInTheDocument();
expect(await screen.findByTestId('case-details-link')).toHaveTextContent('Test Case');
});
@@ -100,14 +93,11 @@ describe('Related Cases', () => {
{ id: '789', title: 'Test Case 1' },
{ id: '456', title: 'Test Case 2' },
]);
- act(() => {
- render(
-
-
-
- );
- });
-
+ render(
+
+
+
+ );
expect(await screen.findByText('2 cases:')).toBeInTheDocument();
const cases = await screen.findAllByTestId('case-details-link');
expect(cases).toHaveLength(2);
diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_flyout/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_flyout/index.test.tsx
index 623ab9b282480..fe41893e25c3d 100644
--- a/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_flyout/index.test.tsx
+++ b/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_flyout/index.test.tsx
@@ -6,17 +6,15 @@
*/
import React from 'react';
-import { ThemeProvider } from 'styled-components';
import { mount, ReactWrapper } from 'enzyme';
-import { waitFor } from '@testing-library/react';
+import { act, waitFor } from '@testing-library/react';
import { AddExceptionFlyout } from '.';
-import { useCurrentUser } from '../../../lib/kibana';
import { getExceptionBuilderComponentLazy } from '@kbn/lists-plugin/public';
import { useAsync } from '@kbn/securitysolution-hook-utils';
import { getExceptionListSchemaMock } from '@kbn/lists-plugin/common/schemas/response/exception_list_schema.mock';
import { useFetchIndex } from '../../../containers/source';
-import { stubIndexPattern } from '@kbn/data-plugin/common/stubs';
+import { createIndexPatternFieldStub, stubIndexPattern } from '@kbn/data-plugin/common/stubs';
import { useAddOrUpdateException } from '../use_add_exception';
import { useFetchOrCreateRuleExceptionList } from '../use_fetch_or_create_rule_exception_list';
import { useSignalIndex } from '../../../../detections/containers/detection_engine/alerts/use_signal_index';
@@ -24,24 +22,14 @@ import * as helpers from '../helpers';
import { getExceptionListItemSchemaMock } from '@kbn/lists-plugin/common/schemas/response/exception_list_item_schema.mock';
import type { EntriesArray, ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types';
+import { TestProviders } from '../../../mock';
+
import {
getRulesEqlSchemaMock,
getRulesSchemaMock,
} from '../../../../../common/detection_engine/schemas/response/rules_schema.mocks';
import { useRuleAsync } from '../../../../detections/containers/detection_engine/rules/use_rule_async';
import { AlertData } from '../types';
-import { getMockTheme } from '../../../lib/kibana/kibana_react.mock';
-
-const mockTheme = getMockTheme({
- eui: {
- euiBreakpoints: {
- l: '1200px',
- },
- paddingSizes: {
- m: '10px',
- },
- },
-});
jest.mock('../../../../detections/containers/detection_engine/alerts/use_signal_index');
jest.mock('../../../lib/kibana');
@@ -68,7 +56,6 @@ const mockUseFetchOrCreateRuleExceptionList = useFetchOrCreateRuleExceptionList
>;
const mockUseSignalIndex = useSignalIndex as jest.Mock>>;
const mockUseFetchIndex = useFetchIndex as jest.Mock;
-const mockUseCurrentUser = useCurrentUser as jest.Mock>>;
const mockUseRuleAsync = useRuleAsync as jest.Mock;
describe('When the add exception modal is opened', () => {
@@ -104,7 +91,6 @@ describe('When the add exception modal is opened', () => {
indexPatterns: stubIndexPattern,
},
]);
- mockUseCurrentUser.mockReturnValue({ username: 'test-username' });
mockUseRuleAsync.mockImplementation(() => ({
rule: getRulesSchemaMock(),
}));
@@ -126,7 +112,7 @@ describe('When the add exception modal is opened', () => {
},
]);
wrapper = mount(
-
+
{
onCancel={jest.fn()}
onConfirm={jest.fn()}
/>
-
+
);
});
it('should show the loading spinner', () => {
@@ -147,7 +133,7 @@ describe('When the add exception modal is opened', () => {
let wrapper: ReactWrapper;
beforeEach(async () => {
wrapper = mount(
-
+
{
onCancel={jest.fn()}
onConfirm={jest.fn()}
/>
-
+
);
const callProps = mockGetExceptionBuilderComponentLazy.mock.calls[0][0];
await waitFor(() => callProps.onChange({ exceptionItems: [] }));
@@ -191,7 +177,7 @@ describe('When the add exception modal is opened', () => {
file: { path: 'test/path' },
};
wrapper = mount(
-
+
{
onConfirm={jest.fn()}
alertData={alertDataMock}
/>
-
+
);
const callProps = mockGetExceptionBuilderComponentLazy.mock.calls[0][0];
await waitFor(() =>
@@ -251,7 +237,7 @@ describe('When the add exception modal is opened', () => {
file: { path: 'test/path' },
};
wrapper = mount(
-
+
{
onConfirm={jest.fn()}
alertData={alertDataMock}
/>
-
+
);
const callProps = mockGetExceptionBuilderComponentLazy.mock.calls[0][0];
await waitFor(() =>
@@ -312,7 +298,7 @@ describe('When the add exception modal is opened', () => {
file: { path: 'test/path' },
};
wrapper = mount(
-
+
{
onConfirm={jest.fn()}
alertData={alertDataMock}
/>
-
+
);
const callProps = mockGetExceptionBuilderComponentLazy.mock.calls[0][0];
await waitFor(() =>
@@ -359,24 +345,26 @@ describe('When the add exception modal is opened', () => {
describe('when there is bulk-closeable alert data passed to an endpoint list exception', () => {
let wrapper: ReactWrapper;
+ const stubbed = [
+ { name: 'file.path.caseless', type: 'string', aggregatable: true, searchable: true },
+ { name: 'subject_name', type: 'string', aggregatable: true, searchable: true },
+ { name: 'trusted', type: 'string', aggregatable: true, searchable: true },
+ { name: 'file.hash.sha256', type: 'string', aggregatable: true, searchable: true },
+ { name: 'event.code', type: 'string', aggregatable: true, searchable: true },
+ ].map((item) => createIndexPatternFieldStub({ spec: item }));
let callProps: {
onChange: (props: { exceptionItems: ExceptionListItemSchema[] }) => void;
exceptionListItems: ExceptionListItemSchema[];
};
beforeEach(async () => {
+ const stubbedIndexPattern = stubIndexPattern;
// Mocks the index patterns to contain the pre-populated endpoint fields so that the exception qualifies as bulk closable
mockUseFetchIndex.mockImplementation(() => [
false,
{
indexPatterns: {
- ...stubIndexPattern,
- fields: [
- { name: 'file.path.caseless', type: 'string' },
- { name: 'subject_name', type: 'string' },
- { name: 'trusted', type: 'string' },
- { name: 'file.hash.sha256', type: 'string' },
- { name: 'event.code', type: 'string' },
- ],
+ ...stubbedIndexPattern,
+ fields: stubbed,
},
},
]);
@@ -385,25 +373,28 @@ describe('When the add exception modal is opened', () => {
_id: 'test-id',
file: { path: 'test/path' },
};
- wrapper = mount(
-
-
-
- );
- callProps = mockGetExceptionBuilderComponentLazy.mock.calls[0][0];
- await waitFor(() =>
- callProps.onChange({ exceptionItems: [...callProps.exceptionListItems] })
- );
+ await act(async () => {
+ wrapper = mount(
+
+
+
+ );
+ });
+ await waitFor(() => {
+ callProps = mockGetExceptionBuilderComponentLazy.mock.calls[0][0];
+
+ return callProps.onChange({ exceptionItems: [...callProps.exceptionListItems] });
+ });
});
- it('has the add exception button enabled', () => {
+ it('has the add exception button enabled', async () => {
expect(
wrapper.find('button[data-test-subj="add-exception-confirm-button"]').getDOMNode()
).not.toBeDisabled();
@@ -456,7 +447,7 @@ describe('When the add exception modal is opened', () => {
test('when there are exception builder errors submit button is disabled', async () => {
const wrapper = mount(
-
+
{
onCancel={jest.fn()}
onConfirm={jest.fn()}
/>
-
+
);
const callProps = mockGetExceptionBuilderComponentLazy.mock.calls[0][0];
await waitFor(() => callProps.onChange({ exceptionItems: [], errorExists: true }));
diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_flyout/index.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_flyout/index.tsx
index 0e063a05bab0c..ceaebb04e7be0 100644
--- a/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_flyout/index.tsx
+++ b/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_flyout/index.tsx
@@ -35,6 +35,7 @@ import type {
} from '@kbn/securitysolution-io-ts-list-types';
import { ExceptionsBuilderExceptionItem } from '@kbn/securitysolution-list-utils';
import { getExceptionBuilderComponentLazy } from '@kbn/lists-plugin/public';
+import { DataViewBase } from '@kbn/es-query';
import {
hasEqlSequenceQuery,
isEqlRule,
@@ -72,6 +73,7 @@ export interface AddExceptionFlyoutProps {
ruleId: string;
exceptionListType: ExceptionListType;
ruleIndices: string[];
+ dataViewId?: string;
alertData?: AlertData;
/**
* The components that use this may or may not define `alertData`
@@ -127,6 +129,7 @@ export const AddExceptionFlyout = memo(function AddExceptionFlyout({
ruleName,
ruleId,
ruleIndices,
+ dataViewId,
exceptionListType,
alertData,
isAlertDataLoading,
@@ -135,7 +138,7 @@ export const AddExceptionFlyout = memo(function AddExceptionFlyout({
onRuleChange,
alertStatus,
}: AddExceptionFlyoutProps) {
- const { http, unifiedSearch } = useKibana().services;
+ const { http, unifiedSearch, data } = useKibana().services;
const [errorsExist, setErrorExists] = useState(false);
const [comment, setComment] = useState('');
const { rule: maybeRule, loading: isRuleLoading } = useRuleAsync(ruleId);
@@ -166,7 +169,21 @@ export const AddExceptionFlyout = memo(function AddExceptionFlyout({
}
}, [jobs, ruleIndices]);
- const [isIndexPatternLoading, { indexPatterns }] = useFetchIndex(memoRuleIndices);
+ const [isIndexPatternLoading, { indexPatterns: indexIndexPatterns }] =
+ useFetchIndex(memoRuleIndices);
+
+ const [indexPattern, setIndexPattern] = useState(indexIndexPatterns);
+
+ useEffect(() => {
+ const fetchSingleDataView = async () => {
+ if (dataViewId != null && dataViewId !== '') {
+ const dv = await data.dataViews.get(dataViewId);
+ setIndexPattern(dv);
+ }
+ };
+
+ fetchSingleDataView();
+ }, [data.dataViews, dataViewId, setIndexPattern]);
const handleBuilderOnChange = useCallback(
({
@@ -513,7 +530,8 @@ export const AddExceptionFlyout = memo(function AddExceptionFlyout({
listNamespaceType: ruleExceptionList.namespace_type,
listTypeSpecificIndexPatternFilter: filterIndexPatterns,
ruleName,
- indexPatterns,
+ indexPatterns:
+ dataViewId != null && dataViewId !== '' ? indexPattern : indexIndexPatterns,
isOrDisabled: isExceptionBuilderFormDisabled,
isAndDisabled: isExceptionBuilderFormDisabled,
isNestedDisabled: isExceptionBuilderFormDisabled,
diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/edit_exception_flyout/index.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/edit_exception_flyout/index.tsx
index 0097f2d02cb3e..78803b787f142 100644
--- a/x-pack/plugins/security_solution/public/common/components/exceptions/edit_exception_flyout/index.tsx
+++ b/x-pack/plugins/security_solution/public/common/components/exceptions/edit_exception_flyout/index.tsx
@@ -32,6 +32,8 @@ import type {
CreateExceptionListItemSchema,
} from '@kbn/securitysolution-io-ts-list-types';
import { getExceptionBuilderComponentLazy } from '@kbn/lists-plugin/public';
+import { DataViewBase } from '@kbn/es-query';
+
import {
hasEqlSequenceQuery,
isEqlRule,
@@ -63,6 +65,7 @@ interface EditExceptionFlyoutProps {
ruleName: string;
ruleId: string;
ruleIndices: string[];
+ dataViewId?: string;
exceptionItem: ExceptionListItemSchema;
exceptionListType: ExceptionListType;
onCancel: () => void;
@@ -106,13 +109,14 @@ export const EditExceptionFlyout = memo(function EditExceptionFlyout({
ruleName,
ruleId,
ruleIndices,
+ dataViewId,
exceptionItem,
exceptionListType,
onCancel,
onConfirm,
onRuleChange,
}: EditExceptionFlyoutProps) {
- const { http, unifiedSearch } = useKibana().services;
+ const { http, unifiedSearch, data } = useKibana().services;
const [comment, setComment] = useState('');
const [errorsExist, setErrorExists] = useState(false);
const { rule: maybeRule, loading: isRuleLoading } = useRuleAsync(ruleId);
@@ -143,7 +147,20 @@ export const EditExceptionFlyout = memo(function EditExceptionFlyout({
}
}, [jobs, ruleIndices]);
- const [isIndexPatternLoading, { indexPatterns }] = useFetchIndex(memoRuleIndices);
+ const [isIndexPatternLoading, { indexPatterns: indexIndexPatterns }] =
+ useFetchIndex(memoRuleIndices);
+ const [indexPattern, setIndexPattern] = useState(indexIndexPatterns);
+
+ useEffect(() => {
+ const fetchSingleDataView = async () => {
+ if (dataViewId != null && dataViewId !== '') {
+ const dv = await data.dataViews.get(dataViewId);
+ setIndexPattern(dv);
+ }
+ };
+
+ fetchSingleDataView();
+ }, [data.dataViews, dataViewId, setIndexPattern]);
const handleExceptionUpdateError = useCallback(
(error: Error, statusCode: number | null, message: string | null) => {
@@ -374,7 +391,7 @@ export const EditExceptionFlyout = memo(function EditExceptionFlyout({
dataTestSubj: 'edit-exception-builder',
idAria: 'edit-exception-builder',
onChange: handleBuilderOnChange,
- indexPatterns,
+ indexPatterns: indexPattern,
})}
diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/index.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/index.tsx
index 21c041df34136..63093c06a9450 100644
--- a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/index.tsx
+++ b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/index.tsx
@@ -56,6 +56,7 @@ interface ExceptionsViewerProps {
ruleId: string;
ruleName: string;
ruleIndices: string[];
+ dataViewId?: string;
exceptionListsMeta: ExceptionListIdentifiers[];
availableListTypes: ExceptionListTypeEnum[];
commentsAccordionId: string;
@@ -66,6 +67,7 @@ const ExceptionsViewerComponent = ({
ruleId,
ruleName,
ruleIndices,
+ dataViewId,
exceptionListsMeta,
availableListTypes,
commentsAccordionId,
@@ -341,6 +343,7 @@ const ExceptionsViewerComponent = ({
ruleName={ruleName}
ruleId={ruleId}
ruleIndices={ruleIndices}
+ dataViewId={dataViewId}
exceptionListType={exceptionListTypeToEdit}
exceptionItem={exceptionToEdit}
onCancel={handleOnCancelExceptionModal}
@@ -353,6 +356,7 @@ const ExceptionsViewerComponent = ({
({
}),
}));
+const mockUseShowTimeline = jest.fn((): [boolean] => [false]);
+jest.mock('../../../utils/timeline/use_show_timeline', () => ({
+ useShowTimeline: () => mockUseShowTimeline(),
+}));
+const mockUseIsPolicySettingsBarVisible = jest.fn((): boolean => false);
+jest.mock('../../../../management/pages/policy/view/policy_hooks', () => ({
+ useIsPolicySettingsBarVisible: () => mockUseIsPolicySettingsBarVisible(),
+}));
+
const renderNav = () =>
render(, {
wrapper: TestProviders,
@@ -178,4 +188,39 @@ describe('SecuritySideNav', () => {
})
);
});
+
+ describe('bottom offset', () => {
+ it('should render with bottom offset when timeline bar visible', () => {
+ mockUseIsPolicySettingsBarVisible.mockReturnValueOnce(false);
+ mockUseShowTimeline.mockReturnValueOnce([true]);
+ renderNav();
+ expect(mockSolutionGroupedNav).toHaveBeenCalledWith(
+ expect.objectContaining({
+ bottomOffset: bottomNavOffset,
+ })
+ );
+ });
+
+ it('should render with bottom offset when policy settings bar visible', () => {
+ mockUseShowTimeline.mockReturnValueOnce([false]);
+ mockUseIsPolicySettingsBarVisible.mockReturnValueOnce(true);
+ renderNav();
+ expect(mockSolutionGroupedNav).toHaveBeenCalledWith(
+ expect.objectContaining({
+ bottomOffset: bottomNavOffset,
+ })
+ );
+ });
+
+ it('should not render with bottom offset when not needed', () => {
+ mockUseShowTimeline.mockReturnValueOnce([false]);
+ mockUseIsPolicySettingsBarVisible.mockReturnValueOnce(false);
+ renderNav();
+ expect(mockSolutionGroupedNav).toHaveBeenCalledWith(
+ expect.not.objectContaining({
+ bottomOffset: bottomNavOffset,
+ })
+ );
+ });
+ });
});
diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/security_side_nav/security_side_nav.tsx b/x-pack/plugins/security_solution/public/common/components/navigation/security_side_nav/security_side_nav.tsx
index dc00396b4e209..8db77bae34a80 100644
--- a/x-pack/plugins/security_solution/public/common/components/navigation/security_side_nav/security_side_nav.tsx
+++ b/x-pack/plugins/security_solution/public/common/components/navigation/security_side_nav/security_side_nav.tsx
@@ -16,6 +16,9 @@ import { SolutionGroupedNav } from '../solution_grouped_nav';
import { CustomSideNavItem, DefaultSideNavItem, SideNavItem } from '../solution_grouped_nav/types';
import { NavLinkItem } from '../types';
import { EuiIconLaunch } from './icons/launch';
+import { useShowTimeline } from '../../../utils/timeline/use_show_timeline';
+import { useIsPolicySettingsBarVisible } from '../../../../management/pages/policy/view/policy_hooks';
+import { bottomNavOffset } from '../../../lib/helpers';
const isFooterNavItem = (id: SecurityPageName) =>
id === SecurityPageName.landing || id === SecurityPageName.administration;
@@ -142,9 +145,21 @@ export const SecuritySideNav: React.FC = () => {
const [items, footerItems] = useSideNavItems();
const selectedId = useSelectedId();
+ const isPolicySettingsVisible = useIsPolicySettingsBarVisible();
+ const [isTimelineBottomBarVisible] = useShowTimeline();
+ const bottomOffset =
+ isTimelineBottomBarVisible || isPolicySettingsVisible ? bottomNavOffset : undefined;
+
if (items.length === 0 && footerItems.length === 0) {
return ;
}
- return ;
+ return (
+
+ );
};
diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/solution_grouped_nav.test.tsx b/x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/solution_grouped_nav.test.tsx
index 5f5fd14605643..8ef060fb3ba90 100644
--- a/x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/solution_grouped_nav.test.tsx
+++ b/x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/solution_grouped_nav.test.tsx
@@ -12,11 +12,6 @@ import { TestProviders } from '../../../mock';
import { SolutionGroupedNav, SolutionGroupedNavProps } from './solution_grouped_nav';
import { SideNavItem } from './types';
-const mockUseShowTimeline = jest.fn((): [boolean] => [false]);
-jest.mock('../../../utils/timeline/use_show_timeline', () => ({
- useShowTimeline: () => mockUseShowTimeline(),
-}));
-
const mockItems: SideNavItem[] = [
{
id: SecurityPageName.dashboardsLanding,
diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/solution_grouped_nav.tsx b/x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/solution_grouped_nav.tsx
index 073723b80f518..8aecccc521416 100644
--- a/x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/solution_grouped_nav.tsx
+++ b/x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/solution_grouped_nav.tsx
@@ -25,6 +25,7 @@ export interface SolutionGroupedNavProps {
items: SideNavItem[];
selectedId: string;
footerItems?: SideNavItem[];
+ bottomOffset?: string;
}
export interface SolutionNavItemsProps {
items: SideNavItem[];
@@ -52,6 +53,7 @@ export const SolutionGroupedNavComponent: React.FC = ({
items,
selectedId,
footerItems = [],
+ bottomOffset,
}) => {
const isMobileSize = useIsWithinBreakpoints(['xs', 's']);
@@ -108,9 +110,10 @@ export const SolutionGroupedNavComponent: React.FC = ({
items={panelItems}
title={title}
categories={categories}
+ bottomOffset={bottomOffset}
/>
);
- }, [activePanelNavId, navItemsById, onClosePanelNav, onOutsidePanelClick]);
+ }, [activePanelNavId, bottomOffset, navItemsById, onClosePanelNav, onOutsidePanelClick]);
return (
<>
diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/solution_grouped_nav_panel.styles.tsx b/x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/solution_grouped_nav_panel.styles.tsx
index 069c146ca0719..c237043e5c4c2 100644
--- a/x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/solution_grouped_nav_panel.styles.tsx
+++ b/x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/solution_grouped_nav_panel.styles.tsx
@@ -7,7 +7,7 @@
import { EuiPanel } from '@elastic/eui';
import styled from 'styled-components';
-export const EuiPanelStyled = styled(EuiPanel)<{ $hasBottomBar: boolean }>`
+export const EuiPanelStyled = styled(EuiPanel)<{ $bottomOffset?: string }>`
position: fixed;
top: 95px;
left: 247px;
@@ -16,11 +16,11 @@ export const EuiPanelStyled = styled(EuiPanel)<{ $hasBottomBar: boolean }>`
height: inherit;
// If the bottom bar is visible add padding to the navigation
- ${({ $hasBottomBar, theme }) =>
- $hasBottomBar &&
+ ${({ $bottomOffset, theme }) =>
+ $bottomOffset != null &&
`
height: inherit;
- bottom: 51px;
+ bottom: ${$bottomOffset};
box-shadow:
// left
-${theme.eui.euiSizeS} 0 ${theme.eui.euiSizeS} -${theme.eui.euiSizeS} rgb(0 0 0 / 15%),
diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/solution_grouped_nav_panel.test.tsx b/x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/solution_grouped_nav_panel.test.tsx
index 8215d9c0b9f40..3162df787af1c 100644
--- a/x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/solution_grouped_nav_panel.test.tsx
+++ b/x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/solution_grouped_nav_panel.test.tsx
@@ -11,11 +11,16 @@ import { SecurityPageName } from '../../../../app/types';
import { TestProviders } from '../../../mock';
import { SolutionNavPanel, SolutionNavPanelProps } from './solution_grouped_nav_panel';
import { DefaultSideNavItem } from './types';
+import { bottomNavOffset } from '../../../lib/helpers';
-const mockUseShowTimeline = jest.fn((): [boolean] => [false]);
-jest.mock('../../../utils/timeline/use_show_timeline', () => ({
- useShowTimeline: () => mockUseShowTimeline(),
-}));
+const mockUseIsWithinBreakpoints = jest.fn(() => true);
+jest.mock('@elastic/eui', () => {
+ const original = jest.requireActual('@elastic/eui');
+ return {
+ ...original,
+ useIsWithinBreakpoints: () => mockUseIsWithinBreakpoints(),
+ };
+});
const mockItems: DefaultSideNavItem[] = [
{
@@ -100,6 +105,22 @@ describe('SolutionGroupedNav', () => {
});
});
+ describe('bottom offset', () => {
+ it('should add bottom offset', () => {
+ mockUseIsWithinBreakpoints.mockReturnValueOnce(true);
+ const result = renderNavPanel({ bottomOffset: bottomNavOffset });
+
+ expect(result.getByTestId('groupedNavPanel')).toHaveStyle({ bottom: bottomNavOffset });
+ });
+
+ it('should not add bottom offset if not large screen', () => {
+ mockUseIsWithinBreakpoints.mockReturnValueOnce(false);
+ const result = renderNavPanel({ bottomOffset: bottomNavOffset });
+
+ expect(result.getByTestId('groupedNavPanel')).not.toHaveStyle({ bottom: bottomNavOffset });
+ });
+ });
+
describe('close', () => {
it('should call onClose callback if link clicked', () => {
const result = renderNavPanel();
diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/solution_grouped_nav_panel.tsx b/x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/solution_grouped_nav_panel.tsx
index a418f666d2782..4c0ccc6116703 100644
--- a/x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/solution_grouped_nav_panel.tsx
+++ b/x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/solution_grouped_nav_panel.tsx
@@ -24,7 +24,6 @@ import {
} from '@elastic/eui';
import classNames from 'classnames';
import { EuiPanelStyled } from './solution_grouped_nav_panel.styles';
-import { useShowTimeline } from '../../../utils/timeline/use_show_timeline';
import type { DefaultSideNavItem } from './types';
import type { LinkCategories } from '../../../links/types';
@@ -34,6 +33,7 @@ export interface SolutionNavPanelProps {
title: string;
items: DefaultSideNavItem[];
categories?: LinkCategories;
+ bottomOffset?: string;
}
export interface SolutionNavPanelCategoriesProps {
categories: LinkCategories;
@@ -58,12 +58,14 @@ const SolutionNavPanelComponent: React.FC = ({
title,
categories,
items,
+ bottomOffset,
}) => {
- const [hasTimelineBar] = useShowTimeline();
const isLargerBreakpoint = useIsWithinBreakpoints(['l', 'xl']);
- const isTimelineVisible = hasTimelineBar && isLargerBreakpoint;
const panelClasses = classNames('eui-yScroll');
+ // Only larger breakpoint needs to add bottom offset, other sizes should have full height
+ const bottomOffsetLargerBreakpoint = isLargerBreakpoint ? bottomOffset : undefined;
+
// ESC key closes PanelNav
const onKeyDown = useCallback(
(ev: KeyboardEvent) => {
@@ -82,8 +84,8 @@ const SolutionNavPanelComponent: React.FC = ({
{
// FLAKY: https://github.com/elastic/kibana/issues/132659
describe.skip('#onQuerySubmit', () => {
test(' is the only reference that changed when filterQuery props get updated', async () => {
- const wrapper = await getWrapper(
-
- );
- const searchBarProps = wrapper.find(SearchBar).props();
- const onChangedQueryRef = searchBarProps.onQueryChange;
- const onSubmitQueryRef = searchBarProps.onQuerySubmit;
- const onSavedQueryRef = searchBarProps.onSavedQueryUpdated;
+ await act(async () => {
+ const wrapper = await getWrapper(
+
+ );
+ const searchBarProps = wrapper.find(SearchBar).props();
+ const onChangedQueryRef = searchBarProps.onQueryChange;
+ const onSubmitQueryRef = searchBarProps.onQuerySubmit;
+ const onSavedQueryRef = searchBarProps.onSavedQueryUpdated;
- wrapper.setProps({ filterQuery: { expression: 'new: one', kind: 'kuery' } });
- wrapper.update();
+ wrapper.setProps({ filterQuery: { expression: 'new: one', kind: 'kuery' } });
+ wrapper.update();
- expect(onSubmitQueryRef).not.toEqual(wrapper.find(SearchBar).props().onQuerySubmit);
- expect(onChangedQueryRef).not.toEqual(wrapper.find(SearchBar).props().onQueryChange);
- expect(onSavedQueryRef).toEqual(wrapper.find(SearchBar).props().onSavedQueryUpdated);
+ expect(onSubmitQueryRef).not.toEqual(wrapper.find(SearchBar).props().onQuerySubmit);
+ expect(onChangedQueryRef).not.toEqual(wrapper.find(SearchBar).props().onQueryChange);
+ expect(onSavedQueryRef).toEqual(wrapper.find(SearchBar).props().onSavedQueryUpdated);
+ });
});
test(' is only reference that changed when timelineId props get updated', async () => {
@@ -278,68 +280,74 @@ describe('QueryBar ', () => {
});
test('is only reference that changed when dataProviders props get updated', async () => {
- const wrapper = await getWrapper(
-
- );
- const searchBarProps = wrapper.find(SearchBar).props();
- const onChangedQueryRef = searchBarProps.onQueryChange;
- const onSubmitQueryRef = searchBarProps.onQuerySubmit;
- const onSavedQueryRef = searchBarProps.onSavedQueryUpdated;
- wrapper.setProps({ onSavedQuery: jest.fn() });
- wrapper.update();
+ await act(async () => {
+ const wrapper = await getWrapper(
+
+ );
+ const searchBarProps = wrapper.find(SearchBar).props();
+ const onChangedQueryRef = searchBarProps.onQueryChange;
+ const onSubmitQueryRef = searchBarProps.onQuerySubmit;
+ const onSavedQueryRef = searchBarProps.onSavedQueryUpdated;
+ wrapper.setProps({ onSavedQuery: jest.fn() });
+ wrapper.update();
- expect(onSavedQueryRef).not.toEqual(wrapper.find(SearchBar).props().onSavedQueryUpdated);
- expect(onChangedQueryRef).toEqual(wrapper.find(SearchBar).props().onQueryChange);
- expect(onSubmitQueryRef).toEqual(wrapper.find(SearchBar).props().onQuerySubmit);
+ expect(onSavedQueryRef).not.toEqual(wrapper.find(SearchBar).props().onSavedQueryUpdated);
+ expect(onChangedQueryRef).toEqual(wrapper.find(SearchBar).props().onQueryChange);
+ expect(onSubmitQueryRef).toEqual(wrapper.find(SearchBar).props().onQuerySubmit);
+ });
});
});
describe('SavedQueryManagementComponent state', () => {
test('popover should remain open when "Save current query" button was clicked', async () => {
- const wrapper = await getWrapper(
-
- );
- const isSavedQueryPopoverOpen = () =>
- wrapper.find('EuiPopover[data-test-subj="queryBarMenuPopover"]').prop('isOpen');
+ await act(async () => {
+ const wrapper = await getWrapper(
+
+ );
+ const isSavedQueryPopoverOpen = () =>
+ wrapper.find('EuiPopover[data-test-subj="queryBarMenuPopover"]').prop('isOpen');
- expect(isSavedQueryPopoverOpen()).toBeFalsy();
+ expect(isSavedQueryPopoverOpen()).toBeFalsy();
- wrapper.find('button[data-test-subj="showQueryBarMenu"]').simulate('click');
+ wrapper.find('button[data-test-subj="showQueryBarMenu"]').simulate('click');
- await waitFor(() => {
- expect(isSavedQueryPopoverOpen()).toBeTruthy();
- });
- wrapper.find('button[data-test-subj="saved-query-management-save-button"]').simulate('click');
+ await waitFor(() => {
+ expect(isSavedQueryPopoverOpen()).toBeTruthy();
+ });
+ wrapper
+ .find('button[data-test-subj="saved-query-management-save-button"]')
+ .simulate('click');
- await waitFor(() => {
- expect(isSavedQueryPopoverOpen()).toBeTruthy();
+ await waitFor(() => {
+ expect(isSavedQueryPopoverOpen()).toBeTruthy();
+ });
});
});
});
diff --git a/x-pack/plugins/security_solution/public/common/hooks/eql/api.ts b/x-pack/plugins/security_solution/public/common/hooks/eql/api.ts
index e29ebad739960..68a20d82e95ab 100644
--- a/x-pack/plugins/security_solution/public/common/hooks/eql/api.ts
+++ b/x-pack/plugins/security_solution/public/common/hooks/eql/api.ts
@@ -12,6 +12,8 @@ import {
EqlSearchStrategyResponse,
EQL_SEARCH_STRATEGY,
} from '@kbn/data-plugin/common';
+import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
+
import {
getValidationErrors,
isErrorResponse,
@@ -19,22 +21,27 @@ import {
} from '../../../../common/search_strategy/eql';
interface Params {
- index: string[];
+ dataViewTitle: string;
query: string;
data: DataPublicPluginStart;
signal: AbortSignal;
+ runtimeMappings: estypes.MappingRuntimeFields | undefined;
}
export const validateEql = async ({
data,
- index,
+ dataViewTitle,
query,
signal,
+ runtimeMappings,
}: Params): Promise<{ valid: boolean; errors: string[] }> => {
const { rawResponse: response } = await firstValueFrom(
data.search.search(
{
- params: { index: index.join(), body: { query, size: 0 } },
+ params: {
+ index: dataViewTitle,
+ body: { query, runtime_mappings: runtimeMappings, size: 0 },
+ },
options: { ignore: [400] },
},
{
diff --git a/x-pack/plugins/security_solution/public/common/lib/helpers/index.tsx b/x-pack/plugins/security_solution/public/common/lib/helpers/index.tsx
index 42417df1b3677..e233d397b1f70 100644
--- a/x-pack/plugins/security_solution/public/common/lib/helpers/index.tsx
+++ b/x-pack/plugins/security_solution/public/common/lib/helpers/index.tsx
@@ -30,3 +30,4 @@ export type ValueOf = T[keyof T];
*/
export const gutterTimeline = '70px'; // Michael: Temporary until timeline is moved.
+export const bottomNavOffset = '51px';
diff --git a/x-pack/plugins/security_solution/public/common/utils/timeline/use_show_timeline.test.tsx b/x-pack/plugins/security_solution/public/common/utils/timeline/use_show_timeline.test.tsx
index 9968785e884fa..f19e98020fce8 100644
--- a/x-pack/plugins/security_solution/public/common/utils/timeline/use_show_timeline.test.tsx
+++ b/x-pack/plugins/security_solution/public/common/utils/timeline/use_show_timeline.test.tsx
@@ -29,12 +29,33 @@ const mockUseSourcererDataView = jest.fn(
jest.mock('../../containers/sourcerer', () => ({
useSourcererDataView: () => mockUseSourcererDataView(),
}));
-const mockedUseIsGroupedNavigationEnabled = jest.fn();
+const mockedUseIsGroupedNavigationEnabled = jest.fn();
jest.mock('../../components/navigation/helpers', () => ({
useIsGroupedNavigationEnabled: () => mockedUseIsGroupedNavigationEnabled(),
}));
+const mockSiemUserCanRead = jest.fn(() => true);
+jest.mock('../../lib/kibana', () => {
+ const original = jest.requireActual('../../lib/kibana');
+
+ return {
+ ...original,
+ useKibana: () => ({
+ services: {
+ ...original.useKibana().services,
+ application: {
+ capabilities: {
+ siem: {
+ show: mockSiemUserCanRead(),
+ },
+ },
+ },
+ },
+ }),
+ };
+});
+
describe('use show timeline', () => {
beforeAll(() => {
// initialize all App links before running test
@@ -157,4 +178,18 @@ describe('use show timeline', () => {
expect(result.current).toEqual([false]);
});
});
+
+ describe('Security solution capabilities', () => {
+ it('should show timeline when user has read capabilities', () => {
+ mockSiemUserCanRead.mockReturnValueOnce(true);
+ const { result } = renderHook(() => useShowTimeline());
+ expect(result.current).toEqual([true]);
+ });
+
+ it('should not show timeline when user does not have read capabilities', () => {
+ mockSiemUserCanRead.mockReturnValueOnce(false);
+ const { result } = renderHook(() => useShowTimeline());
+ expect(result.current).toEqual([false]);
+ });
+ });
});
diff --git a/x-pack/plugins/security_solution/public/common/utils/timeline/use_show_timeline.tsx b/x-pack/plugins/security_solution/public/common/utils/timeline/use_show_timeline.tsx
index 041e36f52a3db..25d175f46aad3 100644
--- a/x-pack/plugins/security_solution/public/common/utils/timeline/use_show_timeline.tsx
+++ b/x-pack/plugins/security_solution/public/common/utils/timeline/use_show_timeline.tsx
@@ -5,13 +5,14 @@
* 2.0.
*/
-import { useState, useEffect, useMemo } from 'react';
+import { useMemo } from 'react';
import { matchPath, useLocation } from 'react-router-dom';
import { getLinksWithHiddenTimeline } from '../../links';
import { useIsGroupedNavigationEnabled } from '../../components/navigation/helpers';
import { SourcererScopeName } from '../../store/sourcerer/model';
import { useSourcererDataView } from '../../containers/sourcerer';
+import { useKibana } from '../../lib/kibana';
const DEPRECATED_HIDDEN_TIMELINE_ROUTES: readonly string[] = [
`/cases/configure`,
@@ -23,33 +24,36 @@ const DEPRECATED_HIDDEN_TIMELINE_ROUTES: readonly string[] = [
'/manage',
];
-const isTimelineHidden = (currentPath: string, isGroupedNavigationEnabled: boolean): boolean => {
+const isTimelinePathVisible = (
+ currentPath: string,
+ isGroupedNavigationEnabled: boolean
+): boolean => {
const groupLinksWithHiddenTimelinePaths = getLinksWithHiddenTimeline().map((l) => l.path);
const hiddenTimelineRoutes = isGroupedNavigationEnabled
? groupLinksWithHiddenTimelinePaths
: DEPRECATED_HIDDEN_TIMELINE_ROUTES;
- return !!hiddenTimelineRoutes.find((route) => matchPath(currentPath, route));
+ return !hiddenTimelineRoutes.find((route) => matchPath(currentPath, route));
};
export const useShowTimeline = () => {
const isGroupedNavigationEnabled = useIsGroupedNavigationEnabled();
const { pathname } = useLocation();
const { indicesExist, dataViewId } = useSourcererDataView(SourcererScopeName.timeline);
+ const userHasSecuritySolutionVisible = useKibana().services.application.capabilities.siem.show;
- const [isTimelinePath, setIsTimelinePath] = useState(
- !isTimelineHidden(pathname, isGroupedNavigationEnabled)
+ const isTimelineAllowed = useMemo(
+ () => userHasSecuritySolutionVisible && (indicesExist || dataViewId === null),
+ [indicesExist, dataViewId, userHasSecuritySolutionVisible]
);
- useEffect(() => {
- setIsTimelinePath(!isTimelineHidden(pathname, isGroupedNavigationEnabled));
- }, [pathname, isGroupedNavigationEnabled]);
-
- const showTimeline = useMemo(
- () => isTimelinePath && (dataViewId === null || indicesExist),
- [isTimelinePath, indicesExist, dataViewId]
- );
+ const showTimeline = useMemo(() => {
+ if (!isTimelineAllowed) {
+ return false;
+ }
+ return isTimelinePathVisible(pathname, isGroupedNavigationEnabled);
+ }, [isTimelineAllowed, pathname, isGroupedNavigationEnabled]);
return [showTimeline];
};
diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/data_view_selector/index.test.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/data_view_selector/index.test.tsx
new file mode 100644
index 0000000000000..8e547084cbd13
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/detections/components/rules/data_view_selector/index.test.tsx
@@ -0,0 +1,27 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import React from 'react';
+import { shallow } from 'enzyme';
+
+import { DataViewSelector } from '.';
+import { useFormFieldMock } from '../../../../common/mock';
+
+jest.mock('../../../../common/lib/kibana');
+
+describe('data_view_selector', () => {
+ it('renders correctly', () => {
+ const Component = () => {
+ const field = useFormFieldMock({ value: '' });
+ // @ts-expect-error TODO: FIX THIS
+ return ;
+ };
+ const wrapper = shallow();
+
+ expect(wrapper.dive().find('[data-test-subj="pick-rule-data-source"]')).toHaveLength(1);
+ });
+});
diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/data_view_selector/index.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/data_view_selector/index.tsx
new file mode 100644
index 0000000000000..c4c11d1d549ed
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/detections/components/rules/data_view_selector/index.tsx
@@ -0,0 +1,145 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import React, { useMemo, useState, useEffect } from 'react';
+
+import {
+ EuiCallOut,
+ EuiComboBox,
+ EuiComboBoxOptionOption,
+ EuiFormRow,
+ EuiSpacer,
+} from '@elastic/eui';
+
+import { DataViewListItem } from '@kbn/data-views-plugin/common';
+import { DataViewBase } from '@kbn/es-query';
+import { FieldHook, getFieldValidityAndErrorMessage } from '../../../../shared_imports';
+import * as i18n from './translations';
+import { useKibana } from '../../../../common/lib/kibana';
+import { DefineStepRule } from '../../../pages/detection_engine/rules/types';
+
+interface DataViewSelectorProps {
+ kibanaDataViews: { [x: string]: DataViewListItem };
+ field: FieldHook;
+ setIndexPattern: (indexPattern: DataViewBase) => void;
+}
+
+export const DataViewSelector = ({
+ kibanaDataViews,
+ field,
+ setIndexPattern,
+}: DataViewSelectorProps) => {
+ const { data } = useKibana().services;
+
+ let isInvalid;
+ let errorMessage;
+ let dataViewId: string | null | undefined;
+ if (field != null) {
+ const fieldAndError = getFieldValidityAndErrorMessage(field);
+ isInvalid = fieldAndError.isInvalid;
+ errorMessage = fieldAndError.errorMessage;
+ dataViewId = field.value;
+ }
+
+ const kibanaDataViewsDefined = useMemo(
+ () => kibanaDataViews != null && Object.keys(kibanaDataViews).length > 0,
+ [kibanaDataViews]
+ );
+
+ // Most likely case here is that a data view of an existing rule was deleted
+ // and can no longer be found
+ const selectedDataViewNotFound = useMemo(
+ () =>
+ dataViewId != null &&
+ dataViewId !== '' &&
+ kibanaDataViewsDefined &&
+ !Object.hasOwn(kibanaDataViews, dataViewId),
+ [kibanaDataViewsDefined, dataViewId, kibanaDataViews]
+ );
+ const [selectedOption, setSelectedOption] = useState>>(
+ !selectedDataViewNotFound && dataViewId != null && dataViewId !== ''
+ ? [{ id: kibanaDataViews[dataViewId].id, label: kibanaDataViews[dataViewId].title }]
+ : []
+ );
+
+ const [selectedDataView, setSelectedDataView] = useState();
+
+ // TODO: optimize this, pass down array of data view ids
+ // at the same time we grab the data views in the top level form component
+ const dataViewOptions = useMemo(() => {
+ return kibanaDataViewsDefined
+ ? Object.values(kibanaDataViews).map((dv) => ({
+ label: dv.title,
+ id: dv.id,
+ }))
+ : [];
+ }, [kibanaDataViewsDefined, kibanaDataViews]);
+
+ useEffect(() => {
+ const fetchSingleDataView = async () => {
+ if (selectedDataView != null) {
+ const dv = await data.dataViews.get(selectedDataView.id);
+ setIndexPattern(dv);
+ }
+ };
+
+ fetchSingleDataView();
+ }, [data.dataViews, selectedDataView, setIndexPattern]);
+
+ const onChangeDataViews = (options: Array>) => {
+ const selectedDataViewOption = options;
+
+ setSelectedOption(selectedDataViewOption ?? []);
+
+ if (
+ selectedDataViewOption != null &&
+ selectedDataViewOption.length > 0 &&
+ selectedDataViewOption[0].id != null
+ ) {
+ setSelectedDataView(kibanaDataViews[selectedDataViewOption[0].id]);
+ field?.setValue(selectedDataViewOption[0].id);
+ } else {
+ setSelectedDataView(undefined);
+ field?.setValue(undefined);
+ }
+ };
+
+ return (
+ <>
+ {selectedDataViewNotFound && dataViewId != null && (
+ <>
+
+ {i18n.DATA_VIEW_NOT_FOUND_WARNING_DESCRIPTION(dataViewId)}
+
+
+ >
+ )}
+
+
+
+ >
+ );
+};
diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/data_view_selector/translations.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/data_view_selector/translations.tsx
new file mode 100644
index 0000000000000..a52ba9c4f7dd7
--- /dev/null
+++ b/x-pack/plugins/security_solution/public/detections/components/rules/data_view_selector/translations.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
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import { i18n } from '@kbn/i18n';
+
+export const PICK_INDEX_PATTERNS = i18n.translate(
+ 'xpack.securitySolution.detectionEngine.stepDefineRule.pickDataView',
+ {
+ defaultMessage: 'Select a Data View',
+ }
+);
+
+export const DATA_VIEW_NOT_FOUND_WARNING_LABEL = i18n.translate(
+ 'xpack.securitySolution.detectionEngine.stepDefineRule.dataViewNotFoundLabel',
+ {
+ defaultMessage: 'Selected data view not found',
+ }
+);
+
+export const DATA_VIEW_NOT_FOUND_WARNING_DESCRIPTION = (dataView: string) =>
+ i18n.translate(
+ 'xpack.securitySolution.detectionEngine.stepDefineRule.dataViewNotFoundDescription',
+ {
+ values: { dataView },
+ defaultMessage:
+ 'Your data view of "id": "{dataView}" was not found. It could be that it has since been deleted.',
+ }
+ );
diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/description_step/index.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/description_step/index.tsx
index df5fcb8d96ba7..ba112a596b653 100644
--- a/x-pack/plugins/security_solution/public/detections/components/rules/description_step/index.tsx
+++ b/x-pack/plugins/security_solution/public/detections/components/rules/description_step/index.tsx
@@ -238,6 +238,8 @@ export const getDescriptionItem = (
} else if (field === 'threatMapping') {
const threatMap: ThreatMapping = get(field, data);
return buildThreatMappingDescription(label, threatMap);
+ } else if (field === 'dataViewId') {
+ return [];
} else if (Array.isArray(get(field, data)) && field !== 'threatMapping') {
const values: string[] = get(field, data);
return buildStringArrayDescription(label, field, values);
diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/eql_query_bar/validators.ts b/x-pack/plugins/security_solution/public/detections/components/rules/eql_query_bar/validators.ts
index 249d26e991894..6de4a8ced764f 100644
--- a/x-pack/plugins/security_solution/public/detections/components/rules/eql_query_bar/validators.ts
+++ b/x-pack/plugins/security_solution/public/detections/components/rules/eql_query_bar/validators.ts
@@ -57,7 +57,7 @@ export const eqlValidator = async (
const [{ value, formData }] = args;
const { query: queryValue } = value as FieldValueQueryBar;
const query = queryValue.query as string;
- const { index, ruleType } = formData as DefineStepRule;
+ const { dataViewId, index, ruleType } = formData as DefineStepRule;
const needsValidation =
(ruleType === undefined && !isEmpty(query)) || (isEqlRule(ruleType) && !isEmpty(query));
@@ -67,8 +67,17 @@ export const eqlValidator = async (
try {
const { data } = KibanaServices.get();
+ let dataViewTitle = index?.join();
+ let runtimeMappings = {};
+ if (dataViewId != null) {
+ const dataView = await data.dataViews.get(dataViewId);
+
+ dataViewTitle = dataView.title;
+ runtimeMappings = dataView.getRuntimeMappings();
+ }
+
const signal = new AbortController().signal;
- const response = await validateEql({ data, query, signal, index });
+ const response = await validateEql({ data, query, signal, dataViewTitle, runtimeMappings });
if (response?.valid === false) {
return {
diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/rule_preview/helpers.test.ts b/x-pack/plugins/security_solution/public/detections/components/rules/rule_preview/helpers.test.ts
index 8621201a399e7..b3ceb363ba18f 100644
--- a/x-pack/plugins/security_solution/public/detections/components/rules/rule_preview/helpers.test.ts
+++ b/x-pack/plugins/security_solution/public/detections/components/rules/rule_preview/helpers.test.ts
@@ -70,6 +70,7 @@ describe('query_preview/helpers', () => {
isQueryBarValid: true,
isThreatQueryBarValid: true,
index: [],
+ dataViewId: undefined,
threatIndex: ['threat-*'],
threatMapping: [
{ entries: [{ field: 'test-field', value: 'test-value', type: 'mapping' }] },
@@ -86,6 +87,7 @@ describe('query_preview/helpers', () => {
isQueryBarValid: false,
isThreatQueryBarValid: true,
index: ['test-*'],
+ dataViewId: undefined,
threatIndex: ['threat-*'],
threatMapping: [
{ entries: [{ field: 'test-field', value: 'test-value', type: 'mapping' }] },
@@ -102,6 +104,7 @@ describe('query_preview/helpers', () => {
isQueryBarValid: true,
isThreatQueryBarValid: false,
index: ['test-*'],
+ dataViewId: undefined,
threatIndex: ['threat-*'],
threatMapping: [
{ entries: [{ field: 'test-field', value: 'test-value', type: 'mapping' }] },
@@ -118,6 +121,7 @@ describe('query_preview/helpers', () => {
isQueryBarValid: true,
isThreatQueryBarValid: true,
index: ['test-*'],
+ dataViewId: undefined,
threatIndex: [],
threatMapping: [
{ entries: [{ field: 'test-field', value: 'test-value', type: 'mapping' }] },
@@ -134,6 +138,7 @@ describe('query_preview/helpers', () => {
isQueryBarValid: true,
isThreatQueryBarValid: true,
index: ['test-*'],
+ dataViewId: undefined,
threatIndex: ['threat-*'],
threatMapping: [],
machineLearningJobId: ['test-ml-job-id'],
@@ -148,6 +153,7 @@ describe('query_preview/helpers', () => {
isQueryBarValid: true,
isThreatQueryBarValid: true,
index: ['test-*'],
+ dataViewId: undefined,
threatIndex: ['threat-*'],
threatMapping: [],
machineLearningJobId: [],
@@ -162,6 +168,7 @@ describe('query_preview/helpers', () => {
isQueryBarValid: true,
isThreatQueryBarValid: true,
index: ['test-*'],
+ dataViewId: undefined,
threatIndex: ['threat-*'],
threatMapping: [],
machineLearningJobId: [],
@@ -176,6 +183,7 @@ describe('query_preview/helpers', () => {
isQueryBarValid: true,
isThreatQueryBarValid: true,
index: ['test-*'],
+ dataViewId: undefined,
threatIndex: ['threat-*'],
threatMapping: [
{ entries: [{ field: 'test-field', value: 'test-value', type: 'mapping' }] },
@@ -192,6 +200,7 @@ describe('query_preview/helpers', () => {
isQueryBarValid: true,
isThreatQueryBarValid: true,
index: ['test-*'],
+ dataViewId: undefined,
threatIndex: ['threat-*'],
threatMapping: [],
machineLearningJobId: [],
diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/rule_preview/helpers.ts b/x-pack/plugins/security_solution/public/detections/components/rules/rule_preview/helpers.ts
index a2c4d2b14cdc9..29587298b454e 100644
--- a/x-pack/plugins/security_solution/public/detections/components/rules/rule_preview/helpers.ts
+++ b/x-pack/plugins/security_solution/public/detections/components/rules/rule_preview/helpers.ts
@@ -206,6 +206,7 @@ export const getIsRulePreviewDisabled = ({
isQueryBarValid,
isThreatQueryBarValid,
index,
+ dataViewId,
threatIndex,
threatMapping,
machineLearningJobId,
@@ -215,12 +216,14 @@ export const getIsRulePreviewDisabled = ({
isQueryBarValid: boolean;
isThreatQueryBarValid: boolean;
index: string[];
+ dataViewId: string | undefined;
threatIndex: string[];
threatMapping: ThreatMapping;
machineLearningJobId: string[];
queryBar: FieldValueQueryBar;
}) => {
- if (!isQueryBarValid || index.length === 0) return true;
+ if (!isQueryBarValid || ((index == null || index.length === 0) && dataViewId == null))
+ return true;
if (ruleType === 'threat_match') {
if (!isThreatQueryBarValid || !threatIndex.length || !threatMapping) return true;
if (
diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/rule_preview/index.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/rule_preview/index.tsx
index 20ed47fffda2f..8a4bd38fea039 100644
--- a/x-pack/plugins/security_solution/public/detections/components/rules/rule_preview/index.tsx
+++ b/x-pack/plugins/security_solution/public/detections/components/rules/rule_preview/index.tsx
@@ -43,6 +43,7 @@ export interface RulePreviewProps {
index: string[];
isDisabled: boolean;
query: FieldValueQueryBar;
+ dataViewId?: string;
ruleType: Type;
threatIndex: string[];
threatMapping: ThreatMapping;
@@ -65,6 +66,7 @@ const defaultTimeRange: Unit = 'h';
const RulePreviewComponent: React.FC = ({
index,
+ dataViewId,
isDisabled,
query,
ruleType,
@@ -108,6 +110,7 @@ const RulePreviewComponent: React.FC = ({
} = usePreviewRoute({
index,
isDisabled,
+ dataViewId,
query,
threatIndex,
threatQuery,
diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/rule_preview/use_preview_histogram.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/rule_preview/use_preview_histogram.tsx
index d65fd91e6b891..881e86d27923b 100644
--- a/x-pack/plugins/security_solution/public/detections/components/rules/rule_preview/use_preview_histogram.tsx
+++ b/x-pack/plugins/security_solution/public/detections/components/rules/rule_preview/use_preview_histogram.tsx
@@ -37,7 +37,7 @@ export const usePreviewHistogram = ({
config: getEsQueryConfig(uiSettings),
indexPattern: {
fields: [],
- title: index.join(),
+ title: index == null ? '' : index.join(),
},
queries: [{ query: `kibana.alert.rule.uuid:${previewId}`, language: 'kuery' }],
filters: [],
diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/rule_preview/use_preview_route.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/rule_preview/use_preview_route.tsx
index fde527e49a46f..b055597cacffe 100644
--- a/x-pack/plugins/security_solution/public/detections/components/rules/rule_preview/use_preview_route.tsx
+++ b/x-pack/plugins/security_solution/public/detections/components/rules/rule_preview/use_preview_route.tsx
@@ -18,6 +18,7 @@ import { EqlOptionsSelected } from '../../../../../common/search_strategy';
interface PreviewRouteParams {
isDisabled: boolean;
index: string[];
+ dataViewId?: string;
threatIndex: string[];
query: FieldValueQueryBar;
threatQuery: FieldValueQueryBar;
@@ -32,6 +33,7 @@ interface PreviewRouteParams {
export const usePreviewRoute = ({
index,
+ dataViewId,
isDisabled,
query,
threatIndex,
@@ -91,6 +93,7 @@ export const usePreviewRoute = ({
setRule(
formatPreviewRule({
index,
+ dataViewId,
query,
ruleType,
threatIndex,
@@ -106,6 +109,7 @@ export const usePreviewRoute = ({
}
}, [
index,
+ dataViewId,
isRequestTriggered,
query,
rule,
diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule/index.test.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule/index.test.tsx
index 02600b80c3b66..cbd5c070198ec 100644
--- a/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule/index.test.tsx
+++ b/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule/index.test.tsx
@@ -31,6 +31,7 @@ const mockTheme = getMockTheme({
},
});
+jest.mock('../../../../common/lib/kibana');
jest.mock('../../../../common/containers/source');
jest.mock('@elastic/eui', () => {
const original = jest.requireActual('@elastic/eui');
diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule/index.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule/index.tsx
index 5f5b636d6afe1..b8cc1077001a2 100644
--- a/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule/index.tsx
+++ b/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule/index.tsx
@@ -9,6 +9,7 @@ import { EuiAccordion, EuiFlexItem, EuiSpacer, EuiFormRow } from '@elastic/eui';
import React, { FC, memo, useCallback, useEffect, useState, useMemo } from 'react';
import styled from 'styled-components';
+import { DataViewBase } from '@kbn/es-query';
import {
RuleStepProps,
RuleStep,
@@ -42,6 +43,7 @@ import { AutocompleteField } from '../autocomplete_field';
import { useFetchIndex } from '../../../../common/containers/source';
import { isThreatMatchRule } from '../../../../../common/detection_engine/utils';
import { DEFAULT_INDICATOR_SOURCE_PATH } from '../../../../../common/constants';
+import { useKibana } from '../../../../common/lib/kibana';
const CommonUseField = getUseField({ component: Field });
@@ -73,6 +75,8 @@ const StepAboutRuleComponent: FC = ({
onSubmit,
setForm,
}) => {
+ const { data } = useKibana().services;
+
const isThreatMatchRuleValue = useMemo(
() => isThreatMatchRule(defineRuleData?.ruleType),
[defineRuleData?.ruleType]
@@ -96,7 +100,38 @@ const StepAboutRuleComponent: FC = ({
);
const [severityValue, setSeverityValue] = useState(initialState.severity.value);
- const [indexPatternLoading, { indexPatterns }] = useFetchIndex(defineRuleData?.index ?? []);
+
+ /**
+ * 1. if not null, fetch data view from id saved on rule form
+ * 2. Create a state to set the indexPattern to be used
+ * 3. useEffect if indexIndexPattern is updated and dataView from rule form is empty
+ */
+
+ const [indexPatternLoading, { indexPatterns: indexIndexPattern }] = useFetchIndex(
+ defineRuleData?.index ?? []
+ );
+
+ const [indexPattern, setIndexPattern] = useState(indexIndexPattern);
+
+ useEffect(() => {
+ if (
+ defineRuleData?.index != null &&
+ (defineRuleData?.dataViewId === '' || defineRuleData?.dataViewId == null)
+ ) {
+ setIndexPattern(indexIndexPattern);
+ }
+ }, [defineRuleData?.dataViewId, defineRuleData?.index, indexIndexPattern]);
+
+ useEffect(() => {
+ const fetchSingleDataView = async () => {
+ if (defineRuleData?.dataViewId != null && defineRuleData?.dataViewId !== '') {
+ const dv = await data.dataViews.get(defineRuleData?.dataViewId);
+ setIndexPattern(dv);
+ }
+ };
+
+ fetchSingleDataView();
+ }, [data.dataViews, defineRuleData, indexIndexPattern, setIndexPattern]);
const { form } = useForm({
defaultValue: initialState,
@@ -190,7 +225,7 @@ const StepAboutRuleComponent: FC = ({
idAria: 'detectionEngineStepAboutRuleSeverityField',
isDisabled: isLoading || indexPatternLoading,
options: severityOptions,
- indices: indexPatterns,
+ indices: indexPattern,
}}
/>
@@ -203,7 +238,7 @@ const StepAboutRuleComponent: FC = ({
dataTestSubj: 'detectionEngineStepAboutRuleRiskScore',
idAria: 'detectionEngineStepAboutRuleRiskScore',
isDisabled: isLoading || indexPatternLoading,
- indices: indexPatterns,
+ indices: indexPattern,
}}
/>
@@ -345,7 +380,7 @@ const StepAboutRuleComponent: FC = ({
dataTestSubj: 'detectionEngineStepAboutRuleRuleNameOverride',
fieldType: 'string',
idAria: 'detectionEngineStepAboutRuleRuleNameOverride',
- indices: indexPatterns,
+ indices: indexPattern,
isDisabled: isLoading || indexPatternLoading,
placeholder: '',
}}
@@ -358,7 +393,7 @@ const StepAboutRuleComponent: FC = ({
dataTestSubj: 'detectionEngineStepAboutRuleTimestampOverride',
fieldType: 'date',
idAria: 'detectionEngineStepAboutRuleTimestampOverride',
- indices: indexPatterns,
+ indices: indexPattern,
isDisabled: isLoading || indexPatternLoading,
placeholder: '',
}}
diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/step_define_rule/index.test.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/step_define_rule/index.test.tsx
index 6c19a49c7f491..2236e8513deec 100644
--- a/x-pack/plugins/security_solution/public/detections/components/rules/step_define_rule/index.test.tsx
+++ b/x-pack/plugins/security_solution/public/detections/components/rules/step_define_rule/index.test.tsx
@@ -12,6 +12,29 @@ import { StepDefineRule, aggregatableFields } from '.';
import mockBrowserFields from './mock_browser_fields.json';
jest.mock('../../../../common/lib/kibana');
+jest.mock('../../../../common/hooks/use_selector', () => {
+ const actual = jest.requireActual('../../../../common/hooks/use_selector');
+ return {
+ ...actual,
+ useDeepEqualSelector: () => ({
+ kibanaDataViews: [{ id: 'world' }],
+ sourcererScope: 'my-selected-dataview-id',
+ }),
+ };
+});
+jest.mock('../../../../common/containers/sourcerer', () => {
+ const actual = jest.requireActual('../../../../common/containers/sourcerer');
+ return {
+ ...actual,
+ useSourcererDataView: jest
+ .fn()
+ .mockReturnValue({ indexPattern: ['fakeindex'], loading: false }),
+ };
+});
+jest.mock('react-router-dom', () => {
+ const actual = jest.requireActual('react-router-dom');
+ return { ...actual, useLocation: jest.fn().mockReturnValue({ pathname: '/alerts' }) };
+});
test('aggregatableFields', function () {
expect(aggregatableFields(mockBrowserFields)).toMatchSnapshot();
diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/step_define_rule/index.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/step_define_rule/index.tsx
index 20a34c0b75a3a..40d76b43b5c0f 100644
--- a/x-pack/plugins/security_solution/public/detections/components/rules/step_define_rule/index.tsx
+++ b/x-pack/plugins/security_solution/public/detections/components/rules/step_define_rule/index.tsx
@@ -5,13 +5,26 @@
* 2.0.
*/
-import { EuiButtonEmpty, EuiFormRow, EuiSpacer } from '@elastic/eui';
-import React, { FC, memo, useCallback, useMemo, useState, useEffect } from 'react';
+import {
+ EuiButtonEmpty,
+ EuiFlexGroup,
+ EuiFlexItem,
+ EuiFormRow,
+ EuiSpacer,
+ EuiButtonGroup,
+ EuiButtonGroupOptionProps,
+ EuiText,
+} from '@elastic/eui';
+import React, { FC, memo, useCallback, useState, useEffect, useMemo } from 'react';
+
import styled from 'styled-components';
+import { i18n as i18nCore } from '@kbn/i18n';
import { isEqual, isEmpty } from 'lodash';
import { FieldSpec } from '@kbn/data-views-plugin/common';
import usePrevious from 'react-use/lib/usePrevious';
+import { DataViewBase, DataViewFieldBase } from '@kbn/es-query';
+import { FormattedMessage } from '@kbn/i18n-react';
import {
DEFAULT_INDEX_KEY,
DEFAULT_THREAT_INDEX_KEY,
@@ -56,13 +69,29 @@ import {
isThresholdRule,
} from '../../../../../common/detection_engine/utils';
import { EqlQueryBar } from '../eql_query_bar';
+import { DataViewSelector } from '../data_view_selector';
import { ThreatMatchInput } from '../threatmatch_input';
import { BrowserField, BrowserFields, useFetchIndex } from '../../../../common/containers/source';
import { RulePreview } from '../rule_preview';
import { getIsRulePreviewDisabled } from '../rule_preview/helpers';
+import { DocLink } from '../../../../common/components/links_to_docs/doc_link';
+
+const DATA_VIEW_SELECT_ID = 'dataView';
+const INDEX_PATTERN_SELECT_ID = 'indexPatterns';
const CommonUseField = getUseField({ component: Field });
+const StyledButtonGroup = styled(EuiButtonGroup)`
+ display: flex;
+ justify-content: right;
+ .euiButtonGroupButton {
+ padding-right: ${(props) => props.theme.eui.paddingSizes.l};
+ }
+`;
+
+const StyledFlexGroup = styled(EuiFlexGroup)`
+ margin-bottom: -21px;
+`;
interface StepDefineRuleProps extends RuleStepProps {
defaultValues?: DefineStepRule;
}
@@ -146,11 +175,13 @@ const StepDefineRuleComponent: FC = ({
isUpdateView = false,
onSubmit,
setForm,
+ kibanaDataViews,
}) => {
const mlCapabilities = useMlCapabilities();
const [openTimelineSearch, setOpenTimelineSearch] = useState(false);
const [indexModified, setIndexModified] = useState(false);
const [threatIndexModified, setThreatIndexModified] = useState(false);
+
const [indicesConfig] = useUiSetting$(DEFAULT_INDEX_KEY);
const [threatIndicesConfig] = useUiSetting$(DEFAULT_THREAT_INDEX_KEY);
const initialState = defaultValues ?? {
@@ -158,17 +189,20 @@ const StepDefineRuleComponent: FC = ({
index: indicesConfig,
threatIndex: threatIndicesConfig,
};
+
const { form } = useForm({
defaultValue: initialState,
options: { stripEmptyFields: false },
schema,
});
+
const { getFields, getFormData, reset, submit } = form;
const [
{
index: formIndex,
ruleType: formRuleType,
queryBar: formQuery,
+ dataViewId: formDataViewId,
threatIndex: formThreatIndex,
threatQueryBar: formThreatQuery,
threshold: formThreshold,
@@ -183,6 +217,7 @@ const StepDefineRuleComponent: FC = ({
'ruleType',
'queryBar',
'threshold',
+ 'dataViewId',
'threshold.field',
'threshold.value',
'threshold.cardinality.field',
@@ -197,16 +232,43 @@ const StepDefineRuleComponent: FC = ({
const [isQueryBarValid, setIsQueryBarValid] = useState(false);
const [isThreatQueryBarValid, setIsThreatQueryBarValid] = useState(false);
const index = formIndex || initialState.index;
+ const dataView = formDataViewId || initialState.dataViewId;
const threatIndex = formThreatIndex || initialState.threatIndex;
const machineLearningJobId = formMachineLearningJobId ?? initialState.machineLearningJobId;
const anomalyThreshold = formAnomalyThreshold ?? initialState.anomalyThreshold;
const ruleType = formRuleType || initialState.ruleType;
+
+ // if 'index' is selected, use these browser fields
+ // otherwise use the dataview browserfields
const previousRuleType = usePrevious(ruleType);
- const [indexPatternsLoading, { browserFields, indexPatterns }] = useFetchIndex(index);
- const fields: Readonly = aggregatableFields(browserFields);
const [optionsSelected, setOptionsSelected] = useState(
defaultValues?.eqlOptions || {}
);
+ const [initIsIndexPatternLoading, { browserFields, indexPatterns: initIndexPattern }] =
+ useFetchIndex(index, false);
+ const [indexPattern, setIndexPattern] = useState(initIndexPattern);
+ const [isIndexPatternLoading, setIsIndexPatternLoading] = useState(initIsIndexPatternLoading);
+ const [dataSourceRadioIdSelected, setDataSourceRadioIdSelected] = useState(
+ dataView == null || dataView === '' ? INDEX_PATTERN_SELECT_ID : DATA_VIEW_SELECT_ID
+ );
+
+ useEffect(() => {
+ if (dataSourceRadioIdSelected === INDEX_PATTERN_SELECT_ID) {
+ setIndexPattern(initIndexPattern);
+ }
+ }, [initIndexPattern, dataSourceRadioIdSelected]);
+
+ // Callback for when user toggles between Data Views and Index Patterns
+ const onChangeDataSource = (optionId: string) => {
+ setDataSourceRadioIdSelected(optionId);
+ };
+
+ const [aggFields, setAggregatableFields] = useState([]);
+
+ useEffect(() => {
+ const { fields } = indexPattern;
+ setAggregatableFields(fields);
+ }, [indexPattern]);
const [
threatIndexPatternsLoading,
@@ -331,21 +393,26 @@ const StepDefineRuleComponent: FC = ({
const ThresholdInputChildren = useCallback(
({ thresholdField, thresholdValue, thresholdCardinalityField, thresholdCardinalityValue }) => (
),
- [fields]
+ [aggFields]
);
+ const SourcererFlex = styled(EuiFlexItem)`
+ align-items: flex-end;
+ `;
+
+ SourcererFlex.displayName = 'SourcererFlex';
const ThreatMatchInputChildren = useCallback(
({ threatMapping }) => (
= ({
),
[
handleResetThreatIndices,
- indexPatterns,
+ indexPattern,
threatBrowserFields,
threatIndexModified,
threatIndexPatterns,
@@ -364,6 +431,166 @@ const StepDefineRuleComponent: FC = ({
]
);
+ const dataViewIndexPatternToggleButtonOptions: EuiButtonGroupOptionProps[] = useMemo(
+ () => [
+ {
+ id: INDEX_PATTERN_SELECT_ID,
+ label: i18nCore.translate(
+ 'xpack.securitySolution.ruleDefine.indexTypeSelect.indexPattern',
+ {
+ defaultMessage: 'Index Patterns',
+ }
+ ),
+ iconType:
+ dataSourceRadioIdSelected === INDEX_PATTERN_SELECT_ID ? 'checkInCircleFilled' : 'empty',
+ 'data-test-subj': `rule-index-toggle-${INDEX_PATTERN_SELECT_ID}`,
+ },
+ {
+ id: DATA_VIEW_SELECT_ID,
+ label: i18nCore.translate('xpack.securitySolution.ruleDefine.indexTypeSelect.dataView', {
+ defaultMessage: 'Data View',
+ }),
+ iconType:
+ dataSourceRadioIdSelected === DATA_VIEW_SELECT_ID ? 'checkInCircleFilled' : 'empty',
+ 'data-test-subj': `rule-index-toggle-${DATA_VIEW_SELECT_ID}`,
+ },
+ ],
+ [dataSourceRadioIdSelected]
+ );
+
+ const DataViewSelectorMemo = useMemo(() => {
+ return (
+
+ );
+ }, [kibanaDataViews]);
+ const DataSource = useMemo(() => {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {dataSourceRadioIdSelected === DATA_VIEW_SELECT_ID ? (
+ DataViewSelectorMemo
+ ) : (
+
+ {i18n.RESET_DEFAULT_INDEX}
+
+ ) : null,
+ }}
+ componentProps={{
+ idAria: 'detectionEngineStepDefineRuleIndices',
+ 'data-test-subj': 'detectionEngineStepDefineRuleIndices',
+ euiFieldProps: {
+ fullWidth: true,
+ placeholder: '',
+ },
+ }}
+ />
+ )}
+
+
+
+ );
+ }, [
+ dataSourceRadioIdSelected,
+ dataViewIndexPatternToggleButtonOptions,
+ DataViewSelectorMemo,
+ indexModified,
+ handleResetIndices,
+ ]);
+
+ const QueryBarMemo = useMemo(
+ () => (
+
+ {i18n.IMPORT_TIMELINE_QUERY}
+
+ ),
+ }}
+ component={QueryBarDefineRule}
+ componentProps={{
+ browserFields,
+ // docValueFields,
+ // runtimeMappings,
+ idAria: 'detectionEngineStepDefineRuleQueryBar',
+ indexPattern,
+ isDisabled: isLoading,
+ isLoading: isIndexPatternLoading,
+ dataTestSubj: 'detectionEngineStepDefineRuleQueryBar',
+ openTimelineSearch,
+ onValidityChange: setIsQueryBarValid,
+ onCloseTimelineSearch: handleCloseTimelineSearch,
+ }}
+ />
+ ),
+ [
+ browserFields,
+ handleCloseTimelineSearch,
+ handleOpenTimelineSearch,
+ indexPattern,
+ isIndexPatternLoading,
+ isLoading,
+ openTimelineSearch,
+ ]
+ );
const onOptionsChange = useCallback((field: FieldsEqlOptions, value: string | undefined) => {
setOptionsSelected((prevOptions) => ({
...prevOptions,
@@ -373,31 +600,31 @@ const StepDefineRuleComponent: FC = ({
const optionsData = useMemo(
() =>
- isEmpty(indexPatterns.fields)
+ isEmpty(indexPattern.fields)
? {
keywordFields: [],
dateFields: [],
nonDateFields: [],
}
: {
- keywordFields: (indexPatterns.fields as FieldSpec[])
+ keywordFields: (indexPattern.fields as FieldSpec[])
.filter((f) => f.esTypes?.includes('keyword'))
.map((f) => ({ label: f.name })),
- dateFields: indexPatterns.fields
+ dateFields: indexPattern.fields
.filter((f) => f.type === 'date')
.map((f) => ({ label: f.name })),
- nonDateFields: indexPatterns.fields
+ nonDateFields: indexPattern.fields
.filter((f) => f.type !== 'date')
.map((f) => ({ label: f.name })),
},
- [indexPatterns]
+ [indexPattern]
);
return isReadOnlyView ? (
@@ -418,25 +645,9 @@ const StepDefineRuleComponent: FC = ({
/>
<>
-
- {i18n.RESET_DEFAULT_INDEX}
-
- ) : null,
- }}
- componentProps={{
- idAria: 'detectionEngineStepDefineRuleIndices',
- 'data-test-subj': 'detectionEngineStepDefineRuleIndices',
- euiFieldProps: {
- fullWidth: true,
- placeholder: '',
- },
- }}
- />
+
+ {DataSource}
+
{isEqlRule(ruleType) ? (
= ({
onValidityChange: setIsQueryBarValid,
idAria: 'detectionEngineStepDefineRuleEqlQueryBar',
isDisabled: isLoading,
- indexPattern: indexPatterns,
+ isLoading: isIndexPatternLoading,
+ indexPattern,
showFilterBar: true,
- isLoading: indexPatternsLoading,
+ // isLoading: indexPatternsLoading,
dataTestSubj: 'detectionEngineStepDefineRuleEqlQueryBar',
}}
config={{
@@ -461,34 +673,7 @@ const StepDefineRuleComponent: FC = ({
}}
/>
) : (
-
- {i18n.IMPORT_TIMELINE_QUERY}
-
- ),
- }}
- component={QueryBarDefineRule}
- componentProps={{
- browserFields,
- idAria: 'detectionEngineStepDefineRuleQueryBar',
- indexPattern: indexPatterns,
- isDisabled: isLoading,
- isLoading: indexPatternsLoading,
- dataTestSubj: 'detectionEngineStepDefineRuleQueryBar',
- openTimelineSearch,
- onValidityChange: setIsQueryBarValid,
- onCloseTimelineSearch: handleCloseTimelineSearch,
- }}
- />
+ QueryBarMemo
)}
>
@@ -566,11 +751,13 @@ const StepDefineRuleComponent: FC = ({
= ({
>
);
};
-
export const StepDefineRule = memo(StepDefineRuleComponent);
export function aggregatableFields(browserFields: BrowserFields): BrowserFields {
diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/step_define_rule/schema.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/step_define_rule/schema.tsx
index eb6a71ac8a5ee..c863eb8cf1eab 100644
--- a/x-pack/plugins/security_solution/public/detections/components/rules/step_define_rule/schema.tsx
+++ b/x-pack/plugins/security_solution/public/detections/components/rules/step_define_rule/schema.tsx
@@ -59,9 +59,9 @@ export const schema: FormSchema = {
...args: Parameters
): ReturnType> | undefined => {
const [{ formData }] = args;
- const needsValidation = !isMlRule(formData.ruleType);
+ const skipValidation = isMlRule(formData.ruleType) || formData.dataViewId != null;
- if (!needsValidation) {
+ if (skipValidation) {
return;
}
@@ -77,6 +77,48 @@ export const schema: FormSchema = {
},
],
},
+ dataViewTitle: {
+ label: i18n.translate(
+ 'xpack.securitySolution.detectionEngine.createRule.stepAboutRule.dataViewSelector',
+ {
+ defaultMessage: 'Data View',
+ }
+ ),
+ validations: [],
+ },
+ dataViewId: {
+ fieldsToValidateOnChange: ['dataViewId'],
+ validations: [
+ {
+ validator: (
+ ...args: Parameters
+ ): ReturnType> | undefined => {
+ const [{ path, formData }] = args;
+ // the dropdown defaults the dataViewId to an empty string somehow on render..
+ // need to figure this out.
+ const notEmptyDataViewId = formData.dataViewId != null && formData.dataViewId !== '';
+ const skipValidation =
+ isMlRule(formData.ruleType) ||
+ ((formData.index != null || notEmptyDataViewId) &&
+ !(formData.index != null && notEmptyDataViewId));
+
+ if (skipValidation) {
+ return;
+ }
+
+ return {
+ path,
+ message: i18n.translate(
+ 'xpack.securitySolution.detectionEngine.createRule.stepDefineRule.dataViewSelectorFieldRequired',
+ {
+ defaultMessage: 'Please select an available Data View or Index Pattern.',
+ }
+ ),
+ };
+ },
+ },
+ ],
+ },
eqlOptions: {},
queryBar: {
validations: [
diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/threshold_input/index.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/threshold_input/index.tsx
index 77c88918abf9c..91efeff024831 100644
--- a/x-pack/plugins/security_solution/public/detections/components/rules/threshold_input/index.tsx
+++ b/x-pack/plugins/security_solution/public/detections/components/rules/threshold_input/index.tsx
@@ -9,8 +9,7 @@ import React, { useMemo } from 'react';
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import styled from 'styled-components';
-import { BrowserFields } from '../../../../common/containers/source';
-import { getCategorizedFieldNames } from '../../../../timelines/components/edit_data_provider/helpers';
+import { DataViewFieldBase } from '@kbn/es-query';
import { FieldHook, Field } from '../../../../shared_imports';
import { THRESHOLD_FIELD_PLACEHOLDER } from './translations';
@@ -30,7 +29,7 @@ interface ThresholdInputProps {
thresholdValue: FieldHook;
thresholdCardinalityField: FieldHook;
thresholdCardinalityValue: FieldHook;
- browserFields: BrowserFields;
+ browserFields: DataViewFieldBase[];
}
const OperatorWrapper = styled(EuiFlexItem)`
@@ -53,7 +52,7 @@ const ThresholdInputComponent: React.FC = ({
() => ({
fullWidth: true,
noSuggestions: false,
- options: getCategorizedFieldNames(browserFields),
+ options: browserFields.map((field) => ({ label: field.name })),
placeholder: THRESHOLD_FIELD_PLACEHOLDER,
onCreateOption: undefined,
style: { width: `${FIELD_COMBO_BOX_WIDTH}px` },
@@ -64,7 +63,7 @@ const ThresholdInputComponent: React.FC = ({
() => ({
fullWidth: true,
noSuggestions: false,
- options: getCategorizedFieldNames(browserFields),
+ options: browserFields.map((field) => ({ label: field.name })),
placeholder: THRESHOLD_FIELD_PLACEHOLDER,
onCreateOption: undefined,
style: { width: `${FIELD_COMBO_BOX_WIDTH}px` },
diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/types.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/types.ts
index 6aad67f91920d..630e8804d31e5 100644
--- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/types.ts
+++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/types.ts
@@ -29,6 +29,7 @@ import {
building_block_type,
license,
rule_name_override,
+ data_view_id,
timestamp_override,
timestamp_field,
event_category_override,
@@ -133,6 +134,7 @@ export const RuleSchema = t.intersection([
anomaly_threshold: t.number,
filters: t.array(t.unknown),
index: t.array(t.string),
+ data_view_id,
language: t.string,
license,
meta: MetaRule,
diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/__mocks__/mock.ts b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/__mocks__/mock.ts
index 006ab332dc174..f67b34a7149d4 100644
--- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/__mocks__/mock.ts
+++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/__mocks__/mock.ts
@@ -193,6 +193,7 @@ export const mockDefineStepRule = (): DefineStepRule => ({
anomalyThreshold: 50,
machineLearningJobId: [],
index: ['filebeat-'],
+ dataViewId: undefined,
queryBar: mockQueryBar,
threatQueryBar: mockQueryBar,
requiredFields: [],
diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/bulk_actions/forms/index_patterns_form.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/bulk_actions/forms/index_patterns_form.tsx
index 04cdbb5d88aa9..fa027cb2e4f75 100644
--- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/bulk_actions/forms/index_patterns_form.tsx
+++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/bulk_actions/forms/index_patterns_form.tsx
@@ -40,6 +40,7 @@ type IndexPatternsEditActions =
interface IndexPatternsFormData {
index: string[];
overwrite: boolean;
+ overwriteDataViews: boolean;
}
const schema: FormSchema = {
@@ -58,9 +59,17 @@ const schema: FormSchema = {
type: FIELD_TYPES.CHECKBOX,
label: i18n.BULK_EDIT_FLYOUT_FORM_ADD_INDEX_PATTERNS_OVERWRITE_LABEL,
},
+ overwriteDataViews: {
+ type: FIELD_TYPES.CHECKBOX,
+ label: i18n.BULK_EDIT_FLYOUT_FORM_DATA_VIEWS_OVERWRITE_LABEL,
+ },
};
-const initialFormData: IndexPatternsFormData = { index: [], overwrite: false };
+const initialFormData: IndexPatternsFormData = {
+ index: [],
+ overwrite: false,
+ overwriteDataViews: false,
+};
const getFormConfig = (editAction: IndexPatternsEditActions) =>
editAction === BulkActionEditType.add_index_patterns
@@ -95,7 +104,10 @@ const IndexPatternsFormComponent = ({
const { indexHelpText, indexLabel, formTitle } = getFormConfig(editAction);
- const [{ overwrite }] = useFormData({ form, watch: ['overwrite'] });
+ const [{ overwrite, overwriteDataViews }] = useFormData({
+ form,
+ watch: ['overwrite', 'overwriteDataViews'],
+ });
const { uiSettings } = useKibana().services;
const defaultPatterns = uiSettings.get(DEFAULT_INDEX_KEY);
@@ -108,6 +120,7 @@ const IndexPatternsFormComponent = ({
const payload = {
value: data.index,
type: data.overwrite ? BulkActionEditType.set_index_patterns : editAction,
+ overwriteDataViews: data.overwriteDataViews,
};
onConfirm(payload);
@@ -139,7 +152,7 @@ const IndexPatternsFormComponent = ({
/>
)}
{overwrite && (
-
+
)}
+
+ {overwriteDataViews && (
+
+
+
+
+
+ )}
);
};
diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/helpers.ts b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/helpers.ts
index 214ec2373c949..13d3af58d1e99 100644
--- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/helpers.ts
+++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/helpers.ts
@@ -89,6 +89,7 @@ export interface RuleFields {
machineLearningJobId: unknown;
queryBar: unknown;
index: unknown;
+ dataViewId?: unknown;
ruleType: unknown;
threshold?: unknown;
threatIndex?: unknown;
@@ -350,6 +351,7 @@ export const formatDefineStepData = (defineStepData: DefineStepRule): DefineStep
return {
...baseFields,
...typeFields,
+ data_view_id: ruleFields.dataViewId,
};
};
@@ -476,6 +478,7 @@ export const formatRule = (
export const formatPreviewRule = ({
index,
+ dataViewId,
query,
threatIndex,
threatQuery,
@@ -488,6 +491,7 @@ export const formatPreviewRule = ({
eqlOptions,
}: {
index: string[];
+ dataViewId?: string;
threatIndex: string[];
query: FieldValueQueryBar;
threatQuery: FieldValueQueryBar;
@@ -502,6 +506,7 @@ export const formatPreviewRule = ({
const defineStepData = {
...stepDefineDefaultValue,
index,
+ dataViewId,
queryBar: query,
ruleType,
threatIndex,
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 42ba9fafd3ea2..a6ff44f6d6f2a 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
@@ -13,9 +13,10 @@ import {
EuiSpacer,
EuiFlexGroup,
} from '@elastic/eui';
-import React, { useCallback, useRef, useState, useMemo } from 'react';
+import React, { useCallback, useRef, useState, useMemo, useEffect } from 'react';
import styled from 'styled-components';
+import { DataViewListItem } from '@kbn/data-views-plugin/common';
import { useCreateRule } from '../../../../containers/detection_engine/rules';
import { CreateRulesSchema } from '../../../../../../common/detection_engine/schemas/request';
import { useListsConfig } from '../../../../containers/detection_engine/lists/use_lists_config';
@@ -93,6 +94,7 @@ const CreateRulePageComponent: React.FC = () => {
const { loading: listsConfigLoading, needsConfiguration: needsListsConfiguration } =
useListsConfig();
const { navigateToApp } = useKibana().services.application;
+ const { data: dataServices } = useKibana().services;
const loading = userInfoLoading || listsConfigLoading;
const [, dispatchToaster] = useStateToaster();
const [activeStep, setActiveStep] = useState(RuleStep.defineRule);
@@ -136,6 +138,22 @@ const CreateRulePageComponent: React.FC = () => {
const ruleType = stepsData.current[RuleStep.defineRule].data?.ruleType;
const ruleName = stepsData.current[RuleStep.aboutRule].data?.name;
const actionMessageParams = useMemo(() => getActionMessageParams(ruleType), [ruleType]);
+ const [dataViewOptions, setDataViewOptions] = useState<{ [x: string]: DataViewListItem }>({});
+
+ useEffect(() => {
+ const fetchDataViews = async () => {
+ const dataViewsRefs = await dataServices.dataViews.getIdsWithTitle();
+ const dataViewIdIndexPatternMap = dataViewsRefs.reduce(
+ (acc, item) => ({
+ ...acc,
+ [item.id]: item,
+ }),
+ {}
+ );
+ setDataViewOptions(dataViewIdIndexPatternMap);
+ };
+ fetchDataViews();
+ }, [dataServices.dataViews]);
const handleAccordionToggle = useCallback(
(step: RuleStep, isOpen: boolean) =>
@@ -333,6 +351,7 @@ const CreateRulePageComponent: React.FC = () => {
isLoading={isLoading || loading}
setForm={setFormHook}
onSubmit={submitStepDefineRule}
+ kibanaDataViews={dataViewOptions}
descriptionColumns="singleSplit"
// We need a key to make this component remount when edit/view mode is toggled
// https://github.com/elastic/kibana/pull/132834#discussion_r881705566
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 8212bebabb5c3..5f110a43eb8b1 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
@@ -26,8 +26,6 @@ import { useSourcererDataView } from '../../../../../common/containers/sourcerer
import { useParams } from 'react-router-dom';
import { mockHistory, Router } from '../../../../../common/mock/router';
-import { useKibana } from '../../../../../common/lib/kibana';
-
import { fillEmptySeverityMappings } from '../helpers';
// Test will fail because we will to need to mock some core services to make the test work
@@ -64,7 +62,15 @@ jest.mock('../../../../containers/detection_engine/rules/use_rule_with_fallback'
useRuleWithFallback: jest.fn(),
};
});
-jest.mock('../../../../../common/containers/sourcerer');
+jest.mock('../../../../../common/containers/sourcerer', () => {
+ const actual = jest.requireActual('../../../../../common/containers/sourcerer');
+ return {
+ ...actual,
+ useSourcererDataView: jest
+ .fn()
+ .mockReturnValue({ indexPattern: ['fakeindex'], loading: false }),
+ };
+});
jest.mock('../../../../../common/containers/use_global_time', () => ({
useGlobalTime: jest.fn().mockReturnValue({
from: '2020-07-07T08:20:18.966Z',
@@ -80,13 +86,55 @@ jest.mock('react-router-dom', () => {
...originalModule,
useParams: jest.fn(),
useHistory: jest.fn(),
+ useLocation: jest.fn().mockReturnValue({ pathname: '/alerts' }),
};
});
-jest.mock('../../../../../common/lib/kibana');
-
const mockRedirectLegacyUrl = jest.fn();
const mockGetLegacyUrlConflict = jest.fn();
+jest.mock('../../../../../common/lib/kibana', () => {
+ const originalModule = jest.requireActual('../../../../../common/lib/kibana');
+ return {
+ ...originalModule,
+ useKibana: () => ({
+ services: {
+ storage: {
+ get: jest.fn().mockReturnValue(true),
+ },
+ application: {
+ getUrlForApp: (appId: string, options?: { path?: string }) =>
+ `/app/${appId}${options?.path}`,
+ navigateToApp: jest.fn(),
+ capabilities: {
+ actions: {
+ delete: true,
+ save: true,
+ show: true,
+ },
+ },
+ },
+ data: {
+ dataViews: {
+ getIdsWithTitle: () => [],
+ },
+ search: {
+ search: () => ({
+ subscribe: () => ({
+ unsubscribe: jest.fn(),
+ }),
+ }),
+ },
+ },
+ spaces: {
+ ui: {
+ components: { getLegacyUrlConflict: mockGetLegacyUrlConflict },
+ redirectLegacyUrl: mockRedirectLegacyUrl,
+ },
+ },
+ },
+ }),
+ };
+});
const state: State = {
...mockGlobalState,
@@ -143,16 +191,6 @@ describe('RuleDetailsPageComponent', () => {
async function setup() {
mockRedirectLegacyUrl.mockReset();
mockGetLegacyUrlConflict.mockReset();
- const useKibanaMock = useKibana as jest.Mocked;
-
- // eslint-disable-next-line react-hooks/rules-of-hooks
- useKibanaMock().services.spaces = {
- ui: {
- // @ts-expect-error
- components: { getLegacyUrlConflict: mockGetLegacyUrlConflict },
- redirectLegacyUrl: mockRedirectLegacyUrl,
- },
- };
}
it('renders correctly with no outcome property on rule', async () => {
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 cc8872b901f44..9ac9442ec170f 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 { Dispatch } from 'redux';
import { isTab } from '@kbn/timelines-plugin/public';
+import { DataViewListItem } from '@kbn/data-views-plugin/common';
import {
useDeepEqualSelector,
useShallowEqualSelector,
@@ -173,7 +174,16 @@ const RuleDetailsPageComponent: React.FC = ({
clearEventsLoading,
clearSelected,
}) => {
- const { navigateToApp } = useKibana().services.application;
+ const {
+ data,
+ application: {
+ navigateToApp,
+ capabilities: { actions },
+ },
+ timelines: timelinesUi,
+ spaces: spacesApi,
+ } = useKibana().services;
+
const dispatch = useDispatch();
const containerElement = useRef(null);
const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []);
@@ -241,24 +251,43 @@ const RuleDetailsPageComponent: React.FC = ({
defineRuleData: null,
scheduleRuleData: null,
};
+ const [dataViewTitle, setDataViewTitle] = useState();
+ useEffect(() => {
+ const fetchDataViewTitle = async () => {
+ if (defineRuleData?.dataViewId != null && defineRuleData?.dataViewId !== '') {
+ const dataView = await data.dataViews.get(defineRuleData?.dataViewId);
+ setDataViewTitle(dataView.title);
+ }
+ };
+ fetchDataViewTitle();
+ }, [data.dataViews, defineRuleData?.dataViewId]);
const [showBuildingBlockAlerts, setShowBuildingBlockAlerts] = useState(false);
const [showOnlyThreatIndicatorAlerts, setShowOnlyThreatIndicatorAlerts] = useState(false);
const mlCapabilities = useMlCapabilities();
const { formatUrl } = useFormatUrl(SecurityPageName.rules);
const { globalFullScreen } = useGlobalFullScreen();
const [filterGroup, setFilterGroup] = useState(FILTER_OPEN);
+ const [dataViewOptions, setDataViewOptions] = useState<{ [x: string]: DataViewListItem }>({});
+ useEffect(() => {
+ const fetchDataViews = async () => {
+ const dataViewsRefs = await data.dataViews.getIdsWithTitle();
+ if (dataViewsRefs.length > 0) {
+ const dataViewIdIndexPatternMap = dataViewsRefs.reduce(
+ (acc, item) => ({
+ ...acc,
+ [item.id]: item,
+ }),
+ {}
+ );
+ setDataViewOptions(dataViewIdIndexPatternMap);
+ }
+ };
+ fetchDataViews();
+ }, [data.dataViews]);
// TODO: Refactor license check + hasMlAdminPermissions to common check
const hasMlPermissions = hasMlLicense(mlCapabilities) && hasMlAdminPermissions(mlCapabilities);
- const {
- services: {
- application: {
- capabilities: { actions },
- },
- timelines: timelinesUi,
- spaces: spacesApi,
- },
- } = useKibana();
+
const hasActionsPrivileges = useMemo(() => {
if (rule?.actions != null && rule?.actions.length > 0 && isBoolean(actions.show)) {
return actions.show;
@@ -450,7 +479,6 @@ const RuleDetailsPageComponent: React.FC = ({
),
[isExistingRule, ruleDetailTab, setRuleDetailTab, pageTabs]
);
- const ruleIndices = useMemo(() => rule?.index ?? DEFAULT_INDEX_PATTERN, [rule?.index]);
const lastExecution = rule?.execution_summary?.last_execution;
const lastExecutionStatus = lastExecution?.status;
@@ -734,7 +762,8 @@ const RuleDetailsPageComponent: React.FC = ({
descriptionColumns="singleSplit"
isReadOnlyView={true}
isLoading={false}
- defaultValues={defineRuleData}
+ defaultValues={{ dataViewTitle, ...defineRuleData }}
+ kibanaDataViews={dataViewOptions}
/>
)}
@@ -814,7 +843,8 @@ const RuleDetailsPageComponent: React.FC = ({
= ({
-
>
);
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 6734636853a08..a563ab85be336 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
@@ -18,6 +18,7 @@ import { FormattedMessage } from '@kbn/i18n-react';
import React, { FC, memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useParams } from 'react-router-dom';
+import { DataViewListItem } from '@kbn/data-views-plugin/common';
import { UpdateRulesSchema } from '../../../../../../common/detection_engine/schemas/request';
import { useRule, useUpdateRule } from '../../../../containers/detection_engine/rules';
import { useListsConfig } from '../../../../containers/detection_engine/lists/use_lists_config';
@@ -75,6 +76,7 @@ const EditRulePageComponent: FC = () => {
] = useUserData();
const { loading: listsConfigLoading, needsConfiguration: needsListsConfiguration } =
useListsConfig();
+ const { data: dataServices } = useKibana().services;
const { navigateToApp } = useKibana().services.application;
const { detailName: ruleId } = useParams<{ detailName: string | undefined }>();
@@ -103,6 +105,22 @@ const EditRulePageComponent: FC = () => {
return stepData.data != null && !stepIsValid(stepData);
});
const [{ isLoading, isSaved }, setRule] = useUpdateRule();
+ const [dataViewOptions, setDataViewOptions] = useState<{ [x: string]: DataViewListItem }>({});
+
+ useEffect(() => {
+ const fetchDataViews = async () => {
+ const dataViewsRefs = await dataServices.dataViews.getIdsWithTitle();
+ const dataViewIdIndexPatternMap = dataViewsRefs.reduce(
+ (acc, item) => ({
+ ...acc,
+ [item.id]: item,
+ }),
+ {}
+ );
+ setDataViewOptions(dataViewIdIndexPatternMap);
+ };
+ fetchDataViews();
+ }, [dataServices.dataViews]);
const actionMessageParams = useMemo(() => getActionMessageParams(rule?.type), [rule?.type]);
const setFormHook = useCallback(
(step: K, hook: RuleStepsFormHooks[K]) => {
@@ -138,6 +156,7 @@ const EditRulePageComponent: FC = () => {
isUpdateView
defaultValues={defineStep.data}
setForm={setFormHook}
+ kibanaDataViews={dataViewOptions}
/>
)}
@@ -226,6 +245,7 @@ const EditRulePageComponent: FC = () => {
scheduleStep.data,
actionsStep.data,
actionMessageParams,
+ dataViewOptions,
]
);
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 93e17efd38d5f..c568d49fe7867 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
@@ -83,6 +83,7 @@ export const getDefineStepsData = (rule: Rule): DefineStepRule => ({
anomalyThreshold: rule.anomaly_threshold ?? 50,
machineLearningJobId: rule.machine_learning_job_id ?? [],
index: rule.index ?? [],
+ dataViewId: rule.data_view_id,
threatIndex: rule.threat_index ?? [],
threatQueryBar: {
query: { query: rule.threat_query ?? '', language: rule.threat_language ?? '' },
diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/translations.ts b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/translations.ts
index 6eae85a64438b..7a9c02d7ed8f9 100644
--- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/translations.ts
+++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/translations.ts
@@ -312,6 +312,13 @@ export const BULK_EDIT_FLYOUT_FORM_ADD_INDEX_PATTERNS_OVERWRITE_LABEL = i18n.tra
}
);
+export const BULK_EDIT_FLYOUT_FORM_DATA_VIEWS_OVERWRITE_LABEL = i18n.translate(
+ 'xpack.securitySolution.detectionEngine.components.allRules.bulkActions.bulkEditFlyoutForm.dataViewsOverwriteCheckboxLabel',
+ {
+ defaultMessage: 'Apply changes to rules configured with data views',
+ }
+);
+
export const BULK_EDIT_FLYOUT_FORM_DELETE_INDEX_PATTERNS_LABEL = i18n.translate(
'xpack.securitySolution.detectionEngine.components.allRules.bulkActions.bulkEditFlyoutForm.deleteIndexPatternsComboboxLabel',
{
@@ -1066,7 +1073,7 @@ export const RULES_BULK_EDIT_SUCCESS_DESCRIPTION = (rulesCount: number) =>
{
values: { rulesCount },
defaultMessage:
- "You've successfully updated {rulesCount, plural, =1 {# rule} other {# rules}}.",
+ "You've successfully updated {rulesCount, plural, =1 {# rule} other {# rules}}. If you did not select to apply changes to rules using Kibana data views, those rules were not updated and will continue using data views.",
}
);
diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/types.ts b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/types.ts
index 3ec6b51fa6329..119a43efab894 100644
--- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/types.ts
+++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/types.ts
@@ -6,6 +6,7 @@
*/
import type { List } from '@kbn/securitysolution-io-ts-list-types';
+
import {
RiskScoreMapping,
ThreatIndex,
@@ -17,6 +18,8 @@ import {
} from '@kbn/securitysolution-io-ts-alerting-types';
import type { Filter } from '@kbn/es-query';
import { RuleAction } from '@kbn/alerting-plugin/common';
+import { DataViewListItem } from '@kbn/data-views-plugin/common';
+
import { RuleAlertAction } from '../../../../../common/detection_engine/types';
import { FieldValueQueryBar } from '../../../components/rules/query_bar';
import { FieldValueTimeline } from '../../../components/rules/pick_timeline';
@@ -89,6 +92,7 @@ export interface RuleStepProps {
onSubmit?: () => void;
resizeParentContainer?: (height: number) => void;
setForm?: (step: K, hook: RuleStepsFormHooks[K]) => void;
+ kibanaDataViews?: { [x: string]: DataViewListItem };
}
export interface AboutStepRule {
@@ -128,11 +132,17 @@ export interface AboutStepRiskScore {
isMappingChecked: boolean;
}
+/**
+ * add / update data source types to show XOR relationship between 'index' and 'dataViewId' fields
+ * Maybe something with io-ts?
+ */
export interface DefineStepRule {
anomalyThreshold: number;
index: string[];
machineLearningJobId: string[];
queryBar: FieldValueQueryBar;
+ dataViewId?: string;
+ dataViewTitle?: string;
relatedIntegrations: RelatedIntegrationArray;
requiredFields: RequiredFieldArray;
ruleType: Type;
@@ -164,6 +174,7 @@ export interface DefineStepRuleJson {
machine_learning_job_id?: string[];
saved_id?: string;
query?: string;
+ data_view_id?: string;
language?: string;
threshold?: {
field: string[];
diff --git a/x-pack/plugins/security_solution/public/kubernetes/pages/index.tsx b/x-pack/plugins/security_solution/public/kubernetes/pages/index.tsx
index 6cb28c7a59ce2..59ba4fa340e21 100644
--- a/x-pack/plugins/security_solution/public/kubernetes/pages/index.tsx
+++ b/x-pack/plugins/security_solution/public/kubernetes/pages/index.tsx
@@ -5,7 +5,8 @@
* 2.0.
*/
-import React from 'react';
+import React, { useMemo } from 'react';
+import { getEsQueryConfig } from '@kbn/data-plugin/common';
import { SecuritySolutionPageWrapper } from '../../common/components/page_wrapper';
import { useKibana } from '../../common/lib/kibana';
import { SecurityPageName } from '../../../common/constants';
@@ -13,17 +14,52 @@ import { SpyRoute } from '../../common/utils/route/spy_routes';
import { FiltersGlobal } from '../../common/components/filters_global';
import { SiemSearchBar } from '../../common/components/search_bar';
import { showGlobalFilters } from '../../timelines/components/timeline/helpers';
+import { inputsSelectors } from '../../common/store';
import { useGlobalFullScreen } from '../../common/containers/use_full_screen';
import { useSourcererDataView } from '../../common/containers/sourcerer';
+import { useGlobalTime } from '../../common/containers/use_global_time';
+import { useDeepEqualSelector } from '../../common/hooks/use_selector';
+import { convertToBuildEsQuery } from '../../common/lib/keury';
+import { useInvalidFilterQuery } from '../../common/hooks/use_invalid_filter_query';
export const KubernetesContainer = React.memo(() => {
- const { kubernetesSecurity } = useKibana().services;
+ const { kubernetesSecurity, uiSettings } = useKibana().services;
const { globalFullScreen } = useGlobalFullScreen();
const {
indexPattern,
// runtimeMappings,
// loading: isLoadingIndexPattern,
} = useSourcererDataView();
+ const { from, to } = useGlobalTime();
+
+ const getGlobalFiltersQuerySelector = useMemo(
+ () => inputsSelectors.globalFiltersQuerySelector(),
+ []
+ );
+ const getGlobalQuerySelector = useMemo(() => inputsSelectors.globalQuerySelector(), []);
+ const query = useDeepEqualSelector(getGlobalQuerySelector);
+ const filters = useDeepEqualSelector(getGlobalFiltersQuerySelector);
+
+ const [filterQuery, kqlError] = useMemo(
+ () =>
+ convertToBuildEsQuery({
+ config: getEsQueryConfig(uiSettings),
+ indexPattern,
+ queries: [query],
+ filters,
+ }),
+ [filters, indexPattern, uiSettings, query]
+ );
+
+ useInvalidFilterQuery({
+ id: 'kubernetesQuery',
+ filterQuery,
+ kqlError,
+ query,
+ startDate: from,
+ endDate: to,
+ });
+
return (
{kubernetesSecurity.getKubernetesPage({
@@ -32,6 +68,12 @@ export const KubernetesContainer = React.memo(() => {
),
+ indexPattern,
+ globalFilter: {
+ filterQuery,
+ startDate: from,
+ endDate: to,
+ },
})}
diff --git a/x-pack/plugins/security_solution/public/management/components/policy_response/policy_response.tsx b/x-pack/plugins/security_solution/public/management/components/policy_response/policy_response.tsx
index b468dce0a5b48..027e8e541d89b 100644
--- a/x-pack/plugins/security_solution/public/management/components/policy_response/policy_response.tsx
+++ b/x-pack/plugins/security_solution/public/management/components/policy_response/policy_response.tsx
@@ -8,7 +8,9 @@
import React, { memo, useCallback } from 'react';
import styled from 'styled-components';
import { FormattedMessage } from '@kbn/i18n-react';
+import { DocLinksStart } from '@kbn/core/public';
import { EuiHealth, EuiText, EuiTreeView, EuiNotificationBadge } from '@elastic/eui';
+import { useKibana } from '../../../common/lib/kibana';
import {
HostPolicyResponseActionStatus,
HostPolicyResponseAppliedAction,
@@ -17,7 +19,7 @@ import {
ImmutableArray,
ImmutableObject,
} from '../../../../common/endpoint/types';
-import { formatResponse } from './policy_response_friendly_names';
+import { formatResponse, PolicyResponseActionFormatter } from './policy_response_friendly_names';
import { PolicyResponseActionItem } from './policy_response_action_item';
// Most of them are needed in order to display large react nodes (PolicyResponseActionItem) in child levels.
@@ -59,6 +61,7 @@ export const PolicyResponse = memo(
policyResponseActions,
policyResponseAttentionCount,
}: PolicyResponseProps) => {
+ const { docLinks } = useKibana().services;
const getEntryIcon = useCallback(
(status: HostPolicyResponseActionStatus, unsuccessCounts: number) =>
status === HostPolicyResponseActionStatus.success ? (
@@ -88,6 +91,12 @@ export const PolicyResponse = memo(
(currentAction) => currentAction.name === actionKey
) as ImmutableObject;
+ const policyResponseActionFormatter = new PolicyResponseActionFormatter(
+ action || {},
+ docLinks.links.securitySolution.policyResponseTroubleshooting[
+ action.name as keyof DocLinksStart['links']['securitySolution']['policyResponseTroubleshooting']
+ ]
+ );
return {
label: (
- {formatResponse(actionKey)}
+ {policyResponseActionFormatter.title}
),
id: actionKey,
@@ -116,11 +125,7 @@ export const PolicyResponse = memo(
{
label: (
{}} // TODO
+ policyResponseActionFormatter={policyResponseActionFormatter}
/>
),
id: `action_message_${actionKey}`,
@@ -135,7 +140,11 @@ export const PolicyResponse = memo(
};
});
},
- [getEntryIcon, policyResponseActions]
+ [
+ docLinks.links.securitySolution.policyResponseTroubleshooting,
+ getEntryIcon,
+ policyResponseActions,
+ ]
);
const getResponseConfigs = useCallback(
diff --git a/x-pack/plugins/security_solution/public/management/components/policy_response/policy_response_action_item.tsx b/x-pack/plugins/security_solution/public/management/components/policy_response/policy_response_action_item.tsx
index 9f9fdb48ede15..65529f96fff2c 100644
--- a/x-pack/plugins/security_solution/public/management/components/policy_response/policy_response_action_item.tsx
+++ b/x-pack/plugins/security_solution/public/management/components/policy_response/policy_response_action_item.tsx
@@ -7,8 +7,8 @@
import React, { memo } from 'react';
import styled from 'styled-components';
-import { EuiButton, EuiCallOut, EuiText, EuiSpacer } from '@elastic/eui';
-import { HostPolicyResponseActionStatus } from '../../../../common/endpoint/types';
+import { EuiLink, EuiCallOut, EuiText } from '@elastic/eui';
+import { PolicyResponseActionFormatter } from './policy_response_friendly_names';
const StyledEuiCallout = styled(EuiCallOut)`
padding: ${({ theme }) => theme.eui.paddingSizes.s};
@@ -19,39 +19,36 @@ const StyledEuiCallout = styled(EuiCallOut)`
`;
interface PolicyResponseActionItemProps {
- status: HostPolicyResponseActionStatus;
- actionTitle: string;
- actionMessage: string;
- actionButtonLabel?: string;
- actionButtonOnClick?: () => void;
+ policyResponseActionFormatter: PolicyResponseActionFormatter;
}
/**
* A policy response action item
*/
export const PolicyResponseActionItem = memo(
- ({
- status,
- actionTitle,
- actionMessage,
- actionButtonLabel,
- actionButtonOnClick,
- }: PolicyResponseActionItemProps) => {
- return status !== HostPolicyResponseActionStatus.success &&
- status !== HostPolicyResponseActionStatus.unsupported ? (
-
+ ({ policyResponseActionFormatter }: PolicyResponseActionItemProps) => {
+ return policyResponseActionFormatter.hasError ? (
+
- {actionMessage}
+ {policyResponseActionFormatter.errorDescription}
+ {policyResponseActionFormatter.linkText && policyResponseActionFormatter.linkUrl && (
+
+ {policyResponseActionFormatter.linkText}
+
+ )}
-
- {actionButtonLabel && actionButtonOnClick && (
-
- {actionButtonLabel}
-
- )}
) : (
- {actionMessage}
+ {policyResponseActionFormatter.description || policyResponseActionFormatter.title}
);
}
diff --git a/x-pack/plugins/security_solution/public/management/components/policy_response/policy_response_friendly_names.ts b/x-pack/plugins/security_solution/public/management/components/policy_response/policy_response_friendly_names.ts
index 3551d00c50c73..2abc1efd406ec 100644
--- a/x-pack/plugins/security_solution/public/management/components/policy_response/policy_response_friendly_names.ts
+++ b/x-pack/plugins/security_solution/public/management/components/policy_response/policy_response_friendly_names.ts
@@ -6,270 +6,418 @@
*/
import { i18n } from '@kbn/i18n';
+import {
+ HostPolicyResponseActionStatus,
+ HostPolicyResponseAppliedAction,
+ ImmutableObject,
+} from '../../../../common/endpoint/types';
-const policyResponses: Array<[string, string]> = [
- [
- 'configure_dns_events',
- i18n.translate('xpack.securitySolution.endpoint.details.policyResponse.configure_dns_events', {
- defaultMessage: 'Configure DNS Events',
- }),
- ],
- [
- 'configure_elasticsearch_connection',
- i18n.translate(
- 'xpack.securitySolution.endpoint.details.policyResponse.configure_elasticsearch_connection',
- { defaultMessage: 'Configure Elasticsearch Connection' }
- ),
- ],
- [
- 'configure_file_events',
- i18n.translate('xpack.securitySolution.endpoint.details.policyResponse.configure_file_events', {
- defaultMessage: 'Configure File Events',
- }),
- ],
- [
- 'configure_imageload_events',
- i18n.translate(
- 'xpack.securitySolution.endpoint.details.policyResponse.configure_imageload_events',
- { defaultMessage: 'Configure Image Load Events' }
- ),
- ],
- [
- 'configure_kernel',
- i18n.translate('xpack.securitySolution.endpoint.details.policyResponse.configure_kernel', {
- defaultMessage: 'Configure Kernel',
- }),
- ],
- [
- 'configure_logging',
- i18n.translate('xpack.securitySolution.endpoint.details.policyResponse.configure_logging', {
- defaultMessage: 'Configure Logging',
- }),
- ],
- [
- 'configure_malware',
- i18n.translate('xpack.securitySolution.endpoint.details.policyResponse.configure_malware', {
- defaultMessage: 'Configure Malware',
- }),
- ],
- [
- 'configure_network_events',
- i18n.translate(
- 'xpack.securitySolution.endpoint.details.policyResponse.configure_network_events',
- { defaultMessage: 'Configure Network Events' }
- ),
- ],
- [
- 'configure_process_events',
- i18n.translate(
- 'xpack.securitySolution.endpoint.details.policyResponse.configure_process_events',
- { defaultMessage: 'Configure Process Events' }
- ),
- ],
- [
- 'configure_registry_events',
- i18n.translate(
- 'xpack.securitySolution.endpoint.details.policyResponse.configure_registry_events',
- { defaultMessage: 'Configure Registry Events' }
- ),
- ],
- [
- 'configure_security_events',
- i18n.translate(
- 'xpack.securitySolution.endpoint.details.policyResponse.configure_security_events',
- { defaultMessage: 'Configure Security Events' }
- ),
- ],
- [
- 'connect_kernel',
- i18n.translate('xpack.securitySolution.endpoint.details.policyResponse.connect_kernel', {
- defaultMessage: 'Connect Kernel',
- }),
- ],
- [
- 'detect_async_image_load_events',
- i18n.translate(
- 'xpack.securitySolution.endpoint.details.policyResponse.detect_async_image_load_events',
- { defaultMessage: 'Detect Async Image Load Events' }
- ),
- ],
- [
- 'detect_file_open_events',
- i18n.translate(
- 'xpack.securitySolution.endpoint.details.policyResponse.detect_file_open_events',
- { defaultMessage: 'Detect File Open Events' }
- ),
- ],
- [
- 'detect_file_write_events',
- i18n.translate(
- 'xpack.securitySolution.endpoint.details.policyResponse.detect_file_write_events',
- { defaultMessage: 'Detect File Write Events' }
- ),
- ],
- [
- 'detect_network_events',
- i18n.translate('xpack.securitySolution.endpoint.details.policyResponse.detect_network_events', {
- defaultMessage: 'Detect Network Events',
- }),
- ],
- [
- 'detect_process_events',
- i18n.translate('xpack.securitySolution.endpoint.details.policyResponse.detect_process_events', {
- defaultMessage: 'Detect Process Events',
- }),
- ],
- [
- 'detect_registry_events',
- i18n.translate(
- 'xpack.securitySolution.endpoint.details.policyResponse.detect_registry_events',
- { defaultMessage: 'Detect Registry Events' }
- ),
- ],
- [
- 'detect_sync_image_load_events',
- i18n.translate(
- 'xpack.securitySolution.endpoint.details.policyResponse.detect_sync_image_load_events',
- { defaultMessage: 'Detect Sync Image Load Events' }
- ),
- ],
- [
- 'download_global_artifacts',
- i18n.translate(
- 'xpack.securitySolution.endpoint.details.policyResponse.download_global_artifacts',
- { defaultMessage: 'Download Global Artifacts' }
- ),
- ],
- [
- 'download_user_artifacts',
- i18n.translate(
- 'xpack.securitySolution.endpoint.details.policyResponse.download_user_artifacts',
- { defaultMessage: 'Download User Artifacts' }
- ),
- ],
- [
- 'load_config',
- i18n.translate('xpack.securitySolution.endpoint.details.policyResponse.load_config', {
- defaultMessage: 'Load Config',
- }),
- ],
- [
- 'load_malware_model',
- i18n.translate('xpack.securitySolution.endpoint.details.policyResponse.load_malware_model', {
- defaultMessage: 'Load Malware Model',
- }),
- ],
- [
- 'read_elasticsearch_config',
- i18n.translate(
- 'xpack.securitySolution.endpoint.details.policyResponse.read_elasticsearch_config',
- { defaultMessage: 'Read Elasticsearch Config' }
- ),
- ],
- [
- 'read_events_config',
- i18n.translate('xpack.securitySolution.endpoint.details.policyResponse.read_events_config', {
- defaultMessage: 'Read Events Config',
- }),
- ],
- [
- 'read_kernel_config',
- i18n.translate('xpack.securitySolution.endpoint.details.policyResponse.read_kernel_config', {
- defaultMessage: 'Read Kernel Config',
- }),
- ],
- [
- 'read_logging_config',
- i18n.translate('xpack.securitySolution.endpoint.details.policyResponse.read_logging_config', {
- defaultMessage: 'Read Logging Config',
- }),
- ],
- [
- 'read_malware_config',
- i18n.translate('xpack.securitySolution.endpoint.details.policyResponse.read_malware_config', {
- defaultMessage: 'Read Malware Config',
- }),
- ],
- [
- 'workflow',
- i18n.translate('xpack.securitySolution.endpoint.details.policyResponse.workflow', {
- defaultMessage: 'Workflow',
- }),
- ],
-];
+type PolicyResponseSections =
+ | 'logging'
+ | 'streaming'
+ | 'malware'
+ | 'events'
+ | 'memory_protection'
+ | 'behavior_protection';
-const responseMap = new Map(policyResponses);
-
-// Additional values used in the Policy Response UI
-responseMap.set(
- 'success',
- i18n.translate('xpack.securitySolution.endpoint.details.policyResponse.success', {
- defaultMessage: 'Success',
- })
-);
-responseMap.set(
- 'warning',
- i18n.translate('xpack.securitySolution.endpoint.details.policyResponse.warning', {
- defaultMessage: 'Warning',
- })
-);
-responseMap.set(
- 'failure',
- i18n.translate('xpack.securitySolution.endpoint.details.policyResponse.failed', {
- defaultMessage: 'Failed',
- })
-);
-responseMap.set(
- 'logging',
- i18n.translate('xpack.securitySolution.endpoint.details.policyResponse.logging', {
- defaultMessage: 'Logging',
- })
-);
-responseMap.set(
- 'streaming',
- i18n.translate('xpack.securitySolution.endpoint.details.policyResponse.streaming', {
- defaultMessage: 'Streaming',
- })
-);
-responseMap.set(
- 'malware',
- i18n.translate('xpack.securitySolution.endpoint.details.policyResponse.malware', {
- defaultMessage: 'Malware',
- })
-);
-responseMap.set(
- 'events',
- i18n.translate('xpack.securitySolution.endpoint.details.policyResponse.events', {
- defaultMessage: 'Events',
- })
-);
-responseMap.set(
- 'memory_protection',
- i18n.translate('xpack.securitySolution.endpoint.details.policyResponse.memory_protection', {
- defaultMessage: 'Memory Threat',
- })
-);
-responseMap.set(
- 'behavior_protection',
- i18n.translate('xpack.securitySolution.endpoint.details.policyResponse.behavior_protection', {
- defaultMessage: 'Malicious Behavior',
- })
+const policyResponseSections = Object.freeze(
+ new Map([
+ [
+ 'logging',
+ i18n.translate('xpack.securitySolution.endpoint.details.policyResponse.logging', {
+ defaultMessage: 'Logging',
+ }),
+ ],
+ [
+ 'streaming',
+ i18n.translate('xpack.securitySolution.endpoint.details.policyResponse.streaming', {
+ defaultMessage: 'Streaming',
+ }),
+ ],
+ [
+ 'malware',
+ i18n.translate('xpack.securitySolution.endpoint.details.policyResponse.malware', {
+ defaultMessage: 'Malware',
+ }),
+ ],
+ [
+ 'events',
+ i18n.translate('xpack.securitySolution.endpoint.details.policyResponse.events', {
+ defaultMessage: 'Events',
+ }),
+ ],
+ [
+ 'memory_protection',
+ i18n.translate('xpack.securitySolution.endpoint.details.policyResponse.memory_protection', {
+ defaultMessage: 'Memory Threat',
+ }),
+ ],
+ [
+ 'behavior_protection',
+ i18n.translate('xpack.securitySolution.endpoint.details.policyResponse.behavior_protection', {
+ defaultMessage: 'Malicious Behavior',
+ }),
+ ],
+ ])
);
/**
* Maps a server provided value to corresponding i18n'd string.
*/
-export function formatResponse(responseString: string) {
- if (responseMap.has(responseString)) {
- return responseMap.get(responseString);
+export function formatResponse(responseString: PolicyResponseSections | string) {
+ if (policyResponseSections.has(responseString)) {
+ return policyResponseSections.get(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(
+ policyResponseSections.set(
responseString,
responseString.replace(/_/g, ' ').replace(/\b(\w)/g, (m) => m.toUpperCase())
);
- return responseMap.get(responseString);
+ return policyResponseSections.get(responseString);
+}
+
+type PolicyResponseAction =
+ | 'configure_dns_events'
+ | 'configure_dns_events'
+ | 'configure_elasticsearch_connection'
+ | 'configure_file_events'
+ | 'configure_imageload_events'
+ | 'configure_kernel'
+ | 'configure_logging'
+ | 'configure_malware'
+ | 'configure_network_events'
+ | 'configure_process_events'
+ | 'configure_registry_events'
+ | 'configure_security_events'
+ | 'connect_kernel'
+ | 'detect_async_image_load_events'
+ | 'detect_file_open_events'
+ | 'detect_file_write_events'
+ | 'detect_network_events'
+ | 'detect_process_events'
+ | 'detect_registry_events'
+ | 'detect_sync_image_load_events'
+ | 'download_global_artifacts'
+ | 'download_user_artifacts'
+ | 'load_config'
+ | 'load_malware_model'
+ | 'read_elasticsearch_config'
+ | 'read_events_config'
+ | 'read_kernel_config'
+ | 'read_logging_config'
+ | 'read_malware_config'
+ | 'workflow'
+ | 'full_disk_access';
+
+const policyResponseTitles = Object.freeze(
+ new Map([
+ [
+ 'configure_dns_events',
+ i18n.translate(
+ 'xpack.securitySolution.endpoint.details.policyResponse.configure_dns_events',
+ {
+ defaultMessage: 'Configure DNS Events',
+ }
+ ),
+ ],
+ [
+ 'configure_elasticsearch_connection',
+ i18n.translate(
+ 'xpack.securitySolution.endpoint.details.policyResponse.configure_elasticsearch_connection',
+ { defaultMessage: 'Configure Elasticsearch Connection' }
+ ),
+ ],
+ [
+ 'configure_file_events',
+ i18n.translate(
+ 'xpack.securitySolution.endpoint.details.policyResponse.configure_file_events',
+ {
+ defaultMessage: 'Configure File Events',
+ }
+ ),
+ ],
+ [
+ 'configure_imageload_events',
+ i18n.translate(
+ 'xpack.securitySolution.endpoint.details.policyResponse.configure_imageload_events',
+ { defaultMessage: 'Configure Image Load Events' }
+ ),
+ ],
+ [
+ 'configure_kernel',
+ i18n.translate('xpack.securitySolution.endpoint.details.policyResponse.configure_kernel', {
+ defaultMessage: 'Configure Kernel',
+ }),
+ ],
+ [
+ 'configure_logging',
+ i18n.translate('xpack.securitySolution.endpoint.details.policyResponse.configure_logging', {
+ defaultMessage: 'Configure Logging',
+ }),
+ ],
+ [
+ 'configure_malware',
+ i18n.translate('xpack.securitySolution.endpoint.details.policyResponse.configure_malware', {
+ defaultMessage: 'Configure Malware',
+ }),
+ ],
+ [
+ 'configure_network_events',
+ i18n.translate(
+ 'xpack.securitySolution.endpoint.details.policyResponse.configure_network_events',
+ { defaultMessage: 'Configure Network Events' }
+ ),
+ ],
+ [
+ 'configure_process_events',
+ i18n.translate(
+ 'xpack.securitySolution.endpoint.details.policyResponse.configure_process_events',
+ { defaultMessage: 'Configure Process Events' }
+ ),
+ ],
+ [
+ 'configure_registry_events',
+ i18n.translate(
+ 'xpack.securitySolution.endpoint.details.policyResponse.configure_registry_events',
+ { defaultMessage: 'Configure Registry Events' }
+ ),
+ ],
+ [
+ 'configure_security_events',
+ i18n.translate(
+ 'xpack.securitySolution.endpoint.details.policyResponse.configure_security_events',
+ { defaultMessage: 'Configure Security Events' }
+ ),
+ ],
+ [
+ 'connect_kernel',
+ i18n.translate('xpack.securitySolution.endpoint.details.policyResponse.connect_kernel', {
+ defaultMessage: 'Connect Kernel',
+ }),
+ ],
+ [
+ 'detect_async_image_load_events',
+ i18n.translate(
+ 'xpack.securitySolution.endpoint.details.policyResponse.detect_async_image_load_events',
+ { defaultMessage: 'Detect Async Image Load Events' }
+ ),
+ ],
+ [
+ 'detect_file_open_events',
+ i18n.translate(
+ 'xpack.securitySolution.endpoint.details.policyResponse.detect_file_open_events',
+ { defaultMessage: 'Detect File Open Events' }
+ ),
+ ],
+ [
+ 'detect_file_write_events',
+ i18n.translate(
+ 'xpack.securitySolution.endpoint.details.policyResponse.detect_file_write_events',
+ { defaultMessage: 'Detect File Write Events' }
+ ),
+ ],
+ [
+ 'detect_network_events',
+ i18n.translate(
+ 'xpack.securitySolution.endpoint.details.policyResponse.detect_network_events',
+ {
+ defaultMessage: 'Detect Network Events',
+ }
+ ),
+ ],
+ [
+ 'detect_process_events',
+ i18n.translate(
+ 'xpack.securitySolution.endpoint.details.policyResponse.detect_process_events',
+ {
+ defaultMessage: 'Detect Process Events',
+ }
+ ),
+ ],
+ [
+ 'detect_registry_events',
+ i18n.translate(
+ 'xpack.securitySolution.endpoint.details.policyResponse.detect_registry_events',
+ { defaultMessage: 'Detect Registry Events' }
+ ),
+ ],
+ [
+ 'detect_sync_image_load_events',
+ i18n.translate(
+ 'xpack.securitySolution.endpoint.details.policyResponse.detect_sync_image_load_events',
+ { defaultMessage: 'Detect Sync Image Load Events' }
+ ),
+ ],
+ [
+ 'download_global_artifacts',
+ i18n.translate(
+ 'xpack.securitySolution.endpoint.details.policyResponse.download_global_artifacts',
+ { defaultMessage: 'Download Global Artifacts' }
+ ),
+ ],
+ [
+ 'download_user_artifacts',
+ i18n.translate(
+ 'xpack.securitySolution.endpoint.details.policyResponse.download_user_artifacts',
+ { defaultMessage: 'Download User Artifacts' }
+ ),
+ ],
+ [
+ 'load_config',
+ i18n.translate('xpack.securitySolution.endpoint.details.policyResponse.load_config', {
+ defaultMessage: 'Load Config',
+ }),
+ ],
+ [
+ 'load_malware_model',
+ i18n.translate('xpack.securitySolution.endpoint.details.policyResponse.load_malware_model', {
+ defaultMessage: 'Load Malware Model',
+ }),
+ ],
+ [
+ 'read_elasticsearch_config',
+ i18n.translate(
+ 'xpack.securitySolution.endpoint.details.policyResponse.read_elasticsearch_config',
+ { defaultMessage: 'Read Elasticsearch Config' }
+ ),
+ ],
+ [
+ 'read_events_config',
+ i18n.translate('xpack.securitySolution.endpoint.details.policyResponse.read_events_config', {
+ defaultMessage: 'Read Events Config',
+ }),
+ ],
+ [
+ 'read_kernel_config',
+ i18n.translate('xpack.securitySolution.endpoint.details.policyResponse.read_kernel_config', {
+ defaultMessage: 'Read Kernel Config',
+ }),
+ ],
+ [
+ 'read_logging_config',
+ i18n.translate('xpack.securitySolution.endpoint.details.policyResponse.read_logging_config', {
+ defaultMessage: 'Read Logging Config',
+ }),
+ ],
+ [
+ 'read_malware_config',
+ i18n.translate('xpack.securitySolution.endpoint.details.policyResponse.read_malware_config', {
+ defaultMessage: 'Read Malware Config',
+ }),
+ ],
+ [
+ 'workflow',
+ i18n.translate('xpack.securitySolution.endpoint.details.policyResponse.workflow', {
+ defaultMessage: 'Workflow',
+ }),
+ ],
+ [
+ 'full_disk_access',
+ i18n.translate('xpack.securitySolution.endpoint.details.policyResponse.full_disk_access', {
+ defaultMessage: 'Full Disk Access',
+ }),
+ ],
+ ])
+);
+
+type PolicyResponseStatus = `${HostPolicyResponseActionStatus}`;
+
+const policyResponseStatuses = Object.freeze(
+ new Map([
+ [
+ 'success',
+ i18n.translate('xpack.securitySolution.endpoint.details.policyResponse.success', {
+ defaultMessage: 'Success',
+ }),
+ ],
+ [
+ 'warning',
+ i18n.translate('xpack.securitySolution.endpoint.details.policyResponse.warning', {
+ defaultMessage: 'Warning',
+ }),
+ ],
+ [
+ 'failure',
+ i18n.translate('xpack.securitySolution.endpoint.details.policyResponse.failed', {
+ defaultMessage: 'Failed',
+ }),
+ ],
+ [
+ 'unsupported',
+ i18n.translate('xpack.securitySolution.endpoint.details.policyResponse.unsupported', {
+ defaultMessage: 'Unsupported',
+ }),
+ ],
+ ])
+);
+
+const descriptions = Object.freeze(
+ new Map | string, string>([
+ [
+ 'full_disk_access',
+ i18n.translate(
+ 'xpack.securitySolution.endpoint.details.policyResponse.description.full_disk_access',
+ {
+ defaultMessage: 'You must enable full disk access for Elastic Endpoint on your machine. ',
+ }
+ ),
+ ],
+ ])
+);
+
+const linkTexts = Object.freeze(
+ new Map | string, string>([
+ [
+ 'full_disk_access',
+ i18n.translate(
+ 'xpack.securitySolution.endpoint.details.policyResponse.link.text.full_disk_access',
+ {
+ defaultMessage: 'Learn more.',
+ }
+ ),
+ ],
+ ])
+);
+
+/**
+ * An array with errors we want to bubble up in policy response
+ */
+const GENERIC_ACTION_ERRORS: readonly string[] = Object.freeze(['full_disk_access']);
+
+export class PolicyResponseActionFormatter {
+ public key: string;
+ public title: string;
+ public description: string;
+ public hasError: boolean;
+ public errorTitle: string;
+ public errorDescription?: string;
+ public status?: string;
+ public linkText?: string;
+ public linkUrl?: string;
+
+ constructor(
+ policyResponseAppliedAction: ImmutableObject,
+ link?: string
+ ) {
+ this.key = policyResponseAppliedAction.name;
+ this.title =
+ policyResponseTitles.get(this.key) ??
+ this.key.replace(/_/g, ' ').replace(/\b(\w)/g, (m) => m.toUpperCase());
+ this.hasError =
+ policyResponseAppliedAction.status === 'failure' ||
+ policyResponseAppliedAction.status === 'warning';
+ this.description = descriptions.get(this.key) || policyResponseAppliedAction.message;
+ this.errorDescription = descriptions.get(this.key) || policyResponseAppliedAction.message;
+ this.errorTitle = this.errorDescription ? this.title : policyResponseAppliedAction.name;
+ this.status = policyResponseStatuses.get(policyResponseAppliedAction.status);
+ this.linkText = linkTexts.get(this.key);
+ this.linkUrl = link;
+ }
+
+ public isGeneric(): boolean {
+ return GENERIC_ACTION_ERRORS.includes(this.key);
+ }
}
diff --git a/x-pack/plugins/security_solution/public/management/components/policy_response/policy_response_wrapper.test.tsx b/x-pack/plugins/security_solution/public/management/components/policy_response/policy_response_wrapper.test.tsx
index 1b772f203a0fd..aa1b33e24d8fe 100644
--- a/x-pack/plugins/security_solution/public/management/components/policy_response/policy_response_wrapper.test.tsx
+++ b/x-pack/plugins/security_solution/public/management/components/policy_response/policy_response_wrapper.test.tsx
@@ -219,4 +219,47 @@ describe('when on the policy response', () => {
const component = await renderOpenedTree();
expect(component.getByText('A New Unknown Action')).not.toBeNull();
});
+
+ it('should not display error callout if status success', async () => {
+ const policyResponse = createPolicyResponse();
+ policyResponse.Endpoint.policy.applied.actions.forEach(
+ (action) => (action.status = HostPolicyResponseActionStatus.success)
+ );
+ runMock(policyResponse);
+ const component = await renderOpenedTree();
+ expect(component.queryAllByTestId('endpointPolicyResponseErrorCallOut')).toHaveLength(0);
+ });
+
+ describe('error callout', () => {
+ let policyResponse: HostPolicyResponse;
+
+ beforeEach(() => {
+ policyResponse = createPolicyResponse(HostPolicyResponseActionStatus.failure);
+ runMock(policyResponse);
+ });
+
+ it('should not display link if type is NOT mapped', async () => {
+ const component = await renderOpenedTree();
+ const calloutLink = component.queryByTestId('endpointPolicyResponseErrorCallOutLink');
+ expect(calloutLink).toBeNull();
+ });
+
+ it('should display link if type is mapped', async () => {
+ const action = {
+ name: 'full_disk_access',
+ message:
+ 'You must enable full disk access for Elastic Endpoint on your machine. See our troubleshooting documentation for more information',
+ status: HostPolicyResponseActionStatus.failure,
+ };
+
+ policyResponse.Endpoint.policy.applied.actions.push(action);
+ policyResponse.Endpoint.policy.applied.response.configurations.malware.concerned_actions.push(
+ 'full_disk_access'
+ );
+
+ const component = await renderOpenedTree();
+ const calloutLinks = component.queryAllByTestId('endpointPolicyResponseErrorCallOutLink');
+ expect(calloutLinks.length).toEqual(2);
+ });
+ });
});
diff --git a/x-pack/plugins/security_solution/public/management/components/policy_response/policy_response_wrapper.tsx b/x-pack/plugins/security_solution/public/management/components/policy_response/policy_response_wrapper.tsx
index 3f30fc5dbb148..d9538e96fc099 100644
--- a/x-pack/plugins/security_solution/public/management/components/policy_response/policy_response_wrapper.tsx
+++ b/x-pack/plugins/security_solution/public/management/components/policy_response/policy_response_wrapper.tsx
@@ -4,14 +4,18 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
-import React, { memo, useEffect, useState } from 'react';
+import React, { memo, useEffect, useState, useMemo } from 'react';
import { EuiEmptyPrompt, EuiLoadingSpinner, EuiSpacer, EuiText } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
+import { DocLinksStart } from '@kbn/core/public';
+import { useKibana } from '../../../common/lib/kibana';
import type { HostPolicyResponse } from '../../../../common/endpoint/types';
import { PreferenceFormattedDateFromPrimitive } from '../../../common/components/formatted_date';
import { useGetEndpointPolicyResponse } from '../../hooks/endpoint/use_get_endpoint_policy_response';
import { PolicyResponse } from './policy_response';
import { getFailedOrWarningActionCountFromPolicyResponse } from '../../pages/endpoint_hosts/store/utils';
+import { PolicyResponseActionItem } from './policy_response_action_item';
+import { PolicyResponseActionFormatter } from './policy_response_friendly_names';
export interface PolicyResponseWrapperProps {
endpointId: string;
@@ -23,6 +27,8 @@ export const PolicyResponseWrapper = memo(
({ endpointId, showRevisionMessage = true, onShowNeedsAttentionBadge }) => {
const { data, isLoading, isFetching, isError } = useGetEndpointPolicyResponse(endpointId);
+ const { docLinks } = useKibana().services;
+
const [policyResponseConfig, setPolicyResponseConfig] =
useState();
const [policyResponseActions, setPolicyResponseActions] =
@@ -58,6 +64,34 @@ export const PolicyResponseWrapper = memo(
}
}, [policyResponseAttentionCount, onShowNeedsAttentionBadge]);
+ const genericErrors = useMemo(() => {
+ if (!policyResponseConfig && !policyResponseActions) {
+ return [];
+ }
+
+ return policyResponseActions?.reduce(
+ (acc, currentAction) => {
+ const policyResponseActionFormatter = new PolicyResponseActionFormatter(
+ currentAction,
+ docLinks.links.securitySolution.policyResponseTroubleshooting[
+ currentAction.name as keyof DocLinksStart['links']['securitySolution']['policyResponseTroubleshooting']
+ ]
+ );
+
+ if (policyResponseActionFormatter.isGeneric() && policyResponseActionFormatter.hasError) {
+ acc.push(policyResponseActionFormatter);
+ }
+
+ return acc;
+ },
+ []
+ );
+ }, [
+ docLinks.links.securitySolution.policyResponseTroubleshooting,
+ policyResponseActions,
+ policyResponseConfig,
+ ]);
+
return (
<>
{showRevisionMessage && (
@@ -91,11 +125,20 @@ export const PolicyResponseWrapper = memo(
)}
{isLoading && }
{policyResponseConfig !== undefined && policyResponseActions !== undefined && (
-
+ <>
+
+
+ {genericErrors?.map((genericActionError) => (
+
+
+
+
+ ))}
+ >
)}
>
);
diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/actions/response_actions.test.ts b/x-pack/plugins/security_solution/server/endpoint/routes/actions/response_actions.test.ts
index cc04517d87bbd..3a91ee35269be 100644
--- a/x-pack/plugins/security_solution/server/endpoint/routes/actions/response_actions.test.ts
+++ b/x-pack/plugins/security_solution/server/endpoint/routes/actions/response_actions.test.ts
@@ -39,6 +39,7 @@ import {
metadataTransformPrefix,
ENDPOINT_ACTIONS_INDEX,
KILL_PROCESS_ROUTE,
+ SUSPEND_PROCESS_ROUTE,
} from '../../../../common/endpoint/constants';
import {
ActionDetails,
@@ -369,6 +370,17 @@ describe('Response actions', () => {
expect(actionDoc.data.command).toEqual('kill-process');
});
+ it('sends the suspend-process command payload from the suspend process route', async () => {
+ const ctx = await callRoute(SUSPEND_PROCESS_ROUTE, {
+ body: { endpoint_ids: ['XYZ'] },
+ });
+ const actionDoc: EndpointAction = (
+ ctx.core.elasticsearch.client.asInternalUser.index.mock
+ .calls[0][0] as estypes.IndexRequest
+ ).body!;
+ expect(actionDoc.data.command).toEqual('suspend-process');
+ });
+
describe('With endpoint data streams', () => {
it('handles unisolation', async () => {
const ctx = await callRoute(
@@ -454,6 +466,35 @@ describe('Response actions', () => {
expect(responseBody.action).toBeUndefined();
});
+ it('handles suspend-process', async () => {
+ const parameters = { entity_id: 1234 };
+ const ctx = await callRoute(
+ SUSPEND_PROCESS_ROUTE,
+ {
+ body: { endpoint_ids: ['XYZ'], parameters },
+ },
+ { endpointDsExists: true }
+ );
+ const indexDoc = ctx.core.elasticsearch.client.asInternalUser.index;
+ const actionDocs: [
+ { index: string; body?: LogsEndpointAction },
+ { index: string; body?: EndpointAction }
+ ] = [
+ indexDoc.mock.calls[0][0] as estypes.IndexRequest,
+ indexDoc.mock.calls[1][0] as estypes.IndexRequest