diff --git a/x-pack/plugins/security_solution/common/endpoint/schema/resolver.ts b/x-pack/plugins/security_solution/common/endpoint/schema/resolver.ts index c67ad3665d004..f3e67f84b2880 100644 --- a/x-pack/plugins/security_solution/common/endpoint/schema/resolver.ts +++ b/x-pack/plugins/security_solution/common/endpoint/schema/resolver.ts @@ -10,7 +10,7 @@ import { schema } from '@kbn/config-schema'; * Used to validate GET requests for a complete resolver tree. */ export const validateTree = { - params: schema.object({ id: schema.string() }), + params: schema.object({ id: schema.string({ minLength: 1 }) }), query: schema.object({ children: schema.number({ defaultValue: 200, min: 0, max: 10000 }), ancestors: schema.number({ defaultValue: 200, min: 0, max: 10000 }), @@ -19,7 +19,7 @@ export const validateTree = { afterEvent: schema.maybe(schema.string()), afterAlert: schema.maybe(schema.string()), afterChild: schema.maybe(schema.string()), - legacyEndpointID: schema.maybe(schema.string()), + legacyEndpointID: schema.maybe(schema.string({ minLength: 1 })), }), }; @@ -27,11 +27,11 @@ export const validateTree = { * Used to validate GET requests for non process events for a specific event. */ export const validateEvents = { - params: schema.object({ id: schema.string() }), + params: schema.object({ id: schema.string({ minLength: 1 }) }), query: schema.object({ events: schema.number({ defaultValue: 1000, min: 1, max: 10000 }), afterEvent: schema.maybe(schema.string()), - legacyEndpointID: schema.maybe(schema.string()), + legacyEndpointID: schema.maybe(schema.string({ minLength: 1 })), }), }; @@ -39,11 +39,11 @@ export const validateEvents = { * Used to validate GET requests for alerts for a specific process. */ export const validateAlerts = { - params: schema.object({ id: schema.string() }), + params: schema.object({ id: schema.string({ minLength: 1 }) }), query: schema.object({ alerts: schema.number({ defaultValue: 1000, min: 1, max: 10000 }), afterAlert: schema.maybe(schema.string()), - legacyEndpointID: schema.maybe(schema.string()), + legacyEndpointID: schema.maybe(schema.string({ minLength: 1 })), }), }; @@ -51,10 +51,10 @@ export const validateAlerts = { * Used to validate GET requests for the ancestors of a process event. */ export const validateAncestry = { - params: schema.object({ id: schema.string() }), + params: schema.object({ id: schema.string({ minLength: 1 }) }), query: schema.object({ ancestors: schema.number({ defaultValue: 200, min: 0, max: 10000 }), - legacyEndpointID: schema.maybe(schema.string()), + legacyEndpointID: schema.maybe(schema.string({ minLength: 1 })), }), }; @@ -62,11 +62,11 @@ export const validateAncestry = { * Used to validate GET requests for children of a specified process event. */ export const validateChildren = { - params: schema.object({ id: schema.string() }), + params: schema.object({ id: schema.string({ minLength: 1 }) }), query: schema.object({ children: schema.number({ defaultValue: 200, min: 1, max: 10000 }), afterChild: schema.maybe(schema.string()), - legacyEndpointID: schema.maybe(schema.string()), + legacyEndpointID: schema.maybe(schema.string({ minLength: 1 })), }), }; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/helpers.test.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/helpers.test.ts index 8ba1a999e2b2a..c8adaa891610a 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/helpers.test.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/helpers.test.ts @@ -6,7 +6,13 @@ import { Ecs } from '../../../../graphql/types'; -import { eventHasNotes, eventIsPinned, getPinTooltip, stringifyEvent } from './helpers'; +import { + eventHasNotes, + eventIsPinned, + getPinTooltip, + stringifyEvent, + isInvestigateInResolverActionEnabled, +} from './helpers'; import { TimelineType } from '../../../../../common/types/timeline'; describe('helpers', () => { @@ -242,4 +248,54 @@ describe('helpers', () => { expect(eventIsPinned({ eventId, pinnedEventIds })).toEqual(false); }); }); + + describe('isInvestigateInResolverActionEnabled', () => { + it('returns false if agent.type does not equal endpoint', () => { + const data: Ecs = { _id: '1', agent: { type: ['blah'] } }; + + expect(isInvestigateInResolverActionEnabled(data)).toBeFalsy(); + }); + + it('returns false if agent.type does not have endpoint in first array index', () => { + const data: Ecs = { _id: '1', agent: { type: ['blah', 'endpoint'] } }; + + expect(isInvestigateInResolverActionEnabled(data)).toBeFalsy(); + }); + + it('returns false if process.entity_id is not defined', () => { + const data: Ecs = { _id: '1', agent: { type: ['endpoint'] } }; + + expect(isInvestigateInResolverActionEnabled(data)).toBeFalsy(); + }); + + it('returns true if agent.type has endpoint in first array index', () => { + const data: Ecs = { + _id: '1', + agent: { type: ['endpoint', 'blah'] }, + process: { entity_id: ['5'] }, + }; + + expect(isInvestigateInResolverActionEnabled(data)).toBeTruthy(); + }); + + it('returns false if multiple entity_ids', () => { + const data: Ecs = { + _id: '1', + agent: { type: ['endpoint', 'blah'] }, + process: { entity_id: ['5', '10'] }, + }; + + expect(isInvestigateInResolverActionEnabled(data)).toBeFalsy(); + }); + + it('returns false if entity_id is an empty string', () => { + const data: Ecs = { + _id: '1', + agent: { type: ['endpoint', 'blah'] }, + process: { entity_id: [''] }, + }; + + expect(isInvestigateInResolverActionEnabled(data)).toBeFalsy(); + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/helpers.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/helpers.ts index 067cea175c99b..6a5e25632c29b 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/helpers.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/helpers.ts @@ -106,7 +106,8 @@ export const getEventType = (event: Ecs): Omit => { export const isInvestigateInResolverActionEnabled = (ecsData?: Ecs) => { return ( get(['agent', 'type', 0], ecsData) === 'endpoint' && - get(['process', 'entity_id'], ecsData)?.length > 0 + get(['process', 'entity_id'], ecsData)?.length === 1 && + get(['process', 'entity_id', 0], ecsData) !== '' ); }; diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/entity.ts b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/entity.ts index ae91201646103..c79bcda71de9b 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/entity.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/entity.ts @@ -70,6 +70,13 @@ export function handleEntities(): RequestHandler implements MSearchQuer } private buildQuery(ids: string | string[]): { query: JsonObject; index: string | string[] } { - const idsArray = ResolverQuery.createIdsArray(ids); + // only accept queries for entity_ids that are not an empty string + const idsArray = ResolverQuery.createIdsArray(ids).filter((id) => id !== ''); if (this.endpointID) { return { query: this.legacyQuery(this.endpointID, idsArray), index: legacyEventIndexPattern }; } diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/queries/children.ts b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/queries/children.ts index 7fd3808662baa..d99533e23f2c2 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/queries/children.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/queries/children.ts @@ -74,6 +74,18 @@ export class ChildrenQuery extends ResolverQuery { ], }, }, + { + exists: { + field: 'process.entity_id', + }, + }, + { + bool: { + must_not: { + term: { 'process.entity_id': '' }, + }, + }, + }, { term: { 'event.category': 'process' }, }, diff --git a/x-pack/test/security_solution_endpoint_api_int/apis/index.ts b/x-pack/test/security_solution_endpoint_api_int/apis/index.ts index fb11a7c52fd35..56adc2382e234 100644 --- a/x-pack/test/security_solution_endpoint_api_int/apis/index.ts +++ b/x-pack/test/security_solution_endpoint_api_int/apis/index.ts @@ -26,7 +26,8 @@ export default function endpointAPIIntegrationTests(providerContext: FtrProvider before(async () => { await ingestManager.setup(); }); - loadTestFile(require.resolve('./resolver')); + loadTestFile(require.resolve('./resolver/entity_id')); + loadTestFile(require.resolve('./resolver/tree')); loadTestFile(require.resolve('./metadata')); loadTestFile(require.resolve('./policy')); loadTestFile(require.resolve('./artifacts')); diff --git a/x-pack/test/security_solution_endpoint_api_int/apis/resolver/entity_id.ts b/x-pack/test/security_solution_endpoint_api_int/apis/resolver/entity_id.ts new file mode 100644 index 0000000000000..4f2a801377204 --- /dev/null +++ b/x-pack/test/security_solution_endpoint_api_int/apis/resolver/entity_id.ts @@ -0,0 +1,156 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import expect from '@kbn/expect'; +import { SearchResponse } from 'elasticsearch'; +import { eventsIndexPattern } from '../../../../plugins/security_solution/common/endpoint/constants'; +import { + ResolverTree, + ResolverEntityIndex, +} from '../../../../plugins/security_solution/common/endpoint/types'; +import { FtrProviderContext } from '../../ftr_provider_context'; +import { + EndpointDocGenerator, + Event, +} from '../../../../plugins/security_solution/common/endpoint/generate_data'; +import { InsertedEvents } from '../../services/resolver'; + +export default function resolverAPIIntegrationTests({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const resolver = getService('resolverGenerator'); + const es = getService('es'); + const generator = new EndpointDocGenerator('resolver'); + + describe('Resolver handling of entity ids', () => { + describe('entity api', () => { + let origin: Event; + let genData: InsertedEvents; + before(async () => { + origin = generator.generateEvent({ parentEntityID: 'a' }); + origin.process.entity_id = ''; + genData = await resolver.insertEvents([origin]); + }); + + after(async () => { + await resolver.deleteData(genData); + }); + + it('excludes events that have an empty entity_id field', async () => { + // first lets get the _id of the document using the parent.process.entity_id + // then we'll use the API to search for that specific document + const res = await es.search>({ + index: genData.indices[0], + body: { + query: { + bool: { + filter: [ + { + term: { 'process.parent.entity_id': origin.process.parent!.entity_id }, + }, + ], + }, + }, + }, + }); + const { body }: { body: ResolverEntityIndex } = await supertest.get( + // using the same indices value here twice to force the query parameter to be an array + // for some reason using supertest's query() function doesn't construct a parsable array + `/api/endpoint/resolver/entity?_id=${res.body.hits.hits[0]._id}&indices=${eventsIndexPattern}&indices=${eventsIndexPattern}` + ); + expect(body).to.be.empty(); + }); + }); + + describe('children', () => { + let origin: Event; + let childNoEntityID: Event; + let childWithEntityID: Event; + let events: Event[]; + let genData: InsertedEvents; + + before(async () => { + // construct a tree with an origin and two direct children. One child will not have an entity_id. That child + // should not be returned by the backend. + origin = generator.generateEvent({ entityID: 'a' }); + childNoEntityID = generator.generateEvent({ + parentEntityID: origin.process.entity_id, + ancestry: [origin.process.entity_id], + }); + // force it to be empty + childNoEntityID.process.entity_id = ''; + + childWithEntityID = generator.generateEvent({ + entityID: 'b', + parentEntityID: origin.process.entity_id, + ancestry: [origin.process.entity_id], + }); + events = [origin, childNoEntityID, childWithEntityID]; + genData = await resolver.insertEvents(events); + }); + + after(async () => { + await resolver.deleteData(genData); + }); + + it('does not find children without a process entity_id', async () => { + const { body }: { body: ResolverTree } = await supertest + .get(`/api/endpoint/resolver/${origin.process.entity_id}`) + .expect(200); + expect(body.children.childNodes.length).to.be(1); + expect(body.children.childNodes[0].entityID).to.be(childWithEntityID.process.entity_id); + }); + }); + + describe('ancestors', () => { + let origin: Event; + let ancestor1: Event; + let ancestor2: Event; + let ancestorNoEntityID: Event; + let events: Event[]; + let genData: InsertedEvents; + + before(async () => { + // construct a tree with an origin that has two ancestors. The origin will have an empty string as one of the + // entity_ids in the ancestry array. This is to make sure that the backend will not query for that event. + ancestor2 = generator.generateEvent({ + entityID: '2', + }); + ancestor1 = generator.generateEvent({ + entityID: '1', + parentEntityID: ancestor2.process.entity_id, + ancestry: [ancestor2.process.entity_id], + }); + + // we'll insert an event that doesn't have an entity id so if the backend does search for it, it should be + // returned and our test should fail + ancestorNoEntityID = generator.generateEvent({ + ancestry: [ancestor2.process.entity_id], + }); + ancestorNoEntityID.process.entity_id = ''; + + origin = generator.generateEvent({ + entityID: 'a', + parentEntityID: ancestor1.process.entity_id, + ancestry: ['', ancestor2.process.entity_id], + }); + + events = [origin, ancestor1, ancestor2, ancestorNoEntityID]; + genData = await resolver.insertEvents(events); + }); + + after(async () => { + await resolver.deleteData(genData); + }); + + it('does not query for ancestors that have an empty string for the entity_id', async () => { + const { body }: { body: ResolverTree } = await supertest + .get(`/api/endpoint/resolver/${origin.process.entity_id}`) + .expect(200); + expect(body.ancestry.ancestors.length).to.be(1); + expect(body.ancestry.ancestors[0].entityID).to.be(ancestor2.process.entity_id); + }); + }); + }); +} diff --git a/x-pack/test/security_solution_endpoint_api_int/apis/resolver.ts b/x-pack/test/security_solution_endpoint_api_int/apis/resolver/tree.ts similarity index 98% rename from x-pack/test/security_solution_endpoint_api_int/apis/resolver.ts rename to x-pack/test/security_solution_endpoint_api_int/apis/resolver/tree.ts index 3b515f86c6761..3527e7e575c99 100644 --- a/x-pack/test/security_solution_endpoint_api_int/apis/resolver.ts +++ b/x-pack/test/security_solution_endpoint_api_int/apis/resolver/tree.ts @@ -16,12 +16,12 @@ import { LegacyEndpointEvent, ResolverNodeStats, ResolverRelatedAlerts, -} from '../../../plugins/security_solution/common/endpoint/types'; +} from '../../../../plugins/security_solution/common/endpoint/types'; import { parentEntityId, eventId, -} from '../../../plugins/security_solution/common/endpoint/models/event'; -import { FtrProviderContext } from '../ftr_provider_context'; +} from '../../../../plugins/security_solution/common/endpoint/models/event'; +import { FtrProviderContext } from '../../ftr_provider_context'; import { Event, Tree, @@ -29,8 +29,8 @@ import { RelatedEventCategory, RelatedEventInfo, categoryMapping, -} from '../../../plugins/security_solution/common/endpoint/generate_data'; -import { Options, GeneratedTrees } from '../services/resolver'; +} from '../../../../plugins/security_solution/common/endpoint/generate_data'; +import { Options, GeneratedTrees } from '../../services/resolver'; /** * Check that the given lifecycle is in the resolver tree's corresponding map @@ -256,7 +256,7 @@ export default function resolverAPIIntegrationTests({ getService }: FtrProviderC ancestryArraySize: 2, }; - describe('Resolver', () => { + describe('Resolver tree', () => { before(async () => { await esArchiver.load('endpoint/resolver/api_feature'); resolverTrees = await resolver.createTrees(treeOptions); @@ -264,7 +264,7 @@ export default function resolverAPIIntegrationTests({ getService }: FtrProviderC tree = resolverTrees.trees[0]; }); after(async () => { - await resolver.deleteTrees(resolverTrees); + await resolver.deleteData(resolverTrees); // this unload is for an endgame-* index so it does not use data streams await esArchiver.unload('endpoint/resolver/api_feature'); }); diff --git a/x-pack/test/security_solution_endpoint_api_int/services/resolver.ts b/x-pack/test/security_solution_endpoint_api_int/services/resolver.ts index 7f568a2b00314..335689b804d5b 100644 --- a/x-pack/test/security_solution_endpoint_api_int/services/resolver.ts +++ b/x-pack/test/security_solution_endpoint_api_int/services/resolver.ts @@ -7,9 +7,12 @@ import { TreeOptions, Tree, EndpointDocGenerator, + Event, } from '../../../plugins/security_solution/common/endpoint/generate_data'; import { FtrProviderContext } from '../ftr_provider_context'; +const processIndex = 'logs-endpoint.events.process-default'; + /** * Options for build a resolver tree */ @@ -26,17 +29,41 @@ export interface Options extends TreeOptions { */ export interface GeneratedTrees { trees: Tree[]; - eventsIndex: string; - alertsIndex: string; + indices: string[]; +} + +/** + * Structure containing the events inserted into ES and the index they live in + */ +export interface InsertedEvents { + events: Event[]; + indices: string[]; +} + +interface BulkCreateHeader { + create: { + _index: string; + }; } export function ResolverGeneratorProvider({ getService }: FtrProviderContext) { const client = getService('es'); return { + async insertEvents( + events: Event[], + eventsIndex: string = processIndex + ): Promise { + const body = events.reduce((array: Array, doc) => { + array.push({ create: { _index: eventsIndex } }, doc); + return array; + }, []); + await client.bulk({ body, refresh: true }); + return { events, indices: [eventsIndex] }; + }, async createTrees( options: Options, - eventsIndex: string = 'logs-endpoint.events.process-default', + eventsIndex: string = processIndex, alertsIndex: string = 'logs-endpoint.alerts-default' ): Promise { const seed = options.seed || 'resolver-seed'; @@ -45,7 +72,7 @@ export function ResolverGeneratorProvider({ getService }: FtrProviderContext) { const numTrees = options.numTrees ?? 1; for (let j = 0; j < numTrees; j++) { const tree = generator.generateTree(options); - const body = tree.allEvents.reduce((array: Array>, doc) => { + const body = tree.allEvents.reduce((array: Array, doc) => { let index = eventsIndex; if (doc.event.kind === 'alert') { index = alertsIndex; @@ -60,23 +87,21 @@ export function ResolverGeneratorProvider({ getService }: FtrProviderContext) { await client.bulk({ body, refresh: true }); allTrees.push(tree); } - return { trees: allTrees, eventsIndex, alertsIndex }; + return { trees: allTrees, indices: [eventsIndex, alertsIndex] }; }, - async deleteTrees(trees: GeneratedTrees) { - /** - * The ingest manager handles creating the template for the endpoint's indices. It is using a V2 template - * with data streams. Data streams aren't included in the javascript elasticsearch client in kibana yet so we - * need to do raw requests here. Delete a data stream is slightly different than that of a regular index which - * is why we're using _data_stream here. - */ - await client.transport.request({ - method: 'DELETE', - path: `_data_stream/${trees.eventsIndex}`, - }); - await client.transport.request({ - method: 'DELETE', - path: `_data_stream/${trees.alertsIndex}`, - }); + async deleteData(genData: { indices: string[] }) { + for (const index of genData.indices) { + /** + * The ingest manager handles creating the template for the endpoint's indices. It is using a V2 template + * with data streams. Data streams aren't included in the javascript elasticsearch client in kibana yet so we + * need to do raw requests here. Delete a data stream is slightly different than that of a regular index which + * is why we're using _data_stream here. + */ + await client.transport.request({ + method: 'DELETE', + path: `_data_stream/${index}`, + }); + } }, }; }