Skip to content

Commit 4ee34e6

Browse files
[SIEM][Detection Engine] Fixes bug with timeline templates not working (#60538)
### Summary Fixes a bug with the timeline templates not working when specifying filters. * Creates a type safe mechanism for getting StringArrays or regular strings * AddsType Script function returns to functions in the helpers file * Adds unit tests for the effected areas of code and corner cases Before this fix you would get these toaster errors if you tried to use a template name such as `host.name` in the timeline filters: <img width="677" alt="Screen Shot 2020-03-18 at 12 58 01 AM" src="https://user-images.githubusercontent.com/1151048/76934058-0bd2fc80-68b4-11ea-8dad-7c257bb81a1d.png"> After this fix it will work for you. Testing: 1) Create a timeline template that has a host.name as both a query and a filter such as this. You can give the value of the host.name any value such as placeholder. <img width="1125" alt="Screen Shot 2020-03-18 at 12 56 04 AM" src="https://user-images.githubusercontent.com/1151048/76934108-20af9000-68b4-11ea-8a11-4ba9c935506f.png"> 2) Create a signal that uses it and produces a lot of signals off of something such as all host names <img width="1054" alt="Screen Shot 2020-03-18 at 12 50 47 AM" src="https://user-images.githubusercontent.com/1151048/76934198-4f2d6b00-68b4-11ea-8ae3-6de76154cbb7.png"> 3) Ensure you select your **Timeline template** you saved by using the drop down <img width="1071" alt="Screen Shot 2020-03-18 at 12 51 21 AM" src="https://user-images.githubusercontent.com/1151048/76934281-73894780-68b4-11ea-9a2a-a0a9176f28ce.png"> 4) Once your signals have run, go to the signals page and send one of the signals for your newly crated rule which has a host name to the timeline from "View in timeline" <img width="568" alt="Screen Shot 2020-03-18 at 12 52 10 AM" src="https://user-images.githubusercontent.com/1151048/76934365-a4697c80-68b4-11ea-91a5-e0dea7e3e18f.png"> You should notice that your timeline has both the query and the filter set correctly such as this <img width="1114" alt="Screen Shot 2020-03-18 at 12 56 23 AM" src="https://user-images.githubusercontent.com/1151048/76934432-c105b480-68b4-11ea-9a82-3e8a2da19376.png"> ### Other notes All the different fields you can choose from for templates are: ``` 'host.name', 'host.hostname', 'host.domain', 'host.id', 'host.ip', 'client.ip', 'destination.ip', 'server.ip', 'source.ip', 'network.community_id', 'user.name', 'process.name', ``` And it should not work with anything outside of those. You should be able to mix and match them into different filters and queries to have a multiples of them. ### Checklist - [x] [Unit or functional tests](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#cross-browser-compatibility) were updated or added to match the most common scenarios
1 parent 9cb2477 commit 4ee34e6

File tree

2 files changed

+328
-18
lines changed

2 files changed

+328
-18
lines changed
Lines changed: 273 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,273 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License;
4+
* you may not use this file except in compliance with the Elastic License.
5+
*/
6+
7+
import {
8+
getStringArray,
9+
replaceTemplateFieldFromQuery,
10+
replaceTemplateFieldFromMatchFilters,
11+
reformatDataProviderWithNewValue,
12+
} from './helpers';
13+
import { mockEcsData } from '../../../../mock/mock_ecs';
14+
import { Filter } from '../../../../../../../../../src/plugins/data/public';
15+
import { DataProvider } from '../../../../components/timeline/data_providers/data_provider';
16+
import { mockDataProviders } from '../../../../components/timeline/data_providers/mock/mock_data_providers';
17+
import { cloneDeep } from 'lodash/fp';
18+
19+
describe('helpers', () => {
20+
let mockEcsDataClone = cloneDeep(mockEcsData);
21+
beforeEach(() => {
22+
mockEcsDataClone = cloneDeep(mockEcsData);
23+
});
24+
describe('getStringOrStringArray', () => {
25+
test('it should correctly return a string array', () => {
26+
const value = getStringArray('x', {
27+
x: 'The nickname of the developer we all :heart:',
28+
});
29+
expect(value).toEqual(['The nickname of the developer we all :heart:']);
30+
});
31+
32+
test('it should correctly return a string array with a single element', () => {
33+
const value = getStringArray('x', {
34+
x: ['The nickname of the developer we all :heart:'],
35+
});
36+
expect(value).toEqual(['The nickname of the developer we all :heart:']);
37+
});
38+
39+
test('it should correctly return a string array with two elements of strings', () => {
40+
const value = getStringArray('x', {
41+
x: ['The nickname of the developer we all :heart:', 'We are all made of stars'],
42+
});
43+
expect(value).toEqual([
44+
'The nickname of the developer we all :heart:',
45+
'We are all made of stars',
46+
]);
47+
});
48+
49+
test('it should correctly return a string array with deep elements', () => {
50+
const value = getStringArray('x.y.z', {
51+
x: { y: { z: 'zed' } },
52+
});
53+
expect(value).toEqual(['zed']);
54+
});
55+
56+
test('it should correctly return a string array with a non-existent value', () => {
57+
const value = getStringArray('non.existent', {
58+
x: { y: { z: 'zed' } },
59+
});
60+
expect(value).toEqual([]);
61+
});
62+
63+
test('it should trace an error if the value is not a string', () => {
64+
const mockConsole: Console = ({ trace: jest.fn() } as unknown) as Console;
65+
const value = getStringArray('a', { a: 5 }, mockConsole);
66+
expect(value).toEqual([]);
67+
expect(
68+
mockConsole.trace
69+
).toHaveBeenCalledWith(
70+
'Data type that is not a string or string array detected:',
71+
5,
72+
'when trying to access field:',
73+
'a',
74+
'from data object of:',
75+
{ a: 5 }
76+
);
77+
});
78+
79+
test('it should trace an error if the value is an array of mixed values', () => {
80+
const mockConsole: Console = ({ trace: jest.fn() } as unknown) as Console;
81+
const value = getStringArray('a', { a: ['hi', 5] }, mockConsole);
82+
expect(value).toEqual([]);
83+
expect(
84+
mockConsole.trace
85+
).toHaveBeenCalledWith(
86+
'Data type that is not a string or string array detected:',
87+
['hi', 5],
88+
'when trying to access field:',
89+
'a',
90+
'from data object of:',
91+
{ a: ['hi', 5] }
92+
);
93+
});
94+
});
95+
96+
describe('replaceTemplateFieldFromQuery', () => {
97+
test('given an empty query string this returns an empty query string', () => {
98+
const replacement = replaceTemplateFieldFromQuery('', mockEcsDataClone[0]);
99+
expect(replacement).toEqual('');
100+
});
101+
102+
test('given a query string with spaces this returns an empty query string', () => {
103+
const replacement = replaceTemplateFieldFromQuery(' ', mockEcsDataClone[0]);
104+
expect(replacement).toEqual('');
105+
});
106+
107+
test('it should replace a query with a template value such as apache from a mock template', () => {
108+
const replacement = replaceTemplateFieldFromQuery(
109+
'host.name: placeholdertext',
110+
mockEcsDataClone[0]
111+
);
112+
expect(replacement).toEqual('host.name: apache');
113+
});
114+
115+
test('it should replace a template field with an ECS value that is not an array', () => {
116+
mockEcsDataClone[0].host!.name = ('apache' as unknown) as string[]; // very unsafe cast for this test case
117+
const replacement = replaceTemplateFieldFromQuery('host.name: *', mockEcsDataClone[0]);
118+
expect(replacement).toEqual('host.name: *');
119+
});
120+
121+
test('it should NOT replace a query with a template value that is not part of the template fields array', () => {
122+
const replacement = replaceTemplateFieldFromQuery(
123+
'user.id: placeholdertext',
124+
mockEcsDataClone[0]
125+
);
126+
expect(replacement).toEqual('user.id: placeholdertext');
127+
});
128+
});
129+
130+
describe('replaceTemplateFieldFromMatchFilters', () => {
131+
test('given an empty query filter this will return an empty filter', () => {
132+
const replacement = replaceTemplateFieldFromMatchFilters([], mockEcsDataClone[0]);
133+
expect(replacement).toEqual([]);
134+
});
135+
136+
test('given a query filter this will return that filter with the placeholder replaced', () => {
137+
const filters: Filter[] = [
138+
{
139+
meta: {
140+
type: 'phrase',
141+
key: 'host.name',
142+
alias: 'alias',
143+
disabled: false,
144+
negate: false,
145+
params: { query: 'Braden' },
146+
},
147+
query: { match_phrase: { 'host.name': 'Braden' } },
148+
},
149+
];
150+
const replacement = replaceTemplateFieldFromMatchFilters(filters, mockEcsDataClone[0]);
151+
const expected: Filter[] = [
152+
{
153+
meta: {
154+
type: 'phrase',
155+
key: 'host.name',
156+
alias: 'alias',
157+
disabled: false,
158+
negate: false,
159+
params: { query: 'apache' },
160+
},
161+
query: { match_phrase: { 'host.name': 'apache' } },
162+
},
163+
];
164+
expect(replacement).toEqual(expected);
165+
});
166+
167+
test('given a query filter with a value not in the templateFields, this will NOT replace the placeholder value', () => {
168+
const filters: Filter[] = [
169+
{
170+
meta: {
171+
type: 'phrase',
172+
key: 'user.id',
173+
alias: 'alias',
174+
disabled: false,
175+
negate: false,
176+
params: { query: 'Evan' },
177+
},
178+
query: { match_phrase: { 'user.id': 'Evan' } },
179+
},
180+
];
181+
const replacement = replaceTemplateFieldFromMatchFilters(filters, mockEcsDataClone[0]);
182+
const expected: Filter[] = [
183+
{
184+
meta: {
185+
type: 'phrase',
186+
key: 'user.id',
187+
alias: 'alias',
188+
disabled: false,
189+
negate: false,
190+
params: { query: 'Evan' },
191+
},
192+
query: { match_phrase: { 'user.id': 'Evan' } },
193+
},
194+
];
195+
expect(replacement).toEqual(expected);
196+
});
197+
});
198+
199+
describe('reformatDataProviderWithNewValue', () => {
200+
test('it should replace a query with a template value such as apache from a mock data provider', () => {
201+
const mockDataProvider: DataProvider = mockDataProviders[0];
202+
mockDataProvider.queryMatch.field = 'host.name';
203+
mockDataProvider.id = 'Braden';
204+
mockDataProvider.name = 'Braden';
205+
mockDataProvider.queryMatch.value = 'Braden';
206+
const replacement = reformatDataProviderWithNewValue(mockDataProvider, mockEcsDataClone[0]);
207+
expect(replacement).toEqual({
208+
id: 'apache',
209+
name: 'apache',
210+
enabled: true,
211+
excluded: false,
212+
kqlQuery: '',
213+
queryMatch: {
214+
field: 'host.name',
215+
value: 'apache',
216+
operator: ':',
217+
displayField: undefined,
218+
displayValue: undefined,
219+
},
220+
and: [],
221+
});
222+
});
223+
224+
test('it should replace a query with a template value such as apache from a mock data provider using a string in the data provider', () => {
225+
mockEcsDataClone[0].host!.name = ('apache' as unknown) as string[]; // very unsafe cast for this test case
226+
const mockDataProvider: DataProvider = mockDataProviders[0];
227+
mockDataProvider.queryMatch.field = 'host.name';
228+
mockDataProvider.id = 'Braden';
229+
mockDataProvider.name = 'Braden';
230+
mockDataProvider.queryMatch.value = 'Braden';
231+
const replacement = reformatDataProviderWithNewValue(mockDataProvider, mockEcsDataClone[0]);
232+
expect(replacement).toEqual({
233+
id: 'apache',
234+
name: 'apache',
235+
enabled: true,
236+
excluded: false,
237+
kqlQuery: '',
238+
queryMatch: {
239+
field: 'host.name',
240+
value: 'apache',
241+
operator: ':',
242+
displayField: undefined,
243+
displayValue: undefined,
244+
},
245+
and: [],
246+
});
247+
});
248+
249+
test('it should NOT replace a query with a template value that is not part of a template such as user.id', () => {
250+
const mockDataProvider: DataProvider = mockDataProviders[0];
251+
mockDataProvider.queryMatch.field = 'user.id';
252+
mockDataProvider.id = 'my-id';
253+
mockDataProvider.name = 'Rebecca';
254+
mockDataProvider.queryMatch.value = 'Rebecca';
255+
const replacement = reformatDataProviderWithNewValue(mockDataProvider, mockEcsDataClone[0]);
256+
expect(replacement).toEqual({
257+
id: 'my-id',
258+
name: 'Rebecca',
259+
enabled: true,
260+
excluded: false,
261+
kqlQuery: '',
262+
queryMatch: {
263+
field: 'user.id',
264+
value: 'Rebecca',
265+
operator: ':',
266+
displayField: undefined,
267+
displayValue: undefined,
268+
},
269+
and: [],
270+
});
271+
});
272+
});
273+
});

x-pack/legacy/plugins/siem/public/pages/detection_engine/components/signals/helpers.ts

Lines changed: 55 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,11 @@ interface FindValueToChangeInQuery {
1717
valueToChange: string;
1818
}
1919

20+
/**
21+
* Fields that will be replaced with the template strings from a a saved timeline template.
22+
* This is used for the signals detection engine feature when you save a timeline template
23+
* and are the fields you can replace when creating a template.
24+
*/
2025
const templateFields = [
2126
'host.name',
2227
'host.hostname',
@@ -32,6 +37,36 @@ const templateFields = [
3237
'process.name',
3338
];
3439

40+
/**
41+
* This will return an unknown as a string array if it exists from an unknown data type and a string
42+
* that represents the path within the data object the same as lodash's "get". If the value is non-existent
43+
* we will return an empty array. If it is a non string value then this will log a trace to the console
44+
* that it encountered an error and return an empty array.
45+
* @param field string of the field to access
46+
* @param data The unknown data that is typically a ECS value to get the value
47+
* @param localConsole The local console which can be sent in to make this pure (for tests) or use the default console
48+
*/
49+
export const getStringArray = (field: string, data: unknown, localConsole = console): string[] => {
50+
const value: unknown | undefined = get(field, data);
51+
if (value == null) {
52+
return [];
53+
} else if (typeof value === 'string') {
54+
return [value];
55+
} else if (Array.isArray(value) && value.every(element => typeof element === 'string')) {
56+
return value;
57+
} else {
58+
localConsole.trace(
59+
'Data type that is not a string or string array detected:',
60+
value,
61+
'when trying to access field:',
62+
field,
63+
'from data object of:',
64+
data
65+
);
66+
return [];
67+
}
68+
};
69+
3570
export const findValueToChangeInQuery = (
3671
keuryNode: KueryNode,
3772
valueToChange: FindValueToChangeInQuery[] = []
@@ -66,31 +101,33 @@ export const findValueToChangeInQuery = (
66101
);
67102
};
68103

69-
export const replaceTemplateFieldFromQuery = (query: string, ecsData: Ecs) => {
104+
export const replaceTemplateFieldFromQuery = (query: string, ecsData: Ecs): string => {
70105
if (query.trim() !== '') {
71106
const valueToChange = findValueToChangeInQuery(esKuery.fromKueryExpression(query));
72107
return valueToChange.reduce((newQuery, vtc) => {
73-
const newValue = get(vtc.field, ecsData);
74-
if (newValue != null) {
75-
return newQuery.replace(vtc.valueToChange, newValue);
108+
const newValue = getStringArray(vtc.field, ecsData);
109+
if (newValue.length) {
110+
return newQuery.replace(vtc.valueToChange, newValue[0]);
111+
} else {
112+
return newQuery;
76113
}
77-
return newQuery;
78114
}, query);
115+
} else {
116+
return '';
79117
}
80-
return '';
81118
};
82119

83-
export const replaceTemplateFieldFromMatchFilters = (filters: Filter[], ecsData: Ecs) =>
120+
export const replaceTemplateFieldFromMatchFilters = (filters: Filter[], ecsData: Ecs): Filter[] =>
84121
filters.map(filter => {
85122
if (
86123
filter.meta.type === 'phrase' &&
87124
filter.meta.key != null &&
88125
templateFields.includes(filter.meta.key)
89126
) {
90-
const newValue = get(filter.meta.key, ecsData);
91-
if (newValue != null) {
92-
filter.meta.params = { query: newValue };
93-
filter.query = { match_phrase: { [filter.meta.key]: newValue } };
127+
const newValue = getStringArray(filter.meta.key, ecsData);
128+
if (newValue.length) {
129+
filter.meta.params = { query: newValue[0] };
130+
filter.query = { match_phrase: { [filter.meta.key]: newValue[0] } };
94131
}
95132
}
96133
return filter;
@@ -101,11 +138,11 @@ export const reformatDataProviderWithNewValue = <T extends DataProvider | DataPr
101138
ecsData: Ecs
102139
): T => {
103140
if (templateFields.includes(dataProvider.queryMatch.field)) {
104-
const newValue = get(dataProvider.queryMatch.field, ecsData);
105-
if (newValue != null) {
106-
dataProvider.id = dataProvider.id.replace(dataProvider.name, newValue);
107-
dataProvider.name = newValue;
108-
dataProvider.queryMatch.value = newValue;
141+
const newValue = getStringArray(dataProvider.queryMatch.field, ecsData);
142+
if (newValue.length) {
143+
dataProvider.id = dataProvider.id.replace(dataProvider.name, newValue[0]);
144+
dataProvider.name = newValue[0];
145+
dataProvider.queryMatch.value = newValue[0];
109146
dataProvider.queryMatch.displayField = undefined;
110147
dataProvider.queryMatch.displayValue = undefined;
111148
}
@@ -116,8 +153,8 @@ export const reformatDataProviderWithNewValue = <T extends DataProvider | DataPr
116153
export const replaceTemplateFieldFromDataProviders = (
117154
dataProviders: DataProvider[],
118155
ecsData: Ecs
119-
) =>
120-
dataProviders.map((dataProvider: DataProvider) => {
156+
): DataProvider[] =>
157+
dataProviders.map(dataProvider => {
121158
const newDataProvider = reformatDataProviderWithNewValue(dataProvider, ecsData);
122159
if (newDataProvider.and != null && !isEmpty(newDataProvider.and)) {
123160
newDataProvider.and = newDataProvider.and.map(andDataProvider =>

0 commit comments

Comments
 (0)