From 9dd843561a23b705f19651a1ba14c3e2b784aed6 Mon Sep 17 00:00:00 2001 From: Jonathan Buttner <56361221+jonathan-buttner@users.noreply.github.com> Date: Mon, 15 Jun 2020 12:01:37 -0400 Subject: [PATCH] [Endpoint] Adding alerts route (#68183) * Adding alerts route * Adding related alerts generator changes, tests, and script updates * Fixing missed parameter * Aligning the AlertEvent and ResolverEvent definition * Fixing type errors * Fixing import error Co-authored-by: Elastic Machine --- .../common/endpoint/generate_data.test.ts | 78 +++++++++---- .../common/endpoint/generate_data.ts | 106 +++++++++++++----- .../common/endpoint/schema/resolver.ts | 14 +++ .../common/endpoint/types.ts | 26 +++-- .../common/endpoint_alerts/types.ts | 7 +- .../public/endpoint_alerts/view/index.tsx | 3 +- .../scripts/endpoint/README.md | 4 +- .../scripts/endpoint/resolver_generator.ts | 9 +- .../server/endpoint/routes/resolver.ts | 11 ++ .../server/endpoint/routes/resolver/alerts.ts | 37 ++++++ .../routes/resolver/queries/alerts.ts | 73 ++++++++++++ .../server/endpoint/routes/resolver/tree.ts | 12 +- .../resolver/utils/children_helper.test.ts | 2 +- .../endpoint/routes/resolver/utils/fetch.ts | 37 +++++- .../endpoint/routes/resolver/utils/node.ts | 20 ++++ .../endpoint/routes/resolver/utils/tree.ts | 17 +++ .../api_integration/apis/endpoint/resolver.ts | 63 ++++++++++- 17 files changed, 452 insertions(+), 67 deletions(-) create mode 100644 x-pack/plugins/security_solution/server/endpoint/routes/resolver/alerts.ts create mode 100644 x-pack/plugins/security_solution/server/endpoint/routes/resolver/queries/alerts.ts diff --git a/x-pack/plugins/security_solution/common/endpoint/generate_data.test.ts b/x-pack/plugins/security_solution/common/endpoint/generate_data.test.ts index 6c8c5e3f51808..ba4f2251564e8 100644 --- a/x-pack/plugins/security_solution/common/endpoint/generate_data.test.ts +++ b/x-pack/plugins/security_solution/common/endpoint/generate_data.test.ts @@ -100,11 +100,40 @@ describe('data generator', () => { expect(processEvent.process.name).not.toBeNull(); }); + describe('creates an origin alert when no related alerts are requested', () => { + let tree: Tree; + beforeEach(() => { + tree = generator.generateTree({ + alwaysGenMaxChildrenPerNode: true, + ancestors: 3, + children: 3, + generations: 3, + percentTerminated: 100, + percentWithRelated: 100, + relatedEvents: 0, + relatedAlerts: 0, + }); + }); + + it('creates an alert for the origin node but no other nodes', () => { + for (const node of tree.ancestry.values()) { + expect(node.relatedAlerts.length).toEqual(0); + } + + for (const node of tree.children.values()) { + expect(node.relatedAlerts.length).toEqual(0); + } + + expect(tree.origin.relatedAlerts.length).toEqual(1); + }); + }); + describe('creates a resolver tree structure', () => { let tree: Tree; const ancestors = 3; const childrenPerNode = 3; const generations = 3; + const relatedAlerts = 4; beforeEach(() => { tree = generator.generateTree({ alwaysGenMaxChildrenPerNode: true, @@ -118,14 +147,16 @@ describe('data generator', () => { { category: RelatedEventCategory.File, count: 2 }, { category: RelatedEventCategory.Network, count: 1 }, ], + relatedAlerts, }); }); const eventInNode = (event: Event, node: TreeNode) => { const inLifecycle = node.lifecycle.includes(event); const inRelated = node.relatedEvents.includes(event); + const inRelatedAlerts = node.relatedAlerts.includes(event); - return (inRelated || inLifecycle) && event.process.entity_id === node.id; + return (inRelated || inRelatedAlerts || inLifecycle) && event.process.entity_id === node.id; }; it('has the right related events for each node', () => { @@ -158,6 +189,18 @@ describe('data generator', () => { checkRelatedEvents(tree.origin); }); + it('has the right number of related alerts for each node', () => { + for (const node of tree.ancestry.values()) { + expect(node.relatedAlerts.length).toEqual(relatedAlerts); + } + + for (const node of tree.children.values()) { + expect(node.relatedAlerts.length).toEqual(relatedAlerts); + } + + expect(tree.origin.relatedAlerts.length).toEqual(relatedAlerts); + }); + it('has the right number of ancestors', () => { expect(tree.ancestry.size).toEqual(ancestors); }); @@ -187,33 +230,28 @@ describe('data generator', () => { expect(tree.allEvents.length).toBeGreaterThan(0); tree.allEvents.forEach((event) => { - if (event.event.kind === 'alert') { - expect(event).toEqual(tree.alertEvent); - } else { - const ancestor = tree.ancestry.get(event.process.entity_id); - if (ancestor) { - expect(eventInNode(event, ancestor)).toBeTruthy(); - return; - } - - const children = tree.children.get(event.process.entity_id); - if (children) { - expect(eventInNode(event, children)).toBeTruthy(); - return; - } + const ancestor = tree.ancestry.get(event.process.entity_id); + if (ancestor) { + expect(eventInNode(event, ancestor)).toBeTruthy(); + return; + } - expect(eventInNode(event, tree.origin)).toBeTruthy(); + const children = tree.children.get(event.process.entity_id); + if (children) { + expect(eventInNode(event, children)).toBeTruthy(); + return; } + + expect(eventInNode(event, tree.origin)).toBeTruthy(); }); }); const nodeEventCount = (node: TreeNode) => { - return node.lifecycle.length + node.relatedEvents.length; + return node.lifecycle.length + node.relatedEvents.length + node.relatedAlerts.length; }; it('has the correct number of total events', () => { - // starts at 1 because the alert is in the allEvents array - let total = 1; + let total = 0; for (const node of tree.ancestry.values()) { total += nodeEventCount(node); } @@ -232,7 +270,7 @@ describe('data generator', () => { let events: Event[]; beforeEach(() => { - events = generator.createAlertEventAncestry(3, 0, 0, 0); + events = generator.createAlertEventAncestry(3, 0, 0, 0, 0); }); it('with n-1 process events', () => { diff --git a/x-pack/plugins/security_solution/common/endpoint/generate_data.ts b/x-pack/plugins/security_solution/common/endpoint/generate_data.ts index 2d004d3315beb..7944d7d365ed8 100644 --- a/x-pack/plugins/security_solution/common/endpoint/generate_data.ts +++ b/x-pack/plugins/security_solution/common/endpoint/generate_data.ts @@ -183,6 +183,7 @@ interface HostInfo { agent: { version: string; id: string; + type: string; }; host: Host; endpoint: { @@ -222,6 +223,7 @@ export interface TreeNode { id: string; lifecycle: Event[]; relatedEvents: Event[]; + relatedAlerts: Event[]; } /** @@ -237,7 +239,6 @@ export interface Tree { */ ancestry: Map; origin: TreeNode; - alertEvent: Event; /** * All events from children, ancestry, origin, and the alert in a single array */ @@ -251,7 +252,8 @@ export interface TreeOptions { ancestors?: number; generations?: number; children?: number; - relatedEvents?: RelatedEventInfo[]; + relatedEvents?: RelatedEventInfo[] | number; + relatedAlerts?: number; percentWithRelated?: number; percentTerminated?: number; alwaysGenMaxChildrenPerNode?: boolean; @@ -294,6 +296,7 @@ export class EndpointDocGenerator { agent: { version: this.randomVersion(), id: this.seededUUIDv4(), + type: 'endpoint', }, elastic: { agent: { @@ -343,6 +346,9 @@ export class EndpointDocGenerator { return { ...this.commonInfo, '@timestamp': ts, + ecs: { + version: '1.4.0', + }, event: { action: this.randomChoice(FILE_OPERATIONS), kind: 'alert', @@ -482,30 +488,33 @@ export class EndpointDocGenerator { // and add the event to the right array. let node = nodeMap.get(nodeId); if (!node) { - node = { id: nodeId, lifecycle: [], relatedEvents: [] }; + node = { id: nodeId, lifecycle: [], relatedEvents: [], relatedAlerts: [] }; } // place the event in the right array depending on its category - if (event.event.category === 'process') { - node.lifecycle.push(event); - } else { - node.relatedEvents.push(event); + if (event.event.kind === 'event') { + if (event.event.category === 'process') { + node.lifecycle.push(event); + } else { + node.relatedEvents.push(event); + } + } else if (event.event.kind === 'alert') { + node.relatedAlerts.push(event); } + return nodeMap.set(nodeId, node); }; const ancestry = this.createAlertEventAncestry( options.ancestors, options.relatedEvents, + options.relatedAlerts, options.percentWithRelated, options.percentTerminated ); - // create a mapping of entity_id -> lifecycle and related events - // slice gets everything but the last item which is an alert - const ancestryNodes: Map = ancestry - .slice(0, -1) - .reduce(addEventToMap, new Map()); + // create a mapping of entity_id -> {lifecycle, related events, and related alerts} + const ancestryNodes: Map = ancestry.reduce(addEventToMap, new Map()); const alert = ancestry[ancestry.length - 1]; const origin = ancestryNodes.get(alert.process.entity_id); @@ -522,6 +531,7 @@ export class EndpointDocGenerator { options.generations, options.children, options.relatedEvents, + options.relatedAlerts, options.percentWithRelated, options.percentTerminated, options.alwaysGenMaxChildrenPerNode @@ -533,7 +543,6 @@ export class EndpointDocGenerator { return { children: childrenNodes, ancestry: ancestryNodes, - alertEvent: alert, allEvents: [...ancestry, ...children], origin, }; @@ -547,6 +556,7 @@ export class EndpointDocGenerator { * @param childGenerations - number of child generations to create relative to the alert * @param maxChildrenPerNode - maximum number of children for any given node in the tree * @param relatedEventsPerNode - number of related events (file, registry, etc) to create for each process event in the tree + * @param relatedAlertsPerNode - number of alerts to generate for each node, if this is 0 an alert will still be generated for the origin node * @param percentNodesWithRelated - percent of nodes which should have related events * @param percentTerminated - percent of nodes which will have process termination events * @param alwaysGenMaxChildrenPerNode - flag to always return the max children per node instead of it being a random number of children @@ -557,6 +567,7 @@ export class EndpointDocGenerator { childGenerations?: number, maxChildrenPerNode?: number, relatedEventsPerNode?: number, + relatedAlertsPerNode?: number, percentNodesWithRelated?: number, percentTerminated?: number, alwaysGenMaxChildrenPerNode?: boolean @@ -567,6 +578,7 @@ export class EndpointDocGenerator { childGenerations, maxChildrenPerNode, relatedEventsPerNode, + relatedAlertsPerNode, percentNodesWithRelated, percentTerminated, alwaysGenMaxChildrenPerNode @@ -583,6 +595,7 @@ export class EndpointDocGenerator { * @param maxChildrenPerNode - maximum number of children for any given node in the tree * @param relatedEventsPerNode - can be an array of RelatedEventInfo objects describing the related events that should be generated for each process node * or a number which defines the number of related events and will default to random categories + * @param relatedAlertsPerNode - number of alerts to generate for each node, if this is 0 an alert will still be generated for the origin node * @param percentNodesWithRelated - percent of nodes which should have related events * @param percentTerminated - percent of nodes which will have process termination events * @param alwaysGenMaxChildrenPerNode - flag to always return the max children per node instead of it being a random number of children @@ -592,6 +605,7 @@ export class EndpointDocGenerator { childGenerations?: number, maxChildrenPerNode?: number, relatedEventsPerNode?: RelatedEventInfo[] | number, + relatedAlertsPerNode?: number, percentNodesWithRelated?: number, percentTerminated?: number, alwaysGenMaxChildrenPerNode?: boolean @@ -611,6 +625,7 @@ export class EndpointDocGenerator { childGenerations, maxChildrenPerNode, relatedEventsPerNode, + relatedAlertsPerNode, percentNodesWithRelated, percentTerminated, alwaysGenMaxChildrenPerNode @@ -622,12 +637,14 @@ export class EndpointDocGenerator { * @param alertAncestors - number of ancestor generations to create * @param relatedEventsPerNode - can be an array of RelatedEventInfo objects describing the related events that should be generated for each process node * or a number which defines the number of related events and will default to random categories - * @param pctWithRelated - percent of ancestors that will have related events + * @param relatedAlertsPerNode - number of alerts to generate for each node, if this is 0 an alert will still be generated for the origin node + * @param pctWithRelated - percent of ancestors that will have related events and alerts * @param pctWithTerminated - percent of ancestors that will have termination events */ public createAlertEventAncestry( alertAncestors = 3, relatedEventsPerNode: RelatedEventInfo[] | number = 5, + relatedAlertsPerNode: number = 3, pctWithRelated = 30, pctWithTerminated = 100 ): Event[] { @@ -638,16 +655,32 @@ export class EndpointDocGenerator { let ancestor = root; let timestamp = root['@timestamp'] + 1000; - // generate related alerts for root - const processDuration: number = 6 * 3600; - if (this.randomN(100) < pctWithRelated) { + const addRelatedAlerts = ( + node: Event, + alertsPerNode: number, + secBeforeAlert: number, + eventList: Event[] + ) => { + for (const relatedAlert of this.relatedAlertsGenerator(node, alertsPerNode, secBeforeAlert)) { + eventList.push(relatedAlert); + } + }; + + const addRelatedEvents = (node: Event, secBeforeEvent: number, eventList: Event[]) => { for (const relatedEvent of this.relatedEventsGenerator( - ancestor, + node, relatedEventsPerNode, - processDuration + secBeforeEvent )) { - events.push(relatedEvent); + eventList.push(relatedEvent); } + }; + + // generate related alerts for rootW + const processDuration: number = 6 * 3600; + if (this.randomN(100) < pctWithRelated) { + addRelatedEvents(ancestor, processDuration, events); + addRelatedAlerts(ancestor, relatedAlertsPerNode, processDuration, events); } // generate the termination event for the root @@ -687,13 +720,15 @@ export class EndpointDocGenerator { // generate related alerts for ancestor if (this.randomN(100) < pctWithRelated) { - for (const relatedEvent of this.relatedEventsGenerator( - ancestor, - relatedEventsPerNode, - processDuration - )) { - events.push(relatedEvent); + addRelatedEvents(ancestor, processDuration, events); + let numAlertsPerNode = relatedAlertsPerNode; + // if this is the last ancestor, create one less related alert so that we have a uniform amount of related alerts + // for each node. The last alert at the end of this function should always be created even if the related alerts + // amount is 0 + if (i === alertAncestors - 1) { + numAlertsPerNode -= 1; } + addRelatedAlerts(ancestor, numAlertsPerNode, processDuration, events); } } events.push( @@ -718,6 +753,7 @@ export class EndpointDocGenerator { generations = 2, maxChildrenPerNode = 2, relatedEventsPerNode: RelatedEventInfo[] | number = 3, + relatedAlertsPerNode: number = 3, percentNodesWithRelated = 100, percentChildrenTerminated = 100, alwaysGenMaxChildrenPerNode = false @@ -776,6 +812,7 @@ export class EndpointDocGenerator { } if (this.randomN(100) < percentNodesWithRelated) { yield* this.relatedEventsGenerator(child, relatedEventsPerNode, processDuration); + yield* this.relatedAlertsGenerator(child, relatedAlertsPerNode, processDuration); } } } @@ -820,6 +857,23 @@ export class EndpointDocGenerator { } } + /** + * Creates related alerts for a process event + * @param node - process event to relate alerts to by entityID + * @param relatedAlerts - number which defines the number of related alerts to create + * @param alertCreationTime - maximum number of seconds after process event that related alert timestamp can be + */ + public *relatedAlertsGenerator( + node: Event, + relatedAlerts: number = 3, + alertCreationTime: number = 6 * 3600 + ) { + for (let i = 0; i < relatedAlerts; i++) { + const ts = node['@timestamp'] + this.randomN(alertCreationTime) * 1000; + yield this.generateAlert(ts, node.process.entity_id, node.process.parent?.entity_id); + } + } + /** * Generates an Ingest `datasource` that includes the Endpoint Policy data */ 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 8d60a532aa67c..f5c3fd519c9c5 100644 --- a/x-pack/plugins/security_solution/common/endpoint/schema/resolver.ts +++ b/x-pack/plugins/security_solution/common/endpoint/schema/resolver.ts @@ -16,7 +16,9 @@ export const validateTree = { generations: schema.number({ defaultValue: 3, min: 0, max: 3 }), ancestors: schema.number({ defaultValue: 3, min: 0, max: 5 }), events: schema.number({ defaultValue: 100, min: 0, max: 1000 }), + alerts: schema.number({ defaultValue: 100, min: 0, max: 1000 }), afterEvent: schema.maybe(schema.string()), + afterAlert: schema.maybe(schema.string()), afterChild: schema.maybe(schema.string()), legacyEndpointID: schema.maybe(schema.string()), }), @@ -34,6 +36,18 @@ export const validateEvents = { }), }; +/** + * Used to validate GET requests for alerts for a specific process. + */ +export const validateAlerts = { + params: schema.object({ id: schema.string() }), + query: schema.object({ + alerts: schema.number({ defaultValue: 100, min: 1, max: 1000 }), + afterAlert: schema.maybe(schema.string()), + legacyEndpointID: schema.maybe(schema.string()), + }), +}; + /** * Used to validate GET requests for the ancestors of a process event. */ diff --git a/x-pack/plugins/security_solution/common/endpoint/types.ts b/x-pack/plugins/security_solution/common/endpoint/types.ts index cfbf8f176b32d..0341b7593caf0 100644 --- a/x-pack/plugins/security_solution/common/endpoint/types.ts +++ b/x-pack/plugins/security_solution/common/endpoint/types.ts @@ -107,6 +107,7 @@ export interface ResolverTree { entityID: string; children: ResolverChildren; relatedEvents: Omit; + relatedAlerts: Omit; ancestry: ResolverAncestry; lifecycle: ResolverEvent[]; stats: ResolverNodeStats; @@ -148,6 +149,15 @@ export interface ResolverRelatedEvents { nextEvent: string | null; } +/** + * Response structure for the alerts route. + */ +export interface ResolverRelatedAlerts { + entityID: string; + alerts: ResolverEvent[]; + nextAlert: string | null; +} + /** * Returned by the server via /api/endpoint/metadata */ @@ -236,11 +246,15 @@ interface DllFields { /** * Describes an Alert Event. */ -export type AlertEvent = Immutable<{ +export interface AlertEvent { '@timestamp': number; agent: { id: string; version: string; + type: string; + }; + ecs: { + version: string; }; event: { id: string; @@ -321,7 +335,7 @@ export type AlertEvent = Immutable<{ }; host: Host; dll?: DllFields[]; -}>; +} /** * The status of the host @@ -423,13 +437,7 @@ export interface EndpointEvent { id: string; kind: string; }; - host: { - id: string; - hostname: string; - ip: string[]; - mac: string[]; - os: HostOS; - }; + host: Host; process: { entity_id: string; name: string; diff --git a/x-pack/plugins/security_solution/common/endpoint_alerts/types.ts b/x-pack/plugins/security_solution/common/endpoint_alerts/types.ts index 3fbde79414aa0..d37051faeb740 100644 --- a/x-pack/plugins/security_solution/common/endpoint_alerts/types.ts +++ b/x-pack/plugins/security_solution/common/endpoint_alerts/types.ts @@ -15,7 +15,6 @@ import { AlertEvent, KbnConfigSchemaInputTypeOf, AppLocation, - Immutable, } from '../endpoint/types'; /** @@ -119,7 +118,7 @@ export type AlertListData = AlertResultList; export interface AlertListState { /** Array of alert items. */ - readonly alerts: Immutable; + readonly alerts: AlertData[]; /** The total number of alerts on the page. */ readonly total: number; @@ -131,10 +130,10 @@ export interface AlertListState { readonly pageIndex: number; /** Current location object from React Router history. */ - readonly location?: Immutable; + readonly location?: AppLocation; /** Specific Alert data to be shown in the details view */ - readonly alertDetails?: Immutable; + readonly alertDetails?: AlertDetails; /** Search bar state including indexPatterns */ readonly searchBar: AlertsSearchBarState; diff --git a/x-pack/plugins/security_solution/public/endpoint_alerts/view/index.tsx b/x-pack/plugins/security_solution/public/endpoint_alerts/view/index.tsx index b39ea678596a4..6ad0d5ec94219 100644 --- a/x-pack/plugins/security_solution/public/endpoint_alerts/view/index.tsx +++ b/x-pack/plugins/security_solution/public/endpoint_alerts/view/index.tsx @@ -32,6 +32,7 @@ import { useAlertListSelector } from './hooks/use_alerts_selector'; import { AlertDetailsOverview } from './details'; import { FormattedDate } from './formatted_date'; import { AlertIndexSearchBar } from './index_search_bar'; +import { Immutable } from '../../../common/endpoint/types'; export const AlertIndex = memo(() => { const history = useHistory(); @@ -145,7 +146,7 @@ export const AlertIndex = memo(() => { history.push(urlFromQueryParams(paramsWithoutFlyoutDetails)); }, [history, queryParams]); - const timestampForRows: Map = useMemo(() => { + const timestampForRows: Map, number> = useMemo(() => { return new Map( alertListData.map((alertData) => { return [alertData, alertData['@timestamp']]; diff --git a/x-pack/plugins/security_solution/scripts/endpoint/README.md b/x-pack/plugins/security_solution/scripts/endpoint/README.md index f78cc5e1717e6..0c36a47307232 100644 --- a/x-pack/plugins/security_solution/scripts/endpoint/README.md +++ b/x-pack/plugins/security_solution/scripts/endpoint/README.md @@ -44,8 +44,10 @@ Options: [number] [default: 3] --relatedEvents, --related number of related events to create for each process event [number] [default: 5] + --relatedAlerts, --relAlerts number of related alerts to create for each + process event [number] [default: 5] --percentWithRelated, --pr percent of process events to add related events - to [number] [default: 30] + and related alerts to [number] [default: 30] --percentTerminated, --pt percent of process events to add termination event for [number] [default: 30] --maxChildrenPerNode, --maxCh always generate the max number of children per diff --git a/x-pack/plugins/security_solution/scripts/endpoint/resolver_generator.ts b/x-pack/plugins/security_solution/scripts/endpoint/resolver_generator.ts index fb25d22a1b5fe..e4460b337960f 100644 --- a/x-pack/plugins/security_solution/scripts/endpoint/resolver_generator.ts +++ b/x-pack/plugins/security_solution/scripts/endpoint/resolver_generator.ts @@ -135,9 +135,15 @@ async function main() { type: 'number', default: 5, }, + relatedAlerts: { + alias: 'relAlerts', + describe: 'number of related alerts to create for each process event', + type: 'number', + default: 5, + }, percentWithRelated: { alias: 'pr', - describe: 'percent of process events to add related events to', + describe: 'percent of process events to add related events and related alerts to', type: 'number', default: 30, }, @@ -225,6 +231,7 @@ async function main() { argv.generations, argv.children, argv.relatedEvents, + argv.relatedAlerts, argv.percentWithRelated, argv.percentTerminated, argv.maxChildrenPerNode diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/resolver.ts b/x-pack/plugins/security_solution/server/endpoint/routes/resolver.ts index 9a4f55770f934..9b45a1a6c5354 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/resolver.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/resolver.ts @@ -11,11 +11,13 @@ import { validateEvents, validateChildren, validateAncestry, + validateAlerts, } from '../../../common/endpoint/schema/resolver'; import { handleEvents } from './resolver/events'; import { handleChildren } from './resolver/children'; import { handleAncestry } from './resolver/ancestry'; import { handleTree } from './resolver/tree'; +import { handleAlerts } from './resolver/alerts'; export function registerResolverRoutes(router: IRouter, endpointAppContext: EndpointAppContext) { const log = endpointAppContext.logFactory.get('resolver'); @@ -29,6 +31,15 @@ export function registerResolverRoutes(router: IRouter, endpointAppContext: Endp handleEvents(log, endpointAppContext) ); + router.get( + { + path: '/api/endpoint/resolver/{id}/alerts', + validate: validateAlerts, + options: { authRequired: true }, + }, + handleAlerts(log, endpointAppContext) + ); + router.get( { path: '/api/endpoint/resolver/{id}/children', diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/alerts.ts b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/alerts.ts new file mode 100644 index 0000000000000..04171dd8137e5 --- /dev/null +++ b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/alerts.ts @@ -0,0 +1,37 @@ +/* + * 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 { TypeOf } from '@kbn/config-schema'; +import { RequestHandler, Logger } from 'kibana/server'; +import { validateAlerts } from '../../../../common/endpoint/schema/resolver'; +import { Fetcher } from './utils/fetch'; +import { EndpointAppContext } from '../../types'; + +export function handleAlerts( + log: Logger, + endpointAppContext: EndpointAppContext +): RequestHandler, TypeOf> { + return async (context, req, res) => { + const { + params: { id }, + query: { alerts, afterAlert, legacyEndpointID: endpointID }, + } = req; + try { + const indexRetriever = endpointAppContext.service.getIndexPatternRetriever(); + const client = context.core.elasticsearch.legacy.client; + const indexPattern = await indexRetriever.getEventIndexPattern(context); + + const fetcher = new Fetcher(client, id, indexPattern, endpointID); + + return res.ok({ + body: await fetcher.alerts(alerts, afterAlert), + }); + } catch (err) { + log.warn(err); + return res.internalError({ body: err }); + } + }; +} diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/queries/alerts.ts b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/queries/alerts.ts new file mode 100644 index 0000000000000..013bc4302de2e --- /dev/null +++ b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/queries/alerts.ts @@ -0,0 +1,73 @@ +/* + * 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 { SearchResponse } from 'elasticsearch'; +import { ResolverEvent } from '../../../../../common/endpoint/types'; +import { ResolverQuery } from './base'; +import { PaginationBuilder, PaginatedResults } from '../utils/pagination'; +import { JsonObject } from '../../../../../../../../src/plugins/kibana_utils/common'; + +/** + * Builds a query for retrieving alerts for a node. + */ +export class AlertsQuery extends ResolverQuery { + constructor( + private readonly pagination: PaginationBuilder, + indexPattern: string, + endpointID?: string + ) { + super(indexPattern, endpointID); + } + + protected legacyQuery(endpointID: string, uniquePIDs: string[]): JsonObject { + return { + query: { + bool: { + filter: [ + { + terms: { 'endgame.unique_pid': uniquePIDs }, + }, + { + term: { 'agent.id': endpointID }, + }, + { + term: { 'event.kind': 'alert' }, + }, + ], + }, + }, + ...this.pagination.buildQueryFields( + uniquePIDs.length, + 'endgame.serial_event_id', + 'endgame.unique_pid' + ), + }; + } + + protected query(entityIDs: string[]): JsonObject { + return { + query: { + bool: { + filter: [ + { + terms: { 'process.entity_id': entityIDs }, + }, + { + term: { 'event.kind': 'alert' }, + }, + ], + }, + }, + ...this.pagination.buildQueryFields(entityIDs.length, 'event.id', 'process.entity_id'), + }; + } + + formatResponse(response: SearchResponse): PaginatedResults { + return { + results: ResolverQuery.getResults(response), + totals: PaginationBuilder.getTotals(response.aggregations), + }; + } +} diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree.ts b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree.ts index d750fb256a4a0..baad56c74b8a8 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree.ts @@ -23,6 +23,8 @@ export function handleTree( generations, ancestors, events, + alerts, + afterAlert, afterEvent, afterChild, legacyEndpointID: endpointID, @@ -35,13 +37,19 @@ export function handleTree( const fetcher = new Fetcher(client, id, indexPattern, endpointID); - const [childrenNodes, ancestry, relatedEvents] = await Promise.all([ + const [childrenNodes, ancestry, relatedEvents, relatedAlerts] = await Promise.all([ fetcher.children(children, generations, afterChild), fetcher.ancestors(ancestors), fetcher.events(events, afterEvent), + fetcher.alerts(alerts, afterAlert), ]); - const tree = new Tree(id, { ancestry, children: childrenNodes, relatedEvents }); + const tree = new Tree(id, { + ancestry, + children: childrenNodes, + relatedEvents, + relatedAlerts, + }); const enrichedTree = await fetcher.stats(tree); diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/utils/children_helper.test.ts b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/utils/children_helper.test.ts index 11f3dd69b3f95..51c9cef08a466 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/utils/children_helper.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/utils/children_helper.test.ts @@ -34,7 +34,7 @@ describe('Children helper', () => { const root = generator.generateEvent(); it('builds the children response structure', () => { - const children = Array.from(generator.descendantsTreeGenerator(root, 3, 3, 0, 0, 100, true)); + const children = Array.from(generator.descendantsTreeGenerator(root, 3, 3, 0, 0, 0, 100, true)); // because we requested the generator to always return the max children, there will always be at least 2 parents const parents = findParents(children); diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/utils/fetch.ts b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/utils/fetch.ts index 4ac8e206d4f3b..da6d8e2cbde81 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/utils/fetch.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/utils/fetch.ts @@ -9,6 +9,7 @@ import { ResolverChildren, ResolverRelatedEvents, ResolverAncestry, + ResolverRelatedAlerts, } from '../../../../../common/endpoint/types'; import { entityId, parentEntityId } from '../../../../../common/endpoint/models/event'; import { PaginationBuilder } from './pagination'; @@ -17,8 +18,9 @@ import { LifecycleQuery } from '../queries/lifecycle'; import { ChildrenQuery } from '../queries/children'; import { EventsQuery } from '../queries/events'; import { StatsQuery } from '../queries/stats'; -import { createAncestry, createRelatedEvents, createLifecycle } from './node'; +import { createAncestry, createRelatedEvents, createLifecycle, createRelatedAlerts } from './node'; import { ChildrenNodesHelper } from './children_helper'; +import { AlertsQuery } from '../queries/alerts'; /** * Handles retrieving nodes of a resolver tree. @@ -80,6 +82,16 @@ export class Fetcher { return this.doEvents(limit, after); } + /** + * Retrieves the alerts for the origin node. + * + * @param limit the upper bound number of alerts to return + * @param after a cursor to use as the starting point for retrieving alerts + */ + public async alerts(limit: number, after?: string): Promise { + return this.doAlerts(limit, after); + } + /** * Enriches a resolver tree with statistics for how many related events and alerts exist for each node in the tree. * @@ -138,6 +150,29 @@ export class Fetcher { ); } + private async doAlerts(limit: number, after?: string) { + const query = new AlertsQuery( + PaginationBuilder.createBuilder(limit, after), + this.indexPattern, + this.endpointID + ); + + const { totals, results } = await query.search(this.client, this.id); + if (results.length === 0) { + // return an empty set of results + return createRelatedAlerts(this.id); + } + if (!totals[this.id]) { + throw new Error(`Could not find the totals for related events entity_id: ${this.id}`); + } + + return createRelatedAlerts( + this.id, + results, + PaginationBuilder.buildCursor(totals[this.id], results) + ); + } + private async doChildren( cache: ChildrenNodesHelper, ids: string[], diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/utils/node.ts b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/utils/node.ts index 2fe7e364bb460..58aa9efc1fc56 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/utils/node.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/utils/node.ts @@ -11,6 +11,7 @@ import { ResolverRelatedEvents, ResolverTree, ChildNode, + ResolverRelatedAlerts, } from '../../../../../common/endpoint/types'; /** @@ -28,6 +29,21 @@ export function createRelatedEvents( return { entityID, events, nextEvent }; } +/** + * Creates an alert object that the alerts handler would return + * + * @param entityID the entity_id for these related events + * @param alerts array of alerts + * @param nextAlert the cursor to retrieve the next alert + */ +export function createRelatedAlerts( + entityID: string, + alerts: ResolverEvent[] = [], + nextAlert: string | null = null +): ResolverRelatedAlerts { + return { entityID, alerts, nextAlert }; +} + /** * Creates a child node that would be used in the child handler response * @@ -74,6 +90,10 @@ export function createTree(entityID: string): ResolverTree { events: [], nextEvent: null, }, + relatedAlerts: { + alerts: [], + nextAlert: null, + }, lifecycle: [], ancestry: { ancestors: [], diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/utils/tree.ts b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/utils/tree.ts index 048964068324b..9e47f4eb94485 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/utils/tree.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/utils/tree.ts @@ -12,6 +12,7 @@ import { ResolverAncestry, ResolverTree, ResolverChildren, + ResolverRelatedAlerts, } from '../../../../../common/endpoint/types'; import { createTree } from './node'; @@ -25,6 +26,7 @@ export interface Options { relatedEvents?: ResolverRelatedEvents; ancestry?: ResolverAncestry; children?: ResolverChildren; + relatedAlerts?: ResolverRelatedAlerts; } /** @@ -74,6 +76,7 @@ export class Tree { this.addRelatedEvents(options.relatedEvents); this.addAncestors(options.ancestry); this.addChildren(options.children); + this.addRelatedAlerts(options.relatedAlerts); } /** @@ -108,6 +111,20 @@ export class Tree { this.tree.relatedEvents.nextEvent = relatedEventsInfo.nextEvent; } + /** + * Add alerts for the tree's origin node. Alerts cannot be added for other nodes. + * + * @param alertInfo is the alerts and pagination information to add to the tree. + */ + private addRelatedAlerts(alertInfo: ResolverRelatedAlerts | undefined) { + if (!alertInfo) { + return; + } + + this.tree.relatedAlerts.alerts = alertInfo.alerts; + this.tree.relatedAlerts.nextAlert = alertInfo.nextAlert; + } + /** * Add ancestors to the tree. * diff --git a/x-pack/test/api_integration/apis/endpoint/resolver.ts b/x-pack/test/api_integration/apis/endpoint/resolver.ts index 650585293dadf..2bb52473508b3 100644 --- a/x-pack/test/api_integration/apis/endpoint/resolver.ts +++ b/x-pack/test/api_integration/apis/endpoint/resolver.ts @@ -15,6 +15,7 @@ import { ResolverTree, LegacyEndpointEvent, ResolverNodeStats, + ResolverRelatedAlerts, } from '../../../../plugins/security_solution/common/endpoint/types'; import { parentEntityId } from '../../../../plugins/security_solution/common/endpoint/models/event'; import { FtrProviderContext } from '../../ftr_provider_context'; @@ -199,6 +200,7 @@ export default function resolverAPIIntegrationTests({ getService }: FtrProviderC const treeOptions: Options = { ancestors: 5, relatedEvents: relatedEventsToGen, + relatedAlerts: 4, children: 3, generations: 2, percentTerminated: 100, @@ -220,6 +222,62 @@ export default function resolverAPIIntegrationTests({ getService }: FtrProviderC await esArchiver.unload('endpoint/resolver/api_feature'); }); + describe('related alerts route', () => { + describe('endpoint events', () => { + it('should not find any alerts', async () => { + const { body }: { body: ResolverRelatedAlerts } = await supertest + .get(`/api/endpoint/resolver/5555/alerts`) + .expect(200); + expect(body.nextAlert).to.eql(null); + expect(body.alerts).to.be.empty(); + }); + + it('should return details for the root node', async () => { + const { body }: { body: ResolverRelatedAlerts } = await supertest + .get(`/api/endpoint/resolver/${tree.origin.id}/alerts`) + .expect(200); + expect(body.alerts.length).to.eql(4); + compareArrays(tree.origin.relatedAlerts, body.alerts, true); + expect(body.nextAlert).to.eql(null); + }); + + it('should return paginated results for the root node', async () => { + let { body }: { body: ResolverRelatedAlerts } = await supertest + .get(`/api/endpoint/resolver/${tree.origin.id}/alerts?alerts=2`) + .expect(200); + expect(body.alerts.length).to.eql(2); + compareArrays(tree.origin.relatedAlerts, body.alerts); + expect(body.nextAlert).not.to.eql(null); + + ({ body } = await supertest + .get( + `/api/endpoint/resolver/${tree.origin.id}/alerts?alerts=2&afterAlert=${body.nextAlert}` + ) + .expect(200)); + expect(body.alerts.length).to.eql(2); + compareArrays(tree.origin.relatedAlerts, body.alerts); + expect(body.nextAlert).to.not.eql(null); + + ({ body } = await supertest + .get( + `/api/endpoint/resolver/${tree.origin.id}/alerts?alerts=2&afterAlert=${body.nextAlert}` + ) + .expect(200)); + expect(body.alerts).to.be.empty(); + expect(body.nextAlert).to.eql(null); + }); + + it('should return the first page of information when the cursor is invalid', async () => { + const { body }: { body: ResolverRelatedAlerts } = await supertest + .get(`/api/endpoint/resolver/${tree.origin.id}/alerts?afterAlert=blah`) + .expect(200); + expect(body.alerts.length).to.eql(4); + compareArrays(tree.origin.relatedAlerts, body.alerts, true); + expect(body.nextAlert).to.eql(null); + }); + }); + }); + describe('related events route', () => { describe('legacy events', () => { const endpointID = '5a0c957f-b8e7-4538-965e-57e8bb86ad3a'; @@ -605,7 +663,7 @@ export default function resolverAPIIntegrationTests({ getService }: FtrProviderC it('returns a tree', async () => { const { body }: { body: ResolverTree } = await supertest .get( - `/api/endpoint/resolver/${tree.origin.id}?children=100&generations=3&ancestors=5&events=4` + `/api/endpoint/resolver/${tree.origin.id}?children=100&generations=3&ancestors=5&events=4&alerts=4` ) .expect(200); @@ -621,6 +679,9 @@ export default function resolverAPIIntegrationTests({ getService }: FtrProviderC expect(body.relatedEvents.nextEvent).to.equal(null); compareArrays(tree.origin.relatedEvents, body.relatedEvents.events, true); + expect(body.relatedAlerts.nextAlert).to.equal(null); + compareArrays(tree.origin.relatedAlerts, body.relatedAlerts.alerts, true); + compareArrays(tree.origin.lifecycle, body.lifecycle, true); verifyStats(body.stats, relatedEventsToGen); });