Skip to content

Commit 225a87d

Browse files
authored
[Security Solution][Detection Engine] EQL sequence document merging: treat dot and nested notation the same (elastic#254830)
## Summary Fixes elastic#163756 EQL sequence rules merge shared field-value pairs from all events into the "shell" alert. Previously, if a field appeared in **dot notation** (e.g. `'user.email': 'me@example.com'`) in some events and **nested notation** (e.g. `user: { email: 'me@example.com' }`) in others, the merge logic did not treat them as the same path, so the field was omitted from the shell alert. Now, this situation will result in the nested notation field being used. Like before this change, if both fields have the same notation, that notation is preserved in the alert too.
1 parent 6c24da5 commit 225a87d

2 files changed

Lines changed: 237 additions & 34 deletions

File tree

x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_types/eql/build_alert_group_from_sequence.test.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -298,6 +298,69 @@ describe('buildAlert', () => {
298298
};
299299
expect(intersection).toEqual(expected);
300300
});
301+
302+
test('should treat dot and nested notation the same', () => {
303+
const a = {
304+
'user.email': 'marshall@elastic.co',
305+
};
306+
const b = {
307+
user: {
308+
email: 'marshall@elastic.co',
309+
},
310+
};
311+
const intersection = objectPairIntersection(a, b);
312+
const expected = {
313+
user: {
314+
email: 'marshall@elastic.co',
315+
},
316+
};
317+
expect(intersection).toEqual(expected);
318+
});
319+
320+
test('should return undefined when first argument is undefined', () => {
321+
expect(objectPairIntersection(undefined, { a: 1 })).toEqual(undefined);
322+
});
323+
324+
test('should return undefined when second argument is undefined', () => {
325+
expect(objectPairIntersection({ a: 1 }, undefined)).toEqual(undefined);
326+
});
327+
328+
test('should return undefined for two empty objects', () => {
329+
expect(objectPairIntersection({}, {})).toEqual(undefined);
330+
});
331+
332+
test('should return undefined when one object is empty', () => {
333+
expect(objectPairIntersection({}, { a: 1 })).toEqual(undefined);
334+
expect(objectPairIntersection({ a: 1 }, {})).toEqual(undefined);
335+
});
336+
337+
test('should treat deep dot notation and nested notation the same', () => {
338+
const a = { 'a.b.c': 42 };
339+
const b = { a: { b: { c: 42 } } };
340+
const intersection = objectPairIntersection(a, b);
341+
expect(intersection).toEqual({ a: { b: { c: 42 } } });
342+
});
343+
344+
test('should merge when both use dot notation for the same path and keep the dot notation', () => {
345+
const a = { 'user.email': 'a@test.co' };
346+
const b = { 'user.email': 'a@test.co' };
347+
const intersection = objectPairIntersection(a, b);
348+
expect(intersection).toEqual({ 'user.email': 'a@test.co' });
349+
});
350+
351+
test('should merge when use dot notation and nested notation are arrays', () => {
352+
const a = { 'user.emails': ['a@test.co'] };
353+
const b = { user: { emails: ['a@test.co'] } };
354+
const intersection = objectPairIntersection(a, b);
355+
expect(intersection).toEqual({ user: { emails: ['a@test.co'] } });
356+
});
357+
358+
test('should use last-wins when the same path appears via both notations in one object', () => {
359+
const a = { 'user.email': 'dot@test.co', user: { email: 'nested@test.co' } };
360+
const b = { user: { email: 'nested@test.co' } };
361+
const intersection = objectPairIntersection(a, b);
362+
expect(intersection).toEqual({ user: { email: 'nested@test.co' } });
363+
});
301364
});
302365

303366
test('should treat numbers and strings as unequal', () => {
@@ -638,5 +701,16 @@ describe('buildAlert', () => {
638701
};
639702
expect(intersection).toEqual(expected);
640703
});
704+
705+
test('should merge objects when some use dot notation and others use nested', () => {
706+
const a = { 'user.id': 'u1', event: { action: 'login' } };
707+
const b = { user: { id: 'u1' }, event: { action: 'login' } };
708+
const c = { 'user.id': 'u1', 'event.action': 'login' };
709+
const intersection = objectArrayIntersection([a, b, c]);
710+
expect(intersection).toEqual({
711+
user: { id: 'u1' },
712+
event: { action: 'login' },
713+
});
714+
});
641715
});
642716
});

x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_types/eql/build_alert_group_from_sequence.ts

Lines changed: 163 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import type {
1414
} from '@kbn/rule-data-utils';
1515
import { ALERT_URL, ALERT_UUID } from '@kbn/rule-data-utils';
1616
import { intersection as lodashIntersection, isArray } from 'lodash';
17+
import { setWith } from '@kbn/safer-lodash-set';
1718

1819
import { getAlertDetailsUrl } from '../../../../../common/utils/alert_detail_path';
1920
import { DEFAULT_ALERTS_INDEX } from '../../../../../common/constants';
@@ -246,11 +247,139 @@ export const objectArrayIntersection = (objects: object[]) => {
246247
}
247248
};
248249

250+
const isPlainObject = (v: unknown): v is Record<string, unknown> =>
251+
typeof v === 'object' && v !== null && !Array.isArray(v);
252+
253+
/**
254+
* Flattens an object to path-value pairs and records which paths came from
255+
* dot-notation keys. A path is "from dot" when it was produced by expanding
256+
* a single key that contained '.'.
257+
*/
258+
const flattenToPathValuesWithNotation = (
259+
obj: object
260+
): { pathValues: [string[], unknown][]; dotPaths: Set<string> } => {
261+
const pathValues: [string[], unknown][] = [];
262+
const dotPaths = new Set<string>();
263+
const stack: [object, string[]][] = [[obj, []]];
264+
while (stack.length > 0) {
265+
const [o, prefix] = stack.pop() as [object, string[]];
266+
for (const [k, v] of Object.entries(o)) {
267+
const path = prefix.concat(k.includes('.') ? k.split('.') : [k]);
268+
const keyWasDot = k.includes('.');
269+
if (isPlainObject(v)) {
270+
stack.push([v, path]);
271+
} else {
272+
pathValues.push([path, v]);
273+
if (keyWasDot) {
274+
dotPaths.add(path.join('.'));
275+
}
276+
}
277+
}
278+
}
279+
return { pathValues, dotPaths };
280+
};
281+
282+
/**
283+
* Flattens an object to path-value pairs (paths as string arrays).
284+
* Dot-notation keys are expanded so 'user.email' and user: { email } yield the same path.
285+
*/
286+
const flattenToPathValues = (obj: object): [string[], unknown][] =>
287+
flattenToPathValuesWithNotation(obj).pathValues;
288+
289+
/**
290+
* Builds a nested object from path-value pairs.
291+
*/
292+
const unflatten = (pathValues: [string[], unknown][]): Record<string, unknown> => {
293+
const result: Record<string, unknown> = {};
294+
for (const [path, value] of pathValues) {
295+
setWith(result, path, value);
296+
}
297+
return result;
298+
};
299+
300+
/** Result of intersecting two values: either a literal value to set, or a nested pair to process later. */
301+
type IntersectionResult =
302+
| { kind: 'value'; value: unknown }
303+
| { kind: 'nested'; a: Record<string, unknown>; b: Record<string, unknown> };
304+
305+
/**
306+
* Computes the intersection of two values (primitives, arrays, or nested objects).
307+
* For nested objects, returns a descriptor so the caller can push work onto the stack.
308+
*/
309+
const intersectValues = (aVal: unknown, bVal: unknown): IntersectionResult | undefined => {
310+
if (isPlainObject(aVal) && isPlainObject(bVal)) {
311+
return { kind: 'nested', a: aVal, b: bVal };
312+
}
313+
if (aVal === bVal) {
314+
return { kind: 'value', value: aVal };
315+
}
316+
if (isArray(aVal) || isArray(bVal)) {
317+
const arrA = isArray(aVal) ? aVal : [aVal];
318+
const arrB = isArray(bVal) ? bVal : [bVal];
319+
return { kind: 'value', value: lodashIntersection(arrA, arrB) };
320+
}
321+
return undefined;
322+
};
323+
324+
const isEmptyOrAllUndefined = (o: Record<string, unknown>): boolean =>
325+
Object.keys(o).length === 0 || Object.values(o).every((v) => v === undefined);
326+
327+
/** Removes nested objects that are empty or have only undefined values (iterative). */
328+
const pruneEmptyNestedObjects = (obj: Record<string, unknown>): void => {
329+
type PruneItem = [Record<string, unknown> | null, string | null, Record<string, unknown>];
330+
const pruneStack: PruneItem[] = [[null, null, obj]];
331+
const toPrune: PruneItem[] = [];
332+
while (pruneStack.length > 0) {
333+
const item = pruneStack.pop() as PruneItem;
334+
toPrune.push(item);
335+
const [, , o] = item;
336+
for (const [k, v] of Object.entries(o)) {
337+
if (isPlainObject(v)) {
338+
pruneStack.push([o, k, v]);
339+
}
340+
}
341+
}
342+
for (let i = toPrune.length - 1; i >= 0; i--) {
343+
const [parent, key, o] = toPrune[i];
344+
if (parent !== null && key !== null && isEmptyOrAllUndefined(o)) {
345+
delete parent[key];
346+
}
347+
}
348+
};
349+
350+
const hasDefinedValues = (obj: Record<string, unknown>): boolean =>
351+
Object.values(obj).some((v) => v !== undefined);
352+
353+
/**
354+
* Converts a normalized (nested) intersection result to output form:
355+
* paths that were dot notation in both inputs stay as dot keys; others stay nested.
356+
*/
357+
const applyNotationPreference = (
358+
intersectionNested: Record<string, unknown>,
359+
aDotPaths: Set<string>,
360+
bDotPaths: Set<string>
361+
): Record<string, unknown> => {
362+
const pathValues = flattenToPathValues(intersectionNested);
363+
const result: Record<string, unknown> = {};
364+
for (const [path, value] of pathValues) {
365+
const pathStr = path.join('.');
366+
if (aDotPaths.has(pathStr) && bDotPaths.has(pathStr)) {
367+
result[pathStr] = value;
368+
} else {
369+
setWith(result, path, value);
370+
}
371+
}
372+
return result;
373+
};
374+
249375
/**
250-
* Finds the intersection of two objects by recursively
251-
* finding the "intersection" of each of of their common keys'
376+
* Finds the intersection of two objects iteratively by
377+
* finding the "intersection" of each of their common keys'
252378
* values. If an intersection cannot be found between a key's
253379
* values, the value will be undefined in the returned object.
380+
* Dot-notation keys (e.g. 'user.email') and nested notation
381+
* (e.g. user: { email }) are treated as the same; the result
382+
* is always in nested form.
254383
*
255384
* @param a object
256385
* @param b object
@@ -260,40 +389,40 @@ export const objectPairIntersection = (a: object | undefined, b: object | undefi
260389
if (a === undefined || b === undefined) {
261390
return undefined;
262391
}
263-
const intersection: Record<string, unknown> = {};
264-
Object.entries(a).forEach(([key, aVal]) => {
265-
if (key in b) {
266-
const bVal = (b as Record<string, unknown>)[key];
267-
if (
268-
typeof aVal === 'object' &&
269-
!(aVal instanceof Array) &&
270-
aVal !== null &&
271-
typeof bVal === 'object' &&
272-
!(bVal instanceof Array) &&
273-
bVal !== null
274-
) {
275-
intersection[key] = objectPairIntersection(aVal, bVal);
276-
} else if (aVal === bVal) {
277-
intersection[key] = aVal;
278-
} else if (isArray(aVal) && isArray(bVal)) {
279-
intersection[key] = lodashIntersection(aVal, bVal);
280-
} else if (isArray(aVal) && !isArray(bVal)) {
281-
intersection[key] = lodashIntersection(aVal, [bVal]);
282-
} else if (!isArray(aVal) && isArray(bVal)) {
283-
intersection[key] = lodashIntersection([aVal], bVal);
392+
const { pathValues: aPathValues, dotPaths: aDotPaths } = flattenToPathValuesWithNotation(a);
393+
const { pathValues: bPathValues, dotPaths: bDotPaths } = flattenToPathValuesWithNotation(b);
394+
const aNorm = unflatten(aPathValues);
395+
const bNorm = unflatten(bPathValues);
396+
const intersectionNested: Record<string, unknown> = {};
397+
const stack: [Record<string, unknown>, Record<string, unknown>, Record<string, unknown>][] = [
398+
[intersectionNested, aNorm, bNorm],
399+
];
400+
while (stack.length > 0) {
401+
const [target, aObj, bObj] = stack.pop() as [
402+
Record<string, unknown>,
403+
Record<string, unknown>,
404+
Record<string, unknown>
405+
];
406+
for (const [key, aVal] of Object.entries(aObj)) {
407+
if (key in bObj) {
408+
const result = intersectValues(aVal, bObj[key]);
409+
if (result === undefined) {
410+
// eslint-disable-next-line no-continue
411+
continue;
412+
}
413+
if (result.kind === 'nested') {
414+
const nextTarget: Record<string, unknown> = {};
415+
target[key] = nextTarget;
416+
stack.push([nextTarget, result.a, result.b]);
417+
} else {
418+
target[key] = result.value;
419+
}
284420
}
285421
}
286-
});
287-
// Count up the number of entries that are NOT undefined in the intersection
288-
// If there are no keys OR all entries are undefined, return undefined
289-
if (
290-
Object.values(intersection).reduce(
291-
(acc: number, value) => (value !== undefined ? acc + 1 : acc),
292-
0
293-
) === 0
294-
) {
422+
}
423+
pruneEmptyNestedObjects(intersectionNested);
424+
if (!hasDefinedValues(intersectionNested)) {
295425
return undefined;
296-
} else {
297-
return intersection;
298426
}
427+
return applyNotationPreference(intersectionNested, aDotPaths, bDotPaths);
299428
};

0 commit comments

Comments
 (0)