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 8e4d82e4feb7d..a1d7649873779 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 @@ -326,6 +326,30 @@ describe('data generator', () => { } }); + it('groups the children by their parent ID correctly', () => { + expect(tree.childrenByParent.size).toBe(13); + expect(tree.childrenByParent.get(tree.origin.id)?.size).toBe(3); + + for (const value of tree.childrenByParent.values()) { + expect(value.size).toBe(3); + } + + // loop over everything but the last level because those nodes won't be parents + for (let i = 0; i < tree.childrenLevels.length - 1; i++) { + const level = tree.childrenLevels[i]; + // loop over all the nodes in a level + for (const id of level.keys()) { + // each node in the level should have 3 children + expect(tree.childrenByParent.get(id)?.size).toBe(3); + + // let's make sure the children of this ID are actually in the next level and that they are the same reference + for (const [childID, childNode] of tree.childrenByParent.get(id)!.entries()) { + expect(tree.childrenLevels[i + 1].get(childID)).toBe(childNode); + } + } + } + }); + it('has the right related events for each node', () => { const checkRelatedEvents = (node: TreeNode) => { expect(node.relatedEvents.length).toEqual(4); 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 5ab1dd0aa7f74..61c3d3ab192b6 100644 --- a/x-pack/plugins/security_solution/common/endpoint/generate_data.ts +++ b/x-pack/plugins/security_solution/common/endpoint/generate_data.ts @@ -31,6 +31,7 @@ import { import { EsAssetReference, KibanaAssetReference } from '../../../fleet/common/types/models'; import { agentPolicyStatuses } from '../../../fleet/common/constants'; import { firstNonNullValue } from './models/ecs_safety_helpers'; +import { EventOptions } from './types/generator'; export type Event = AlertEvent | SafeEndpointEvent; /** @@ -44,21 +45,6 @@ export type Event = AlertEvent | SafeEndpointEvent; */ export const ANCESTRY_LIMIT: number = 2; -interface EventOptions { - timestamp?: number; - entityID?: string; - parentEntityID?: string; - eventType?: string | string[]; - eventCategory?: string | string[]; - processName?: string; - ancestry?: string[]; - ancestryArrayLimit?: number; - pid?: number; - parentPid?: number; - extensions?: object; - eventsDataStream?: DataStream; -} - const Windows: OSFields[] = [ { name: 'windows 10.0', @@ -299,6 +285,10 @@ export interface TreeNode { * A resolver tree that makes accessing specific nodes easier for tests. */ export interface Tree { + /** + * Children grouped by the parent's ID + */ + childrenByParent: Map>; /** * Map of entity_id to node */ @@ -648,7 +638,7 @@ export class EndpointDocGenerator { const ancestry: string[] = options.ancestry?.slice(0, options?.ancestryArrayLimit ?? ANCESTRY_LIMIT) ?? []; - const processName = options.processName ? options.processName : randomProcessName(); + const processName = options.processName ? options.processName : this.randomProcessName(); const detailRecordForEventType = options.extensions || ((eventCategory) => { @@ -761,16 +751,16 @@ export class EndpointDocGenerator { public generateTree(options: TreeOptions = {}): Tree { const optionsWithDef = getTreeOptionsWithDef(options); const addEventToMap = (nodeMap: Map, event: Event) => { - const nodeId = entityIDSafeVersion(event); - if (!nodeId) { + const nodeID = entityIDSafeVersion(event); + if (!nodeID) { return nodeMap; } // if a node already exists for the entity_id we'll use that one, otherwise let's create a new empty node // and add the event to the right array. - let node = nodeMap.get(nodeId); + let node = nodeMap.get(nodeID); if (!node) { - node = { id: nodeId, lifecycle: [], relatedEvents: [], relatedAlerts: [] }; + node = { id: nodeID, lifecycle: [], relatedEvents: [], relatedAlerts: [] }; } // place the event in the right array depending on its category @@ -784,7 +774,7 @@ export class EndpointDocGenerator { node.relatedAlerts.push(event); } - return nodeMap.set(nodeId, node); + return nodeMap.set(nodeID, node); }; const groupNodesByParent = (children: Map) => { @@ -851,6 +841,7 @@ export class EndpointDocGenerator { const { startTime, endTime } = EndpointDocGenerator.getStartEndTimes(allEvents); return { + childrenByParent, children: childrenNodes, ancestry: ancestryNodes, allEvents, @@ -1640,6 +1631,11 @@ export class EndpointDocGenerator { HostPolicyResponseActionStatus.warning, ]); } + + /** Return a random fake process name */ + private randomProcessName(): string { + return this.randomChoice(fakeProcessNames); + } } const fakeProcessNames = [ @@ -1650,7 +1646,3 @@ const fakeProcessNames = [ 'iexlorer.exe', 'explorer.exe', ]; -/** Return a random fake process name */ -function randomProcessName(): string { - return fakeProcessNames[Math.floor(Math.random() * fakeProcessNames.length)]; -} diff --git a/x-pack/plugins/security_solution/common/endpoint/models/event.ts b/x-pack/plugins/security_solution/common/endpoint/models/event.ts index 3e39ed6eb7a69..3e11226d95550 100644 --- a/x-pack/plugins/security_solution/common/endpoint/models/event.ts +++ b/x-pack/plugins/security_solution/common/endpoint/models/event.ts @@ -3,7 +3,13 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { LegacyEndpointEvent, ResolverEvent, SafeResolverEvent, ECSField } from '../types'; +import { + LegacyEndpointEvent, + ResolverEvent, + SafeResolverEvent, + ECSField, + WinlogEvent, +} from '../types'; import { firstNonNullValue, hasValue, values } from './ecs_safety_helpers'; /** @@ -188,6 +194,15 @@ export function eventID(event: SafeResolverEvent): number | undefined | string { ); } +/** + * Retrieve the record_id field from a winlog event. + * + * @param event a winlog event + */ +export function winlogRecordID(event: WinlogEvent): undefined | string { + return firstNonNullValue(event.winlog?.record_id); +} + /** * Minimum fields needed from the `SafeResolverEvent` type for the function below to operate correctly. */ diff --git a/x-pack/plugins/security_solution/common/endpoint/models/node.ts b/x-pack/plugins/security_solution/common/endpoint/models/node.ts new file mode 100644 index 0000000000000..7eea94ce27c6b --- /dev/null +++ b/x-pack/plugins/security_solution/common/endpoint/models/node.ts @@ -0,0 +1,62 @@ +/* + * 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 { ResolverNode } from '../types'; +import { firstNonNullValue } from './ecs_safety_helpers'; + +/** + * These functions interact with the generic resolver node structure that does not define a specific format for the data + * returned by Elasticsearch. These functions are similar to the events.ts model's function except that they do not + * assume that the data will conform to a structure like an Endpoint or LegacyEndgame event. + */ + +/** + * @description - Extract the first non null value from the nodeID depending on the datasource. Returns + * undefined if the field was never set. + */ +export function nodeID(node: ResolverNode): string | undefined { + return node?.id ? String(firstNonNullValue(node.id)) : undefined; +} + +/** + * @description - Provides the parent for the given node + */ +export function parentId(node: ResolverNode): string | undefined { + return node?.parent ? String(firstNonNullValue(node?.parent)) : undefined; +} + +/** + * The `@timestamp` for the event, as a `Date` object. + * If `@timestamp` couldn't be parsed as a `Date`, returns `undefined`. + */ +export function timestampAsDate(node: ResolverNode): Date | undefined { + const value = nodeDataTimestamp(node); + if (value === undefined) { + return undefined; + } + + const date = new Date(value); + // Check if the date is valid + if (isFinite(date.getTime())) { + return date; + } else { + return undefined; + } +} + +/** + * Extracts the first non null value from the `@timestamp` field in the node data attribute. + */ +export function nodeDataTimestamp(node: ResolverNode): undefined | number | string { + return firstNonNullValue(node?.data['@timestamp']); +} + +/** + * @description - Extract the first non null value from the node name depending on the datasource. If it was never set + * default to the ID, and if no ID, then undefined + */ +export function nodeName(node: ResolverNode): string | undefined { + return node?.name ? String(firstNonNullValue(node.name)) : undefined; +} 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 6777b1dabbd53..af3628b720749 100644 --- a/x-pack/plugins/security_solution/common/endpoint/schema/resolver.ts +++ b/x-pack/plugins/security_solution/common/endpoint/schema/resolver.ts @@ -40,7 +40,7 @@ export const validateTree = { descendants: schema.number({ defaultValue: 1000, min: 0, max: 10000 }), // if the ancestry array isn't specified allowing 200 might be too high ancestors: schema.number({ defaultValue: 200, min: 0, max: 10000 }), - timerange: schema.object({ + timeRange: schema.object({ from: schema.string(), to: schema.string(), }), @@ -70,11 +70,14 @@ export const validateEvents = { limit: schema.number({ defaultValue: 1000, min: 1, max: 10000 }), afterEvent: schema.maybe(schema.string()), }), - body: schema.nullable( - schema.object({ - filter: schema.maybe(schema.string()), - }) - ), + body: schema.object({ + timeRange: schema.object({ + from: schema.string(), + to: schema.string(), + }), + indexPatterns: schema.arrayOf(schema.string()), + filter: schema.maybe(schema.string()), + }), }; /** diff --git a/x-pack/plugins/security_solution/common/endpoint/types/generator.ts b/x-pack/plugins/security_solution/common/endpoint/types/generator.ts new file mode 100644 index 0000000000000..628dd4d6fc864 --- /dev/null +++ b/x-pack/plugins/security_solution/common/endpoint/types/generator.ts @@ -0,0 +1,25 @@ +/* + * 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 { DataStream } from './index'; + +/** + * The configuration options for generating an event. + */ +export interface EventOptions { + timestamp?: number; + entityID?: string; + parentEntityID?: string; + eventType?: string | string[]; + eventCategory?: string | string[]; + processName?: string; + ancestry?: string[]; + ancestryArrayLimit?: number; + pid?: number; + parentPid?: number; + extensions?: object; + eventsDataStream?: DataStream; +} diff --git a/x-pack/plugins/security_solution/common/endpoint/types/index.ts b/x-pack/plugins/security_solution/common/endpoint/types/index.ts index 248e0126a42e5..94fa448840c42 100644 --- a/x-pack/plugins/security_solution/common/endpoint/types/index.ts +++ b/x-pack/plugins/security_solution/common/endpoint/types/index.ts @@ -29,7 +29,6 @@ export interface PolicyDetailsRouteState { * Object that allows you to maintain stateful information in the location object across navigation events * */ - export interface AppLocation { pathname: string; search: string; @@ -62,6 +61,9 @@ type ImmutableMap = ReadonlyMap, Immutable>; type ImmutableSet = ReadonlySet>; type ImmutableObject = { readonly [K in keyof T]: Immutable }; +/** + * Stats for related events for a particular node in a resolver graph. + */ export interface EventStats { /** * The total number of related events (all events except process and alerts) that exist for a node. @@ -128,8 +130,18 @@ export interface ResolverNode { stats: EventStats; } +/** + * The structure for a resolver graph that is generic and data type agnostic. The nodes in the graph do not conform + * to a specific document type. The format of the nodes is defined by the schema used to query for the graph. + */ +export interface NewResolverTree { + originID: string; + nodes: ResolverNode[]; +} + /** * Statistical information for a node in a resolver tree. + * @deprecated use {@link EventStats} instead to model the stats for a node */ export interface ResolverNodeStats { /** @@ -144,6 +156,8 @@ export interface ResolverNodeStats { /** * A child node can also have additional children so we need to provide a pagination cursor. + * + * @deprecated use {@link ResolverNode} instead */ export interface ResolverChildNode extends ResolverLifecycleNode { /** @@ -165,6 +179,8 @@ export interface ResolverChildNode extends ResolverLifecycleNode { /** * Safe version of `ResolverChildNode`. + * + * @deprecated use {@link ResolverNode} instead */ export interface SafeResolverChildNode extends SafeResolverLifecycleNode { /** @@ -187,6 +203,8 @@ export interface SafeResolverChildNode extends SafeResolverLifecycleNode { /** * The response structure for the children route. The structure is an array of nodes where each node * has an array of lifecycle events. + * + * @deprecated use {@link ResolverNode} instead */ export interface ResolverChildren { childNodes: ResolverChildNode[]; @@ -205,6 +223,8 @@ export interface ResolverChildren { /** * Safe version of `ResolverChildren`. + * + * @deprecated use {@link ResolverNode} instead */ export interface SafeResolverChildren { childNodes: SafeResolverChildNode[]; @@ -223,6 +243,8 @@ export interface SafeResolverChildren { /** * A flattened tree representing the nodes in a resolver graph. + * + * @deprecated use {@link ResolverNode} instead */ export interface ResolverTree { /** @@ -240,6 +262,8 @@ export interface ResolverTree { /** * Safe version of `ResolverTree`. + * + * @deprecated use {@link ResolverNode} instead */ export interface SafeResolverTree { /** @@ -256,6 +280,8 @@ export interface SafeResolverTree { /** * The lifecycle events (start, end etc) for a node. + * + * @deprecated use {@link ResolverNode} instead */ export interface ResolverLifecycleNode { entityID: string; @@ -268,6 +294,8 @@ export interface ResolverLifecycleNode { /** * Safe version of `ResolverLifecycleNode`. + * + * @deprecated use {@link ResolverNode} instead */ export interface SafeResolverLifecycleNode { entityID: string; @@ -280,6 +308,8 @@ export interface SafeResolverLifecycleNode { /** * The response structure when searching for ancestors of a node. + * + * @deprecated use {@link ResolverNode} instead */ export interface ResolverAncestry { /** @@ -295,6 +325,8 @@ export interface ResolverAncestry { /** * Safe version of `ResolverAncestry`. + * + * @deprecated use {@link ResolverNode} instead */ export interface SafeResolverAncestry { /** @@ -310,6 +342,8 @@ export interface SafeResolverAncestry { /** * Response structure for the related events route. + * + * @deprecated use {@link ResolverNode} instead */ export interface ResolverRelatedEvents { entityID: string; @@ -750,7 +784,17 @@ export type ECSField = T | null | undefined | Array; * A more conservative version of `ResolverEvent` that treats fields as optional and use `ECSField` to type all ECS fields. * Prefer this over `ResolverEvent`. */ -export type SafeResolverEvent = SafeEndpointEvent | SafeLegacyEndpointEvent; +export type SafeResolverEvent = SafeEndpointEvent | SafeLegacyEndpointEvent | WinlogEvent; + +/** + * A type for describing a winlog event until we can leverage runtime fields. + */ +export type WinlogEvent = Partial<{ + winlog: Partial<{ + record_id: ECSField; + }>; +}> & + SafeEndpointEvent; /** * Safer version of ResolverEvent. Please use this going forward. diff --git a/x-pack/plugins/security_solution/public/resolver/data_access_layer/factory.ts b/x-pack/plugins/security_solution/public/resolver/data_access_layer/factory.ts index 66dc7b98168ea..4f3d8bf4a67e2 100644 --- a/x-pack/plugins/security_solution/public/resolver/data_access_layer/factory.ts +++ b/x-pack/plugins/security_solution/public/resolver/data_access_layer/factory.ts @@ -6,13 +6,14 @@ import { KibanaReactContextValue } from '../../../../../../src/plugins/kibana_react/public'; import { StartServices } from '../../types'; -import { DataAccessLayer } from '../types'; +import { DataAccessLayer, TimeRange } from '../types'; import { + ResolverNode, ResolverRelatedEvents, - ResolverTree, ResolverEntityIndex, ResolverPaginatedEvents, SafeResolverEvent, + ResolverSchema, } from '../../../common/endpoint/types'; /** @@ -26,13 +27,33 @@ export function dataAccessLayerFactory( * Used to get non-process related events for a node. * @deprecated use the new API (eventsWithEntityIDAndCategory & event) instead */ - async relatedEvents(entityID: string): Promise { + async relatedEvents({ + entityID, + timeRange, + indexPatterns, + }: { + entityID: string; + timeRange: TimeRange; + indexPatterns: string[]; + }): Promise { const response: ResolverPaginatedEvents = await context.services.http.post( '/api/endpoint/resolver/events', { query: {}, body: JSON.stringify({ - filter: `process.entity_id:"${entityID}" and not event.category:"process"`, + indexPatterns, + timeRange: { + from: timeRange.from.toISOString(), + to: timeRange.to.toISOString(), + }, + filter: JSON.stringify({ + bool: { + filter: [ + { term: { 'process.entity_id': entityID } }, + { bool: { must_not: { term: { 'event.category': 'process' } } } }, + ], + }, + }), }), } ); @@ -44,28 +65,128 @@ export function dataAccessLayerFactory( * Return events that have `process.entity_id` that includes `entityID` and that have * a `event.category` that includes `category`. */ - eventsWithEntityIDAndCategory( - entityID: string, - category: string, - after?: string - ): Promise { + eventsWithEntityIDAndCategory({ + entityID, + category, + after, + timeRange, + indexPatterns, + }: { + entityID: string; + category: string; + after?: string; + timeRange: TimeRange; + indexPatterns: string[]; + }): Promise { return context.services.http.post('/api/endpoint/resolver/events', { query: { afterEvent: after, limit: 25 }, body: JSON.stringify({ - filter: `process.entity_id:"${entityID}" and event.category:"${category}"`, + timeRange: { + from: timeRange.from.toISOString(), + to: timeRange.to.toISOString(), + }, + indexPatterns, + filter: JSON.stringify({ + bool: { + filter: [ + { term: { 'process.entity_id': entityID } }, + { term: { 'event.category': category } }, + ], + }, + }), }), }); }, + /** + * Retrieves the node data for a set of node IDs. This is specifically for Endpoint graphs. It + * only returns process lifecycle events. + */ + async nodeData({ + ids, + timeRange, + indexPatterns, + limit, + }: { + ids: string[]; + timeRange: TimeRange; + indexPatterns: string[]; + limit: number; + }): Promise { + const response: ResolverPaginatedEvents = await context.services.http.post( + '/api/endpoint/resolver/events', + { + query: { limit }, + body: JSON.stringify({ + timeRange: { + from: timeRange.from.toISOString(), + to: timeRange.to.toISOString(), + }, + indexPatterns, + filter: JSON.stringify({ + bool: { + filter: [ + { terms: { 'process.entity_id': ids } }, + { term: { 'event.category': 'process' } }, + ], + }, + }), + }), + } + ); + return response.events; + }, + /** * Return up to one event that has an `event.id` that includes `eventID`. */ - async event(eventID: string): Promise { + async event({ + nodeID, + eventID, + eventCategory, + eventTimestamp, + winlogRecordID, + timeRange, + indexPatterns, + }: { + nodeID: string; + eventCategory: string[]; + eventTimestamp: string; + eventID?: string | number; + winlogRecordID: string; + timeRange: TimeRange; + indexPatterns: string[]; + }): Promise { + /** @description - eventID isn't provided by winlog. This can be removed once runtime fields are available */ + const filter = + eventID === undefined + ? { + bool: { + filter: [ + { terms: { 'event.category': eventCategory } }, + { term: { 'process.entity_id': nodeID } }, + { term: { '@timestamp': eventTimestamp } }, + { term: { 'winlog.record_id': winlogRecordID } }, + ], + }, + } + : { + bool: { + filter: [{ term: { 'event.id': eventID } }], + }, + }; const response: ResolverPaginatedEvents = await context.services.http.post( '/api/endpoint/resolver/events', { query: { limit: 1 }, - body: JSON.stringify({ filter: `event.id:"${eventID}"` }), + body: JSON.stringify({ + indexPatterns, + timeRange: { + from: timeRange.from.toISOString(), + to: timeRange.to.toISOString(), + }, + filter: JSON.stringify(filter), + }), } ); const [oneEvent] = response.events; @@ -73,11 +194,38 @@ export function dataAccessLayerFactory( }, /** - * Used to get descendant and ancestor process events for a node. + * Retrieves a resolver graph given an ID, schema, timerange, and indices to use when search. + * + * @param {string} dataId - Id of the data for what will be the origin node in the graph + * @param {*} schema - schema detailing what the id and parent fields should be + * @param {*} timerange - date range in time to search for the nodes in the graph + * @param {string[]} indices - specific indices to use for searching for the nodes in the graph + * @returns {Promise} the nodes in the graph */ - async resolverTree(entityID: string, signal: AbortSignal): Promise { - return context.services.http.get(`/api/endpoint/resolver/${entityID}`, { - signal, + async resolverTree({ + dataId, + schema, + timeRange, + indices, + ancestors, + descendants, + }: { + dataId: string; + schema: ResolverSchema; + timeRange: TimeRange; + indices: string[]; + ancestors: number; + descendants: number; + }): Promise { + return context.services.http.post('/api/endpoint/resolver/tree', { + body: JSON.stringify({ + ancestors, + descendants, + timeRange, + schema, + nodes: [dataId], + indexPatterns: indices, + }), }); }, diff --git a/x-pack/plugins/security_solution/public/resolver/data_access_layer/mocks/emptify_mock.ts b/x-pack/plugins/security_solution/public/resolver/data_access_layer/mocks/emptify_mock.ts index 540430695b6f5..86b072e1bf573 100644 --- a/x-pack/plugins/security_solution/public/resolver/data_access_layer/mocks/emptify_mock.ts +++ b/x-pack/plugins/security_solution/public/resolver/data_access_layer/mocks/emptify_mock.ts @@ -3,13 +3,11 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ - -import { SafeResolverEvent } from './../../../../common/endpoint/types/index'; - import { ResolverRelatedEvents, - ResolverTree, + ResolverNode, ResolverEntityIndex, + SafeResolverEvent, } from '../../../../common/endpoint/types'; import { mockTreeWithNoProcessEvents } from '../../mocks/resolver_tree'; import { DataAccessLayer } from '../../types'; @@ -19,7 +17,8 @@ type EmptiableRequests = | 'resolverTree' | 'entities' | 'eventsWithEntityIDAndCategory' - | 'event'; + | 'event' + | 'nodeData'; interface Metadata { /** @@ -58,7 +57,7 @@ export function emptifyMock( async relatedEvents(...args): Promise { return dataShouldBeEmpty.includes('relatedEvents') ? Promise.resolve({ - entityID: args[0], + entityID: args[0].entityID, events: [], nextEvent: null, }) @@ -79,6 +78,16 @@ export function emptifyMock( : dataAccessLayer.eventsWithEntityIDAndCategory(...args); }, + /** + * Fetch the node data (lifecycle events for endpoint) for a set of nodes + */ + async nodeData(...args): Promise { + return dataShouldBeEmpty.includes('nodeData') ? [] : dataAccessLayer.nodeData(...args); + }, + + /** + * Retrieve the related events for a node. + */ async event(...args): Promise { return dataShouldBeEmpty.includes('event') ? null : dataAccessLayer.event(...args); }, @@ -86,9 +95,9 @@ export function emptifyMock( /** * Fetch a ResolverTree for a entityID */ - async resolverTree(...args): Promise { + async resolverTree(...args): Promise { return dataShouldBeEmpty.includes('resolverTree') - ? Promise.resolve(mockTreeWithNoProcessEvents()) + ? Promise.resolve(mockTreeWithNoProcessEvents().nodes) : dataAccessLayer.resolverTree(...args); }, diff --git a/x-pack/plugins/security_solution/public/resolver/data_access_layer/mocks/generator_tree.ts b/x-pack/plugins/security_solution/public/resolver/data_access_layer/mocks/generator_tree.ts new file mode 100644 index 0000000000000..fd9eac56c5795 --- /dev/null +++ b/x-pack/plugins/security_solution/public/resolver/data_access_layer/mocks/generator_tree.ts @@ -0,0 +1,199 @@ +/* + * 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 { TreeOptions } from '../../../../common/endpoint/generate_data'; +import { DataAccessLayer, GeneratedTreeMetadata, TimeRange } from '../../types'; + +import { + ResolverRelatedEvents, + ResolverEntityIndex, + SafeResolverEvent, + ResolverNode, + ResolverSchema, +} from '../../../../common/endpoint/types'; +import * as eventModel from '../../../../common/endpoint/models/event'; +import { generateTree } from '../../mocks/generator'; + +/** + * This file can be used to create a mock data access layer that leverages a generated tree using the + * EndpointDocGenerator class. The advantage of using this mock is that it gives us a lot of control how we want the + * tree to look (ancestors, descendants, generations, related events, etc). + * + * The data access layer is mainly useful for testing the nodeData state within resolver. + */ + +/** + * Creates a Data Access Layer based on a resolver generator tree. + * + * @param treeOptions options for generating a resolver tree, these are passed to the resolver generator + * @param dalOverrides a DAL to override the functions in this mock, this allows extra functionality to be specified in the tests + */ +export function generateTreeWithDAL( + treeOptions?: TreeOptions, + dalOverrides?: DataAccessLayer +): { + dataAccessLayer: DataAccessLayer; + metadata: GeneratedTreeMetadata; +} { + /** + * The generateTree function uses a static seed for the random number generated used internally by the + * function. This means that the generator will return the same generated tree (ids, names, structure, etc) each + * time the doc generate is used in tests. This way we can rely on the generate returning consistent responses + * for our tests. The results won't be unpredictable and they will not result in flaky tests. + */ + const { allNodes, generatedTree, formattedTree } = generateTree(treeOptions); + + const metadata: GeneratedTreeMetadata = { + databaseDocumentID: '_id', + generatedTree, + formattedTree, + }; + + const defaultDAL: DataAccessLayer = { + /** + * Fetch related events for an entity ID + */ + async relatedEvents({ + entityID, + timeRange, + indexPatterns, + }: { + entityID: string; + timeRange: TimeRange; + indexPatterns: string[]; + }): Promise { + const node = allNodes.get(entityID); + const events: SafeResolverEvent[] = []; + if (node) { + events.push(...node.relatedEvents); + } + + return { entityID, events, nextEvent: null }; + }, + + /** + * Returns the related events for a specific ID and category. + */ + async eventsWithEntityIDAndCategory({ + entityID, + category, + after, + timeRange, + indexPatterns, + }: { + entityID: string; + category: string; + after?: string; + timeRange: TimeRange; + indexPatterns: string[]; + }): Promise<{ events: SafeResolverEvent[]; nextEvent: string | null }> { + const node = allNodes.get(entityID); + const events: SafeResolverEvent[] = []; + if (node) { + events.push( + ...node.relatedEvents.filter((event: SafeResolverEvent) => { + const categories = eventModel.eventCategory(event); + return categories.length > 0 && categories[0] === category; + }) + ); + } + return { events, nextEvent: null }; + }, + + /** + * Always returns null. + */ + async event({ + nodeID, + eventCategory, + eventTimestamp, + eventID, + timeRange, + indexPatterns, + }: { + nodeID: string; + eventCategory: string[]; + eventTimestamp: string; + eventID?: string | number; + timeRange: TimeRange; + indexPatterns: string[]; + }): Promise { + return null; + }, + + /** + * Returns the lifecycle events for a set of nodes. + */ + async nodeData({ + ids, + timeRange, + indexPatterns, + limit, + }: { + ids: string[]; + timeRange: TimeRange; + indexPatterns: string[]; + limit: number; + }): Promise { + return ids + .reduce((acc: SafeResolverEvent[], id: string) => { + const treeNode = allNodes.get(id); + if (treeNode) { + acc.push(...treeNode.lifecycle); + } + return acc; + }, []) + .slice(0, limit); + }, + + /** + * Fetches the generated resolver graph. + */ + async resolverTree({ + dataId, + schema, + timeRange, + indices, + ancestors, + descendants, + }: { + dataId: string; + schema: ResolverSchema; + timeRange: TimeRange; + indices: string[]; + ancestors: number; + descendants: number; + }): Promise { + return formattedTree.nodes; + }, + + /** + * Returns a schema matching the generated graph and the origin's ID. + */ + async entities(): Promise { + return [ + { + name: 'endpoint', + schema: { + id: 'process.entity_id', + parent: 'process.parent.entity_id', + ancestry: 'process.Ext.ancestry', + name: 'process.name', + }, + id: generatedTree.origin.id, + }, + ]; + }, + }; + + return { + metadata, + dataAccessLayer: { + ...defaultDAL, + ...(dalOverrides ?? {}), + }, + }; +} diff --git a/x-pack/plugins/security_solution/public/resolver/data_access_layer/mocks/no_ancestors_two_children.ts b/x-pack/plugins/security_solution/public/resolver/data_access_layer/mocks/no_ancestors_two_children.ts index 472fdc79d1f02..1283399f8cda4 100644 --- a/x-pack/plugins/security_solution/public/resolver/data_access_layer/mocks/no_ancestors_two_children.ts +++ b/x-pack/plugins/security_solution/public/resolver/data_access_layer/mocks/no_ancestors_two_children.ts @@ -7,11 +7,12 @@ import { ResolverRelatedEvents, SafeResolverEvent, - ResolverTree, ResolverEntityIndex, + ResolverNode, + ResolverSchema, } from '../../../../common/endpoint/types'; import { mockTreeWithNoAncestorsAnd2Children } from '../../mocks/resolver_tree'; -import { DataAccessLayer } from '../../types'; +import { DataAccessLayer, TimeRange } from '../../types'; interface Metadata { /** @@ -51,7 +52,15 @@ export function noAncestorsTwoChildren(): { dataAccessLayer: DataAccessLayer; me /** * Fetch related events for an entity ID */ - relatedEvents(entityID: string): Promise { + relatedEvents({ + entityID, + timeRange, + indexPatterns, + }: { + entityID: string; + timeRange: TimeRange; + indexPatterns: string[]; + }): Promise { return Promise.resolve({ entityID, events: [], @@ -63,11 +72,19 @@ export function noAncestorsTwoChildren(): { dataAccessLayer: DataAccessLayer; me * Return events that have `process.entity_id` that includes `entityID` and that have * a `event.category` that includes `category`. */ - async eventsWithEntityIDAndCategory( - entityID: string, - category: string, - after?: string - ): Promise<{ + async eventsWithEntityIDAndCategory({ + entityID, + category, + after, + timeRange, + indexPatterns, + }: { + entityID: string; + category: string; + after?: string; + timeRange: TimeRange; + indexPatterns: string[]; + }): Promise<{ events: SafeResolverEvent[]; nextEvent: string | null; }> { @@ -78,21 +95,65 @@ export function noAncestorsTwoChildren(): { dataAccessLayer: DataAccessLayer; me }; }, - async event(_eventID: string): Promise { + async event({ + nodeID, + eventID, + eventCategory, + eventTimestamp, + winlogRecordID, + timeRange, + indexPatterns, + }: { + nodeID: string; + eventCategory: string[]; + eventTimestamp: string; + eventID?: string | number; + winlogRecordID: string; + timeRange: TimeRange; + indexPatterns: string[]; + }): Promise { return null; }, + async nodeData({ + ids, + timeRange, + indexPatterns, + limit, + }: { + ids: string[]; + timeRange: TimeRange; + indexPatterns: string[]; + limit: number; + }): Promise { + return []; + }, + /** * Fetch a ResolverTree for a entityID */ - resolverTree(): Promise { - return Promise.resolve( - mockTreeWithNoAncestorsAnd2Children({ - originID: metadata.entityIDs.origin, - firstChildID: metadata.entityIDs.firstChild, - secondChildID: metadata.entityIDs.secondChild, - }) - ); + async resolverTree({ + dataId, + schema, + timeRange, + indices, + ancestors, + descendants, + }: { + dataId: string; + schema: ResolverSchema; + timeRange: TimeRange; + indices: string[]; + ancestors: number; + descendants: number; + }): Promise { + const { treeResponse } = mockTreeWithNoAncestorsAnd2Children({ + originID: metadata.entityIDs.origin, + firstChildID: metadata.entityIDs.firstChild, + secondChildID: metadata.entityIDs.secondChild, + }); + + return Promise.resolve(treeResponse); }, /** diff --git a/x-pack/plugins/security_solution/public/resolver/data_access_layer/mocks/no_ancestors_two_children_in_index_called_awesome_index.ts b/x-pack/plugins/security_solution/public/resolver/data_access_layer/mocks/no_ancestors_two_children_in_index_called_awesome_index.ts index b085738d3fd2e..a0f91ca1cb33f 100644 --- a/x-pack/plugins/security_solution/public/resolver/data_access_layer/mocks/no_ancestors_two_children_in_index_called_awesome_index.ts +++ b/x-pack/plugins/security_solution/public/resolver/data_access_layer/mocks/no_ancestors_two_children_in_index_called_awesome_index.ts @@ -6,13 +6,14 @@ import { ResolverRelatedEvents, - ResolverTree, ResolverEntityIndex, SafeResolverEvent, + ResolverNode, + ResolverSchema, } from '../../../../common/endpoint/types'; import { mockEndpointEvent } from '../../mocks/endpoint_event'; import { mockTreeWithNoAncestorsAnd2Children } from '../../mocks/resolver_tree'; -import { DataAccessLayer } from '../../types'; +import { DataAccessLayer, TimeRange } from '../../types'; interface Metadata { /** @@ -56,7 +57,15 @@ export function noAncestorsTwoChildenInIndexCalledAwesomeIndex(): { /** * Fetch related events for an entity ID */ - relatedEvents(entityID: string): Promise { + relatedEvents({ + entityID, + timeRange, + indexPatterns, + }: { + entityID: string; + timeRange: TimeRange; + indexPatterns: string[]; + }): Promise { return Promise.resolve({ entityID, events: [ @@ -70,11 +79,19 @@ export function noAncestorsTwoChildenInIndexCalledAwesomeIndex(): { }); }, - async eventsWithEntityIDAndCategory( - entityID: string, + async eventsWithEntityIDAndCategory({ + entityID, category, - after?: string - ): Promise<{ + after, + timeRange, + indexPatterns, + }: { + entityID: string; + category: string; + after?: string; + timeRange: TimeRange; + indexPatterns: string[]; + }): Promise<{ events: SafeResolverEvent[]; nextEvent: string | null; }> { @@ -89,7 +106,23 @@ export function noAncestorsTwoChildenInIndexCalledAwesomeIndex(): { }; }, - async event(eventID: string): Promise { + async event({ + nodeID, + eventID, + eventCategory, + eventTimestamp, + winlogRecordID, + timeRange, + indexPatterns, + }: { + nodeID: string; + eventCategory: string[]; + eventTimestamp: string; + eventID?: string | number; + winlogRecordID: string; + timeRange: TimeRange; + indexPatterns: string[]; + }): Promise { return mockEndpointEvent({ entityID: metadata.entityIDs.origin, eventID, @@ -97,18 +130,53 @@ export function noAncestorsTwoChildenInIndexCalledAwesomeIndex(): { }, /** - * Fetch a ResolverTree for a entityID + * Creates a fake event for each of the ids requested */ - resolverTree(): Promise { - return Promise.resolve( - mockTreeWithNoAncestorsAnd2Children({ - originID: metadata.entityIDs.origin, - firstChildID: metadata.entityIDs.firstChild, - secondChildID: metadata.entityIDs.secondChild, + async nodeData({ + ids, + timeRange, + indexPatterns, + limit, + }: { + ids: string[]; + timeRange: TimeRange; + indexPatterns: string[]; + limit: number; + }): Promise { + return ids.map((id: string) => + mockEndpointEvent({ + entityID: id, }) ); }, + /** + * Fetch a ResolverTree for a entityID + */ + async resolverTree({ + dataId, + schema, + timeRange, + indices, + ancestors, + descendants, + }: { + dataId: string; + schema: ResolverSchema; + timeRange: TimeRange; + indices: string[]; + ancestors: number; + descendants: number; + }): Promise { + const { treeResponse } = mockTreeWithNoAncestorsAnd2Children({ + originID: metadata.entityIDs.origin, + firstChildID: metadata.entityIDs.firstChild, + secondChildID: metadata.entityIDs.secondChild, + }); + + return Promise.resolve(treeResponse); + }, + /** * Get entities matching a document. */ diff --git a/x-pack/plugins/security_solution/public/resolver/data_access_layer/mocks/no_ancestors_two_children_with_related_events_and_cursor_on_origin.ts b/x-pack/plugins/security_solution/public/resolver/data_access_layer/mocks/no_ancestors_two_children_with_related_events_and_cursor_on_origin.ts index 43704db358d7e..ef1d774edf74c 100644 --- a/x-pack/plugins/security_solution/public/resolver/data_access_layer/mocks/no_ancestors_two_children_with_related_events_and_cursor_on_origin.ts +++ b/x-pack/plugins/security_solution/public/resolver/data_access_layer/mocks/no_ancestors_two_children_with_related_events_and_cursor_on_origin.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { DataAccessLayer } from '../../types'; +import { DataAccessLayer, TimeRange } from '../../types'; import { mockTreeWithNoAncestorsAndTwoChildrenAndRelatedEventsOnOrigin, firstRelatedEventID, @@ -12,9 +12,10 @@ import { } from '../../mocks/resolver_tree'; import { ResolverRelatedEvents, - ResolverTree, ResolverEntityIndex, SafeResolverEvent, + ResolverNode, + ResolverSchema, } from '../../../../common/endpoint/types'; import * as eventModel from '../../../../common/endpoint/models/event'; @@ -55,7 +56,11 @@ export function noAncestorsTwoChildrenWithRelatedEventsOnOriginWithOneAfterCurso databaseDocumentID: '_id', entityIDs: { origin: 'origin', firstChild: 'firstChild', secondChild: 'secondChild' }, }; - const tree = mockTreeWithNoAncestorsAndTwoChildrenAndRelatedEventsOnOrigin({ + const { + tree, + relatedEvents, + nodeDataResponse, + } = mockTreeWithNoAncestorsAndTwoChildrenAndRelatedEventsOnOrigin({ originID: metadata.entityIDs.origin, firstChildID: metadata.entityIDs.firstChild, secondChildID: metadata.entityIDs.secondChild, @@ -67,11 +72,19 @@ export function noAncestorsTwoChildrenWithRelatedEventsOnOriginWithOneAfterCurso /** * Fetch related events for an entity ID */ - async relatedEvents(entityID: string): Promise { + async relatedEvents({ + entityID, + timeRange, + indexPatterns, + }: { + entityID: string; + timeRange: TimeRange; + indexPatterns: string[]; + }): Promise { /** * Respond with the mocked related events when the origin's related events are fetched. **/ - const events = entityID === metadata.entityIDs.origin ? tree.relatedEvents.events : []; + const events = entityID === metadata.entityIDs.origin ? relatedEvents.events : []; return { entityID, @@ -87,11 +100,19 @@ export function noAncestorsTwoChildrenWithRelatedEventsOnOriginWithOneAfterCurso * return the first event, calling with the cursor set to the id of the first event * will return the second. */ - async eventsWithEntityIDAndCategory( - entityID: string, - category: string, - after?: string - ): Promise<{ events: SafeResolverEvent[]; nextEvent: string | null }> { + async eventsWithEntityIDAndCategory({ + entityID, + category, + after, + timeRange, + indexPatterns, + }: { + entityID: string; + category: string; + after?: string; + timeRange: TimeRange; + indexPatterns: string[]; + }): Promise<{ events: SafeResolverEvent[]; nextEvent: string | null }> { /** * For testing: This 'fakes' the behavior of one related event being `after` * a cursor for an earlier event. @@ -109,7 +130,7 @@ export function noAncestorsTwoChildrenWithRelatedEventsOnOriginWithOneAfterCurso const events = entityID === metadata.entityIDs.origin - ? tree.relatedEvents.events.filter( + ? relatedEvents.events.filter( (event) => eventModel.eventCategory(event).includes(category) && splitOnCursor(event) ) @@ -123,17 +144,62 @@ export function noAncestorsTwoChildrenWithRelatedEventsOnOriginWithOneAfterCurso /** * Any of the origin's related events by event.id */ - async event(eventID: string): Promise { - return ( - tree.relatedEvents.events.find((event) => eventModel.eventID(event) === eventID) ?? null - ); + async event({ + nodeID, + eventID, + eventCategory, + eventTimestamp, + winlogRecordID, + timeRange, + indexPatterns, + }: { + nodeID: string; + eventCategory: string[]; + eventTimestamp: string; + eventID?: string | number; + winlogRecordID: string; + timeRange: TimeRange; + indexPatterns: string[]; + }): Promise { + return relatedEvents.events.find((event) => eventModel.eventID(event) === eventID) ?? null; + }, + + /** + * Returns a static array of events. Ignores request parameters. + */ + async nodeData({ + ids, + timeRange, + indexPatterns, + limit, + }: { + ids: string[]; + timeRange: TimeRange; + indexPatterns: string[]; + limit: number; + }): Promise { + return nodeDataResponse; }, /** * Fetch a ResolverTree for a entityID */ - async resolverTree(): Promise { - return tree; + async resolverTree({ + dataId, + schema, + timeRange, + indices, + ancestors, + descendants, + }: { + dataId: string; + schema: ResolverSchema; + timeRange: TimeRange; + indices: string[]; + ancestors: number; + descendants: number; + }): Promise { + return tree.nodes; }, /** diff --git a/x-pack/plugins/security_solution/public/resolver/data_access_layer/mocks/no_ancestors_two_children_with_related_events_on_origin.ts b/x-pack/plugins/security_solution/public/resolver/data_access_layer/mocks/no_ancestors_two_children_with_related_events_on_origin.ts index c4d538d2eed94..1413b7ec5684b 100644 --- a/x-pack/plugins/security_solution/public/resolver/data_access_layer/mocks/no_ancestors_two_children_with_related_events_on_origin.ts +++ b/x-pack/plugins/security_solution/public/resolver/data_access_layer/mocks/no_ancestors_two_children_with_related_events_on_origin.ts @@ -4,13 +4,14 @@ * you may not use this file except in compliance with the Elastic License. */ -import { DataAccessLayer } from '../../types'; +import { DataAccessLayer, TimeRange } from '../../types'; import { mockTreeWithNoAncestorsAndTwoChildrenAndRelatedEventsOnOrigin } from '../../mocks/resolver_tree'; import { ResolverRelatedEvents, - ResolverTree, ResolverEntityIndex, SafeResolverEvent, + ResolverNode, + ResolverSchema, } from '../../../../common/endpoint/types'; import * as eventModel from '../../../../common/endpoint/models/event'; @@ -46,7 +47,11 @@ export function noAncestorsTwoChildrenWithRelatedEventsOnOrigin(): { databaseDocumentID: '_id', entityIDs: { origin: 'origin', firstChild: 'firstChild', secondChild: 'secondChild' }, }; - const tree = mockTreeWithNoAncestorsAndTwoChildrenAndRelatedEventsOnOrigin({ + const { + tree, + relatedEvents, + nodeDataResponse, + } = mockTreeWithNoAncestorsAndTwoChildrenAndRelatedEventsOnOrigin({ originID: metadata.entityIDs.origin, firstChildID: metadata.entityIDs.firstChild, secondChildID: metadata.entityIDs.secondChild, @@ -58,11 +63,19 @@ export function noAncestorsTwoChildrenWithRelatedEventsOnOrigin(): { /** * Fetch related events for an entity ID */ - async relatedEvents(entityID: string): Promise { + async relatedEvents({ + entityID, + timeRange, + indexPatterns, + }: { + entityID: string; + timeRange: TimeRange; + indexPatterns: string[]; + }): Promise { /** * Respond with the mocked related events when the origin's related events are fetched. **/ - const events = entityID === metadata.entityIDs.origin ? tree.relatedEvents.events : []; + const events = entityID === metadata.entityIDs.origin ? relatedEvents.events : []; return { entityID, @@ -76,13 +89,22 @@ export function noAncestorsTwoChildrenWithRelatedEventsOnOrigin(): { * `entityID` must match the origin node's `process.entity_id`. * Does not respect the `_after` parameter. */ - async eventsWithEntityIDAndCategory( - entityID: string, - category: string - ): Promise<{ events: SafeResolverEvent[]; nextEvent: string | null }> { + async eventsWithEntityIDAndCategory({ + entityID, + category, + after, + timeRange, + indexPatterns, + }: { + entityID: string; + category: string; + after?: string; + timeRange: TimeRange; + indexPatterns: string[]; + }): Promise<{ events: SafeResolverEvent[]; nextEvent: string | null }> { const events = entityID === metadata.entityIDs.origin - ? tree.relatedEvents.events.filter((event) => + ? relatedEvents.events.filter((event) => eventModel.eventCategory(event).includes(category) ) : []; @@ -95,17 +117,59 @@ export function noAncestorsTwoChildrenWithRelatedEventsOnOrigin(): { /** * Any of the origin's related events by event.id */ - async event(eventID: string): Promise { - return ( - tree.relatedEvents.events.find((event) => eventModel.eventID(event) === eventID) ?? null - ); + async event({ + nodeID, + eventID, + eventCategory, + eventTimestamp, + winlogRecordID, + timeRange, + indexPatterns, + }: { + nodeID: string; + eventCategory: string[]; + eventTimestamp: string; + eventID?: string | number; + winlogRecordID: string; + timeRange: TimeRange; + indexPatterns: string[]; + }): Promise { + return relatedEvents.events.find((event) => eventModel.eventID(event) === eventID) ?? null; + }, + + async nodeData({ + ids, + timeRange, + indexPatterns, + limit, + }: { + ids: string[]; + timeRange: TimeRange; + indexPatterns: string[]; + limit: number; + }): Promise { + return nodeDataResponse; }, /** * Fetch a ResolverTree for a entityID */ - async resolverTree(): Promise { - return tree; + async resolverTree({ + dataId, + schema, + timeRange, + indices, + ancestors, + descendants, + }: { + dataId: string; + schema: ResolverSchema; + timeRange: TimeRange; + indices: string[]; + ancestors: number; + descendants: number; + }): Promise { + return tree.nodes; }, /** diff --git a/x-pack/plugins/security_solution/public/resolver/data_access_layer/mocks/one_node_with_paginated_related_events.ts b/x-pack/plugins/security_solution/public/resolver/data_access_layer/mocks/one_node_with_paginated_related_events.ts index 7849776ed1378..98d42cee9aee9 100644 --- a/x-pack/plugins/security_solution/public/resolver/data_access_layer/mocks/one_node_with_paginated_related_events.ts +++ b/x-pack/plugins/security_solution/public/resolver/data_access_layer/mocks/one_node_with_paginated_related_events.ts @@ -4,13 +4,14 @@ * you may not use this file except in compliance with the Elastic License. */ -import { DataAccessLayer } from '../../types'; +import { DataAccessLayer, TimeRange } from '../../types'; import { mockTreeWithOneNodeAndTwoPagesOfRelatedEvents } from '../../mocks/resolver_tree'; import { ResolverRelatedEvents, - ResolverTree, ResolverEntityIndex, SafeResolverEvent, + ResolverNode, + ResolverSchema, } from '../../../../common/endpoint/types'; import * as eventModel from '../../../../common/endpoint/models/event'; @@ -37,7 +38,10 @@ export function oneNodeWithPaginatedEvents(): { databaseDocumentID: '_id', entityIDs: { origin: 'origin' }, }; - const tree = mockTreeWithOneNodeAndTwoPagesOfRelatedEvents({ + const mockTree: { + nodes: ResolverNode[]; + events: SafeResolverEvent[]; + } = mockTreeWithOneNodeAndTwoPagesOfRelatedEvents({ originID: metadata.entityIDs.origin, }); @@ -47,11 +51,19 @@ export function oneNodeWithPaginatedEvents(): { /** * Fetch related events for an entity ID */ - async relatedEvents(entityID: string): Promise { + async relatedEvents({ + entityID, + timeRange, + indexPatterns, + }: { + entityID: string; + timeRange: TimeRange; + indexPatterns: string[]; + }): Promise { /** * Respond with the mocked related events when the origin's related events are fetched. **/ - const events = entityID === metadata.entityIDs.origin ? tree.relatedEvents.events : []; + const events = entityID === metadata.entityIDs.origin ? mockTree.events : []; return { entityID, @@ -63,13 +75,21 @@ export function oneNodeWithPaginatedEvents(): { /** * If called with an "after" cursor, return the 2nd page, else return the first. */ - async eventsWithEntityIDAndCategory( - entityID: string, - category: string, - after?: string - ): Promise<{ events: SafeResolverEvent[]; nextEvent: string | null }> { + async eventsWithEntityIDAndCategory({ + entityID, + category, + after, + timeRange, + indexPatterns, + }: { + entityID: string; + category: string; + after?: string; + timeRange: TimeRange; + indexPatterns: string[]; + }): Promise<{ events: SafeResolverEvent[]; nextEvent: string | null }> { let events: SafeResolverEvent[] = []; - const eventsOfCategory = tree.relatedEvents.events.filter( + const eventsOfCategory = mockTree.events.filter( (event) => event.event?.category === category ); if (after === undefined) { @@ -86,17 +106,59 @@ export function oneNodeWithPaginatedEvents(): { /** * Any of the origin's related events by event.id */ - async event(eventID: string): Promise { - return ( - tree.relatedEvents.events.find((event) => eventModel.eventID(event) === eventID) ?? null - ); + async event({ + nodeID, + eventID, + eventCategory, + eventTimestamp, + winlogRecordID, + timeRange, + indexPatterns, + }: { + nodeID: string; + eventCategory: string[]; + eventTimestamp: string; + eventID?: string | number; + winlogRecordID: string; + timeRange: TimeRange; + indexPatterns: string[]; + }): Promise { + return mockTree.events.find((event) => eventModel.eventID(event) === eventID) ?? null; + }, + + async nodeData({ + ids, + timeRange, + indexPatterns, + limit, + }: { + ids: string[]; + timeRange: TimeRange; + indexPatterns: string[]; + limit: number; + }): Promise { + return []; }, /** * Fetch a ResolverTree for a entityID */ - async resolverTree(): Promise { - return tree; + async resolverTree({ + dataId, + schema, + timeRange, + indices, + ancestors, + descendants, + }: { + dataId: string; + schema: ResolverSchema; + timeRange: TimeRange; + indices: string[]; + ancestors: number; + descendants: number; + }): Promise { + return mockTree.nodes; }, /** diff --git a/x-pack/plugins/security_solution/public/resolver/data_access_layer/mocks/pausify_mock.ts b/x-pack/plugins/security_solution/public/resolver/data_access_layer/mocks/pausify_mock.ts index 6832affa3e511..d3f4540779db1 100644 --- a/x-pack/plugins/security_solution/public/resolver/data_access_layer/mocks/pausify_mock.ts +++ b/x-pack/plugins/security_solution/public/resolver/data_access_layer/mocks/pausify_mock.ts @@ -4,13 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { SafeResolverEvent } from './../../../../common/endpoint/types/index'; +import { ResolverNode, SafeResolverEvent } from './../../../../common/endpoint/types/index'; -import { - ResolverRelatedEvents, - ResolverTree, - ResolverEntityIndex, -} from '../../../../common/endpoint/types'; +import { ResolverRelatedEvents, ResolverEntityIndex } from '../../../../common/endpoint/types'; import { DataAccessLayer } from '../../types'; type PausableRequests = @@ -18,7 +14,8 @@ type PausableRequests = | 'resolverTree' | 'entities' | 'eventsWithEntityIDAndCategory' - | 'event'; + | 'event' + | 'nodeData'; interface Metadata { /** @@ -49,12 +46,14 @@ export function pausifyMock({ let relatedEventsPromise = Promise.resolve(); let eventsWithEntityIDAndCategoryPromise = Promise.resolve(); let eventPromise = Promise.resolve(); + let nodeDataPromise = Promise.resolve(); let resolverTreePromise = Promise.resolve(); let entitiesPromise = Promise.resolve(); let relatedEventsResolver: (() => void) | null; let eventsWithEntityIDAndCategoryResolver: (() => void) | null; let eventResolver: (() => void) | null; + let nodeDataResolver: (() => void) | null; let resolverTreeResolver: (() => void) | null; let entitiesResolver: (() => void) | null; @@ -68,6 +67,7 @@ export function pausifyMock({ 'eventsWithEntityIDAndCategory' ); const pauseEventRequest = pausableRequests.includes('event'); + const pauseNodeDataRequest = pausableRequests.includes('nodeData'); if (pauseRelatedEventsRequest && !relatedEventsResolver) { relatedEventsPromise = new Promise((resolve) => { @@ -89,6 +89,11 @@ export function pausifyMock({ relatedEventsResolver = resolve; }); } + if (pauseNodeDataRequest && !nodeDataResolver) { + nodeDataPromise = new Promise((resolve) => { + nodeDataResolver = resolve; + }); + } if (pauseResolverTreeRequest && !resolverTreeResolver) { resolverTreePromise = new Promise((resolve) => { resolverTreeResolver = resolve; @@ -108,6 +113,7 @@ export function pausifyMock({ 'eventsWithEntityIDAndCategory' ); const resumeEventRequest = pausableRequests.includes('event'); + const resumeNodeDataRequest = pausableRequests.includes('nodeData'); if (resumeEntitiesRequest && entitiesResolver) { entitiesResolver(); @@ -129,6 +135,10 @@ export function pausifyMock({ eventResolver(); eventResolver = null; } + if (resumeNodeDataRequest && nodeDataResolver) { + nodeDataResolver(); + nodeDataResolver = null; + } }, dataAccessLayer: { ...dataAccessLayer, @@ -161,10 +171,15 @@ export function pausifyMock({ return dataAccessLayer.event(...args); }, + async nodeData(...args): Promise { + await nodeDataPromise; + return dataAccessLayer.nodeData(...args); + }, + /** * Fetch a ResolverTree for a entityID */ - async resolverTree(...args): Promise { + async resolverTree(...args): Promise { await resolverTreePromise; return dataAccessLayer.resolverTree(...args); }, diff --git a/x-pack/plugins/security_solution/public/resolver/lib/tree_sequencers.test.ts b/x-pack/plugins/security_solution/public/resolver/lib/tree_sequencers.test.ts new file mode 100644 index 0000000000000..dcb01b02fd016 --- /dev/null +++ b/x-pack/plugins/security_solution/public/resolver/lib/tree_sequencers.test.ts @@ -0,0 +1,182 @@ +/* + * 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 { ResolverNode } from '../../../common/endpoint/types'; +import { EndpointDocGenerator, TreeNode } from '../../../common/endpoint/generate_data'; +import { calculateGenerationsAndDescendants } from './tree_sequencers'; +import { nodeID } from '../../../common/endpoint/models/node'; +import { genResolverNode, generateTree, convertEventToResolverNode } from '../mocks/generator'; + +describe('calculateGenerationsAndDescendants', () => { + const childrenOfNode = (childrenByParent: Map>) => { + return (parentNode: ResolverNode): ResolverNode[] => { + const id = nodeID(parentNode); + if (!id) { + return []; + } + + return Array.from(childrenByParent.get(id)?.values() ?? []).map((node: TreeNode) => { + return convertEventToResolverNode(node.lifecycle[0]); + }); + }; + }; + + let generator: EndpointDocGenerator; + beforeEach(() => { + generator = new EndpointDocGenerator('resolver'); + }); + + it('returns zero generations and descendants for a node with no children', () => { + const node = genResolverNode(generator); + const { generations, descendants } = calculateGenerationsAndDescendants({ + node, + currentLevel: 0, + totalDescendants: 0, + children: (parentNode: ResolverNode): ResolverNode[] => [], + }); + expect(generations).toBe(0); + expect(descendants).toBe(0); + }); + + it('returns one generation and one descendant for a node with one child', () => { + const tree = generateTree({ generations: 1, children: 1 }); + const { generations, descendants } = calculateGenerationsAndDescendants({ + node: convertEventToResolverNode(tree.generatedTree.origin.lifecycle[0]), + currentLevel: 0, + totalDescendants: 0, + children: childrenOfNode(tree.generatedTree.childrenByParent), + }); + + expect(generations).toBe(1); + expect(descendants).toBe(1); + }); + + it('returns 2 generations and 12 descendants for a graph that has 2 generations and three children per node', () => { + const tree = generateTree({ generations: 2, children: 3 }); + const { generations, descendants } = calculateGenerationsAndDescendants({ + node: convertEventToResolverNode(tree.generatedTree.origin.lifecycle[0]), + currentLevel: 0, + totalDescendants: 0, + children: childrenOfNode(tree.generatedTree.childrenByParent), + }); + expect(generations).toBe(2); + expect(descendants).toBe(12); + }); + + describe('graph with 3 generations and 7 descendants and weighted on the left', () => { + let childrenByParent: Map; + let origin: ResolverNode; + beforeEach(() => { + /** + * Build a tree that looks like this + * . + └── origin + ├── a + ├── b + │ └── d + └── c + ├── e + └── f + └── g + */ + + origin = genResolverNode(generator, { entityID: 'origin' }); + const a = genResolverNode(generator, { entityID: 'a', parentEntityID: String(origin.id) }); + const b = genResolverNode(generator, { entityID: 'b', parentEntityID: String(origin.id) }); + const d = genResolverNode(generator, { entityID: 'd', parentEntityID: String(b.id) }); + const c = genResolverNode(generator, { entityID: 'c', parentEntityID: String(origin.id) }); + const e = genResolverNode(generator, { entityID: 'e', parentEntityID: String(c.id) }); + const f = genResolverNode(generator, { entityID: 'f', parentEntityID: String(c.id) }); + const g = genResolverNode(generator, { entityID: 'g', parentEntityID: String(f.id) }); + + childrenByParent = new Map([ + ['origin', [a, b, c]], + ['a', []], + ['b', [d]], + ['c', [e, f]], + ['d', []], + ['e', []], + ['f', [g]], + ['g', []], + ]); + }); + it('returns 3 generations and 7 descendants', () => { + const { generations, descendants } = calculateGenerationsAndDescendants({ + node: origin, + currentLevel: 0, + totalDescendants: 0, + children: (parent: ResolverNode): ResolverNode[] => { + const id = nodeID(parent); + if (!id) { + return []; + } + + return childrenByParent.get(id) ?? []; + }, + }); + + expect(generations).toBe(3); + expect(descendants).toBe(7); + }); + }); + + describe('graph with 3 generations and 7 descendants and weighted on the right', () => { + let childrenByParent: Map; + let origin: ResolverNode; + beforeEach(() => { + /** + * Build a tree that looks like this + . + └── origin + ├── a + │ ├── d + │ │ └── f + │ └── e + ├── b + │ └── g + └── c + */ + + origin = genResolverNode(generator, { entityID: 'origin' }); + const a = genResolverNode(generator, { entityID: 'a', parentEntityID: String(origin.id) }); + const d = genResolverNode(generator, { entityID: 'd', parentEntityID: String(a.id) }); + const f = genResolverNode(generator, { entityID: 'f', parentEntityID: String(d.id) }); + const e = genResolverNode(generator, { entityID: 'e', parentEntityID: String(a.id) }); + const b = genResolverNode(generator, { entityID: 'b', parentEntityID: String(origin.id) }); + const g = genResolverNode(generator, { entityID: 'g', parentEntityID: String(b.id) }); + const c = genResolverNode(generator, { entityID: 'c', parentEntityID: String(origin.id) }); + + childrenByParent = new Map([ + ['origin', [a, b, c]], + ['a', [d, e]], + ['b', [g]], + ['c', []], + ['d', [f]], + ['e', []], + ['f', []], + ['g', []], + ]); + }); + it('returns 3 generations and 7 descendants', () => { + const { generations, descendants } = calculateGenerationsAndDescendants({ + node: origin, + currentLevel: 0, + totalDescendants: 0, + children: (parent: ResolverNode): ResolverNode[] => { + const id = nodeID(parent); + if (!id) { + return []; + } + + return childrenByParent.get(id) ?? []; + }, + }); + + expect(generations).toBe(3); + expect(descendants).toBe(7); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/resolver/lib/tree_sequencers.ts b/x-pack/plugins/security_solution/public/resolver/lib/tree_sequencers.ts index 843126c0eef5a..6a4846e6cfd92 100644 --- a/x-pack/plugins/security_solution/public/resolver/lib/tree_sequencers.ts +++ b/x-pack/plugins/security_solution/public/resolver/lib/tree_sequencers.ts @@ -19,3 +19,44 @@ export function* levelOrder(root: T, children: (parent: T) => T[]): Iterable< nextLevel = []; } } + +/** + * Calculates the generations and descendants in a resolver graph starting from a specific node in the graph. + * + * @param node the ResolverNode to start traversing the tree from + * @param currentLevel the level within the tree, the caller should pass in 0 to calculate the descendants from the + * passed in node + * @param totalDescendants the accumulated descendants while traversing the tree + * @param children a function for retrieving the direct children of a node + */ +export function calculateGenerationsAndDescendants({ + node, + currentLevel, + totalDescendants, + children, +}: { + node: T; + currentLevel: number; + totalDescendants: number; + children: (parent: T) => T[]; +}): { generations: number; descendants: number } { + const childrenArray = children(node); + // we reached a node that does not have any children so return + if (childrenArray.length <= 0) { + return { generations: currentLevel, descendants: totalDescendants }; + } + + let greatestLevel = 0; + let sumDescendants = totalDescendants; + for (const child of childrenArray) { + const { generations, descendants } = calculateGenerationsAndDescendants({ + node: child, + currentLevel: currentLevel + 1, + totalDescendants: sumDescendants + 1, + children, + }); + sumDescendants = descendants; + greatestLevel = Math.max(greatestLevel, generations); + } + return { generations: greatestLevel, descendants: sumDescendants }; +} diff --git a/x-pack/plugins/security_solution/public/resolver/mocks/endpoint_event.ts b/x-pack/plugins/security_solution/public/resolver/mocks/endpoint_event.ts index d19ca285ff3ff..500f523c8c35e 100644 --- a/x-pack/plugins/security_solution/public/resolver/mocks/endpoint_event.ts +++ b/x-pack/plugins/security_solution/public/resolver/mocks/endpoint_event.ts @@ -26,14 +26,14 @@ export function mockEndpointEvent({ eventType?: string; eventCategory?: string; pid?: number; - eventID?: string; + eventID?: string | number; }): SafeResolverEvent { return { '@timestamp': timestamp, event: { type: eventType, category: eventCategory, - id: eventID, + id: String(eventID), }, agent: { id: 'agent.id', diff --git a/x-pack/plugins/security_solution/public/resolver/mocks/generator.ts b/x-pack/plugins/security_solution/public/resolver/mocks/generator.ts new file mode 100644 index 0000000000000..67d3c0fb4a911 --- /dev/null +++ b/x-pack/plugins/security_solution/public/resolver/mocks/generator.ts @@ -0,0 +1,160 @@ +/* + * 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 { + EventStats, + FieldsObject, + NewResolverTree, + ResolverNode, + SafeResolverEvent, +} from '../../../common/endpoint/types'; +import { EventOptions } from '../../../common/endpoint/types/generator'; +import { + EndpointDocGenerator, + Tree, + TreeNode, + TreeOptions, + Event, +} from '../../../common/endpoint/generate_data'; +import * as eventModel from '../../../common/endpoint/models/event'; + +/** + * A structure for holding the generated tree. + */ +interface GeneratedTreeResponse { + generatedTree: Tree; + formattedTree: NewResolverTree; + allNodes: Map; +} + +/** + * Generates a tree consisting of endpoint data using the specified options. + * + * The returned object includes the tree in the raw form that is easier to navigate because it leverages maps and + * the formatted tree that can be used wherever NewResolverTree is expected. + * + * @param treeOptions options for how the tree should be generated, like number of ancestors, descendants, etc + */ +export function generateTree(treeOptions?: TreeOptions): GeneratedTreeResponse { + /** + * The parameter to EndpointDocGenerator is used as a seed for the random number generated used internally by the + * object. This means that the generator will return the same generated tree (ids, names, structure, etc) each + * time the doc generate is used in tests. This way we can rely on the generate returning consistent responses + * for our tests. The results won't be unpredictable and they will not result in flaky tests. + */ + const generator = new EndpointDocGenerator('resolver'); + const generatedTree = generator.generateTree({ + ...treeOptions, + // Force the tree generation to not randomize the number of children per node, it will always be the max specified + // in the passed in options + alwaysGenMaxChildrenPerNode: true, + }); + + const allNodes = new Map([ + [generatedTree.origin.id, generatedTree.origin], + ...generatedTree.children, + ...generatedTree.ancestry, + ]); + return { + allNodes, + generatedTree, + formattedTree: formatTree(generatedTree), + }; +} + +/** + * Builds a fields object style object from a generated event. + * + * @param {SafeResolverEvent} event a lifecycle event to convert into FieldObject style + */ +const buildFieldsObj = (event: Event): FieldsObject => { + return { + '@timestamp': eventModel.timestampSafeVersion(event) ?? 0, + 'process.entity_id': eventModel.entityIDSafeVersion(event) ?? '', + 'process.parent.entity_id': eventModel.parentEntityIDSafeVersion(event) ?? '', + 'process.name': eventModel.processNameSafeVersion(event) ?? '', + }; +}; + +/** + * Builds a ResolverNode from an endpoint event. + * + * @param event an endpoint event + * @param stats the related events stats to associate with the node + */ +export function convertEventToResolverNode( + event: Event, + stats: EventStats = { total: 0, byCategory: {} } +): ResolverNode { + return { + data: buildFieldsObj(event), + id: eventModel.entityIDSafeVersion(event) ?? '', + parent: eventModel.parentEntityIDSafeVersion(event), + stats, + name: eventModel.processNameSafeVersion(event), + }; +} + +/** + * Creates a ResolverNode object. + * + * @param generator a document generator + * @param options the configuration options to use when creating the node + * @param stats the related events stats to associate with the node + */ +export function genResolverNode( + generator: EndpointDocGenerator, + options?: EventOptions, + stats?: EventStats +) { + return convertEventToResolverNode(generator.generateEvent(options), stats); +} + +/** + * Converts a generated Tree to the new resolver tree format. + * + * @param tree a generated tree. + */ +export function formatTree(tree: Tree): NewResolverTree { + const allData = new Map([[tree.origin.id, tree.origin], ...tree.children, ...tree.ancestry]); + + /** + * Creates an EventStats object from a generated TreeNOde. + * @param node a TreeNode created by the EndpointDocGenerator + */ + const buildStats = (node: TreeNode): EventStats => { + return node.relatedEvents.reduce( + (accStats: EventStats, event: SafeResolverEvent) => { + accStats.total += 1; + const categories = eventModel.eventCategory(event); + if (categories.length > 0) { + const category = categories[0]; + if (accStats.byCategory[category] === undefined) { + accStats.byCategory[category] = 1; + } else { + accStats.byCategory[category] += 1; + } + } + return accStats; + }, + { total: 0, byCategory: {} } + ); + }; + + const treeResponse = Array.from(allData.values()).reduce( + (acc: ResolverNode[], node: TreeNode) => { + const lifecycleEvent = node.lifecycle[0]; + acc.push(convertEventToResolverNode(lifecycleEvent, buildStats(node))); + return acc; + }, + [] + ); + + return { + nodes: treeResponse, + originID: tree.origin.id, + }; +} diff --git a/x-pack/plugins/security_solution/public/resolver/mocks/resolver_node.ts b/x-pack/plugins/security_solution/public/resolver/mocks/resolver_node.ts new file mode 100644 index 0000000000000..eaee736469263 --- /dev/null +++ b/x-pack/plugins/security_solution/public/resolver/mocks/resolver_node.ts @@ -0,0 +1,39 @@ +/* + * 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 { ResolverNode } from '../../../common/endpoint/types'; + +/** + * Simple mock endpoint event that works for tree layouts. + */ +export function mockResolverNode({ + id, + name = 'node', + timestamp, + parentID, + stats = { total: 0, byCategory: {} }, +}: { + id: string; + name: string; + timestamp: number; + parentID?: string; + stats?: ResolverNode['stats']; +}): ResolverNode { + const resolverNode: ResolverNode = { + id, + name, + stats, + parent: parentID, + data: { + '@timestamp': timestamp, + 'process.entity_id': id, + 'process.name': name, + 'process.parent.entity_id': parentID, + }, + }; + + return resolverNode; +} diff --git a/x-pack/plugins/security_solution/public/resolver/mocks/resolver_tree.ts b/x-pack/plugins/security_solution/public/resolver/mocks/resolver_tree.ts index e4b8a7f477abb..f8e4880c652f6 100644 --- a/x-pack/plugins/security_solution/public/resolver/mocks/resolver_tree.ts +++ b/x-pack/plugins/security_solution/public/resolver/mocks/resolver_tree.ts @@ -5,98 +5,57 @@ */ import { mockEndpointEvent } from './endpoint_event'; -import { ResolverTree, SafeResolverEvent } from '../../../common/endpoint/types'; +import { + SafeResolverEvent, + NewResolverTree, + ResolverNode, + ResolverRelatedEvents, +} from '../../../common/endpoint/types'; import * as eventModel from '../../../common/endpoint/models/event'; +import * as nodeModel from '../../../common/endpoint/models/node'; +import { mockResolverNode } from './resolver_node'; export function mockTreeWithOneNodeAndTwoPagesOfRelatedEvents({ originID, }: { originID: string; -}): ResolverTree { - const originEvent: SafeResolverEvent = mockEndpointEvent({ - entityID: originID, - processName: 'c', - parentEntityID: undefined, - timestamp: 1600863932318, - }); +}): { + nodes: ResolverNode[]; + events: SafeResolverEvent[]; +} { + const timestamp = 1600863932318; + const nodeName = 'c'; + const eventsToGenerate = 30; const events = []; + // page size is currently 25 - const eventsToGenerate = 30; for (let i = 0; i < eventsToGenerate; i++) { const newEvent = mockEndpointEvent({ entityID: originID, eventID: `test-${i}`, eventType: 'access', eventCategory: 'registry', - timestamp: 1600863932318, + timestamp, }); events.push(newEvent); } - return { - entityID: originID, - children: { - childNodes: [], - nextChild: null, - }, - ancestry: { - nextAncestor: null, - ancestors: [], - }, - lifecycle: [originEvent], - relatedEvents: { events, nextEvent: null }, - relatedAlerts: { alerts: [], nextAlert: null }, - stats: { events: { total: eventsToGenerate, byCategory: {} }, totalAlerts: 0 }, - }; -} -export function mockTreeWith2AncestorsAndNoChildren({ - originID, - firstAncestorID, - secondAncestorID, -}: { - secondAncestorID: string; - firstAncestorID: string; - originID: string; -}): ResolverTree { - const secondAncestor: SafeResolverEvent = mockEndpointEvent({ - entityID: secondAncestorID, - processName: 'a', - parentEntityID: 'none', - timestamp: 1600863932316, - }); - const firstAncestor: SafeResolverEvent = mockEndpointEvent({ - entityID: firstAncestorID, - processName: 'b', - parentEntityID: secondAncestorID, - timestamp: 1600863932317, - }); - const originEvent: SafeResolverEvent = mockEndpointEvent({ - entityID: originID, - processName: 'c', - parentEntityID: firstAncestorID, - timestamp: 1600863932318, + const originNode: ResolverNode = mockResolverNode({ + id: originID, + name: nodeName, + timestamp, + stats: { total: eventsToGenerate, byCategory: { registry: eventsToGenerate } }, }); + + const treeResponse = [originNode]; + return { - entityID: originID, - children: { - childNodes: [], - nextChild: null, - }, - ancestry: { - nextAncestor: null, - ancestors: [ - { entityID: secondAncestorID, lifecycle: [secondAncestor] }, - { entityID: firstAncestorID, lifecycle: [firstAncestor] }, - ], - }, - lifecycle: [originEvent], - relatedEvents: { events: [], nextEvent: null }, - relatedAlerts: { alerts: [], nextAlert: null }, - stats: { events: { total: 2, byCategory: {} }, totalAlerts: 0 }, + nodes: treeResponse, + events, }; } -export function mockTreeWithAllProcessesTerminated({ +export function mockTreeWith2AncestorsAndNoChildren({ originID, firstAncestorID, secondAncestorID, @@ -104,88 +63,72 @@ export function mockTreeWithAllProcessesTerminated({ secondAncestorID: string; firstAncestorID: string; originID: string; -}): ResolverTree { - const secondAncestor: SafeResolverEvent = mockEndpointEvent({ - entityID: secondAncestorID, - processName: 'a', - parentEntityID: 'none', - timestamp: 1600863932316, - }); - const firstAncestor: SafeResolverEvent = mockEndpointEvent({ - entityID: firstAncestorID, - processName: 'b', - parentEntityID: secondAncestorID, +}): NewResolverTree { + const secondAncestorNode: ResolverNode = mockResolverNode({ + id: secondAncestorID, + name: 'a', timestamp: 1600863932317, }); - const originEvent: SafeResolverEvent = mockEndpointEvent({ - entityID: originID, - processName: 'c', - parentEntityID: firstAncestorID, - timestamp: 1600863932318, - }); - const secondAncestorTermination: SafeResolverEvent = mockEndpointEvent({ - entityID: secondAncestorID, - processName: 'a', - parentEntityID: 'none', - timestamp: 1600863932316, - eventType: 'end', - }); - const firstAncestorTermination: SafeResolverEvent = mockEndpointEvent({ - entityID: firstAncestorID, - processName: 'b', - parentEntityID: secondAncestorID, + + const firstAncestorNode: ResolverNode = mockResolverNode({ + id: firstAncestorID, + name: 'b', + parentID: secondAncestorID, timestamp: 1600863932317, - eventType: 'end', }); - const originEventTermination: SafeResolverEvent = mockEndpointEvent({ - entityID: originID, - processName: 'c', - parentEntityID: firstAncestorID, + + const originNode: ResolverNode = mockResolverNode({ + id: originID, + name: 'c', + parentID: firstAncestorID, timestamp: 1600863932318, - eventType: 'end', + stats: { total: 2, byCategory: {} }, }); - return ({ - entityID: originID, - children: { - childNodes: [], - }, - ancestry: { - ancestors: [ - { lifecycle: [secondAncestor, secondAncestorTermination] }, - { lifecycle: [firstAncestor, firstAncestorTermination] }, - ], - }, - lifecycle: [originEvent, originEventTermination], - } as unknown) as ResolverTree; + + return { + originID, + nodes: [secondAncestorNode, firstAncestorNode, originNode], + }; } /** * Add/replace related event info (on origin node) for any mock ResolverTree */ -function withRelatedEventsOnOrigin(tree: ResolverTree, events: SafeResolverEvent[]): ResolverTree { +function withRelatedEventsOnOrigin( + tree: NewResolverTree, + events: SafeResolverEvent[], + nodeDataResponse: SafeResolverEvent[], + originID: string +): { + tree: NewResolverTree; + relatedEvents: ResolverRelatedEvents; + nodeDataResponse: SafeResolverEvent[]; +} { const byCategory: Record = {}; const stats = { - totalAlerts: 0, - events: { - total: 0, - byCategory, - }, + total: 0, + byCategory, }; for (const event of events) { - stats.events.total++; + stats.total++; for (const category of eventModel.eventCategory(event)) { - stats.events.byCategory[category] = stats.events.byCategory[category] - ? stats.events.byCategory[category] + 1 - : 1; + stats.byCategory[category] = stats.byCategory[category] ? stats.byCategory[category] + 1 : 1; } } + + const originNode = tree.nodes.find((node) => node.id === originID); + if (originNode) { + originNode.stats = stats; + } + return { - ...tree, - stats, + tree, relatedEvents: { + entityID: originID, events, nextEvent: null, }, + nodeDataResponse, }; } @@ -197,22 +140,27 @@ export function mockTreeWithNoAncestorsAnd2Children({ originID: string; firstChildID: string; secondChildID: string; -}): ResolverTree { - const origin: SafeResolverEvent = mockEndpointEvent({ +}): { + treeResponse: ResolverNode[]; + resolverTree: NewResolverTree; + relatedEvents: ResolverRelatedEvents; + nodeDataResponse: SafeResolverEvent[]; +} { + const originProcessEvent: SafeResolverEvent = mockEndpointEvent({ pid: 0, entityID: originID, processName: 'c.ext', parentEntityID: 'none', timestamp: 1600863932316, }); - const firstChild: SafeResolverEvent = mockEndpointEvent({ + const firstChildProcessEvent: SafeResolverEvent = mockEndpointEvent({ pid: 1, entityID: firstChildID, processName: 'd', parentEntityID: originID, timestamp: 1600863932317, }); - const secondChild: SafeResolverEvent = mockEndpointEvent({ + const secondChildProcessEvent: SafeResolverEvent = mockEndpointEvent({ pid: 2, entityID: secondChildID, processName: @@ -221,23 +169,42 @@ export function mockTreeWithNoAncestorsAnd2Children({ timestamp: 1600863932318, }); + const originNode: ResolverNode = mockResolverNode({ + id: originID, + name: 'c.ext', + stats: { total: 2, byCategory: {} }, + timestamp: 1600863932316, + }); + + const firstChildNode: ResolverNode = mockResolverNode({ + id: firstChildID, + name: 'd', + parentID: originID, + timestamp: 1600863932317, + }); + + const secondChildNode: ResolverNode = mockResolverNode({ + id: secondChildID, + name: + 'really_really_really_really_really_really_really_really_really_really_really_really_really_really_long_node_name', + parentID: originID, + timestamp: 1600863932318, + }); + + const treeResponse = [originNode, firstChildNode, secondChildNode]; + return { - entityID: originID, - children: { - childNodes: [ - { entityID: firstChildID, lifecycle: [firstChild] }, - { entityID: secondChildID, lifecycle: [secondChild] }, - ], - nextChild: null, + treeResponse, + resolverTree: { + originID, + nodes: treeResponse, }, - ancestry: { - ancestors: [], - nextAncestor: null, + relatedEvents: { + entityID: originID, + events: [], + nextEvent: null, }, - lifecycle: [origin], - relatedEvents: { events: [], nextEvent: null }, - relatedAlerts: { alerts: [], nextAlert: null }, - stats: { events: { total: 2, byCategory: {} }, totalAlerts: 0 }, + nodeDataResponse: [originProcessEvent, firstChildProcessEvent, secondChildProcessEvent], }; } @@ -254,101 +221,79 @@ export function mockTreeWith1AncestorAnd2ChildrenAndAllNodesHave2GraphableEvents originID: string; firstChildID: string; secondChildID: string; -}): ResolverTree { - const ancestor: SafeResolverEvent = mockEndpointEvent({ - entityID: ancestorID, - processName: ancestorID, +}): NewResolverTree { + const ancestor: ResolverNode = mockResolverNode({ + id: ancestorID, + name: ancestorID, timestamp: 1600863932317, - parentEntityID: undefined, + parentID: undefined, }); - const ancestorClone: SafeResolverEvent = mockEndpointEvent({ - entityID: ancestorID, - processName: ancestorID, + const ancestorClone: ResolverNode = mockResolverNode({ + id: ancestorID, + name: ancestorID, timestamp: 1600863932317, - parentEntityID: undefined, + parentID: undefined, }); - const origin: SafeResolverEvent = mockEndpointEvent({ - entityID: originID, - processName: originID, - parentEntityID: ancestorID, + const origin: ResolverNode = mockResolverNode({ + id: originID, + name: originID, + parentID: ancestorID, timestamp: 1600863932316, }); - const originClone: SafeResolverEvent = mockEndpointEvent({ - entityID: originID, - processName: originID, - parentEntityID: ancestorID, + const originClone: ResolverNode = mockResolverNode({ + id: originID, + name: originID, + parentID: ancestorID, timestamp: 1600863932316, }); - const firstChild: SafeResolverEvent = mockEndpointEvent({ - entityID: firstChildID, - processName: firstChildID, - parentEntityID: originID, + const firstChild: ResolverNode = mockResolverNode({ + id: firstChildID, + name: firstChildID, + parentID: originID, timestamp: 1600863932317, }); - const firstChildClone: SafeResolverEvent = mockEndpointEvent({ - entityID: firstChildID, - processName: firstChildID, - parentEntityID: originID, + const firstChildClone: ResolverNode = mockResolverNode({ + id: firstChildID, + name: firstChildID, + parentID: originID, timestamp: 1600863932317, }); - const secondChild: SafeResolverEvent = mockEndpointEvent({ - entityID: secondChildID, - processName: secondChildID, - parentEntityID: originID, + const secondChild: ResolverNode = mockResolverNode({ + id: secondChildID, + name: secondChildID, + parentID: originID, timestamp: 1600863932318, }); - const secondChildClone: SafeResolverEvent = mockEndpointEvent({ - entityID: secondChildID, - processName: secondChildID, - parentEntityID: originID, + const secondChildClone: ResolverNode = mockResolverNode({ + id: secondChildID, + name: secondChildID, + parentID: originID, timestamp: 1600863932318, }); - return ({ - entityID: originID, - children: { - childNodes: [ - { lifecycle: [firstChild, firstChildClone] }, - { lifecycle: [secondChild, secondChildClone] }, - ], - }, - ancestry: { - ancestors: [{ lifecycle: [ancestor, ancestorClone] }], - }, - lifecycle: [origin, originClone], - } as unknown) as ResolverTree; -} + const treeResponse = [ + ancestor, + ancestorClone, + origin, + originClone, + firstChild, + firstChildClone, + secondChild, + secondChildClone, + ]; -export function mockTreeWithNoProcessEvents(): ResolverTree { return { - entityID: 'entityID', - children: { - childNodes: [], - nextChild: null, - }, - relatedEvents: { - events: [], - nextEvent: null, - }, - relatedAlerts: { - alerts: [], - nextAlert: null, - }, - lifecycle: [], - ancestry: { - ancestors: [], - nextAncestor: null, - }, - stats: { - totalAlerts: 0, - events: { - total: 0, - byCategory: {}, - }, - }, + originID, + nodes: treeResponse, }; } +export function mockTreeWithNoProcessEvents(): NewResolverTree { + return { + originID: 'entityID', + nodes: [], + }; +} /** * first ID (to check in the mock data access layer) */ @@ -367,12 +312,14 @@ export function mockTreeWithNoAncestorsAndTwoChildrenAndRelatedEventsOnOrigin({ firstChildID: string; secondChildID: string; }) { - const baseTree = mockTreeWithNoAncestorsAnd2Children({ + const { resolverTree, nodeDataResponse } = mockTreeWithNoAncestorsAnd2Children({ originID, firstChildID, secondChildID, }); - const parentEntityID = eventModel.parentEntityIDSafeVersion(baseTree.lifecycle[0]); + const parentEntityID = nodeModel.parentId( + resolverTree.nodes.find((node) => node.id === originID)! + ); const relatedEvents = [ mockEndpointEvent({ entityID: originID, @@ -415,5 +362,5 @@ export function mockTreeWithNoAncestorsAndTwoChildrenAndRelatedEventsOnOrigin({ }) ); } - return withRelatedEventsOnOrigin(baseTree, relatedEvents); + return withRelatedEventsOnOrigin(resolverTree, relatedEvents, nodeDataResponse, originID); } diff --git a/x-pack/plugins/security_solution/public/resolver/mocks/tree_schema.ts b/x-pack/plugins/security_solution/public/resolver/mocks/tree_schema.ts new file mode 100644 index 0000000000000..375e2a76229a6 --- /dev/null +++ b/x-pack/plugins/security_solution/public/resolver/mocks/tree_schema.ts @@ -0,0 +1,39 @@ +/* + * 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 { ResolverSchema } from '../../../common/endpoint/types'; + +/* + * This file provides simple factory functions which return mock schemas for various data sources such as endpoint and winlogbeat. + * This information is part of what is returned by the `entities` call in the dataAccessLayer and used in the`resolverTree` api call. + */ + +const defaultProcessSchema = { + id: 'process.entity_id', + name: 'process.name', + parent: 'process.parent.entity_id', +}; + +/* Factory function returning the source and schema for the endpoint data source */ +export function endpointSourceSchema(): { dataSource: string; schema: ResolverSchema } { + return { + dataSource: 'endpoint', + schema: { + ...defaultProcessSchema, + ancestry: 'process.Ext.ancestry', + }, + }; +} + +/* Factory function returning the source and schema for the winlogbeat data source */ +export function winlogSourceSchema(): { dataSource: string; schema: ResolverSchema } { + return { + dataSource: 'winlogbeat', + schema: { + ...defaultProcessSchema, + }, + }; +} diff --git a/x-pack/plugins/security_solution/public/resolver/models/indexed_process_tree/__snapshots__/isometric_taxi_layout.test.ts.snap b/x-pack/plugins/security_solution/public/resolver/models/indexed_process_tree/__snapshots__/isometric_taxi_layout.test.ts.snap index b77a5d09008cc..7a79adbff2d74 100644 --- a/x-pack/plugins/security_solution/public/resolver/models/indexed_process_tree/__snapshots__/isometric_taxi_layout.test.ts.snap +++ b/x-pack/plugins/security_solution/public/resolver/models/indexed_process_tree/__snapshots__/isometric_taxi_layout.test.ts.snap @@ -12,34 +12,36 @@ exports[`resolver graph layout when rendering one node renders right 1`] = ` Object { "ariaLevels": Map { Object { - "@timestamp": 1582233383000, - "agent": Object { - "id": "", - "type": "", - "version": "", - }, - "endgame": Object { - "event_subtype_full": "creation_event", - "event_type_full": "process_event", - "process_name": "", - "unique_pid": 0, + "data": Object { + "@timestamp": 1606234833273, + "process.entity_id": "A", + "process.name": "powershell.exe", + "process.parent.entity_id": "", + }, + "id": "A", + "name": "powershell.exe", + "parent": undefined, + "stats": Object { + "byCategory": Object {}, + "total": 0, }, } => 1, }, "edgeLineSegments": Array [], "processNodePositions": Map { Object { - "@timestamp": 1582233383000, - "agent": Object { - "id": "", - "type": "", - "version": "", + "data": Object { + "@timestamp": 1606234833273, + "process.entity_id": "A", + "process.name": "powershell.exe", + "process.parent.entity_id": "", }, - "endgame": Object { - "event_subtype_full": "creation_event", - "event_type_full": "process_event", - "process_name": "", - "unique_pid": 0, + "id": "A", + "name": "powershell.exe", + "parent": undefined, + "stats": Object { + "byCategory": Object {}, + "total": 0, }, } => Array [ 0, @@ -53,136 +55,145 @@ exports[`resolver graph layout when rendering two forks, and one fork has an ext Object { "ariaLevels": Map { Object { - "@timestamp": 1582233383000, - "agent": Object { - "id": "", - "type": "", - "version": "", + "data": Object { + "@timestamp": 1606234833273, + "process.entity_id": "A", + "process.name": "lsass.exe", + "process.parent.entity_id": "", }, - "endgame": Object { - "event_subtype_full": "creation_event", - "event_type_full": "process_event", - "process_name": "", - "unique_pid": 0, + "id": "A", + "name": "lsass.exe", + "parent": undefined, + "stats": Object { + "byCategory": Object {}, + "total": 0, }, } => 1, Object { - "@timestamp": 1582233383000, - "agent": Object { - "id": "", - "type": "", - "version": "", + "data": Object { + "@timestamp": 1606234833273, + "process.entity_id": "B", + "process.name": "lsass.exe", + "process.parent.entity_id": "A", }, - "endgame": Object { - "event_subtype_full": "already_running", - "event_type_full": "process_event", - "unique_pid": 1, - "unique_ppid": 0, + "id": "B", + "name": "lsass.exe", + "parent": "A", + "stats": Object { + "byCategory": Object {}, + "total": 0, }, } => 2, Object { - "@timestamp": 1582233383000, - "agent": Object { - "id": "", - "type": "", - "version": "", + "data": Object { + "@timestamp": 1606234833273, + "process.entity_id": "C", + "process.name": "powershell.exe", + "process.parent.entity_id": "A", }, - "endgame": Object { - "event_subtype_full": "creation_event", - "event_type_full": "process_event", - "unique_pid": 2, - "unique_ppid": 0, + "id": "C", + "name": "powershell.exe", + "parent": "A", + "stats": Object { + "byCategory": Object {}, + "total": 0, }, } => 2, Object { - "@timestamp": 1582233383000, - "agent": Object { - "id": "", - "type": "", - "version": "", + "data": Object { + "@timestamp": 1606234833273, + "process.entity_id": "I", + "process.name": "lsass.exe", + "process.parent.entity_id": "A", }, - "endgame": Object { - "event_subtype_full": "termination_event", - "event_type_full": "process_event", - "unique_pid": 8, - "unique_ppid": 0, + "id": "I", + "name": "lsass.exe", + "parent": "A", + "stats": Object { + "byCategory": Object {}, + "total": 0, }, } => 2, Object { - "@timestamp": 1582233383000, - "agent": Object { - "id": "", - "type": "", - "version": "", + "data": Object { + "@timestamp": 1606234833273, + "process.entity_id": "D", + "process.name": "notepad.exe", + "process.parent.entity_id": "B", }, - "endgame": Object { - "event_subtype_full": "creation_event", - "event_type_full": "process_event", - "unique_pid": 3, - "unique_ppid": 1, + "id": "D", + "name": "notepad.exe", + "parent": "B", + "stats": Object { + "byCategory": Object {}, + "total": 0, }, } => 3, Object { - "@timestamp": 1582233383000, - "agent": Object { - "id": "", - "type": "", - "version": "", + "data": Object { + "@timestamp": 1606234833273, + "process.entity_id": "E", + "process.name": "notepad.exe", + "process.parent.entity_id": "B", }, - "endgame": Object { - "event_subtype_full": "creation_event", - "event_type_full": "process_event", - "unique_pid": 4, - "unique_ppid": 1, + "id": "E", + "name": "notepad.exe", + "parent": "B", + "stats": Object { + "byCategory": Object {}, + "total": 0, }, } => 3, Object { - "@timestamp": 1582233383000, - "agent": Object { - "id": "", - "type": "", - "version": "", + "data": Object { + "@timestamp": 1606234833273, + "process.entity_id": "F", + "process.name": "explorer.exe", + "process.parent.entity_id": "C", }, - "endgame": Object { - "event_subtype_full": "creation_event", - "event_type_full": "process_event", - "unique_pid": 5, - "unique_ppid": 2, + "id": "F", + "name": "explorer.exe", + "parent": "C", + "stats": Object { + "byCategory": Object {}, + "total": 0, }, } => 3, Object { - "@timestamp": 1582233383000, - "agent": Object { - "id": "", - "type": "", - "version": "", + "data": Object { + "@timestamp": 1606234833273, + "process.entity_id": "G", + "process.name": "explorer.exe", + "process.parent.entity_id": "C", }, - "endgame": Object { - "event_subtype_full": "creation_event", - "event_type_full": "process_event", - "unique_pid": 6, - "unique_ppid": 2, + "id": "G", + "name": "explorer.exe", + "parent": "C", + "stats": Object { + "byCategory": Object {}, + "total": 0, }, } => 3, Object { - "@timestamp": 1582233383000, - "agent": Object { - "id": "", - "type": "", - "version": "", + "data": Object { + "@timestamp": 1606234833273, + "process.entity_id": "H", + "process.name": "mimikatz.exe", + "process.parent.entity_id": "G", }, - "endgame": Object { - "event_subtype_full": "creation_event", - "event_type_full": "process_event", - "unique_pid": 7, - "unique_ppid": 6, + "id": "H", + "name": "mimikatz.exe", + "parent": "G", + "stats": Object { + "byCategory": Object {}, + "total": 0, }, } => 4, }, "edgeLineSegments": Array [ Object { "metadata": Object { - "reactKey": "parentToMidedge:0:1", + "reactKey": "parentToMidedge:A:B", }, "points": Array [ Array [ @@ -197,7 +208,7 @@ Object { }, Object { "metadata": Object { - "reactKey": "midwayedge:0:1", + "reactKey": "midwayedge:A:B", }, "points": Array [ Array [ @@ -216,7 +227,7 @@ Object { "duration": "<1", "durationType": "millisecond", }, - "reactKey": "edge:0:1", + "reactKey": "edge:A:B", }, "points": Array [ Array [ @@ -235,7 +246,7 @@ Object { "duration": "<1", "durationType": "millisecond", }, - "reactKey": "edge:0:2", + "reactKey": "edge:A:C", }, "points": Array [ Array [ @@ -254,7 +265,7 @@ Object { "duration": "<1", "durationType": "millisecond", }, - "reactKey": "edge:0:8", + "reactKey": "edge:A:I", }, "points": Array [ Array [ @@ -269,7 +280,7 @@ Object { }, Object { "metadata": Object { - "reactKey": "parentToMidedge:1:3", + "reactKey": "parentToMidedge:B:D", }, "points": Array [ Array [ @@ -284,7 +295,7 @@ Object { }, Object { "metadata": Object { - "reactKey": "midwayedge:1:3", + "reactKey": "midwayedge:B:D", }, "points": Array [ Array [ @@ -303,7 +314,7 @@ Object { "duration": "<1", "durationType": "millisecond", }, - "reactKey": "edge:1:3", + "reactKey": "edge:B:D", }, "points": Array [ Array [ @@ -322,7 +333,7 @@ Object { "duration": "<1", "durationType": "millisecond", }, - "reactKey": "edge:1:4", + "reactKey": "edge:B:E", }, "points": Array [ Array [ @@ -337,7 +348,7 @@ Object { }, Object { "metadata": Object { - "reactKey": "parentToMidedge:2:5", + "reactKey": "parentToMidedge:C:F", }, "points": Array [ Array [ @@ -352,7 +363,7 @@ Object { }, Object { "metadata": Object { - "reactKey": "midwayedge:2:5", + "reactKey": "midwayedge:C:F", }, "points": Array [ Array [ @@ -371,7 +382,7 @@ Object { "duration": "<1", "durationType": "millisecond", }, - "reactKey": "edge:2:5", + "reactKey": "edge:C:F", }, "points": Array [ Array [ @@ -390,7 +401,7 @@ Object { "duration": "<1", "durationType": "millisecond", }, - "reactKey": "edge:2:6", + "reactKey": "edge:C:G", }, "points": Array [ Array [ @@ -409,7 +420,7 @@ Object { "duration": "<1", "durationType": "millisecond", }, - "reactKey": "edge:6:7", + "reactKey": "edge:G:H", }, "points": Array [ Array [ @@ -425,153 +436,162 @@ Object { ], "processNodePositions": Map { Object { - "@timestamp": 1582233383000, - "agent": Object { - "id": "", - "type": "", - "version": "", + "data": Object { + "@timestamp": 1606234833273, + "process.entity_id": "A", + "process.name": "lsass.exe", + "process.parent.entity_id": "", }, - "endgame": Object { - "event_subtype_full": "creation_event", - "event_type_full": "process_event", - "process_name": "", - "unique_pid": 0, + "id": "A", + "name": "lsass.exe", + "parent": undefined, + "stats": Object { + "byCategory": Object {}, + "total": 0, }, } => Array [ 0, -0.8164965809277259, ], Object { - "@timestamp": 1582233383000, - "agent": Object { - "id": "", - "type": "", - "version": "", + "data": Object { + "@timestamp": 1606234833273, + "process.entity_id": "B", + "process.name": "lsass.exe", + "process.parent.entity_id": "A", }, - "endgame": Object { - "event_subtype_full": "already_running", - "event_type_full": "process_event", - "unique_pid": 1, - "unique_ppid": 0, + "id": "B", + "name": "lsass.exe", + "parent": "A", + "stats": Object { + "byCategory": Object {}, + "total": 0, }, } => Array [ 98.99494936611666, -400.8998212355134, ], Object { - "@timestamp": 1582233383000, - "agent": Object { - "id": "", - "type": "", - "version": "", + "data": Object { + "@timestamp": 1606234833273, + "process.entity_id": "C", + "process.name": "powershell.exe", + "process.parent.entity_id": "A", }, - "endgame": Object { - "event_subtype_full": "creation_event", - "event_type_full": "process_event", - "unique_pid": 2, - "unique_ppid": 0, + "id": "C", + "name": "powershell.exe", + "parent": "A", + "stats": Object { + "byCategory": Object {}, + "total": 0, }, } => Array [ 494.9747468305833, -172.28077857575016, ], Object { - "@timestamp": 1582233383000, - "agent": Object { - "id": "", - "type": "", - "version": "", + "data": Object { + "@timestamp": 1606234833273, + "process.entity_id": "I", + "process.name": "lsass.exe", + "process.parent.entity_id": "A", }, - "endgame": Object { - "event_subtype_full": "termination_event", - "event_type_full": "process_event", - "unique_pid": 8, - "unique_ppid": 0, + "id": "I", + "name": "lsass.exe", + "parent": "A", + "stats": Object { + "byCategory": Object {}, + "total": 0, }, } => Array [ 791.9595949289333, -0.8164965809277259, ], Object { - "@timestamp": 1582233383000, - "agent": Object { - "id": "", - "type": "", - "version": "", + "data": Object { + "@timestamp": 1606234833273, + "process.entity_id": "D", + "process.name": "notepad.exe", + "process.parent.entity_id": "B", }, - "endgame": Object { - "event_subtype_full": "creation_event", - "event_type_full": "process_event", - "unique_pid": 3, - "unique_ppid": 1, + "id": "D", + "name": "notepad.exe", + "parent": "B", + "stats": Object { + "byCategory": Object {}, + "total": 0, }, } => Array [ 395.9797974644666, -686.6736245602175, ], Object { - "@timestamp": 1582233383000, - "agent": Object { - "id": "", - "type": "", - "version": "", + "data": Object { + "@timestamp": 1606234833273, + "process.entity_id": "E", + "process.name": "notepad.exe", + "process.parent.entity_id": "B", }, - "endgame": Object { - "event_subtype_full": "creation_event", - "event_type_full": "process_event", - "unique_pid": 4, - "unique_ppid": 1, + "id": "E", + "name": "notepad.exe", + "parent": "B", + "stats": Object { + "byCategory": Object {}, + "total": 0, }, } => Array [ 593.9696961966999, -572.3641032303359, ], Object { - "@timestamp": 1582233383000, - "agent": Object { - "id": "", - "type": "", - "version": "", + "data": Object { + "@timestamp": 1606234833273, + "process.entity_id": "F", + "process.name": "explorer.exe", + "process.parent.entity_id": "C", }, - "endgame": Object { - "event_subtype_full": "creation_event", - "event_type_full": "process_event", - "unique_pid": 5, - "unique_ppid": 2, + "id": "F", + "name": "explorer.exe", + "parent": "C", + "stats": Object { + "byCategory": Object {}, + "total": 0, }, } => Array [ 791.9595949289333, -458.05458190045425, ], Object { - "@timestamp": 1582233383000, - "agent": Object { - "id": "", - "type": "", - "version": "", + "data": Object { + "@timestamp": 1606234833273, + "process.entity_id": "G", + "process.name": "explorer.exe", + "process.parent.entity_id": "C", }, - "endgame": Object { - "event_subtype_full": "creation_event", - "event_type_full": "process_event", - "unique_pid": 6, - "unique_ppid": 2, + "id": "G", + "name": "explorer.exe", + "parent": "C", + "stats": Object { + "byCategory": Object {}, + "total": 0, }, } => Array [ 989.9494936611666, -343.7450605705726, ], Object { - "@timestamp": 1582233383000, - "agent": Object { - "id": "", - "type": "", - "version": "", + "data": Object { + "@timestamp": 1606234833273, + "process.entity_id": "H", + "process.name": "mimikatz.exe", + "process.parent.entity_id": "G", }, - "endgame": Object { - "event_subtype_full": "creation_event", - "event_type_full": "process_event", - "unique_pid": 7, - "unique_ppid": 6, + "id": "H", + "name": "mimikatz.exe", + "parent": "G", + "stats": Object { + "byCategory": Object {}, + "total": 0, }, } => Array [ 1187.9393923933999, @@ -585,31 +605,33 @@ exports[`resolver graph layout when rendering two nodes, one being the parent of Object { "ariaLevels": Map { Object { - "@timestamp": 1582233383000, - "agent": Object { - "id": "", - "type": "", - "version": "", + "data": Object { + "@timestamp": 1606234833273, + "process.entity_id": "A", + "process.name": "iexlorer.exe", + "process.parent.entity_id": "", }, - "endgame": Object { - "event_subtype_full": "creation_event", - "event_type_full": "process_event", - "process_name": "", - "unique_pid": 0, + "id": "A", + "name": "iexlorer.exe", + "parent": undefined, + "stats": Object { + "byCategory": Object {}, + "total": 0, }, } => 1, Object { - "@timestamp": 1582233383000, - "agent": Object { - "id": "", - "type": "", - "version": "", + "data": Object { + "@timestamp": 1606234833273, + "process.entity_id": "B", + "process.name": "notepad.exe", + "process.parent.entity_id": "A", }, - "endgame": Object { - "event_subtype_full": "already_running", - "event_type_full": "process_event", - "unique_pid": 1, - "unique_ppid": 0, + "id": "B", + "name": "notepad.exe", + "parent": "A", + "stats": Object { + "byCategory": Object {}, + "total": 0, }, } => 2, }, @@ -620,7 +642,7 @@ Object { "duration": "<1", "durationType": "millisecond", }, - "reactKey": "edge:0:1", + "reactKey": "edge:A:B", }, "points": Array [ Array [ @@ -636,34 +658,36 @@ Object { ], "processNodePositions": Map { Object { - "@timestamp": 1582233383000, - "agent": Object { - "id": "", - "type": "", - "version": "", + "data": Object { + "@timestamp": 1606234833273, + "process.entity_id": "A", + "process.name": "iexlorer.exe", + "process.parent.entity_id": "", }, - "endgame": Object { - "event_subtype_full": "creation_event", - "event_type_full": "process_event", - "process_name": "", - "unique_pid": 0, + "id": "A", + "name": "iexlorer.exe", + "parent": undefined, + "stats": Object { + "byCategory": Object {}, + "total": 0, }, } => Array [ 0, -0.8164965809277259, ], Object { - "@timestamp": 1582233383000, - "agent": Object { - "id": "", - "type": "", - "version": "", - }, - "endgame": Object { - "event_subtype_full": "already_running", - "event_type_full": "process_event", - "unique_pid": 1, - "unique_ppid": 0, + "data": Object { + "@timestamp": 1606234833273, + "process.entity_id": "B", + "process.name": "notepad.exe", + "process.parent.entity_id": "A", + }, + "id": "B", + "name": "notepad.exe", + "parent": "A", + "stats": Object { + "byCategory": Object {}, + "total": 0, }, } => Array [ 197.9898987322333, diff --git a/x-pack/plugins/security_solution/public/resolver/models/indexed_process_tree/index.test.ts b/x-pack/plugins/security_solution/public/resolver/models/indexed_process_tree/index.test.ts new file mode 100644 index 0000000000000..bbe4221d843d2 --- /dev/null +++ b/x-pack/plugins/security_solution/public/resolver/models/indexed_process_tree/index.test.ts @@ -0,0 +1,116 @@ +/* + * 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 { ResolverNode } from '../../../../common/endpoint/types'; +import { EndpointDocGenerator } from '../../../../common/endpoint/generate_data'; +import { generateTree, genResolverNode } from '../../mocks/generator'; +import { IndexedProcessTree } from '../../types'; +import { factory } from './index'; + +describe('factory', () => { + const originID = 'origin'; + let tree: IndexedProcessTree; + let generator: EndpointDocGenerator; + beforeEach(() => { + generator = new EndpointDocGenerator('resolver'); + }); + + describe('graph with an undefined originID', () => { + beforeEach(() => { + const generatedTreeMetadata = generateTree({ + ancestors: 5, + generations: 2, + children: 2, + }); + tree = factory(generatedTreeMetadata.formattedTree.nodes, undefined); + }); + + it('sets ancestors, descendants, and generations to undefined', () => { + expect(tree.ancestors).toBeUndefined(); + expect(tree.descendants).toBeUndefined(); + expect(tree.generations).toBeUndefined(); + }); + }); + + describe('graph with 10 ancestors', () => { + beforeEach(() => { + const generatedTreeMetadata = generateTree({ + // the ancestors value here does not include the origin + ancestors: 9, + }); + tree = factory( + generatedTreeMetadata.formattedTree.nodes, + generatedTreeMetadata.generatedTree.origin.id + ); + }); + + it('returns 10 ancestors', () => { + expect(tree.ancestors).toBe(10); + }); + }); + + describe('graph with 3 generations and 7 descendants and weighted on the left', () => { + let origin: ResolverNode; + let a: ResolverNode; + let b: ResolverNode; + let c: ResolverNode; + let d: ResolverNode; + let e: ResolverNode; + let f: ResolverNode; + let g: ResolverNode; + beforeEach(() => { + /** + * Build a tree that looks like this + * . + └── origin + ├── a + ├── b + │ └── d + └── c + ├── e + └── f + └── g + */ + + origin = genResolverNode(generator, { entityID: originID }); + a = genResolverNode(generator, { entityID: 'a', parentEntityID: String(origin.id) }); + b = genResolverNode(generator, { entityID: 'b', parentEntityID: String(origin.id) }); + d = genResolverNode(generator, { entityID: 'd', parentEntityID: String(b.id) }); + c = genResolverNode(generator, { entityID: 'c', parentEntityID: String(origin.id) }); + e = genResolverNode(generator, { entityID: 'e', parentEntityID: String(c.id) }); + f = genResolverNode(generator, { entityID: 'f', parentEntityID: String(c.id) }); + g = genResolverNode(generator, { entityID: 'g', parentEntityID: String(f.id) }); + tree = factory([origin, a, b, c, d, e, f, g], originID); + }); + + it('returns 3 generations, 7 descendants, 1 ancestors', () => { + expect(tree.generations).toBe(3); + expect(tree.descendants).toBe(7); + expect(tree.ancestors).toBe(1); + }); + + it('returns the origin for the originID', () => { + expect(tree.originID).toBe(originID); + }); + + it('constructs the idToChildren map correctly', () => { + // the idToChildren only has ids for the parents, there are 4 obvious parents and 1 parent to the origin + // that would be a key of undefined, so 5 total. + expect(tree.idToChildren.size).toBe(5); + expect(tree.idToChildren.get('c')).toEqual([e, f]); + expect(tree.idToChildren.get('b')).toEqual([d]); + expect(tree.idToChildren.get('origin')).toEqual([a, b, c]); + expect(tree.idToChildren.get('f')).toEqual([g]); + expect(tree.idToChildren.get('g')).toEqual(undefined); + }); + + it('constructs the idToNode map correctly', () => { + expect(tree.idToNode.size).toBe(8); + expect(tree.idToNode.get('origin')).toBe(origin); + expect(tree.idToNode.get('g')).toBe(g); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/resolver/models/indexed_process_tree/index.ts b/x-pack/plugins/security_solution/public/resolver/models/indexed_process_tree/index.ts index f6b893ba25b78..a14d7d87a7d45 100644 --- a/x-pack/plugins/security_solution/public/resolver/models/indexed_process_tree/index.ts +++ b/x-pack/plugins/security_solution/public/resolver/models/indexed_process_tree/index.ts @@ -6,9 +6,59 @@ import { orderByTime } from '../process_event'; import { IndexedProcessTree } from '../../types'; -import { SafeResolverEvent } from '../../../../common/endpoint/types'; -import { levelOrder as baseLevelOrder } from '../../lib/tree_sequencers'; -import * as eventModel from '../../../../common/endpoint/models/event'; +import { ResolverNode } from '../../../../common/endpoint/types'; +import { + levelOrder as baseLevelOrder, + calculateGenerationsAndDescendants, +} from '../../lib/tree_sequencers'; +import * as nodeModel from '../../../../common/endpoint/models/node'; + +function calculateGenerationsAndDescendantsFromOrigin( + origin: ResolverNode | undefined, + descendants: Map +): { generations: number; descendants: number } | undefined { + if (!origin) { + return; + } + + return calculateGenerationsAndDescendants({ + node: origin, + currentLevel: 0, + totalDescendants: 0, + children: (parentNode: ResolverNode): ResolverNode[] => + descendants.get(nodeModel.nodeID(parentNode)) ?? [], + }); +} + +function parentInternal(node: ResolverNode, idToNode: Map) { + const uniqueParentId = nodeModel.parentId(node); + if (uniqueParentId === undefined) { + return undefined; + } else { + return idToNode.get(uniqueParentId); + } +} + +/** + * Returns the number of ancestors nodes (including the origin) in the graph. + */ +function countAncestors( + originID: string | undefined, + idToNode: Map +): number | undefined { + if (!originID) { + return; + } + + // include the origin + let total = 1; + let current: ResolverNode | undefined = idToNode.get(originID); + while (current !== undefined && parentInternal(current, idToNode) !== undefined) { + total++; + current = parentInternal(current, idToNode); + } + return total; +} /** * Create a new IndexedProcessTree from an array of ProcessEvents. @@ -16,24 +66,25 @@ import * as eventModel from '../../../../common/endpoint/models/event'; */ export function factory( // Array of processes to index as a tree - processes: SafeResolverEvent[] + nodes: ResolverNode[], + originID: string | undefined ): IndexedProcessTree { - const idToChildren = new Map(); - const idToValue = new Map(); + const idToChildren = new Map(); + const idToValue = new Map(); - for (const process of processes) { - const entityID: string | undefined = eventModel.entityIDSafeVersion(process); - if (entityID !== undefined) { - idToValue.set(entityID, process); + for (const node of nodes) { + const nodeID: string | undefined = nodeModel.nodeID(node); + if (nodeID !== undefined) { + idToValue.set(nodeID, node); - const uniqueParentPid: string | undefined = eventModel.parentEntityIDSafeVersion(process); + const uniqueParentId: string | undefined = nodeModel.parentId(node); - let childrenWithTheSameParent = idToChildren.get(uniqueParentPid); + let childrenWithTheSameParent = idToChildren.get(uniqueParentId); if (!childrenWithTheSameParent) { childrenWithTheSameParent = []; - idToChildren.set(uniqueParentPid, childrenWithTheSameParent); + idToChildren.set(uniqueParentId, childrenWithTheSameParent); } - childrenWithTheSameParent.push(process); + childrenWithTheSameParent.push(node); } } @@ -42,28 +93,43 @@ export function factory( siblings.sort(orderByTime); } + let generations: number | undefined; + let descendants: number | undefined; + if (originID) { + const originNode = idToValue.get(originID); + const treeGenerationsAndDescendants = calculateGenerationsAndDescendantsFromOrigin( + originNode, + idToChildren + ); + generations = treeGenerationsAndDescendants?.generations; + descendants = treeGenerationsAndDescendants?.descendants; + } + + const ancestors = countAncestors(originID, idToValue); + return { idToChildren, - idToProcess: idToValue, + idToNode: idToValue, + originID, + generations, + descendants, + ancestors, }; } /** * Returns an array with any children `ProcessEvent`s of the passed in `process` */ -export function children( - tree: IndexedProcessTree, - parentID: string | undefined -): SafeResolverEvent[] { - const currentProcessSiblings = tree.idToChildren.get(parentID); - return currentProcessSiblings === undefined ? [] : currentProcessSiblings; +export function children(tree: IndexedProcessTree, parentID: string | undefined): ResolverNode[] { + const currentSiblings = tree.idToChildren.get(parentID); + return currentSiblings === undefined ? [] : currentSiblings; } /** * Get the indexed process event for the ID */ -export function processEvent(tree: IndexedProcessTree, entityID: string): SafeResolverEvent | null { - return tree.idToProcess.get(entityID) ?? null; +export function treeNode(tree: IndexedProcessTree, entityID: string): ResolverNode | null { + return tree.idToNode.get(entityID) ?? null; } /** @@ -71,21 +137,16 @@ export function processEvent(tree: IndexedProcessTree, entityID: string): SafeRe */ export function parent( tree: IndexedProcessTree, - childProcess: SafeResolverEvent -): SafeResolverEvent | undefined { - const uniqueParentPid = eventModel.parentEntityIDSafeVersion(childProcess); - if (uniqueParentPid === undefined) { - return undefined; - } else { - return tree.idToProcess.get(uniqueParentPid); - } + childNode: ResolverNode +): ResolverNode | undefined { + return parentInternal(childNode, tree.idToNode); } /** * Number of processes in the tree */ export function size(tree: IndexedProcessTree) { - return tree.idToProcess.size; + return tree.idToNode.size; } /** @@ -96,7 +157,7 @@ export function root(tree: IndexedProcessTree) { return null; } // any node will do - let current: SafeResolverEvent = tree.idToProcess.values().next().value; + let current: ResolverNode = tree.idToNode.values().next().value; // iteratively swap current w/ its parent while (parent(tree, current) !== undefined) { @@ -111,8 +172,8 @@ export function root(tree: IndexedProcessTree) { export function* levelOrder(tree: IndexedProcessTree) { const rootNode = root(tree); if (rootNode !== null) { - yield* baseLevelOrder(rootNode, (parentNode: SafeResolverEvent): SafeResolverEvent[] => - children(tree, eventModel.entityIDSafeVersion(parentNode)) + yield* baseLevelOrder(rootNode, (parentNode: ResolverNode): ResolverNode[] => + children(tree, nodeModel.nodeID(parentNode)) ); } } diff --git a/x-pack/plugins/security_solution/public/resolver/models/indexed_process_tree/isometric_taxi_layout.test.ts b/x-pack/plugins/security_solution/public/resolver/models/indexed_process_tree/isometric_taxi_layout.test.ts index 40be175c9fdbb..f2af28e3ae6dc 100644 --- a/x-pack/plugins/security_solution/public/resolver/models/indexed_process_tree/isometric_taxi_layout.test.ts +++ b/x-pack/plugins/security_solution/public/resolver/models/indexed_process_tree/isometric_taxi_layout.test.ts @@ -3,24 +3,29 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { IsometricTaxiLayout } from '../../types'; -import { LegacyEndpointEvent } from '../../../../common/endpoint/types'; +import { ResolverNode } from '../../../../common/endpoint/types'; import { isometricTaxiLayoutFactory } from './isometric_taxi_layout'; -import { mockProcessEvent } from '../../models/process_event_test_helpers'; import { factory } from './index'; +import { EndpointDocGenerator } from '../../../../common/endpoint/generate_data'; +import { genResolverNode } from '../../mocks/generator'; +import { IsometricTaxiLayout } from '../../types'; + +function layout(events: ResolverNode[]) { + return isometricTaxiLayoutFactory(factory(events, 'A')); +} describe('resolver graph layout', () => { - let processA: LegacyEndpointEvent; - let processB: LegacyEndpointEvent; - let processC: LegacyEndpointEvent; - let processD: LegacyEndpointEvent; - let processE: LegacyEndpointEvent; - let processF: LegacyEndpointEvent; - let processG: LegacyEndpointEvent; - let processH: LegacyEndpointEvent; - let processI: LegacyEndpointEvent; - let events: LegacyEndpointEvent[]; - let layout: () => IsometricTaxiLayout; + let processA: ResolverNode; + let processB: ResolverNode; + let processC: ResolverNode; + let processD: ResolverNode; + let processE: ResolverNode; + let processF: ResolverNode; + let processG: ResolverNode; + let processH: ResolverNode; + let processI: ResolverNode; + + const gen = new EndpointDocGenerator('resolver'); beforeEach(() => { /* @@ -35,105 +40,76 @@ describe('resolver graph layout', () => { * H * */ - processA = mockProcessEvent({ - endgame: { - process_name: '', - event_type_full: 'process_event', - event_subtype_full: 'creation_event', - unique_pid: 0, - }, - }); - processB = mockProcessEvent({ - endgame: { - event_type_full: 'process_event', - event_subtype_full: 'already_running', - unique_pid: 1, - unique_ppid: 0, - }, - }); - processC = mockProcessEvent({ - endgame: { - event_type_full: 'process_event', - event_subtype_full: 'creation_event', - unique_pid: 2, - unique_ppid: 0, - }, - }); - processD = mockProcessEvent({ - endgame: { - event_type_full: 'process_event', - event_subtype_full: 'creation_event', - unique_pid: 3, - unique_ppid: 1, - }, - }); - processE = mockProcessEvent({ - endgame: { - event_type_full: 'process_event', - event_subtype_full: 'creation_event', - unique_pid: 4, - unique_ppid: 1, - }, - }); - processF = mockProcessEvent({ - endgame: { - event_type_full: 'process_event', - event_subtype_full: 'creation_event', - unique_pid: 5, - unique_ppid: 2, - }, - }); - processG = mockProcessEvent({ - endgame: { - event_type_full: 'process_event', - event_subtype_full: 'creation_event', - unique_pid: 6, - unique_ppid: 2, - }, - }); - processH = mockProcessEvent({ - endgame: { - event_type_full: 'process_event', - event_subtype_full: 'creation_event', - unique_pid: 7, - unique_ppid: 6, - }, - }); - processI = mockProcessEvent({ - endgame: { - event_type_full: 'process_event', - event_subtype_full: 'termination_event', - unique_pid: 8, - unique_ppid: 0, - }, - }); - layout = () => isometricTaxiLayoutFactory(factory(events)); - events = []; + const timestamp = 1606234833273; + processA = genResolverNode(gen, { entityID: 'A', eventType: ['start'], timestamp }); + processB = genResolverNode(gen, { + entityID: 'B', + parentEntityID: 'A', + eventType: ['info'], + timestamp, + }); + processC = genResolverNode(gen, { + entityID: 'C', + parentEntityID: 'A', + eventType: ['start'], + timestamp, + }); + processD = genResolverNode(gen, { + entityID: 'D', + parentEntityID: 'B', + eventType: ['start'], + timestamp, + }); + processE = genResolverNode(gen, { + entityID: 'E', + parentEntityID: 'B', + eventType: ['start'], + timestamp, + }); + processF = genResolverNode(gen, { + timestamp, + entityID: 'F', + parentEntityID: 'C', + eventType: ['start'], + }); + processG = genResolverNode(gen, { + timestamp, + entityID: 'G', + parentEntityID: 'C', + eventType: ['start'], + }); + processH = genResolverNode(gen, { + timestamp, + entityID: 'H', + parentEntityID: 'G', + eventType: ['start'], + }); + processI = genResolverNode(gen, { + timestamp, + entityID: 'I', + parentEntityID: 'A', + eventType: ['end'], + }); }); describe('when rendering no nodes', () => { it('renders right', () => { - expect(layout()).toMatchSnapshot(); + expect(layout([])).toMatchSnapshot(); }); }); describe('when rendering one node', () => { - beforeEach(() => { - events = [processA]; - }); it('renders right', () => { - expect(layout()).toMatchSnapshot(); + expect(layout([processA])).toMatchSnapshot(); }); }); describe('when rendering two nodes, one being the parent of the other', () => { - beforeEach(() => { - events = [processA, processB]; - }); it('renders right', () => { - expect(layout()).toMatchSnapshot(); + expect(layout([processA, processB])).toMatchSnapshot(); }); }); describe('when rendering two forks, and one fork has an extra long tine', () => { + let layoutResponse: IsometricTaxiLayout; beforeEach(() => { - events = [ + layoutResponse = layout([ processA, processB, processC, @@ -143,29 +119,29 @@ describe('resolver graph layout', () => { processG, processH, processI, - ]; + ]); }); it('renders right', () => { - expect(layout()).toMatchSnapshot(); + expect(layoutResponse).toMatchSnapshot(); }); it('should have node a at level 1', () => { - expect(layout().ariaLevels.get(processA)).toBe(1); + expect(layoutResponse.ariaLevels.get(processA)).toBe(1); }); it('should have nodes b and c at level 2', () => { - expect(layout().ariaLevels.get(processB)).toBe(2); - expect(layout().ariaLevels.get(processC)).toBe(2); + expect(layoutResponse.ariaLevels.get(processB)).toBe(2); + expect(layoutResponse.ariaLevels.get(processC)).toBe(2); }); it('should have nodes d, e, f, and g at level 3', () => { - expect(layout().ariaLevels.get(processD)).toBe(3); - expect(layout().ariaLevels.get(processE)).toBe(3); - expect(layout().ariaLevels.get(processF)).toBe(3); - expect(layout().ariaLevels.get(processG)).toBe(3); + expect(layoutResponse.ariaLevels.get(processD)).toBe(3); + expect(layoutResponse.ariaLevels.get(processE)).toBe(3); + expect(layoutResponse.ariaLevels.get(processF)).toBe(3); + expect(layoutResponse.ariaLevels.get(processG)).toBe(3); }); it('should have node h at level 4', () => { - expect(layout().ariaLevels.get(processH)).toBe(4); + expect(layoutResponse.ariaLevels.get(processH)).toBe(4); }); it('should have 9 items in the map of aria levels', () => { - expect(layout().ariaLevels.size).toBe(9); + expect(layoutResponse.ariaLevels.size).toBe(9); }); }); }); diff --git a/x-pack/plugins/security_solution/public/resolver/models/indexed_process_tree/isometric_taxi_layout.ts b/x-pack/plugins/security_solution/public/resolver/models/indexed_process_tree/isometric_taxi_layout.ts index 0003be827aca8..e66db07914978 100644 --- a/x-pack/plugins/security_solution/public/resolver/models/indexed_process_tree/isometric_taxi_layout.ts +++ b/x-pack/plugins/security_solution/public/resolver/models/indexed_process_tree/isometric_taxi_layout.ts @@ -8,14 +8,14 @@ import { Vector2, EdgeLineSegment, ProcessWidths, - ProcessPositions, + NodePositions, EdgeLineMetadata, ProcessWithWidthMetadata, Matrix3, IsometricTaxiLayout, } from '../../types'; -import * as eventModel from '../../../../common/endpoint/models/event'; -import { SafeResolverEvent } from '../../../../common/endpoint/types'; +import * as nodeModel from '../../../../common/endpoint/models/node'; +import { ResolverNode } from '../../../../common/endpoint/types'; import * as vector2 from '../vector2'; import * as indexedProcessTreeModel from './index'; import { getFriendlyElapsedTime as elapsedTime } from '../../lib/date'; @@ -29,35 +29,35 @@ export function isometricTaxiLayoutFactory( /** * Walk the tree in reverse level order, calculating the 'width' of subtrees. */ - const widths: Map = widthsOfProcessSubtrees(indexedProcessTree); + const subTreeWidths: Map = calculateSubTreeWidths(indexedProcessTree); /** * Walk the tree in level order. Using the precalculated widths, calculate the position of nodes. * Nodes are positioned relative to their parents and preceding siblings. */ - const positions: Map = processPositions(indexedProcessTree, widths); + const nodePositions: Map = calculateNodePositions( + indexedProcessTree, + subTreeWidths + ); /** * With the widths and positions precalculated, we calculate edge line segments (arrays of vector2s) * which connect them in a 'pitchfork' design. */ - const edgeLineSegments: EdgeLineSegment[] = processEdgeLineSegments( + const edgeLineSegments: EdgeLineSegment[] = calculateEdgeLineSegments( indexedProcessTree, - widths, - positions + subTreeWidths, + nodePositions ); /** * Transform the positions of nodes and edges so they seem like they are on an isometric grid. */ const transformedEdgeLineSegments: EdgeLineSegment[] = []; - const transformedPositions = new Map(); + const transformedPositions = new Map(); - for (const [processEvent, position] of positions) { - transformedPositions.set( - processEvent, - vector2.applyMatrix3(position, isometricTransformMatrix) - ); + for (const [node, position] of nodePositions) { + transformedPositions.set(node, vector2.applyMatrix3(position, isometricTransformMatrix)); } for (const edgeLineSegment of edgeLineSegments) { @@ -86,8 +86,8 @@ export function isometricTaxiLayoutFactory( /** * Calculate a level (starting at 1) for each node. */ -function ariaLevels(indexedProcessTree: IndexedProcessTree): Map { - const map: Map = new Map(); +function ariaLevels(indexedProcessTree: IndexedProcessTree): Map { + const map: Map = new Map(); for (const node of indexedProcessTreeModel.levelOrder(indexedProcessTree)) { const parentNode = indexedProcessTreeModel.parent(indexedProcessTree, node); if (parentNode === undefined) { @@ -145,22 +145,19 @@ function ariaLevels(indexedProcessTree: IndexedProcessTree): Map(); +function calculateSubTreeWidths(indexedProcessTree: IndexedProcessTree): ProcessWidths { + const widths = new Map(); if (indexedProcessTreeModel.size(indexedProcessTree) === 0) { return widths; } - const processesInReverseLevelOrder: SafeResolverEvent[] = [ + const nodesInReverseLevelOrder: ResolverNode[] = [ ...indexedProcessTreeModel.levelOrder(indexedProcessTree), ].reverse(); - for (const process of processesInReverseLevelOrder) { - const children = indexedProcessTreeModel.children( - indexedProcessTree, - eventModel.entityIDSafeVersion(process) - ); + for (const node of nodesInReverseLevelOrder) { + const children = indexedProcessTreeModel.children(indexedProcessTree, nodeModel.nodeID(node)); const sumOfWidthOfChildren = function sumOfWidthOfChildren() { return children.reduce(function sum(currentValue, child) { @@ -175,7 +172,7 @@ function widthsOfProcessSubtrees(indexedProcessTree: IndexedProcessTree): Proces }; const width = sumOfWidthOfChildren() + Math.max(0, children.length - 1) * distanceBetweenNodes; - widths.set(process, width); + widths.set(node, width); } return widths; @@ -184,10 +181,10 @@ function widthsOfProcessSubtrees(indexedProcessTree: IndexedProcessTree): Proces /** * Layout the graph. Note: if any process events are missing the `entity_id`, this will throw an Error. */ -function processEdgeLineSegments( +function calculateEdgeLineSegments( indexedProcessTree: IndexedProcessTree, widths: ProcessWidths, - positions: ProcessPositions + positions: NodePositions ): EdgeLineSegment[] { const edgeLineSegments: EdgeLineSegment[] = []; for (const metadata of levelOrderWithWidths(indexedProcessTree, widths)) { @@ -198,16 +195,16 @@ function processEdgeLineSegments( // eslint-disable-next-line no-continue continue; } - const { process, parent, parentWidth } = metadata; - const position = positions.get(process); + const { node, parent, parentWidth } = metadata; + const position = positions.get(node); const parentPosition = positions.get(parent); - const parentID = eventModel.entityIDSafeVersion(parent); - const processEntityID = eventModel.entityIDSafeVersion(process); + const parentID = nodeModel.nodeID(parent); + const nodeID = nodeModel.nodeID(node); - if (processEntityID === undefined) { - throw new Error('tried to graph a Resolver that had a process with no `process.entity_id`'); + if (nodeID === undefined) { + throw new Error('tried to graph a Resolver that had a node with without an id'); } - const edgeLineID = `edge:${parentID ?? 'undefined'}:${processEntityID}`; + const edgeLineID = `edge:${parentID ?? 'undefined'}:${nodeID}`; if (position === undefined || parentPosition === undefined) { /** @@ -216,12 +213,12 @@ function processEdgeLineSegments( throw new Error(); } - const parentTime = eventModel.timestampSafeVersion(parent); - const processTime = eventModel.timestampSafeVersion(process); + const parentTime = nodeModel.nodeDataTimestamp(parent); + const nodeTime = nodeModel.nodeDataTimestamp(node); const timeBetweenParentAndNode = - parentTime !== undefined && processTime !== undefined - ? elapsedTime(parentTime, processTime) + parentTime !== undefined && nodeTime !== undefined + ? elapsedTime(parentTime, nodeTime) : undefined; const edgeLineMetadata: EdgeLineMetadata = { @@ -249,11 +246,8 @@ function processEdgeLineSegments( metadata: edgeLineMetadata, }; - const siblings = indexedProcessTreeModel.children( - indexedProcessTree, - eventModel.entityIDSafeVersion(parent) - ); - const isFirstChild = process === siblings[0]; + const siblings = indexedProcessTreeModel.children(indexedProcessTree, nodeModel.nodeID(parent)); + const isFirstChild = node === siblings[0]; if (metadata.isOnlyChild) { // add a single line segment directly from parent to child. We don't do the 'pitchfork' in this case. @@ -314,17 +308,17 @@ function processEdgeLineSegments( return edgeLineSegments; } -function processPositions( +function calculateNodePositions( indexedProcessTree: IndexedProcessTree, widths: ProcessWidths -): ProcessPositions { - const positions = new Map(); +): NodePositions { + const positions = new Map(); /** * This algorithm iterates the tree in level order. It keeps counters that are reset for each parent. * By keeping track of the last parent node, we can know when we are dealing with a new set of siblings and * reset the counters. */ - let lastProcessedParentNode: SafeResolverEvent | undefined; + let lastProcessedParentNode: ResolverNode | undefined; /** * Nodes are positioned relative to their siblings. We walk this in level order, so we handle * children left -> right. @@ -339,13 +333,13 @@ function processPositions( for (const metadata of levelOrderWithWidths(indexedProcessTree, widths)) { // Handle root node if (metadata.parent === null) { - const { process } = metadata; + const { node } = metadata; /** * Place the root node at (0, 0) for now. */ - positions.set(process, [0, 0]); + positions.set(node, [0, 0]); } else { - const { process, parent, isOnlyChild, width, parentWidth } = metadata; + const { node, parent, isOnlyChild, width, parentWidth } = metadata; // Reinit counters when parent changes if (lastProcessedParentNode !== parent) { @@ -394,7 +388,7 @@ function processPositions( const position = vector2.add([xOffset, yDistanceBetweenNodes], parentPosition); - positions.set(process, position); + positions.set(node, position); numberOfPrecedingSiblings += 1; runningWidthOfPrecedingSiblings += width; @@ -404,12 +398,12 @@ function processPositions( return positions; } function* levelOrderWithWidths( - tree: IndexedProcessTree, + indexedProcessTree: IndexedProcessTree, widths: ProcessWidths ): Iterable { - for (const process of indexedProcessTreeModel.levelOrder(tree)) { - const parent = indexedProcessTreeModel.parent(tree, process); - const width = widths.get(process); + for (const node of indexedProcessTreeModel.levelOrder(indexedProcessTree)) { + const parent = indexedProcessTreeModel.parent(indexedProcessTree, node); + const width = widths.get(node); if (width === undefined) { /** @@ -421,7 +415,7 @@ function* levelOrderWithWidths( /** If the parent is undefined, we are processing the root. */ if (parent === undefined) { yield { - process, + node, width, parent: null, parentWidth: null, @@ -440,15 +434,15 @@ function* levelOrderWithWidths( } const metadata: Partial = { - process, + node, width, parent, parentWidth, }; const siblings = indexedProcessTreeModel.children( - tree, - eventModel.entityIDSafeVersion(parent) + indexedProcessTree, + nodeModel.nodeID(parent) ); if (siblings.length === 1) { metadata.isOnlyChild = true; @@ -506,22 +500,12 @@ const distanceBetweenNodesInUnits = 2; */ const distanceBetweenNodes = distanceBetweenNodesInUnits * unit; -/** - * @deprecated use `nodePosition` - */ -export function processPosition( - model: IsometricTaxiLayout, - node: SafeResolverEvent -): Vector2 | undefined { - return model.processNodePositions.get(node); -} - export function nodePosition(model: IsometricTaxiLayout, nodeID: string): Vector2 | undefined { // Find the indexed object matching the nodeID // NB: this is O(n) now, but we will be indexing the nodeIDs in the future. - for (const candidate of model.processNodePositions.keys()) { - if (eventModel.entityIDSafeVersion(candidate) === nodeID) { - return processPosition(model, candidate); + for (const [candidateKey, candidatePosition] of model.processNodePositions.entries()) { + if (nodeModel.nodeID(candidateKey) === nodeID) { + return candidatePosition; } } } diff --git a/x-pack/plugins/security_solution/public/resolver/models/location_search.ts b/x-pack/plugins/security_solution/public/resolver/models/location_search.ts index ab6e4c84b1548..e07cf48b9d092 100644 --- a/x-pack/plugins/security_solution/public/resolver/models/location_search.ts +++ b/x-pack/plugins/security_solution/public/resolver/models/location_search.ts @@ -41,7 +41,9 @@ export const isPanelViewAndParameters: ( panelParameters: schema.object({ nodeID: schema.string(), eventCategory: schema.string(), - eventID: schema.string(), + eventID: schema.oneOf([schema.string(), schema.literal(undefined), schema.number()]), + eventTimestamp: schema.string(), + winlogRecordID: schema.string(), }), }), ]); diff --git a/x-pack/plugins/security_solution/public/resolver/models/node_data.test.ts b/x-pack/plugins/security_solution/public/resolver/models/node_data.test.ts new file mode 100644 index 0000000000000..056bfd656f32e --- /dev/null +++ b/x-pack/plugins/security_solution/public/resolver/models/node_data.test.ts @@ -0,0 +1,169 @@ +/* + * 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 { EndpointDocGenerator } from '../../../common/endpoint/generate_data'; +import { NodeData } from '../types'; +import { + setErrorNodes, + setReloadedNodes, + setRequestedNodes, + updateWithReceivedNodes, +} from './node_data'; + +describe('node data model', () => { + const generator = new EndpointDocGenerator('resolver'); + describe('creates a copy of the map', () => { + const original: Map = new Map(); + + it('creates a copy when using setRequestedNodes', () => { + expect(setRequestedNodes(original, new Set()) === original).toBeFalsy(); + }); + + it('creates a copy when using setErrorNodes', () => { + expect(setErrorNodes(original, new Set()) === original).toBeFalsy(); + }); + + it('creates a copy when using setReloadedNodes', () => { + expect(setReloadedNodes(original, '5') === original).toBeFalsy(); + }); + + it('creates a copy when using updateWithReceivedNodes', () => { + expect( + updateWithReceivedNodes({ + storedNodeInfo: original, + receivedEvents: [], + requestedNodes: new Set(), + numberOfRequestedEvents: 1, + }) === original + ).toBeFalsy(); + }); + }); + + it('overwrites the existing entries and creates new ones when calling setRequestedNodes', () => { + const state: Map = new Map([ + ['1', { events: [generator.generateEvent()], status: 'running', eventType: ['start'] }], + ]); + + expect(setRequestedNodes(state, new Set(['1', '2']))).toEqual( + new Map([ + ['1', { events: [], status: 'loading' }], + ['2', { events: [], status: 'loading' }], + ]) + ); + }); + + it('overwrites the existing entries and creates new ones when calling setErrorNodes', () => { + const state: Map = new Map([ + ['1', { events: [generator.generateEvent()], status: 'running', eventType: ['start'] }], + ]); + + expect(setErrorNodes(state, new Set(['1', '2']))).toEqual( + new Map([ + ['1', { events: [], status: 'error' }], + ['2', { events: [], status: 'error' }], + ]) + ); + }); + + describe('setReloadedNodes', () => { + it('removes the id from the map', () => { + const state: Map = new Map([['1', { events: [], status: 'error' }]]); + expect(setReloadedNodes(state, '1')).toEqual(new Map()); + }); + }); + + describe('updateWithReceivedNodes', () => { + const node1Events = [generator.generateEvent({ entityID: '1', eventType: ['start'] })]; + const node2Events = [generator.generateEvent({ entityID: '2', eventType: ['start'] })]; + const state: Map = new Map([ + ['1', { events: node1Events, status: 'error' }], + ['2', { events: node2Events, status: 'error' }], + ]); + describe('reachedLimit is false', () => { + it('overwrites entries with the received data', () => { + const genNodeEvent = generator.generateEvent({ entityID: '1', eventType: ['start'] }); + + expect( + updateWithReceivedNodes({ + storedNodeInfo: state, + receivedEvents: [genNodeEvent], + requestedNodes: new Set(['1']), + // a number greater than the amount received so the reached limit flag with be false + numberOfRequestedEvents: 10, + }) + ).toEqual( + new Map([ + ['1', { events: [genNodeEvent], status: 'running' }], + ['2', { events: node2Events, status: 'error' }], + ]) + ); + }); + + it('initializes entries from the requested nodes even if no data was received', () => { + expect( + updateWithReceivedNodes({ + storedNodeInfo: state, + receivedEvents: [], + requestedNodes: new Set(['1', '2']), + numberOfRequestedEvents: 1, + }) + ).toEqual( + new Map([ + ['1', { events: [], status: 'running' }], + ['2', { events: [], status: 'running' }], + ]) + ); + }); + }); + + describe('reachedLimit is true', () => { + it('deletes entries in the map that we did not receive data for', () => { + expect( + updateWithReceivedNodes({ + storedNodeInfo: state, + receivedEvents: [], + requestedNodes: new Set(['1']), + numberOfRequestedEvents: 0, + }) + ).toEqual(new Map([['2', { events: node2Events, status: 'error' }]])); + }); + + it('attempts to remove entries from the map even if they do not exist', () => { + expect( + updateWithReceivedNodes({ + storedNodeInfo: state, + receivedEvents: [], + requestedNodes: new Set(['10']), + numberOfRequestedEvents: 0, + }) + ).toEqual( + new Map([ + ['1', { events: node1Events, status: 'error' }], + ['2', { events: node2Events, status: 'error' }], + ]) + ); + }); + + it('does not delete the entry if it exists in the received node data from the server', () => { + const genNodeEvent = generator.generateEvent({ entityID: '1', eventType: ['start'] }); + + expect( + updateWithReceivedNodes({ + storedNodeInfo: state, + receivedEvents: [genNodeEvent], + requestedNodes: new Set(['1']), + numberOfRequestedEvents: 1, + }) + ).toEqual( + new Map([ + ['1', { events: [genNodeEvent], status: 'running' }], + ['2', { events: node2Events, status: 'error' }], + ]) + ); + }); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/resolver/models/node_data.ts b/x-pack/plugins/security_solution/public/resolver/models/node_data.ts new file mode 100644 index 0000000000000..fbb4f8bba314d --- /dev/null +++ b/x-pack/plugins/security_solution/public/resolver/models/node_data.ts @@ -0,0 +1,153 @@ +/* + * 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 { entityIDSafeVersion } from '../../../common/endpoint/models/event'; +import { SafeResolverEvent } from '../../../common/endpoint/types'; +import { FetchedNodeData, NodeData } from '../types'; +import { isTerminatedProcess } from './process_event'; + +/** + * Creates a copy of the node data map and initializes the specified IDs to an empty object with status requested. + * + * @param storedNodeInfo the node data from state + * @param requestedNodes a set of IDs that are being requested + */ +export function setRequestedNodes( + storedNodeInfo = new Map(), + requestedNodes: Set +): Map { + const requestedNodesArray = Array.from(requestedNodes); + return new Map([ + ...storedNodeInfo, + ...requestedNodesArray.map((id: string): [string, NodeData] => [ + id, + { events: [], status: 'loading' }, + ]), + ]); +} + +/** + * Creates a copy of the node data map and sets the specified IDs to an error state. + * + * @param storedNodeInfo the node data from state + * @param errorNodes a set of IDs we requested from the backend that returned a failure + */ +export function setErrorNodes( + storedNodeInfo = new Map(), + errorNodes: Set +): Map { + const errorNodesArray = Array.from(errorNodes); + return new Map([ + ...storedNodeInfo, + ...errorNodesArray.map((id: string): [string, NodeData] => [ + id, + { events: [], status: 'error' }, + ]), + ]); +} + +/** + * Marks the node id to be reloaded by the middleware. It removes the entry in the map to mark it to be reloaded. + * + * @param storedNodeInfo the node data from state + * @param nodeID the ID to remove from state to mark it to be reloaded in the middleware. + */ +export function setReloadedNodes( + storedNodeInfo: Map = new Map(), + nodeID: string +): Map { + const newData = new Map([...storedNodeInfo]); + newData.delete(nodeID); + return newData; +} + +function groupByID(events: SafeResolverEvent[]): Map { + // group the returned events by their ID + const newData = new Map(); + for (const result of events) { + const id = entityIDSafeVersion(result); + const terminated = isTerminatedProcess(result); + if (id) { + const info = newData.get(id); + if (!info) { + newData.set(id, { events: [result], terminated }); + } else { + info.events.push(result); + /** + * Track whether we have seen a termination event. It is useful to do this here rather than in a selector + * because the selector would have to loop over all events each time a new node's data is received. + */ + info.terminated = info.terminated || terminated; + } + } + } + + return newData; +} + +/** + * Creates a copy of the node data map and updates it with the data returned by the server. If the server did not return + * data for a particular ID we will determine whether no data exists for that ID or if the server reached the limit we + * requested by using the reachedLimit flag. + * + * @param storedNodeInfo the node data from state + * @param receivedNodes the events grouped by ID that the server returned + * @param requestedNodes the IDs that we requested the server find events for + * @param reachedLimit a flag indicating whether the server returned the same number of events we requested + */ +export function updateWithReceivedNodes({ + storedNodeInfo = new Map(), + receivedEvents, + requestedNodes, + numberOfRequestedEvents, +}: { + storedNodeInfo: Map | undefined; + receivedEvents: SafeResolverEvent[]; + requestedNodes: Set; + numberOfRequestedEvents: number; +}): Map { + const copiedMap = new Map([...storedNodeInfo]); + const reachedLimit = receivedEvents.length >= numberOfRequestedEvents; + const receivedNodes: Map = groupByID(receivedEvents); + + for (const id of requestedNodes.values()) { + // If the server returned the same number of events that we requested it's possible + // that we won't have node data for each of the IDs. So we'll want to remove the ID's + // from the map that we don't have node data for + if (!receivedNodes.has(id)) { + if (reachedLimit) { + copiedMap.delete(id); + } else { + // if we didn't reach the limit but we didn't receive any node data for a particular ID + // then that means Elasticsearch does not have any node data for that ID. + copiedMap.set(id, { events: [], status: 'running' }); + } + } + } + + // for the nodes we got results for, create a new array with the contents of those events + for (const [id, info] of receivedNodes.entries()) { + copiedMap.set(id, { + events: [...info.events], + status: info.terminated ? 'terminated' : 'running', + }); + } + + return copiedMap; +} + +/** + * This is used for displaying information in the node panel mainly and we should be able to remove it eventually in + * favor of showing all the node data associated with a node in the tree. + * + * @param data node data for a specific node ID + * @returns the first event or undefined if the node data passed in was undefined + */ +export function firstEvent(data: NodeData | undefined): SafeResolverEvent | undefined { + return !data || data.status === 'loading' || data.status === 'error' || data.events.length <= 0 + ? undefined + : data.events[0]; +} diff --git a/x-pack/plugins/security_solution/public/resolver/models/process_event.test.ts b/x-pack/plugins/security_solution/public/resolver/models/process_event.test.ts index 380b15cf9da4c..96493feb83e39 100644 --- a/x-pack/plugins/security_solution/public/resolver/models/process_event.test.ts +++ b/x-pack/plugins/security_solution/public/resolver/models/process_event.test.ts @@ -6,7 +6,7 @@ import { eventType, orderByTime, userInfoForProcess } from './process_event'; import { mockProcessEvent } from './process_event_test_helpers'; -import { LegacyEndpointEvent, SafeResolverEvent } from '../../../common/endpoint/types'; +import { LegacyEndpointEvent, ResolverNode } from '../../../common/endpoint/types'; describe('process event', () => { describe('eventType', () => { @@ -41,17 +41,19 @@ describe('process event', () => { }); }); describe('orderByTime', () => { - let mock: (time: number, eventID: string) => SafeResolverEvent; - let events: SafeResolverEvent[]; + let mock: (time: number, nodeID: string) => ResolverNode; + let events: ResolverNode[]; beforeEach(() => { - mock = (time, eventID) => { - return { + mock = (time, nodeID) => ({ + data: { '@timestamp': time, - event: { - id: eventID, - }, - }; - }; + }, + id: nodeID, + stats: { + total: 0, + byCategory: {}, + }, + }); // 2 events each for numbers -1, 0, 1, and NaN // each event has a unique id, a through h // order is arbitrary @@ -71,51 +73,83 @@ describe('process event', () => { expect(events).toMatchInlineSnapshot(` Array [ Object { - "@timestamp": -1, - "event": Object { - "id": "a", + "data": Object { + "@timestamp": -1, + }, + "id": "a", + "stats": Object { + "byCategory": Object {}, + "total": 0, }, }, Object { - "@timestamp": -1, - "event": Object { - "id": "b", + "data": Object { + "@timestamp": -1, + }, + "id": "b", + "stats": Object { + "byCategory": Object {}, + "total": 0, }, }, Object { - "@timestamp": 0, - "event": Object { - "id": "c", + "data": Object { + "@timestamp": 0, + }, + "id": "c", + "stats": Object { + "byCategory": Object {}, + "total": 0, }, }, Object { - "@timestamp": 0, - "event": Object { - "id": "d", + "data": Object { + "@timestamp": 0, + }, + "id": "d", + "stats": Object { + "byCategory": Object {}, + "total": 0, }, }, Object { - "@timestamp": 1, - "event": Object { - "id": "e", + "data": Object { + "@timestamp": 1, + }, + "id": "e", + "stats": Object { + "byCategory": Object {}, + "total": 0, }, }, Object { - "@timestamp": 1, - "event": Object { - "id": "f", + "data": Object { + "@timestamp": 1, + }, + "id": "f", + "stats": Object { + "byCategory": Object {}, + "total": 0, }, }, Object { - "@timestamp": NaN, - "event": Object { - "id": "g", + "data": Object { + "@timestamp": NaN, + }, + "id": "g", + "stats": Object { + "byCategory": Object {}, + "total": 0, }, }, Object { - "@timestamp": NaN, - "event": Object { - "id": "h", + "data": Object { + "@timestamp": NaN, + }, + "id": "h", + "stats": Object { + "byCategory": Object {}, + "total": 0, }, }, ] diff --git a/x-pack/plugins/security_solution/public/resolver/models/process_event.ts b/x-pack/plugins/security_solution/public/resolver/models/process_event.ts index 1510fc7f9f365..0fa054ffbd29e 100644 --- a/x-pack/plugins/security_solution/public/resolver/models/process_event.ts +++ b/x-pack/plugins/security_solution/public/resolver/models/process_event.ts @@ -7,18 +7,23 @@ import { firstNonNullValue } from '../../../common/endpoint/models/ecs_safety_helpers'; import * as eventModel from '../../../common/endpoint/models/event'; -import { ResolverEvent, SafeResolverEvent } from '../../../common/endpoint/types'; +import * as nodeModel from '../../../common/endpoint/models/node'; +import { ResolverEvent, SafeResolverEvent, ResolverNode } from '../../../common/endpoint/types'; import { ResolverProcessType } from '../types'; /** * Returns true if the process's eventType is either 'processCreated' or 'processRan'. * Resolver will only render 'graphable' process events. + * */ export function isGraphableProcess(passedEvent: SafeResolverEvent) { return eventType(passedEvent) === 'processCreated' || eventType(passedEvent) === 'processRan'; } -export function isTerminatedProcess(passedEvent: SafeResolverEvent) { +/** + * Returns true if the process was terminated. + */ +export function isTerminatedProcess(passedEvent: SafeResolverEvent): boolean { return eventType(passedEvent) === 'processTerminated'; } @@ -26,8 +31,8 @@ export function isTerminatedProcess(passedEvent: SafeResolverEvent) { * ms since Unix epoc, based on timestamp. * may return NaN if the timestamp wasn't present or was invalid. */ -export function datetime(passedEvent: SafeResolverEvent): number | null { - const timestamp = eventModel.timestampSafeVersion(passedEvent); +export function datetime(node: ResolverNode): number | null { + const timestamp = nodeModel.nodeDataTimestamp(node); const time = timestamp === undefined ? 0 : new Date(timestamp).getTime(); @@ -146,15 +151,13 @@ export function argsForProcess(passedEvent: ResolverEvent): string | undefined { /** * used to sort events */ -export function orderByTime(first: SafeResolverEvent, second: SafeResolverEvent): number { +export function orderByTime(first: ResolverNode, second: ResolverNode): number { const firstDatetime: number | null = datetime(first); const secondDatetime: number | null = datetime(second); if (firstDatetime === secondDatetime) { - // break ties using an arbitrary (stable) comparison of `eventId` (which should be unique) - return String(eventModel.eventIDSafeVersion(first)).localeCompare( - String(eventModel.eventIDSafeVersion(second)) - ); + // break ties using an arbitrary (stable) comparison of `nodeID` (which should be unique) + return String(nodeModel.nodeID(first)).localeCompare(String(nodeModel.nodeID(second))); } else if (firstDatetime === null || secondDatetime === null) { // sort `null`'s as higher than numbers return (firstDatetime === null ? 1 : 0) - (secondDatetime === null ? 1 : 0); diff --git a/x-pack/plugins/security_solution/public/resolver/models/process_event_test_helpers.ts b/x-pack/plugins/security_solution/public/resolver/models/process_event_test_helpers.ts index aaca6770e157a..691ca5e21b225 100644 --- a/x-pack/plugins/security_solution/public/resolver/models/process_event_test_helpers.ts +++ b/x-pack/plugins/security_solution/public/resolver/models/process_event_test_helpers.ts @@ -30,7 +30,7 @@ export function mockProcessEvent(parts: DeepPartial): Legac timestamp_utc: '', serial_event_id: 1, }, - '@timestamp': 1582233383000, + '@timestamp': 0, agent: { type: '', id: '', diff --git a/x-pack/plugins/security_solution/public/resolver/models/resolver_tree.ts b/x-pack/plugins/security_solution/public/resolver/models/resolver_tree.ts index 775b88246b61f..901e19debc991 100644 --- a/x-pack/plugins/security_solution/public/resolver/models/resolver_tree.ts +++ b/x-pack/plugins/security_solution/public/resolver/models/resolver_tree.ts @@ -8,10 +8,81 @@ import { ResolverTree, ResolverNodeStats, ResolverLifecycleNode, - ResolverChildNode, SafeResolverEvent, + NewResolverTree, + ResolverNode, + EventStats, + ResolverSchema, } from '../../../common/endpoint/types'; -import * as eventModel from '../../../common/endpoint/models/event'; +import * as nodeModel from '../../../common/endpoint/models/node'; + +/** + * These values are only exported for testing. They should not be used directly. Instead use the functions below. + */ + +/** + * The limit for the ancestors in the server request when the ancestry field is defined in the schema. + */ +export const ancestorsWithAncestryField = 200; +/** + * The limit for the ancestors in the server request when the ancestry field is not defined in the schema. + */ +export const ancestorsWithoutAncestryField = 20; +/** + * The limit for the generations in the server request when the ancestry field is defined. Essentially this means + * that the generations field will be ignored when the ancestry field is defined. + */ +export const generationsWithAncestryField = 0; +/** + * The limit for the generations in the server request when the ancestry field is not defined. + */ +export const generationsWithoutAncestryField = 10; +/** + * The limit for the descendants in the server request. + */ +export const descendantsLimit = 500; + +/** + * Returns the number of ancestors we should use when requesting a tree from the server + * depending on whether the schema received from the server has the ancestry field defined. + */ +export function ancestorsRequestAmount(schema: ResolverSchema | undefined) { + return schema?.ancestry !== undefined + ? ancestorsWithAncestryField + : ancestorsWithoutAncestryField; +} + +/** + * Returns the number of generations we should use when requesting a tree from the server + * depending on whether the schema received from the server has the ancestry field defined. + */ +export function generationsRequestAmount(schema: ResolverSchema | undefined) { + return schema?.ancestry !== undefined + ? generationsWithAncestryField + : generationsWithoutAncestryField; +} + +/** + * The number of the descendants to use in a request to the server for a resolver tree. + */ +export function descendantsRequestAmount() { + return descendantsLimit; +} + +/** + * This returns a map of nodeIDs to the associated stats provided by the datasource. + */ +export function nodeStats(tree: NewResolverTree): Map { + const stats = new Map(); + + for (const node of tree.nodes) { + if (node.stats) { + const nodeID = nodeModel.nodeID(node); + stats.set(nodeID, node.stats); + } + } + return stats; +} /** * ResolverTree is a type returned by the server. @@ -20,6 +91,8 @@ import * as eventModel from '../../../common/endpoint/models/event'; /** * This returns the 'LifecycleNodes' of the tree. These nodes have * the entityID and stats for a process. Used by `relatedEventsStats`. + * + * @deprecated use indexed_process_tree instead */ function lifecycleNodes(tree: ResolverTree): ResolverLifecycleNode[] { return [tree, ...tree.children.childNodes, ...tree.ancestry.ancestors]; @@ -27,6 +100,8 @@ function lifecycleNodes(tree: ResolverTree): ResolverLifecycleNode[] { /** * All the process events + * + * @deprecated use nodeData instead */ export function lifecycleEvents(tree: ResolverTree) { const events: SafeResolverEvent[] = [...tree.lifecycle]; @@ -41,15 +116,17 @@ export function lifecycleEvents(tree: ResolverTree) { /** * This returns a map of entity_ids to stats for the related events and alerts. + * + * @deprecated use indexed_process_tree instead */ export function relatedEventsStats(tree: ResolverTree): Map { - const nodeStats: Map = new Map(); + const nodeRelatedEventStats: Map = new Map(); for (const node of lifecycleNodes(tree)) { if (node.stats) { - nodeStats.set(node.entityID, node.stats); + nodeRelatedEventStats.set(node.entityID, node.stats); } } - return nodeStats; + return nodeRelatedEventStats; } /** @@ -59,74 +136,23 @@ export function relatedEventsStats(tree: ResolverTree): Map { + it('creates a range starting from 1970-01-01T00:00:00.000Z to +275760-09-13T00:00:00.000Z by default', () => { + const { from, to } = createRange(); + expect(from.toISOString()).toBe('1970-01-01T00:00:00.000Z'); + expect(to.toISOString()).toBe('+275760-09-13T00:00:00.000Z'); + }); + + it('creates an invalid to date using a number greater than 8640000000000000', () => { + const { to } = createRange({ to: new Date(maxDate + 1) }); + expect(() => { + to.toISOString(); + }).toThrow(RangeError); + }); +}); diff --git a/x-pack/plugins/security_solution/public/resolver/models/time_range.ts b/x-pack/plugins/security_solution/public/resolver/models/time_range.ts new file mode 100644 index 0000000000000..fca184edd58c2 --- /dev/null +++ b/x-pack/plugins/security_solution/public/resolver/models/time_range.ts @@ -0,0 +1,35 @@ +/* + * 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 { TimeRange } from '../types'; + +/** + * This is the maximum millisecond value that can be used with a Date object. If you use a number greater than this it + * will result in an invalid date. + * + * See https://stackoverflow.com/questions/11526504/minimum-and-maximum-date for more details. + */ +export const maxDate = 8640000000000000; + +/** + * This function create a TimeRange and by default uses beginning of epoch and the maximum positive date in the future + * (8640000000000000). It allows the range to be configurable to allow testing a value greater than the maximum date. + * + * @param from the beginning date to use in the TimeRange + * @param to the ending date to use in the TimeRange + */ +export function createRange({ + from = new Date(0), + to = new Date(maxDate), +}: { + from?: Date; + to?: Date; +} = {}): TimeRange { + return { + from, + to, + }; +} diff --git a/x-pack/plugins/security_solution/public/resolver/store/data/action.ts b/x-pack/plugins/security_solution/public/resolver/store/data/action.ts index 35a1e14a66625..3f7d0c0708d17 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/data/action.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/data/action.ts @@ -6,9 +6,10 @@ import { ResolverRelatedEvents, - ResolverTree, + NewResolverTree, SafeEndpointEvent, SafeResolverEvent, + ResolverSchema, } from '../../../../common/endpoint/types'; import { TreeFetcherParameters } from '../../types'; @@ -18,7 +19,15 @@ interface ServerReturnedResolverData { /** * The result of fetching data */ - result: ResolverTree; + result: NewResolverTree; + /** + * The current data source (i.e. endpoint, winlogbeat, etc...) + */ + dataSource: string; + /** + * The Resolver Schema for the current data source + */ + schema: ResolverSchema; /** * The database parameters that was used to fetch the resolver tree */ @@ -101,6 +110,71 @@ interface ServerReturnedNodeEventsInCategory { eventCategory: string; }; } + +/** + * When events are returned for a set of graph nodes. For Endpoint graphs the events returned are process lifecycle events. + */ +interface ServerReturnedNodeData { + readonly type: 'serverReturnedNodeData'; + readonly payload: { + /** + * A map of the node's ID to an array of events + */ + nodeData: SafeResolverEvent[]; + /** + * The list of IDs that were originally sent to the server. This won't necessarily equal nodeData.keys() because + * data could have been deleted in Elasticsearch since the original graph nodes were returned or the server's + * API limit could have been reached. + */ + requestedIDs: Set; + /** + * The number of events that we requested from the server (the limit in the request). + * This will be used to compute a flag about whether we reached the limit with the number of events returned by + * the server. If the server returned the same amount of data we requested, then + * we might be missing events for some of the requested node IDs. We'll mark those nodes in such a way + * that we'll request their data in a subsequent request. + */ + numberOfRequestedEvents: number; + }; +} + +/** + * When the middleware kicks off the request for node data to the server. + */ +interface AppRequestingNodeData { + readonly type: 'appRequestingNodeData'; + readonly payload: { + /** + * The list of IDs that will be sent to the server to retrieve data for. + */ + requestedIDs: Set; + }; +} + +/** + * When the user clicks on a node that was in an error state to reload the node data. + */ +interface UserReloadedResolverNode { + readonly type: 'userReloadedResolverNode'; + /** + * The nodeID (aka entity_id) that was select. + */ + readonly payload: string; +} + +/** + * When the server returns an error after the app requests node data for a set of nodes. + */ +interface ServerFailedToReturnNodeData { + readonly type: 'serverFailedToReturnNodeData'; + readonly payload: { + /** + * The list of IDs that were sent to the server to retrieve data for. + */ + requestedIDs: Set; + }; +} + interface AppRequestedCurrentRelatedEventData { type: 'appRequestedCurrentRelatedEventData'; } @@ -125,4 +199,8 @@ export type DataAction = | AppRequestedResolverData | UserRequestedAdditionalRelatedEvents | ServerFailedToReturnNodeEventsInCategory - | AppAbortedResolverDataRequest; + | AppAbortedResolverDataRequest + | ServerReturnedNodeData + | ServerFailedToReturnNodeData + | AppRequestingNodeData + | UserReloadedResolverNode; diff --git a/x-pack/plugins/security_solution/public/resolver/store/data/reducer.test.ts b/x-pack/plugins/security_solution/public/resolver/store/data/reducer.test.ts index 5714345de0431..de1b882182827 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/data/reducer.test.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/data/reducer.test.ts @@ -4,220 +4,200 @@ * you may not use this file except in compliance with the Elastic License. */ import { createStore, Store } from 'redux'; -import { EndpointDocGenerator, TreeNode } from '../../../../common/endpoint/generate_data'; -import { mock as mockResolverTree } from '../../models/resolver_tree'; +import { RelatedEventCategory } from '../../../../common/endpoint/generate_data'; import { dataReducer } from './reducer'; import * as selectors from './selectors'; -import { DataState } from '../../types'; +import { DataState, GeneratedTreeMetadata } from '../../types'; import { DataAction } from './action'; -import { ResolverChildNode, ResolverTree } from '../../../../common/endpoint/types'; -import { values } from '../../../../common/endpoint/models/ecs_safety_helpers'; -import { mockTreeFetcherParameters } from '../../mocks/tree_fetcher_parameters'; +import { generateTreeWithDAL } from '../../data_access_layer/mocks/generator_tree'; +import { endpointSourceSchema, winlogSourceSchema } from './../../mocks/tree_schema'; +import { NewResolverTree, ResolverSchema } from '../../../../common/endpoint/types'; +import { ancestorsWithAncestryField, descendantsLimit } from '../../models/resolver_tree'; + +type SourceAndSchemaFunction = () => { schema: ResolverSchema; dataSource: string }; /** * Test the data reducer and selector. */ describe('Resolver Data Middleware', () => { let store: Store; - let dispatchTree: (tree: ResolverTree) => void; + let dispatchTree: (tree: NewResolverTree, sourceAndSchema: SourceAndSchemaFunction) => void; beforeEach(() => { store = createStore(dataReducer, undefined); - dispatchTree = (tree) => { + dispatchTree = (tree: NewResolverTree, sourceAndSchema: SourceAndSchemaFunction) => { + const { schema, dataSource } = sourceAndSchema(); const action: DataAction = { type: 'serverReturnedResolverData', payload: { result: tree, - parameters: mockTreeFetcherParameters(), + dataSource, + schema, + parameters: { + databaseDocumentID: '', + indices: [], + }, }, }; store.dispatch(action); }; }); - describe('when data was received and the ancestry and children edges had cursors', () => { + describe('when the generated tree has dimensions smaller than the limits sent to the server', () => { + let generatedTreeMetadata: GeneratedTreeMetadata; beforeEach(() => { - // Generate a 'tree' using the Resolver generator code. This structure isn't the same as what the API returns. - const baseTree = generateBaseTree(); - const tree = mockResolverTree({ - // Casting here because the generator returns the SafeResolverEvent type which isn't yet compatible with - // a lot of the frontend functions. So casting it back to the unsafe type for now. - events: baseTree.allEvents, - cursors: { - childrenNextChild: 'aValidChildCursor', - ancestryNextAncestor: 'aValidAncestorCursor', - }, - })!; - dispatchTree(tree); - }); - it('should indicate there are additional ancestor', () => { - expect(selectors.hasMoreAncestors(store.getState())).toBe(true); + ({ metadata: generatedTreeMetadata } = generateTreeWithDAL({ + ancestors: 5, + generations: 1, + children: 5, + })); }); - it('should indicate there are additional children', () => { - expect(selectors.hasMoreChildren(store.getState())).toBe(true); + + describe.each([ + ['endpoint', endpointSourceSchema], + ['winlog', winlogSourceSchema], + ])('when using %s schema to layout the graph', (name, schema) => { + beforeEach(() => { + dispatchTree(generatedTreeMetadata.formattedTree, schema); + }); + it('should indicate that there are no more ancestors to retrieve', () => { + expect(selectors.hasMoreAncestors(store.getState())).toBeFalsy(); + }); + + it('should indicate that there are no more descendants to retrieve', () => { + expect(selectors.hasMoreChildren(store.getState())).toBeFalsy(); + }); + + it('should indicate that there were no more generations to retrieve', () => { + expect(selectors.hasMoreGenerations(store.getState())).toBeFalsy(); + }); }); }); - describe('when data was received with stats mocked for the first child node', () => { - let firstChildNodeInTree: TreeNode; - let tree: ResolverTree; + describe('when the generated tree has dimensions larger than the limits sent to the server', () => { + let generatedTreeMetadata: GeneratedTreeMetadata; + beforeEach(() => { + ({ metadata: generatedTreeMetadata } = generateTreeWithDAL({ + ancestors: ancestorsWithAncestryField + 10, + // using the descendants limit here so we can avoid creating a massive tree but still + // accurately get over the descendants limit as well + generations: descendantsLimit + 10, + children: 1, + })); + }); - /** - * Compiling stats to use for checking limit warnings and counts of missing events - * e.g. Limit warnings should show when number of related events actually displayed - * is lower than the estimated count from stats. - */ + describe('when using endpoint schema to layout the graph', () => { + beforeEach(() => { + dispatchTree(generatedTreeMetadata.formattedTree, endpointSourceSchema); + }); + it('should indicate that there are more ancestors to retrieve', () => { + expect(selectors.hasMoreAncestors(store.getState())).toBeTruthy(); + }); - beforeEach(() => { - ({ tree, firstChildNodeInTree } = mockedTree()); - if (tree) { - dispatchTree(tree); - } + it('should indicate that there are more descendants to retrieve', () => { + expect(selectors.hasMoreChildren(store.getState())).toBeTruthy(); + }); + + it('should indicate that there were no more generations to retrieve', () => { + expect(selectors.hasMoreGenerations(store.getState())).toBeFalsy(); + }); }); - describe('and when related events were returned with totals equalling what stat counts indicate they should be', () => { + describe('when using winlog schema to layout the graph', () => { beforeEach(() => { - // Return related events for the first child node - const relatedAction: DataAction = { - type: 'serverReturnedRelatedEventData', - payload: { - entityID: firstChildNodeInTree.id, - // Casting here because the generator returns the SafeResolverEvent type which isn't yet compatible with - // a lot of the frontend functions. So casting it back to the unsafe type for now. - events: firstChildNodeInTree.relatedEvents, - nextEvent: null, - }, - }; - store.dispatch(relatedAction); + dispatchTree(generatedTreeMetadata.formattedTree, winlogSourceSchema); + }); + it('should indicate that there are more ancestors to retrieve', () => { + expect(selectors.hasMoreAncestors(store.getState())).toBeTruthy(); + }); + + it('should indicate that there are more descendants to retrieve', () => { + expect(selectors.hasMoreChildren(store.getState())).toBeTruthy(); }); - it('should have the correct related events', () => { - const selectedEventsByEntityId = selectors.relatedEventsByEntityId(store.getState()); - const selectedEventsForFirstChildNode = selectedEventsByEntityId.get( - firstChildNodeInTree.id - )!.events; - expect(selectedEventsForFirstChildNode).toBe(firstChildNodeInTree.relatedEvents); + it('should indicate that there were more generations to retrieve', () => { + expect(selectors.hasMoreGenerations(store.getState())).toBeTruthy(); }); }); }); -}); -function mockedTree() { - // Generate a 'tree' using the Resolver generator code. This structure isn't the same as what the API returns. - const baseTree = generateBaseTree(); - - const { children } = baseTree; - const firstChildNodeInTree = [...children.values()][0]; - - // The `generateBaseTree` mock doesn't calculate stats (the actual data has them.) - // So calculate some stats for just the node that we'll test. - const statsResults = compileStatsForChild(firstChildNodeInTree); - - const tree = mockResolverTree({ - // Casting here because the generator returns the SafeResolverEvent type which isn't yet compatible with - // a lot of the frontend functions. So casting it back to the unsafe type for now. - events: baseTree.allEvents, - /** - * Calculate children from the ResolverTree response using the children of the `Tree` we generated using the Resolver data generator code. - * Compile (and attach) stats to the first child node. - * - * The purpose of `children` here is to set the `actual` - * value that the stats values will be compared with - * to derive things like the number of missing events and if - * related event limits should be shown. - */ - children: [...baseTree.children.values()].map((node: TreeNode) => { - const childNode: Partial = {}; - // Casting here because the generator returns the SafeResolverEvent type which isn't yet compatible with - // a lot of the frontend functions. So casting it back to the unsafe type for now. - childNode.lifecycle = node.lifecycle; - - // `TreeNode` has `id` which is the same as `entityID`. - // The `ResolverChildNode` calls the entityID as `entityID`. - // Set `entityID` on `childNode` since the code in test relies on it. - childNode.entityID = node.id; - - // This should only be true for the first child. - if (node.id === firstChildNodeInTree.id) { - // attach stats - childNode.stats = { - events: statsResults.eventStats, - totalAlerts: 0, - }; - } - return childNode; - }) as ResolverChildNode[] /** - Cast to ResolverChildNode[] array is needed because incoming - TreeNodes from the generator cannot be assigned cleanly to the - tree model's expected ResolverChildNode type. - */, + describe('when the generated tree has more children than the limit, less generations than the limit, and no ancestors', () => { + let generatedTreeMetadata: GeneratedTreeMetadata; + beforeEach(() => { + ({ metadata: generatedTreeMetadata } = generateTreeWithDAL({ + ancestors: 0, + generations: 1, + children: descendantsLimit + 1, + })); + }); + + describe('when using endpoint schema to layout the graph', () => { + beforeEach(() => { + dispatchTree(generatedTreeMetadata.formattedTree, endpointSourceSchema); + }); + it('should indicate that there are no more ancestors to retrieve', () => { + expect(selectors.hasMoreAncestors(store.getState())).toBeFalsy(); + }); + + it('should indicate that there are more descendants to retrieve', () => { + expect(selectors.hasMoreChildren(store.getState())).toBeTruthy(); + }); + + it('should indicate that there were no more generations to retrieve', () => { + expect(selectors.hasMoreGenerations(store.getState())).toBeFalsy(); + }); + }); + + describe('when using winlog schema to layout the graph', () => { + beforeEach(() => { + dispatchTree(generatedTreeMetadata.formattedTree, winlogSourceSchema); + }); + it('should indicate that there are no more ancestors to retrieve', () => { + expect(selectors.hasMoreAncestors(store.getState())).toBeFalsy(); + }); + + it('should indicate that there are more descendants to retrieve', () => { + expect(selectors.hasMoreChildren(store.getState())).toBeTruthy(); + }); + + it('should indicate that there were no more generations to retrieve', () => { + expect(selectors.hasMoreGenerations(store.getState())).toBeFalsy(); + }); + }); }); - return { - tree: tree!, - firstChildNodeInTree, - categoryToOverCount: statsResults.firstCategory, - }; -} - -function generateBaseTree() { - const generator = new EndpointDocGenerator('seed'); - return generator.generateTree({ - ancestors: 1, - generations: 2, - children: 3, - percentWithRelated: 100, - alwaysGenMaxChildrenPerNode: true, + describe('when data was received for a resolver tree', () => { + let metadata: GeneratedTreeMetadata; + beforeEach(() => { + ({ metadata } = generateTreeWithDAL({ + generations: 1, + children: 1, + percentWithRelated: 100, + relatedEvents: [ + { + count: 5, + category: RelatedEventCategory.Driver, + }, + ], + })); + dispatchTree(metadata.formattedTree, endpointSourceSchema); + }); + it('should have the correct total related events for a child node', () => { + // get the first level of children, and there should only be a single child + const childNode = Array.from(metadata.generatedTree.childrenLevels[0].values())[0]; + const total = selectors.relatedEventTotalCount(store.getState())(childNode.id); + expect(total).toEqual(5); + }); + it('should have the correct related events stats for a child node', () => { + // get the first level of children, and there should only be a single child + const childNode = Array.from(metadata.generatedTree.childrenLevels[0].values())[0]; + const stats = selectors.nodeStats(store.getState())(childNode.id); + expect(stats).toEqual({ + total: 5, + byCategory: { + driver: 5, + }, + }); + }); }); -} - -function compileStatsForChild( - node: TreeNode -): { - eventStats: { - /** The total number of related events. */ - total: number; - /** A record with the categories of events as keys, and the count of events per category as values. */ - byCategory: Record; - }; - /** The category of the first event. */ - firstCategory: string; -} { - const totalRelatedEvents = node.relatedEvents.length; - // For the purposes of testing, we pick one category to fake an extra event for - // so we can test if the event limit selectors do the right thing. - - let firstCategory: string | undefined; - - const compiledStats = node.relatedEvents.reduce( - (counts: Record, relatedEvent) => { - // get an array of categories regardless of whether category is a string or string[] - const categories: string[] = values(relatedEvent.event?.category); - - for (const category of categories) { - // Set the first category as 'categoryToOverCount' - if (firstCategory === undefined) { - firstCategory = category; - } - - // Increment the count of events with this category - counts[category] = counts[category] ? counts[category] + 1 : 1; - } - return counts; - }, - {} - ); - if (firstCategory === undefined) { - throw new Error('there were no related events for the node.'); - } - return { - /** - * Object to use for the first child nodes stats `events` object? - */ - eventStats: { - total: totalRelatedEvents, - byCategory: compiledStats, - }, - firstCategory, - }; -} +}); diff --git a/x-pack/plugins/security_solution/public/resolver/store/data/reducer.ts b/x-pack/plugins/security_solution/public/resolver/store/data/reducer.ts index b91cf5b59ce21..af23b0cacca82 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/data/reducer.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/data/reducer.ts @@ -10,6 +10,7 @@ import { ResolverAction } from '../actions'; import * as treeFetcherParameters from '../../models/tree_fetcher_parameters'; import * as selectors from './selectors'; import * as nodeEventsInCategoryModel from './node_events_in_category_model'; +import * as nodeDataModel from '../../models/node_data'; const initialState: DataState = { currentRelatedEvent: { @@ -86,6 +87,8 @@ export const dataReducer: Reducer = (state = initialS */ lastResponse: { result: action.payload.result, + dataSource: action.payload.dataSource, + schema: action.payload.schema, parameters: action.payload.parameters, successful: true, }, @@ -183,6 +186,41 @@ export const dataReducer: Reducer = (state = initialS } else { return state; } + } else if (action.type === 'serverReturnedNodeData') { + const updatedNodeData = nodeDataModel.updateWithReceivedNodes({ + storedNodeInfo: state.nodeData, + receivedEvents: action.payload.nodeData, + requestedNodes: action.payload.requestedIDs, + numberOfRequestedEvents: action.payload.numberOfRequestedEvents, + }); + + return { + ...state, + nodeData: updatedNodeData, + }; + } else if (action.type === 'userReloadedResolverNode') { + const updatedNodeData = nodeDataModel.setReloadedNodes(state.nodeData, action.payload); + return { + ...state, + nodeData: updatedNodeData, + }; + } else if (action.type === 'appRequestingNodeData') { + const updatedNodeData = nodeDataModel.setRequestedNodes( + state.nodeData, + action.payload.requestedIDs + ); + + return { + ...state, + nodeData: updatedNodeData, + }; + } else if (action.type === 'serverFailedToReturnNodeData') { + const updatedData = nodeDataModel.setErrorNodes(state.nodeData, action.payload.requestedIDs); + + return { + ...state, + nodeData: updatedData, + }; } else if (action.type === 'appRequestedCurrentRelatedEventData') { const nextState: DataState = { ...state, diff --git a/x-pack/plugins/security_solution/public/resolver/store/data/selectors.test.ts b/x-pack/plugins/security_solution/public/resolver/store/data/selectors.test.ts index d9717b52d9ce1..98625f8bc919f 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/data/selectors.test.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/data/selectors.test.ts @@ -13,12 +13,72 @@ import { mockTreeWithNoAncestorsAnd2Children, mockTreeWith2AncestorsAndNoChildren, mockTreeWith1AncestorAnd2ChildrenAndAllNodesHave2GraphableEvents, - mockTreeWithAllProcessesTerminated, mockTreeWithNoProcessEvents, } from '../../mocks/resolver_tree'; -import * as eventModel from '../../../../common/endpoint/models/event'; -import { EndpointEvent } from '../../../../common/endpoint/types'; +import { endpointSourceSchema } from './../../mocks/tree_schema'; +import * as nodeModel from '../../../../common/endpoint/models/node'; import { mockTreeFetcherParameters } from '../../mocks/tree_fetcher_parameters'; +import { SafeResolverEvent } from '../../../../common/endpoint/types'; +import { mockEndpointEvent } from '../../mocks/endpoint_event'; + +function mockNodeDataWithAllProcessesTerminated({ + originID, + firstAncestorID, + secondAncestorID, +}: { + secondAncestorID: string; + firstAncestorID: string; + originID: string; +}): SafeResolverEvent[] { + const secondAncestor: SafeResolverEvent = mockEndpointEvent({ + entityID: secondAncestorID, + processName: 'a', + parentEntityID: 'none', + timestamp: 1600863932316, + }); + const firstAncestor: SafeResolverEvent = mockEndpointEvent({ + entityID: firstAncestorID, + processName: 'b', + parentEntityID: secondAncestorID, + timestamp: 1600863932317, + }); + const originEvent: SafeResolverEvent = mockEndpointEvent({ + entityID: originID, + processName: 'c', + parentEntityID: firstAncestorID, + timestamp: 1600863932318, + }); + const secondAncestorTermination: SafeResolverEvent = mockEndpointEvent({ + entityID: secondAncestorID, + processName: 'a', + parentEntityID: 'none', + timestamp: 1600863932316, + eventType: 'end', + }); + const firstAncestorTermination: SafeResolverEvent = mockEndpointEvent({ + entityID: firstAncestorID, + processName: 'b', + parentEntityID: secondAncestorID, + timestamp: 1600863932317, + eventType: 'end', + }); + const originEventTermination: SafeResolverEvent = mockEndpointEvent({ + entityID: originID, + processName: 'c', + parentEntityID: firstAncestorID, + timestamp: 1600863932318, + eventType: 'end', + }); + + return [ + originEvent, + originEventTermination, + firstAncestor, + firstAncestorTermination, + secondAncestor, + secondAncestorTermination, + ]; +} describe('data state', () => { let actions: ResolverAction[] = []; @@ -310,6 +370,7 @@ describe('data state', () => { const firstAncestorID = 'b'; const secondAncestorID = 'a'; beforeEach(() => { + const { schema, dataSource } = endpointSourceSchema(); actions.push({ type: 'serverReturnedResolverData', payload: { @@ -318,6 +379,8 @@ describe('data state', () => { firstAncestorID, secondAncestorID, }), + dataSource, + schema, // this value doesn't matter parameters: mockTreeFetcherParameters(), }, @@ -337,28 +400,31 @@ describe('data state', () => { const originID = 'c'; const firstAncestorID = 'b'; const secondAncestorID = 'a'; + const nodeData = mockNodeDataWithAllProcessesTerminated({ + originID, + firstAncestorID, + secondAncestorID, + }); beforeEach(() => { actions.push({ - type: 'serverReturnedResolverData', + type: 'serverReturnedNodeData', payload: { - result: mockTreeWithAllProcessesTerminated({ - originID, - firstAncestorID, - secondAncestorID, - }), - // this value doesn't matter - parameters: mockTreeFetcherParameters(), + nodeData, + requestedIDs: new Set([originID, firstAncestorID, secondAncestorID]), + // mock the requested size being larger than the returned number of events so we + // avoid the case where the limit was reached + numberOfRequestedEvents: nodeData.length + 1, }, }); }); it('should have origin as terminated', () => { - expect(selectors.isProcessTerminated(state())(originID)).toBe(true); + expect(selectors.nodeDataStatus(state())(originID)).toBe('terminated'); }); it('should have first ancestor as termianted', () => { - expect(selectors.isProcessTerminated(state())(firstAncestorID)).toBe(true); + expect(selectors.nodeDataStatus(state())(firstAncestorID)).toBe('terminated'); }); it('should have second ancestor as terminated', () => { - expect(selectors.isProcessTerminated(state())(secondAncestorID)).toBe(true); + expect(selectors.nodeDataStatus(state())(secondAncestorID)).toBe('terminated'); }); }); describe('with a tree with 2 children and no ancestors', () => { @@ -366,10 +432,18 @@ describe('data state', () => { const firstChildID = 'd'; const secondChildID = 'e'; beforeEach(() => { + const { resolverTree } = mockTreeWithNoAncestorsAnd2Children({ + originID, + firstChildID, + secondChildID, + }); + const { schema, dataSource } = endpointSourceSchema(); actions.push({ type: 'serverReturnedResolverData', payload: { - result: mockTreeWithNoAncestorsAnd2Children({ originID, firstChildID, secondChildID }), + result: resolverTree, + dataSource, + schema, // this value doesn't matter parameters: mockTreeFetcherParameters(), }, @@ -390,28 +464,29 @@ describe('data state', () => { const firstChildID = 'd'; const secondChildID = 'e'; beforeEach(() => { - const tree = mockTreeWithNoAncestorsAnd2Children({ originID, firstChildID, secondChildID }); - for (const event of tree.lifecycle) { - // delete the process.parent key, if present - // cast as `EndpointEvent` because `ResolverEvent` can also be `LegacyEndpointEvent` which has no `process` field - delete (event as EndpointEvent).process?.parent; - } - + const { resolverTree } = mockTreeWithNoAncestorsAnd2Children({ + originID, + firstChildID, + secondChildID, + }); + const { schema, dataSource } = endpointSourceSchema(); actions.push({ type: 'serverReturnedResolverData', payload: { - result: tree, + result: resolverTree, + dataSource, + schema, // this value doesn't matter parameters: mockTreeFetcherParameters(), }, }); }); it('should be able to calculate the aria flowto candidates for all processes nodes', () => { - const graphables = selectors.graphableProcesses(state()); + const graphables = selectors.graphableNodes(state()); expect(graphables.length).toBe(3); - for (const event of graphables) { + for (const node of graphables) { expect(() => { - selectors.ariaFlowtoCandidate(state())(eventModel.entityIDSafeVersion(event)!); + selectors.ariaFlowtoCandidate(state())(nodeModel.nodeID(node)!); }).not.toThrow(); } }); @@ -428,26 +503,32 @@ describe('data state', () => { firstChildID, secondChildID, }); + const { schema, dataSource } = endpointSourceSchema(); actions.push({ type: 'serverReturnedResolverData', payload: { result: tree, + dataSource, + schema, // this value doesn't matter parameters: mockTreeFetcherParameters(), }, }); }); it('should have 4 graphable processes', () => { - expect(selectors.graphableProcesses(state()).length).toBe(4); + expect(selectors.graphableNodes(state()).length).toBe(4); }); }); describe('with a tree with no process events', () => { beforeEach(() => { + const { schema, dataSource } = endpointSourceSchema(); const tree = mockTreeWithNoProcessEvents(); actions.push({ type: 'serverReturnedResolverData', payload: { result: tree, + dataSource, + schema, // this value doesn't matter parameters: mockTreeFetcherParameters(), }, diff --git a/x-pack/plugins/security_solution/public/resolver/store/data/selectors.ts b/x-pack/plugins/security_solution/public/resolver/store/data/selectors.ts index 9334e14af5ecd..3772b9852aa66 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/data/selectors.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/data/selectors.ts @@ -12,29 +12,32 @@ import { Vector2, IndexedEntity, IndexedEdgeLineSegment, - IndexedProcessNode, + IndexedTreeNode, AABB, VisibleEntites, TreeFetcherParameters, IsometricTaxiLayout, + NodeData, + NodeDataStatus, } from '../../types'; -import { isGraphableProcess, isTerminatedProcess } from '../../models/process_event'; import * as indexedProcessTreeModel from '../../models/indexed_process_tree'; -import * as eventModel from '../../../../common/endpoint/models/event'; +import * as nodeModel from '../../../../common/endpoint/models/node'; import * as nodeEventsInCategoryModel from './node_events_in_category_model'; import { - ResolverTree, - ResolverNodeStats, - ResolverRelatedEvents, SafeResolverEvent, + NewResolverTree, + ResolverNode, + EventStats, + ResolverSchema, } from '../../../../common/endpoint/types'; import * as resolverTreeModel from '../../models/resolver_tree'; import * as treeFetcherParametersModel from '../../models/tree_fetcher_parameters'; import * as isometricTaxiLayoutModel from '../../models/indexed_process_tree/isometric_taxi_layout'; +import * as aabbModel from '../../models/aabb'; import * as vector2 from '../../models/vector2'; /** - * If there is currently a request. + * Was a request made for graph data */ export function isTreeLoading(state: DataState): boolean { return state.tree?.pendingRequestParameters !== undefined; @@ -58,102 +61,112 @@ export function resolverComponentInstanceID(state: DataState): string { } /** - * The last ResolverTree we received, if any. It may be stale (it might not be for the same databaseDocumentID that + * The last NewResolverTree we received, if any. It may be stale (it might not be for the same databaseDocumentID that * we're currently interested in. */ -const resolverTreeResponse = (state: DataState): ResolverTree | undefined => { +const resolverTreeResponse = (state: DataState): NewResolverTree | undefined => { return state.tree?.lastResponse?.successful ? state.tree?.lastResponse.result : undefined; }; +/** + * If we received a NewResolverTree, return the schema associated with that tree, otherwise return undefined. + * As of writing, this is only used for the info popover in the graph_controls panel + */ +export function resolverTreeSourceAndSchema( + state: DataState +): { schema: ResolverSchema; dataSource: string } | undefined { + if (state.tree?.lastResponse?.successful) { + const { schema, dataSource } = state.tree?.lastResponse; + return { schema, dataSource }; + } + return undefined; +} + /** * the node ID of the node representing the databaseDocumentID. * NB: this could be stale if the last response is stale */ export const originID: (state: DataState) => string | undefined = createSelector( resolverTreeResponse, - function (resolverTree?) { - if (resolverTree) { - // This holds the entityID (aka nodeID) of the node related to the last fetched `_id` - return resolverTree.entityID; - } - return undefined; + function (resolverTree) { + return resolverTree?.originID; } ); /** - * Process events that will be displayed as terminated. + * Returns a data structure for accessing events for specific nodes in a graph. For Endpoint graphs these nodes will be + * process lifecycle events. */ -export const terminatedProcesses = createSelector( - resolverTreeResponse, - function (tree?: ResolverTree) { - if (!tree) { - return new Set(); - } - return new Set( - resolverTreeModel - .lifecycleEvents(tree) - .filter(isTerminatedProcess) - .map((terminatedEvent) => { - return eventModel.entityIDSafeVersion(terminatedEvent); - }) - ); - } -); +const nodeData = (state: DataState): Map | undefined => { + return state.nodeData; +}; /** - * A function that given an entity id returns a boolean indicating if the id is in the set of terminated processes. + * Returns a function that can be called to retrieve the node data for a specific node ID. */ -export const isProcessTerminated = createSelector(terminatedProcesses, function ( - // eslint-disable-next-line @typescript-eslint/no-shadow - terminatedProcesses -) { - return (entityID: string) => { - return terminatedProcesses.has(entityID); +export const nodeDataForID: ( + state: DataState +) => (id: string) => NodeData | undefined = createSelector(nodeData, (nodeInfo) => { + return (id: string) => { + const info = nodeInfo?.get(id); + return info; }; }); /** - * Process events that will be graphed. + * Returns a function that can be called to retrieve the state of the node, running, loading, or terminated. */ -export const graphableProcesses = createSelector(resolverTreeResponse, function (tree?) { - // Keep track of the last process event (in array order) for each entity ID - const events: Map = new Map(); - if (tree) { - for (const event of resolverTreeModel.lifecycleEvents(tree)) { - if (isGraphableProcess(event)) { - const entityID = eventModel.entityIDSafeVersion(event); - if (entityID !== undefined) { - events.set(entityID, event); - } +export const nodeDataStatus: (state: DataState) => (id: string) => NodeDataStatus = createSelector( + nodeDataForID, + (nodeInfo) => { + return (id: string) => { + const info = nodeInfo(id); + if (!info) { + return 'loading'; + } + + return info.status; + }; + } +); + +/** + * Nodes that will be graphed. + */ +export const graphableNodes = createSelector(resolverTreeResponse, function (treeResponse?) { + // Keep track of each unique nodeID + const nodes: Map = new Map(); + if (treeResponse?.nodes) { + for (const node of treeResponse.nodes) { + const nodeID = nodeModel.nodeID(node); + if (nodeID !== undefined) { + nodes.set(nodeID, node); } } - return [...events.values()]; + return [...nodes.values()]; } else { return []; } }); -/** - * The 'indexed process tree' contains the tree data, indexed in helpful ways. Used for O(1) access to stuff during graph layout. - */ -export const tree = createSelector(graphableProcesses, function indexedTree( +const tree = createSelector(graphableNodes, originID, function indexedProcessTree( // eslint-disable-next-line @typescript-eslint/no-shadow - graphableProcesses + graphableNodes, + currentOriginID ) { - return indexedProcessTreeModel.factory(graphableProcesses); + return indexedProcessTreeModel.factory(graphableNodes, currentOriginID); }); /** - * This returns a map of entity_ids to stats about the related events and alerts. - * @deprecated + * This returns a map of nodeIDs to the associated stats provided by the datasource. */ -export const relatedEventsStats: ( +export const nodeStats: ( state: DataState -) => (nodeID: string) => ResolverNodeStats | undefined = createSelector( +) => (nodeID: string) => EventStats | undefined = createSelector( resolverTreeResponse, - (resolverTree?: ResolverTree) => { + (resolverTree?: NewResolverTree) => { if (resolverTree) { - const map = resolverTreeModel.relatedEventsStats(resolverTree); + const map = resolverTreeModel.nodeStats(resolverTree); return (nodeID: string) => map.get(nodeID); } else { return () => undefined; @@ -166,25 +179,14 @@ export const relatedEventsStats: ( */ export const relatedEventTotalCount: ( state: DataState -) => (entityID: string) => number | undefined = createSelector( - relatedEventsStats, - (relatedStats) => { - return (entityID) => { - return relatedStats(entityID)?.events?.total; - }; - } -); - -/** - * returns a map of entity_ids to related event data. - * @deprecated - */ -export function relatedEventsByEntityId(data: DataState): Map { - return data.relatedEvents; -} +) => (entityID: string) => number | undefined = createSelector(nodeStats, (getNodeStats) => { + return (nodeID) => { + return getNodeStats(nodeID)?.total; + }; +}); /** - * + * Returns a boolean indicating if an even in the event_detail view is loading. * * @export * @param {DataState} state @@ -195,106 +197,25 @@ export function isCurrentRelatedEventLoading(state: DataState) { } /** - * + * Returns the current related event data for the `event_detail` view. * * @export * @param {DataState} state - * @returns {(SafeResolverEvent | null)} the current related event data for the `event_detail` view + * @returns {(ResolverNode | null)} the current related event data for the `event_detail` view */ export function currentRelatedEventData(state: DataState): SafeResolverEvent | null { return state.currentRelatedEvent.data; } -/** - * Get an event (from memory) by its `event.id`. - * @deprecated Use the API to find events by ID - */ -export const eventByID = createSelector(relatedEventsByEntityId, (relatedEvents) => { - // A map of nodeID to a map of eventID to events. Lazily populated. - const memo = new Map>(); - return ({ eventID, nodeID }: { eventID: string; nodeID: string }) => { - // We keep related events in a map by their nodeID. - const eventsWrapper = relatedEvents.get(nodeID); - if (!eventsWrapper) { - return undefined; - } - // When an event from a nodeID is requested, build a map for all events related to that node. - if (!memo.has(nodeID)) { - const map = new Map(); - for (const event of eventsWrapper.events) { - const id = eventModel.eventIDSafeVersion(event); - if (id !== undefined) { - map.set(id, event); - } - } - memo.set(nodeID, map); - } - const eventMap = memo.get(nodeID); - if (!eventMap) { - // This shouldn't be possible. - return undefined; - } - return eventMap.get(eventID); - }; -}); - -/** - * Returns a function that returns a function (when supplied with an entity id for a node) - * that returns related events for a node that match an event.category (when supplied with the category) - * @deprecated - */ -export const relatedEventsByCategory: ( - state: DataState -) => (node: string, eventCategory: string) => SafeResolverEvent[] = createSelector( - relatedEventsByEntityId, - function ( - // eslint-disable-next-line @typescript-eslint/no-shadow - relatedEventsByEntityId - ) { - // A map of nodeID -> event category -> SafeResolverEvent[] - const nodeMap: Map> = new Map(); - for (const [nodeID, events] of relatedEventsByEntityId) { - // A map of eventCategory -> SafeResolverEvent[] - let categoryMap = nodeMap.get(nodeID); - if (!categoryMap) { - categoryMap = new Map(); - nodeMap.set(nodeID, categoryMap); - } - - for (const event of events.events) { - for (const category of eventModel.eventCategory(event)) { - let eventsInCategory = categoryMap.get(category); - if (!eventsInCategory) { - eventsInCategory = []; - categoryMap.set(category, eventsInCategory); - } - eventsInCategory.push(event); - } - } - } - - // Use the same empty array for all values that are missing - const emptyArray: SafeResolverEvent[] = []; - - return (entityID: string, category: string): SafeResolverEvent[] => { - const categoryMap = nodeMap.get(entityID); - if (!categoryMap) { - return emptyArray; - } - const eventsInCategory = categoryMap.get(category); - return eventsInCategory ?? emptyArray; - }; - } -); export const relatedEventCountByCategory: ( state: DataState ) => (nodeID: string, eventCategory: string) => number | undefined = createSelector( - relatedEventsStats, - (statsMap) => { + nodeStats, + (getNodeStats) => { return (nodeID: string, eventCategory: string): number | undefined => { - const stats = statsMap(nodeID); + const stats = getNodeStats(nodeID); if (stats) { - const value = Object.prototype.hasOwnProperty.call(stats.events.byCategory, eventCategory); + const value = Object.prototype.hasOwnProperty.call(stats.byCategory, eventCategory); if (typeof value === 'number' && Number.isFinite(value)) { return value; } @@ -304,22 +225,61 @@ export const relatedEventCountByCategory: ( ); /** - * `true` if there were more children than we got in the last request. - * @deprecated + * Returns true if there might be more generations in the graph that we didn't get because we reached + * the requested generations limit. + * + * If we set a limit at 10 and we received 9, then we know there weren't anymore. If we received 10 then there + * might be more generations. */ -export function hasMoreChildren(state: DataState): boolean { - const resolverTree = resolverTreeResponse(state); - return resolverTree ? resolverTreeModel.hasMoreChildren(resolverTree) : false; -} +export const hasMoreGenerations: (state: DataState) => boolean = createSelector( + tree, + resolverTreeSourceAndSchema, + (resolverTree, sourceAndSchema) => { + // if the ancestry field is defined then the server request will not be limited by the generations + // field, so let's just assume that we always get all the generations we can, but we are instead + // limited by the number of descendants to retrieve which is handled by a different selector + if (sourceAndSchema?.schema?.ancestry) { + return false; + } + + return ( + (resolverTree.generations ?? 0) >= + resolverTreeModel.generationsRequestAmount(sourceAndSchema?.schema) + ); + } +); /** - * `true` if there were more ancestors than we got in the last request. - * @deprecated + * Returns true if there might be more descendants in the graph that we didn't get because + * we reached the requested descendants limit. + * + * If we set a limit at 10 and we received 9, then we know there weren't anymore. If we received + * 10, there might be more. */ -export function hasMoreAncestors(state: DataState): boolean { - const resolverTree = resolverTreeResponse(state); - return resolverTree ? resolverTreeModel.hasMoreAncestors(resolverTree) : false; -} +export const hasMoreChildren: (state: DataState) => boolean = createSelector( + tree, + (resolverTree) => { + return (resolverTree.descendants ?? 0) >= resolverTreeModel.descendantsRequestAmount(); + } +); + +/** + * Returns true if there might be more ancestors in the graph that we didn't get because + * we reached the requested limit. + * + * If we set a limit at 10 and we received 9, then we know there weren't anymore. If we received + * 10, there might be more. + */ +export const hasMoreAncestors: (state: DataState) => boolean = createSelector( + tree, + resolverTreeSourceAndSchema, + (resolverTree, sourceAndSchema) => { + return ( + (resolverTree.ancestors ?? 0) >= + resolverTreeModel.ancestorsRequestAmount(sourceAndSchema?.schema) + ); + } +); /** * If the tree resource needs to be fetched then these are the parameters that should be used. @@ -345,34 +305,34 @@ export function treeParametersToFetch(state: DataState): TreeFetcherParameters | } } +/** + * The indices to use for the requests with the backend. + */ +export const treeParamterIndices = createSelector(treeParametersToFetch, (parameters) => { + return parameters?.indices ?? []; +}); + export const layout: (state: DataState) => IsometricTaxiLayout = createSelector( tree, originID, - function processNodePositionsAndEdgeLineSegments( - indexedProcessTree, - // eslint-disable-next-line @typescript-eslint/no-shadow - originID - ) { + function processNodePositionsAndEdgeLineSegments(indexedProcessTree, currentOriginID) { // use the isometric taxi layout as a base const taxiLayout = isometricTaxiLayoutModel.isometricTaxiLayoutFactory(indexedProcessTree); - - if (!originID) { + if (!currentOriginID) { // no data has loaded. return taxiLayout; } // find the origin node - const originNode = indexedProcessTreeModel.processEvent(indexedProcessTree, originID); - + const originNode = indexedProcessTreeModel.treeNode(indexedProcessTree, currentOriginID); if (originNode === null) { // If a tree is returned that has no process events for the origin, this can happen. return taxiLayout; } // Find the position of the origin, we'll center the map on it intrinsically - const originPosition = isometricTaxiLayoutModel.processPosition(taxiLayout, originNode); + const originPosition = isometricTaxiLayoutModel.nodePosition(taxiLayout, currentOriginID); // adjust the position of everything so that the origin node is at `(0, 0)` - if (originPosition === undefined) { // not sure how this could happen. return taxiLayout; @@ -389,12 +349,12 @@ export const layout: (state: DataState) => IsometricTaxiLayout = createSelector( * Legacy functions take process events instead of nodeID, use this to get * process events for them. */ -export const processEventForID: ( +export const graphNodeForID: ( state: DataState -) => (nodeID: string) => SafeResolverEvent | null = createSelector( +) => (nodeID: string) => ResolverNode | null = createSelector( tree, (indexedProcessTree) => (nodeID: string) => { - return indexedProcessTreeModel.processEvent(indexedProcessTree, nodeID); + return indexedProcessTreeModel.treeNode(indexedProcessTree, nodeID); } ); @@ -403,9 +363,9 @@ export const processEventForID: ( */ export const ariaLevel: (state: DataState) => (nodeID: string) => number | null = createSelector( layout, - processEventForID, - ({ ariaLevels }, processEventGetter) => (nodeID: string) => { - const node = processEventGetter(nodeID); + graphNodeForID, + ({ ariaLevels }, graphNodeGetter) => (nodeID: string) => { + const node = graphNodeGetter(nodeID); return node ? ariaLevels.get(node) ?? null : null; } ); @@ -419,8 +379,8 @@ export const ariaFlowtoCandidate: ( state: DataState ) => (nodeID: string) => string | null = createSelector( tree, - processEventForID, - (indexedProcessTree, eventGetter) => { + graphNodeForID, + (indexedProcessTree, nodeGetter) => { // A map of preceding sibling IDs to following sibling IDs or `null`, if there is no following sibling const memo: Map = new Map(); @@ -441,9 +401,9 @@ export const ariaFlowtoCandidate: ( * Getting the following sibling of a node has an `O(n)` time complexity where `n` is the number of children the parent of the node has. * For this reason, we calculate the following siblings of the node and all of its siblings at once and cache them. */ - const nodeEvent: SafeResolverEvent | null = eventGetter(nodeID); + const node: ResolverNode | null = nodeGetter(nodeID); - if (!nodeEvent) { + if (!node) { // this should never happen. throw new Error('could not find child event in process tree.'); } @@ -451,18 +411,18 @@ export const ariaFlowtoCandidate: ( // nodes with the same parent ID const children = indexedProcessTreeModel.children( indexedProcessTree, - eventModel.parentEntityIDSafeVersion(nodeEvent) + nodeModel.parentId(node) ); - let previousChild: SafeResolverEvent | null = null; + let previousChild: ResolverNode | null = null; // Loop over all nodes that have the same parent ID (even if the parent ID is undefined or points to a node that isn't in the tree.) for (const child of children) { if (previousChild !== null) { // Set the `child` as the following sibling of `previousChild`. - const previousChildEntityID = eventModel.entityIDSafeVersion(previousChild); - const followingSiblingEntityID = eventModel.entityIDSafeVersion(child); - if (previousChildEntityID !== undefined && followingSiblingEntityID !== undefined) { - memo.set(previousChildEntityID, followingSiblingEntityID); + const previousChildNodeId = nodeModel.nodeID(previousChild); + const followingSiblingEntityID = nodeModel.nodeID(child); + if (previousChildNodeId !== undefined && followingSiblingEntityID !== undefined) { + memo.set(previousChildNodeId, followingSiblingEntityID); } } // Set the child as the previous child. @@ -471,9 +431,9 @@ export const ariaFlowtoCandidate: ( if (previousChild) { // if there is a previous child, it has no following sibling. - const entityID = eventModel.entityIDSafeVersion(previousChild); - if (entityID !== undefined) { - memo.set(entityID, null); + const previousChildNodeID = nodeModel.nodeID(previousChild); + if (previousChildNodeID !== undefined) { + memo.set(previousChildNodeID, null); } } @@ -486,26 +446,26 @@ const spatiallyIndexedLayout: (state: DataState) => rbush = creat layout, function ({ processNodePositions, edgeLineSegments }) { const spatialIndex: rbush = new rbush(); - const processesToIndex: IndexedProcessNode[] = []; + const nodeToIndex: IndexedTreeNode[] = []; const edgeLineSegmentsToIndex: IndexedEdgeLineSegment[] = []; // Make sure these numbers are big enough to cover the process nodes at all zoom levels. // The process nodes don't extend equally in all directions from their center point. - const processNodeViewWidth = 720; - const processNodeViewHeight = 240; + const graphNodeViewWidth = 720; + const graphNodeViewHeight = 240; const lineSegmentPadding = 30; - for (const [processEvent, position] of processNodePositions) { + for (const [treeNode, position] of processNodePositions) { const [nodeX, nodeY] = position; - const indexedEvent: IndexedProcessNode = { - minX: nodeX - 0.5 * processNodeViewWidth, - minY: nodeY - 0.5 * processNodeViewHeight, - maxX: nodeX + 0.5 * processNodeViewWidth, - maxY: nodeY + 0.5 * processNodeViewHeight, + const indexedEvent: IndexedTreeNode = { + minX: nodeX - 0.5 * graphNodeViewWidth, + minY: nodeY - 0.5 * graphNodeViewHeight, + maxX: nodeX + 0.5 * graphNodeViewWidth, + maxY: nodeY + 0.5 * graphNodeViewHeight, position, - entity: processEvent, - type: 'processNode', + entity: treeNode, + type: 'treeNode', }; - processesToIndex.push(indexedEvent); + nodeToIndex.push(indexedEvent); } for (const edgeLineSegment of edgeLineSegments) { const { @@ -521,7 +481,7 @@ const spatiallyIndexedLayout: (state: DataState) => rbush = creat }; edgeLineSegmentsToIndex.push(indexedLineSegment); } - spatialIndex.load([...processesToIndex, ...edgeLineSegmentsToIndex]); + spatialIndex.load([...nodeToIndex, ...edgeLineSegmentsToIndex]); return spatialIndex; } ); @@ -551,9 +511,9 @@ export const nodesAndEdgelines: ( maxX, maxY, }); - const visibleProcessNodePositions = new Map( + const visibleProcessNodePositions = new Map( entities - .filter((entity): entity is IndexedProcessNode => entity.type === 'processNode') + .filter((entity): entity is IndexedTreeNode => entity.type === 'treeNode') .map((node) => [node.entity, node.position]) ); const connectingEdgeLineSegments = entities @@ -563,9 +523,28 @@ export const nodesAndEdgelines: ( processNodePositions: visibleProcessNodePositions, connectingEdgeLineSegments, }; - }); + }, aaBBEqualityCheck); }); +function isAABBType(value: unknown): value is AABB { + const castValue = value as AABB; + return castValue.maximum !== undefined && castValue.minimum !== undefined; +} + +/** + * This is needed to avoid the TS error that is caused by using aabbModel.isEqual directly. Ideally we could + * just pass that function instead of having to check the type of the parameters. It might be worth doing a PR to + * the reselect library to correct the type. + */ +function aaBBEqualityCheck(a: T, b: T, index: number): boolean { + if (isAABBType(a) && isAABBType(b)) { + return aabbModel.isEqual(a, b); + } else { + // this is equivalent to the default equality check for defaultMemoize + return a === b; + } +} + /** * If there is a pending request that's for a entity ID that doesn't matche the `entityID`, then we should cancel it. */ @@ -589,24 +568,21 @@ export function treeRequestParametersToAbort(state: DataState): TreeFetcherParam /** * The sum of all related event categories for a process. */ -export const relatedEventTotalForProcess: ( +export const statsTotalForNode: ( state: DataState -) => (event: SafeResolverEvent) => number | null = createSelector( - relatedEventsStats, - (statsForProcess) => { - return (event: SafeResolverEvent) => { - const nodeID = eventModel.entityIDSafeVersion(event); - if (nodeID === undefined) { - return null; - } - const stats = statsForProcess(nodeID); - if (!stats) { - return null; - } - return stats.events.total; - }; - } -); +) => (event: ResolverNode) => number | null = createSelector(nodeStats, (getNodeStats) => { + return (node: ResolverNode) => { + const nodeID = nodeModel.nodeID(node); + if (nodeID === undefined) { + return null; + } + const stats = getNodeStats(nodeID); + if (!stats) { + return null; + } + return stats.total; + }; +}); /** * Total count of events related to `node`. @@ -615,10 +591,10 @@ export const relatedEventTotalForProcess: ( export const totalRelatedEventCountForNode: ( state: DataState ) => (nodeID: string) => number | undefined = createSelector( - relatedEventsStats, - (stats) => (nodeID: string) => { - const nodeStats = stats(nodeID); - return nodeStats === undefined ? undefined : nodeStats.events.total; + nodeStats, + (getNodeStats) => (nodeID: string) => { + const stats = getNodeStats(nodeID); + return stats === undefined ? undefined : stats.total; } ); @@ -629,13 +605,13 @@ export const totalRelatedEventCountForNode: ( export const relatedEventCountOfTypeForNode: ( state: DataState ) => (nodeID: string, category: string) => number | undefined = createSelector( - relatedEventsStats, - (stats) => (nodeID: string, category: string) => { - const nodeStats = stats(nodeID); - if (!nodeStats) { + nodeStats, + (getNodeStats) => (nodeID: string, category: string) => { + const stats = getNodeStats(nodeID); + if (!stats) { return undefined; } else { - return nodeStats.events.byCategory[category]; + return stats.byCategory[category]; } } ); diff --git a/x-pack/plugins/security_solution/public/resolver/store/data/visible_entities.test.ts b/x-pack/plugins/security_solution/public/resolver/store/data/visible_entities.test.ts index 506acefe51676..d05cf08b48844 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/data/visible_entities.test.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/data/visible_entities.test.ts @@ -8,20 +8,21 @@ import { Store, createStore } from 'redux'; import { ResolverAction } from '../actions'; import { resolverReducer } from '../reducer'; import { ResolverState } from '../../types'; -import { LegacyEndpointEvent, SafeResolverEvent } from '../../../../common/endpoint/types'; +import { ResolverNode } from '../../../../common/endpoint/types'; import { visibleNodesAndEdgeLines } from '../selectors'; -import { mockProcessEvent } from '../../models/process_event_test_helpers'; import { mock as mockResolverTree } from '../../models/resolver_tree'; import { mockTreeFetcherParameters } from '../../mocks/tree_fetcher_parameters'; +import { endpointSourceSchema } from './../../mocks/tree_schema'; +import { mockResolverNode } from '../../mocks/resolver_node'; describe('resolver visible entities', () => { - let processA: LegacyEndpointEvent; - let processB: LegacyEndpointEvent; - let processC: LegacyEndpointEvent; - let processD: LegacyEndpointEvent; - let processE: LegacyEndpointEvent; - let processF: LegacyEndpointEvent; - let processG: LegacyEndpointEvent; + let nodeA: ResolverNode; + let nodeB: ResolverNode; + let nodeC: ResolverNode; + let nodeD: ResolverNode; + let nodeE: ResolverNode; + let nodeF: ResolverNode; + let nodeG: ResolverNode; let store: Store; beforeEach(() => { @@ -34,86 +35,75 @@ describe('resolver visible entities', () => { * | * D etc */ - processA = mockProcessEvent({ - endgame: { - process_name: '', - event_type_full: 'process_event', - event_subtype_full: 'creation_event', - unique_pid: 0, - }, + nodeA = mockResolverNode({ + name: '', + id: '0', + stats: { total: 0, byCategory: {} }, + timestamp: 0, }); - processB = mockProcessEvent({ - endgame: { - event_type_full: 'process_event', - event_subtype_full: 'already_running', - unique_pid: 1, - unique_ppid: 0, - }, + nodeB = mockResolverNode({ + id: '1', + name: '', + parentID: '0', + stats: { total: 0, byCategory: {} }, + timestamp: 0, }); - processC = mockProcessEvent({ - endgame: { - event_type_full: 'process_event', - event_subtype_full: 'creation_event', - unique_pid: 2, - unique_ppid: 1, - }, + nodeC = mockResolverNode({ + id: '2', + name: '', + parentID: '1', + stats: { total: 0, byCategory: {} }, + timestamp: 0, }); - processD = mockProcessEvent({ - endgame: { - event_type_full: 'process_event', - event_subtype_full: 'creation_event', - unique_pid: 3, - unique_ppid: 2, - }, + nodeD = mockResolverNode({ + id: '3', + name: '', + parentID: '2', + stats: { total: 0, byCategory: {} }, + timestamp: 0, }); - processE = mockProcessEvent({ - endgame: { - event_type_full: 'process_event', - event_subtype_full: 'creation_event', - unique_pid: 4, - unique_ppid: 3, - }, + nodeE = mockResolverNode({ + id: '4', + name: '', + parentID: '3', + stats: { total: 0, byCategory: {} }, + timestamp: 0, }); - processF = mockProcessEvent({ - endgame: { - event_type_full: 'process_event', - event_subtype_full: 'creation_event', - unique_pid: 5, - unique_ppid: 4, - }, + nodeF = mockResolverNode({ + id: '5', + name: '', + parentID: '4', + stats: { total: 0, byCategory: {} }, + timestamp: 0, }); - processF = mockProcessEvent({ - endgame: { - event_type_full: 'process_event', - event_subtype_full: 'creation_event', - unique_pid: 6, - unique_ppid: 5, - }, + nodeF = mockResolverNode({ + id: '6', + name: '', + parentID: '5', + stats: { total: 0, byCategory: {} }, + timestamp: 0, }); - processG = mockProcessEvent({ - endgame: { - event_type_full: 'process_event', - event_subtype_full: 'creation_event', - unique_pid: 7, - unique_ppid: 6, - }, + nodeG = mockResolverNode({ + id: '7', + name: '', + parentID: '6', + stats: { total: 0, byCategory: {} }, + timestamp: 0, }); store = createStore(resolverReducer, undefined); }); describe('when rendering a large tree with a small viewport', () => { beforeEach(() => { - const events: SafeResolverEvent[] = [ - processA, - processB, - processC, - processD, - processE, - processF, - processG, - ]; + const nodes: ResolverNode[] = [nodeA, nodeB, nodeC, nodeD, nodeE, nodeF, nodeG]; + const { schema, dataSource } = endpointSourceSchema(); const action: ResolverAction = { type: 'serverReturnedResolverData', - payload: { result: mockResolverTree({ events })!, parameters: mockTreeFetcherParameters() }, + payload: { + result: mockResolverTree({ nodes })!, + dataSource, + schema, + parameters: mockTreeFetcherParameters(), + }, }; const cameraAction: ResolverAction = { type: 'userSetRasterSize', payload: [300, 200] }; store.dispatch(action); @@ -130,18 +120,16 @@ describe('resolver visible entities', () => { }); describe('when rendering a large tree with a large viewport', () => { beforeEach(() => { - const events: SafeResolverEvent[] = [ - processA, - processB, - processC, - processD, - processE, - processF, - processG, - ]; + const nodes: ResolverNode[] = [nodeA, nodeB, nodeC, nodeD, nodeE, nodeF, nodeG]; + const { schema, dataSource } = endpointSourceSchema(); const action: ResolverAction = { type: 'serverReturnedResolverData', - payload: { result: mockResolverTree({ events })!, parameters: mockTreeFetcherParameters() }, + payload: { + result: mockResolverTree({ nodes })!, + dataSource, + schema, + parameters: mockTreeFetcherParameters(), + }, }; const cameraAction: ResolverAction = { type: 'userSetRasterSize', payload: [2000, 2000] }; store.dispatch(action); diff --git a/x-pack/plugins/security_solution/public/resolver/store/middleware/current_related_event_fetcher.ts b/x-pack/plugins/security_solution/public/resolver/store/middleware/current_related_event_fetcher.ts index 7f83ef7bf2aa8..d1076fb8a8836 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/middleware/current_related_event_fetcher.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/middleware/current_related_event_fetcher.ts @@ -10,6 +10,7 @@ import { SafeResolverEvent } from '../../../../common/endpoint/types'; import { ResolverState, DataAccessLayer, PanelViewAndParameters } from '../../types'; import * as selectors from '../selectors'; +import { createRange } from './../../models/time_range'; import { ResolverAction } from '../actions'; /** @@ -31,6 +32,7 @@ export function CurrentRelatedEventFetcher( const state = api.getState(); const newParams = selectors.panelViewAndParameters(state); + const indices = selectors.treeParameterIndices(state); const oldParams = last; last = newParams; @@ -38,6 +40,10 @@ export function CurrentRelatedEventFetcher( // If the panel view params have changed and the current panel view is the `eventDetail`, then fetch the event details for that eventID. if (!isEqual(newParams, oldParams) && newParams.panelView === 'eventDetail') { const currentEventID = newParams.panelParameters.eventID; + const currentNodeID = newParams.panelParameters.nodeID; + const currentEventCategory = newParams.panelParameters.eventCategory; + const currentEventTimestamp = newParams.panelParameters.eventTimestamp; + const winlogRecordID = newParams.panelParameters.winlogRecordID; api.dispatch({ type: 'appRequestedCurrentRelatedEventData', @@ -45,7 +51,15 @@ export function CurrentRelatedEventFetcher( let result: SafeResolverEvent | null = null; try { - result = await dataAccessLayer.event(currentEventID); + result = await dataAccessLayer.event({ + nodeID: currentNodeID, + eventCategory: [currentEventCategory], + eventTimestamp: currentEventTimestamp, + eventID: currentEventID, + winlogRecordID, + indexPatterns: indices, + timeRange: createRange(), + }); } catch (error) { api.dispatch({ type: 'serverFailedToReturnCurrentRelatedEventData', diff --git a/x-pack/plugins/security_solution/public/resolver/store/middleware/index.ts b/x-pack/plugins/security_solution/public/resolver/store/middleware/index.ts index 3bc4612026c12..916ea95ca06bb 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/middleware/index.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/middleware/index.ts @@ -11,6 +11,7 @@ import { ResolverTreeFetcher } from './resolver_tree_fetcher'; import { ResolverAction } from '../actions'; import { RelatedEventsFetcher } from './related_events_fetcher'; import { CurrentRelatedEventFetcher } from './current_related_event_fetcher'; +import { NodeDataFetcher } from './node_data_fetcher'; type MiddlewareFactory = ( dataAccessLayer: DataAccessLayer @@ -29,11 +30,13 @@ export const resolverMiddlewareFactory: MiddlewareFactory = (dataAccessLayer: Da const resolverTreeFetcher = ResolverTreeFetcher(dataAccessLayer, api); const relatedEventsFetcher = RelatedEventsFetcher(dataAccessLayer, api); const currentRelatedEventFetcher = CurrentRelatedEventFetcher(dataAccessLayer, api); + const nodeDataFetcher = NodeDataFetcher(dataAccessLayer, api); return async (action: ResolverAction) => { next(action); resolverTreeFetcher(); relatedEventsFetcher(); + nodeDataFetcher(); currentRelatedEventFetcher(); }; }; diff --git a/x-pack/plugins/security_solution/public/resolver/store/middleware/node_data_fetcher.ts b/x-pack/plugins/security_solution/public/resolver/store/middleware/node_data_fetcher.ts new file mode 100644 index 0000000000000..8388933170a56 --- /dev/null +++ b/x-pack/plugins/security_solution/public/resolver/store/middleware/node_data_fetcher.ts @@ -0,0 +1,118 @@ +/* + * 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 { Dispatch, MiddlewareAPI } from 'redux'; +import { SafeResolverEvent } from '../../../../common/endpoint/types'; + +import { ResolverState, DataAccessLayer } from '../../types'; +import * as selectors from '../selectors'; +import { ResolverAction } from '../actions'; +import { createRange } from './../../models/time_range'; + +/** + * Max number of nodes to request from the server + */ +const nodeDataLimit = 5000; + +/** + * This fetcher will request data for the nodes that are in the visible region of the resolver graph. Before fetching + * the data, it checks to see we already have the data or we're already in the process of getting the data. + * + * For Endpoint resolver graphs, the node data will be lifecycle process events. + */ +export function NodeDataFetcher( + dataAccessLayer: DataAccessLayer, + api: MiddlewareAPI, ResolverState> +): () => void { + return async () => { + const state = api.getState(); + + /** + * Using the greatest positive number here so that we will request the node data for the nodes in view + * before the animation finishes. This will be a better user experience since we'll start the request while + * the camera is panning and there's a higher chance it'll be finished once the camera finishes panning. + * + * This gets the visible nodes that we haven't already requested or received data for + */ + const newIDsToRequest: Set = selectors.newIDsToRequest(state)(Number.POSITIVE_INFINITY); + const indices = selectors.treeParameterIndices(state); + + if (newIDsToRequest.size <= 0) { + return; + } + + /** + * Dispatch an action indicating that we are going to request data for a set of nodes so that we can show a loading + * state for those nodes in the UI. + * + * When we dispatch this, this middleware will run again but the visible nodes will be the same, the nodeData + * state will have the new visible nodes in it, and newIDsToRequest will be an empty set. + */ + api.dispatch({ + type: 'appRequestingNodeData', + payload: { + requestedIDs: newIDsToRequest, + }, + }); + + let results: SafeResolverEvent[] | undefined; + try { + results = await dataAccessLayer.nodeData({ + ids: Array.from(newIDsToRequest), + timeRange: createRange(), + indexPatterns: indices, + limit: nodeDataLimit, + }); + } catch (error) { + /** + * Dispatch an action indicating all the nodes that we failed to retrieve data for + */ + api.dispatch({ + type: 'serverFailedToReturnNodeData', + payload: { + requestedIDs: newIDsToRequest, + }, + }); + } + + if (results) { + /** + * Dispatch an action including the new node data we received and the original IDs we requested. We might + * not have received events for each node so the original IDs will help with identifying nodes that we have + * no data for. + */ + api.dispatch({ + type: 'serverReturnedNodeData', + payload: { + nodeData: results, + requestedIDs: newIDsToRequest, + /** + * The reason we need this is to handle the case where the results does not contain node data for each node ID + * that we requested. This situation can happen for a couple reasons: + * + * 1. The data no longer exists in Elasticsearch. This is an unlikely scenario because for us to be requesting + * a node ID it means that when we retrieved the initial resolver graph we had at least 1 event so that we could + * draw a node using that event on the graph. A user could delete the node's data between the time when we + * requested the original graph and now. + * + * In this situation we'll want to record that there is no node data for that specific node but still mark the + * status as Received. + * + * 2. The request limit for the /events API was received. Currently we pass in 5000 as the limit. If we receive + * 5000 events back than it is possible that we won't receive a single event for one of the node IDs we requested. + * In this scenario we'll want to mark the node in such a way that on a future action we'll try requesting + * the data for that particular node. We'll have a higher likelihood of receiving data on subsequent requests + * because the number of node IDs that we request will go done as we receive their data back. + * + * In this scenario we'll remove the entry in the node data map so that on a subsequent middleware call + * if that node is still in view we'll request its node data. + */ + numberOfRequestedEvents: nodeDataLimit, + }, + }); + } + }; +} diff --git a/x-pack/plugins/security_solution/public/resolver/store/middleware/related_events_fetcher.ts b/x-pack/plugins/security_solution/public/resolver/store/middleware/related_events_fetcher.ts index 6d054a20b856d..099ef33ec8b17 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/middleware/related_events_fetcher.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/middleware/related_events_fetcher.ts @@ -11,6 +11,7 @@ import { ResolverPaginatedEvents } from '../../../../common/endpoint/types'; import { ResolverState, DataAccessLayer, PanelViewAndParameters } from '../../types'; import * as selectors from '../selectors'; import { ResolverAction } from '../actions'; +import { createRange } from './../../models/time_range'; export function RelatedEventsFetcher( dataAccessLayer: DataAccessLayer, @@ -26,6 +27,8 @@ export function RelatedEventsFetcher( const newParams = selectors.panelViewAndParameters(state); const isLoadingMoreEvents = selectors.isLoadingMoreNodeEventsInCategory(state); + const indices = selectors.treeParameterIndices(state); + const oldParams = last; // Update this each time before fetching data (or even if we don't fetch data) so that subsequent actions that call this (concurrently) will have up to date info. last = newParams; @@ -42,13 +45,20 @@ export function RelatedEventsFetcher( let result: ResolverPaginatedEvents | null = null; try { if (cursor) { - result = await dataAccessLayer.eventsWithEntityIDAndCategory( - nodeID, - eventCategory, - cursor - ); + result = await dataAccessLayer.eventsWithEntityIDAndCategory({ + entityID: nodeID, + category: eventCategory, + after: cursor, + indexPatterns: indices, + timeRange: createRange(), + }); } else { - result = await dataAccessLayer.eventsWithEntityIDAndCategory(nodeID, eventCategory); + result = await dataAccessLayer.eventsWithEntityIDAndCategory({ + entityID: nodeID, + category: eventCategory, + indexPatterns: indices, + timeRange: createRange(), + }); } } catch (error) { api.dispatch({ diff --git a/x-pack/plugins/security_solution/public/resolver/store/middleware/resolver_tree_fetcher.ts b/x-pack/plugins/security_solution/public/resolver/store/middleware/resolver_tree_fetcher.ts index aecdd6b92a463..414afa569af4e 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/middleware/resolver_tree_fetcher.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/middleware/resolver_tree_fetcher.ts @@ -5,11 +5,18 @@ */ import { Dispatch, MiddlewareAPI } from 'redux'; -import { ResolverTree, ResolverEntityIndex } from '../../../../common/endpoint/types'; - +import { + ResolverEntityIndex, + ResolverNode, + NewResolverTree, + ResolverSchema, +} from '../../../../common/endpoint/types'; import { ResolverState, DataAccessLayer } from '../../types'; import * as selectors from '../selectors'; import { ResolverAction } from '../actions'; +import { ancestorsRequestAmount, descendantsRequestAmount } from '../../models/resolver_tree'; +import { createRange } from './../../models/time_range'; + /** * A function that handles syncing ResolverTree data w/ the current entity ID. * This will make a request anytime the entityID changes (to something other than undefined.) @@ -22,7 +29,6 @@ export function ResolverTreeFetcher( api: MiddlewareAPI, ResolverState> ): () => void { let lastRequestAbortController: AbortController | undefined; - // Call this after each state change. // This fetches the ResolverTree for the current entityID // if the entityID changes while @@ -35,7 +41,10 @@ export function ResolverTreeFetcher( // calling abort will cause an action to be fired } else if (databaseParameters !== null) { lastRequestAbortController = new AbortController(); - let result: ResolverTree | undefined; + let entityIDToFetch: string | undefined; + let dataSource: string | undefined; + let dataSourceSchema: ResolverSchema | undefined; + let result: ResolverNode[] | undefined; // Inform the state that we've made the request. Without this, the middleware will try to make the request again // immediately. api.dispatch({ @@ -45,7 +54,7 @@ export function ResolverTreeFetcher( try { const matchingEntities: ResolverEntityIndex = await dataAccessLayer.entities({ _id: databaseParameters.databaseDocumentID, - indices: databaseParameters.indices ?? [], + indices: databaseParameters.indices, signal: lastRequestAbortController.signal, }); if (matchingEntities.length < 1) { @@ -56,11 +65,31 @@ export function ResolverTreeFetcher( }); return; } - const entityIDToFetch = matchingEntities[0].id; - result = await dataAccessLayer.resolverTree( - entityIDToFetch, - lastRequestAbortController.signal - ); + ({ id: entityIDToFetch, schema: dataSourceSchema, name: dataSource } = matchingEntities[0]); + + result = await dataAccessLayer.resolverTree({ + dataId: entityIDToFetch, + schema: dataSourceSchema, + timeRange: createRange(), + indices: databaseParameters.indices, + ancestors: ancestorsRequestAmount(dataSourceSchema), + descendants: descendantsRequestAmount(), + }); + + const resolverTree: NewResolverTree = { + originID: entityIDToFetch, + nodes: result, + }; + + api.dispatch({ + type: 'serverReturnedResolverData', + payload: { + result: resolverTree, + dataSource, + schema: dataSourceSchema, + parameters: databaseParameters, + }, + }); } catch (error) { // https://developer.mozilla.org/en-US/docs/Web/API/DOMException#exception-AbortError if (error instanceof DOMException && error.name === 'AbortError') { @@ -75,15 +104,6 @@ export function ResolverTreeFetcher( }); } } - if (result !== undefined) { - api.dispatch({ - type: 'serverReturnedResolverData', - payload: { - result, - parameters: databaseParameters, - }, - }); - } } }; } diff --git a/x-pack/plugins/security_solution/public/resolver/store/reducer.ts b/x-pack/plugins/security_solution/public/resolver/store/reducer.ts index 997a3d0ae6b38..095404a1c6841 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/reducer.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/reducer.ts @@ -23,8 +23,8 @@ const uiReducer: Reducer = ( if (action.type === 'serverReturnedResolverData') { const next: ResolverUIState = { ...state, - ariaActiveDescendant: action.payload.result.entityID, - selectedNode: action.payload.result.entityID, + ariaActiveDescendant: action.payload.result.originID, + selectedNode: action.payload.result.originID, }; return next; } else if (action.type === 'userFocusedOnResolverNode') { diff --git a/x-pack/plugins/security_solution/public/resolver/store/selectors.test.ts b/x-pack/plugins/security_solution/public/resolver/store/selectors.test.ts index d15274f0363ac..d1a9ddf8e76e1 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/selectors.test.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/selectors.test.ts @@ -13,8 +13,9 @@ import { mockTreeWith2AncestorsAndNoChildren, mockTreeWithNoAncestorsAnd2Children, } from '../mocks/resolver_tree'; -import { SafeResolverEvent } from '../../../common/endpoint/types'; +import { ResolverNode } from '../../../common/endpoint/types'; import { mockTreeFetcherParameters } from '../mocks/tree_fetcher_parameters'; +import { endpointSourceSchema } from './../mocks/tree_schema'; describe('resolver selectors', () => { const actions: ResolverAction[] = []; @@ -35,6 +36,7 @@ describe('resolver selectors', () => { const firstAncestorID = 'b'; const secondAncestorID = 'a'; beforeEach(() => { + const { schema, dataSource } = endpointSourceSchema(); actions.push({ type: 'serverReturnedResolverData', payload: { @@ -43,6 +45,8 @@ describe('resolver selectors', () => { firstAncestorID, secondAncestorID, }), + dataSource, + schema, // this value doesn't matter parameters: mockTreeFetcherParameters(), }, @@ -73,10 +77,18 @@ describe('resolver selectors', () => { const firstChildID = 'd'; const secondChildID = 'e'; beforeEach(() => { + const { resolverTree } = mockTreeWithNoAncestorsAnd2Children({ + originID, + firstChildID, + secondChildID, + }); + const { schema, dataSource } = endpointSourceSchema(); actions.push({ type: 'serverReturnedResolverData', payload: { - result: mockTreeWithNoAncestorsAnd2Children({ originID, firstChildID, secondChildID }), + result: resolverTree, + dataSource, + schema, // this value doesn't matter parameters: mockTreeFetcherParameters(), }, @@ -115,9 +127,9 @@ describe('resolver selectors', () => { const layout = selectors.layout(state()); // find the position of the second child - const secondChild = selectors.processEventForID(state())(secondChildID); + const secondChild = selectors.graphNodeForID(state())(secondChildID); const positionOfSecondChild = layout.processNodePositions.get( - secondChild as SafeResolverEvent + secondChild as ResolverNode )!; // the child is indexed by an AABB that extends -720/2 to the left @@ -132,27 +144,27 @@ describe('resolver selectors', () => { }); }); it('the origin should be in view', () => { - const origin = selectors.processEventForID(state())(originID)!; + const origin = selectors.graphNodeForID(state())(originID)!; expect( selectors .visibleNodesAndEdgeLines(state())(0) - .processNodePositions.has(origin as SafeResolverEvent) + .processNodePositions.has(origin as ResolverNode) ).toBe(true); }); it('the first child should be in view', () => { - const firstChild = selectors.processEventForID(state())(firstChildID)!; + const firstChild = selectors.graphNodeForID(state())(firstChildID)!; expect( selectors .visibleNodesAndEdgeLines(state())(0) - .processNodePositions.has(firstChild as SafeResolverEvent) + .processNodePositions.has(firstChild as ResolverNode) ).toBe(true); }); it('the second child should not be in view', () => { - const secondChild = selectors.processEventForID(state())(secondChildID)!; + const secondChild = selectors.graphNodeForID(state())(secondChildID)!; expect( selectors .visibleNodesAndEdgeLines(state())(0) - .processNodePositions.has(secondChild as SafeResolverEvent) + .processNodePositions.has(secondChild as ResolverNode) ).toBe(false); }); it('should return nothing as the flowto for the first child', () => { diff --git a/x-pack/plugins/security_solution/public/resolver/store/selectors.ts b/x-pack/plugins/security_solution/public/resolver/store/selectors.ts index 9a2ab53458a9c..6272c862e0f4d 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/selectors.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/selectors.ts @@ -8,9 +8,9 @@ import { createSelector, defaultMemoize } from 'reselect'; import * as cameraSelectors from './camera/selectors'; import * as dataSelectors from './data/selectors'; import * as uiSelectors from './ui/selectors'; -import { ResolverState, IsometricTaxiLayout } from '../types'; -import { ResolverNodeStats, SafeResolverEvent } from '../../../common/endpoint/types'; -import { entityIDSafeVersion } from '../../../common/endpoint/models/event'; +import { ResolverState, IsometricTaxiLayout, DataState } from '../types'; +import { EventStats } from '../../../common/endpoint/types'; +import * as nodeModel from '../../../common/endpoint/models/node'; /** * A matrix that when applied to a Vector2 will convert it from world coordinates to screen coordinates. @@ -53,31 +53,6 @@ export const userIsPanning = composeSelectors(cameraStateSelector, cameraSelecto */ export const isAnimating = composeSelectors(cameraStateSelector, cameraSelectors.isAnimating); -/** - * Whether or not a given entity id is in the set of termination events. - */ -export const isProcessTerminated = composeSelectors( - dataStateSelector, - dataSelectors.isProcessTerminated -); - -/** - * Retrieve an event from memory using the event's ID. - */ -export const eventByID = composeSelectors(dataStateSelector, dataSelectors.eventByID); - -/** - * Given a nodeID (aka entity_id) get the indexed process event. - * Legacy functions take process events instead of nodeID, use this to get - * process events for them. - */ -export const processEventForID: ( - state: ResolverState -) => (nodeID: string) => SafeResolverEvent | null = composeSelectors( - dataStateSelector, - dataSelectors.processEventForID -); - /** * The position of nodes and edges. */ @@ -99,24 +74,27 @@ export const treeRequestParametersToAbort = composeSelectors( dataSelectors.treeRequestParametersToAbort ); -export const resolverComponentInstanceID = composeSelectors( +/** + * This should be the siem default indices to pass to the backend for querying data. + */ +export const treeParameterIndices = composeSelectors( dataStateSelector, - dataSelectors.resolverComponentInstanceID + dataSelectors.treeParamterIndices ); -export const terminatedProcesses = composeSelectors( +export const resolverComponentInstanceID = composeSelectors( dataStateSelector, - dataSelectors.terminatedProcesses + dataSelectors.resolverComponentInstanceID ); /** - * Returns a map of `ResolverEvent` entity_id to their related event and alert statistics + * This returns a map of nodeIDs to the associated stats provided by the datasource. */ -export const relatedEventsStats: ( +export const nodeStats: ( state: ResolverState -) => (nodeID: string) => ResolverNodeStats | undefined = composeSelectors( +) => (nodeID: string) => EventStats | undefined = composeSelectors( dataStateSelector, - dataSelectors.relatedEventsStats + dataSelectors.nodeStats ); /** @@ -154,25 +132,6 @@ export const currentRelatedEventData = composeSelectors( dataSelectors.currentRelatedEventData ); -/** - * Map of related events... by entity id - * @deprecated - */ -export const relatedEventsByEntityId = composeSelectors( - dataStateSelector, - dataSelectors.relatedEventsByEntityId -); - -/** - * Returns a function that returns a function (when supplied with an entity id for a node) - * that returns related events for a node that match an event.category (when supplied with the category) - * @deprecated - */ -export const relatedEventsByCategory = composeSelectors( - dataStateSelector, - dataSelectors.relatedEventsByCategory -); - /** * Returns the id of the "current" tree node (fake-focused) */ @@ -221,23 +180,28 @@ export const hadErrorLoadingTree = composeSelectors( ); /** - * True if the children cursor is not null + * True there might be more descendants to retrieve in the resolver graph. */ export const hasMoreChildren = composeSelectors(dataStateSelector, dataSelectors.hasMoreChildren); /** - * True if the ancestor cursor is not null + * True if there might be more ancestors to retrieve in the resolver graph. */ export const hasMoreAncestors = composeSelectors(dataStateSelector, dataSelectors.hasMoreAncestors); /** - * An array containing all the processes currently in the Resolver than can be graphed + * True if there might be more generations to retrieve in the resolver graph. */ -export const graphableProcesses = composeSelectors( +export const hasMoreGenerations = composeSelectors( dataStateSelector, - dataSelectors.graphableProcesses + dataSelectors.hasMoreGenerations ); +/** + * An array containing all the processes currently in the Resolver than can be graphed + */ +export const graphableNodes = composeSelectors(dataStateSelector, dataSelectors.graphableNodes); + const boundingBox = composeSelectors(cameraStateSelector, cameraSelectors.viewableBoundingBox); const nodesAndEdgelines = composeSelectors(dataStateSelector, dataSelectors.nodesAndEdgelines); @@ -246,9 +210,9 @@ const nodesAndEdgelines = composeSelectors(dataStateSelector, dataSelectors.node * Total count of related events for a process. * @deprecated */ -export const relatedEventTotalForProcess = composeSelectors( +export const statsTotalForNode = composeSelectors( dataStateSelector, - dataSelectors.relatedEventTotalForProcess + dataSelectors.statsTotalForNode ); /** @@ -301,8 +265,8 @@ export const ariaFlowtoNodeID: ( // get a `Set` containing their node IDs const nodesVisibleAtTime: Set = new Set(); // NB: in practice, any event that has been graphed is guaranteed to have an entity_id - for (const visibleEvent of processNodePositions.keys()) { - const nodeID = entityIDSafeVersion(visibleEvent); + for (const visibleNode of processNodePositions.keys()) { + const nodeID = nodeModel.nodeID(visibleNode); if (nodeID !== undefined) { nodesVisibleAtTime.add(nodeID); } @@ -395,6 +359,57 @@ export const isLoadingMoreNodeEventsInCategory = composeSelectors( dataSelectors.isLoadingMoreNodeEventsInCategory ); +/** + * Returns the state of the node, loading, running, or terminated. + */ +export const nodeDataStatus = composeSelectors(dataStateSelector, dataSelectors.nodeDataStatus); + +/** + * Returns the node data object for a specific node ID. + */ +export const nodeDataForID = composeSelectors(dataStateSelector, dataSelectors.nodeDataForID); + +/** + * Returns the graph node for a given ID + */ +export const graphNodeForID = composeSelectors(dataStateSelector, dataSelectors.graphNodeForID); + +/** + * Returns a Set of node IDs representing the visible nodes in the view that we do no have node data for already. + */ +export const newIDsToRequest: ( + state: ResolverState +) => (time: number) => Set = createSelector( + composeSelectors(dataStateSelector, (dataState: DataState) => dataState.nodeData), + visibleNodesAndEdgeLines, + function (nodeData, visibleNodesAndEdgeLinesAtTime) { + return defaultMemoize((time: number) => { + const { processNodePositions: nodesInView } = visibleNodesAndEdgeLinesAtTime(time); + + const nodes: Set = new Set(); + // loop through the nodes in view and see if any of them are new aka we don't have node data for them already + for (const node of nodesInView.keys()) { + const id = nodeModel.nodeID(node); + // if the node has a valid ID field, and we either don't have any node data currently, or + // the map doesn't have info for this particular node, then add it to the set so it'll be requested + // by the middleware + if (id !== undefined && (!nodeData || !nodeData.has(id))) { + nodes.add(id); + } + } + return nodes; + }); + } +); + +/** + * Returns the schema for the current resolver tree. Currently, only used in the graph controls panel. + */ +export const resolverTreeSourceAndSchema = composeSelectors( + dataStateSelector, + dataSelectors.resolverTreeSourceAndSchema +); + /** * Calls the `secondSelector` with the result of the `selector`. Use this when re-exporting a * concern-specific selector. `selector` should return the concern-specific state. diff --git a/x-pack/plugins/security_solution/public/resolver/test_utilities/spy_middleware_factory.ts b/x-pack/plugins/security_solution/public/resolver/test_utilities/spy_middleware_factory.ts index 45730531cf467..e94095d4884ea 100644 --- a/x-pack/plugins/security_solution/public/resolver/test_utilities/spy_middleware_factory.ts +++ b/x-pack/plugins/security_solution/public/resolver/test_utilities/spy_middleware_factory.ts @@ -47,7 +47,12 @@ export const spyMiddlewareFactory: () => SpyMiddleware = () => { break; } // eslint-disable-next-line no-console - console.log('action', actionStatePair.action, 'state', actionStatePair.state); + console.log( + 'action', + JSON.stringify(actionStatePair.action, null, 2), + 'state', + JSON.stringify(actionStatePair.state, null, 2) + ); } })(); return () => { diff --git a/x-pack/plugins/security_solution/public/resolver/types.ts b/x-pack/plugins/security_solution/public/resolver/types.ts index 6cb25861a7b58..82ec7d1eee67e 100644 --- a/x-pack/plugins/security_solution/public/resolver/types.ts +++ b/x-pack/plugins/security_solution/public/resolver/types.ts @@ -12,12 +12,15 @@ import { BBox } from 'rbush'; import { Provider } from 'react-redux'; import { ResolverAction } from './store/actions'; import { + ResolverNode, ResolverRelatedEvents, - ResolverTree, ResolverEntityIndex, SafeResolverEvent, ResolverPaginatedEvents, + NewResolverTree, + ResolverSchema, } from '../../common/endpoint/types'; +import { Tree } from '../../common/endpoint/generate_data'; /** * Redux state for the Resolver feature. Properties on this interface are populated via multiple reducers using redux's `combineReducers`. @@ -152,7 +155,7 @@ export type CameraState = { /** * Wrappers around our internal types that make them compatible with `rbush`. */ -export type IndexedEntity = IndexedEdgeLineSegment | IndexedProcessNode; +export type IndexedEntity = IndexedEdgeLineSegment | IndexedTreeNode; /** * The entity stored in `rbush` for resolver edge lines. @@ -165,9 +168,9 @@ export interface IndexedEdgeLineSegment extends BBox { /** * The entity store in `rbush` for resolver process nodes. */ -export interface IndexedProcessNode extends BBox { - type: 'processNode'; - entity: SafeResolverEvent; +export interface IndexedTreeNode extends BBox { + type: 'treeNode'; + entity: ResolverNode; position: Vector2; } @@ -191,7 +194,7 @@ export interface CrumbInfo { * A type containing all things to actually be rendered to the DOM. */ export interface VisibleEntites { - processNodePositions: ProcessPositions; + processNodePositions: NodePositions; connectingEdgeLineSegments: EdgeLineSegment[]; } @@ -240,6 +243,57 @@ export interface NodeEventsInCategoryState { error?: boolean; } +/** + * Return structure for the mock DAL returned by this file. + */ +export interface GeneratedTreeMetadata { + /** + * The `_id` of the document being analyzed. + */ + databaseDocumentID: string; + /** + * This field holds the nodes created by the resolver generator that make up a resolver graph. + */ + generatedTree: Tree; + /** + * The nodes in this tree are equivalent to those in the generatedTree field. This nodes + * are just structured in a way that they match the NewResolverTree type. This helps with the + * Data Access Layer that is expecting to return a NewResolverTree type. + */ + formattedTree: NewResolverTree; +} + +/** + * The state of the process cubes in the graph. + * + * 'running' if the process represented by the node is still running. + * 'loading' if we don't have the data yet to determine if the node is running or terminated. + * 'terminated' if the process represented by the node is terminated. + * 'error' if we were unable to retrieve data associated with the node. + */ +export type NodeDataStatus = 'running' | 'loading' | 'terminated' | 'error'; + +/** + * Defines the data structure used by the node data middleware. The middleware creates a map of node IDs to this + * structure before dispatching the action to the reducer. + */ +export interface FetchedNodeData { + events: SafeResolverEvent[]; + terminated: boolean; +} + +/** + * NodeData contains information about a node in the resolver graph. For Endpoint + * graphs, the events will be process lifecycle events. + */ +export interface NodeData { + events: SafeResolverEvent[]; + /** + * An indication of the current state for retrieving the data. + */ + status: NodeDataStatus; +} + /** * State for `data` reducer which handles receiving Resolver data from the back-end. */ @@ -290,9 +344,17 @@ export interface DataState { */ readonly successful: true; /** - * The ResolverTree parsed from the response. + * The NewResolverTree parsed from the response. + */ + readonly result: NewResolverTree; + /** + * The current data source (i.e. endpoint, winlogbeat, etc...) + */ + readonly dataSource: string; + /** + * The Resolver Schema for the current data source */ - readonly result: ResolverTree; + readonly schema: ResolverSchema; } | { /** @@ -313,6 +375,14 @@ export interface DataState { * The `search` part of the URL. */ readonly locationSearch?: string; + + /** + * The additional data for each node in the graph. For an Endpoint graph the data will be + * process lifecycle events. + * + * If a node ID exists in the map it means that node came into view in the graph. + */ + readonly nodeData?: Map; } /** @@ -384,21 +454,46 @@ export interface IndexedProcessTree { /** * Map of ID to a process's ordered children */ - idToChildren: Map; + idToChildren: Map; /** * Map of ID to process */ - idToProcess: Map; + idToNode: Map; + /** + * The id of the origin or root node provided by the backend + */ + originID: string | undefined; + /** + * The number of generations from the origin in the tree. If the origin has no descendants, then this value will be + * zero. The origin of the graph is the analyzed event, not necessarily the root node of the tree. + * + * If the originID is not defined then the generations will be undefined. + */ + generations: number | undefined; + /** + * The number of descendants from the origin of the graph. The origin of the graph is the analyzed event, not + * necessarily the root node of the tree. + * + * If the originID is not defined then the descendants will be undefined. + */ + descendants: number | undefined; + /** + * The number of ancestors from the origin of the graph. The amount includes the origin. The origin of the graph is + * analyzed event. + * + * If the originID is not defined the ancestors will be undefined. + */ + ancestors: number | undefined; } /** - * A map of `ProcessEvents` (representing process nodes) to the 'width' of their subtrees as calculated by `widthsOfProcessSubtrees` + * A map of `ProcessEvents` (representing process nodes) to the 'width' of their subtrees as calculated by `calculateSubgraphWidths` */ -export type ProcessWidths = Map; +export type ProcessWidths = Map; /** - * Map of ProcessEvents (representing process nodes) to their positions. Calculated by `processPositions` + * Map of ProcessEvents (representing process nodes) to their positions. Calculated by `calculateNodePositions` */ -export type ProcessPositions = Map; +export type NodePositions = Map; export type DurationTypes = | 'millisecond' @@ -449,14 +544,14 @@ export interface EdgeLineSegment { } /** - * Used to provide pre-calculated info from `widthsOfProcessSubtrees`. These 'width' values are used in the layout of the graph. + * Used to provide pre-calculated info from `calculateSubgraphWidths`. These 'width' values are used in the layout of the graph. */ export type ProcessWithWidthMetadata = { - process: SafeResolverEvent; + node: ResolverNode; width: number; } & ( | { - parent: SafeResolverEvent; + parent: ResolverNode; parentWidth: number; isOnlyChild: boolean; firstChildWidth: number; @@ -555,6 +650,8 @@ export type ResolverProcessType = | 'processTerminated' | 'unknownProcessEvent' | 'processCausedAlert' + | 'processLoading' + | 'processError' | 'unknownEvent'; export type ResolverStore = Store; @@ -566,7 +663,7 @@ export interface IsometricTaxiLayout { /** * A map of events to position. Each event represents its own node. */ - processNodePositions: Map; + processNodePositions: Map; /** * A map of edge-line segments, which graphically connect nodes. @@ -576,7 +673,15 @@ export interface IsometricTaxiLayout { /** * defines the aria levels for nodes. */ - ariaLevels: Map; + ariaLevels: Map; +} + +/** + * Defines the type for bounding a search by a time box. + */ +export interface TimeRange { + from: Date; + to: Date; } /** @@ -589,27 +694,89 @@ export interface DataAccessLayer { /** * Fetch related events for an entity ID */ - relatedEvents: (entityID: string) => Promise; + relatedEvents: ({ + entityID, + timeRange, + indexPatterns, + }: { + entityID: string; + timeRange: TimeRange; + indexPatterns: string[]; + }) => Promise; /** * Return events that have `process.entity_id` that includes `entityID` and that have * a `event.category` that includes `category`. */ - eventsWithEntityIDAndCategory: ( - entityID: string, - category: string, - after?: string - ) => Promise; + eventsWithEntityIDAndCategory: ({ + entityID, + category, + after, + timeRange, + indexPatterns, + }: { + entityID: string; + category: string; + after?: string; + timeRange: TimeRange; + indexPatterns: string[]; + }) => Promise; + + /** + * Retrieves the node data for a set of node IDs. This is specifically for Endpoint graphs. It + * only returns process lifecycle events. + */ + nodeData({ + ids, + timeRange, + indexPatterns, + limit, + }: { + ids: string[]; + timeRange: TimeRange; + indexPatterns: string[]; + limit: number; + }): Promise; /** * Return up to one event that has an `event.id` that includes `eventID`. */ - event: (eventID: string) => Promise; - - /** - * Fetch a ResolverTree for a entityID - */ - resolverTree: (entityID: string, signal: AbortSignal) => Promise; + event: ({ + nodeID, + eventCategory, + eventTimestamp, + eventID, + timeRange, + indexPatterns, + winlogRecordID, + }: { + nodeID: string; + eventCategory: string[]; + eventTimestamp: string; + eventID?: string | number; + winlogRecordID: string; + timeRange: TimeRange; + indexPatterns: string[]; + }) => Promise; + + /** + * Fetch a resolver graph for a given id. + */ + resolverTree({ + dataId, + schema, + timeRange, + indices, + ancestors, + descendants, + }: { + dataId: string; + schema: ResolverSchema; + timeRange: TimeRange; + indices: string[]; + ancestors: number; + descendants: number; + }): Promise; /** * Get entities matching a document. @@ -797,6 +964,18 @@ export type PanelViewAndParameters = /** * `event.id` that uniquely identifies the event to show. */ - eventID: string; + eventID?: string | number; + + /** + * `event['@timestamp']` that identifies the given timestamp for an event + */ + eventTimestamp: string; + + /** + * `winlog.record_id` an ID that unique identifies a winlogbeat sysmon event. This is not a globally unique field + * and must be coupled with nodeID, category, and timestamp. Once we have runtime fields support we should remove + * this. + */ + winlogRecordID: string; }; }; diff --git a/x-pack/plugins/security_solution/public/resolver/view/clickthrough.test.tsx b/x-pack/plugins/security_solution/public/resolver/view/clickthrough.test.tsx index 198f0dc7905e9..c0105cff63fed 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/clickthrough.test.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/clickthrough.test.tsx @@ -11,7 +11,10 @@ import { Simulator } from '../test_utilities/simulator'; import '../test_utilities/extend_jest'; import { noAncestorsTwoChildrenWithRelatedEventsOnOrigin } from '../data_access_layer/mocks/no_ancestors_two_children_with_related_events_on_origin'; import { urlSearch } from '../test_utilities/url_search'; -import { Vector2, AABB } from '../types'; +import { Vector2, AABB, TimeRange, DataAccessLayer } from '../types'; +import { generateTreeWithDAL } from '../data_access_layer/mocks/generator_tree'; +import { ReactWrapper } from 'enzyme'; +import { SafeResolverEvent } from '../../../common/endpoint/types'; let simulator: Simulator; let databaseDocumentID: string; @@ -139,7 +142,7 @@ describe('Resolver, when analyzing a tree that has no ancestors and 2 children', await expect( simulator.map(() => { /** - * This test verifies corectness w.r.t. the tree/treeitem roles + * This test verifies correctness w.r.t. the tree/treeitem roles * From W3C: `Authors MUST ensure elements with role treeitem are contained in, or owned by, an element with the role group or tree.` * * https://www.w3.org/TR/wai-aria-1.1/#tree @@ -208,6 +211,207 @@ describe('Resolver, when analyzing a tree that has no ancestors and 2 children', }); }); +describe('Resolver, when using a generated tree with 20 generations, 4 children per child, and 10 ancestors', () => { + const findAndClickFirstLoadingNodeInPanel = async (graphSimulator: Simulator) => { + // If the camera has not moved it will return a node with ID 2kt059pl3i, this is the first node with the state + // loading that is outside of the initial loaded view + const getLoadingNodeInList = async () => { + return (await graphSimulator.resolve('resolver:node-list:node-link')) + ?.findWhere((wrapper) => wrapper.text().toLowerCase().includes('loading')) + ?.first(); + }; + + const loadingNode = await getLoadingNodeInList(); + + if (!loadingNode) { + throw new Error("Unable to find a node without it's node data"); + } + loadingNode.simulate('click', { button: 0 }); + // the time here is equivalent to the animation duration in the camera reducer + graphSimulator.runAnimationFramesTimeFromNow(1000); + }; + + const firstLoadingNodeInListID = '2kt059pl3i'; + + const identifiedLoadingNodeInGraph: ( + graphSimulator: Simulator + ) => Promise = async (graphSimulator: Simulator) => + graphSimulator.resolveWrapper(() => + graphSimulator.selectedProcessNode(firstLoadingNodeInListID) + ); + + const identifiedLoadingNodeInGraphState: ( + graphSimulator: Simulator + ) => Promise = async (graphSimulator: Simulator) => + ( + await graphSimulator.resolveWrapper(() => + graphSimulator.selectedProcessNode(firstLoadingNodeInListID) + ) + ) + ?.find('[data-test-subj="resolver:node:description"]') + .first() + .text(); + + let generatorDAL: DataAccessLayer; + + beforeEach(async () => { + const { metadata: dataAccessLayerMetadata, dataAccessLayer } = generateTreeWithDAL({ + ancestors: 3, + children: 3, + generations: 4, + }); + + generatorDAL = dataAccessLayer; + // save a reference to the `_id` supported by the mock data layer + databaseDocumentID = dataAccessLayerMetadata.databaseDocumentID; + }); + + describe('when clicking on a node in the panel whose node data has not yet been loaded and using a data access layer that returns an error for the clicked node', () => { + let throwError: boolean; + beforeEach(async () => { + // all the tests in this describe block will receive an error when loading data for the firstLoadingNodeInListID + // unless the tests explicitly sets this flag to false + throwError = true; + const nodeDataError = ({ + ids, + timeRange, + indexPatterns, + limit, + }: { + ids: string[]; + timeRange: TimeRange; + indexPatterns: string[]; + limit: number; + }): Promise => { + if (throwError && ids.includes(firstLoadingNodeInListID)) { + throw new Error( + 'simulated error for retrieving first loading node in the process node list' + ); + } + + return generatorDAL.nodeData({ ids, timeRange, indexPatterns, limit }); + }; + + // create a simulator using most of the generator's data access layer, but let's use our nodeDataError + // so we can simulator an error when loading data + simulator = new Simulator({ + databaseDocumentID, + dataAccessLayer: { ...generatorDAL, nodeData: nodeDataError }, + resolverComponentInstanceID, + indices: [], + }); + + await findAndClickFirstLoadingNodeInPanel(simulator); + }); + + it('should receive an error while loading the node data', async () => { + throwError = true; + + await expect( + simulator.map(async () => ({ + nodeState: await identifiedLoadingNodeInGraphState(simulator), + })) + ).toYieldEqualTo({ + nodeState: 'Error Process', + }); + }); + + describe('when completing the navigation to the node that is in an error state and clicking the reload data button', () => { + beforeEach(async () => { + throwError = true; + // ensure that the node is in view + await identifiedLoadingNodeInGraph(simulator); + // at this point the node's state should be error + + // don't throw an error now, so we can test that the reload button actually loads the data correctly + throwError = false; + const firstLoadingNodeInListButton = await simulator.resolveWrapper(() => + simulator.processNodePrimaryButton(firstLoadingNodeInListID) + ); + // Click the primary button to reload the node's data + if (firstLoadingNodeInListButton) { + firstLoadingNodeInListButton.simulate('click', { button: 0 }); + } + }); + + it('should load data after receiving an error', async () => { + // we should receive the node's data now so we'll know that it is terminated + await expect( + simulator.map(async () => ({ + nodeState: await identifiedLoadingNodeInGraphState(simulator), + })) + ).toYieldEqualTo({ + nodeState: 'Terminated Process', + }); + }); + }); + }); + + describe('when clicking on a node in the process panel that is not loaded', () => { + beforeEach(async () => { + simulator = new Simulator({ + databaseDocumentID, + dataAccessLayer: generatorDAL, + resolverComponentInstanceID, + indices: [], + }); + + await findAndClickFirstLoadingNodeInPanel(simulator); + }); + + it('should load the node data for the process and mark the process node as terminated in the graph', async () => { + await expect( + simulator.map(async () => ({ + nodeState: await identifiedLoadingNodeInGraphState(simulator), + })) + ).toYieldEqualTo({ + nodeState: 'Terminated Process', + }); + }); + + describe('when finishing the navigation to the node that is not loaded and navigating back to the process list in the panel', () => { + beforeEach(async () => { + // make sure the node is in view + await identifiedLoadingNodeInGraph(simulator); + + const breadcrumbs = await simulator.resolve( + 'resolver:node-detail:breadcrumbs:node-list-link' + ); + + // navigate back to the node list in the panel + if (breadcrumbs) { + breadcrumbs.simulate('click', { button: 0 }); + } + }); + + it('should load the node data and mark it as terminated in the node list', async () => { + const getNodeInPanelList = async () => { + // grab the node in the list that has the ID that we're looking for + return ( + (await simulator.resolve('resolver:node-list:node-link')) + ?.findWhere( + (wrapper) => wrapper.prop('data-test-node-id') === firstLoadingNodeInListID + ) + ?.first() + // grab the description tag so we can determine the state of the process + .find('desc') + .first() + ); + }; + + // check that the panel displays the node as terminated as well + await expect( + simulator.map(async () => ({ + nodeState: (await getNodeInPanelList())?.text(), + })) + ).toYieldEqualTo({ + nodeState: 'Terminated Process', + }); + }); + }); + }); +}); + describe('Resolver, when analyzing a tree that has 2 related registry and 1 related event of all other categories for the origin node', () => { beforeEach(async () => { // create a mock data access layer with related events diff --git a/x-pack/plugins/security_solution/public/resolver/view/graph_controls.test.tsx b/x-pack/plugins/security_solution/public/resolver/view/graph_controls.test.tsx index 95fe68d95d702..d8743d3b3ebd6 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/graph_controls.test.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/graph_controls.test.tsx @@ -81,6 +81,20 @@ describe('graph controls: when relsover is loaded with an origin node', () => { }); }); + it('should display the legend and schema popover buttons', async () => { + await expect( + simulator.map(() => ({ + schemaInfoButton: simulator.testSubject('resolver:graph-controls:schema-info-button') + .length, + nodeLegendButton: simulator.testSubject('resolver:graph-controls:node-legend-button') + .length, + })) + ).toYieldEqualTo({ + schemaInfoButton: 1, + nodeLegendButton: 1, + }); + }); + it("should show the origin node in it's original position", async () => { await expect(originNodeStyle()).toYieldObjectEqualTo(originalPositionStyle); }); @@ -219,4 +233,66 @@ describe('graph controls: when relsover is loaded with an origin node', () => { }); }); }); + + describe('when the schema information button is clicked', () => { + beforeEach(async () => { + (await simulator.resolve('resolver:graph-controls:schema-info-button'))!.simulate('click', { + button: 0, + }); + }); + + it('should show the schema information table with the expected values', async () => { + await expect( + simulator.map(() => + simulator + .testSubject('resolver:graph-controls:schema-info:description') + .map((description) => description.text()) + ) + ).toYieldEqualTo(['endpoint', 'process.entity_id', 'process.parent.entity_id']); + }); + }); + + describe('when the node legend button is clicked', () => { + beforeEach(async () => { + (await simulator.resolve('resolver:graph-controls:node-legend-button'))!.simulate('click', { + button: 0, + }); + }); + + it('should show the node legend table with the expected values', async () => { + await expect( + simulator.map(() => + simulator + .testSubject('resolver:graph-controls:node-legend:description') + .map((description) => description.text()) + ) + ).toYieldEqualTo(['Running Process', 'Terminated Process', 'Loading Process', 'Error']); + }); + }); + + describe('when the node legend button is clicked while the schema info button is open', () => { + beforeEach(async () => { + (await simulator.resolve('resolver:graph-controls:schema-info-button'))!.simulate('click', { + button: 0, + }); + }); + + it('should close the schema information table and open the node legend table', async () => { + expect(simulator.testSubject('resolver:graph-controls:schema-info').length).toBe(1); + + await simulator + .testSubject('resolver:graph-controls:node-legend-button')! + .simulate('click', { button: 0 }); + + await expect( + simulator.map(() => ({ + nodeLegend: simulator.testSubject('resolver:graph-controls:node-legend').length, + schemaInfo: simulator.testSubject('resolver:graph-controls:schema-info').length, + })) + ).toYieldObjectEqualTo({ + nodeLegend: 1, + schemaInfo: 0, + }); + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/resolver/view/graph_controls.tsx b/x-pack/plugins/security_solution/public/resolver/view/graph_controls.tsx index dbeca840a4b66..bd84aa8260495 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/graph_controls.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/graph_controls.tsx @@ -5,30 +5,80 @@ */ /* eslint-disable react/display-name */ - /* eslint-disable react/button-has-type */ -import React, { useCallback, useMemo, useContext } from 'react'; +import React, { useCallback, useMemo, useContext, useState } from 'react'; import styled from 'styled-components'; -import { EuiRange, EuiPanel, EuiIcon } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { + EuiRange, + EuiPanel, + EuiIcon, + EuiButtonIcon, + EuiPopover, + EuiPopoverTitle, + EuiIconTip, + EuiDescriptionListTitle, + EuiDescriptionListDescription, +} from '@elastic/eui'; import { useSelector, useDispatch } from 'react-redux'; import { SideEffectContext } from './side_effect_context'; import { Vector2 } from '../types'; import * as selectors from '../store/selectors'; import { ResolverAction } from '../store/actions'; import { useColors } from './use_colors'; +import { StyledDescriptionList } from './panels/styles'; +import { CubeForProcess } from './panels/cube_for_process'; +import { GeneratedText } from './generated_text'; -interface StyledGraphControls { - graphControlsBackground: string; - graphControlsIconColor: string; +interface StyledGraphControlProps { + $backgroundColor: string; + $iconColor: string; + $borderColor: string; } -const StyledGraphControls = styled.div` +const StyledGraphControlsColumn = styled.div` + display: flex; + flex-direction: column; + + &:not(last-of-type) { + margin-right: 5px; + } +`; + +const StyledEuiDescriptionListTitle = styled(EuiDescriptionListTitle)` + text-transform: uppercase; + max-width: 25%; +`; + +const StyledEuiDescriptionListDescription = styled(EuiDescriptionListDescription)` + min-width: 75%; + width: 75%; +`; + +const StyledEuiButtonIcon = styled(EuiButtonIcon)` + background-color: ${(props) => props.$backgroundColor}; + color: ${(props) => props.$iconColor}; + border-color: ${(props) => props.$borderColor}; + border-width: 1px; + border-style: solid; + border-radius: 4px; + width: 40px; + height: 40px; + + &:not(last-of-type) { + margin-bottom: 7px; + } +`; + +const StyledGraphControls = styled.div>` + display: flex; + flex-direction: row; position: absolute; top: 5px; right: 5px; - background-color: ${(props) => props.graphControlsBackground}; - color: ${(props) => props.graphControlsIconColor}; + background-color: transparent; + color: ${(props) => props.$iconColor}; .zoom-controls { display: flex; @@ -56,6 +106,7 @@ const StyledGraphControls = styled.div` /** * Controls for zooming, panning, and centering in Resolver */ + export const GraphControls = React.memo( ({ className, @@ -68,8 +119,22 @@ export const GraphControls = React.memo( const dispatch: (action: ResolverAction) => unknown = useDispatch(); const scalingFactor = useSelector(selectors.scalingFactor); const { timestamp } = useContext(SideEffectContext); + const [activePopover, setPopover] = useState(null); const colorMap = useColors(); + const setActivePopover = useCallback( + (value) => { + if (value === activePopover) { + setPopover(null); + } else { + setPopover(value); + } + }, + [setPopover, activePopover] + ); + + const closePopover = useCallback(() => setPopover(null), []); + const handleZoomAmountChange = useCallback( (event: React.ChangeEvent | React.MouseEvent) => { const valueAsNumber = parseFloat( @@ -125,84 +190,385 @@ export const GraphControls = React.memo( return ( - -
- -
-
- - + + + + + + +
+ +
+
+ + + +
+
+ +
+
+ -
-
+ -
-
- - - - - + +
); } ); + +const SchemaInformation = ({ + closePopover, + setActivePopover, + isOpen, +}: { + closePopover: () => void; + setActivePopover: (value: 'schemaInfo' | null) => void; + isOpen: boolean; +}) => { + const colorMap = useColors(); + const sourceAndSchema = useSelector(selectors.resolverTreeSourceAndSchema); + const setAsActivePopover = useCallback(() => setActivePopover('schemaInfo'), [setActivePopover]); + + const schemaInfoButtonTitle = i18n.translate( + 'xpack.securitySolution.resolver.graphControls.schemaInfoButtonTitle', + { + defaultMessage: 'Schema Information', + } + ); + + const unknownSchemaValue = i18n.translate( + 'xpack.securitySolution.resolver.graphControls.unknownSchemaValue', + { + defaultMessage: 'Unknown', + } + ); + + return ( + + } + isOpen={isOpen} + closePopover={closePopover} + anchorPosition="leftCenter" + > + + {i18n.translate('xpack.securitySolution.resolver.graphControls.schemaInfoTitle', { + defaultMessage: 'process tree', + })} + + +
+ + <> + + {i18n.translate('xpack.securitySolution.resolver.graphControls.schemaSource', { + defaultMessage: 'source', + })} + + + {sourceAndSchema?.dataSource ?? unknownSchemaValue} + + + {i18n.translate('xpack.securitySolution.resolver.graphControls.schemaID', { + defaultMessage: 'id', + })} + + + {sourceAndSchema?.schema.id ?? unknownSchemaValue} + + + {i18n.translate('xpack.securitySolution.resolver.graphControls.schemaEdge', { + defaultMessage: 'edge', + })} + + + {sourceAndSchema?.schema.parent ?? unknownSchemaValue} + + + +
+
+ ); +}; + +// This component defines the cube legend that allows users to identify the meaning of the cubes +// Should be updated to be dynamic if and when non process based resolvers are possible +const NodeLegend = ({ + closePopover, + setActivePopover, + isOpen, +}: { + closePopover: () => void; + setActivePopover: (value: 'nodeLegend') => void; + isOpen: boolean; +}) => { + const setAsActivePopover = useCallback(() => setActivePopover('nodeLegend'), [setActivePopover]); + const colorMap = useColors(); + + const nodeLegendButtonTitle = i18n.translate( + 'xpack.securitySolution.resolver.graphControls.nodeLegendButtonTitle', + { + defaultMessage: 'Node Legend', + } + ); + + return ( + + } + isOpen={isOpen} + closePopover={closePopover} + anchorPosition="leftCenter" + > + + {i18n.translate('xpack.securitySolution.resolver.graphControls.nodeLegend', { + defaultMessage: 'legend', + })} + +
+ + <> + + + + + + {i18n.translate( + 'xpack.securitySolution.resolver.graphControls.runningProcessCube', + { + defaultMessage: 'Running Process', + } + )} + + + + + + + + {i18n.translate( + 'xpack.securitySolution.resolver.graphControls.terminatedProcessCube', + { + defaultMessage: 'Terminated Process', + } + )} + + + + + + + + {i18n.translate( + 'xpack.securitySolution.resolver.graphControls.currentlyLoadingCube', + { + defaultMessage: 'Loading Process', + } + )} + + + + + + + + {i18n.translate('xpack.securitySolution.resolver.graphControls.errorCube', { + defaultMessage: 'Error', + })} + + + + +
+
+ ); +}; diff --git a/x-pack/plugins/security_solution/public/resolver/view/panels/cube_for_process.tsx b/x-pack/plugins/security_solution/public/resolver/view/panels/cube_for_process.tsx index cc5f39e985d9e..99c57757fbb6a 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/panels/cube_for_process.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/panels/cube_for_process.tsx @@ -17,40 +17,44 @@ interface StyledSVGCube { } import { useCubeAssets } from '../use_cube_assets'; import { useSymbolIDs } from '../use_symbol_ids'; +import { NodeDataStatus } from '../../types'; /** * Icon representing a process node. */ export const CubeForProcess = memo(function ({ className, - running, + size = '2.15em', + state, isOrigin, 'data-test-subj': dataTestSubj, }: { 'data-test-subj'?: string; /** - * True if the process represented by the node is still running. + * The state of the process's node data (for endpoint the process's lifecycle events) */ - running: boolean; + state: NodeDataStatus; + /** The css size (px, em, etc...) for the width and height of the svg cube. Defaults to 2.15em */ + size?: string; isOrigin?: boolean; className?: string; }) { - const { cubeSymbol, strokeColor } = useCubeAssets(!running, false); + const { cubeSymbol, strokeColor } = useCubeAssets(state, false); const { processCubeActiveBacking } = useSymbolIDs(); return ( {i18n.translate('xpack.securitySolution.resolver.node_icon', { - defaultMessage: '{running, select, true {Running Process} false {Terminated Process}}', - values: { running }, + defaultMessage: `{state, select, running {Running Process} terminated {Terminated Process} loading {Loading Process} error {Error Process}}`, + values: { state }, })} {isOrigin && ( diff --git a/x-pack/plugins/security_solution/public/resolver/view/panels/event_detail.tsx b/x-pack/plugins/security_solution/public/resolver/view/panels/event_detail.tsx index 4936cf0cbb80e..003182bd5f1b7 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/panels/event_detail.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/panels/event_detail.tsx @@ -29,6 +29,7 @@ import { useLinkProps } from '../use_link_props'; import { SafeResolverEvent } from '../../../../common/endpoint/types'; import { deepObjectEntries } from './deep_object_entries'; import { useFormattedDate } from './use_formatted_date'; +import * as nodeDataModel from '../../models/node_data'; const eventDetailRequestError = i18n.translate( 'xpack.securitySolution.resolver.panel.eventDetail.requestError', @@ -39,23 +40,24 @@ const eventDetailRequestError = i18n.translate( export const EventDetail = memo(function EventDetail({ nodeID, - eventID, eventCategory: eventType, }: { nodeID: string; - eventID: string; /** The event type to show in the breadcrumbs */ eventCategory: string; }) { const isEventLoading = useSelector(selectors.isCurrentRelatedEventLoading); - const isProcessTreeLoading = useSelector(selectors.isTreeLoading); + const isTreeLoading = useSelector(selectors.isTreeLoading); + const processEvent = useSelector((state: ResolverState) => + nodeDataModel.firstEvent(selectors.nodeDataForID(state)(nodeID)) + ); + const nodeStatus = useSelector((state: ResolverState) => selectors.nodeDataStatus(state)(nodeID)); - const isLoading = isEventLoading || isProcessTreeLoading; + const isNodeDataLoading = nodeStatus === 'loading'; + const isLoading = isEventLoading || isTreeLoading || isNodeDataLoading; const event = useSelector(selectors.currentRelatedEventData); - const processEvent = useSelector((state: ResolverState) => - selectors.processEventForID(state)(nodeID) - ); + return isLoading ? ( @@ -90,7 +92,7 @@ const EventDetailContents = memo(function ({ * Event type to use in the breadcrumbs */ eventType: string; - processEvent: SafeResolverEvent | null; + processEvent: SafeResolverEvent | undefined; }) { const timestamp = eventModel.timestampSafeVersion(event); const formattedDate = diff --git a/x-pack/plugins/security_solution/public/resolver/view/panels/index.tsx b/x-pack/plugins/security_solution/public/resolver/view/panels/index.tsx index f6fbd280e7ed5..c6e81f691e2fe 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/panels/index.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/panels/index.tsx @@ -37,7 +37,6 @@ export const PanelRouter = memo(function () { return ( ); diff --git a/x-pack/plugins/security_solution/public/resolver/view/panels/node_detail.tsx b/x-pack/plugins/security_solution/public/resolver/view/panels/node_detail.tsx index 27a7723d7d656..fedf1ae2499ae 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/panels/node_detail.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/panels/node_detail.tsx @@ -20,6 +20,7 @@ import { GeneratedText } from '../generated_text'; import { CopyablePanelField } from './copyable_panel_field'; import { Breadcrumbs } from './breadcrumbs'; import { processPath, processPID } from '../../models/process_event'; +import * as nodeDataModel from '../../models/node_data'; import { CubeForProcess } from './cube_for_process'; import { SafeResolverEvent } from '../../../../common/endpoint/types'; import { useCubeAssets } from '../use_cube_assets'; @@ -28,28 +29,35 @@ import { PanelLoading } from './panel_loading'; import { StyledPanel } from '../styles'; import { useLinkProps } from '../use_link_props'; import { useFormattedDate } from './use_formatted_date'; +import { PanelContentError } from './panel_content_error'; const StyledCubeForProcess = styled(CubeForProcess)` position: relative; top: 0.75em; `; +const nodeDetailError = i18n.translate('xpack.securitySolution.resolver.panel.nodeDetail.Error', { + defaultMessage: 'Node details were unable to be retrieved', +}); + export const NodeDetail = memo(function ({ nodeID }: { nodeID: string }) { const processEvent = useSelector((state: ResolverState) => - selectors.processEventForID(state)(nodeID) + nodeDataModel.firstEvent(selectors.nodeDataForID(state)(nodeID)) ); - return ( - <> - {processEvent === null ? ( - - - - ) : ( - - - - )} - + const nodeStatus = useSelector((state: ResolverState) => selectors.nodeDataStatus(state)(nodeID)); + + return nodeStatus === 'loading' ? ( + + + + ) : processEvent ? ( + + + + ) : ( + + + ); }); @@ -65,9 +73,7 @@ const NodeDetailView = memo(function ({ nodeID: string; }) { const processName = eventModel.processNameSafeVersion(processEvent); - const isProcessTerminated = useSelector((state: ResolverState) => - selectors.isProcessTerminated(state)(nodeID) - ); + const nodeState = useSelector((state: ResolverState) => selectors.nodeDataStatus(state)(nodeID)); const relatedEventTotal = useSelector((state: ResolverState) => { return selectors.relatedEventTotalCount(state)(nodeID); }); @@ -171,7 +177,7 @@ const NodeDetailView = memo(function ({ }, ]; }, [processName, nodesLinkNavProps]); - const { descriptionText } = useCubeAssets(isProcessTerminated, false); + const { descriptionText } = useCubeAssets(nodeState, false); const nodeDetailNavProps = useLinkProps({ panelView: 'nodeEvents', @@ -187,7 +193,7 @@ const NodeDetailView = memo(function ({ {processName} diff --git a/x-pack/plugins/security_solution/public/resolver/view/panels/node_events.tsx b/x-pack/plugins/security_solution/public/resolver/view/panels/node_events.tsx index d0601fad43f57..6f0c336ab3df4 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/panels/node_events.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/panels/node_events.tsx @@ -13,21 +13,21 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { useSelector } from 'react-redux'; import { Breadcrumbs } from './breadcrumbs'; import * as event from '../../../../common/endpoint/models/event'; -import { ResolverNodeStats } from '../../../../common/endpoint/types'; +import { EventStats } from '../../../../common/endpoint/types'; import * as selectors from '../../store/selectors'; import { ResolverState } from '../../types'; import { StyledPanel } from '../styles'; import { PanelLoading } from './panel_loading'; import { useLinkProps } from '../use_link_props'; +import * as nodeDataModel from '../../models/node_data'; export function NodeEvents({ nodeID }: { nodeID: string }) { const processEvent = useSelector((state: ResolverState) => - selectors.processEventForID(state)(nodeID) + nodeDataModel.firstEvent(selectors.nodeDataForID(state)(nodeID)) ); - const relatedEventsStats = useSelector((state: ResolverState) => - selectors.relatedEventsStats(state)(nodeID) - ); - if (processEvent === null || relatedEventsStats === undefined) { + const nodeStats = useSelector((state: ResolverState) => selectors.nodeStats(state)(nodeID)); + + if (processEvent === undefined || nodeStats === undefined) { return ( @@ -39,10 +39,10 @@ export function NodeEvents({ nodeID }: { nodeID: string }) { - + ); } @@ -64,7 +64,7 @@ const EventCategoryLinks = memo(function ({ relatedStats, }: { nodeID: string; - relatedStats: ResolverNodeStats; + relatedStats: EventStats; }) { interface EventCountsTableView { eventType: string; @@ -72,7 +72,7 @@ const EventCategoryLinks = memo(function ({ } const rows = useMemo(() => { - return Object.entries(relatedStats.events.byCategory).map( + return Object.entries(relatedStats.byCategory).map( ([eventType, count]): EventCountsTableView => { return { eventType, @@ -80,7 +80,7 @@ const EventCategoryLinks = memo(function ({ }; } ); - }, [relatedStats.events.byCategory]); + }, [relatedStats.byCategory]); const columns = useMemo>>( () => [ diff --git a/x-pack/plugins/security_solution/public/resolver/view/panels/node_events_of_type.tsx b/x-pack/plugins/security_solution/public/resolver/view/panels/node_events_of_type.tsx index c9648c6f562e5..fbfba38295ea4 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/panels/node_events_of_type.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/panels/node_events_of_type.tsx @@ -42,9 +42,7 @@ export const NodeEventsInCategory = memo(function ({ nodeID: string; eventCategory: string; }) { - const processEvent = useSelector((state: ResolverState) => - selectors.processEventForID(state)(nodeID) - ); + const node = useSelector((state: ResolverState) => selectors.graphNodeForID(state)(nodeID)); const eventCount = useSelector((state: ResolverState) => selectors.totalRelatedEventCountForNode(state)(nodeID) ); @@ -57,13 +55,13 @@ export const NodeEventsInCategory = memo(function ({ const hasError = useSelector(selectors.hadErrorLoadingNodeEventsInCategory); return ( <> - {isLoading || processEvent === null ? ( + {isLoading ? ( ) : ( - {hasError ? ( + {hasError || !node ? ( { useCallback((state: ResolverState) => { const { processNodePositions } = selectors.layout(state); const view: ProcessTableView[] = []; - for (const processEvent of processNodePositions.keys()) { - const name = eventModel.processNameSafeVersion(processEvent); - const nodeID = eventModel.entityIDSafeVersion(processEvent); + for (const treeNode of processNodePositions.keys()) { + const name = nodeModel.nodeName(treeNode); + const nodeID = nodeModel.nodeID(treeNode); if (nodeID !== undefined) { view.push({ name, - timestamp: eventModel.timestampAsDateSafeVersion(processEvent), + timestamp: nodeModel.timestampAsDate(treeNode), nodeID, }); } @@ -119,7 +119,8 @@ export const NodeList = memo(() => { const children = useSelector(selectors.hasMoreChildren); const ancestors = useSelector(selectors.hasMoreAncestors); - const showWarning = children === true || ancestors === true; + const generations = useSelector(selectors.hasMoreGenerations); + const showWarning = children === true || ancestors === true || generations === true; const rowProps = useMemo(() => ({ 'data-test-subj': 'resolver:node-list:item' }), []); return ( @@ -141,9 +142,7 @@ function NodeDetailLink({ name, nodeID }: { name?: string; nodeID: string }) { const isOrigin = useSelector((state: ResolverState) => { return selectors.originID(state) === nodeID; }); - const isTerminated = useSelector((state: ResolverState) => - nodeID === undefined ? false : selectors.isProcessTerminated(state)(nodeID) - ); + const nodeState = useSelector((state: ResolverState) => selectors.nodeDataStatus(state)(nodeID)); const { descriptionText } = useColors(); const linkProps = useLinkProps({ panelView: 'nodeDetail', panelParameters: { nodeID } }); const dispatch: (action: ResolverAction) => void = useDispatch(); @@ -162,7 +161,12 @@ function NodeDetailLink({ name, nodeID }: { name?: string; nodeID: string }) { [timestamp, linkProps, dispatch, nodeID] ); return ( - + {name === undefined ? ( {i18n.translate( @@ -175,7 +179,7 @@ function NodeDetailLink({ name, nodeID }: { name?: string; nodeID: string }) { ) : ( diff --git a/x-pack/plugins/security_solution/public/resolver/view/panels/panel_states.test.tsx b/x-pack/plugins/security_solution/public/resolver/view/panels/panel_states.test.tsx index 39a5130ecaf68..6f20063d10d0a 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/panels/panel_states.test.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/panels/panel_states.test.tsx @@ -23,6 +23,8 @@ describe('Resolver: panel loading and resolution states', () => { nodeID: 'origin', eventCategory: 'registry', eventID: firstRelatedEventID, + eventTimestamp: '0', + winlogRecordID: '0', }, panelView: 'eventDetail', }); @@ -129,7 +131,7 @@ describe('Resolver: panel loading and resolution states', () => { }); describe('when navigating to the event categories panel', () => { - let resumeRequest: (pausableRequest: ['entities']) => void; + let resumeRequest: (pausableRequest: ['eventsWithEntityIDAndCategory']) => void; beforeEach(() => { const { metadata: { databaseDocumentID }, @@ -140,7 +142,7 @@ describe('Resolver: panel loading and resolution states', () => { resumeRequest = resume; memoryHistory = createMemoryHistory(); - pause(['entities']); + pause(['eventsWithEntityIDAndCategory']); simulator = new Simulator({ dataAccessLayer, @@ -170,7 +172,7 @@ describe('Resolver: panel loading and resolution states', () => { }); it('should successfully load the events in category panel', async () => { - await resumeRequest(['entities']); + await resumeRequest(['eventsWithEntityIDAndCategory']); await expect( simulator.map(() => ({ resolverPanelLoading: simulator.testSubject('resolver:panel:loading').length, @@ -186,7 +188,7 @@ describe('Resolver: panel loading and resolution states', () => { }); describe('when navigating to the node detail panel', () => { - let resumeRequest: (pausableRequest: ['entities']) => void; + let resumeRequest: (pausableRequest: ['nodeData']) => void; beforeEach(() => { const { metadata: { databaseDocumentID }, @@ -197,7 +199,7 @@ describe('Resolver: panel loading and resolution states', () => { resumeRequest = resume; memoryHistory = createMemoryHistory(); - pause(['entities']); + pause(['nodeData']); simulator = new Simulator({ dataAccessLayer, @@ -226,7 +228,7 @@ describe('Resolver: panel loading and resolution states', () => { }); it('should successfully load the events in category panel', async () => { - await resumeRequest(['entities']); + await resumeRequest(['nodeData']); await expect( simulator.map(() => ({ resolverPanelLoading: simulator.testSubject('resolver:panel:loading').length, diff --git a/x-pack/plugins/security_solution/public/resolver/view/process_event_dot.tsx b/x-pack/plugins/security_solution/public/resolver/view/process_event_dot.tsx index 7a3657fe93514..ab6083c796b3a 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/process_event_dot.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/process_event_dot.tsx @@ -9,12 +9,13 @@ import styled from 'styled-components'; import { htmlIdGenerator, EuiButton, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { useSelector } from 'react-redux'; import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; import { NodeSubMenu } from './styles'; import { applyMatrix3 } from '../models/vector2'; import { Vector2, Matrix3, ResolverState } from '../types'; -import { SafeResolverEvent } from '../../../common/endpoint/types'; +import { ResolverNode } from '../../../common/endpoint/types'; import { useResolverDispatch } from './use_resolver_dispatch'; -import * as eventModel from '../../../common/endpoint/models/event'; +import * as nodeModel from '../../../common/endpoint/models/node'; import * as selectors from '../store/selectors'; import { fontSize } from './font_size'; import { useCubeAssets } from './use_cube_assets'; @@ -65,9 +66,50 @@ const StyledDescriptionText = styled.div` z-index: 45; `; -const StyledOuterGroup = styled.g` +interface StyledEuiButtonContent { + readonly isShowingIcon: boolean; +} + +const StyledEuiButtonContent = styled.span` + padding: ${(props) => (props.isShowingIcon ? '0px' : '0 12px')}; +`; + +const StyledOuterGroup = styled.g<{ isNodeLoading: boolean }>` fill: none; pointer-events: visiblePainted; + // The below will apply the loading css to the element that references the cube + // when the nodeData is loading for the current node + ${(props) => + props.isNodeLoading && + ` + & .cube { + animation-name: pulse; + /** + * his is a multiple of .6 so it can match up with the EUI button's loading spinner + * which is (0.6s). Using .6 here makes it a bit too fast. + */ + animation-duration: 1.8s; + animation-delay: 0; + animation-direction: normal; + animation-iteration-count: infinite; + animation-timing-function: linear; + } + + /** + * Animation loading state of the cube. + */ + @keyframes pulse { + 0% { + opacity: 1; + } + 50% { + opacity: 0.35; + } + 100% { + opacity: 1; + } + } + `} `; /** @@ -77,9 +119,9 @@ const UnstyledProcessEventDot = React.memo( ({ className, position, - event, + node, + nodeID, projectionMatrix, - isProcessTerminated, timeAtRender, }: { /** @@ -87,21 +129,21 @@ const UnstyledProcessEventDot = React.memo( */ className?: string; /** - * The positon of the process node, in 'world' coordinates. + * The positon of the graph node, in 'world' coordinates. */ position: Vector2; /** - * An event which contains details about the process node. + * An event which contains details about the graph node. */ - event: SafeResolverEvent; + node: ResolverNode; /** - * projectionMatrix which can be used to convert `position` to screen coordinates. + * The unique identifier for the node based on a datasource id */ - projectionMatrix: Matrix3; + nodeID: string; /** - * Whether or not to show the process as terminated. + * projectionMatrix which can be used to convert `position` to screen coordinates. */ - isProcessTerminated: boolean; + projectionMatrix: Matrix3; /** * The time (unix epoch) at render. @@ -125,14 +167,7 @@ const UnstyledProcessEventDot = React.memo( const ariaActiveDescendant = useSelector(selectors.ariaActiveDescendant); const selectedNode = useSelector(selectors.selectedNode); const originID = useSelector(selectors.originID); - const nodeID: string | undefined = eventModel.entityIDSafeVersion(event); - if (nodeID === undefined) { - // NB: this component should be taking nodeID as a `string` instead of handling this logic here - throw new Error('Tried to render a node with no ID'); - } - const relatedEventStats = useSelector((state: ResolverState) => - selectors.relatedEventsStats(state)(nodeID) - ); + const nodeStats = useSelector((state: ResolverState) => selectors.nodeStats(state)(nodeID)); // define a standard way of giving HTML IDs to nodes based on their entity_id/nodeID. // this is used to link nodes via aria attributes @@ -218,6 +253,11 @@ const UnstyledProcessEventDot = React.memo( | null; } = React.createRef(); const colorMap = useColors(); + + const nodeState = useSelector((state: ResolverState) => + selectors.nodeDataStatus(state)(nodeID) + ); + const isNodeLoading = nodeState === 'loading'; const { backingFill, cubeSymbol, @@ -226,7 +266,7 @@ const UnstyledProcessEventDot = React.memo( labelButtonFill, strokeColor, } = useCubeAssets( - isProcessTerminated, + nodeState, /** * There is no definition for 'trigger process' yet. return false. */ false @@ -257,19 +297,29 @@ const UnstyledProcessEventDot = React.memo( if (animationTarget.current?.beginElement) { animationTarget.current.beginElement(); } - dispatch({ - type: 'userSelectedResolverNode', - payload: nodeID, - }); - processDetailNavProps.onClick(clickEvent); + + if (nodeState === 'error') { + dispatch({ + type: 'userReloadedResolverNode', + payload: nodeID, + }); + } else { + dispatch({ + type: 'userSelectedResolverNode', + payload: nodeID, + }); + processDetailNavProps.onClick(clickEvent); + } }, - [animationTarget, dispatch, nodeID, processDetailNavProps] + [animationTarget, dispatch, nodeID, processDetailNavProps, nodeState] ); const grandTotal: number | null = useSelector((state: ResolverState) => - selectors.relatedEventTotalForProcess(state)(event) + selectors.statsTotalForNode(state)(node) ); + const nodeName = nodeModel.nodeName(node); + /* eslint-disable jsx-a11y/click-events-have-key-events */ /** * Key event handling (e.g. 'Enter'/'Space') is provisioned by the `EuiKeyboardAccessible` component @@ -315,7 +365,7 @@ const UnstyledProcessEventDot = React.memo( zIndex: 30, }} > - + - + - {eventModel.processNameSafeVersion(event)} + {i18n.translate('xpack.securitySolution.resolver.node_button_name', { + defaultMessage: `{nodeState, select, error {Reload {nodeName}} other {{nodeName}}}`, + values: { + nodeState, + nodeName, + }, + })} - + 0 && ( )} diff --git a/x-pack/plugins/security_solution/public/resolver/view/resolver_loading_state.test.tsx b/x-pack/plugins/security_solution/public/resolver/view/resolver_loading_state.test.tsx index d8d8de640d786..fa1686e7ea4b6 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/resolver_loading_state.test.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/resolver_loading_state.test.tsx @@ -35,12 +35,12 @@ describe('Resolver: data loading and resolution states', () => { simulator.map(() => ({ resolverGraphLoading: simulator.testSubject('resolver:graph:loading').length, resolverGraphError: simulator.testSubject('resolver:graph:error').length, - resolverGraph: simulator.testSubject('resolver:graph').length, + resolverTree: simulator.testSubject('resolver:graph').length, })) ).toYieldEqualTo({ resolverGraphLoading: 1, resolverGraphError: 0, - resolverGraph: 0, + resolverTree: 0, }); }); }); @@ -66,12 +66,12 @@ describe('Resolver: data loading and resolution states', () => { simulator.map(() => ({ resolverGraphLoading: simulator.testSubject('resolver:graph:loading').length, resolverGraphError: simulator.testSubject('resolver:graph:error').length, - resolverGraph: simulator.testSubject('resolver:graph').length, + resolverTree: simulator.testSubject('resolver:graph').length, })) ).toYieldEqualTo({ resolverGraphLoading: 1, resolverGraphError: 0, - resolverGraph: 0, + resolverTree: 0, }); }); }); @@ -96,12 +96,12 @@ describe('Resolver: data loading and resolution states', () => { simulator.map(() => ({ resolverGraphLoading: simulator.testSubject('resolver:graph:loading').length, resolverGraphError: simulator.testSubject('resolver:graph:error').length, - resolverGraph: simulator.testSubject('resolver:graph').length, + resolverTree: simulator.testSubject('resolver:graph').length, })) ).toYieldEqualTo({ resolverGraphLoading: 0, resolverGraphError: 1, - resolverGraph: 0, + resolverTree: 0, }); }); }); @@ -126,13 +126,13 @@ describe('Resolver: data loading and resolution states', () => { simulator.map(() => ({ resolverGraphLoading: simulator.testSubject('resolver:graph:loading').length, resolverGraphError: simulator.testSubject('resolver:graph:error').length, - resolverGraph: simulator.testSubject('resolver:graph').length, + resolverTree: simulator.testSubject('resolver:graph').length, resolverGraphNodes: simulator.testSubject('resolver:node').length, })) ).toYieldEqualTo({ resolverGraphLoading: 0, resolverGraphError: 0, - resolverGraph: 1, + resolverTree: 1, resolverGraphNodes: 0, }); }); @@ -158,13 +158,13 @@ describe('Resolver: data loading and resolution states', () => { simulator.map(() => ({ resolverGraphLoading: simulator.testSubject('resolver:graph:loading').length, resolverGraphError: simulator.testSubject('resolver:graph:error').length, - resolverGraph: simulator.testSubject('resolver:graph').length, + resolverTree: simulator.testSubject('resolver:graph').length, resolverGraphNodes: simulator.testSubject('resolver:node').length, })) ).toYieldEqualTo({ resolverGraphLoading: 0, resolverGraphError: 0, - resolverGraph: 1, + resolverTree: 1, resolverGraphNodes: 3, }); }); diff --git a/x-pack/plugins/security_solution/public/resolver/view/resolver_without_providers.tsx b/x-pack/plugins/security_solution/public/resolver/view/resolver_without_providers.tsx index ed969b913a72e..65b72cf4bfa77 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/resolver_without_providers.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/resolver_without_providers.tsx @@ -19,7 +19,7 @@ import { useCamera } from './use_camera'; import { SymbolDefinitions } from './symbol_definitions'; import { useStateSyncingActions } from './use_state_syncing_actions'; import { StyledMapContainer, GraphContainer } from './styles'; -import { entityIDSafeVersion } from '../../../common/endpoint/models/event'; +import * as nodeModel from '../../../common/endpoint/models/node'; import { SideEffectContext } from './side_effect_context'; import { ResolverProps, ResolverState } from '../types'; import { PanelRouter } from './panels'; @@ -54,7 +54,7 @@ export const ResolverWithoutProviders = React.memo( } = useSelector((state: ResolverState) => selectors.visibleNodesAndEdgeLines(state)(timeAtRender) ); - const terminatedProcesses = useSelector(selectors.terminatedProcesses); + const { projectionMatrix, ref: cameraRef, onMouseDown } = useCamera(); const ref = useCallback( @@ -113,15 +113,18 @@ export const ResolverWithoutProviders = React.memo( /> ) )} - {[...processNodePositions].map(([processEvent, position]) => { - const processEntityId = entityIDSafeVersion(processEvent); + {[...processNodePositions].map(([treeNode, position]) => { + const nodeID = nodeModel.nodeID(treeNode); + if (nodeID === undefined) { + throw new Error('Tried to render a node without an ID'); + } return ( ); diff --git a/x-pack/plugins/security_solution/public/resolver/view/submenu.tsx b/x-pack/plugins/security_solution/public/resolver/view/submenu.tsx index 6312991ddb743..e24c4b5664e42 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/submenu.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/submenu.tsx @@ -8,7 +8,7 @@ import { i18n } from '@kbn/i18n'; import React, { useMemo } from 'react'; import { FormattedMessage } from 'react-intl'; import { EuiI18nNumber } from '@elastic/eui'; -import { ResolverNodeStats } from '../../../common/endpoint/types'; +import { EventStats } from '../../../common/endpoint/types'; import { useRelatedEventByCategoryNavigation } from './use_related_event_by_category_navigation'; import { useColors } from './use_colors'; @@ -67,7 +67,7 @@ export const NodeSubMenuComponents = React.memo( ({ className, nodeID, - relatedEventStats, + nodeStats, }: { className?: string; // eslint-disable-next-line react/no-unused-prop-types @@ -76,18 +76,18 @@ export const NodeSubMenuComponents = React.memo( * Receive the projection matrix, so we can see when the camera position changed, so we can force the submenu to reposition itself. */ nodeID: string; - relatedEventStats: ResolverNodeStats | undefined; + nodeStats: EventStats | undefined; }) => { // The last projection matrix that was used to position the popover const relatedEventCallbacks = useRelatedEventByCategoryNavigation({ nodeID, - categories: relatedEventStats?.events?.byCategory, + categories: nodeStats?.byCategory, }); const relatedEventOptions = useMemo(() => { - if (relatedEventStats === undefined) { + if (nodeStats === undefined) { return []; } else { - return Object.entries(relatedEventStats.events.byCategory).map(([category, total]) => { + return Object.entries(nodeStats.byCategory).map(([category, total]) => { const [mantissa, scale, hasRemainder] = compactNotationParts(total || 0); const prefix = ( { diff --git a/x-pack/plugins/security_solution/public/resolver/view/symbol_definitions.tsx b/x-pack/plugins/security_solution/public/resolver/view/symbol_definitions.tsx index edf551c6cbeb9..b06cce11661e8 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/symbol_definitions.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/symbol_definitions.tsx @@ -8,10 +8,59 @@ import React, { memo } from 'react'; import styled from 'styled-components'; +import { i18n } from '@kbn/i18n'; import { useUiSetting } from '../../../../../../src/plugins/kibana_react/public'; import { useSymbolIDs } from './use_symbol_ids'; import { usePaintServerIDs } from './use_paint_server_ids'; +const loadingProcessTitle = i18n.translate( + 'xpack.securitySolution.resolver.symbolDefinitions.loadingProcess', + { + defaultMessage: 'Loading Process', + } +); + +const errorProcessTitle = i18n.translate( + 'xpack.securitySolution.resolver.symbolDefinitions.errorProcess', + { + defaultMessage: 'Error Process', + } +); + +const runningProcessTitle = i18n.translate( + 'xpack.securitySolution.resolver.symbolDefinitions.runningProcess', + { + defaultMessage: 'Running Process', + } +); + +const triggerProcessTitle = i18n.translate( + 'xpack.securitySolution.resolver.symbolDefinitions.triggerProcess', + { + defaultMessage: 'Trigger Process', + } +); + +const terminatedProcessTitle = i18n.translate( + 'xpack.securitySolution.resolver.symbolDefinitions.terminatedProcess', + { + defaultMessage: 'Terminated Process', + } +); + +const terminatedTriggerProcessTitle = i18n.translate( + 'xpack.securitySolution.resolver.symbolDefinitions.terminatedTriggerProcess', + { + defaultMessage: 'Terminated Trigger Process', + } +); + +const hoveredProcessBackgroundTitle = i18n.translate( + 'xpack.securitySolution.resolver.symbolDefinitions.hoveredProcessBackground', + { + defaultMessage: 'Hovered Process Background', + } +); /** * PaintServers: Where color palettes, gradients, patterns and other similar concerns * are exposed to the component @@ -20,6 +69,17 @@ const PaintServers = memo(({ isDarkMode }: { isDarkMode: boolean }) => { const paintServerIDs = usePaintServerIDs(); return ( <> + + + + { paintOrder="normal" /> + + {loadingProcessTitle} + + + + {errorProcessTitle} + + + + + + + - {'Running Process'} + {runningProcessTitle} { /> - {'resolver_dark process running'} + {triggerProcessTitle} { /> - {'Terminated Process'} + {terminatedProcessTitle} { - {'Terminated Trigger Process'} + {terminatedTriggerProcessTitle} {isDarkMode && ( { - {'resolver active backing'} + {hoveredProcessBackgroundTitle} { /** Enzyme full DOM wrapper for the element the camera is attached to. */ @@ -247,43 +248,48 @@ describe('useCamera on an unpainted element', () => { expect(simulator.mock.requestAnimationFrame).not.toHaveBeenCalled(); }); describe('when the camera begins animation', () => { - let process: SafeResolverEvent; + let node: ResolverNode; beforeEach(async () => { - const events: SafeResolverEvent[] = []; - const numberOfEvents: number = 10; + const nodes: ResolverNode[] = []; + const numberOfNodes: number = 10; - for (let index = 0; index < numberOfEvents; index++) { - const uniquePpid = index === 0 ? undefined : index - 1; - events.push( - mockProcessEvent({ - endgame: { - unique_pid: index, - unique_ppid: uniquePpid, - event_type_full: 'process_event', - event_subtype_full: 'creation_event', - }, + for (let index = 0; index < numberOfNodes; index++) { + const parentID = index === 0 ? undefined : String(index - 1); + nodes.push( + mockResolverNode({ + id: String(index), + name: '', + parentID, + timestamp: 0, + stats: { total: 0, byCategory: {} }, }) ); } - const tree = mockResolverTree({ events }); + const tree = mockResolverTree({ nodes }); if (tree !== null) { + const { schema, dataSource } = endpointSourceSchema(); const serverResponseAction: ResolverAction = { type: 'serverReturnedResolverData', - payload: { result: tree, parameters: mockTreeFetcherParameters() }, + payload: { + result: tree, + dataSource, + schema, + parameters: mockTreeFetcherParameters(), + }, }; store.dispatch(serverResponseAction); } else { throw new Error('failed to create tree'); } - const processes: SafeResolverEvent[] = [ + const resolverNodes: ResolverNode[] = [ ...selectors.layout(store.getState()).processNodePositions.keys(), ]; - process = processes[processes.length - 1]; + node = resolverNodes[resolverNodes.length - 1]; if (!process) { throw new Error('missing the process to bring into view'); } simulator.controls.time = 0; - const nodeID = entityIDSafeVersion(process); + const nodeID = nodeModel.nodeID(node); if (!nodeID) { throw new Error('could not find nodeID for process'); } diff --git a/x-pack/plugins/security_solution/public/resolver/view/use_colors.ts b/x-pack/plugins/security_solution/public/resolver/view/use_colors.ts index 7daf181a7b2bb..90ce5dc22d177 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/use_colors.ts +++ b/x-pack/plugins/security_solution/public/resolver/view/use_colors.ts @@ -15,6 +15,7 @@ type ResolverColorNames = | 'full' | 'graphControls' | 'graphControlsBackground' + | 'graphControlsBorderColor' | 'linkColor' | 'resolverBackground' | 'resolverEdge' @@ -38,6 +39,7 @@ export function useColors(): ColorMap { full: theme.euiColorFullShade, graphControls: theme.euiColorDarkestShade, graphControlsBackground: theme.euiColorEmptyShade, + graphControlsBorderColor: theme.euiColorLightShade, processBackingFill: `${theme.euiColorPrimary}${isDarkMode ? '1F' : '0F'}`, // Add opacity 0F = 6% , 1F = 12% resolverBackground: theme.euiColorEmptyShade, resolverEdge: isDarkMode ? theme.euiColorLightShade : theme.euiColorLightestShade, diff --git a/x-pack/plugins/security_solution/public/resolver/view/use_cube_assets.ts b/x-pack/plugins/security_solution/public/resolver/view/use_cube_assets.ts index c743ebc43f2be..94f08c5f3fee3 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/use_cube_assets.ts +++ b/x-pack/plugins/security_solution/public/resolver/view/use_cube_assets.ts @@ -10,7 +10,7 @@ import { ButtonColor } from '@elastic/eui'; import euiThemeAmsterdamDark from '@elastic/eui/dist/eui_theme_amsterdam_dark.json'; import euiThemeAmsterdamLight from '@elastic/eui/dist/eui_theme_amsterdam_light.json'; import { useMemo } from 'react'; -import { ResolverProcessType } from '../types'; +import { ResolverProcessType, NodeDataStatus } from '../types'; import { useUiSetting } from '../../../../../../src/plugins/kibana_react/public'; import { useSymbolIDs } from './use_symbol_ids'; import { useColors } from './use_colors'; @@ -19,7 +19,7 @@ import { useColors } from './use_colors'; * Provides colors and HTML IDs used to render the 'cube' graphic that accompanies nodes. */ export function useCubeAssets( - isProcessTerminated: boolean, + cubeType: NodeDataStatus, isProcessTrigger: boolean ): NodeStyleConfig { const SymbolIds = useSymbolIDs(); @@ -40,6 +40,28 @@ export function useCubeAssets( labelButtonFill: 'primary', strokeColor: theme.euiColorPrimary, }, + loadingCube: { + backingFill: colorMap.processBackingFill, + cubeSymbol: `#${SymbolIds.loadingCube}`, + descriptionFill: colorMap.descriptionText, + descriptionText: i18n.translate('xpack.securitySolution.endpoint.resolver.loadingProcess', { + defaultMessage: 'Loading Process', + }), + isLabelFilled: false, + labelButtonFill: 'primary', + strokeColor: theme.euiColorPrimary, + }, + errorCube: { + backingFill: colorMap.processBackingFill, + cubeSymbol: `#${SymbolIds.errorCube}`, + descriptionFill: colorMap.descriptionText, + descriptionText: i18n.translate('xpack.securitySolution.endpoint.resolver.errorProcess', { + defaultMessage: 'Error Process', + }), + isLabelFilled: false, + labelButtonFill: 'primary', + strokeColor: theme.euiColorPrimary, + }, runningTriggerCube: { backingFill: colorMap.triggerBackingFill, cubeSymbol: `#${SymbolIds.runningTriggerCube}`, @@ -83,16 +105,22 @@ export function useCubeAssets( [SymbolIds, colorMap, theme] ); - if (isProcessTerminated) { + if (cubeType === 'terminated') { if (isProcessTrigger) { return nodeAssets.terminatedTriggerCube; } else { return nodeAssets[processTypeToCube.processTerminated]; } - } else if (isProcessTrigger) { - return nodeAssets[processTypeToCube.processCausedAlert]; + } else if (cubeType === 'running') { + if (isProcessTrigger) { + return nodeAssets[processTypeToCube.processCausedAlert]; + } else { + return nodeAssets[processTypeToCube.processRan]; + } + } else if (cubeType === 'error') { + return nodeAssets[processTypeToCube.processError]; } else { - return nodeAssets[processTypeToCube.processRan]; + return nodeAssets[processTypeToCube.processLoading]; } } @@ -102,6 +130,8 @@ const processTypeToCube: Record = { processTerminated: 'terminatedProcessCube', unknownProcessEvent: 'runningProcessCube', processCausedAlert: 'runningTriggerCube', + processLoading: 'loadingCube', + processError: 'errorCube', unknownEvent: 'runningProcessCube', }; interface NodeStyleMap { @@ -109,6 +139,8 @@ interface NodeStyleMap { runningTriggerCube: NodeStyleConfig; terminatedProcessCube: NodeStyleConfig; terminatedTriggerCube: NodeStyleConfig; + loadingCube: NodeStyleConfig; + errorCube: NodeStyleConfig; } interface NodeStyleConfig { backingFill: string; diff --git a/x-pack/plugins/security_solution/public/resolver/view/use_paint_server_ids.ts b/x-pack/plugins/security_solution/public/resolver/view/use_paint_server_ids.ts index 0336a29bb0721..10fbd58a9deb3 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/use_paint_server_ids.ts +++ b/x-pack/plugins/security_solution/public/resolver/view/use_paint_server_ids.ts @@ -23,6 +23,8 @@ export function usePaintServerIDs() { runningTriggerCube: `${prefix}-psRunningTriggerCube`, terminatedProcessCube: `${prefix}-psTerminatedProcessCube`, terminatedTriggerCube: `${prefix}-psTerminatedTriggerCube`, + loadingCube: `${prefix}-psLoadingCube`, + errorCube: `${prefix}-psErrorCube`, }; }, [resolverComponentInstanceID]); } diff --git a/x-pack/plugins/security_solution/public/resolver/view/use_symbol_ids.ts b/x-pack/plugins/security_solution/public/resolver/view/use_symbol_ids.ts index 0e1fd5737a3ce..da00d4c0dbf43 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/use_symbol_ids.ts +++ b/x-pack/plugins/security_solution/public/resolver/view/use_symbol_ids.ts @@ -25,6 +25,8 @@ export function useSymbolIDs() { terminatedProcessCube: `${prefix}-terminatedCube`, terminatedTriggerCube: `${prefix}-terminatedTriggerCube`, processCubeActiveBacking: `${prefix}-activeBacking`, + loadingCube: `${prefix}-loadingCube`, + errorCube: `${prefix}-errorCube`, }; }, [resolverComponentInstanceID]); } diff --git a/x-pack/plugins/security_solution/public/timelines/components/graph_overlay/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/graph_overlay/index.tsx index 5b558df8388e4..b53c11868998f 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/graph_overlay/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/graph_overlay/index.tsx @@ -72,7 +72,7 @@ const NavigationComponent: React.FC = ({ timelineFullScreen, toggleFullScreen, }) => ( - + {i18n.CLOSE_ANALYZER} diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/helpers.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/helpers.tsx index 1d4cea700d003..0dae9a97b6e5b 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/helpers.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/helpers.tsx @@ -117,7 +117,9 @@ export const getEventType = (event: Ecs): Omit => { }; export const isInvestigateInResolverActionEnabled = (ecsData?: Ecs) => - get(['agent', 'type', 0], ecsData) === 'endpoint' && + (get(['agent', 'type', 0], ecsData) === 'endpoint' || + (get(['agent', 'type', 0], ecsData) === 'winlogbeat' && + get(['event', 'module', 0], ecsData) === 'sysmon')) && 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 c731692e6fb89..6d4168d744fca 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 @@ -19,7 +19,7 @@ interface SupportedSchema { /** * A constraint to search for in the documented returned by Elasticsearch */ - constraint: { field: string; value: string }; + constraints: Array<{ field: string; value: string }>; /** * Schema to return to the frontend so that it can be passed in to call to the /tree API @@ -34,10 +34,12 @@ interface SupportedSchema { const supportedSchemas: SupportedSchema[] = [ { name: 'endpoint', - constraint: { - field: 'agent.type', - value: 'endpoint', - }, + constraints: [ + { + field: 'agent.type', + value: 'endpoint', + }, + ], schema: { id: 'process.entity_id', parent: 'process.parent.entity_id', @@ -47,10 +49,16 @@ const supportedSchemas: SupportedSchema[] = [ }, { name: 'winlogbeat', - constraint: { - field: 'agent.type', - value: 'winlogbeat', - }, + constraints: [ + { + field: 'agent.type', + value: 'winlogbeat', + }, + { + field: 'event.module', + value: 'sysmon', + }, + ], schema: { id: 'process.entity_id', parent: 'process.parent.entity_id', @@ -104,14 +112,17 @@ export function handleEntities(): RequestHandler { - const kqlQuery: JsonObject[] = []; - if (kql) { - kqlQuery.push(esKuery.toElasticsearchQuery(esKuery.fromKueryExpression(kql))); - } + async search( + client: IScopedClusterClient, + filter: string | undefined + ): Promise { + const parsedFilters = EventsQuery.buildFilters(filter); const response: ApiResponse< SearchResponse - > = await client.asCurrentUser.search(this.buildSearch(kqlQuery)); + > = await client.asCurrentUser.search(this.buildSearch(parsedFilters)); return response.body.hits.hits.map((hit) => hit._source); } } diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree/queries/descendants.ts b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree/queries/descendants.ts index 3baf3a8667529..63cd3b5d694af 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree/queries/descendants.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree/queries/descendants.ts @@ -8,12 +8,12 @@ import { ApiResponse } from '@elastic/elasticsearch'; import { IScopedClusterClient } from 'src/core/server'; import { FieldsObject, ResolverSchema } from '../../../../../../common/endpoint/types'; import { JsonObject, JsonValue } from '../../../../../../../../../src/plugins/kibana_utils/common'; -import { NodeID, Timerange, docValueFields } from '../utils/index'; +import { NodeID, TimeRange, docValueFields } from '../utils/index'; interface DescendantsParams { schema: ResolverSchema; indexPatterns: string | string[]; - timerange: Timerange; + timeRange: TimeRange; } /** @@ -22,13 +22,13 @@ interface DescendantsParams { export class DescendantsQuery { private readonly schema: ResolverSchema; private readonly indexPatterns: string | string[]; - private readonly timerange: Timerange; + private readonly timeRange: TimeRange; private readonly docValueFields: JsonValue[]; - constructor({ schema, indexPatterns, timerange }: DescendantsParams) { + constructor({ schema, indexPatterns, timeRange }: DescendantsParams) { this.docValueFields = docValueFields(schema); this.schema = schema; this.indexPatterns = indexPatterns; - this.timerange = timerange; + this.timeRange = timeRange; } private query(nodes: NodeID[], size: number): JsonObject { @@ -46,8 +46,8 @@ export class DescendantsQuery { { range: { '@timestamp': { - gte: this.timerange.from, - lte: this.timerange.to, + gte: this.timeRange.from, + lte: this.timeRange.to, format: 'strict_date_optional_time', }, }, @@ -126,8 +126,8 @@ export class DescendantsQuery { { range: { '@timestamp': { - gte: this.timerange.from, - lte: this.timerange.to, + gte: this.timeRange.from, + lte: this.timeRange.to, format: 'strict_date_optional_time', }, }, diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree/queries/lifecycle.ts b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree/queries/lifecycle.ts index 5253806be66ba..150b07c63ce2f 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree/queries/lifecycle.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree/queries/lifecycle.ts @@ -8,12 +8,12 @@ import { ApiResponse } from '@elastic/elasticsearch'; import { IScopedClusterClient } from 'src/core/server'; import { FieldsObject, ResolverSchema } from '../../../../../../common/endpoint/types'; import { JsonObject, JsonValue } from '../../../../../../../../../src/plugins/kibana_utils/common'; -import { NodeID, Timerange, docValueFields } from '../utils/index'; +import { NodeID, TimeRange, docValueFields } from '../utils/index'; interface LifecycleParams { schema: ResolverSchema; indexPatterns: string | string[]; - timerange: Timerange; + timeRange: TimeRange; } /** @@ -22,13 +22,13 @@ interface LifecycleParams { export class LifecycleQuery { private readonly schema: ResolverSchema; private readonly indexPatterns: string | string[]; - private readonly timerange: Timerange; + private readonly timeRange: TimeRange; private readonly docValueFields: JsonValue[]; - constructor({ schema, indexPatterns, timerange }: LifecycleParams) { + constructor({ schema, indexPatterns, timeRange }: LifecycleParams) { this.docValueFields = docValueFields(schema); this.schema = schema; this.indexPatterns = indexPatterns; - this.timerange = timerange; + this.timeRange = timeRange; } private query(nodes: NodeID[]): JsonObject { @@ -46,8 +46,8 @@ export class LifecycleQuery { { range: { '@timestamp': { - gte: this.timerange.from, - lte: this.timerange.to, + gte: this.timeRange.from, + lte: this.timeRange.to, format: 'strict_date_optional_time', }, }, diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree/queries/stats.ts b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree/queries/stats.ts index 117cc3647dd0e..22d2c600feb01 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree/queries/stats.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree/queries/stats.ts @@ -8,7 +8,7 @@ import { ApiResponse } from '@elastic/elasticsearch'; import { IScopedClusterClient } from 'src/core/server'; import { JsonObject } from '../../../../../../../../../src/plugins/kibana_utils/common'; import { EventStats, ResolverSchema } from '../../../../../../common/endpoint/types'; -import { NodeID, Timerange } from '../utils/index'; +import { NodeID, TimeRange } from '../utils/index'; interface AggBucket { key: string; @@ -28,7 +28,7 @@ interface CategoriesAgg extends AggBucket { interface StatsParams { schema: ResolverSchema; indexPatterns: string | string[]; - timerange: Timerange; + timeRange: TimeRange; } /** @@ -37,11 +37,11 @@ interface StatsParams { export class StatsQuery { private readonly schema: ResolverSchema; private readonly indexPatterns: string | string[]; - private readonly timerange: Timerange; - constructor({ schema, indexPatterns, timerange }: StatsParams) { + private readonly timeRange: TimeRange; + constructor({ schema, indexPatterns, timeRange }: StatsParams) { this.schema = schema; this.indexPatterns = indexPatterns; - this.timerange = timerange; + this.timeRange = timeRange; } private query(nodes: NodeID[]): JsonObject { @@ -53,8 +53,8 @@ export class StatsQuery { { range: { '@timestamp': { - gte: this.timerange.from, - lte: this.timerange.to, + gte: this.timeRange.from, + lte: this.timeRange.to, format: 'strict_date_optional_time', }, }, diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree/utils/fetch.test.ts b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree/utils/fetch.test.ts index d5e0af9dea239..796ed60ddbbc3 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree/utils/fetch.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree/utils/fetch.test.ts @@ -80,7 +80,7 @@ describe('fetcher test', () => { descendantLevels: 1, descendants: 5, ancestors: 0, - timerange: { + timeRange: { from: '', to: '', }, @@ -100,7 +100,7 @@ describe('fetcher test', () => { descendantLevels: 0, descendants: 0, ancestors: 0, - timerange: { + timeRange: { from: '', to: '', }, @@ -163,7 +163,7 @@ describe('fetcher test', () => { descendantLevels: 2, descendants: 5, ancestors: 0, - timerange: { + timeRange: { from: '', to: '', }, @@ -188,7 +188,7 @@ describe('fetcher test', () => { descendantLevels: 0, descendants: 0, ancestors: 5, - timerange: { + timeRange: { from: '', to: '', }, @@ -211,7 +211,7 @@ describe('fetcher test', () => { descendantLevels: 0, descendants: 0, ancestors: 0, - timerange: { + timeRange: { from: '', to: '', }, @@ -249,7 +249,7 @@ describe('fetcher test', () => { descendantLevels: 0, descendants: 0, ancestors: 2, - timerange: { + timeRange: { from: '', to: '', }, @@ -292,7 +292,7 @@ describe('fetcher test', () => { descendantLevels: 0, descendants: 0, ancestors: 2, - timerange: { + timeRange: { from: '', to: '', }, @@ -342,7 +342,7 @@ describe('fetcher test', () => { descendantLevels: 0, descendants: 0, ancestors: 3, - timerange: { + timeRange: { from: '', to: '', }, diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree/utils/fetch.ts b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree/utils/fetch.ts index 356357082d6ee..2ff231892a593 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree/utils/fetch.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree/utils/fetch.ts @@ -27,7 +27,7 @@ export interface TreeOptions { descendantLevels: number; descendants: number; ancestors: number; - timerange: { + timeRange: { from: string; to: string; }; @@ -76,7 +76,7 @@ export class Fetcher { const query = new StatsQuery({ indexPatterns: options.indexPatterns, schema: options.schema, - timerange: options.timerange, + timeRange: options.timeRange, }); const eventStats = await query.search(this.client, statsIDs); @@ -136,7 +136,7 @@ export class Fetcher { const query = new LifecycleQuery({ schema: options.schema, indexPatterns: options.indexPatterns, - timerange: options.timerange, + timeRange: options.timeRange, }); let nodes = options.nodes; @@ -182,7 +182,7 @@ export class Fetcher { const query = new DescendantsQuery({ schema: options.schema, indexPatterns: options.indexPatterns, - timerange: options.timerange, + timeRange: options.timeRange, }); let nodes: NodeID[] = options.nodes; diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree/utils/index.ts b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree/utils/index.ts index be08b4390a69c..c00e90a386fb6 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree/utils/index.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/tree/utils/index.ts @@ -9,7 +9,7 @@ import { ResolverSchema } from '../../../../../../common/endpoint/types'; /** * Represents a time range filter */ -export interface Timerange { +export interface TimeRange { from: string; to: string; } diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/utils/ancestry_query_handler.ts b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/utils/ancestry_query_handler.ts index 5bc911fb075b5..00aab683bf010 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/utils/ancestry_query_handler.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/utils/ancestry_query_handler.ts @@ -49,18 +49,18 @@ export class AncestryQueryHandler implements QueryHandler private toMapOfNodes(results: SafeResolverEvent[]) { return results.reduce( (nodes: Map, event: SafeResolverEvent) => { - const nodeId = entityIDSafeVersion(event); - if (!nodeId) { + const nodeID = entityIDSafeVersion(event); + if (!nodeID) { return nodes; } - let node = nodes.get(nodeId); + let node = nodes.get(nodeID); if (!node) { - node = createLifecycle(nodeId, []); + node = createLifecycle(nodeID, []); } node.lifecycle.push(event); - return nodes.set(nodeId, node); + return nodes.set(nodeID, node); }, new Map() ); diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 1ca0f7ec1f69c..eb1fd694114ce 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -17914,7 +17914,6 @@ "xpack.securitySolution.resolver.eventDescription.networkEventLabel": "{ networkDirection } { forwardedIP }", "xpack.securitySolution.resolver.eventDescription.registryKeyLabel": "{ registryKey }", "xpack.securitySolution.resolver.eventDescription.registryPathLabel": "{ registryPath }", - "xpack.securitySolution.resolver.node_icon": "{running, select, true {実行中のプロセス} false {終了したプロセス}}", "xpack.securitySolution.resolver.panel.copyToClipboard": "クリップボードにコピー", "xpack.securitySolution.resolver.panel.eventDetail.requestError": "イベント詳細を取得できませんでした", "xpack.securitySolution.resolver.panel.nodeList.title": "すべてのプロセスイベント", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 65e24a89350c3..8ad261449854e 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -17932,7 +17932,6 @@ "xpack.securitySolution.resolver.eventDescription.networkEventLabel": "{ networkDirection } { forwardedIP }", "xpack.securitySolution.resolver.eventDescription.registryKeyLabel": "{ registryKey }", "xpack.securitySolution.resolver.eventDescription.registryPathLabel": "{ registryPath }", - "xpack.securitySolution.resolver.node_icon": "{running, select, true {正在运行的进程} false {已终止的进程}}", "xpack.securitySolution.resolver.panel.copyToClipboard": "复制到剪贴板", "xpack.securitySolution.resolver.panel.eventDetail.requestError": "无法检索事件详情", "xpack.securitySolution.resolver.panel.nodeList.title": "所有进程事件", diff --git a/x-pack/test/functional/es_archives/endpoint/resolver/signals/data.json.gz b/x-pack/test/functional/es_archives/endpoint/resolver/signals/data.json.gz index e1b9c01101f6e..0000bc249b476 100644 Binary files a/x-pack/test/functional/es_archives/endpoint/resolver/signals/data.json.gz and b/x-pack/test/functional/es_archives/endpoint/resolver/signals/data.json.gz differ diff --git a/x-pack/test/functional/es_archives/endpoint/resolver/winlogbeat/data.json.gz b/x-pack/test/functional/es_archives/endpoint/resolver/winlogbeat/data.json.gz new file mode 100644 index 0000000000000..529ee42991f90 Binary files /dev/null and b/x-pack/test/functional/es_archives/endpoint/resolver/winlogbeat/data.json.gz differ diff --git a/x-pack/test/functional/es_archives/endpoint/resolver/winlogbeat/mappings.json b/x-pack/test/functional/es_archives/endpoint/resolver/winlogbeat/mappings.json new file mode 100644 index 0000000000000..a8673d85c3061 --- /dev/null +++ b/x-pack/test/functional/es_archives/endpoint/resolver/winlogbeat/mappings.json @@ -0,0 +1,2934 @@ +{ + "type": "index", + "value": { + "aliases": { + "winlogbeat-7.11.0-default": { + "is_write_index": true + } + }, + "index": "winlogbeat-7.11.0-2020.12.03-000001", + "mappings": { + "dynamic": "false", + "properties": { + "@timestamp": { + "type": "date" + }, + "agent": { + "properties": { + "ephemeral_id": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "as": { + "properties": { + "number": { + "type": "long" + }, + "organization": { + "properties": { + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "client": { + "properties": { + "address": { + "ignore_above": 1024, + "type": "keyword" + }, + "as": { + "properties": { + "number": { + "type": "long" + }, + "organization": { + "properties": { + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "bytes": { + "type": "long" + }, + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "geo": { + "properties": { + "city_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "continent_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "location": { + "type": "geo_point" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "ip": { + "type": "ip" + }, + "mac": { + "ignore_above": 1024, + "type": "keyword" + }, + "nat": { + "properties": { + "ip": { + "type": "ip" + }, + "port": { + "type": "long" + } + } + }, + "packets": { + "type": "long" + }, + "port": { + "type": "long" + }, + "registered_domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "top_level_domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "user": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "email": { + "ignore_above": 1024, + "type": "keyword" + }, + "full_name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "group": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "hash": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "cloud": { + "properties": { + "account": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "availability_zone": { + "ignore_above": 1024, + "type": "keyword" + }, + "instance": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "machine": { + "properties": { + "type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "provider": { + "ignore_above": 1024, + "type": "keyword" + }, + "region": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "code_signature": { + "properties": { + "exists": { + "type": "boolean" + }, + "status": { + "ignore_above": 1024, + "type": "keyword" + }, + "subject_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "trusted": { + "type": "boolean" + }, + "valid": { + "type": "boolean" + } + } + }, + "container": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "image": { + "properties": { + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "tag": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "labels": { + "type": "object" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "runtime": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "destination": { + "properties": { + "address": { + "ignore_above": 1024, + "type": "keyword" + }, + "as": { + "properties": { + "number": { + "type": "long" + }, + "organization": { + "properties": { + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "bytes": { + "type": "long" + }, + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "geo": { + "properties": { + "city_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "continent_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "location": { + "type": "geo_point" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "ip": { + "type": "ip" + }, + "mac": { + "ignore_above": 1024, + "type": "keyword" + }, + "nat": { + "properties": { + "ip": { + "type": "ip" + }, + "port": { + "type": "long" + } + } + }, + "packets": { + "type": "long" + }, + "port": { + "type": "long" + }, + "registered_domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "top_level_domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "user": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "email": { + "ignore_above": 1024, + "type": "keyword" + }, + "full_name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "group": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "hash": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "dll": { + "properties": { + "code_signature": { + "properties": { + "exists": { + "type": "boolean" + }, + "status": { + "ignore_above": 1024, + "type": "keyword" + }, + "subject_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "trusted": { + "type": "boolean" + }, + "valid": { + "type": "boolean" + } + } + }, + "hash": { + "properties": { + "md5": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha1": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha256": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha512": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "path": { + "ignore_above": 1024, + "type": "keyword" + }, + "pe": { + "properties": { + "company": { + "ignore_above": 1024, + "type": "keyword" + }, + "description": { + "ignore_above": 1024, + "type": "keyword" + }, + "file_version": { + "ignore_above": 1024, + "type": "keyword" + }, + "original_file_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "product": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "dns": { + "properties": { + "answers": { + "properties": { + "class": { + "ignore_above": 1024, + "type": "keyword" + }, + "data": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "ttl": { + "type": "long" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "header_flags": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "op_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "question": { + "properties": { + "class": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "registered_domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "subdomain": { + "ignore_above": 1024, + "type": "keyword" + }, + "top_level_domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "resolved_ip": { + "type": "ip" + }, + "response_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "ecs": { + "properties": { + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "error": { + "properties": { + "code": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "message": { + "norms": false, + "type": "text" + }, + "stack_trace": { + "doc_values": false, + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "index": false, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "event": { + "properties": { + "action": { + "ignore_above": 1024, + "type": "keyword" + }, + "category": { + "ignore_above": 1024, + "type": "keyword" + }, + "code": { + "ignore_above": 1024, + "type": "keyword" + }, + "created": { + "type": "date" + }, + "dataset": { + "ignore_above": 1024, + "type": "keyword" + }, + "duration": { + "type": "long" + }, + "end": { + "type": "date" + }, + "hash": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "ingested": { + "type": "date" + }, + "kind": { + "ignore_above": 1024, + "type": "keyword" + }, + "module": { + "ignore_above": 1024, + "type": "keyword" + }, + "original": { + "doc_values": false, + "ignore_above": 1024, + "index": false, + "type": "keyword" + }, + "outcome": { + "ignore_above": 1024, + "type": "keyword" + }, + "provider": { + "ignore_above": 1024, + "type": "keyword" + }, + "reference": { + "ignore_above": 1024, + "type": "keyword" + }, + "risk_score": { + "type": "float" + }, + "risk_score_norm": { + "type": "float" + }, + "sequence": { + "type": "long" + }, + "severity": { + "type": "long" + }, + "start": { + "type": "date" + }, + "timezone": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + }, + "url": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "file": { + "properties": { + "accessed": { + "type": "date" + }, + "attributes": { + "ignore_above": 1024, + "type": "keyword" + }, + "code_signature": { + "properties": { + "exists": { + "type": "boolean" + }, + "status": { + "ignore_above": 1024, + "type": "keyword" + }, + "subject_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "trusted": { + "type": "boolean" + }, + "valid": { + "type": "boolean" + } + } + }, + "created": { + "type": "date" + }, + "ctime": { + "type": "date" + }, + "device": { + "ignore_above": 1024, + "type": "keyword" + }, + "directory": { + "ignore_above": 1024, + "type": "keyword" + }, + "drive_letter": { + "ignore_above": 1, + "type": "keyword" + }, + "extension": { + "ignore_above": 1024, + "type": "keyword" + }, + "gid": { + "ignore_above": 1024, + "type": "keyword" + }, + "group": { + "ignore_above": 1024, + "type": "keyword" + }, + "hash": { + "properties": { + "md5": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha1": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha256": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha512": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "inode": { + "ignore_above": 1024, + "type": "keyword" + }, + "mime_type": { + "ignore_above": 1024, + "type": "keyword" + }, + "mode": { + "ignore_above": 1024, + "type": "keyword" + }, + "mtime": { + "type": "date" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "owner": { + "ignore_above": 1024, + "type": "keyword" + }, + "path": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "pe": { + "properties": { + "company": { + "ignore_above": 1024, + "type": "keyword" + }, + "description": { + "ignore_above": 1024, + "type": "keyword" + }, + "file_version": { + "ignore_above": 1024, + "type": "keyword" + }, + "original_file_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "product": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "size": { + "type": "long" + }, + "target_path": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + }, + "uid": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "geo": { + "properties": { + "city_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "continent_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "location": { + "type": "geo_point" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "group": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "hash": { + "properties": { + "md5": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha1": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha256": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha512": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "host": { + "properties": { + "architecture": { + "ignore_above": 1024, + "type": "keyword" + }, + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "geo": { + "properties": { + "city_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "continent_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "location": { + "type": "geo_point" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "hostname": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "ip": { + "type": "ip" + }, + "mac": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "os": { + "properties": { + "family": { + "ignore_above": 1024, + "type": "keyword" + }, + "full": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "kernel": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "platform": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + }, + "uptime": { + "type": "long" + }, + "user": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "email": { + "ignore_above": 1024, + "type": "keyword" + }, + "full_name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "group": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "hash": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "http": { + "properties": { + "request": { + "properties": { + "body": { + "properties": { + "bytes": { + "type": "long" + }, + "content": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "bytes": { + "type": "long" + }, + "method": { + "ignore_above": 1024, + "type": "keyword" + }, + "referrer": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "response": { + "properties": { + "body": { + "properties": { + "bytes": { + "type": "long" + }, + "content": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "bytes": { + "type": "long" + }, + "status_code": { + "type": "long" + } + } + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "interface": { + "properties": { + "alias": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "labels": { + "type": "object" + }, + "log": { + "properties": { + "level": { + "ignore_above": 1024, + "type": "keyword" + }, + "logger": { + "ignore_above": 1024, + "type": "keyword" + }, + "origin": { + "properties": { + "file": { + "properties": { + "line": { + "type": "integer" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "function": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "original": { + "doc_values": false, + "ignore_above": 1024, + "index": false, + "type": "keyword" + }, + "syslog": { + "properties": { + "facility": { + "properties": { + "code": { + "type": "long" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "priority": { + "type": "long" + }, + "severity": { + "properties": { + "code": { + "type": "long" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + } + } + }, + "message": { + "norms": false, + "type": "text" + }, + "network": { + "properties": { + "application": { + "ignore_above": 1024, + "type": "keyword" + }, + "bytes": { + "type": "long" + }, + "community_id": { + "ignore_above": 1024, + "type": "keyword" + }, + "direction": { + "ignore_above": 1024, + "type": "keyword" + }, + "forwarded_ip": { + "type": "ip" + }, + "iana_number": { + "ignore_above": 1024, + "type": "keyword" + }, + "inner": { + "properties": { + "vlan": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "packets": { + "type": "long" + }, + "protocol": { + "ignore_above": 1024, + "type": "keyword" + }, + "transport": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + }, + "vlan": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "observer": { + "properties": { + "egress": { + "properties": { + "interface": { + "properties": { + "alias": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "vlan": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "zone": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "geo": { + "properties": { + "city_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "continent_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "location": { + "type": "geo_point" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "hostname": { + "ignore_above": 1024, + "type": "keyword" + }, + "ingress": { + "properties": { + "interface": { + "properties": { + "alias": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "vlan": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "zone": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "ip": { + "type": "ip" + }, + "mac": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "os": { + "properties": { + "family": { + "ignore_above": 1024, + "type": "keyword" + }, + "full": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "kernel": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "platform": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "product": { + "ignore_above": 1024, + "type": "keyword" + }, + "serial_number": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + }, + "vendor": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "organization": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "os": { + "properties": { + "family": { + "ignore_above": 1024, + "type": "keyword" + }, + "full": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "kernel": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "platform": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "package": { + "properties": { + "architecture": { + "ignore_above": 1024, + "type": "keyword" + }, + "build_version": { + "ignore_above": 1024, + "type": "keyword" + }, + "checksum": { + "ignore_above": 1024, + "type": "keyword" + }, + "description": { + "ignore_above": 1024, + "type": "keyword" + }, + "install_scope": { + "ignore_above": 1024, + "type": "keyword" + }, + "installed": { + "type": "date" + }, + "license": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "path": { + "ignore_above": 1024, + "type": "keyword" + }, + "reference": { + "ignore_above": 1024, + "type": "keyword" + }, + "size": { + "type": "long" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "pe": { + "properties": { + "company": { + "ignore_above": 1024, + "type": "keyword" + }, + "description": { + "ignore_above": 1024, + "type": "keyword" + }, + "file_version": { + "ignore_above": 1024, + "type": "keyword" + }, + "original_file_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "product": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "process": { + "properties": { + "args": { + "ignore_above": 1024, + "type": "keyword" + }, + "args_count": { + "type": "long" + }, + "code_signature": { + "properties": { + "exists": { + "type": "boolean" + }, + "status": { + "ignore_above": 1024, + "type": "keyword" + }, + "subject_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "trusted": { + "type": "boolean" + }, + "valid": { + "type": "boolean" + } + } + }, + "command_line": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "executable": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "exit_code": { + "type": "long" + }, + "hash": { + "properties": { + "md5": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha1": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha256": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha512": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "parent": { + "properties": { + "args": { + "ignore_above": 1024, + "type": "keyword" + }, + "args_count": { + "type": "long" + }, + "code_signature": { + "properties": { + "exists": { + "type": "boolean" + }, + "status": { + "ignore_above": 1024, + "type": "keyword" + }, + "subject_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "trusted": { + "type": "boolean" + }, + "valid": { + "type": "boolean" + } + } + }, + "command_line": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "executable": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "exit_code": { + "type": "long" + }, + "hash": { + "properties": { + "md5": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha1": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha256": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha512": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "pgid": { + "type": "long" + }, + "pid": { + "type": "long" + }, + "ppid": { + "type": "long" + }, + "start": { + "type": "date" + }, + "thread": { + "properties": { + "id": { + "type": "long" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "title": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "uptime": { + "type": "long" + }, + "working_directory": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "pe": { + "properties": { + "company": { + "ignore_above": 1024, + "type": "keyword" + }, + "description": { + "ignore_above": 1024, + "type": "keyword" + }, + "file_version": { + "ignore_above": 1024, + "type": "keyword" + }, + "original_file_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "product": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "pgid": { + "type": "long" + }, + "pid": { + "type": "long" + }, + "ppid": { + "type": "long" + }, + "start": { + "type": "date" + }, + "thread": { + "properties": { + "id": { + "type": "long" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "title": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "uptime": { + "type": "long" + }, + "working_directory": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "registry": { + "properties": { + "data": { + "properties": { + "bytes": { + "ignore_above": 1024, + "type": "keyword" + }, + "strings": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "hive": { + "ignore_above": 1024, + "type": "keyword" + }, + "key": { + "ignore_above": 1024, + "type": "keyword" + }, + "path": { + "ignore_above": 1024, + "type": "keyword" + }, + "value": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "related": { + "properties": { + "hash": { + "ignore_above": 1024, + "type": "keyword" + }, + "ip": { + "type": "ip" + }, + "user": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "rule": { + "properties": { + "author": { + "ignore_above": 1024, + "type": "keyword" + }, + "category": { + "ignore_above": 1024, + "type": "keyword" + }, + "description": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "license": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "reference": { + "ignore_above": 1024, + "type": "keyword" + }, + "ruleset": { + "ignore_above": 1024, + "type": "keyword" + }, + "uuid": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "server": { + "properties": { + "address": { + "ignore_above": 1024, + "type": "keyword" + }, + "as": { + "properties": { + "number": { + "type": "long" + }, + "organization": { + "properties": { + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "bytes": { + "type": "long" + }, + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "geo": { + "properties": { + "city_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "continent_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "location": { + "type": "geo_point" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "ip": { + "type": "ip" + }, + "mac": { + "ignore_above": 1024, + "type": "keyword" + }, + "nat": { + "properties": { + "ip": { + "type": "ip" + }, + "port": { + "type": "long" + } + } + }, + "packets": { + "type": "long" + }, + "port": { + "type": "long" + }, + "registered_domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "top_level_domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "user": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "email": { + "ignore_above": 1024, + "type": "keyword" + }, + "full_name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "group": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "hash": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "service": { + "properties": { + "ephemeral_id": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "node": { + "properties": { + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "state": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "source": { + "properties": { + "address": { + "ignore_above": 1024, + "type": "keyword" + }, + "as": { + "properties": { + "number": { + "type": "long" + }, + "organization": { + "properties": { + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "bytes": { + "type": "long" + }, + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "geo": { + "properties": { + "city_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "continent_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "location": { + "type": "geo_point" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "ip": { + "type": "ip" + }, + "mac": { + "ignore_above": 1024, + "type": "keyword" + }, + "nat": { + "properties": { + "ip": { + "type": "ip" + }, + "port": { + "type": "long" + } + } + }, + "packets": { + "type": "long" + }, + "port": { + "type": "long" + }, + "registered_domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "top_level_domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "user": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "email": { + "ignore_above": 1024, + "type": "keyword" + }, + "full_name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "group": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "hash": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "tags": { + "ignore_above": 1024, + "type": "keyword" + }, + "threat": { + "properties": { + "framework": { + "ignore_above": 1024, + "type": "keyword" + }, + "tactic": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "reference": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "technique": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "reference": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "tls": { + "properties": { + "cipher": { + "ignore_above": 1024, + "type": "keyword" + }, + "client": { + "properties": { + "certificate": { + "ignore_above": 1024, + "type": "keyword" + }, + "certificate_chain": { + "ignore_above": 1024, + "type": "keyword" + }, + "hash": { + "properties": { + "md5": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha1": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha256": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "issuer": { + "ignore_above": 1024, + "type": "keyword" + }, + "ja3": { + "ignore_above": 1024, + "type": "keyword" + }, + "not_after": { + "type": "date" + }, + "not_before": { + "type": "date" + }, + "server_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "subject": { + "ignore_above": 1024, + "type": "keyword" + }, + "supported_ciphers": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "curve": { + "ignore_above": 1024, + "type": "keyword" + }, + "established": { + "type": "boolean" + }, + "next_protocol": { + "ignore_above": 1024, + "type": "keyword" + }, + "resumed": { + "type": "boolean" + }, + "server": { + "properties": { + "certificate": { + "ignore_above": 1024, + "type": "keyword" + }, + "certificate_chain": { + "ignore_above": 1024, + "type": "keyword" + }, + "hash": { + "properties": { + "md5": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha1": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha256": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "issuer": { + "ignore_above": 1024, + "type": "keyword" + }, + "ja3s": { + "ignore_above": 1024, + "type": "keyword" + }, + "not_after": { + "type": "date" + }, + "not_before": { + "type": "date" + }, + "subject": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + }, + "version_protocol": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "trace": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "transaction": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "url": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "extension": { + "ignore_above": 1024, + "type": "keyword" + }, + "fragment": { + "ignore_above": 1024, + "type": "keyword" + }, + "full": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "original": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "password": { + "ignore_above": 1024, + "type": "keyword" + }, + "path": { + "ignore_above": 1024, + "type": "keyword" + }, + "port": { + "type": "long" + }, + "query": { + "ignore_above": 1024, + "type": "keyword" + }, + "registered_domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "scheme": { + "ignore_above": 1024, + "type": "keyword" + }, + "top_level_domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "username": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "user": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "email": { + "ignore_above": 1024, + "type": "keyword" + }, + "full_name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "group": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "hash": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "user_agent": { + "properties": { + "device": { + "properties": { + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "original": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "os": { + "properties": { + "family": { + "ignore_above": 1024, + "type": "keyword" + }, + "full": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "kernel": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "platform": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "vlan": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "vulnerability": { + "properties": { + "category": { + "ignore_above": 1024, + "type": "keyword" + }, + "classification": { + "ignore_above": 1024, + "type": "keyword" + }, + "description": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "enumeration": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "reference": { + "ignore_above": 1024, + "type": "keyword" + }, + "report_id": { + "ignore_above": 1024, + "type": "keyword" + }, + "scanner": { + "properties": { + "vendor": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "score": { + "properties": { + "base": { + "type": "float" + }, + "environmental": { + "type": "float" + }, + "temporal": { + "type": "float" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "severity": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "settings": { + "index": { + "mapping": { + "total_fields": { + "limit": "10000" + } + }, + "number_of_replicas": "1", + "number_of_shards": "1", + "routing": { + "allocation": { + "include": { + "_tier": "data_hot" + } + } + } + } + } + } +} diff --git a/x-pack/test/security_solution_endpoint_api_int/apis/package.ts b/x-pack/test/security_solution_endpoint_api_int/apis/package.ts index 8dc78ed71d0b6..76361bd459890 100644 --- a/x-pack/test/security_solution_endpoint_api_int/apis/package.ts +++ b/x-pack/test/security_solution_endpoint_api_int/apis/package.ts @@ -106,10 +106,21 @@ export default function ({ getService }: FtrProviderContext) { // this id comes from the es archive file endpoint/pipeline/dns const id = 'LrLSOVHVsFY94TAi++++++eF'; const { body }: { body: ResolverPaginatedEvents } = await supertest - .post(`/api/endpoint/resolver/events?limit=1`) + .post(`/api/endpoint/resolver/events`) + .query({ limit: 1 }) .set('kbn-xsrf', 'xxx') .send({ - filter: `event.id:"${id}"`, + filter: JSON.stringify({ + bool: { + filter: [{ term: { 'event.id': id } }], + }, + }), + indexPatterns: [eventsIndexPattern], + // these times are taken from the es archiver data endpoint/pipeline/dns for this specific event + timeRange: { + from: '2020-10-01T13:50:15.14364600Z', + to: '2020-10-01T13:50:15.14364600Z', + }, }) .expect(200); expect(body.events.length).to.eql(1); @@ -121,10 +132,21 @@ export default function ({ getService }: FtrProviderContext) { // this id comes from the es archive file endpoint/pipeline/dns const id = 'LrLSOVHVsFY94TAi++++++eP'; const { body }: { body: ResolverPaginatedEvents } = await supertest - .post(`/api/endpoint/resolver/events?limit=1`) + .post(`/api/endpoint/resolver/events`) + .query({ limit: 1 }) .set('kbn-xsrf', 'xxx') .send({ - filter: `event.id:"${id}"`, + filter: JSON.stringify({ + bool: { + filter: [{ term: { 'event.id': id } }], + }, + }), + indexPatterns: [eventsIndexPattern], + // these times are taken from the es archiver data endpoint/pipeline/dns for this specific event + timeRange: { + from: '2020-10-01T13:50:15.44516300Z', + to: '2020-10-01T13:50:15.44516300Z', + }, }) .expect(200); expect(body.events.length).to.eql(1); diff --git a/x-pack/test/security_solution_endpoint_api_int/apis/resolver/entity.ts b/x-pack/test/security_solution_endpoint_api_int/apis/resolver/entity.ts index 2607b934e7df2..f26e2410b6c55 100644 --- a/x-pack/test/security_solution_endpoint_api_int/apis/resolver/entity.ts +++ b/x-pack/test/security_solution_endpoint_api_int/apis/resolver/entity.ts @@ -13,52 +13,93 @@ export default function ({ getService }: FtrProviderContext) { const esArchiver = getService('esArchiver'); describe('Resolver tests for the entity route', () => { - before(async () => { - await esArchiver.load('endpoint/resolver/signals'); - }); + describe('winlogbeat tests', () => { + before(async () => { + await esArchiver.load('endpoint/resolver/winlogbeat'); + }); - after(async () => { - await esArchiver.unload('endpoint/resolver/signals'); - }); + after(async () => { + await esArchiver.unload('endpoint/resolver/winlogbeat'); + }); - it('returns an event even if it does not have a mapping for entity_id', async () => { - // this id is from the es archive - const _id = 'fa7eb1546f44fd47d8868be8d74e0082e19f22df493c67a7725457978eb648ab'; - const { body }: { body: ResolverEntityIndex } = await supertest.get( - `/api/endpoint/resolver/entity?_id=${_id}&indices=${eventsIndexPattern}&indices=.siem-signals-default` - ); - expect(body).eql([ - { - name: 'endpoint', - schema: { - id: 'process.entity_id', - parent: 'process.parent.entity_id', - ancestry: 'process.Ext.ancestry', - name: 'process.name', + it('returns a winlogbeat sysmon event when the event matches the schema correctly', async () => { + // this id is from the es archive + const _id = 'sysmon-event'; + const { body }: { body: ResolverEntityIndex } = await supertest.get( + `/api/endpoint/resolver/entity?_id=${_id}&indices=${eventsIndexPattern}&indices=winlogbeat-7.11.0-default` + ); + expect(body).eql([ + { + name: 'winlogbeat', + schema: { + id: 'process.entity_id', + parent: 'process.parent.entity_id', + name: 'process.name', + }, + // this value is from the es archive + id: '{98da333e-2060-5fc9-2e01-000000003f00}', }, - // this value is from the es archive - id: - 'MTIwNWY1NWQtODRkYS00MzkxLWIyNWQtYTNkNGJmNDBmY2E1LTc1NTItMTMyNDM1NDY1MTQuNjI0MjgxMDA=', - }, - ]); - }); + ]); + }); - it('does not return an event when it does not have the entity_id field in the document', async () => { - // this id is from the es archive - const _id = 'no-entity-id-field'; - const { body }: { body: ResolverEntityIndex } = await supertest.get( - `/api/endpoint/resolver/entity?_id=${_id}&indices=${eventsIndexPattern}&indices=.siem-signals-default` - ); - expect(body).to.be.empty(); + it('does not return a powershell event that has event.module set to powershell', async () => { + // this id is from the es archive + const _id = 'powershell-event'; + const { body }: { body: ResolverEntityIndex } = await supertest.get( + `/api/endpoint/resolver/entity?_id=${_id}&indices=${eventsIndexPattern}&indices=winlogbeat-7.11.0-default` + ); + expect(body).to.be.empty(); + }); }); - it('does not return an event when it does not have the process field in the document', async () => { - // this id is from the es archive - const _id = 'no-process-field'; - const { body }: { body: ResolverEntityIndex } = await supertest.get( - `/api/endpoint/resolver/entity?_id=${_id}&indices=${eventsIndexPattern}&indices=.siem-signals-default` - ); - expect(body).to.be.empty(); + describe('signals index mapping tests', () => { + before(async () => { + await esArchiver.load('endpoint/resolver/signals'); + }); + + after(async () => { + await esArchiver.unload('endpoint/resolver/signals'); + }); + + it('returns an event even if it does not have a mapping for entity_id', async () => { + // this id is from the es archive + const _id = 'fa7eb1546f44fd47d8868be8d74e0082e19f22df493c67a7725457978eb648ab'; + const { body }: { body: ResolverEntityIndex } = await supertest.get( + `/api/endpoint/resolver/entity?_id=${_id}&indices=${eventsIndexPattern}&indices=.siem-signals-default` + ); + expect(body).eql([ + { + name: 'endpoint', + schema: { + id: 'process.entity_id', + parent: 'process.parent.entity_id', + ancestry: 'process.Ext.ancestry', + name: 'process.name', + }, + // this value is from the es archive + id: + 'MTIwNWY1NWQtODRkYS00MzkxLWIyNWQtYTNkNGJmNDBmY2E1LTc1NTItMTMyNDM1NDY1MTQuNjI0MjgxMDA=', + }, + ]); + }); + + it('does not return an event when it does not have the entity_id field in the document', async () => { + // this id is from the es archive + const _id = 'no-entity-id-field'; + const { body }: { body: ResolverEntityIndex } = await supertest.get( + `/api/endpoint/resolver/entity?_id=${_id}&indices=${eventsIndexPattern}&indices=.siem-signals-default` + ); + expect(body).to.be.empty(); + }); + + it('does not return an event when it does not have the process field in the document', async () => { + // this id is from the es archive + const _id = 'no-process-field'; + const { body }: { body: ResolverEntityIndex } = await supertest.get( + `/api/endpoint/resolver/entity?_id=${_id}&indices=${eventsIndexPattern}&indices=.siem-signals-default` + ); + expect(body).to.be.empty(); + }); }); }); } diff --git a/x-pack/test/security_solution_endpoint_api_int/apis/resolver/events.ts b/x-pack/test/security_solution_endpoint_api_int/apis/resolver/events.ts index 0878c09cff500..220d932787fff 100644 --- a/x-pack/test/security_solution_endpoint_api_int/apis/resolver/events.ts +++ b/x-pack/test/security_solution_endpoint_api_int/apis/resolver/events.ts @@ -4,7 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ import expect from '@kbn/expect'; -import { eventIDSafeVersion } from '../../../../plugins/security_solution/common/endpoint/models/event'; +import { JsonObject } from 'src/plugins/kibana_utils/common'; +import { eventsIndexPattern } from '../../../../plugins/security_solution/common/endpoint/constants'; +import { + eventIDSafeVersion, + parentEntityIDSafeVersion, + timestampAsDateSafeVersion, +} from '../../../../plugins/security_solution/common/endpoint/models/event'; import { ResolverPaginatedEvents } from '../../../../plugins/security_solution/common/endpoint/types'; import { FtrProviderContext } from '../../ftr_provider_context'; import { @@ -41,23 +47,42 @@ export default function ({ getService }: FtrProviderContext) { }; describe('event route', () => { + let entityIDFilterArray: JsonObject[] | undefined; let entityIDFilter: string | undefined; before(async () => { resolverTrees = await resolver.createTrees(treeOptions); // we only requested a single alert so there's only 1 tree tree = resolverTrees.trees[0]; - entityIDFilter = `process.entity_id:"${tree.origin.id}" and not event.category:"process"`; + entityIDFilterArray = [ + { term: { 'process.entity_id': tree.origin.id } }, + { bool: { must_not: { term: { 'event.category': 'process' } } } }, + ]; + entityIDFilter = JSON.stringify({ + bool: { + filter: entityIDFilterArray, + }, + }); }); after(async () => { await resolver.deleteData(resolverTrees); }); it('should filter events by event.id', async () => { + const filter = JSON.stringify({ + bool: { + filter: [{ term: { 'event.id': tree.origin.relatedEvents[0]?.event?.id } }], + }, + }); const { body }: { body: ResolverPaginatedEvents } = await supertest .post(`/api/endpoint/resolver/events`) .set('kbn-xsrf', 'xxx') .send({ - filter: `event.id:"${tree.origin.relatedEvents[0]?.event?.id}"`, + filter, + indexPatterns: [eventsIndexPattern], + timeRange: { + from: tree.startTime, + to: tree.endTime, + }, }) .expect(200); expect(body.events.length).to.eql(1); @@ -66,11 +91,21 @@ export default function ({ getService }: FtrProviderContext) { }); it('should not find any events when given an invalid entity id', async () => { + const filter = JSON.stringify({ + bool: { + filter: [{ term: { 'process.entity_id': '5555' } }], + }, + }); const { body }: { body: ResolverPaginatedEvents } = await supertest .post(`/api/endpoint/resolver/events`) .set('kbn-xsrf', 'xxx') .send({ - filter: 'process.entity_id:"5555"', + filter, + indexPatterns: [eventsIndexPattern], + timeRange: { + from: tree.startTime, + to: tree.endTime, + }, }) .expect(200); expect(body.nextEvent).to.eql(null); @@ -83,6 +118,11 @@ export default function ({ getService }: FtrProviderContext) { .set('kbn-xsrf', 'xxx') .send({ filter: entityIDFilter, + indexPatterns: [eventsIndexPattern], + timeRange: { + from: tree.startTime, + to: tree.endTime, + }, }) .expect(200); expect(body.events.length).to.eql(4); @@ -91,12 +131,24 @@ export default function ({ getService }: FtrProviderContext) { }); it('should allow for the events to be filtered', async () => { - const filter = `event.category:"${RelatedEventCategory.Driver}" and ${entityIDFilter}`; + const filter = JSON.stringify({ + bool: { + filter: [ + { term: { 'event.category': RelatedEventCategory.Driver } }, + ...(entityIDFilterArray ?? []), + ], + }, + }); const { body }: { body: ResolverPaginatedEvents } = await supertest .post(`/api/endpoint/resolver/events`) .set('kbn-xsrf', 'xxx') .send({ filter, + indexPatterns: [eventsIndexPattern], + timeRange: { + from: tree.startTime, + to: tree.endTime, + }, }) .expect(200); expect(body.events.length).to.eql(2); @@ -113,6 +165,11 @@ export default function ({ getService }: FtrProviderContext) { .set('kbn-xsrf', 'xxx') .send({ filter: entityIDFilter, + indexPatterns: [eventsIndexPattern], + timeRange: { + from: tree.startTime, + to: tree.endTime, + }, }) .expect(200); expect(body.events.length).to.eql(2); @@ -124,6 +181,11 @@ export default function ({ getService }: FtrProviderContext) { .set('kbn-xsrf', 'xxx') .send({ filter: entityIDFilter, + indexPatterns: [eventsIndexPattern], + timeRange: { + from: tree.startTime, + to: tree.endTime, + }, }) .expect(200)); expect(body.events.length).to.eql(2); @@ -135,6 +197,11 @@ export default function ({ getService }: FtrProviderContext) { .set('kbn-xsrf', 'xxx') .send({ filter: entityIDFilter, + indexPatterns: [eventsIndexPattern], + timeRange: { + from: tree.startTime, + to: tree.endTime, + }, }) .expect(200)); expect(body.events).to.be.empty(); @@ -147,6 +214,11 @@ export default function ({ getService }: FtrProviderContext) { .set('kbn-xsrf', 'xxx') .send({ filter: entityIDFilter, + indexPatterns: [eventsIndexPattern], + timeRange: { + from: tree.startTime, + to: tree.endTime, + }, }) .expect(200); expect(body.events.length).to.eql(4); @@ -160,6 +232,11 @@ export default function ({ getService }: FtrProviderContext) { .set('kbn-xsrf', 'xxx') .send({ filter: entityIDFilter, + indexPatterns: [eventsIndexPattern], + timeRange: { + from: tree.startTime, + to: tree.endTime, + }, }) .expect(200); expect(body.events.length).to.eql(4); @@ -171,5 +248,122 @@ export default function ({ getService }: FtrProviderContext) { expect(eventIDSafeVersion(body.events[i])).to.equal(relatedEvents[i].event?.id); } }); + + it('should only return data within the specified timeRange', async () => { + const from = + timestampAsDateSafeVersion(tree.origin.relatedEvents[0])?.toISOString() ?? + new Date(0).toISOString(); + const to = from; + const { body }: { body: ResolverPaginatedEvents } = await supertest + .post(`/api/endpoint/resolver/events`) + .set('kbn-xsrf', 'xxx') + .send({ + filter: entityIDFilter, + indexPatterns: [eventsIndexPattern], + timeRange: { + from, + to, + }, + }) + .expect(200); + expect(body.events.length).to.eql(1); + expect(tree.origin.relatedEvents[0]?.event?.id).to.eql(body.events[0].event?.id); + expect(body.nextEvent).to.eql(null); + }); + + it('should not find events when using an incorrect index pattern', async () => { + const { body }: { body: ResolverPaginatedEvents } = await supertest + .post(`/api/endpoint/resolver/events`) + .set('kbn-xsrf', 'xxx') + .send({ + filter: entityIDFilter, + indexPatterns: ['metrics-*'], + timeRange: { + from: tree.startTime, + to: tree.endTime, + }, + }) + .expect(200); + expect(body.events.length).to.eql(0); + expect(body.nextEvent).to.eql(null); + }); + + it('should retrieve lifecycle events for multiple ids', async () => { + const originParentID = parentEntityIDSafeVersion(tree.origin.lifecycle[0]) ?? ''; + expect(originParentID).to.not.be(''); + const { body }: { body: ResolverPaginatedEvents } = await supertest + .post(`/api/endpoint/resolver/events`) + .set('kbn-xsrf', 'xxx') + .send({ + filter: JSON.stringify({ + bool: { + filter: [ + { terms: { 'process.entity_id': [tree.origin.id, originParentID] } }, + { term: { 'event.category': 'process' } }, + ], + }, + }), + indexPatterns: [eventsIndexPattern], + timeRange: { + from: tree.startTime, + to: tree.endTime, + }, + }) + .expect(200); + // 2 lifecycle events for the origin and 2 for the origin's parent + expect(body.events.length).to.eql(4); + expect(body.nextEvent).to.eql(null); + }); + + it('should paginate lifecycle events for multiple ids', async () => { + const originParentID = parentEntityIDSafeVersion(tree.origin.lifecycle[0]) ?? ''; + expect(originParentID).to.not.be(''); + let { body }: { body: ResolverPaginatedEvents } = await supertest + .post(`/api/endpoint/resolver/events`) + .query({ limit: 2 }) + .set('kbn-xsrf', 'xxx') + .send({ + filter: JSON.stringify({ + bool: { + filter: [ + { terms: { 'process.entity_id': [tree.origin.id, originParentID] } }, + { term: { 'event.category': 'process' } }, + ], + }, + }), + indexPatterns: [eventsIndexPattern], + timeRange: { + from: tree.startTime, + to: tree.endTime, + }, + }) + .expect(200); + expect(body.events.length).to.eql(2); + expect(body.nextEvent).not.to.eql(null); + + ({ body } = await supertest + .post(`/api/endpoint/resolver/events`) + .query({ limit: 3, afterEvent: body.nextEvent }) + .set('kbn-xsrf', 'xxx') + .send({ + filter: JSON.stringify({ + bool: { + filter: [ + { terms: { 'process.entity_id': [tree.origin.id, originParentID] } }, + { term: { 'event.category': 'process' } }, + ], + }, + }), + indexPatterns: [eventsIndexPattern], + timeRange: { + from: tree.startTime, + to: tree.endTime, + }, + }) + .expect(200)); + + expect(body.events.length).to.eql(2); + expect(body.nextEvent).to.eql(null); + }); }); } diff --git a/x-pack/test/security_solution_endpoint_api_int/apis/resolver/tree.ts b/x-pack/test/security_solution_endpoint_api_int/apis/resolver/tree.ts index 7a1210c6b762f..9a731f1d5aee0 100644 --- a/x-pack/test/security_solution_endpoint_api_int/apis/resolver/tree.ts +++ b/x-pack/test/security_solution_endpoint_api_int/apis/resolver/tree.ts @@ -84,7 +84,7 @@ export default function ({ getService }: FtrProviderContext) { ancestors: 9, schema: schemaWithAncestry, nodes: [tree.origin.id], - timerange: { + timeRange: { from: tree.startTime.toISOString(), to: tree.endTime.toISOString(), }, @@ -109,7 +109,7 @@ export default function ({ getService }: FtrProviderContext) { ancestors: 9, schema: schemaWithAncestry, nodes: ['bogus id'], - timerange: { + timeRange: { from: tree.startTime.toISOString(), to: tree.endTime.toISOString(), }, @@ -130,7 +130,7 @@ export default function ({ getService }: FtrProviderContext) { ancestors: 3, schema: schemaWithAncestry, nodes: [tree.origin.id], - timerange: { + timeRange: { from: tree.startTime.toISOString(), to: tree.endTime.toISOString(), }, @@ -155,7 +155,7 @@ export default function ({ getService }: FtrProviderContext) { ancestors: 50, schema: schemaWithoutAncestry, nodes: [tree.origin.id], - timerange: { + timeRange: { from: tree.startTime.toISOString(), to: tree.endTime.toISOString(), }, @@ -183,7 +183,7 @@ export default function ({ getService }: FtrProviderContext) { ancestors: 50, schema: schemaWithoutAncestry, nodes: [tree.origin.id], - timerange: { + timeRange: { from, to: from, }, @@ -210,7 +210,7 @@ export default function ({ getService }: FtrProviderContext) { ancestors: 50, schema: schemaWithoutAncestry, nodes: [tree.origin.id, bottomMostDescendant], - timerange: { + timeRange: { from: tree.startTime.toISOString(), to: tree.endTime.toISOString(), }, @@ -246,7 +246,7 @@ export default function ({ getService }: FtrProviderContext) { ancestors: 50, schema: schemaWithoutAncestry, nodes: [leftNode, rightNode], - timerange: { + timeRange: { from: tree.startTime.toISOString(), to: tree.endTime.toISOString(), }, @@ -277,7 +277,7 @@ export default function ({ getService }: FtrProviderContext) { ancestors: 50, schema: schemaWithoutAncestry, nodes: [tree.origin.id], - timerange: { + timeRange: { from: tree.startTime.toISOString(), to: tree.endTime.toISOString(), }, @@ -299,7 +299,7 @@ export default function ({ getService }: FtrProviderContext) { ancestors: 0, schema: schemaWithoutAncestry, nodes: [tree.origin.id], - timerange: { + timeRange: { from: tree.startTime.toISOString(), to: tree.endTime.toISOString(), }, @@ -329,7 +329,7 @@ export default function ({ getService }: FtrProviderContext) { ancestors: 0, schema: schemaWithAncestry, nodes: [tree.origin.id], - timerange: { + timeRange: { from: tree.startTime.toISOString(), to: tree.endTime.toISOString(), }, @@ -358,7 +358,7 @@ export default function ({ getService }: FtrProviderContext) { ancestors: 0, schema: schemaWithAncestry, nodes: ['bogus id'], - timerange: { + timeRange: { from: tree.startTime.toISOString(), to: tree.endTime.toISOString(), }, @@ -381,7 +381,7 @@ export default function ({ getService }: FtrProviderContext) { ancestors: 0, schema: schemaWithoutAncestry, nodes: [childID], - timerange: { + timeRange: { from: tree.startTime.toISOString(), to: tree.endTime.toISOString(), }, @@ -414,7 +414,7 @@ export default function ({ getService }: FtrProviderContext) { ancestors: 0, schema: schemaWithAncestry, nodes: [leftNodeID, rightNodeID], - timerange: { + timeRange: { from: tree.startTime.toISOString(), to: tree.endTime.toISOString(), }, @@ -447,7 +447,7 @@ export default function ({ getService }: FtrProviderContext) { ancestors: 0, schema: schemaWithAncestry, nodes: [tree.origin.id, originGrandparent], - timerange: { + timeRange: { from: tree.startTime.toISOString(), to: tree.endTime.toISOString(), }, @@ -484,7 +484,7 @@ export default function ({ getService }: FtrProviderContext) { ancestors: 0, schema: schemaWithoutAncestry, nodes: [tree.origin.id, originGrandparent], - timerange: { + timeRange: { from: tree.startTime.toISOString(), to: tree.endTime.toISOString(), }, @@ -520,7 +520,7 @@ export default function ({ getService }: FtrProviderContext) { ancestors: 0, schema: schemaWithoutAncestry, nodes: [tree.origin.id], - timerange: { + timeRange: { from: tree.startTime.toISOString(), to: end, }, @@ -549,7 +549,7 @@ export default function ({ getService }: FtrProviderContext) { ancestors: 50, schema: schemaWithName, nodes: [tree.origin.id], - timerange: { + timeRange: { from: tree.startTime.toISOString(), to: tree.endTime.toISOString(), }, @@ -587,7 +587,7 @@ export default function ({ getService }: FtrProviderContext) { ancestors: 50, schema: schemaWithoutAncestry, nodes: [tree.origin.id], - timerange: { + timeRange: { from: tree.startTime.toISOString(), to: tree.endTime.toISOString(), }, @@ -625,7 +625,7 @@ export default function ({ getService }: FtrProviderContext) { ancestors: 50, schema: schemaWithAncestry, nodes: [tree.origin.id], - timerange: { + timeRange: { from: tree.startTime.toISOString(), to: tree.endTime.toISOString(), }, @@ -663,7 +663,7 @@ export default function ({ getService }: FtrProviderContext) { ancestors: 0, schema: schemaWithAncestry, nodes: [tree.origin.id], - timerange: { + timeRange: { from: tree.startTime.toISOString(), to: tree.endTime.toISOString(), },