mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 17:28:26 -04:00
[Security Solution] Investigation guide - insights in markdown (#145240)
## Summary This pr adds a new parsing plugin to the EuiMarkdownEditor used in security solution that enables users to create run time queries that can be parameterized from alert data, or hard coded literal values. A count of the matching events is displayed in a button that when clicked will open the same event set in timeline. Markdown is expected to be in the following format: `!{insight{"description":"2 top level OR providers, 1 nested AND","label":"test insight", "providers": [[{ "field": "event.id", "value": "kibana.alert.original_event.id", "type": "parameter" }], [{ "field": "event.category", "value": "network", "type": "literal" }, {"field": "process.pid", "value": "process.pid", "type": "parameter"}]]}}` The 2d array is used to allow nested queries, the top level arrays are OR'ed together, and the inner array AND'ed together: <img width="438" alt="image" src="https://user-images.githubusercontent.com/56408403/201940553-96ab3d39-48fa-404f-ab2e-8946b532567b.png"> Following a prefix of !insight, the configuration object takes optional description and label strings, along with a 2 dimensional array called "providers". This value corresponds to what are called data providers in the timeline view,  and are arrays of filters with 3 fields, "field" which is the field name for that part of the query clause, "value" which is the value to be used, and "type" which is either "parameter" or "literal". Filters of type parameter expect value to be the name of a field present in an alert document, and will use the value in the underlying document if found. If the field is not present for some reason, a wildcard is used. If the markdown is rendered in a context not tied to a specific alert, parameter fields are treated as a timeline template field. <img width="632" alt="image" src="https://user-images.githubusercontent.com/56408403/201940922-7114a75f-0430-4397-8384-59f4e960ec9c.png"> ### Checklist Delete any items that are not applicable to this PR. - [x] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md) - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios - [x] Any UI touched in this PR is usable by keyboard only (learn more about [keyboard accessibility](https://webaim.org/techniques/keyboard/))
This commit is contained in:
parent
58e6d9441a
commit
072c70dc99
18 changed files with 599 additions and 40 deletions
|
@ -13,6 +13,7 @@ import {
|
|||
NOTES_LINK,
|
||||
NOTES_TEXT,
|
||||
NOTES_TEXT_AREA,
|
||||
MARKDOWN_INVESTIGATE_BUTTON,
|
||||
} from '../../screens/timeline';
|
||||
import { createTimeline } from '../../tasks/api_calls/timelines';
|
||||
|
||||
|
@ -84,4 +85,11 @@ describe('Timeline notes tab', () => {
|
|||
cy.get(NOTES_LINK).last().should('have.text', `${text}(opens in a new tab or window)`);
|
||||
cy.get(NOTES_LINK).last().click();
|
||||
});
|
||||
|
||||
it('should render insight query from markdown', () => {
|
||||
addNotesToTimeline(
|
||||
`!{insight{"description":"2 top level OR providers, 1 nested AND","label":"test insight", "providers": [[{ "field": "event.id", "value": "kibana.alert.original_event.id", "type": "parameter" }], [{ "field": "event.category", "value": "network", "type": "literal" }, {"field": "process.pid", "value": "process.pid", "type": "parameter"}]]}}`
|
||||
);
|
||||
cy.get(MARKDOWN_INVESTIGATE_BUTTON).should('exist');
|
||||
});
|
||||
});
|
||||
|
|
|
@ -90,6 +90,9 @@ export const NOTES_AUTHOR = '.euiCommentEvent__headerUsername';
|
|||
|
||||
export const NOTES_LINK = '[data-test-subj="markdown-link"]';
|
||||
|
||||
export const MARKDOWN_INVESTIGATE_BUTTON =
|
||||
'[data-test-subj="insight-investigate-in-timeline-button"]';
|
||||
|
||||
export const OPEN_TIMELINE_ICON = '[data-test-subj="open-timeline-button"]';
|
||||
|
||||
export const OPEN_TIMELINE_MODAL = '[data-test-subj="open-timeline-modal"]';
|
||||
|
|
|
@ -161,7 +161,11 @@ export const addNotesToTimeline = (notes: string) => {
|
|||
.then(($el) => {
|
||||
const notesCount = parseInt($el.text(), 10);
|
||||
|
||||
cy.get(NOTES_TEXT_AREA).type(notes);
|
||||
cy.get(NOTES_TEXT_AREA).type(notes, {
|
||||
parseSpecialCharSequences: false,
|
||||
delay: 0,
|
||||
force: true,
|
||||
});
|
||||
cy.get(ADD_NOTE_BUTTON).trigger('click');
|
||||
cy.get(`${NOTES_TAB_BUTTON} .euiBadge`).should('have.text', `${notesCount + 1}`);
|
||||
});
|
||||
|
|
|
@ -5,13 +5,14 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import React, { useMemo, useCallback } from 'react';
|
||||
import { EuiButton, EuiButtonEmpty } from '@elastic/eui';
|
||||
import type { Filter } from '@kbn/es-query';
|
||||
import { useDispatch } from 'react-redux';
|
||||
|
||||
import { sourcererSelectors } from '../../../store';
|
||||
import { InputsModelId } from '../../../store/inputs/constants';
|
||||
import type { TimeRange } from '../../../store/inputs/model';
|
||||
import { inputsActions } from '../../../store/inputs';
|
||||
import { updateProviders, setFilters } from '../../../../timelines/store/timeline/actions';
|
||||
import { sourcererActions } from '../../../store/actions';
|
||||
|
@ -26,7 +27,10 @@ export const InvestigateInTimelineButton: React.FunctionComponent<{
|
|||
asEmptyButton: boolean;
|
||||
dataProviders: DataProvider[] | null;
|
||||
filters?: Filter[] | null;
|
||||
}> = ({ asEmptyButton, children, dataProviders, filters, ...rest }) => {
|
||||
timeRange?: TimeRange;
|
||||
keepDataView?: boolean;
|
||||
isDisabled?: boolean;
|
||||
}> = ({ asEmptyButton, children, dataProviders, filters, timeRange, keepDataView, ...rest }) => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const getDataViewsSelector = useMemo(
|
||||
|
@ -37,15 +41,24 @@ export const InvestigateInTimelineButton: React.FunctionComponent<{
|
|||
getDataViewsSelector(state)
|
||||
);
|
||||
|
||||
const hasTemplateProviders =
|
||||
dataProviders && dataProviders.find((provider) => provider.type === 'template');
|
||||
|
||||
const clearTimeline = useCreateTimeline({
|
||||
timelineId: TimelineId.active,
|
||||
timelineType: TimelineType.default,
|
||||
timelineType: hasTemplateProviders ? TimelineType.template : TimelineType.default,
|
||||
});
|
||||
|
||||
const configureAndOpenTimeline = React.useCallback(() => {
|
||||
const configureAndOpenTimeline = useCallback(() => {
|
||||
if (dataProviders || filters) {
|
||||
// Reset the current timeline
|
||||
clearTimeline();
|
||||
if (timeRange) {
|
||||
clearTimeline({
|
||||
timeRange,
|
||||
});
|
||||
} else {
|
||||
clearTimeline();
|
||||
}
|
||||
if (dataProviders) {
|
||||
// Update the timeline's providers to match the current prevalence field query
|
||||
dispatch(
|
||||
|
@ -66,17 +79,28 @@ export const InvestigateInTimelineButton: React.FunctionComponent<{
|
|||
}
|
||||
// Only show detection alerts
|
||||
// (This is required so the timeline event count matches the prevalence count)
|
||||
dispatch(
|
||||
sourcererActions.setSelectedDataView({
|
||||
id: SourcererScopeName.timeline,
|
||||
selectedDataViewId: defaultDataView.id,
|
||||
selectedPatterns: [signalIndexName || ''],
|
||||
})
|
||||
);
|
||||
if (!keepDataView) {
|
||||
dispatch(
|
||||
sourcererActions.setSelectedDataView({
|
||||
id: SourcererScopeName.timeline,
|
||||
selectedDataViewId: defaultDataView.id,
|
||||
selectedPatterns: [signalIndexName || ''],
|
||||
})
|
||||
);
|
||||
}
|
||||
// Unlock the time range from the global time range
|
||||
dispatch(inputsActions.removeLinkTo([InputsModelId.timeline, InputsModelId.global]));
|
||||
}
|
||||
}, [dataProviders, clearTimeline, dispatch, defaultDataView.id, signalIndexName, filters]);
|
||||
}, [
|
||||
dataProviders,
|
||||
clearTimeline,
|
||||
dispatch,
|
||||
defaultDataView.id,
|
||||
signalIndexName,
|
||||
filters,
|
||||
timeRange,
|
||||
keepDataView,
|
||||
]);
|
||||
|
||||
return asEmptyButton ? (
|
||||
<EuiButtonEmpty
|
||||
|
|
|
@ -13,6 +13,7 @@ import {
|
|||
|
||||
import * as timelineMarkdownPlugin from './timeline';
|
||||
import * as osqueryMarkdownPlugin from './osquery';
|
||||
import * as insightMarkdownPlugin from './insight';
|
||||
|
||||
export const { uiPlugins, parsingPlugins, processingPlugins } = {
|
||||
uiPlugins: getDefaultEuiMarkdownUiPlugins(),
|
||||
|
@ -23,9 +24,11 @@ export const { uiPlugins, parsingPlugins, processingPlugins } = {
|
|||
uiPlugins.push(timelineMarkdownPlugin.plugin);
|
||||
uiPlugins.push(osqueryMarkdownPlugin.plugin);
|
||||
|
||||
parsingPlugins.push(insightMarkdownPlugin.parser);
|
||||
parsingPlugins.push(timelineMarkdownPlugin.parser);
|
||||
parsingPlugins.push(osqueryMarkdownPlugin.parser);
|
||||
|
||||
// This line of code is TS-compatible and it will break if [1][1] change in the future.
|
||||
processingPlugins[1][1].components.insight = insightMarkdownPlugin.renderer;
|
||||
processingPlugins[1][1].components.timeline = timelineMarkdownPlugin.renderer;
|
||||
processingPlugins[1][1].components.osquery = osqueryMarkdownPlugin.renderer;
|
||||
|
|
|
@ -0,0 +1,146 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import type { Plugin } from 'unified';
|
||||
import React, { useContext, useMemo } from 'react';
|
||||
import type { RemarkTokenizer } from '@elastic/eui';
|
||||
import { EuiLoadingSpinner, EuiIcon } from '@elastic/eui';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { useAppToasts } from '../../../../hooks/use_app_toasts';
|
||||
import { useInsightQuery } from './use_insight_query';
|
||||
import { useInsightDataProviders } from './use_insight_data_providers';
|
||||
import { BasicAlertDataContext } from '../../../event_details/investigation_guide_view';
|
||||
import { InvestigateInTimelineButton } from '../../../event_details/table/investigate_in_timeline_button';
|
||||
import type { AbsoluteTimeRange } from '../../../../store/inputs/model';
|
||||
|
||||
interface InsightComponentProps {
|
||||
label?: string;
|
||||
description?: string;
|
||||
providers?: string;
|
||||
}
|
||||
|
||||
export const parser: Plugin = function () {
|
||||
const Parser = this.Parser;
|
||||
const tokenizers = Parser.prototype.inlineTokenizers;
|
||||
const methods = Parser.prototype.inlineMethods;
|
||||
const insightPrefix = '!{insight';
|
||||
|
||||
const tokenizeInsight: RemarkTokenizer = function (eat, value, silent) {
|
||||
if (value.startsWith(insightPrefix) === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const nextChar = value[insightPrefix.length];
|
||||
if (nextChar !== '{' && nextChar !== '}') return false;
|
||||
if (silent) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// is there a configuration?
|
||||
const hasConfiguration = nextChar === '{';
|
||||
|
||||
let configuration: InsightComponentProps = {};
|
||||
if (hasConfiguration) {
|
||||
let configurationString = '';
|
||||
let openObjects = 0;
|
||||
|
||||
for (let i = insightPrefix.length; i < value.length; i++) {
|
||||
const char = value[i];
|
||||
if (char === '{') {
|
||||
openObjects++;
|
||||
configurationString += char;
|
||||
} else if (char === '}') {
|
||||
openObjects--;
|
||||
if (openObjects === -1) {
|
||||
break;
|
||||
}
|
||||
configurationString += char;
|
||||
} else {
|
||||
configurationString += char;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
configuration = JSON.parse(configurationString);
|
||||
return eat(value)({
|
||||
type: 'insight',
|
||||
...configuration,
|
||||
providers: JSON.stringify(configuration.providers),
|
||||
});
|
||||
} catch (err) {
|
||||
const now = eat.now();
|
||||
this.file.fail(
|
||||
i18n.translate('xpack.securitySolution.markdownEditor.plugins.insightConfigError', {
|
||||
values: { err },
|
||||
defaultMessage: 'Unable to parse insight JSON configuration: {err}',
|
||||
}),
|
||||
{
|
||||
line: now.line,
|
||||
column: now.column + insightPrefix.length,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
tokenizeInsight.locator = (value: string, fromIndex: number) => {
|
||||
return value.indexOf(insightPrefix, fromIndex);
|
||||
};
|
||||
tokenizers.insight = tokenizeInsight;
|
||||
methods.splice(methods.indexOf('text'), 0, 'insight');
|
||||
};
|
||||
|
||||
// receives the configuration from the parser and renders
|
||||
const InsightComponent = ({ label, description, providers }: InsightComponentProps) => {
|
||||
const { addError } = useAppToasts();
|
||||
let parsedProviders = [];
|
||||
try {
|
||||
if (providers !== undefined) {
|
||||
parsedProviders = JSON.parse(providers);
|
||||
}
|
||||
} catch (err) {
|
||||
addError(err, {
|
||||
title: i18n.translate('xpack.securitySolution.markdownEditor.plugins.insightProviderError', {
|
||||
defaultMessage: 'Unable to parse insight provider configuration',
|
||||
}),
|
||||
});
|
||||
}
|
||||
const { data: alertData } = useContext(BasicAlertDataContext);
|
||||
const dataProviders = useInsightDataProviders({
|
||||
providers: parsedProviders,
|
||||
alertData,
|
||||
});
|
||||
const { totalCount, isQueryLoading, oldestTimestamp, hasError } = useInsightQuery({
|
||||
dataProviders,
|
||||
});
|
||||
const timerange: AbsoluteTimeRange = useMemo(() => {
|
||||
return {
|
||||
kind: 'absolute',
|
||||
from: oldestTimestamp ?? '',
|
||||
to: new Date().toISOString(),
|
||||
};
|
||||
}, [oldestTimestamp]);
|
||||
if (isQueryLoading) {
|
||||
return <EuiLoadingSpinner size="l" />;
|
||||
} else {
|
||||
return (
|
||||
<InvestigateInTimelineButton
|
||||
asEmptyButton={false}
|
||||
isDisabled={hasError}
|
||||
dataProviders={dataProviders}
|
||||
timeRange={timerange}
|
||||
keepDataView={true}
|
||||
data-test-subj="insight-investigate-in-timeline-button"
|
||||
>
|
||||
<EuiIcon type="timeline" />
|
||||
{` ${label} (${totalCount}) - ${description}`}
|
||||
</InvestigateInTimelineButton>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export { InsightComponent as renderer };
|
|
@ -0,0 +1,132 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
import { renderHook } from '@testing-library/react-hooks';
|
||||
import type { DataProvider } from '@kbn/timelines-plugin/common';
|
||||
import type { UseInsightDataProvidersProps, Provider } from './use_insight_data_providers';
|
||||
import type { TimelineEventsDetailsItem } from '../../../../../../common/search_strategy';
|
||||
import { useInsightDataProviders } from './use_insight_data_providers';
|
||||
import { mockAlertDetailsData } from '../../../event_details/__mocks__';
|
||||
|
||||
const mockAlertDetailsDataWithIsObject = mockAlertDetailsData.map((detail) => {
|
||||
return {
|
||||
...detail,
|
||||
isObjectArray: false,
|
||||
};
|
||||
}) as TimelineEventsDetailsItem[];
|
||||
|
||||
const nestedAndProvider = [
|
||||
[
|
||||
{
|
||||
field: 'event.id',
|
||||
value: 'kibana.alert.rule.uuid',
|
||||
type: 'parameter',
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
field: 'event.category',
|
||||
value: 'network',
|
||||
type: 'literal',
|
||||
},
|
||||
{
|
||||
field: 'process.pid',
|
||||
value: 'process.pid',
|
||||
type: 'parameter',
|
||||
},
|
||||
],
|
||||
] as Provider[][];
|
||||
|
||||
const topLevelOnly = [
|
||||
[
|
||||
{
|
||||
field: 'event.id',
|
||||
value: 'kibana.alert.rule.uuid',
|
||||
type: 'parameter',
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
field: 'event.category',
|
||||
value: 'network',
|
||||
type: 'literal',
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
field: 'process.pid',
|
||||
value: 'process.pid',
|
||||
type: 'parameter',
|
||||
},
|
||||
],
|
||||
] as Provider[][];
|
||||
|
||||
const nonExistantField = [
|
||||
[
|
||||
{
|
||||
field: 'event.id',
|
||||
value: 'kibana.alert.rule.parameters.threshold.field',
|
||||
type: 'parameter',
|
||||
},
|
||||
],
|
||||
] as Provider[][];
|
||||
|
||||
describe('useInsightDataProviders', () => {
|
||||
it('should return 2 data providers, 1 with a nested provider ANDed to it', () => {
|
||||
const { result } = renderHook<UseInsightDataProvidersProps, DataProvider[]>(() =>
|
||||
useInsightDataProviders({
|
||||
providers: nestedAndProvider,
|
||||
alertData: mockAlertDetailsDataWithIsObject,
|
||||
})
|
||||
);
|
||||
const providers = result.current;
|
||||
const providersWithNonEmptyAnd = providers.filter((provider) => provider.and.length > 0);
|
||||
expect(providers.length).toBe(2);
|
||||
expect(providersWithNonEmptyAnd.length).toBe(1);
|
||||
});
|
||||
|
||||
it('should return 3 data providers without any containing nested ANDs', () => {
|
||||
const { result } = renderHook<UseInsightDataProvidersProps, DataProvider[]>(() =>
|
||||
useInsightDataProviders({
|
||||
providers: topLevelOnly,
|
||||
alertData: mockAlertDetailsDataWithIsObject,
|
||||
})
|
||||
);
|
||||
const providers = result.current;
|
||||
const providersWithNonEmptyAnd = providers.filter((provider) => provider.and.length > 0);
|
||||
expect(providers.length).toBe(3);
|
||||
expect(providersWithNonEmptyAnd.length).toBe(0);
|
||||
});
|
||||
|
||||
it('should use a wildcard for a field not present in an alert', () => {
|
||||
const { result } = renderHook<UseInsightDataProvidersProps, DataProvider[]>(() =>
|
||||
useInsightDataProviders({
|
||||
providers: nonExistantField,
|
||||
alertData: mockAlertDetailsDataWithIsObject,
|
||||
})
|
||||
);
|
||||
const providers = result.current;
|
||||
const {
|
||||
queryMatch: { value },
|
||||
} = providers[0];
|
||||
expect(providers.length).toBe(1);
|
||||
expect(value).toBe('*');
|
||||
});
|
||||
|
||||
it('should use template data providers when called without alertData', () => {
|
||||
const { result } = renderHook<UseInsightDataProvidersProps, DataProvider[]>(() =>
|
||||
useInsightDataProviders({
|
||||
providers: nestedAndProvider,
|
||||
})
|
||||
);
|
||||
const providers = result.current;
|
||||
const [first, second] = providers;
|
||||
const [nestedSecond] = second.and;
|
||||
expect(second.type).toBe('default');
|
||||
expect(first.type).toBe('template');
|
||||
expect(nestedSecond.type).toBe('template');
|
||||
});
|
||||
});
|
|
@ -0,0 +1,114 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import type { QueryOperator, DataProvider } from '@kbn/timelines-plugin/common';
|
||||
import { DataProviderType } from '@kbn/timelines-plugin/common';
|
||||
import { IS_OPERATOR } from '../../../../../timelines/components/timeline/data_providers/data_provider';
|
||||
import type { TimelineEventsDetailsItem } from '../../../../../../common/search_strategy';
|
||||
|
||||
export interface Provider {
|
||||
field: string;
|
||||
value: string;
|
||||
type: 'parameter' | 'value';
|
||||
}
|
||||
export interface UseInsightDataProvidersProps {
|
||||
providers: Provider[][];
|
||||
alertData?: TimelineEventsDetailsItem[] | null;
|
||||
}
|
||||
|
||||
export const useInsightDataProviders = ({
|
||||
providers,
|
||||
alertData,
|
||||
}: UseInsightDataProvidersProps): DataProvider[] => {
|
||||
function getFieldValue(fields: TimelineEventsDetailsItem[], fieldToFind: string) {
|
||||
const alertField = fields.find((dataField) => dataField.field === fieldToFind);
|
||||
return alertField?.values ? alertField.values[0] : '*';
|
||||
}
|
||||
const dataProviders: DataProvider[] = useMemo(() => {
|
||||
if (alertData) {
|
||||
return providers.map((innerProvider) => {
|
||||
return innerProvider.reduce((prev, next, index): DataProvider => {
|
||||
const { field, value, type } = next;
|
||||
if (index === 0) {
|
||||
return {
|
||||
and: [],
|
||||
enabled: true,
|
||||
id: JSON.stringify(field + value + type),
|
||||
name: field,
|
||||
excluded: false,
|
||||
kqlQuery: '',
|
||||
type: DataProviderType.default,
|
||||
queryMatch: {
|
||||
field,
|
||||
value: type === 'parameter' ? getFieldValue(alertData, value) : value,
|
||||
operator: IS_OPERATOR as QueryOperator,
|
||||
},
|
||||
};
|
||||
} else {
|
||||
const newProvider = {
|
||||
and: [],
|
||||
enabled: true,
|
||||
id: JSON.stringify(field + value + type),
|
||||
name: field,
|
||||
excluded: false,
|
||||
kqlQuery: '',
|
||||
type: DataProviderType.default,
|
||||
queryMatch: {
|
||||
field,
|
||||
value: type === 'parameter' ? getFieldValue(alertData, value) : value,
|
||||
operator: IS_OPERATOR as QueryOperator,
|
||||
},
|
||||
};
|
||||
prev.and.push(newProvider);
|
||||
}
|
||||
return prev;
|
||||
}, {} as DataProvider);
|
||||
});
|
||||
} else {
|
||||
return providers.map((innerProvider) => {
|
||||
return innerProvider.reduce((prev, next, index) => {
|
||||
const { field, value, type } = next;
|
||||
if (index === 0) {
|
||||
return {
|
||||
and: [],
|
||||
enabled: true,
|
||||
id: JSON.stringify(field + value + type),
|
||||
name: field,
|
||||
excluded: false,
|
||||
kqlQuery: '',
|
||||
type: type === 'parameter' ? DataProviderType.template : DataProviderType.default,
|
||||
queryMatch: {
|
||||
field,
|
||||
value: type === 'parameter' ? `{${value}}` : value,
|
||||
operator: IS_OPERATOR as QueryOperator,
|
||||
},
|
||||
};
|
||||
} else {
|
||||
const newProvider = {
|
||||
and: [],
|
||||
enabled: true,
|
||||
id: JSON.stringify(field + value + type),
|
||||
name: field,
|
||||
excluded: false,
|
||||
kqlQuery: '',
|
||||
type: type === 'parameter' ? DataProviderType.template : DataProviderType.default,
|
||||
queryMatch: {
|
||||
field,
|
||||
value: type === 'parameter' ? `{${value}}` : value,
|
||||
operator: IS_OPERATOR as QueryOperator,
|
||||
},
|
||||
};
|
||||
prev.and.push(newProvider);
|
||||
}
|
||||
return prev;
|
||||
}, {} as DataProvider);
|
||||
});
|
||||
}
|
||||
}, [alertData, providers]);
|
||||
return dataProviders;
|
||||
};
|
|
@ -0,0 +1,46 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
import { renderHook } from '@testing-library/react-hooks';
|
||||
import type { QueryOperator } from '@kbn/timelines-plugin/common';
|
||||
import { DataProviderType } from '@kbn/timelines-plugin/common';
|
||||
import { useInsightQuery } from './use_insight_query';
|
||||
import { TestProviders } from '../../../../mock';
|
||||
import type { UseInsightQuery, UseInsightQueryResult } from './use_insight_query';
|
||||
import { IS_OPERATOR } from '../../../../../timelines/components/timeline/data_providers/data_provider';
|
||||
|
||||
const mockProvider = {
|
||||
and: [],
|
||||
enabled: true,
|
||||
id: 'made-up-id',
|
||||
name: 'test',
|
||||
excluded: false,
|
||||
kqlQuery: '',
|
||||
type: DataProviderType.default,
|
||||
queryMatch: {
|
||||
field: 'event.id',
|
||||
value: '*',
|
||||
operator: IS_OPERATOR as QueryOperator,
|
||||
},
|
||||
};
|
||||
|
||||
describe('useInsightQuery', () => {
|
||||
it('should return renderable defaults', () => {
|
||||
const { result } = renderHook<UseInsightQuery, UseInsightQueryResult>(
|
||||
() =>
|
||||
useInsightQuery({
|
||||
dataProviders: [mockProvider],
|
||||
}),
|
||||
{
|
||||
wrapper: TestProviders,
|
||||
}
|
||||
);
|
||||
const { isQueryLoading, totalCount, oldestTimestamp } = result.current;
|
||||
expect(isQueryLoading).toBeFalsy();
|
||||
expect(totalCount).toBe(-1);
|
||||
expect(oldestTimestamp).toBe(undefined);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,79 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import { getEsQueryConfig } from '@kbn/data-plugin/common';
|
||||
import type { DataProvider } from '@kbn/timelines-plugin/common';
|
||||
import { TimelineId } from '../../../../../../common/types/timeline';
|
||||
import { useKibana } from '../../../../lib/kibana';
|
||||
import { combineQueries } from '../../../../lib/kuery';
|
||||
import { useTimelineEvents } from '../../../../../timelines/containers';
|
||||
import { useSourcererDataView } from '../../../../containers/sourcerer';
|
||||
import { SourcererScopeName } from '../../../../store/sourcerer/model';
|
||||
|
||||
export interface UseInsightQuery {
|
||||
dataProviders: DataProvider[];
|
||||
}
|
||||
|
||||
export interface UseInsightQueryResult {
|
||||
isQueryLoading: boolean;
|
||||
totalCount: number;
|
||||
oldestTimestamp: string | null | undefined;
|
||||
hasError: boolean;
|
||||
}
|
||||
|
||||
export const useInsightQuery = ({ dataProviders }: UseInsightQuery): UseInsightQueryResult => {
|
||||
const { uiSettings } = useKibana().services;
|
||||
const esQueryConfig = useMemo(() => getEsQueryConfig(uiSettings), [uiSettings]);
|
||||
const { browserFields, selectedPatterns, indexPattern, dataViewId } = useSourcererDataView(
|
||||
SourcererScopeName.timeline
|
||||
);
|
||||
const [hasError, setHasError] = useState(false);
|
||||
const combinedQueries = useMemo(() => {
|
||||
try {
|
||||
if (hasError === false) {
|
||||
const parsedCombinedQueries = combineQueries({
|
||||
config: esQueryConfig,
|
||||
dataProviders,
|
||||
indexPattern,
|
||||
browserFields,
|
||||
filters: [],
|
||||
kqlQuery: {
|
||||
query: '',
|
||||
language: 'kuery',
|
||||
},
|
||||
kqlMode: 'filter',
|
||||
});
|
||||
return parsedCombinedQueries;
|
||||
}
|
||||
} catch (err) {
|
||||
setHasError(true);
|
||||
return null;
|
||||
}
|
||||
}, [browserFields, dataProviders, esQueryConfig, hasError, indexPattern]);
|
||||
|
||||
const [isQueryLoading, { events, totalCount }] = useTimelineEvents({
|
||||
dataViewId,
|
||||
fields: ['*'],
|
||||
filterQuery: combinedQueries?.filterQuery,
|
||||
id: TimelineId.active,
|
||||
indexNames: selectedPatterns,
|
||||
language: 'kuery',
|
||||
limit: 1,
|
||||
runtimeMappings: {},
|
||||
});
|
||||
const [oldestEvent] = events;
|
||||
const timestamp =
|
||||
oldestEvent && oldestEvent.data && oldestEvent.data.find((d) => d.field === '@timestamp');
|
||||
const oldestTimestamp = timestamp && timestamp.value && timestamp.value[0];
|
||||
return {
|
||||
isQueryLoading,
|
||||
totalCount,
|
||||
oldestTimestamp,
|
||||
hasError,
|
||||
};
|
||||
};
|
|
@ -258,6 +258,7 @@ const RunOsqueryButtonRenderer = ({
|
|||
label?: string;
|
||||
query: string;
|
||||
ecs_mapping: { [key: string]: {} };
|
||||
test: [];
|
||||
};
|
||||
}) => {
|
||||
const [showFlyout, setShowFlyout] = useState(false);
|
||||
|
|
|
@ -24,7 +24,6 @@ const MarkdownRendererComponent: React.FC<Props> = ({ children, disableLinks })
|
|||
() => (props) => <MarkdownLink {...props} disableLinks={disableLinks} />,
|
||||
[disableLinks]
|
||||
);
|
||||
|
||||
// Deep clone of the processing plugins to prevent affecting the markdown editor.
|
||||
const processingPluginList = cloneDeep(processingPlugins);
|
||||
// This line of code is TS-compatible and it will break if [1][1] change in the future.
|
||||
|
|
|
@ -19,6 +19,7 @@ export interface GetBasicDataFromDetailsData {
|
|||
userName: string;
|
||||
ruleName: string;
|
||||
timestamp: string;
|
||||
data: TimelineEventsDetailsItem[] | null;
|
||||
}
|
||||
|
||||
export const useBasicDataFromDetailsData = (
|
||||
|
@ -62,8 +63,9 @@ export const useBasicDataFromDetailsData = (
|
|||
userName,
|
||||
ruleName,
|
||||
timestamp,
|
||||
data,
|
||||
}),
|
||||
[agentId, alertId, hostName, isAlert, ruleName, timestamp, userName]
|
||||
[agentId, alertId, hostName, isAlert, ruleName, timestamp, userName, data]
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -81,7 +81,7 @@ type TimelineResponse<T extends KueryFilterQueryKind> = T extends 'kuery'
|
|||
|
||||
export interface UseTimelineEventsProps {
|
||||
dataViewId: string | null;
|
||||
endDate: string;
|
||||
endDate?: string;
|
||||
eqlOptions?: EqlOptionsSelected;
|
||||
fields: string[];
|
||||
filterQuery?: ESQuery | string;
|
||||
|
@ -92,7 +92,7 @@ export interface UseTimelineEventsProps {
|
|||
runtimeMappings: MappingRuntimeFields;
|
||||
skip?: boolean;
|
||||
sort?: TimelineRequestSortField[];
|
||||
startDate: string;
|
||||
startDate?: string;
|
||||
timerangeKind?: 'absolute' | 'relative';
|
||||
}
|
||||
|
||||
|
@ -360,17 +360,17 @@ export const useTimelineEventsHandler = ({
|
|||
...deStructureEqlOptions(prevEqlRequest),
|
||||
};
|
||||
|
||||
const timerange =
|
||||
startDate && endDate
|
||||
? { timerange: { interval: '12h', from: startDate, to: endDate } }
|
||||
: {};
|
||||
const currentSearchParameters = {
|
||||
defaultIndex: indexNames,
|
||||
filterQuery: createFilter(filterQuery),
|
||||
querySize: limit,
|
||||
sort,
|
||||
timerange: {
|
||||
interval: '12h',
|
||||
from: startDate,
|
||||
to: endDate,
|
||||
},
|
||||
runtimeMappings,
|
||||
...timerange,
|
||||
...deStructureEqlOptions(eqlOptions),
|
||||
};
|
||||
|
||||
|
@ -391,11 +391,7 @@ export const useTimelineEventsHandler = ({
|
|||
language,
|
||||
runtimeMappings,
|
||||
sort,
|
||||
timerange: {
|
||||
interval: '12h',
|
||||
from: startDate,
|
||||
to: endDate,
|
||||
},
|
||||
...timerange,
|
||||
...(eqlOptions ? eqlOptions : {}),
|
||||
};
|
||||
|
||||
|
|
|
@ -32,7 +32,7 @@ export * from './events';
|
|||
export type TimelineFactoryQueryTypes = TimelineEventsQueries;
|
||||
|
||||
export interface TimelineRequestBasicOptions extends IEsSearchRequest {
|
||||
timerange: TimerangeInput;
|
||||
timerange?: TimerangeInput;
|
||||
filterQuery: ESQuery | string | undefined;
|
||||
defaultIndex: string[];
|
||||
factoryQueryType?: TimelineFactoryQueryTypes;
|
||||
|
|
|
@ -27,17 +27,19 @@ export const buildEqlDsl = (options: TimelineEqlRequestOptions): Record<string,
|
|||
throw new Error(`No query size above ${DEFAULT_MAX_TABLE_QUERY_SIZE}`);
|
||||
}
|
||||
|
||||
const requestFilter: unknown[] = [
|
||||
{
|
||||
range: {
|
||||
[options.timestampField ?? '@timestamp']: {
|
||||
gte: options.timerange.from,
|
||||
lte: options.timerange.to,
|
||||
format: 'strict_date_optional_time',
|
||||
const requestFilter: unknown[] = options.timerange
|
||||
? [
|
||||
{
|
||||
range: {
|
||||
[options.timestampField ?? '@timestamp']: {
|
||||
gte: options.timerange.from,
|
||||
lte: options.timerange.to,
|
||||
format: 'strict_date_optional_time',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
]
|
||||
: [];
|
||||
|
||||
return {
|
||||
allow_no_indices: true,
|
||||
|
|
|
@ -38,6 +38,7 @@ export const timelineEventsAll: TimelineFactory<TimelineEventsQueries.all> = {
|
|||
// eslint-disable-next-line prefer-const
|
||||
let { fieldRequested, ...queryOptions } = cloneDeep(options);
|
||||
queryOptions.fields = buildFieldsRequest(fieldRequested, queryOptions.excludeEcsData);
|
||||
|
||||
const { activePage, querySize } = options.pagination;
|
||||
const producerBuckets = getOr([], 'aggregations.producers.buckets', response.rawResponse);
|
||||
const totalCount = response.rawResponse.hits.total || 0;
|
||||
|
|
|
@ -28,7 +28,6 @@ export const buildTimelineEventsAllQuery = ({
|
|||
timerange,
|
||||
}: Omit<TimelineEventsAllRequestOptions, 'fieldRequested'>) => {
|
||||
const filterClause = [...createQueryFilterClauses(filterQuery)];
|
||||
|
||||
const getTimerangeFilter = (timerangeOption: TimerangeInput | undefined): TimerangeFilter[] => {
|
||||
if (timerangeOption) {
|
||||
const { to, from } = timerangeOption;
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue