mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 17:28:26 -04:00
# Backport This will backport the following commits from `main` to `8.8`: - [[Security Solution] Add support for multiple values in cell actions (#158060)](https://github.com/elastic/kibana/pull/158060) <!--- Backport version: 8.9.7 --> ### Questions ? Please refer to the [Backport tool documentation](https://github.com/sqren/backport) <!--BACKPORT [{"author":{"name":"Sergi Massaneda","email":"sergi.massaneda@elastic.co"},"sourceCommit":{"committedDate":"2023-05-18T18:49:47Z","message":"[Security Solution] Add support for multiple values in cell actions (#158060)\n\n## Summary\r\n\r\nfixes: https://github.com/elastic/kibana/issues/157237\r\ncloses: https://github.com/elastic/kibana/issues/157887 \r\n\r\nEnables cell actions package to support multi-valuated cells. Actions\r\naffected:\r\n- Filter In\r\n- Filter Out\r\n- Add To Timeline\r\n- Copy To Clipboard\r\n\r\n### Recording\r\n\r\n\r\na192173d
-5fca-4b33-91a7-664ecd71550b\r\n\r\n#### Caveat\r\nThe `FilterManager` does not recognize the duplicated filter when using\r\nthe new `Combined` filters (the ones that allow AND/OR operations), so\r\nwhen adding two opposite combined filters, it does not remove the first\r\none and both are applied at the same time:\r\n\r\n\r\n","sha":"4dd1373571c99540d8ca04846f1e2aeb55fbe80f","branchLabelMapping":{"^v8.9.0$":"main","^v(\\d+).(\\d+).\\d+$":"$1.$2"}},"sourcePullRequest":{"labels":["release_note:fix","Team:Threat Hunting","Team:Threat Hunting:Explore","v8.9.0","v8.8.1"],"number":158060,"url":"https://github.com/elastic/kibana/pull/158060","mergeCommit":{"message":"[Security Solution] Add support for multiple values in cell actions (#158060)\n\n## Summary\r\n\r\nfixes: https://github.com/elastic/kibana/issues/157237\r\ncloses: https://github.com/elastic/kibana/issues/157887 \r\n\r\nEnables cell actions package to support multi-valuated cells. Actions\r\naffected:\r\n- Filter In\r\n- Filter Out\r\n- Add To Timeline\r\n- Copy To Clipboard\r\n\r\n### Recording\r\n\r\n\r\na192173d
-5fca-4b33-91a7-664ecd71550b\r\n\r\n#### Caveat\r\nThe `FilterManager` does not recognize the duplicated filter when using\r\nthe new `Combined` filters (the ones that allow AND/OR operations), so\r\nwhen adding two opposite combined filters, it does not remove the first\r\none and both are applied at the same time:\r\n\r\n\r\n","sha":"4dd1373571c99540d8ca04846f1e2aeb55fbe80f"}},"sourceBranch":"main","suggestedTargetBranches":["8.8"],"targetPullRequestStates":[{"branch":"main","label":"v8.9.0","labelRegex":"^v8.9.0$","isSourceBranch":true,"state":"MERGED","url":"https://github.com/elastic/kibana/pull/158060","number":158060,"mergeCommit":{"message":"[Security Solution] Add support for multiple values in cell actions (#158060)\n\n## Summary\r\n\r\nfixes: https://github.com/elastic/kibana/issues/157237\r\ncloses: https://github.com/elastic/kibana/issues/157887 \r\n\r\nEnables cell actions package to support multi-valuated cells. Actions\r\naffected:\r\n- Filter In\r\n- Filter Out\r\n- Add To Timeline\r\n- Copy To Clipboard\r\n\r\n### Recording\r\n\r\n\r\na192173d
-5fca-4b33-91a7-664ecd71550b\r\n\r\n#### Caveat\r\nThe `FilterManager` does not recognize the duplicated filter when using\r\nthe new `Combined` filters (the ones that allow AND/OR operations), so\r\nwhen adding two opposite combined filters, it does not remove the first\r\none and both are applied at the same time:\r\n\r\n\r\n","sha":"4dd1373571c99540d8ca04846f1e2aeb55fbe80f"}},{"branch":"8.8","label":"v8.8.1","labelRegex":"^v(\\d+).(\\d+).\\d+$","isSourceBranch":false,"state":"NOT_CREATED"}]}] BACKPORT--> Co-authored-by: Sergi Massaneda <sergi.massaneda@elastic.co>
This commit is contained in:
parent
89771e719d
commit
ec659b1dcf
8 changed files with 212 additions and 85 deletions
|
@ -48,5 +48,25 @@ describe('Default createCopyToClipboardActionFactory', () => {
|
|||
expect(mockCopy).toHaveBeenCalledWith('user.name: "the value"');
|
||||
expect(mockSuccessToast).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should escape value', async () => {
|
||||
await copyToClipboardAction.execute({
|
||||
...context,
|
||||
field: { ...context.field, value: 'the "value"' },
|
||||
});
|
||||
expect(mockCopy).toHaveBeenCalledWith('user.name: "the \\"value\\""');
|
||||
expect(mockSuccessToast).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should suport multiple values', async () => {
|
||||
await copyToClipboardAction.execute({
|
||||
...context,
|
||||
field: { ...context.field, value: ['the "value"', 'another value', 'last value'] },
|
||||
});
|
||||
expect(mockCopy).toHaveBeenCalledWith(
|
||||
'user.name: "the \\"value\\"" AND "another value" AND "last value"'
|
||||
);
|
||||
expect(mockSuccessToast).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -23,6 +23,8 @@ const COPY_TO_CLIPBOARD_SUCCESS = i18n.translate(
|
|||
}
|
||||
);
|
||||
|
||||
const escapeValue = (value: string) => value.replace(/"/g, '\\"');
|
||||
|
||||
export const createCopyToClipboardActionFactory = createCellActionFactory(
|
||||
({ notifications }: { notifications: NotificationsStart }) => ({
|
||||
type: COPY_CELL_ACTION_TYPE,
|
||||
|
@ -34,8 +36,8 @@ export const createCopyToClipboardActionFactory = createCellActionFactory(
|
|||
let textValue: undefined | string;
|
||||
if (field.value != null) {
|
||||
textValue = Array.isArray(field.value)
|
||||
? field.value.map((value) => `"${value}"`).join(', ')
|
||||
: `"${field.value}"`;
|
||||
? field.value.map((value) => `"${escapeValue(value)}"`).join(' AND ')
|
||||
: `"${escapeValue(field.value)}"`;
|
||||
}
|
||||
const text = textValue ? `${field.name}: ${textValue}` : field.name;
|
||||
const isSuccess = copy(text, { debug: true });
|
||||
|
|
|
@ -18,21 +18,17 @@ describe('createFilter', () => {
|
|||
])('should return filter with $caseName value', ({ caseValue }) => {
|
||||
expect(createFilter({ key: field, value: caseValue, negate: false })).toEqual({
|
||||
meta: {
|
||||
alias: null,
|
||||
disabled: false,
|
||||
type: 'phrase',
|
||||
key: field,
|
||||
negate: false,
|
||||
value,
|
||||
params: {
|
||||
query: value,
|
||||
},
|
||||
},
|
||||
query: {
|
||||
match: {
|
||||
match_phrase: {
|
||||
[field]: {
|
||||
query: value,
|
||||
type: 'phrase',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
@ -45,27 +41,48 @@ describe('createFilter', () => {
|
|||
])('should return negate filter with $caseName value', ({ caseValue }) => {
|
||||
expect(createFilter({ key: field, value: caseValue, negate: true })).toEqual({
|
||||
meta: {
|
||||
alias: null,
|
||||
disabled: false,
|
||||
type: 'phrase',
|
||||
key: field,
|
||||
negate: true,
|
||||
value,
|
||||
params: {
|
||||
query: value,
|
||||
},
|
||||
},
|
||||
query: {
|
||||
match: {
|
||||
match_phrase: {
|
||||
[field]: {
|
||||
query: value,
|
||||
type: 'phrase',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ caseName: 'non-negated', negate: false },
|
||||
{ caseName: 'negated', negate: true },
|
||||
])('should return combined filter with multiple $caseName values', ({ negate }) => {
|
||||
const value2 = 'the-value2';
|
||||
expect(createFilter({ key: field, value: [value, value2], negate })).toEqual({
|
||||
meta: {
|
||||
type: 'combined',
|
||||
relation: 'AND',
|
||||
key: field,
|
||||
negate,
|
||||
params: [
|
||||
{
|
||||
meta: { type: 'phrase', key: field, params: { query: value } },
|
||||
query: { match_phrase: { [field]: { query: value } } },
|
||||
},
|
||||
{
|
||||
meta: { type: 'phrase', key: field, params: { query: value2 } },
|
||||
query: { match_phrase: { [field]: { query: value2 } } },
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ caseName: 'null', caseValue: null },
|
||||
{ caseName: 'undefined', caseValue: undefined },
|
||||
|
@ -79,8 +96,6 @@ describe('createFilter', () => {
|
|||
},
|
||||
},
|
||||
meta: {
|
||||
alias: null,
|
||||
disabled: false,
|
||||
key: field,
|
||||
negate: false,
|
||||
type: 'exists',
|
||||
|
@ -102,8 +117,6 @@ describe('createFilter', () => {
|
|||
},
|
||||
},
|
||||
meta: {
|
||||
alias: null,
|
||||
disabled: false,
|
||||
key: field,
|
||||
negate: true,
|
||||
type: 'exists',
|
||||
|
|
|
@ -5,10 +5,54 @@
|
|||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
import type { Filter } from '@kbn/es-query';
|
||||
import {
|
||||
BooleanRelation,
|
||||
FILTERS,
|
||||
type CombinedFilter,
|
||||
type ExistsFilter,
|
||||
type PhraseFilter,
|
||||
type Filter,
|
||||
} from '@kbn/es-query';
|
||||
|
||||
export const isEmptyFilterValue = (value: string[] | string | null | undefined) =>
|
||||
value == null || value.length === 0;
|
||||
export const isEmptyFilterValue = (
|
||||
value: string[] | string | null | undefined
|
||||
): value is null | undefined | never[] => value == null || value.length === 0;
|
||||
|
||||
const createExistsFilter = ({ key, negate }: { key: string; negate: boolean }): ExistsFilter => ({
|
||||
meta: { key, negate, type: FILTERS.EXISTS, value: 'exists' },
|
||||
query: { exists: { field: key } },
|
||||
});
|
||||
|
||||
const createPhraseFilter = ({
|
||||
key,
|
||||
negate,
|
||||
value,
|
||||
}: {
|
||||
value: string;
|
||||
key: string;
|
||||
negate?: boolean;
|
||||
}): PhraseFilter => ({
|
||||
meta: { key, negate, type: FILTERS.PHRASE, params: { query: value } },
|
||||
query: { match_phrase: { [key]: { query: value } } },
|
||||
});
|
||||
|
||||
const createCombinedFilter = ({
|
||||
values,
|
||||
key,
|
||||
negate,
|
||||
}: {
|
||||
values: string[];
|
||||
key: string;
|
||||
negate: boolean;
|
||||
}): CombinedFilter => ({
|
||||
meta: {
|
||||
key,
|
||||
negate,
|
||||
type: FILTERS.COMBINED,
|
||||
relation: BooleanRelation.AND,
|
||||
params: values.map((value) => createPhraseFilter({ key, value })),
|
||||
},
|
||||
});
|
||||
|
||||
export const createFilter = ({
|
||||
key,
|
||||
|
@ -19,39 +63,15 @@ export const createFilter = ({
|
|||
value: string[] | string | null | undefined;
|
||||
negate: boolean;
|
||||
}): Filter => {
|
||||
const queryValue = !isEmptyFilterValue(value) ? (Array.isArray(value) ? value[0] : value) : null;
|
||||
const meta = { alias: null, disabled: false, key, negate };
|
||||
|
||||
if (queryValue == null) {
|
||||
return {
|
||||
query: {
|
||||
exists: {
|
||||
field: key,
|
||||
},
|
||||
},
|
||||
meta: {
|
||||
...meta,
|
||||
type: 'exists',
|
||||
value: 'exists',
|
||||
},
|
||||
};
|
||||
if (isEmptyFilterValue(value)) {
|
||||
return createExistsFilter({ key, negate });
|
||||
}
|
||||
return {
|
||||
meta: {
|
||||
...meta,
|
||||
type: 'phrase',
|
||||
value: queryValue,
|
||||
params: {
|
||||
query: queryValue,
|
||||
},
|
||||
},
|
||||
query: {
|
||||
match: {
|
||||
[key]: {
|
||||
query: queryValue,
|
||||
type: 'phrase',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length > 1) {
|
||||
return createCombinedFilter({ key, negate, values: value });
|
||||
} else {
|
||||
return createPhraseFilter({ key, negate, value: value[0] });
|
||||
}
|
||||
}
|
||||
return createPhraseFilter({ key, negate, value });
|
||||
};
|
||||
|
|
|
@ -12,6 +12,7 @@ import { createAddToTimelineCellActionFactory } from './add_to_timeline';
|
|||
import type { CellActionExecutionContext } from '@kbn/cell-actions';
|
||||
import { GEO_FIELD_TYPE } from '../../../timelines/components/timeline/body/renderers/constants';
|
||||
import { createStartServicesMock } from '../../../common/lib/kibana/kibana_react.mock';
|
||||
import { set } from 'lodash/fp';
|
||||
|
||||
const services = createStartServicesMock();
|
||||
const mockWarningToast = services.notifications.toasts.addWarning;
|
||||
|
@ -28,24 +29,24 @@ const context = {
|
|||
} as CellActionExecutionContext;
|
||||
|
||||
const defaultDataProvider = {
|
||||
and: [],
|
||||
enabled: true,
|
||||
excluded: false,
|
||||
id: 'event-field-default-timeline-1-user_name-0-the-value',
|
||||
kqlQuery: '',
|
||||
name: 'user.name',
|
||||
queryMatch: {
|
||||
field: 'user.name',
|
||||
operator: ':',
|
||||
value: 'the-value',
|
||||
},
|
||||
};
|
||||
|
||||
const defaultDataProviderAction = {
|
||||
type: addProvider.type,
|
||||
payload: {
|
||||
id: TimelineId.active,
|
||||
providers: [
|
||||
{
|
||||
and: [],
|
||||
enabled: true,
|
||||
excluded: false,
|
||||
id: 'event-field-default-timeline-1-user_name-0-the-value',
|
||||
kqlQuery: '',
|
||||
name: 'user.name',
|
||||
queryMatch: {
|
||||
field: 'user.name',
|
||||
operator: ':',
|
||||
value: 'the-value',
|
||||
},
|
||||
},
|
||||
],
|
||||
providers: [defaultDataProvider],
|
||||
},
|
||||
};
|
||||
|
||||
|
@ -80,9 +81,62 @@ describe('createAddToTimelineCellAction', () => {
|
|||
});
|
||||
|
||||
describe('execute', () => {
|
||||
it('should execute normally', async () => {
|
||||
it('should execute with default value', async () => {
|
||||
await addToTimelineAction.execute(context);
|
||||
expect(mockDispatch).toHaveBeenCalledWith(defaultDataProvider);
|
||||
expect(mockDispatch).toHaveBeenCalledWith(defaultDataProviderAction);
|
||||
expect(mockWarningToast).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should execute with null value', async () => {
|
||||
await addToTimelineAction.execute({
|
||||
field: { name: 'user.name', value: null, type: 'text' },
|
||||
} as CellActionExecutionContext);
|
||||
expect(mockDispatch).toHaveBeenCalledWith(
|
||||
set(
|
||||
'payload.providers[0]',
|
||||
{
|
||||
...defaultDataProvider,
|
||||
id: 'empty-value-timeline-1-user_name-0',
|
||||
excluded: true,
|
||||
queryMatch: {
|
||||
field: 'user.name',
|
||||
value: '',
|
||||
operator: ':*',
|
||||
},
|
||||
},
|
||||
defaultDataProviderAction
|
||||
)
|
||||
);
|
||||
expect(mockWarningToast).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should execute with multiple values', async () => {
|
||||
const value2 = 'value2';
|
||||
const value3 = 'value3';
|
||||
await addToTimelineAction.execute({
|
||||
field: { name: 'user.name', value: [value, value2, value3], type: 'text' },
|
||||
} as CellActionExecutionContext);
|
||||
expect(mockDispatch).toHaveBeenCalledWith(
|
||||
set(
|
||||
'payload.providers[0]',
|
||||
{
|
||||
...defaultDataProvider,
|
||||
and: [
|
||||
{
|
||||
...defaultDataProvider,
|
||||
id: 'event-field-default-timeline-1-user_name-0-value2',
|
||||
queryMatch: { ...defaultDataProvider.queryMatch, value: value2 },
|
||||
},
|
||||
{
|
||||
...defaultDataProvider,
|
||||
id: 'event-field-default-timeline-1-user_name-0-value3',
|
||||
queryMatch: { ...defaultDataProvider.queryMatch, value: value3 },
|
||||
},
|
||||
],
|
||||
},
|
||||
defaultDataProviderAction
|
||||
)
|
||||
);
|
||||
expect(mockWarningToast).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
@ -106,7 +160,7 @@ describe('createAddToTimelineCellAction', () => {
|
|||
negateFilters: false,
|
||||
},
|
||||
});
|
||||
expect(mockDispatch).toHaveBeenCalledWith(defaultDataProvider);
|
||||
expect(mockDispatch).toHaveBeenCalledWith(defaultDataProviderAction);
|
||||
expect(mockWarningToast).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
@ -118,10 +172,10 @@ describe('createAddToTimelineCellAction', () => {
|
|||
},
|
||||
});
|
||||
expect(mockDispatch).toHaveBeenCalledWith({
|
||||
...defaultDataProvider,
|
||||
...defaultDataProviderAction,
|
||||
payload: {
|
||||
...defaultDataProvider.payload,
|
||||
providers: [{ ...defaultDataProvider.payload.providers[0], excluded: true }],
|
||||
...defaultDataProviderAction.payload,
|
||||
providers: [{ ...defaultDataProviderAction.payload.providers[0], excluded: true }],
|
||||
},
|
||||
});
|
||||
expect(mockWarningToast).not.toHaveBeenCalled();
|
||||
|
|
|
@ -40,22 +40,40 @@ export const createAddToTimelineCellActionFactory = createCellActionFactory(
|
|||
getDisplayNameTooltip: () => ADD_TO_TIMELINE,
|
||||
isCompatible: async ({ field }) =>
|
||||
fieldHasCellActions(field.name) && isValidDataProviderField(field.name, field.type),
|
||||
execute: async ({ field, metadata }) => {
|
||||
const dataProviders =
|
||||
execute: async ({ field: { value, type, name }, metadata }) => {
|
||||
const values = Array.isArray(value) ? value : [value];
|
||||
const [firstValue, ...andValues] = values;
|
||||
const [dataProvider] =
|
||||
createDataProviders({
|
||||
contextId: TimelineId.active,
|
||||
fieldType: field.type,
|
||||
values: field.value,
|
||||
field: field.name,
|
||||
fieldType: type,
|
||||
values: firstValue,
|
||||
field: name,
|
||||
negate: metadata?.negateFilters === true,
|
||||
}) ?? [];
|
||||
|
||||
if (dataProviders.length > 0) {
|
||||
store.dispatch(addProvider({ id: TimelineId.active, providers: dataProviders }));
|
||||
if (dataProvider) {
|
||||
andValues.forEach((andValue) => {
|
||||
const [andDataProvider] =
|
||||
createDataProviders({
|
||||
contextId: TimelineId.active,
|
||||
fieldType: type,
|
||||
values: andValue,
|
||||
field: name,
|
||||
negate: metadata?.negateFilters === true,
|
||||
}) ?? [];
|
||||
if (andDataProvider) {
|
||||
dataProvider.and.push(andDataProvider);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (dataProvider) {
|
||||
store.dispatch(addProvider({ id: TimelineId.active, providers: [dataProvider] }));
|
||||
|
||||
let messageValue = '';
|
||||
if (field.value != null) {
|
||||
messageValue = Array.isArray(field.value) ? field.value.join(', ') : field.value;
|
||||
if (value != null) {
|
||||
messageValue = Array.isArray(value) ? value.join(', ') : value;
|
||||
}
|
||||
notificationsService.toasts.addSuccess({
|
||||
title: ADD_TO_TIMELINE_SUCCESS_TITLE(messageValue),
|
||||
|
|
|
@ -94,7 +94,7 @@ export const OverviewCardWithActions: React.FC<OverviewCardWithActionsProps> = (
|
|||
<SecurityCellActions
|
||||
field={{
|
||||
name: enrichedFieldInfo.data.field,
|
||||
value: enrichedFieldInfo?.values ? enrichedFieldInfo?.values[0] : '',
|
||||
value: enrichedFieldInfo?.values,
|
||||
type: enrichedFieldInfo.data.type,
|
||||
aggregatable: enrichedFieldInfo.fieldFromBrowserField?.aggregatable,
|
||||
}}
|
||||
|
|
|
@ -47,7 +47,7 @@ export const SummaryValueCell: React.FC<AlertSummaryRow['description']> = ({
|
|||
<SecurityCellActions
|
||||
field={{
|
||||
name: data.field,
|
||||
value: values && values.length > 0 ? values[0] : '',
|
||||
value: values,
|
||||
type: data.type,
|
||||
aggregatable: fieldFromBrowserField?.aggregatable,
|
||||
}}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue