diff --git a/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/all/constants.ts b/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/all/constants.ts index 15d0e2d5494b8c..29b0df9e4bbf73 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/all/constants.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/all/constants.ts @@ -5,6 +5,15 @@ * 2.0. */ +export const TIMELINE_CTI_FIELDS = [ + 'threat.indicator.event.dataset', + 'threat.indicator.event.reference', + 'threat.indicator.matched.atomic', + 'threat.indicator.matched.field', + 'threat.indicator.matched.type', + 'threat.indicator.provider', +]; + export const TIMELINE_EVENTS_FIELDS = [ '@timestamp', 'signal.status', @@ -230,4 +239,5 @@ export const TIMELINE_EVENTS_FIELDS = [ 'zeek.ssl.established', 'zeek.ssl.resumed', 'zeek.ssl.version', + ...TIMELINE_CTI_FIELDS, ]; diff --git a/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/all/helpers.test.ts b/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/all/helpers.test.ts index 61af6a7664faa6..54afaf764da212 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/all/helpers.test.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/all/helpers.test.ts @@ -7,7 +7,7 @@ import { EventHit } from '../../../../../../common/search_strategy'; import { TIMELINE_EVENTS_FIELDS } from './constants'; -import { formatTimelineData } from './helpers'; +import { buildObjectFromField, formatTimelineData } from './helpers'; import { eventHit } from '../mocks'; describe('#formatTimelineData', () => { @@ -42,12 +42,12 @@ describe('#formatTimelineData', () => { value: ['beats-ci-immutable-ubuntu-1804-1605624279743236239'], }, { - field: 'source.geo.location', - value: [`{"lon":118.7778,"lat":32.0617}`], + field: 'threat.indicator.matched.field', + value: ['matched_field', 'other_matched_field', 'matched_field_2'], }, { - field: 'threat.indicator.matched.field', - value: ['matched_field', 'matched_field_2'], + field: 'source.geo.location', + value: [`{"lon":118.7778,"lat":32.0617}`], }, ], ecs: { @@ -94,6 +94,34 @@ describe('#formatTimelineData', () => { user: { name: ['jenkins'], }, + threat: { + indicator: [ + { + event: { + dataset: [], + reference: [], + }, + matched: { + atomic: ['matched_atomic'], + field: ['matched_field', 'other_matched_field'], + type: [], + }, + provider: ['yourself'], + }, + { + event: { + dataset: [], + reference: [], + }, + matched: { + atomic: ['matched_atomic_2'], + field: ['matched_field_2'], + type: [], + }, + provider: ['other_you'], + }, + ], + }, }, }, }); @@ -371,4 +399,88 @@ describe('#formatTimelineData', () => { }, }); }); + + describe('buildObjectFromField', () => { + it('base case', () => { + expect(buildObjectFromField('@timestamp', eventHit)).toEqual({ + '@timestamp': ['2020-11-17T14:48:08.922Z'], + }); + }); + + it('another base case', () => { + const event = { + fields: { + foo: [{ bar: ['baz'] }], + }, + }; + // @ts-expect-error nestedEvent is minimal + expect(buildObjectFromField('foo.bar', event, 'foo')).toEqual({ + foo: [{ bar: ['baz'] }], + }); + }); + + it('simple nested', () => { + const nestedEvent = { + fields: { + 'foo.bar': [ + { + baz: ['host.name'], + }, + ], + }, + }; + // @ts-expect-error nestedEvent is minimal + expect(buildObjectFromField('foo.bar.baz', nestedEvent, 'foo.bar')).toEqual({ + foo: { + bar: [ + { + baz: ['host.name'], + }, + ], + }, + }); + }); + + it('nested field', () => { + expect( + buildObjectFromField('threat.indicator.matched.atomic', eventHit, 'threat.indicator') + ).toEqual({ + threat: { + indicator: [ + { + matched: { + atomic: ['matched_atomic'], + }, + }, + { + matched: { + atomic: ['matched_atomic_2'], + }, + }, + ], + }, + }); + }); + + it('nested field with multiple values', () => { + expect( + buildObjectFromField('threat.indicator.matched.field', eventHit, 'threat.indicator') + ).toEqual({ + threat: { + indicator: [ + { + matched: { + field: ['matched_field', 'other_matched_field'], + }, + }, + { + matched: { + field: ['matched_field_2'], + }, + }, + ], + }, + }); + }); + }); }); diff --git a/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/all/helpers.ts b/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/all/helpers.ts index e5bb8cb7e14b73..a5425ed30d4ccb 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/all/helpers.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/all/helpers.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { set } from '@elastic/safer-lodash-set'; import { get, has, merge, uniq } from 'lodash/fp'; import { EventHit, @@ -99,6 +100,40 @@ const getValuesFromFields = async ( ); }; +export const buildObjectFromField = ( + fieldPath: string, + hit: EventHit, + nestedParentPath?: string +) => { + if (nestedParentPath) { + const childPath = fieldPath.replace(`${nestedParentPath}.`, ''); + return set( + {}, + nestedParentPath, + (get(nestedParentPath, hit.fields) ?? []).map((nestedFields) => { + const value = get(childPath, nestedFields); + return set({}, childPath, toStringArray(value)); + }) + ); + } else { + const value = has(fieldPath, hit._source) + ? get(fieldPath, hit._source) + : get(fieldPath, hit.fields); + return set({}, fieldPath, toStringArray(value)); + } +}; + +/** + * If a prefix of our full field path is present as a field, we know that our field is nested + */ +const getNestedParentPath = (fieldPath: string, fields: Record = {}) => { + const fieldParts = fieldPath.split('.'); + return Object.keys(fields).find( + (field) => + field !== fieldPath && field === fieldParts.slice(0, field.split('.').length).join('.') + ); +}; + const mergeTimelineFieldsWithHit = async ( fieldName: string, flattenedFields: T, @@ -106,15 +141,12 @@ const mergeTimelineFieldsWithHit = async ( dataFields: readonly string[], ecsFields: readonly string[] ) => { + const nestedParentPath = getNestedParentPath(fieldName, hit.fields); if (fieldName != null || dataFields.includes(fieldName)) { - const fieldNameAsArray = fieldName.split('.'); - const nestedParentFieldName = Object.keys(hit.fields ?? []).find((f) => { - return f === fieldNameAsArray.slice(0, f.split('.').length).join('.'); - }); if ( has(fieldName, hit._source) || has(fieldName, hit.fields) || - nestedParentFieldName != null || + nestedParentPath != null || specialFields.includes(fieldName) ) { const objectWithProperty = { @@ -123,22 +155,13 @@ const mergeTimelineFieldsWithHit = async ( data: dataFields.includes(fieldName) ? [ ...get('node.data', flattenedFields), - ...(await getValuesFromFields(fieldName, hit, nestedParentFieldName)), + ...(await getValuesFromFields(fieldName, hit, nestedParentPath)), ] : get('node.data', flattenedFields), ecs: ecsFields.includes(fieldName) ? { ...get('node.ecs', flattenedFields), - // @ts-expect-error - ...fieldName.split('.').reduceRight( - // @ts-expect-error - (obj, next) => ({ [next]: obj }), - toStringArray( - has(fieldName, hit._source) - ? get(fieldName, hit._source) - : hit.fields[fieldName] - ) - ), + ...buildObjectFromField(fieldName, hit, nestedParentPath), } : get('node.ecs', flattenedFields), }, diff --git a/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/mocks.ts b/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/mocks.ts index 13b7fe70512460..f7ebef4cc6a3d8 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/mocks.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/timeline/factory/events/mocks.ts @@ -40,7 +40,7 @@ export const eventHit = { 'source.geo.location': [{ coordinates: [118.7778, 32.0617], type: 'Point' }], 'threat.indicator': [ { - 'matched.field': ['matched_field'], + 'matched.field': ['matched_field', 'other_matched_field'], first_seen: ['2021-02-22T17:29:25.195Z'], provider: ['yourself'], type: ['custom'],