[Security Solution] Switches remaining rule types to use new Rule Preview API (#116374)

This commit is contained in:
Davis Plumlee 2021-12-07 18:26:47 -05:00 committed by GitHub
parent 894f89d8ce
commit 5d44d79c2b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
55 changed files with 614 additions and 2643 deletions

View file

@ -32,4 +32,3 @@ export const config: PluginConfigDescriptor = {
export type RuleRegistryPluginConfig = TypeOf<typeof config.schema>;
export const INDEX_PREFIX = '.alerts' as const;
export const INDEX_PREFIX_FOR_BACKING_INDICES = '.internal.alerts' as const;

View file

@ -29,6 +29,7 @@ export const createRuleDataClientMock = (
indexName,
kibanaVersion: '7.16.0',
isWriteEnabled: jest.fn(() => true),
indexNameWithNamespace: jest.fn((namespace: string) => indexName + namespace),
// @ts-ignore 4.3.5 upgrade
getReader: jest.fn((_options?: { namespace?: string }) => ({

View file

@ -54,6 +54,10 @@ export class RuleDataClient implements IRuleDataClient {
return this.options.indexInfo.kibanaVersion;
}
public indexNameWithNamespace(namespace: string): string {
return this.options.indexInfo.getPrimaryAlias(namespace);
}
private get writeEnabled(): boolean {
return this._isWriteEnabled;
}

View file

@ -14,6 +14,7 @@ import { TechnicalRuleDataFieldName } from '../../common/technical_rule_data_fie
export interface IRuleDataClient {
indexName: string;
indexNameWithNamespace(namespace: string): string;
kibanaVersion: string;
isWriteEnabled(): boolean;
getReader(options?: { namespace?: string }): IRuleDataReader;

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import { INDEX_PREFIX, INDEX_PREFIX_FOR_BACKING_INDICES } from '../config';
import { INDEX_PREFIX } from '../config';
import { IndexOptions } from './index_options';
import { joinWithDash } from './utils';
@ -23,16 +23,16 @@ interface ConstructorOptions {
export class IndexInfo {
constructor(options: ConstructorOptions) {
const { indexOptions, kibanaVersion } = options;
const { registrationContext, dataset } = indexOptions;
const { registrationContext, dataset, additionalPrefix } = indexOptions;
this.indexOptions = indexOptions;
this.kibanaVersion = kibanaVersion;
this.baseName = joinWithDash(INDEX_PREFIX, `${registrationContext}.${dataset}`);
this.basePattern = joinWithDash(this.baseName, '*');
this.baseNameForBackingIndices = joinWithDash(
INDEX_PREFIX_FOR_BACKING_INDICES,
this.baseName = joinWithDash(
`${additionalPrefix ?? ''}${INDEX_PREFIX}`,
`${registrationContext}.${dataset}`
);
this.basePattern = joinWithDash(this.baseName, '*');
this.baseNameForBackingIndices = `.internal${this.baseName}`;
}
/**

View file

@ -95,6 +95,17 @@ export interface IndexOptions {
* @example '.siem-signals', undefined
*/
secondaryAlias?: string;
/**
* Optional prefix name that will be prepended to indices in addition to
* primary dataset and context naming convention.
*
* Currently used only for creating a preview index for the purpose of
* previewing alerts from a rule. The documents are identical to alerts, but
* shouldn't exist on an alert index and shouldn't be queried together with
* real alerts in any way, because the rule that created them doesn't exist
*/
additionalPrefix?: string;
}
/**

View file

@ -39,7 +39,7 @@ export const DEFAULT_APP_TIME_RANGE = 'securitySolution:timeDefaults' as const;
export const DEFAULT_APP_REFRESH_INTERVAL = 'securitySolution:refreshIntervalDefaults' as const;
export const DEFAULT_ALERTS_INDEX = '.alerts-security.alerts' as const;
export const DEFAULT_SIGNALS_INDEX = '.siem-signals' as const;
export const DEFAULT_PREVIEW_INDEX = '.siem-preview-signals' as const;
export const DEFAULT_PREVIEW_INDEX = '.preview.alerts-security.alerts' as const;
export const DEFAULT_LISTS_INDEX = '.lists' as const;
export const DEFAULT_ITEMS_INDEX = '.items' as const;
// The DEFAULT_MAX_SIGNALS value exists also in `x-pack/plugins/cases/common/constants.ts`
@ -256,8 +256,6 @@ export const DETECTION_ENGINE_PREPACKAGED_RULES_STATUS_URL =
export const DETECTION_ENGINE_RULES_BULK_ACTION =
`${DETECTION_ENGINE_RULES_URL}/_bulk_action` as const;
export const DETECTION_ENGINE_RULES_PREVIEW = `${DETECTION_ENGINE_RULES_URL}/preview` as const;
export const DETECTION_ENGINE_RULES_PREVIEW_INDEX_URL =
`${DETECTION_ENGINE_RULES_PREVIEW}/index` as const;
/**
* Internal detection engine routes

View file

@ -5,11 +5,9 @@
* 2.0.
*/
import { useEffect } from 'react';
import { createPreviewIndex } from './api';
export const usePreviewIndex = () => {
useEffect(() => {
createPreviewIndex();
}, []);
};
export enum RULE_PREVIEW_INVOCATION_COUNT {
HOUR = 20,
DAY = 24,
WEEK = 168,
MONTH = 30,
}

View file

@ -370,6 +370,7 @@ export const previewRulesSchema = t.intersection([
createTypeSpecific,
t.type({ invocationCount: t.number }),
]);
export type PreviewRulesSchema = t.TypeOf<typeof previewRulesSchema>;
type UpdateSchema<T> = SharedUpdateSchema & T;
export type EqlUpdateSchema = UpdateSchema<t.TypeOf<typeof eqlCreateParams>>;

View file

@ -174,7 +174,7 @@ describe('Detection rules, threshold', () => {
cy.get(ALERT_GRID_CELL).contains(rule.name);
});
it('Preview results of keyword using "host.name"', () => {
it.skip('Preview results of keyword using "host.name"', () => {
rule.index = [...rule.index, '.siem-signals*'];
createCustomRuleActivated(getNewRule());
@ -188,7 +188,7 @@ describe('Detection rules, threshold', () => {
cy.get(PREVIEW_HEADER_SUBTITLE).should('have.text', '3 unique hits');
});
it('Preview results of "ip" using "source.ip"', () => {
it.skip('Preview results of "ip" using "source.ip"', () => {
const previewRule: ThresholdRule = {
...rule,
thresholdField: 'source.ip',

View file

@ -104,9 +104,9 @@ export const DEFINE_INDEX_INPUT =
export const EQL_TYPE = '[data-test-subj="eqlRuleType"]';
export const EQL_QUERY_INPUT = '[data-test-subj="eqlQueryBarTextInput"]';
export const PREVIEW_HISTOGRAM = '[data-test-subj="preview-histogram-panel"]';
export const EQL_QUERY_PREVIEW_HISTOGRAM = '[data-test-subj="queryPreviewEqlHistogram"]';
export const EQL_QUERY_INPUT = '[data-test-subj="eqlQueryBarTextInput"]';
export const EQL_QUERY_VALIDATION_SPINNER = '[data-test-subj="eql-validation-loading"]';
@ -170,7 +170,7 @@ export const RISK_OVERRIDE =
export const RULES_CREATION_FORM = '[data-test-subj="stepDefineRule"]';
export const RULES_CREATION_PREVIEW = '[data-test-subj="ruleCreationQueryPreview"]';
export const RULES_CREATION_PREVIEW = '[data-test-subj="rule-preview"]';
export const RULE_DESCRIPTION_INPUT =
'[data-test-subj="detectionEngineStepAboutRuleDescription"] [data-test-subj="input"]';

View file

@ -33,7 +33,6 @@ import {
DEFAULT_RISK_SCORE_INPUT,
DEFINE_CONTINUE_BUTTON,
EQL_QUERY_INPUT,
EQL_QUERY_PREVIEW_HISTOGRAM,
EQL_QUERY_VALIDATION_SPINNER,
EQL_TYPE,
FALSE_POSITIVES_INPUT,
@ -92,6 +91,7 @@ import {
EMAIL_CONNECTOR_USER_INPUT,
EMAIL_CONNECTOR_PASSWORD_INPUT,
EMAIL_CONNECTOR_SERVICE_SELECTOR,
PREVIEW_HISTOGRAM,
} from '../screens/create_new_rule';
import { TOAST_ERROR } from '../screens/shared';
import { SERVER_SIDE_EVENT_COUNT } from '../screens/timeline';
@ -324,12 +324,12 @@ export const fillDefineEqlRuleAndContinue = (rule: CustomRule) => {
.find(QUERY_PREVIEW_BUTTON)
.should('not.be.disabled')
.click({ force: true });
cy.get(EQL_QUERY_PREVIEW_HISTOGRAM)
cy.get(PREVIEW_HISTOGRAM)
.invoke('text')
.then((text) => {
if (text !== 'Hits') {
cy.get(RULES_CREATION_PREVIEW).find(QUERY_PREVIEW_BUTTON).click({ force: true });
cy.get(EQL_QUERY_PREVIEW_HISTOGRAM).should('contain.text', 'Hits');
cy.get(PREVIEW_HISTOGRAM).should('contain.text', 'Hits');
}
});
cy.get(TOAST_ERROR).should('not.exist');

View file

@ -15,7 +15,7 @@ import { MatrixHistogramType } from '../../../../common/search_strategy/security
import { UpdateDateRange } from '../charts/common';
import { GlobalTimeArgs } from '../../containers/use_global_time';
import { DocValueFields } from '../../../../common/search_strategy';
import { Threshold } from '../../../detections/components/rules/query_preview';
import { FieldValueThreshold } from '../../../detections/components/rules/threshold_input';
export type MatrixHistogramMappingTypes = Record<
string,
@ -77,7 +77,7 @@ export interface MatrixHistogramQueryProps {
stackByField: string;
startDate: string;
histogramType: MatrixHistogramType;
threshold?: Threshold;
threshold?: FieldValueThreshold;
skip?: boolean;
isPtrIncluded?: boolean;
includeMissingData?: boolean;

View file

@ -1,128 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { mount } from 'enzyme';
import * as i18n from '../rule_preview/translations';
import { useGlobalTime } from '../../../../common/containers/use_global_time';
import { TestProviders } from '../../../../common/mock';
import { PreviewCustomQueryHistogram } from './custom_histogram';
jest.mock('../../../../common/containers/use_global_time');
describe('PreviewCustomQueryHistogram', () => {
const mockSetQuery = jest.fn();
beforeEach(() => {
(useGlobalTime as jest.Mock).mockReturnValue({
from: '2020-07-07T08:20:18.966Z',
isInitializing: false,
to: '2020-07-08T08:20:18.966Z',
setQuery: mockSetQuery,
});
});
afterEach(() => {
jest.clearAllMocks();
});
test('it renders loader when isLoading is true', () => {
const wrapper = mount(
<TestProviders>
<PreviewCustomQueryHistogram
to="2020-07-08T08:20:18.966Z"
from="2020-07-07T08:20:18.966Z"
data={[]}
totalCount={0}
inspect={{ dsl: [], response: [] }}
refetch={jest.fn()}
isLoading
/>
</TestProviders>
);
expect(wrapper.find('[data-test-subj="queryPreviewLoading"]').exists()).toBeTruthy();
expect(
wrapper.find('[dataTestSubj="queryPreviewCustomHistogram"]').at(0).prop('subtitle')
).toEqual(i18n.QUERY_PREVIEW_SUBTITLE_LOADING);
});
test('it configures data and subtitle', () => {
const wrapper = mount(
<TestProviders>
<PreviewCustomQueryHistogram
to="2020-07-08T08:20:18.966Z"
from="2020-07-07T08:20:18.966Z"
data={[
{ x: 1602247050000, y: 2314, g: 'All others' },
{ x: 1602247162500, y: 3471, g: 'All others' },
{ x: 1602247275000, y: 3369, g: 'All others' },
]}
totalCount={9154}
inspect={{ dsl: [], response: [] }}
refetch={jest.fn()}
isLoading={false}
/>
</TestProviders>
);
expect(wrapper.find('[data-test-subj="queryPreviewLoading"]').exists()).toBeFalsy();
expect(
wrapper.find('[dataTestSubj="queryPreviewCustomHistogram"]').at(0).prop('subtitle')
).toEqual(i18n.QUERY_PREVIEW_TITLE(9154));
expect(wrapper.find('[dataTestSubj="queryPreviewCustomHistogram"]').at(0).props().data).toEqual(
[
{
key: 'hits',
value: [
{
g: 'All others',
x: 1602247050000,
y: 2314,
},
{
g: 'All others',
x: 1602247162500,
y: 3471,
},
{
g: 'All others',
x: 1602247275000,
y: 3369,
},
],
},
]
);
});
test('it invokes setQuery with id, inspect, isLoading and refetch', async () => {
const mockRefetch = jest.fn();
mount(
<TestProviders>
<PreviewCustomQueryHistogram
to="2020-07-08T08:20:18.966Z"
from="2020-07-07T08:20:18.966Z"
data={[]}
totalCount={0}
inspect={{ dsl: ['some dsl'], response: ['query response'] }}
refetch={mockRefetch}
isLoading={false}
/>
</TestProviders>
);
expect(mockSetQuery).toHaveBeenCalledWith({
id: 'queryPreviewCustomHistogramQuery',
inspect: { dsl: ['some dsl'], response: ['query response'] },
loading: false,
refetch: mockRefetch,
});
});
});

View file

@ -1,76 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useEffect, useMemo } from 'react';
import * as i18n from '../rule_preview/translations';
import { useGlobalTime } from '../../../../common/containers/use_global_time';
import { getHistogramConfig } from '../rule_preview/helpers';
import {
ChartSeriesConfigs,
ChartSeriesData,
ChartData,
} from '../../../../common/components/charts/common';
import { InspectResponse } from '../../../../../public/types';
import { inputsModel } from '../../../../common/store';
import { PreviewHistogram } from './histogram';
export const ID = 'queryPreviewCustomHistogramQuery';
interface PreviewCustomQueryHistogramProps {
to: string;
from: string;
isLoading: boolean;
data: ChartData[];
totalCount: number;
inspect: InspectResponse;
refetch: inputsModel.Refetch;
}
export const PreviewCustomQueryHistogram = ({
to,
from,
data,
totalCount,
inspect,
refetch,
isLoading,
}: PreviewCustomQueryHistogramProps) => {
const { setQuery, isInitializing } = useGlobalTime();
useEffect((): void => {
if (!isLoading && !isInitializing) {
setQuery({ id: ID, inspect, loading: isLoading, refetch });
}
}, [setQuery, inspect, isLoading, isInitializing, refetch]);
const barConfig = useMemo(
(): ChartSeriesConfigs => getHistogramConfig(to, from, true),
[from, to]
);
const subtitle = useMemo(
(): string =>
isLoading ? i18n.QUERY_PREVIEW_SUBTITLE_LOADING : i18n.QUERY_PREVIEW_TITLE(totalCount),
[isLoading, totalCount]
);
const chartData = useMemo((): ChartSeriesData[] => [{ key: 'hits', value: data }], [data]);
return (
<PreviewHistogram
id={ID}
data={chartData}
barConfig={barConfig}
title={i18n.QUERY_GRAPH_HITS_TITLE}
subtitle={subtitle}
disclaimer={i18n.QUERY_PREVIEW_DISCLAIMER}
isLoading={isLoading}
dataTestSubj="queryPreviewCustomHistogram"
/>
);
};

View file

@ -1,152 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { mount } from 'enzyme';
import * as i18n from '../rule_preview/translations';
import { useGlobalTime } from '../../../../common/containers/use_global_time';
import { TestProviders } from '../../../../common/mock';
import { PreviewEqlQueryHistogram } from './eql_histogram';
jest.mock('../../../../common/containers/use_global_time');
describe('PreviewEqlQueryHistogram', () => {
const mockSetQuery = jest.fn();
beforeEach(() => {
(useGlobalTime as jest.Mock).mockReturnValue({
from: '2020-07-07T08:20:18.966Z',
isInitializing: false,
to: '2020-07-08T08:20:18.966Z',
setQuery: mockSetQuery,
});
});
afterEach(() => {
jest.clearAllMocks();
});
test('it renders loader when isLoading is true', () => {
const wrapper = mount(
<TestProviders>
<PreviewEqlQueryHistogram
to="2020-07-08T08:20:18.966Z"
from="2020-07-07T08:20:18.966Z"
data={[]}
totalCount={0}
inspect={{ dsl: [], response: [] }}
refetch={jest.fn()}
isLoading
/>
</TestProviders>
);
expect(wrapper.find('[data-test-subj="queryPreviewLoading"]').exists()).toBeTruthy();
expect(
wrapper.find('[dataTestSubj="queryPreviewEqlHistogram"]').at(0).prop('subtitle')
).toEqual(i18n.QUERY_PREVIEW_SUBTITLE_LOADING);
});
test('it configures data and subtitle', () => {
const wrapper = mount(
<TestProviders>
<PreviewEqlQueryHistogram
to="2020-07-08T08:20:18.966Z"
from="2020-07-07T08:20:18.966Z"
data={[
{ x: 1602247050000, y: 2314, g: 'All others' },
{ x: 1602247162500, y: 3471, g: 'All others' },
{ x: 1602247275000, y: 3369, g: 'All others' },
]}
totalCount={9154}
inspect={{ dsl: [], response: [] }}
refetch={jest.fn()}
isLoading={false}
/>
</TestProviders>
);
expect(wrapper.find('[data-test-subj="queryPreviewLoading"]').exists()).toBeFalsy();
expect(
wrapper.find('[dataTestSubj="queryPreviewEqlHistogram"]').at(0).prop('subtitle')
).toEqual(i18n.QUERY_PREVIEW_TITLE(9154));
expect(wrapper.find('[dataTestSubj="queryPreviewEqlHistogram"]').at(0).props().data).toEqual([
{
key: 'hits',
value: [
{
g: 'All others',
x: 1602247050000,
y: 2314,
},
{
g: 'All others',
x: 1602247162500,
y: 3471,
},
{
g: 'All others',
x: 1602247275000,
y: 3369,
},
],
},
]);
});
test('it invokes setQuery with id, inspect, isLoading and refetch', async () => {
const mockRefetch = jest.fn();
mount(
<TestProviders>
<PreviewEqlQueryHistogram
to="2020-07-08T08:20:18.966Z"
from="2020-07-07T08:20:18.966Z"
data={[]}
totalCount={0}
inspect={{ dsl: ['some dsl'], response: ['query response'] }}
refetch={mockRefetch}
isLoading={false}
/>
</TestProviders>
);
expect(mockSetQuery).toHaveBeenCalledWith({
id: 'queryEqlPreviewHistogramQuery',
inspect: { dsl: ['some dsl'], response: ['query response'] },
loading: false,
refetch: mockRefetch,
});
});
test('it displays histogram', () => {
const wrapper = mount(
<TestProviders>
<PreviewEqlQueryHistogram
to="2020-07-08T08:20:18.966Z"
from="2020-07-07T08:20:18.966Z"
data={[
{ x: 1602247050000, y: 2314, g: 'All others' },
{ x: 1602247162500, y: 3471, g: 'All others' },
{ x: 1602247275000, y: 3369, g: 'All others' },
]}
totalCount={9154}
inspect={{ dsl: [], response: [] }}
refetch={jest.fn()}
isLoading={false}
/>
</TestProviders>
);
expect(wrapper.find('[data-test-subj="queryPreviewLoading"]').exists()).toBeFalsy();
expect(
wrapper.find('[data-test-subj="sharedPreviewQueryNoHistogramAvailable"]').exists()
).toBeFalsy();
expect(wrapper.find('[data-test-subj="sharedPreviewQueryHistogram"]').exists()).toBeTruthy();
});
});

View file

@ -1,73 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useEffect, useMemo } from 'react';
import * as i18n from '../rule_preview/translations';
import { getHistogramConfig } from '../rule_preview/helpers';
import {
ChartSeriesData,
ChartSeriesConfigs,
ChartData,
} from '../../../../common/components/charts/common';
import { InspectQuery } from '../../../../common/store/inputs/model';
import { useGlobalTime } from '../../../../common/containers/use_global_time';
import { inputsModel } from '../../../../common/store';
import { PreviewHistogram } from './histogram';
export const ID = 'queryEqlPreviewHistogramQuery';
interface PreviewEqlQueryHistogramProps {
to: string;
from: string;
totalCount: number;
isLoading: boolean;
data: ChartData[];
inspect: InspectQuery;
refetch: inputsModel.Refetch;
}
export const PreviewEqlQueryHistogram = ({
from,
to,
totalCount,
data,
inspect,
refetch,
isLoading,
}: PreviewEqlQueryHistogramProps) => {
const { setQuery, isInitializing } = useGlobalTime();
useEffect((): void => {
if (!isInitializing) {
setQuery({ id: ID, inspect, loading: false, refetch });
}
}, [setQuery, inspect, isInitializing, refetch]);
const barConfig = useMemo((): ChartSeriesConfigs => getHistogramConfig(to, from), [from, to]);
const subtitle = useMemo(
(): string =>
isLoading ? i18n.QUERY_PREVIEW_SUBTITLE_LOADING : i18n.QUERY_PREVIEW_TITLE(totalCount),
[isLoading, totalCount]
);
const chartData = useMemo((): ChartSeriesData[] => [{ key: 'hits', value: data }], [data]);
return (
<PreviewHistogram
id={ID}
data={chartData}
barConfig={barConfig}
title={i18n.QUERY_GRAPH_HITS_TITLE}
subtitle={subtitle}
disclaimer={i18n.QUERY_PREVIEW_DISCLAIMER_MAX_SIGNALS}
isLoading={isLoading}
dataTestSubj="queryPreviewEqlHistogram"
/>
);
};

View file

@ -1,74 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { mount } from 'enzyme';
import { TestProviders } from '../../../../common/mock';
import { PreviewHistogram } from './histogram';
import { getHistogramConfig } from '../rule_preview/helpers';
describe('PreviewHistogram', () => {
test('it renders loading icon if "isLoading" is true', () => {
const wrapper = mount(
<TestProviders>
<PreviewHistogram
id="previewHistogramId"
data={[]}
barConfig={{}}
title="Hits"
subtitle="500 hits"
disclaimer="be ware"
isLoading
/>
</TestProviders>
);
expect(wrapper.find('[data-test-subj="queryPreviewLoading"]').exists()).toBeTruthy();
expect(wrapper.find('[data-test-subj="sharedPreviewQueryHistogram"]').exists()).toBeFalsy();
});
test('it renders chart if "isLoading" is true', () => {
const wrapper = mount(
<TestProviders>
<PreviewHistogram
id="previewHistogramId"
data={[
{
key: 'hits',
value: [
{
g: 'All others',
x: 1602247050000,
y: 2314,
},
{
g: 'All others',
x: 1602247162500,
y: 3471,
},
{
g: 'All others',
x: 1602247275000,
y: 3369,
},
],
},
]}
barConfig={getHistogramConfig('2020-07-08T08:20:18.966Z', '2020-07-07T08:20:18.966Z')}
title="Hits"
subtitle="500 hits"
disclaimer="be ware"
isLoading={false}
/>
</TestProviders>
);
expect(wrapper.find('[data-test-subj="queryPreviewLoading"]').exists()).toBeFalsy();
expect(wrapper.find('[data-test-subj="sharedPreviewQueryHistogram"]').exists()).toBeTruthy();
});
});

View file

@ -1,75 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { EuiFlexGroup, EuiFlexItem, EuiText, EuiSpacer, EuiLoadingChart } from '@elastic/eui';
import styled from 'styled-components';
import { BarChart } from '../../../../common/components/charts/barchart';
import { Panel } from '../../../../common/components/panel';
import { HeaderSection } from '../../../../common/components/header_section';
import { ChartSeriesData, ChartSeriesConfigs } from '../../../../common/components/charts/common';
const LoadingChart = styled(EuiLoadingChart)`
display: block;
margin: 0 auto;
`;
interface PreviewHistogramProps {
id: string;
data: ChartSeriesData[];
dataTestSubj?: string;
barConfig: ChartSeriesConfigs;
title: string;
subtitle: string;
disclaimer: string;
isLoading: boolean;
}
export const PreviewHistogram = ({
id,
data,
dataTestSubj,
barConfig,
title,
subtitle,
disclaimer,
isLoading,
}: PreviewHistogramProps) => {
return (
<>
<Panel height={300} data-test-subj={dataTestSubj}>
<EuiFlexGroup gutterSize="none" direction="column">
<EuiFlexItem grow={1}>
<HeaderSection id={id} title={title} titleSize="xs" subtitle={subtitle} />
</EuiFlexItem>
<EuiFlexItem grow={1}>
{isLoading ? (
<LoadingChart size="l" data-test-subj="queryPreviewLoading" />
) : (
<BarChart
configs={barConfig}
barChart={data}
stackByField={undefined}
timelineId={undefined}
data-test-subj="sharedPreviewQueryHistogram"
/>
)}
</EuiFlexItem>
<EuiFlexItem grow={false}>
<>
<EuiSpacer />
<EuiText size="s" color="subdued">
<p>{disclaimer}</p>
</EuiText>
</>
</EuiFlexItem>
</EuiFlexGroup>
</Panel>
</>
);
};

View file

@ -1,502 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { of } from 'rxjs';
import { ThemeProvider } from 'styled-components';
import { mount } from 'enzyme';
import { TestProviders } from '../../../../common/mock';
import { useKibana } from '../../../../common/lib/kibana';
import { PreviewQuery } from './';
import { getMockEqlResponse } from '../../../../common/hooks/eql/eql_search_response.mock';
import { useMatrixHistogram } from '../../../../common/containers/matrix_histogram';
import { useEqlPreview } from '../../../../common/hooks/eql/';
import { getMockTheme } from '../../../../common/lib/kibana/kibana_react.mock';
import type { FilterMeta } from '@kbn/es-query';
const mockTheme = getMockTheme({
eui: {
euiSuperDatePickerWidth: '180px',
},
});
jest.mock('../../../../common/lib/kibana');
jest.mock('../../../../common/containers/matrix_histogram');
jest.mock('../../../../common/hooks/eql/');
describe('PreviewQuery', () => {
beforeEach(() => {
useKibana().services.notifications.toasts.addError = jest.fn();
useKibana().services.notifications.toasts.addWarning = jest.fn();
(useMatrixHistogram as jest.Mock).mockReturnValue([
false,
{
inspect: { dsl: [], response: [] },
totalCount: 1,
refetch: jest.fn(),
data: [],
buckets: [],
},
(useKibana().services.data.search.search as jest.Mock).mockReturnValue(
of(getMockEqlResponse())
),
]);
(useEqlPreview as jest.Mock).mockReturnValue([
false,
(useKibana().services.data.search.search as jest.Mock).mockReturnValue(
of(getMockEqlResponse())
),
{
inspect: { dsl: [], response: [] },
totalCount: 1,
refetch: jest.fn(),
data: [],
buckets: [],
},
]);
});
afterEach(() => {
jest.clearAllMocks();
});
test('it renders timeframe select and preview button on render', () => {
const wrapper = mount(
<ThemeProvider theme={mockTheme}>
<PreviewQuery
ruleType="query"
dataTestSubj="queryPreviewSelect"
idAria="queryPreview"
query={{ query: { query: 'host.name:*', language: 'kuery' }, filters: [] }}
index={['foo-*']}
threshold={undefined}
isDisabled={false}
/>
</ThemeProvider>
);
expect(wrapper.find('[data-test-subj="queryPreviewSelect"]').exists()).toBeTruthy();
expect(
wrapper.find('[data-test-subj="queryPreviewButton"] button').props().disabled
).toBeFalsy();
expect(wrapper.find('[data-test-subj="queryPreviewCustomHistogram"]').exists()).toBeFalsy();
expect(wrapper.find('[data-test-subj="thresholdQueryPreviewHistogram"]').exists()).toBeFalsy();
expect(wrapper.find('[data-test-subj="queryPreviewEqlHistogram"]').exists()).toBeFalsy();
});
test('it renders preview button disabled if "isDisabled" is true', () => {
const wrapper = mount(
<ThemeProvider theme={mockTheme}>
<PreviewQuery
ruleType="query"
dataTestSubj="queryPreviewSelect"
idAria="queryPreview"
query={{ query: { query: 'host.name:*', language: 'kuery' }, filters: [] }}
index={['foo-*']}
threshold={undefined}
isDisabled
/>
</ThemeProvider>
);
expect(
wrapper.find('[data-test-subj="queryPreviewButton"] button').props().disabled
).toBeTruthy();
});
test('it renders preview button disabled if "query" is undefined', () => {
const wrapper = mount(
<ThemeProvider theme={mockTheme}>
<PreviewQuery
ruleType="query"
dataTestSubj="queryPreviewSelect"
idAria="queryPreview"
query={undefined}
index={['foo-*']}
threshold={undefined}
isDisabled={false}
/>
</ThemeProvider>
);
expect(
wrapper.find('[data-test-subj="queryPreviewButton"] button').props().disabled
).toBeTruthy();
});
test('it renders preview button enabled if query exists', () => {
const wrapper = mount(
<ThemeProvider theme={mockTheme}>
<PreviewQuery
ruleType="query"
dataTestSubj="queryPreviewSelect"
idAria="queryPreview"
query={{ query: { query: 'host.name:"foo"', language: 'kql' }, filters: [] }}
index={['foo-*']}
threshold={undefined}
isDisabled={false}
/>
</ThemeProvider>
);
expect(
wrapper.find('[data-test-subj="queryPreviewButton"] button').props().disabled
).toBeFalsy();
});
test('it renders preview button enabled if no query exists but filters do exist', () => {
const wrapper = mount(
<ThemeProvider theme={mockTheme}>
<PreviewQuery
ruleType="query"
dataTestSubj="queryPreviewSelect"
idAria="queryPreview"
query={{
query: { query: '', language: 'kuery' },
filters: [{ meta: {} as FilterMeta, query: {} }],
}}
index={['foo-*']}
threshold={undefined}
isDisabled={false}
/>
</ThemeProvider>
);
expect(
wrapper.find('[data-test-subj="queryPreviewButton"] button').props().disabled
).toBeFalsy();
});
test('it renders query histogram when rule type is query and preview button clicked', () => {
const wrapper = mount(
<TestProviders>
<PreviewQuery
ruleType="query"
dataTestSubj="queryPreviewSelect"
idAria="queryPreview"
query={{ query: { query: 'host.name:*', language: 'kuery' }, filters: [] }}
index={['foo-*']}
threshold={undefined}
isDisabled={false}
/>
</TestProviders>
);
wrapper.find('[data-test-subj="queryPreviewButton"] button').at(0).simulate('click');
const mockCalls = (useKibana().services.data.search.search as jest.Mock).mock.calls;
expect(mockCalls.length).toEqual(1);
expect(wrapper.find('[data-test-subj="queryPreviewCustomHistogram"]').exists()).toBeTruthy();
expect(wrapper.find('[data-test-subj="thresholdQueryPreviewHistogram"]').exists()).toBeFalsy();
expect(wrapper.find('[data-test-subj="queryPreviewEqlHistogram"]').exists()).toBeFalsy();
});
test('it renders noise warning when rule type is query, timeframe is last hour and hit average is greater than 1/hour', async () => {
const wrapper = mount(
<TestProviders>
<PreviewQuery
ruleType="query"
dataTestSubj="queryPreviewSelect"
idAria="queryPreview"
query={{ query: { query: 'host.name:*', language: 'kuery' }, filters: [] }}
index={['foo-*']}
threshold={undefined}
isDisabled={false}
/>
</TestProviders>
);
(useMatrixHistogram as jest.Mock).mockReturnValue([
false,
{
inspect: { dsl: [], response: [] },
totalCount: 2,
refetch: jest.fn(),
data: [],
buckets: [],
},
(useKibana().services.data.search.search as jest.Mock).mockReturnValue(
of(getMockEqlResponse())
),
]);
wrapper.find('[data-test-subj="queryPreviewButton"] button').at(0).simulate('click');
expect(wrapper.find('[data-test-subj="previewQueryWarning"]').exists()).toBeTruthy();
});
test('it renders query histogram when rule type is saved_query and preview button clicked', () => {
const wrapper = mount(
<TestProviders>
<PreviewQuery
ruleType="saved_query"
dataTestSubj="queryPreviewSelect"
idAria="queryPreview"
query={{ query: { query: 'host.name:*', language: 'kuery' }, filters: [] }}
index={['foo-*']}
threshold={undefined}
isDisabled={false}
/>
</TestProviders>
);
wrapper.find('[data-test-subj="queryPreviewButton"] button').at(0).simulate('click');
const mockCalls = (useKibana().services.data.search.search as jest.Mock).mock.calls;
expect(mockCalls.length).toEqual(1);
expect(wrapper.find('[data-test-subj="queryPreviewCustomHistogram"]').exists()).toBeTruthy();
expect(wrapper.find('[data-test-subj="thresholdQueryPreviewHistogram"]').exists()).toBeFalsy();
expect(wrapper.find('[data-test-subj="queryPreviewEqlHistogram"]').exists()).toBeFalsy();
});
test('it renders eql histogram when preview button clicked and rule type is eql', () => {
const wrapper = mount(
<TestProviders>
<PreviewQuery
ruleType="eql"
dataTestSubj="queryPreviewSelect"
idAria="queryPreview"
query={{ query: { query: 'file where true', language: 'kuery' }, filters: [] }}
index={['foo-*']}
threshold={undefined}
isDisabled={false}
/>
</TestProviders>
);
wrapper.find('[data-test-subj="queryPreviewButton"] button').at(0).simulate('click');
const mockCalls = (useKibana().services.data.search.search as jest.Mock).mock.calls;
expect(mockCalls.length).toEqual(1);
expect(wrapper.find('[data-test-subj="queryPreviewCustomHistogram"]').exists()).toBeFalsy();
expect(wrapper.find('[data-test-subj="thresholdQueryPreviewHistogram"]').exists()).toBeFalsy();
expect(wrapper.find('[data-test-subj="queryPreviewEqlHistogram"]').exists()).toBeTruthy();
});
test('it renders noise warning when rule type is eql, timeframe is last hour and hit average is greater than 1/hour', async () => {
const wrapper = mount(
<TestProviders>
<PreviewQuery
ruleType="eql"
dataTestSubj="queryPreviewSelect"
idAria="queryPreview"
query={{ query: { query: 'file where true', language: 'kuery' }, filters: [] }}
index={['foo-*']}
threshold={undefined}
isDisabled={false}
/>
</TestProviders>
);
(useEqlPreview as jest.Mock).mockReturnValue([
false,
(useKibana().services.data.search.search as jest.Mock).mockReturnValue(
of(getMockEqlResponse())
),
{
inspect: { dsl: [], response: [] },
totalCount: 2,
refetch: jest.fn(),
data: [],
buckets: [],
},
]);
wrapper.find('[data-test-subj="queryPreviewButton"] button').at(0).simulate('click');
expect(wrapper.find('[data-test-subj="previewQueryWarning"]').exists()).toBeTruthy();
});
test('it renders threshold histogram when preview button clicked, rule type is threshold, and threshold field is defined', () => {
const wrapper = mount(
<TestProviders>
<PreviewQuery
ruleType="threshold"
dataTestSubj="queryPreviewSelect"
idAria="queryPreview"
query={{ query: { query: 'file where true', language: 'kuery' }, filters: [] }}
index={['foo-*']}
threshold={{
field: ['agent.hostname'],
value: '200',
cardinality: {
field: ['user.name'],
value: '2',
},
}}
isDisabled={false}
/>
</TestProviders>
);
(useMatrixHistogram as jest.Mock).mockReturnValue([
false,
{
inspect: { dsl: [], response: [] },
totalCount: 500,
refetch: jest.fn(),
data: [],
buckets: [{ key: 'siem-kibana', doc_count: 500 }],
},
(useKibana().services.data.search.search as jest.Mock).mockReturnValue(
of(getMockEqlResponse())
),
]);
wrapper.find('[data-test-subj="queryPreviewButton"] button').at(0).simulate('click');
const mockCalls = (useKibana().services.data.search.search as jest.Mock).mock.calls;
expect(mockCalls.length).toEqual(1);
expect(wrapper.find('[data-test-subj="previewQueryWarning"]').exists()).toBeFalsy();
expect(wrapper.find('[data-test-subj="queryPreviewCustomHistogram"]').exists()).toBeFalsy();
expect(wrapper.find('[data-test-subj="thresholdQueryPreviewHistogram"]').exists()).toBeTruthy();
expect(wrapper.find('[data-test-subj="queryPreviewEqlHistogram"]').exists()).toBeFalsy();
});
test('it renders noise warning when rule type is threshold, and threshold field is defined, timeframe is last hour and hit average is greater than 1/hour', async () => {
const wrapper = mount(
<TestProviders>
<PreviewQuery
ruleType="query"
dataTestSubj="queryPreviewSelect"
idAria="queryPreview"
query={{ query: { query: 'file where true', language: 'kuery' }, filters: [] }}
index={['foo-*']}
threshold={{
field: ['agent.hostname'],
value: '200',
cardinality: {
field: ['user.name'],
value: '2',
},
}}
isDisabled={false}
/>
</TestProviders>
);
(useMatrixHistogram as jest.Mock).mockReturnValue([
false,
{
inspect: { dsl: [], response: [] },
totalCount: 500,
refetch: jest.fn(),
data: [],
buckets: [
{ key: 'siem-kibana', doc_count: 200 },
{ key: 'siem-windows', doc_count: 300 },
],
},
(useKibana().services.data.search.search as jest.Mock).mockReturnValue(
of(getMockEqlResponse())
),
]);
wrapper.find('[data-test-subj="queryPreviewButton"] button').at(0).simulate('click');
expect(wrapper.find('[data-test-subj="previewQueryWarning"]').exists()).toBeTruthy();
});
test('it renders query histogram when preview button clicked, rule type is threshold, and threshold field is empty array', () => {
const wrapper = mount(
<TestProviders>
<PreviewQuery
ruleType="threshold"
dataTestSubj="queryPreviewSelect"
idAria="queryPreview"
query={{ query: { query: 'file where true', language: 'kuery' }, filters: [] }}
index={['foo-*']}
threshold={{
field: [],
value: '200',
cardinality: {
field: ['user.name'],
value: '2',
},
}}
isDisabled={false}
/>
</TestProviders>
);
wrapper.find('[data-test-subj="queryPreviewButton"] button').at(0).simulate('click');
const mockCalls = (useKibana().services.data.search.search as jest.Mock).mock.calls;
expect(mockCalls.length).toEqual(1);
expect(wrapper.find('[data-test-subj="queryPreviewCustomHistogram"]').exists()).toBeTruthy();
expect(wrapper.find('[data-test-subj="thresholdQueryPreviewHistogram"]').exists()).toBeFalsy();
expect(wrapper.find('[data-test-subj="queryPreviewEqlHistogram"]').exists()).toBeFalsy();
});
test('it renders query histogram when preview button clicked, rule type is threshold, and threshold field is empty string', () => {
const wrapper = mount(
<TestProviders>
<PreviewQuery
ruleType="threshold"
dataTestSubj="queryPreviewSelect"
idAria="queryPreview"
query={{ query: { query: 'file where true', language: 'kuery' }, filters: [] }}
index={['foo-*']}
threshold={{
field: [' '],
value: '200',
cardinality: {
field: ['user.name'],
value: '2',
},
}}
isDisabled={false}
/>
</TestProviders>
);
wrapper.find('[data-test-subj="queryPreviewButton"] button').at(0).simulate('click');
const mockCalls = (useKibana().services.data.search.search as jest.Mock).mock.calls;
expect(mockCalls.length).toEqual(1);
expect(wrapper.find('[data-test-subj="queryPreviewCustomHistogram"]').exists()).toBeTruthy();
expect(wrapper.find('[data-test-subj="thresholdQueryPreviewHistogram"]').exists()).toBeFalsy();
expect(wrapper.find('[data-test-subj="queryPreviewEqlHistogram"]').exists()).toBeFalsy();
});
test('it hides histogram when timeframe changes', () => {
const wrapper = mount(
<TestProviders>
<PreviewQuery
ruleType="threshold"
dataTestSubj="queryPreviewSelect"
idAria="queryPreview"
query={{ query: { query: 'file where true', language: 'kuery' }, filters: [] }}
index={['foo-*']}
threshold={undefined}
isDisabled={false}
/>
</TestProviders>
);
wrapper.find('[data-test-subj="queryPreviewButton"] button').at(0).simulate('click');
expect(wrapper.find('[data-test-subj="queryPreviewCustomHistogram"]').exists()).toBeTruthy();
wrapper
.find('[data-test-subj="queryPreviewTimeframeSelect"] select')
.at(0)
.simulate('change', { target: { value: 'd' } });
expect(wrapper.find('[data-test-subj="queryPreviewCustomHistogram"]').exists()).toBeFalsy();
});
});

View file

@ -1,362 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { Fragment, useCallback, useEffect, useMemo, useReducer, useRef } from 'react';
import { Unit } from '@elastic/datemath';
import styled from 'styled-components';
import {
EuiFlexGroup,
EuiFlexItem,
EuiSelect,
EuiFormRow,
EuiButton,
EuiCallOut,
EuiText,
EuiSpacer,
} from '@elastic/eui';
import { debounce } from 'lodash/fp';
import { Type } from '@kbn/securitysolution-io-ts-alerting-types';
import * as i18n from '../rule_preview/translations';
import { useMatrixHistogram } from '../../../../common/containers/matrix_histogram';
import { MatrixHistogramType } from '../../../../../common/search_strategy';
import { FieldValueQueryBar } from '../query_bar';
import { PreviewEqlQueryHistogram } from './eql_histogram';
import { useEqlPreview } from '../../../../common/hooks/eql/';
import { PreviewThresholdQueryHistogram } from './threshold_histogram';
import { formatDate } from '../../../../common/components/super_date_picker';
import { State, queryPreviewReducer } from './reducer';
import { isNoisy } from '../rule_preview/helpers';
import { PreviewCustomQueryHistogram } from './custom_histogram';
import { FieldValueThreshold } from '../threshold_input';
const Select = styled(EuiSelect)`
width: ${({ theme }) => theme.eui.euiSuperDatePickerWidth};
`;
const PreviewButton = styled(EuiButton)`
margin-left: 0;
`;
export const initialState: State = {
timeframeOptions: [],
showHistogram: false,
timeframe: 'h',
warnings: [],
queryFilter: undefined,
toTime: '',
fromTime: '',
queryString: '',
language: 'kuery',
filters: [],
thresholdFieldExists: false,
showNonEqlHistogram: false,
};
export type Threshold = FieldValueThreshold | undefined;
interface PreviewQueryProps {
dataTestSubj: string;
idAria: string;
query: FieldValueQueryBar | undefined;
index: string[];
ruleType: Type;
threshold: Threshold;
isDisabled: boolean;
}
export const PreviewQuery = ({
ruleType,
dataTestSubj,
idAria,
query,
index,
threshold,
isDisabled,
}: PreviewQueryProps) => {
const [
eqlQueryLoading,
startEql,
{
totalCount: eqlQueryTotal,
data: eqlQueryData,
refetch: eqlQueryRefetch,
inspect: eqlQueryInspect,
},
] = useEqlPreview();
const [
{
thresholdFieldExists,
showNonEqlHistogram,
timeframeOptions,
showHistogram,
timeframe,
warnings,
queryFilter,
toTime,
fromTime,
queryString,
},
dispatch,
] = useReducer(queryPreviewReducer(), {
...initialState,
toTime: formatDate('now-1h'),
fromTime: formatDate('now'),
});
const [
isMatrixHistogramLoading,
{ inspect, totalCount: matrixHistTotal, refetch, data: matrixHistoData, buckets },
startNonEql,
] = useMatrixHistogram({
errorMessage: i18n.QUERY_PREVIEW_ERROR,
endDate: fromTime,
startDate: toTime,
filterQuery: queryFilter,
indexNames: index,
includeMissingData: false,
histogramType: MatrixHistogramType.events,
stackByField: 'event.category',
threshold: ruleType === 'threshold' ? threshold : undefined,
skip: true,
});
const setQueryInfo = useCallback(
(queryBar: FieldValueQueryBar | undefined, indices: string[], type: Type): void => {
dispatch({
type: 'setQueryInfo',
queryBar,
index: indices,
ruleType: type,
});
},
[dispatch]
);
const debouncedSetQueryInfo = useRef(debounce(500, setQueryInfo));
const setTimeframeSelect = useCallback(
(selection: Unit): void => {
dispatch({
type: 'setTimeframeSelect',
timeframe: selection,
});
},
[dispatch]
);
const setRuleTypeChange = useCallback(
(type: Type): void => {
dispatch({
type: 'setResetRuleTypeChange',
ruleType: type,
});
},
[dispatch]
);
const setWarnings = useCallback(
(yikes: string[]): void => {
dispatch({
type: 'setWarnings',
warnings: yikes,
});
},
[dispatch]
);
const setNoiseWarning = useCallback((): void => {
dispatch({
type: 'setNoiseWarning',
});
}, [dispatch]);
const setShowHistogram = useCallback(
(show: boolean): void => {
dispatch({
type: 'setShowHistogram',
show,
});
},
[dispatch]
);
const setThresholdValues = useCallback(
(thresh: Threshold, type: Type): void => {
dispatch({
type: 'setThresholdQueryVals',
threshold: thresh,
ruleType: type,
});
},
[dispatch]
);
useEffect(() => {
debouncedSetQueryInfo.current(query, index, ruleType);
}, [index, query, ruleType]);
useEffect((): void => {
setThresholdValues(threshold, ruleType);
}, [setThresholdValues, threshold, ruleType]);
useEffect((): void => {
setRuleTypeChange(ruleType);
}, [ruleType, setRuleTypeChange]);
useEffect((): void => {
switch (ruleType) {
case 'eql':
if (isNoisy(eqlQueryTotal, timeframe)) {
setNoiseWarning();
}
break;
case 'threshold':
const totalHits = thresholdFieldExists ? buckets.length : matrixHistTotal;
if (isNoisy(totalHits, timeframe)) {
setNoiseWarning();
}
break;
default:
if (isNoisy(matrixHistTotal, timeframe)) {
setNoiseWarning();
}
}
}, [
timeframe,
matrixHistTotal,
eqlQueryTotal,
ruleType,
setNoiseWarning,
thresholdFieldExists,
buckets.length,
]);
const handlePreviewEqlQuery = useCallback(
(to: string, from: string): void => {
startEql({
index,
query: queryString,
from,
to,
interval: timeframe,
});
},
[startEql, index, queryString, timeframe]
);
const handleSelectPreviewTimeframe = useCallback(
({ target: { value } }: React.ChangeEvent<HTMLSelectElement>): void => {
setTimeframeSelect(value as Unit);
},
[setTimeframeSelect]
);
const handlePreviewClicked = useCallback((): void => {
const to = formatDate('now');
const from = formatDate(`now-1${timeframe}`);
setWarnings([]);
setShowHistogram(true);
if (ruleType === 'eql') {
handlePreviewEqlQuery(to, from);
} else {
startNonEql(to, from);
}
}, [setWarnings, setShowHistogram, ruleType, handlePreviewEqlQuery, startNonEql, timeframe]);
const previewButtonDisabled = useMemo(() => {
return (
isMatrixHistogramLoading ||
eqlQueryLoading ||
isDisabled ||
query == null ||
(query != null && query.query.query === '' && query.filters.length === 0)
);
}, [eqlQueryLoading, isDisabled, isMatrixHistogramLoading, query]);
return (
<>
<EuiFormRow
label={i18n.QUERY_PREVIEW_LABEL}
helpText={i18n.QUERY_PREVIEW_HELP_TEXT}
error={undefined}
isInvalid={false}
data-test-subj={dataTestSubj}
describedByIds={idAria ? [idAria] : undefined}
>
<EuiFlexGroup>
<EuiFlexItem grow={1}>
<Select
id="queryPreviewSelect"
options={timeframeOptions}
value={timeframe}
onChange={handleSelectPreviewTimeframe}
aria-label={i18n.QUERY_PREVIEW_SELECT_ARIA}
disabled={isDisabled}
data-test-subj="queryPreviewTimeframeSelect"
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<PreviewButton
fill
isDisabled={previewButtonDisabled}
onClick={handlePreviewClicked}
data-test-subj="queryPreviewButton"
>
{i18n.QUERY_PREVIEW_BUTTON}
</PreviewButton>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFormRow>
<EuiSpacer size="s" />
{showNonEqlHistogram && showHistogram && (
<PreviewCustomQueryHistogram
to={toTime}
from={fromTime}
data={matrixHistoData}
totalCount={matrixHistTotal}
inspect={inspect}
refetch={refetch}
isLoading={isMatrixHistogramLoading}
/>
)}
{ruleType === 'threshold' && thresholdFieldExists && showHistogram && (
<PreviewThresholdQueryHistogram
isLoading={isMatrixHistogramLoading}
buckets={buckets}
inspect={inspect}
refetch={refetch}
/>
)}
{ruleType === 'eql' && showHistogram && (
<PreviewEqlQueryHistogram
to={toTime}
from={fromTime}
totalCount={eqlQueryTotal}
data={eqlQueryData}
inspect={eqlQueryInspect}
refetch={eqlQueryRefetch}
isLoading={eqlQueryLoading}
/>
)}
{showHistogram &&
warnings.length > 0 &&
warnings.map((warning, i) => (
<Fragment key={`${warning}-${i}`}>
<EuiSpacer size="s" />
<EuiCallOut color="warning" iconType="help" data-test-subj="previewQueryWarning">
<EuiText>
<p>{warning}</p>
</EuiText>
</EuiCallOut>
</Fragment>
))}
</>
);
};

View file

@ -1,502 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import moment from 'moment';
import * as i18n from '../rule_preview/translations';
import { Action, State, queryPreviewReducer } from './reducer';
import { initialState } from './';
describe('queryPreviewReducer', () => {
let reducer: (state: State, action: Action) => State;
beforeEach(() => {
moment.tz.setDefault('UTC');
reducer = queryPreviewReducer();
});
afterEach(() => {
moment.tz.setDefault('Browser');
});
describe('#setQueryInfo', () => {
test('should not update state if queryBar undefined', () => {
const update = reducer(initialState, {
type: 'setQueryInfo',
queryBar: undefined,
index: ['foo-*'],
ruleType: 'query',
});
expect(update).toEqual(initialState);
});
test('should reset showHistogram if queryBar undefined', () => {
const update = reducer(
{ ...initialState, showHistogram: true },
{
type: 'setQueryInfo',
queryBar: undefined,
index: ['foo-*'],
ruleType: 'query',
}
);
expect(update.showHistogram).toBeFalsy();
});
test('should reset showHistogram if queryBar defined', () => {
const update = reducer(
{ ...initialState, showHistogram: true, warnings: ['uh oh'] },
{
type: 'setQueryInfo',
queryBar: {
query: { query: 'host.name:*', language: 'kuery' },
filters: [{ meta: { alias: '', disabled: false, negate: false } }],
},
index: ['foo-*'],
ruleType: 'query',
}
);
expect(update.showHistogram).toBeFalsy();
});
test('should pull the query, language, and filters from the action', () => {
const update = reducer(initialState, {
type: 'setQueryInfo',
queryBar: {
query: { query: 'host.name:*', language: 'kuery' },
filters: [{ meta: { alias: '', disabled: false, negate: false } }],
},
index: ['foo-*'],
ruleType: 'query',
});
expect(update.language).toEqual('kuery');
expect(update.queryString).toEqual('host.name:*');
expect(update.filters).toEqual([
{ meta: { alias: '', disabled: false, negate: false }, query: {} },
]);
});
test('should create the queryFilter if query type is not eql', () => {
const update = reducer(initialState, {
type: 'setQueryInfo',
queryBar: {
query: { query: 'host.name:*', language: 'kuery' },
filters: [{ meta: { alias: '', disabled: false, negate: false } }],
},
index: ['foo-*'],
ruleType: 'query',
});
expect(update.queryFilter).toEqual({
bool: {
filter: [
{ bool: { minimum_should_match: 1, should: [{ exists: { field: 'host.name' } }] } },
{},
],
must: [],
must_not: [],
should: [],
},
});
});
test('should set query to empty string if it is not of type string', () => {
const update = reducer(initialState, {
type: 'setQueryInfo',
queryBar: {
query: { query: { not: 'a string' }, language: 'kuery' },
filters: [{ meta: { alias: '', disabled: false, negate: false } }],
},
index: ['foo-*'],
ruleType: 'query',
});
expect(update.queryString).toEqual('');
});
});
describe('#setTimeframeSelect', () => {
test('should update timeframe with that specified in action" ', () => {
const update = reducer(initialState, {
type: 'setTimeframeSelect',
timeframe: 'd',
});
expect(update.timeframe).toEqual('d');
});
test('should reset warnings and showHistogram to false" ', () => {
const update = reducer(
{ ...initialState, showHistogram: true, warnings: ['blah'] },
{
type: 'setTimeframeSelect',
timeframe: 'd',
}
);
expect(update.showHistogram).toBeFalsy();
expect(update.warnings).toEqual([]);
});
});
describe('#setResetRuleTypeChange', () => {
test('should reset timeframe, warnings, and hide histogram on rule type change" ', () => {
const update = reducer(
{ ...initialState, timeframe: 'd', showHistogram: true, warnings: ['blah'] },
{
type: 'setResetRuleTypeChange',
ruleType: 'eql',
}
);
expect(update.showHistogram).toBeFalsy();
expect(update.timeframe).toEqual('h');
expect(update.warnings).toEqual([]);
expect(update.showNonEqlHistogram).toBeFalsy();
});
test('should set timeframe options to hour and day if rule type is eql" ', () => {
const update = reducer(
{ ...initialState, timeframe: 'd', showHistogram: true, warnings: ['blah'] },
{
type: 'setResetRuleTypeChange',
ruleType: 'eql',
}
);
expect(update.timeframeOptions).toEqual([
{
text: 'Last hour',
value: 'h',
},
{
text: 'Last day',
value: 'd',
},
]);
});
test('should set "showNonEqlHist" to true and timeframe options to hour, day, and month if rule type is query" ', () => {
const update = reducer(
{ ...initialState, timeframe: 'd', showHistogram: true, warnings: ['blah'] },
{
type: 'setResetRuleTypeChange',
ruleType: 'query',
}
);
expect(update.showNonEqlHistogram).toBeTruthy();
expect(update.timeframeOptions).toEqual([
{
text: 'Last hour',
value: 'h',
},
{
text: 'Last day',
value: 'd',
},
{
text: 'Last month',
value: 'M',
},
]);
});
test('should set "showNonEqlHist" to true and timeframe options to hour, day, and month if rule type is saved_query" ', () => {
const update = reducer(
{ ...initialState, timeframe: 'd', showHistogram: true, warnings: ['blah'] },
{
type: 'setResetRuleTypeChange',
ruleType: 'saved_query',
}
);
expect(update.showNonEqlHistogram).toBeTruthy();
expect(update.timeframeOptions).toEqual([
{
text: 'Last hour',
value: 'h',
},
{
text: 'Last day',
value: 'd',
},
{
text: 'Last month',
value: 'M',
},
]);
});
test('should set "showNonEqlHist" to true and timeframe options to hour, day, and month if rule type is threshold and no threshold field is specified" ', () => {
const update = reducer(
{
...initialState,
timeframe: 'd',
showHistogram: true,
warnings: ['blah'],
thresholdFieldExists: false,
},
{
type: 'setResetRuleTypeChange',
ruleType: 'threshold',
}
);
expect(update.showNonEqlHistogram).toBeTruthy();
expect(update.timeframeOptions).toEqual([
{
text: 'Last hour',
value: 'h',
},
{
text: 'Last day',
value: 'd',
},
{
text: 'Last month',
value: 'M',
},
]);
});
test('should set "showNonEqlHist" to false and timeframe options to hour, day, and month if rule type is threshold and threshold field is specified" ', () => {
const update = reducer(
{
...initialState,
timeframe: 'd',
showHistogram: true,
warnings: ['blah'],
thresholdFieldExists: true,
},
{
type: 'setResetRuleTypeChange',
ruleType: 'threshold',
}
);
expect(update.showNonEqlHistogram).toBeFalsy();
expect(update.timeframeOptions).toEqual([
{
text: 'Last hour',
value: 'h',
},
{
text: 'Last day',
value: 'd',
},
{
text: 'Last month',
value: 'M',
},
]);
});
});
describe('#setWarnings', () => {
test('should set warnings to that passed in action" ', () => {
const update = reducer(initialState, {
type: 'setWarnings',
warnings: ['bad'],
});
expect(update.warnings).toEqual(['bad']);
});
});
describe('#setShowHistogram', () => {
test('should set "setShowHistogram" to false if "action.show" is false', () => {
const update = reducer(initialState, {
type: 'setShowHistogram',
show: false,
});
expect(update.showHistogram).toBeFalsy();
});
test('should set "disableOr" to true if "action.show" is true', () => {
const update = reducer(initialState, {
type: 'setShowHistogram',
show: true,
});
expect(update.showHistogram).toBeTruthy();
});
});
describe('#setThresholdQueryVals', () => {
test('should set thresholdFieldExists to true if threshold field is defined and not empty string', () => {
const update = reducer(initialState, {
type: 'setThresholdQueryVals',
threshold: {
field: ['agent.hostname'],
value: '200',
cardinality: {
field: ['user.name'],
value: '2',
},
},
ruleType: 'threshold',
});
expect(update.thresholdFieldExists).toBeTruthy();
expect(update.showNonEqlHistogram).toBeFalsy();
expect(update.showHistogram).toBeFalsy();
expect(update.warnings).toEqual([]);
});
test('should set thresholdFieldExists to false if threshold field is empty array', () => {
const update = reducer(initialState, {
type: 'setThresholdQueryVals',
threshold: {
field: [],
value: '200',
cardinality: {
field: ['user.name'],
value: '2',
},
},
ruleType: 'threshold',
});
expect(update.thresholdFieldExists).toBeFalsy();
expect(update.showNonEqlHistogram).toBeTruthy();
expect(update.showHistogram).toBeFalsy();
expect(update.warnings).toEqual([]);
});
test('should set thresholdFieldExists to false if threshold field is empty string', () => {
const update = reducer(initialState, {
type: 'setThresholdQueryVals',
threshold: {
field: [' '],
value: '200',
cardinality: {
field: ['user.name'],
value: '2',
},
},
ruleType: 'threshold',
});
expect(update.thresholdFieldExists).toBeFalsy();
expect(update.showNonEqlHistogram).toBeTruthy();
expect(update.showHistogram).toBeFalsy();
expect(update.warnings).toEqual([]);
});
test('should set showNonEqlHistogram to false if ruleType is eql', () => {
const update = reducer(initialState, {
type: 'setThresholdQueryVals',
threshold: {
field: ['agent.hostname'],
value: '200',
cardinality: {
field: ['user.name'],
value: '2',
},
},
ruleType: 'eql',
});
expect(update.showNonEqlHistogram).toBeFalsy();
expect(update.showHistogram).toBeFalsy();
expect(update.warnings).toEqual([]);
});
test('should set showNonEqlHistogram to true if ruleType is query', () => {
const update = reducer(initialState, {
type: 'setThresholdQueryVals',
threshold: {
field: ['agent.hostname'],
value: '200',
cardinality: {
field: ['user.name'],
value: '2',
},
},
ruleType: 'query',
});
expect(update.showNonEqlHistogram).toBeTruthy();
expect(update.showHistogram).toBeFalsy();
expect(update.warnings).toEqual([]);
});
test('should set showNonEqlHistogram to true if ruleType is saved_query', () => {
const update = reducer(initialState, {
type: 'setThresholdQueryVals',
threshold: {
field: ['agent.hostname'],
value: '200',
cardinality: {
field: ['user.name'],
value: '2',
},
},
ruleType: 'saved_query',
});
expect(update.showNonEqlHistogram).toBeTruthy();
expect(update.showHistogram).toBeFalsy();
expect(update.warnings).toEqual([]);
});
});
describe('#setToFrom', () => {
test('should update to and from times to be an hour apart if timeframe is "h"', () => {
const update = reducer(
{ ...initialState, timeframe: 'h' },
{
type: 'setToFrom',
}
);
const dateFrom = moment(update.fromTime);
const dateTo = moment(update.toTime);
const diff = dateFrom.diff(dateTo);
// 3600000ms = 60 minutes
// Sometimes test returns 3599999
expect(Math.ceil(diff / 100000) * 100000).toEqual(3600000);
});
test('should update to and from times to be a day apart if timeframe is "d"', () => {
const update = reducer(
{ ...initialState, timeframe: 'd' },
{
type: 'setToFrom',
}
);
const dateFrom = moment(update.fromTime);
const dateTo = moment(update.toTime);
const diff = dateFrom.diff(dateTo);
// 86400000 = 24 hours
// Sometimes test returns 86399999
expect(Math.ceil(diff / 100000) * 100000).toEqual(86400000);
});
});
describe('#setNoiseWarning', () => {
test('should add noise warning', () => {
const update = reducer(
{ ...initialState, warnings: ['uh oh'] },
{
type: 'setNoiseWarning',
}
);
expect(update.warnings).toEqual(['uh oh', i18n.QUERY_PREVIEW_NOISE_WARNING]);
});
});
});

View file

@ -1,167 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { Unit } from '@elastic/datemath';
import { EuiSelectOption } from '@elastic/eui';
import { Type, Language } from '@kbn/securitysolution-io-ts-alerting-types';
import type { Filter } from '@kbn/es-query';
import * as i18n from '../rule_preview/translations';
import { ESQuery } from '../../../../../common/typed_json';
import { FieldValueQueryBar } from '../query_bar';
import { formatDate } from '../../../../common/components/super_date_picker';
import { getInfoFromQueryBar, getTimeframeOptions } from '../rule_preview/helpers';
import { Threshold } from '.';
export interface State {
timeframeOptions: EuiSelectOption[];
showHistogram: boolean;
timeframe: Unit;
warnings: string[];
queryFilter: ESQuery | undefined;
toTime: string;
fromTime: string;
queryString: string;
language: Language;
filters: Filter[];
thresholdFieldExists: boolean;
showNonEqlHistogram: boolean;
}
export type Action =
| {
type: 'setQueryInfo';
queryBar: FieldValueQueryBar | undefined;
index: string[];
ruleType: Type;
}
| {
type: 'setTimeframeSelect';
timeframe: Unit;
}
| {
type: 'setResetRuleTypeChange';
ruleType: Type;
}
| {
type: 'setWarnings';
warnings: string[];
}
| {
type: 'setShowHistogram';
show: boolean;
}
| {
type: 'setThresholdQueryVals';
threshold: Threshold;
ruleType: Type;
}
| {
type: 'setNoiseWarning';
}
| {
type: 'setToFrom';
};
export const queryPreviewReducer =
() =>
(state: State, action: Action): State => {
switch (action.type) {
case 'setQueryInfo': {
if (action.queryBar != null) {
const { queryString, language, filters, queryFilter } = getInfoFromQueryBar(
action.queryBar,
action.index,
action.ruleType
);
return {
...state,
queryString,
language,
filters,
queryFilter,
showHistogram: false,
};
}
return {
...state,
showHistogram: false,
};
}
case 'setTimeframeSelect': {
return {
...state,
timeframe: action.timeframe,
showHistogram: false,
warnings: [],
};
}
case 'setResetRuleTypeChange': {
const showNonEqlHist =
action.ruleType === 'query' ||
action.ruleType === 'saved_query' ||
(action.ruleType === 'threshold' && !state.thresholdFieldExists);
return {
...state,
showHistogram: false,
timeframe: 'h',
timeframeOptions: getTimeframeOptions(action.ruleType),
showNonEqlHistogram: showNonEqlHist,
warnings: [],
};
}
case 'setWarnings': {
return {
...state,
warnings: action.warnings,
};
}
case 'setShowHistogram': {
return {
...state,
showHistogram: action.show,
};
}
case 'setThresholdQueryVals': {
const thresholdField =
action.threshold != null &&
action.threshold.field != null &&
action.threshold.field.length > 0 &&
action.threshold.field.every((field) => field.trim() !== '');
const showNonEqlHist =
action.ruleType === 'query' ||
action.ruleType === 'saved_query' ||
(action.ruleType === 'threshold' && !thresholdField);
return {
...state,
thresholdFieldExists: thresholdField,
showNonEqlHistogram: showNonEqlHist,
showHistogram: false,
warnings: [],
};
}
case 'setToFrom': {
return {
...state,
fromTime: formatDate('now'),
toTime: formatDate(`now-1${state.timeframe}`),
};
}
case 'setNoiseWarning': {
return {
...state,
warnings: [...state.warnings, i18n.QUERY_PREVIEW_NOISE_WARNING],
};
}
default:
return state;
}
};

View file

@ -1,104 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { mount } from 'enzyme';
import { useGlobalTime } from '../../../../common/containers/use_global_time';
import { TestProviders } from '../../../../common/mock';
import { PreviewThresholdQueryHistogram } from './threshold_histogram';
jest.mock('../../../../common/containers/use_global_time');
describe('PreviewThresholdQueryHistogram', () => {
const mockSetQuery = jest.fn();
beforeEach(() => {
(useGlobalTime as jest.Mock).mockReturnValue({
from: '2020-07-07T08:20:18.966Z',
isInitializing: false,
to: '2020-07-08T08:20:18.966Z',
setQuery: mockSetQuery,
});
});
afterEach(() => {
jest.clearAllMocks();
});
test('it renders loader when isLoading is true', () => {
const wrapper = mount(
<TestProviders>
<PreviewThresholdQueryHistogram
buckets={[]}
inspect={{ dsl: [], response: [] }}
refetch={jest.fn()}
isLoading
/>
</TestProviders>
);
expect(wrapper.find('[data-test-subj="queryPreviewLoading"]').exists()).toBeTruthy();
});
test('it configures buckets data', () => {
const wrapper = mount(
<TestProviders>
<PreviewThresholdQueryHistogram
buckets={[
{ key: 'siem_kibana', doc_count: 400 },
{ key: 'bastion00.siem.estc.dev', doc_count: 80225 },
{ key: 'es02.siem.estc.dev', doc_count: 1228 },
]}
inspect={{ dsl: [], response: [] }}
refetch={jest.fn()}
isLoading={false}
/>
</TestProviders>
);
expect(wrapper.find('[data-test-subj="queryPreviewLoading"]').exists()).toBeFalsy();
expect(
wrapper.find('[dataTestSubj="thresholdQueryPreviewHistogram"]').at(0).props().data
).toEqual([
{
key: 'hits',
value: [
{ g: 'siem_kibana', x: 'siem_kibana', y: 400 },
{ g: 'bastion00.siem.estc.dev', x: 'bastion00.siem.estc.dev', y: 80225 },
{ g: 'es02.siem.estc.dev', x: 'es02.siem.estc.dev', y: 1228 },
],
},
]);
});
test('it invokes setQuery with id, inspect, isLoading and refetch', async () => {
const mockRefetch = jest.fn();
mount(
<TestProviders>
<PreviewThresholdQueryHistogram
buckets={[
{ key: 'siem_kibana', doc_count: 400 },
{ key: 'bastion00.siem.estc.dev', doc_count: 80225 },
{ key: 'es02.siem.estc.dev', doc_count: 1228 },
]}
inspect={{ dsl: ['some dsl'], response: ['query response'] }}
refetch={mockRefetch}
isLoading={false}
/>
</TestProviders>
);
expect(mockSetQuery).toHaveBeenCalledWith({
id: 'queryPreviewThresholdHistogramQuery',
inspect: { dsl: ['some dsl'], response: ['query response'] },
loading: false,
refetch: mockRefetch,
});
});
});

View file

@ -1,82 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useEffect, useMemo } from 'react';
import * as i18n from '../rule_preview/translations';
import { useGlobalTime } from '../../../../common/containers/use_global_time';
import { getThresholdHistogramConfig } from '../rule_preview/helpers';
import { ChartSeriesConfigs, ChartSeriesData } from '../../../../common/components/charts/common';
import { InspectResponse } from '../../../../../public/types';
import { inputsModel } from '../../../../common/store';
import { PreviewHistogram } from './histogram';
export const ID = 'queryPreviewThresholdHistogramQuery';
interface PreviewThresholdQueryHistogramProps {
isLoading: boolean;
buckets: Array<{
key: string;
doc_count: number;
}>;
inspect: InspectResponse;
refetch: inputsModel.Refetch;
}
export const PreviewThresholdQueryHistogram = ({
buckets,
inspect,
refetch,
isLoading,
}: PreviewThresholdQueryHistogramProps) => {
const { setQuery, isInitializing } = useGlobalTime();
useEffect((): void => {
if (!isLoading && !isInitializing) {
setQuery({ id: ID, inspect, loading: isLoading, refetch });
}
}, [setQuery, inspect, isLoading, isInitializing, refetch]);
const { data, totalCount } = useMemo((): { data: ChartSeriesData[]; totalCount: number } => {
const total = buckets.length;
const dataBuckets = buckets.map<{ x: string; y: number; g: string }>(
({ key, doc_count: docCount }) => ({
x: key,
y: docCount,
g: key,
})
);
return {
data: [{ key: 'hits', value: dataBuckets }],
totalCount: total,
};
}, [buckets]);
const barConfig = useMemo((): ChartSeriesConfigs => getThresholdHistogramConfig(), []);
const subtitle = useMemo(
(): string =>
isLoading
? i18n.QUERY_PREVIEW_SUBTITLE_LOADING
: i18n.QUERY_PREVIEW_THRESHOLD_WITH_FIELD_TITLE(totalCount),
[isLoading, totalCount]
);
return (
<PreviewHistogram
id={ID}
data={data}
barConfig={barConfig}
title={i18n.QUERY_GRAPH_HITS_TITLE}
subtitle={subtitle}
disclaimer={i18n.QUERY_PREVIEW_DISCLAIMER}
isLoading={isLoading}
dataTestSubj="thresholdQueryPreviewHistogram"
/>
);
};

View file

@ -14,31 +14,31 @@ import {
describe('query_preview/helpers', () => {
describe('isNoisy', () => {
test('returns true if timeframe selection is "Last hour" and average hits per hour is greater than one', () => {
const isItNoisy = isNoisy(2, 'h');
test('returns true if timeframe selection is "Last hour" and average hits per hour is greater than one execution duration', () => {
const isItNoisy = isNoisy(30, 'h');
expect(isItNoisy).toBeTruthy();
});
test('returns false if timeframe selection is "Last hour" and average hits per hour is one', () => {
const isItNoisy = isNoisy(1, 'h');
test('returns false if timeframe selection is "Last hour" and average hits per hour is less than one execution duration', () => {
const isItNoisy = isNoisy(10, 'h');
expect(isItNoisy).toBeFalsy();
});
test('returns false if timeframe selection is "Last hour" and hits is 0', () => {
const isItNoisy = isNoisy(1, 'h');
const isItNoisy = isNoisy(0, 'h');
expect(isItNoisy).toBeFalsy();
});
test('returns true if timeframe selection is "Last day" and average hits per hour is greater than one', () => {
test('returns true if timeframe selection is "Last day" and average hits per hour is greater than one execution duration', () => {
const isItNoisy = isNoisy(50, 'd');
expect(isItNoisy).toBeTruthy();
});
test('returns false if timeframe selection is "Last day" and average hits per hour is one', () => {
test('returns false if timeframe selection is "Last day" and average hits per hour is equal to one execution duration', () => {
const isItNoisy = isNoisy(24, 'd');
expect(isItNoisy).toBeFalsy();
@ -50,20 +50,20 @@ describe('query_preview/helpers', () => {
expect(isItNoisy).toBeFalsy();
});
test('returns true if timeframe selection is "Last month" and average hits per hour is greater than one', () => {
const isItNoisy = isNoisy(1000, 'M');
test('returns true if timeframe selection is "Last month" and average hits per hour is greater than one execution duration', () => {
const isItNoisy = isNoisy(50, 'M');
expect(isItNoisy).toBeTruthy();
});
test('returns false if timeframe selection is "Last month" and average hits per hour is one', () => {
const isItNoisy = isNoisy(730, 'M');
test('returns false if timeframe selection is "Last month" and average hits per hour is equal to one execution duration', () => {
const isItNoisy = isNoisy(30, 'M');
expect(isItNoisy).toBeFalsy();
});
test('returns false if timeframe selection is "Last month" and hits is 0', () => {
const isItNoisy = isNoisy(1, 'M');
const isItNoisy = isNoisy(0, 'M');
expect(isItNoisy).toBeFalsy();
});
@ -80,6 +80,7 @@ describe('query_preview/helpers', () => {
threatMapping: [
{ entries: [{ field: 'test-field', value: 'test-value', type: 'mapping' }] },
],
machineLearningJobId: ['test-ml-job-id'],
});
expect(isDisabled).toEqual(true);
});
@ -94,6 +95,7 @@ describe('query_preview/helpers', () => {
threatMapping: [
{ entries: [{ field: 'test-field', value: 'test-value', type: 'mapping' }] },
],
machineLearningJobId: ['test-ml-job-id'],
});
expect(isDisabled).toEqual(true);
});
@ -108,6 +110,7 @@ describe('query_preview/helpers', () => {
threatMapping: [
{ entries: [{ field: 'test-field', value: 'test-value', type: 'mapping' }] },
],
machineLearningJobId: ['test-ml-job-id'],
});
expect(isDisabled).toEqual(true);
});
@ -122,6 +125,7 @@ describe('query_preview/helpers', () => {
threatMapping: [
{ entries: [{ field: 'test-field', value: 'test-value', type: 'mapping' }] },
],
machineLearningJobId: ['test-ml-job-id'],
});
expect(isDisabled).toEqual(true);
});
@ -134,6 +138,20 @@ describe('query_preview/helpers', () => {
index: ['test-*'],
threatIndex: ['threat-*'],
threatMapping: [],
machineLearningJobId: ['test-ml-job-id'],
});
expect(isDisabled).toEqual(true);
});
test('disabled when there is no machine learning job id', () => {
const isDisabled = getIsRulePreviewDisabled({
ruleType: 'threat_match',
isQueryBarValid: true,
isThreatQueryBarValid: true,
index: ['test-*'],
threatIndex: ['threat-*'],
threatMapping: [],
machineLearningJobId: [],
});
expect(isDisabled).toEqual(true);
});
@ -148,6 +166,7 @@ describe('query_preview/helpers', () => {
threatMapping: [
{ entries: [{ field: 'test-field', value: 'test-value', type: 'mapping' }] },
],
machineLearningJobId: ['test-ml-job-id'],
});
expect(isDisabled).toEqual(false);
});
@ -163,7 +182,7 @@ describe('query_preview/helpers', () => {
]);
});
test('returns hour, day, and month options if ruleType is not eql', () => {
test('returns hour, day, and month options if ruleType is not eql or threshold', () => {
const options = getTimeframeOptions('query');
expect(options).toEqual([
@ -172,6 +191,12 @@ describe('query_preview/helpers', () => {
{ value: 'M', text: 'Last month' },
]);
});
test('returns hour option if ruleType is threshold', () => {
const options = getTimeframeOptions('threshold');
expect(options).toEqual([{ value: 'h', text: 'Last hour' }]);
});
});
describe('getInfoFromQueryBar', () => {

View file

@ -24,13 +24,13 @@ import { ESQuery } from '../../../../../common/typed_json';
*/
export const isNoisy = (hits: number, timeframe: Unit): boolean => {
if (timeframe === 'h') {
return hits > 1;
return hits > 20;
} else if (timeframe === 'd') {
return hits / 24 > 1;
} else if (timeframe === 'w') {
return hits / 168 > 1;
} else if (timeframe === 'M') {
return hits / 730 > 1;
return hits / 30 > 1;
}
return false;
@ -54,6 +54,8 @@ export const getTimeframeOptions = (ruleType: Type): EuiSelectOption[] => {
{ value: 'd', text: i18n.LAST_DAY },
{ value: 'w', text: i18n.LAST_WEEK },
];
} else if (ruleType === 'threshold') {
return [{ value: 'h', text: i18n.LAST_HOUR }];
} else {
return [
{ value: 'h', text: i18n.LAST_HOUR },
@ -206,6 +208,7 @@ export const getIsRulePreviewDisabled = ({
index,
threatIndex,
threatMapping,
machineLearningJobId,
}: {
ruleType: Type;
isQueryBarValid: boolean;
@ -213,6 +216,7 @@ export const getIsRulePreviewDisabled = ({
index: string[];
threatIndex: string[];
threatMapping: ThreatMapping;
machineLearningJobId: string[];
}) => {
if (!isQueryBarValid || index.length === 0) return true;
if (ruleType === 'threat_match') {
@ -225,5 +229,8 @@ export const getIsRulePreviewDisabled = ({
)
return true;
}
if (ruleType === 'machine_learning') {
return machineLearningJobId.length === 0;
}
return false;
};

View file

@ -6,7 +6,7 @@
*/
import React from 'react';
import { mount } from 'enzyme';
import { render } from '@testing-library/react';
import { TestProviders } from '../../../../common/mock';
import { RulePreview, RulePreviewProps } from './';
@ -45,6 +45,16 @@ const defaultProps: RulePreviewProps = {
filters: [],
query: { query: 'threat.indicator.file.hash.md5:*', language: 'kuery' },
},
threshold: {
field: ['agent.hostname'],
value: '200',
cardinality: {
field: ['user.name'],
value: '2',
},
},
anomalyThreshold: 50,
machineLearningJobId: ['test-ml-job-id'],
};
describe('PreviewQuery', () => {
@ -76,43 +86,43 @@ describe('PreviewQuery', () => {
});
test('it renders timeframe select and preview button on render', () => {
const wrapper = mount(
const wrapper = render(
<TestProviders>
<RulePreview {...defaultProps} />
</TestProviders>
);
expect(wrapper.find('[data-test-subj="rule-preview"]').exists()).toBeTruthy();
expect(wrapper.find('[data-test-subj="preview-time-frame"]').exists()).toBeTruthy();
expect(wrapper.findByTestId('rule-preview')).toBeTruthy();
expect(wrapper.findByTestId('preview-time-frame')).toBeTruthy();
});
test('it renders preview button disabled if "isDisabled" is true', () => {
const wrapper = mount(
const wrapper = render(
<TestProviders>
<RulePreview {...defaultProps} isDisabled={true} />
</TestProviders>
);
expect(wrapper.find('[data-test-subj="preview-button"] button').props().disabled).toBeTruthy();
expect(wrapper.getByTestId('queryPreviewButton').closest('button')).toBeDisabled();
});
test('it renders preview button enabled if "isDisabled" is false', () => {
const wrapper = mount(
const wrapper = render(
<TestProviders>
<RulePreview {...defaultProps} />
</TestProviders>
);
expect(wrapper.find('[data-test-subj="preview-button"] button').props().disabled).toBeFalsy();
expect(wrapper.getByTestId('queryPreviewButton').closest('button')).not.toBeDisabled();
});
test('does not render histogram when there is no previewId', () => {
const wrapper = mount(
const wrapper = render(
<TestProviders>
<RulePreview {...defaultProps} />
</TestProviders>
);
expect(wrapper.find('[data-test-subj="preview-histogram-panel"]').exists()).toBeFalsy();
expect(wrapper.queryByTestId('[data-test-subj="preview-histogram-panel"]')).toBeNull();
});
});

View file

@ -25,6 +25,7 @@ import { getTimeframeOptions } from './helpers';
import { CalloutGroup } from './callout_group';
import { useKibana } from '../../../../common/lib/kibana';
import { LoadingHistogram } from './loading_histogram';
import { FieldValueThreshold } from '../threshold_input';
export interface RulePreviewProps {
index: string[];
@ -34,6 +35,9 @@ export interface RulePreviewProps {
threatIndex: string[];
threatMapping: ThreatMapping;
threatQuery: FieldValueQueryBar;
threshold: FieldValueThreshold;
machineLearningJobId: string[];
anomalyThreshold: number;
}
const Select = styled(EuiSelect)`
@ -44,6 +48,8 @@ const PreviewButton = styled(EuiButton)`
margin-left: 0;
`;
const defaultTimeRange: Unit = 'h';
const RulePreviewComponent: React.FC<RulePreviewProps> = ({
index,
isDisabled,
@ -52,6 +58,9 @@ const RulePreviewComponent: React.FC<RulePreviewProps> = ({
threatIndex,
threatQuery,
threatMapping,
threshold,
machineLearningJobId,
anomalyThreshold,
}) => {
const { spaces } = useKibana().services;
const [spaceId, setSpaceId] = useState('');
@ -61,7 +70,7 @@ const RulePreviewComponent: React.FC<RulePreviewProps> = ({
}
}, [spaces]);
const [timeFrame, setTimeFrame] = useState<Unit>('h');
const [timeFrame, setTimeFrame] = useState<Unit>(defaultTimeRange);
const {
addNoiseWarning,
createPreview,
@ -78,8 +87,16 @@ const RulePreviewComponent: React.FC<RulePreviewProps> = ({
timeFrame,
ruleType,
threatMapping,
threshold,
machineLearningJobId,
anomalyThreshold,
});
// Resets the timeFrame to default when rule type is changed because not all time frames are supported by all rule types
useEffect(() => {
setTimeFrame(defaultTimeRange);
}, [ruleType]);
return (
<>
<EuiFormRow
@ -108,7 +125,7 @@ const RulePreviewComponent: React.FC<RulePreviewProps> = ({
isLoading={isPreviewRequestInProgress}
isDisabled={isDisabled}
onClick={createPreview}
data-test-subj="preview-button"
data-test-subj="queryPreviewButton"
>
{i18n.QUERY_PREVIEW_BUTTON}
</PreviewButton>
@ -117,12 +134,16 @@ const RulePreviewComponent: React.FC<RulePreviewProps> = ({
</EuiFormRow>
<EuiSpacer size="s" />
{isPreviewRequestInProgress && <LoadingHistogram />}
{!isPreviewRequestInProgress && previewId && spaceId && (
{!isPreviewRequestInProgress && previewId && spaceId && query && (
<PreviewHistogram
ruleType={ruleType}
timeFrame={timeFrame}
previewId={previewId}
addNoiseWarning={addNoiseWarning}
spaceId={spaceId}
threshold={threshold}
query={query}
index={index}
/>
)}
<CalloutGroup items={errors} isError />

View file

@ -14,6 +14,7 @@ import { TestProviders } from '../../../../common/mock';
import { usePreviewHistogram } from './use_preview_histogram';
import { PreviewHistogram } from './preview_histogram';
import { mockQueryBar } from '../../../pages/detection_engine/rules/all/__mocks__/mock';
jest.mock('../../../../common/containers/use_global_time');
jest.mock('./use_preview_histogram');
@ -53,6 +54,9 @@ describe('PreviewHistogram', () => {
timeFrame="M"
previewId={'test-preview-id'}
spaceId={'default'}
ruleType={'query'}
query={mockQueryBar}
index={['']}
/>
</TestProviders>
);
@ -86,6 +90,9 @@ describe('PreviewHistogram', () => {
timeFrame="M"
previewId={'test-preview-id'}
spaceId={'default'}
ruleType={'query'}
query={mockQueryBar}
index={['']}
/>
</TestProviders>
);

View file

@ -10,15 +10,18 @@ import usePrevious from 'react-use/lib/usePrevious';
import { Unit } from '@elastic/datemath';
import { EuiFlexGroup, EuiFlexItem, EuiText, EuiSpacer, EuiLoadingChart } from '@elastic/eui';
import styled from 'styled-components';
import { Type } from '@kbn/securitysolution-io-ts-alerting-types';
import * as i18n from './translations';
import { useGlobalTime } from '../../../../common/containers/use_global_time';
import { getHistogramConfig, isNoisy } from './helpers';
import { getHistogramConfig, getThresholdHistogramConfig, isNoisy } from './helpers';
import { ChartSeriesConfigs, ChartSeriesData } from '../../../../common/components/charts/common';
import { Panel } from '../../../../common/components/panel';
import { HeaderSection } from '../../../../common/components/header_section';
import { BarChart } from '../../../../common/components/charts/barchart';
import { usePreviewHistogram } from './use_preview_histogram';
import { formatDate } from '../../../../common/components/super_date_picker';
import { FieldValueThreshold } from '../threshold_input';
import { FieldValueQueryBar } from '../query_bar';
const LoadingChart = styled(EuiLoadingChart)`
display: block;
@ -32,6 +35,10 @@ interface PreviewHistogramProps {
previewId: string;
addNoiseWarning: () => void;
spaceId: string;
threshold?: FieldValueThreshold;
ruleType: Type;
query: FieldValueQueryBar;
index: string[];
}
const DEFAULT_HISTOGRAM_HEIGHT = 300;
@ -41,6 +48,10 @@ export const PreviewHistogram = ({
previewId,
addNoiseWarning,
spaceId,
threshold,
ruleType,
query,
index,
}: PreviewHistogramProps) => {
const { setQuery, isInitializing } = useGlobalTime();
@ -48,12 +59,18 @@ export const PreviewHistogram = ({
const to = useMemo(() => 'now', []);
const startDate = useMemo(() => formatDate(from), [from]);
const endDate = useMemo(() => formatDate(to), [to]);
const isEqlRule = useMemo(() => ruleType === 'eql', [ruleType]);
const isThresholdRule = useMemo(() => ruleType === 'threshold', [ruleType]);
const [isLoading, { data, inspect, totalCount, refetch }] = usePreviewHistogram({
const [isLoading, { data, inspect, totalCount, refetch, buckets }] = usePreviewHistogram({
previewId,
startDate,
endDate,
spaceId,
threshold: isThresholdRule ? threshold : undefined,
query,
index,
ruleType,
});
const previousPreviewId = usePrevious(previewId);
@ -73,18 +90,42 @@ export const PreviewHistogram = ({
}, [setQuery, inspect, isLoading, isInitializing, refetch, previewId]);
const barConfig = useMemo(
(): ChartSeriesConfigs => getHistogramConfig(endDate, startDate, true),
[endDate, startDate]
(): ChartSeriesConfigs => getHistogramConfig(endDate, startDate, !isEqlRule),
[endDate, startDate, isEqlRule]
);
const thresholdBarConfig = useMemo((): ChartSeriesConfigs => getThresholdHistogramConfig(), []);
const chartData = useMemo((): ChartSeriesData[] => [{ key: 'hits', value: data }], [data]);
const { thresholdChartData, thresholdTotalCount } = useMemo((): {
thresholdChartData: ChartSeriesData[];
thresholdTotalCount: number;
} => {
const total = buckets.length;
const dataBuckets = buckets.map<{ x: string; y: number; g: string }>(
({ key, doc_count: docCount }) => ({
x: key,
y: docCount,
g: key,
})
);
return {
thresholdChartData: [{ key: 'hits', value: dataBuckets }],
thresholdTotalCount: total,
};
}, [buckets]);
const subtitle = useMemo(
(): string =>
isLoading ? i18n.QUERY_PREVIEW_SUBTITLE_LOADING : i18n.QUERY_PREVIEW_TITLE(totalCount),
[isLoading, totalCount]
isLoading
? i18n.QUERY_PREVIEW_SUBTITLE_LOADING
: isThresholdRule
? i18n.QUERY_PREVIEW_THRESHOLD_WITH_FIELD_TITLE(thresholdTotalCount)
: i18n.QUERY_PREVIEW_TITLE(totalCount),
[isLoading, totalCount, thresholdTotalCount, isThresholdRule]
);
const chartData = useMemo((): ChartSeriesData[] => [{ key: 'hits', value: data }], [data]);
return (
<Panel height={DEFAULT_HISTOGRAM_HEIGHT} data-test-subj={'preview-histogram-panel'}>
<EuiFlexGroup gutterSize="none" direction="column">
@ -101,8 +142,8 @@ export const PreviewHistogram = ({
<LoadingChart size="l" data-test-subj="preview-histogram-loading" />
) : (
<BarChart
configs={barConfig}
barChart={chartData}
configs={isThresholdRule ? thresholdBarConfig : barConfig}
barChart={isThresholdRule ? thresholdChartData : chartData}
data-test-subj="preview-histogram-bar-chart"
/>
)}

View file

@ -5,6 +5,7 @@
* 2.0.
*/
import { useMemo } from 'react';
import { Type } from '@kbn/securitysolution-io-ts-alerting-types';
import { useMatrixHistogram } from '../../../../common/containers/matrix_histogram';
import { MatrixHistogramType } from '../../../../../common/search_strategy';
import { convertToBuildEsQuery } from '../../../../common/lib/keury';
@ -12,12 +13,18 @@ import { getEsQueryConfig } from '../../../../../../../../src/plugins/data/commo
import { useKibana } from '../../../../common/lib/kibana';
import { QUERY_PREVIEW_ERROR } from './translations';
import { DEFAULT_PREVIEW_INDEX } from '../../../../../common/constants';
import { FieldValueThreshold } from '../threshold_input';
import { FieldValueQueryBar } from '../query_bar';
interface PreviewHistogramParams {
previewId: string | undefined;
endDate: string;
startDate: string;
spaceId: string;
threshold?: FieldValueThreshold;
query: FieldValueQueryBar;
index: string[];
ruleType: Type;
}
export const usePreviewHistogram = ({
@ -25,24 +32,35 @@ export const usePreviewHistogram = ({
startDate,
endDate,
spaceId,
threshold,
query,
index,
ruleType,
}: PreviewHistogramParams) => {
const { uiSettings } = useKibana().services;
const {
query: { query: queryString, language },
filters,
} = query;
const [filterQuery] = convertToBuildEsQuery({
const [filterQuery, error] = convertToBuildEsQuery({
config: getEsQueryConfig(uiSettings),
indexPattern: {
fields: [
{
name: 'signal.rule.id',
type: 'string',
},
],
title: 'Preview',
fields: [],
title: index.join(),
},
queries: [{ query: `signal.rule.id:${previewId}`, language: 'kuery' }],
filters: [],
queries: [
{ query: `signal.rule.id:${previewId}`, language: 'kuery' },
{ query: queryString, language },
],
filters,
});
const stackByField = useMemo(() => {
const stackByDefault = ruleType === 'machine_learning' ? 'host.name' : 'event.category';
return threshold?.field[0] ?? stackByDefault;
}, [threshold, ruleType]);
const matrixHistogramRequest = useMemo(() => {
return {
endDate,
@ -50,10 +68,13 @@ export const usePreviewHistogram = ({
filterQuery,
histogramType: MatrixHistogramType.preview,
indexNames: [`${DEFAULT_PREVIEW_INDEX}-${spaceId}`],
stackByField: 'event.category',
stackByField,
startDate,
threshold,
includeMissingData: false,
skip: error != null,
};
}, [startDate, endDate, filterQuery, spaceId]);
}, [startDate, endDate, filterQuery, spaceId, error, threshold, stackByField]);
return useMatrixHistogram(matrixHistogramRequest);
};

View file

@ -12,6 +12,7 @@ import { FieldValueQueryBar } from '../query_bar';
import { QUERY_PREVIEW_NOISE_WARNING } from './translations';
import { usePreviewRule } from '../../../containers/detection_engine/rules/use_preview_rule';
import { formatPreviewRule } from '../../../pages/detection_engine/rules/create/helpers';
import { FieldValueThreshold } from '../threshold_input';
interface PreviewRouteParams {
isDisabled: boolean;
@ -22,6 +23,9 @@ interface PreviewRouteParams {
ruleType: Type;
timeFrame: Unit;
threatMapping: ThreatMapping;
threshold: FieldValueThreshold;
machineLearningJobId: string[];
anomalyThreshold: number;
}
export const usePreviewRoute = ({
@ -33,10 +37,13 @@ export const usePreviewRoute = ({
timeFrame,
ruleType,
threatMapping,
threshold,
machineLearningJobId,
anomalyThreshold,
}: PreviewRouteParams) => {
const [isRequestTriggered, setIsRequestTriggered] = useState(false);
const { isLoading, response, rule, setRule } = usePreviewRule();
const { isLoading, response, rule, setRule } = usePreviewRule(timeFrame);
const [warnings, setWarnings] = useState<string[]>(response.warnings ?? []);
useEffect(() => {
@ -66,6 +73,9 @@ export const usePreviewRoute = ({
timeFrame,
ruleType,
threatMapping,
threshold,
machineLearningJobId,
anomalyThreshold,
]);
useEffect(() => {
@ -79,6 +89,9 @@ export const usePreviewRoute = ({
threatMapping,
threatQuery,
timeFrame,
threshold,
machineLearningJobId,
anomalyThreshold,
})
);
}
@ -93,6 +106,9 @@ export const usePreviewRoute = ({
threatMapping,
threatQuery,
timeFrame,
threshold,
machineLearningJobId,
anomalyThreshold,
]);
return {

View file

@ -11,7 +11,6 @@ import { shallow } from 'enzyme';
import { StepDefineRule } from './index';
jest.mock('../../../../common/lib/kibana');
jest.mock('../../../containers/detection_engine/alerts/use_preview_index');
describe('StepDefineRule', () => {
it('renders correctly', () => {

View file

@ -55,10 +55,8 @@ import {
import { EqlQueryBar } from '../eql_query_bar';
import { ThreatMatchInput } from '../threatmatch_input';
import { BrowserField, BrowserFields, useFetchIndex } from '../../../../common/containers/source';
import { PreviewQuery } from '../query_preview';
import { RulePreview } from '../rule_preview';
import { getIsRulePreviewDisabled } from '../rule_preview/helpers';
import { usePreviewIndex } from '../../../containers/detection_engine/alerts/use_preview_index';
const CommonUseField = getUseField({ component: Field });
@ -138,7 +136,6 @@ const StepDefineRuleComponent: FC<StepDefineRuleProps> = ({
onSubmit,
setForm,
}) => {
usePreviewIndex();
const mlCapabilities = useMlCapabilities();
const [openTimelineSearch, setOpenTimelineSearch] = useState(false);
const [indexModified, setIndexModified] = useState(false);
@ -165,6 +162,8 @@ const StepDefineRuleComponent: FC<StepDefineRuleProps> = ({
threatQueryBar: formThreatQuery,
threshold: formThreshold,
threatMapping: formThreatMapping,
machineLearningJobId: formMachineLearningJobId,
anomalyThreshold: formAnomalyThreshold,
},
] = useFormData<DefineStepRule>({
form,
@ -179,6 +178,8 @@ const StepDefineRuleComponent: FC<StepDefineRuleProps> = ({
'threshold.cardinality.value',
'threatIndex',
'threatMapping',
'machineLearningJobId',
'anomalyThreshold',
],
});
@ -186,12 +187,10 @@ const StepDefineRuleComponent: FC<StepDefineRuleProps> = ({
const [isThreatQueryBarValid, setIsThreatQueryBarValid] = useState(false);
const index = formIndex || initialState.index;
const threatIndex = formThreatIndex || initialState.threatIndex;
const machineLearningJobId = formMachineLearningJobId ?? initialState.machineLearningJobId;
const anomalyThreshold = formAnomalyThreshold ?? initialState.anomalyThreshold;
const ruleType = formRuleType || initialState.ruleType;
const isPreviewRouteEnabled = useMemo(() => ruleType === 'threat_match', [ruleType]);
const isQueryPreviewEnabled = useMemo(
() => ruleType !== 'machine_learning' && ruleType !== 'threat_match',
[ruleType]
);
const isPreviewRouteEnabled = useMemo(() => ruleType !== 'threat_match', [ruleType]);
const [indexPatternsLoading, { browserFields, indexPatterns }] = useFetchIndex(index);
const aggregatableFields = Object.entries(browserFields).reduce<BrowserFields>(
(groupAcc, [groupName, groupValue]) => {
@ -505,20 +504,6 @@ const StepDefineRuleComponent: FC<StepDefineRuleProps> = ({
}}
/>
</Form>
{isQueryPreviewEnabled && (
<>
<EuiSpacer size="s" />
<PreviewQuery
dataTestSubj="ruleCreationQueryPreview"
idAria="ruleCreationQueryPreview"
ruleType={ruleType}
index={index}
query={formQuery}
isDisabled={!isQueryBarValid || index.length === 0}
threshold={formThreshold}
/>
</>
)}
{isPreviewRouteEnabled && (
<>
<EuiSpacer size="s" />
@ -531,12 +516,16 @@ const StepDefineRuleComponent: FC<StepDefineRuleProps> = ({
index,
threatIndex,
threatMapping: formThreatMapping,
machineLearningJobId,
})}
query={formQuery}
ruleType={ruleType}
threatIndex={threatIndex}
threatQuery={formThreatQuery}
threatMapping={formThreatMapping}
threshold={formThreshold}
machineLearningJobId={machineLearningJobId}
anomalyThreshold={anomalyThreshold}
/>
</>
)}

View file

@ -21,7 +21,6 @@ import {
getUserPrivilege,
createSignalIndex,
createHostIsolation,
createPreviewIndex,
} from './api';
import { coreMock } from '../../../../../../../../src/core/public/mocks';
@ -166,25 +165,6 @@ describe('Detections Alerts API', () => {
});
});
describe('createPreviewIndex', () => {
beforeEach(() => {
fetchMock.mockClear();
fetchMock.mockResolvedValue({ acknowledged: true });
});
test('check parameter url', async () => {
await createPreviewIndex();
expect(fetchMock).toHaveBeenCalledWith('/api/detection_engine/rules/preview/index', {
method: 'POST',
});
});
test('happy path', async () => {
const previewResp = await createPreviewIndex();
expect(previewResp).toEqual({ acknowledged: true });
});
});
describe('createHostIsolation', () => {
const postMock = coreStartMock.http.post;

View file

@ -14,7 +14,6 @@ import {
DETECTION_ENGINE_INDEX_URL,
DETECTION_ENGINE_PRIVILEGES_URL,
ALERTS_AS_DATA_FIND_URL,
DETECTION_ENGINE_RULES_PREVIEW_INDEX_URL,
DETECTION_ENGINE_RULES_PREVIEW,
} from '../../../../../common/constants';
import { HOST_METADATA_GET_ROUTE } from '../../../../../common/endpoint/constants';
@ -134,15 +133,6 @@ export const createSignalIndex = async ({ signal }: BasicSignals): Promise<Alert
signal,
});
/**
* Create Preview Index if needed it
* @throws An error if response is not OK
*/
export const createPreviewIndex = async (): Promise<AlertsIndex> =>
KibanaServices.get().http.fetch<AlertsIndex>(DETECTION_ENGINE_RULES_PREVIEW_INDEX_URL, {
method: 'POST',
});
/**
* Create Preview if needed it
* @throws An error if response is not OK

View file

@ -7,6 +7,8 @@
import { useEffect, useState } from 'react';
import { Unit } from '@elastic/datemath';
import { RULE_PREVIEW_INVOCATION_COUNT } from '../../../../../common/detection_engine/constants';
import { useAppToasts } from '../../../../common/hooks/use_app_toasts';
import {
PreviewResponse,
@ -23,11 +25,24 @@ const emptyPreviewRule: PreviewResponse = {
warnings: [],
};
export const usePreviewRule = () => {
export const usePreviewRule = (timeframe: Unit = 'h') => {
const [rule, setRule] = useState<CreateRulesSchema | null>(null);
const [response, setResponse] = useState<PreviewResponse>(emptyPreviewRule);
const [isLoading, setIsLoading] = useState(false);
const { addError } = useAppToasts();
let invocationCount = RULE_PREVIEW_INVOCATION_COUNT.HOUR;
switch (timeframe) {
case 'd':
invocationCount = RULE_PREVIEW_INVOCATION_COUNT.DAY;
break;
case 'w':
invocationCount = RULE_PREVIEW_INVOCATION_COUNT.WEEK;
break;
case 'M':
invocationCount = RULE_PREVIEW_INVOCATION_COUNT.MONTH;
break;
}
useEffect(() => {
if (!rule) {
@ -45,7 +60,7 @@ export const usePreviewRule = () => {
try {
setIsLoading(true);
const previewRuleResponse = await previewRule({
rule: { ...transformOutput(rule), invocationCount: 1 },
rule: { ...transformOutput(rule), invocationCount },
signal: abortCtrl.signal,
});
if (isSubscribed) {
@ -67,7 +82,7 @@ export const usePreviewRule = () => {
isSubscribed = false;
abortCtrl.abort();
};
}, [rule, addError]);
}, [rule, addError, invocationCount]);
return { isLoading, response, rule, setRule };
};

View file

@ -44,6 +44,7 @@ import { CreateRulesSchema } from '../../../../../../common/detection_engine/sch
import { stepDefineDefaultValue } from '../../../../components/rules/step_define_rule';
import { stepAboutDefaultValue } from '../../../../components/rules/step_about_rule/default_value';
import { stepActionsDefaultValue } from '../../../../components/rules/step_rule_actions';
import { FieldValueThreshold } from '../../../../components/rules/threshold_input';
export const getTimeTypeValue = (time: string): { unit: string; value: number } => {
const timeObj = {
@ -266,7 +267,6 @@ export const formatDefineStepData = (defineStepData: DefineStepRule): DefineStep
...(ruleType === 'query' &&
ruleFields.queryBar?.saved_id && { type: 'saved_query' as Type }),
};
return {
...baseFields,
...typeFields,
@ -402,6 +402,9 @@ export const formatPreviewRule = ({
ruleType,
threatMapping,
timeFrame,
threshold,
machineLearningJobId,
anomalyThreshold,
}: {
index: string[];
threatIndex: string[];
@ -410,6 +413,9 @@ export const formatPreviewRule = ({
ruleType: Type;
threatMapping: ThreatMapping;
timeFrame: Unit;
threshold: FieldValueThreshold;
machineLearningJobId: string[];
anomalyThreshold: number;
}): CreateRulesSchema => {
const defineStepData = {
...stepDefineDefaultValue,
@ -419,6 +425,9 @@ export const formatPreviewRule = ({
threatIndex,
threatQueryBar: threatQuery,
threatMapping,
threshold,
machineLearningJobId,
anomalyThreshold,
};
const aboutStepData = {
...stepAboutDefaultValue,
@ -426,8 +435,8 @@ export const formatPreviewRule = ({
description: 'Preview Rule',
};
const scheduleStepData = {
from: `now-${timeFrame === 'w' ? '604830' : timeFrame === 'd' ? '86420' : '3610'}s`,
interval: `30s`,
from: `now-${timeFrame === 'M' ? '25h' : timeFrame === 'd' ? '65m' : '6m'}`,
interval: `${timeFrame === 'M' ? '1d' : timeFrame === 'd' ? '1h' : '5m'}`,
};
return {
...formatRule<CreateRulesSchema>(

View file

@ -27,7 +27,6 @@ jest.mock('react-router-dom', () => {
});
jest.mock('../../../../../common/lib/kibana');
jest.mock('../../../../containers/detection_engine/lists/use_lists_config');
jest.mock('../../../../containers/detection_engine/alerts/use_preview_index');
jest.mock('../../../../../common/components/link_to');
jest.mock('../../../../components/user_info');
jest.mock('../../../../../common/hooks/use_app_toasts');

View file

@ -85,7 +85,6 @@ jest.mock('react-router-dom', () => {
});
jest.mock('../../../../../common/lib/kibana');
jest.mock('../../../../containers/detection_engine/alerts/use_preview_index');
const mockRedirectLegacyUrl = jest.fn();
const mockGetLegacyUrlConflict = jest.fn();

View file

@ -1,92 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import {
transformError,
getIndexExists,
getPolicyExists,
setPolicy,
createBootstrapIndex,
} from '@kbn/securitysolution-es-utils';
import type {
AppClient,
SecuritySolutionPluginRouter,
SecuritySolutionRequestHandlerContext,
} from '../../../../types';
import { DETECTION_ENGINE_RULES_PREVIEW_INDEX_URL } from '../../../../../common/constants';
import { buildSiemResponse } from '../utils';
import { getSignalsTemplate, SIGNALS_TEMPLATE_VERSION } from './get_signals_template';
import previewPolicy from './preview_policy.json';
import { getIndexVersion } from './get_index_version';
import { isOutdated } from '../../migrations/helpers';
import { templateNeedsUpdate } from './check_template_version';
export const createPreviewIndexRoute = (router: SecuritySolutionPluginRouter) => {
router.post(
{
path: DETECTION_ENGINE_RULES_PREVIEW_INDEX_URL,
validate: false,
options: {
tags: ['access:securitySolution'],
},
},
async (context, request, response) => {
const siemResponse = buildSiemResponse(response);
try {
const siemClient = context.securitySolution?.getAppClient();
if (!siemClient) {
return siemResponse.error({ statusCode: 404 });
}
await createPreviewIndex(context, siemClient);
return response.ok({ body: { acknowledged: true } });
} catch (err) {
const error = transformError(err);
return siemResponse.error({
body: error.message,
statusCode: error.statusCode,
});
}
}
);
};
export const createPreviewIndex = async (
context: SecuritySolutionRequestHandlerContext,
siemClient: AppClient
) => {
const esClient = context.core.elasticsearch.client.asCurrentUser;
const index = siemClient.getPreviewIndex();
const spaceId = context.securitySolution.getSpaceId();
const indexExists = await getIndexExists(esClient, index);
const policyExists = await getPolicyExists(esClient, index);
if (!policyExists) {
await setPolicy(esClient, index, previewPolicy);
}
const ruleDataService = context.securitySolution.getRuleDataService();
const aadIndexAliasName = ruleDataService.getResourceName(`security.alerts-${spaceId}`);
if (await templateNeedsUpdate({ alias: index, esClient })) {
await esClient.indices.putIndexTemplate({
name: index,
body: getSignalsTemplate(index, aadIndexAliasName) as Record<string, unknown>,
});
}
if (indexExists) {
const indexVersion = await getIndexVersion(esClient, index);
if (isOutdated({ current: indexVersion, target: SIGNALS_TEMPLATE_VERSION })) {
await esClient.indices.rollover({ alias: index });
}
} else {
await createBootstrapIndex(esClient, index);
}
};

View file

@ -7,10 +7,10 @@
import moment from 'moment';
import uuid from 'uuid';
import { transformError } from '@kbn/securitysolution-es-utils';
import { IRuleDataClient } from '../../../../../../rule_registry/server';
import { buildSiemResponse } from '../utils';
import { convertCreateAPIToInternalSchema } from '../../schemas/rule_converters';
import { RuleParams } from '../../schemas/rule_schemas';
import { signalRulesAlertType } from '../../signals/signal_rule_alert_type';
import { createWarningsAndErrors } from '../../signals/preview/preview_rule_execution_log_client';
import { parseInterval } from '../../signals/utils';
import { buildMlAuthz } from '../../../machine_learning/authz';
@ -33,22 +33,26 @@ import {
import { ExecutorType } from '../../../../../../alerting/server/types';
import { AlertInstance } from '../../../../../../alerting/server';
import { ConfigType } from '../../../../config';
import { IEventLogService } from '../../../../../../event_log/server';
import { alertInstanceFactoryStub } from '../../signals/preview/alert_instance_factory_stub';
import { CreateRuleOptions } from '../../rule_types/types';
enum InvocationCount {
HOUR = 1,
DAY = 24,
WEEK = 168,
}
import { CreateRuleOptions, CreateSecurityRuleTypeWrapperProps } from '../../rule_types/types';
import {
createEqlAlertType,
createIndicatorMatchAlertType,
createMlAlertType,
createQueryAlertType,
createThresholdAlertType,
} from '../../rule_types';
import { createSecurityRuleTypeWrapper } from '../../rule_types/create_security_rule_type_wrapper';
import { RULE_PREVIEW_INVOCATION_COUNT } from '../../../../../common/detection_engine/constants';
export const previewRulesRoute = async (
router: SecuritySolutionPluginRouter,
config: ConfigType,
ml: SetupPlugins['ml'],
security: SetupPlugins['security'],
ruleOptions: CreateRuleOptions
ruleOptions: CreateRuleOptions,
securityRuleTypeOptions: CreateSecurityRuleTypeWrapperProps,
previewRuleDataClient: IRuleDataClient
) => {
router.post(
{
@ -73,19 +77,22 @@ export const previewRulesRoute = async (
return siemResponse.error({ statusCode: 404 });
}
if (request.body.type !== 'threat_match') {
return response.ok({ body: { errors: ['Not an indicator match rule'] } });
}
let invocationCount = request.body.invocationCount;
if (
![InvocationCount.HOUR, InvocationCount.DAY, InvocationCount.WEEK].includes(
invocationCount
)
![
RULE_PREVIEW_INVOCATION_COUNT.HOUR,
RULE_PREVIEW_INVOCATION_COUNT.DAY,
RULE_PREVIEW_INVOCATION_COUNT.WEEK,
RULE_PREVIEW_INVOCATION_COUNT.MONTH,
].includes(invocationCount)
) {
return response.ok({ body: { errors: ['Invalid invocation count'] } });
}
if (request.body.type === 'threat_match') {
return response.ok({ body: { errors: ['Preview for rule type not supported'] } });
}
const internalRule = convertCreateAPIToInternalSchema(request.body, siemClient, false);
const previewRuleParams = internalRule.params;
@ -99,12 +106,17 @@ export const previewRulesRoute = async (
await context.lists?.getExceptionListClient().createEndpointList();
const spaceId = siemClient.getSpaceId();
const previewIndex = siemClient.getPreviewIndex();
const previewId = uuid.v4();
const username = security?.authc.getCurrentUser(request)?.username;
const { previewRuleExecutionLogClient, warningsAndErrorsStore } = createWarningsAndErrors();
const runState: Record<string, unknown> = {};
const previewRuleTypeWrapper = createSecurityRuleTypeWrapper({
...securityRuleTypeOptions,
ruleDataClient: previewRuleDataClient,
ruleExecutionLogClientOverride: previewRuleExecutionLogClient,
});
const runExecutors = async <
TParams extends RuleParams,
TState extends AlertTypeState,
@ -134,7 +146,7 @@ export const previewRulesRoute = async (
const startedAt = moment();
const parsedDuration = parseDuration(internalRule.schedule.interval) ?? 0;
startedAt.subtract(moment.duration(parsedDuration * invocationCount));
startedAt.subtract(moment.duration(parsedDuration * (invocationCount - 1)));
let previousStartedAt = null;
@ -175,27 +187,67 @@ export const previewRulesRoute = async (
}
};
const signalRuleAlertType = signalRulesAlertType({
...ruleOptions,
lists: context.lists,
config,
indexNameOverride: previewIndex,
ruleExecutionLogClientOverride: previewRuleExecutionLogClient,
// unused as we override the ruleExecutionLogClient
eventLogService: {} as unknown as IEventLogService,
eventsTelemetry: undefined,
ml: undefined,
refreshOverride: 'wait_for',
});
await runExecutors(
signalRuleAlertType.executor,
signalRuleAlertType.id,
signalRuleAlertType.name,
previewRuleParams,
() => true,
alertInstanceFactoryStub
);
switch (previewRuleParams.type) {
case 'query':
const queryAlertType = previewRuleTypeWrapper(createQueryAlertType(ruleOptions));
await runExecutors(
queryAlertType.executor,
queryAlertType.id,
queryAlertType.name,
previewRuleParams,
() => true,
alertInstanceFactoryStub
);
break;
case 'threshold':
const thresholdAlertType = previewRuleTypeWrapper(
createThresholdAlertType(ruleOptions)
);
await runExecutors(
thresholdAlertType.executor,
thresholdAlertType.id,
thresholdAlertType.name,
previewRuleParams,
() => true,
alertInstanceFactoryStub
);
break;
case 'threat_match':
const threatMatchAlertType = previewRuleTypeWrapper(
createIndicatorMatchAlertType(ruleOptions)
);
await runExecutors(
threatMatchAlertType.executor,
threatMatchAlertType.id,
threatMatchAlertType.name,
previewRuleParams,
() => true,
alertInstanceFactoryStub
);
break;
case 'eql':
const eqlAlertType = previewRuleTypeWrapper(createEqlAlertType(ruleOptions));
await runExecutors(
eqlAlertType.executor,
eqlAlertType.id,
eqlAlertType.name,
previewRuleParams,
() => true,
alertInstanceFactoryStub
);
break;
case 'machine_learning':
const mlAlertType = previewRuleTypeWrapper(createMlAlertType(ruleOptions));
await runExecutors(
mlAlertType.executor,
mlAlertType.id,
mlAlertType.name,
previewRuleParams,
() => true,
alertInstanceFactoryStub
);
break;
}
const errors = warningsAndErrorsStore
.filter((item) => item.newStatus === RuleExecutionStatus.failed)
@ -209,6 +261,14 @@ export const previewRulesRoute = async (
)
.map((item) => item.message);
// Refreshes alias to ensure index is able to be read before returning
await context.core.elasticsearch.client.asInternalUser.indices.refresh(
{
index: previewRuleDataClient.indexNameWithNamespace(spaceId),
},
{ ignore: [404] }
);
return response.ok({
body: {
previewId,

View file

@ -41,7 +41,7 @@ import aadFieldConversion from '../routes/index/signal_aad_mapping.json';
/* eslint-disable complexity */
export const createSecurityRuleTypeWrapper: CreateSecurityRuleTypeWrapper =
({ lists, logger, config, ruleDataClient, eventLogService }) =>
({ lists, logger, config, ruleDataClient, eventLogService, ruleExecutionLogClientOverride }) =>
(type) => {
const { alertIgnoreFields: ignoreFields, alertMergeStrategy: mergeStrategy } = config;
const persistenceRuleType = createPersistenceRuleTypeWrapper({ ruleDataClient, logger });
@ -66,12 +66,14 @@ export const createSecurityRuleTypeWrapper: CreateSecurityRuleTypeWrapper =
const esClient = scopedClusterClient.asCurrentUser;
const ruleStatusClient = new RuleExecutionLogClient({
underlyingClient: config.ruleExecutionLog.underlyingClient,
savedObjectsClient,
eventLogService,
logger,
});
const ruleStatusClient = ruleExecutionLogClientOverride
? ruleExecutionLogClientOverride
: new RuleExecutionLogClient({
underlyingClient: config.ruleExecutionLog.underlyingClient,
savedObjectsClient,
eventLogService,
logger,
});
const completeRule = {
ruleConfig: rule,

View file

@ -9,7 +9,7 @@ import { validateNonExact } from '@kbn/securitysolution-io-ts-utils';
import { EQL_RULE_TYPE_ID } from '@kbn/securitysolution-rules';
import { SERVER_APP_ID } from '../../../../../common/constants';
import { CompleteRule, eqlRuleParams, EqlRuleParams } from '../../schemas/rule_schemas';
import { eqlRuleParams, EqlRuleParams } from '../../schemas/rule_schemas';
import { eqlExecutor } from '../../signals/executors/eql';
import { CreateRuleOptions, SecurityAlertType } from '../types';
@ -67,7 +67,7 @@ export const createEqlAlertType = (
exceptionItems,
experimentalFeatures,
logger,
completeRule: completeRule as CompleteRule<EqlRuleParams>,
completeRule,
searchAfterSize,
services,
tuple,

View file

@ -9,7 +9,7 @@ import { validateNonExact } from '@kbn/securitysolution-io-ts-utils';
import { INDICATOR_RULE_TYPE_ID } from '@kbn/securitysolution-rules';
import { SERVER_APP_ID } from '../../../../../common/constants';
import { CompleteRule, threatRuleParams, ThreatRuleParams } from '../../schemas/rule_schemas';
import { threatRuleParams, ThreatRuleParams } from '../../schemas/rule_schemas';
import { threatMatchExecutor } from '../../signals/executors/threat_match';
import { CreateRuleOptions, SecurityAlertType } from '../types';
@ -71,7 +71,7 @@ export const createIndicatorMatchAlertType = (
eventsTelemetry: undefined,
listClient,
logger,
completeRule: completeRule as CompleteRule<ThreatRuleParams>,
completeRule,
searchAfterSize,
services,
tuple,

View file

@ -9,11 +9,7 @@ import { validateNonExact } from '@kbn/securitysolution-io-ts-utils';
import { ML_RULE_TYPE_ID } from '@kbn/securitysolution-rules';
import { SERVER_APP_ID } from '../../../../../common/constants';
import {
CompleteRule,
machineLearningRuleParams,
MachineLearningRuleParams,
} from '../../schemas/rule_schemas';
import { machineLearningRuleParams, MachineLearningRuleParams } from '../../schemas/rule_schemas';
import { mlExecutor } from '../../signals/executors/ml';
import { CreateRuleOptions, SecurityAlertType } from '../types';
@ -73,7 +69,7 @@ export const createMlAlertType = (
listClient,
logger,
ml,
completeRule: completeRule as CompleteRule<MachineLearningRuleParams>,
completeRule,
services,
tuple,
wrapHits,

View file

@ -9,7 +9,7 @@ import { validateNonExact } from '@kbn/securitysolution-io-ts-utils';
import { QUERY_RULE_TYPE_ID } from '@kbn/securitysolution-rules';
import { SERVER_APP_ID } from '../../../../../common/constants';
import { CompleteRule, queryRuleParams, QueryRuleParams } from '../../schemas/rule_schemas';
import { queryRuleParams, QueryRuleParams } from '../../schemas/rule_schemas';
import { queryExecutor } from '../../signals/executors/query';
import { CreateRuleOptions, SecurityAlertType } from '../types';
@ -71,7 +71,7 @@ export const createQueryAlertType = (
eventsTelemetry: undefined,
listClient,
logger,
completeRule: completeRule as CompleteRule<QueryRuleParams>,
completeRule,
searchAfterSize,
services,
tuple,

View file

@ -9,7 +9,7 @@ import { validateNonExact } from '@kbn/securitysolution-io-ts-utils';
import { THRESHOLD_RULE_TYPE_ID } from '@kbn/securitysolution-rules';
import { SERVER_APP_ID } from '../../../../../common/constants';
import { CompleteRule, thresholdRuleParams, ThresholdRuleParams } from '../../schemas/rule_schemas';
import { thresholdRuleParams, ThresholdRuleParams } from '../../schemas/rule_schemas';
import { thresholdExecutor } from '../../signals/executors/threshold';
import { ThresholdAlertState } from '../../signals/types';
import { CreateRuleOptions, SecurityAlertType } from '../types';
@ -62,7 +62,7 @@ export const createThresholdAlertType = (
exceptionItems,
experimentalFeatures,
logger,
completeRule: completeRule as CompleteRule<ThresholdRuleParams>,
completeRule,
services,
startedAt,
state,

View file

@ -36,6 +36,7 @@ import {
import { ExperimentalFeatures } from '../../../../common/experimental_features';
import { IEventLogService } from '../../../../../event_log/server';
import { AlertsFieldMap, RulesFieldMap } from '../../../../common/field_maps';
import { IRuleExecutionLogClient } from '../rule_execution_log';
export interface SecurityAlertTypeReturnValue<TState extends AlertTypeState> {
bulkCreateTimes: string[];
@ -55,7 +56,7 @@ export interface RunOpts<TParams extends RuleParams> {
bulkCreate: BulkCreate;
exceptionItems: ExceptionListItemSchema[];
listClient: ListClient;
completeRule: CompleteRule<RuleParams>;
completeRule: CompleteRule<TParams>;
searchAfterSize: number;
tuple: {
to: Moment;
@ -89,13 +90,18 @@ export type SecurityAlertType<
) => Promise<SearchAfterAndBulkCreateReturnType & { state: TState }>;
};
export type CreateSecurityRuleTypeWrapper = (options: {
export interface CreateSecurityRuleTypeWrapperProps {
lists: SetupPlugins['lists'];
logger: Logger;
config: ConfigType;
ruleDataClient: IRuleDataClient;
eventLogService: IEventLogService;
}) => <
ruleExecutionLogClientOverride?: IRuleExecutionLogClient;
}
export type CreateSecurityRuleTypeWrapper = (
options: CreateSecurityRuleTypeWrapperProps
) => <
TParams extends RuleParams,
TState extends AlertTypeState,
TInstanceContext extends AlertInstanceContext = {}

View file

@ -63,6 +63,7 @@ import { licenseService } from './lib/license';
import { PolicyWatcher } from './endpoint/lib/policy/license_watch';
import { migrateArtifactsToFleet } from './endpoint/lib/artifacts/migrate_artifacts_to_fleet';
import aadFieldConversion from './lib/detection_engine/routes/index/signal_aad_mapping.json';
import previewPolicy from './lib/detection_engine/routes/index/preview_policy.json';
import { registerEventLogProvider } from './lib/detection_engine/rule_execution_log/event_log_adapter/register_event_log_provider';
import { getKibanaPrivilegesFeaturePrivileges, getCasesKibanaFeature } from './features';
import { EndpointMetadataService } from './endpoint/services/metadata';
@ -172,6 +173,7 @@ export class Plugin implements ISecuritySolutionPlugin {
const { ruleDataService } = plugins.ruleRegistry;
let ruleDataClient: IRuleDataClient | null = null;
let previewRuleDataClient: IRuleDataClient | null = null;
// rule options are used both to create and preview rules.
const ruleOptions: CreateRuleOptions = {
@ -181,51 +183,58 @@ export class Plugin implements ISecuritySolutionPlugin {
version: pluginContext.env.packageInfo.version,
};
if (isRuleRegistryEnabled) {
const aliasesFieldMap: FieldMap = {};
Object.entries(aadFieldConversion).forEach(([key, value]) => {
aliasesFieldMap[key] = {
type: 'alias',
path: value,
};
});
const aliasesFieldMap: FieldMap = {};
Object.entries(aadFieldConversion).forEach(([key, value]) => {
aliasesFieldMap[key] = {
type: 'alias',
path: value,
};
});
ruleDataClient = ruleDataService.initializeIndex({
feature: SERVER_APP_ID,
registrationContext: 'security',
dataset: Dataset.alerts,
componentTemplateRefs: [ECS_COMPONENT_TEMPLATE_NAME],
componentTemplates: [
{
name: 'mappings',
mappings: mappingFromFieldMap(
{ ...technicalRuleFieldMap, ...alertsFieldMap, ...rulesFieldMap, ...aliasesFieldMap },
false
),
},
],
secondaryAlias: config.signalsIndex,
});
const ruleDataServiceOptions = {
feature: SERVER_APP_ID,
registrationContext: 'security',
dataset: Dataset.alerts,
componentTemplateRefs: [ECS_COMPONENT_TEMPLATE_NAME],
componentTemplates: [
{
name: 'mappings',
mappings: mappingFromFieldMap(
{ ...technicalRuleFieldMap, ...alertsFieldMap, ...rulesFieldMap, ...aliasesFieldMap },
false
),
},
],
secondaryAlias: config.signalsIndex,
};
const securityRuleTypeWrapper = createSecurityRuleTypeWrapper({
lists: plugins.lists,
logger: this.logger,
config: this.config,
ruleDataClient,
eventLogService,
});
ruleDataClient = ruleDataService.initializeIndex(ruleDataServiceOptions);
const previewIlmPolicy = previewPolicy.policy;
plugins.alerting.registerType(securityRuleTypeWrapper(createEqlAlertType(ruleOptions)));
plugins.alerting.registerType(
securityRuleTypeWrapper(createSavedQueryAlertType(ruleOptions))
);
plugins.alerting.registerType(
securityRuleTypeWrapper(createIndicatorMatchAlertType(ruleOptions))
);
plugins.alerting.registerType(securityRuleTypeWrapper(createMlAlertType(ruleOptions)));
plugins.alerting.registerType(securityRuleTypeWrapper(createQueryAlertType(ruleOptions)));
plugins.alerting.registerType(securityRuleTypeWrapper(createThresholdAlertType(ruleOptions)));
}
previewRuleDataClient = ruleDataService.initializeIndex({
...ruleDataServiceOptions,
additionalPrefix: '.preview',
ilmPolicy: previewIlmPolicy,
});
const securityRuleTypeOptions = {
lists: plugins.lists,
logger: this.logger,
config: this.config,
ruleDataClient,
eventLogService,
};
const securityRuleTypeWrapper = createSecurityRuleTypeWrapper(securityRuleTypeOptions);
plugins.alerting.registerType(securityRuleTypeWrapper(createEqlAlertType(ruleOptions)));
plugins.alerting.registerType(securityRuleTypeWrapper(createSavedQueryAlertType(ruleOptions)));
plugins.alerting.registerType(
securityRuleTypeWrapper(createIndicatorMatchAlertType(ruleOptions))
);
plugins.alerting.registerType(securityRuleTypeWrapper(createMlAlertType(ruleOptions)));
plugins.alerting.registerType(securityRuleTypeWrapper(createQueryAlertType(ruleOptions)));
plugins.alerting.registerType(securityRuleTypeWrapper(createThresholdAlertType(ruleOptions)));
// TODO We need to get the endpoint routes inside of initRoutes
initRoutes(
@ -239,7 +248,9 @@ export class Plugin implements ISecuritySolutionPlugin {
logger,
ruleDataClient,
ruleOptions,
core.getStartServices
core.getStartServices,
securityRuleTypeOptions,
previewRuleDataClient
);
registerEndpointRoutes(router, endpointContext);
registerLimitedConcurrencyRoutes(core);

View file

@ -61,11 +61,13 @@ import { ConfigType } from '../config';
import { TelemetryEventsSender } from '../lib/telemetry/sender';
import { installPrepackedTimelinesRoute } from '../lib/timeline/routes/prepackaged_timelines/install_prepackaged_timelines';
import { previewRulesRoute } from '../lib/detection_engine/routes/rules/preview_rules_route';
import { CreateRuleOptions } from '../lib/detection_engine/rule_types/types';
import {
CreateRuleOptions,
CreateSecurityRuleTypeWrapperProps,
} from '../lib/detection_engine/rule_types/types';
// eslint-disable-next-line no-restricted-imports
import { legacyCreateLegacyNotificationRoute } from '../lib/detection_engine/routes/rules/legacy_create_legacy_notification';
import { createSourcererDataViewRoute } from '../lib/sourcerer/routes';
import { createPreviewIndexRoute } from '../lib/detection_engine/routes/index/create_preview_index_route';
export const initRoutes = (
router: SecuritySolutionPluginRouter,
@ -78,7 +80,9 @@ export const initRoutes = (
logger: Logger,
ruleDataClient: IRuleDataClient | null,
ruleOptions: CreateRuleOptions,
getStartServices: StartServicesAccessor<StartPlugins>
getStartServices: StartServicesAccessor<StartPlugins>,
securityRuleTypeOptions: CreateSecurityRuleTypeWrapperProps,
previewRuleDataClient: IRuleDataClient
) => {
const isRuleRegistryEnabled = ruleDataClient != null;
// Detection Engine Rule routes that have the REST endpoints of /api/detection_engine/rules
@ -89,7 +93,15 @@ export const initRoutes = (
patchRulesRoute(router, ml, isRuleRegistryEnabled);
deleteRulesRoute(router, isRuleRegistryEnabled);
findRulesRoute(router, logger, isRuleRegistryEnabled);
previewRulesRoute(router, config, ml, security, ruleOptions);
previewRulesRoute(
router,
config,
ml,
security,
ruleOptions,
securityRuleTypeOptions,
previewRuleDataClient
);
// Once we no longer have the legacy notifications system/"side car actions" this should be removed.
legacyCreateLegacyNotificationRoute(router, logger);
@ -140,9 +152,6 @@ export const initRoutes = (
readIndexRoute(router, ruleDataService);
deleteIndexRoute(router);
// Detection Engine Preview Index /api/detection_engine/preview/index
createPreviewIndexRoute(router);
// Detection Engine tags routes that have the REST endpoints of /api/detection_engine/tags
readTagsRoute(router, isRuleRegistryEnabled);

View file

@ -21,6 +21,7 @@ export default ({ loadTestFile }: FtrProviderContext): void => {
loadTestFile(require.resolve('./check_privileges'));
loadTestFile(require.resolve('./create_index'));
loadTestFile(require.resolve('./create_rules'));
loadTestFile(require.resolve('./preview_rules'));
loadTestFile(require.resolve('./create_rules_bulk'));
loadTestFile(require.resolve('./create_ml'));
loadTestFile(require.resolve('./create_threat_matching'));

View file

@ -0,0 +1,94 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import expect from '@kbn/expect';
import { DETECTION_ENGINE_RULES_PREVIEW } from '../../../../plugins/security_solution/common/constants';
import { FtrProviderContext } from '../../common/ftr_provider_context';
import { deleteAllAlerts, getSimplePreviewRule, getSimpleRulePreviewOutput } from '../../utils';
import { ROLES } from '../../../../plugins/security_solution/common/test';
import { createUserAndRole, deleteUserAndRole } from '../../../common/services/security_solution';
// eslint-disable-next-line import/no-default-export
export default ({ getService }: FtrProviderContext) => {
const esArchiver = getService('esArchiver');
const supertest = getService('supertest');
const supertestWithoutAuth = getService('supertestWithoutAuth');
const log = getService('log');
describe('create_rules', () => {
describe('creating rules', () => {
before(async () => {
await esArchiver.load('x-pack/test/functional/es_archives/auditbeat/hosts');
});
after(async () => {
await esArchiver.unload('x-pack/test/functional/es_archives/auditbeat/hosts');
});
afterEach(async () => {
await deleteAllAlerts(supertest, log);
});
describe('elastic admin preview', () => {
it('should create a single preview rule', async () => {
const { body } = await supertest
.post(DETECTION_ENGINE_RULES_PREVIEW)
.set('kbn-xsrf', 'true')
.send(getSimplePreviewRule())
.expect(200);
expect(body).to.eql(getSimpleRulePreviewOutput(body.previewId));
});
it("shouldn't cause a 409 conflict if we attempt to create the same rule_id twice", async () => {
await supertest
.post(DETECTION_ENGINE_RULES_PREVIEW)
.set('kbn-xsrf', 'true')
.send(getSimplePreviewRule())
.expect(200);
await supertest
.post(DETECTION_ENGINE_RULES_PREVIEW)
.set('kbn-xsrf', 'true')
.send(getSimplePreviewRule())
.expect(200);
});
it('should throw an error if an invalid invocation count is used', async () => {
const { body } = await supertest
.post(DETECTION_ENGINE_RULES_PREVIEW)
.set('kbn-xsrf', 'true')
.send(getSimplePreviewRule('', 3))
.expect(200);
const { errors } = getSimpleRulePreviewOutput(undefined, ['Invalid invocation count']);
expect(body).to.eql({ errors });
});
});
describe('t1_analyst', () => {
const role = ROLES.t1_analyst;
beforeEach(async () => {
await createUserAndRole(getService, role);
});
afterEach(async () => {
await deleteUserAndRole(getService, role);
});
it('should NOT be able to preview a rule', async () => {
await supertestWithoutAuth
.post(DETECTION_ENGINE_RULES_PREVIEW)
.auth(role, 'changeme')
.set('kbn-xsrf', 'true')
.send(getSimplePreviewRule())
.expect(403);
});
});
});
});
};

View file

@ -33,6 +33,7 @@ import {
QueryCreateSchema,
EqlCreateSchema,
ThresholdCreateSchema,
PreviewRulesSchema,
} from '../../plugins/security_solution/common/detection_engine/schemas/request';
import { signalsMigrationType } from '../../plugins/security_solution/server/lib/detection_engine/migrations/saved_objects';
import {
@ -110,6 +111,27 @@ export const getSimpleRule = (ruleId = 'rule-1', enabled = false): QueryCreateSc
query: 'user.name: root or user.name: admin',
});
/**
* This is a typical simple preview rule for testing that is easy for most basic testing
* @param ruleId
* @param enabled The number of times the rule will be run through the executors. Defaulted to 20,
* the execution time for the default interval time of 5m.
*/
export const getSimplePreviewRule = (
ruleId = 'preview-rule-1',
invocationCount = 20
): PreviewRulesSchema => ({
name: 'Simple Rule Query',
description: 'Simple Rule Query',
risk_score: 1,
rule_id: ruleId,
severity: 'high',
index: ['auditbeat-*'],
type: 'query',
query: 'user.name: root or user.name: admin',
invocationCount,
});
/**
* This is a typical signal testing rule that is easy for most basic testing of output of signals.
* It starts out in an enabled true state. The from is set very far back to test the basics of signal
@ -375,6 +397,24 @@ export const getSimpleRuleOutput = (ruleId = 'rule-1', enabled = false): Partial
version: 1,
});
/**
* This is the typical output of a simple rule preview, with errors and warnings coming up from the rule
* execution process and a `previewId` generated server side for later preview querying
*
* @param previewId Rule id generated by the server itself
* @param errors Errors returned by executor and route file, defaults to empty array
* @param warnings Warnings returned by executor and route file, defaults to empty array
*/
export const getSimpleRulePreviewOutput = (
previewId = undefined,
errors: string[] = [],
warnings: string[] = []
) => ({
errors,
warnings,
previewId,
});
export const resolveSimpleRuleOutput = (
ruleId = 'rule-1',
enabled = false