From 619155c02a9a15f6bad68ef185392636a9c92cba Mon Sep 17 00:00:00 2001 From: Robert Austin Date: Wed, 15 Jul 2020 16:35:50 -0400 Subject: [PATCH] [Resolver] aria-level and aria-flowto support enhancements (#71887) * `IndexedProcessTree` now owns the concern of defining the order of siblings * `IsometricTaxiLayout` now owns the concept of `ariaLevels` * added `datetime` method to `process_event` model which returns a time in ms since unix epoch for the event * renamed some resolver selectors * added resolver selector: `ariaLevel` * added 'data' selector: `followingSibling` (used for aria-flowto) * added resolver selector `ariaFlowtoNodeID` which takes a nodeID, and returns its following sibling's node id (if that sibling is visible.) By only returning visible siblings, we ensure that `aria-flowto` will point to an html ID that is in the dom. --- .../common/endpoint/models/event.ts | 4 +- .../isometric_taxi_layout.test.ts.snap | 175 ++++++++++++ .../models/indexed_process_tree/index.ts | 124 ++++----- .../isometric_taxi_layout.test.ts | 19 ++ .../isometric_taxi_layout.ts | 25 ++ .../resolver/models/process_event.test.ts | 86 +++++- .../public/resolver/models/process_event.ts | 32 +++ .../public/resolver/store/data/selectors.ts | 171 +++++++----- .../store/data/visible_entities.test.ts | 18 +- .../public/resolver/store/methods.ts | 4 +- .../public/resolver/store/selectors.test.ts | 259 ++++++++++++++++++ .../public/resolver/store/selectors.ts | 91 ++++-- .../public/resolver/types.ts | 27 +- .../public/resolver/view/map.tsx | 16 +- .../panels/panel_content_process_list.tsx | 2 +- .../panels/panel_content_related_list.tsx | 7 +- .../resolver/view/process_event_dot.tsx | 87 +++--- .../public/resolver/view/use_camera.test.tsx | 4 +- .../view/use_resolver_query_params.ts | 4 +- .../routes/resolver/utils/pagination.ts | 2 +- 20 files changed, 894 insertions(+), 263 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/resolver/store/selectors.test.ts 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 f8a6807196557..f53da8fb1f096 100644 --- a/x-pack/plugins/security_solution/common/endpoint/models/event.ts +++ b/x-pack/plugins/security_solution/common/endpoint/models/event.ts @@ -32,9 +32,9 @@ export function eventName(event: ResolverEvent): string { } } -export function eventId(event: ResolverEvent): string { +export function eventId(event: ResolverEvent): number | undefined | string { if (isLegacyEvent(event)) { - return event.endgame.serial_event_id ? String(event.endgame.serial_event_id) : ''; + return event.endgame.serial_event_id; } return event.event.id; } 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 74efb41c4c595..6f26bfe063c05 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 @@ -2,6 +2,7 @@ exports[`resolver graph layout when rendering no nodes renders right 1`] = ` Object { + "ariaLevels": Map {}, "edgeLineSegments": Array [], "processNodePositions": Map {}, } @@ -9,6 +10,22 @@ Object { 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, + }, + } => 1, + }, "edgeLineSegments": Array [], "processNodePositions": Map { Object { @@ -34,6 +51,134 @@ Object { exports[`resolver graph layout when rendering two forks, and one fork has an extra long tine 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, + }, + } => 1, + 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, + }, + } => 2, + Object { + "@timestamp": 1582233383000, + "agent": Object { + "id": "", + "type": "", + "version": "", + }, + "endgame": Object { + "event_subtype_full": "creation_event", + "event_type_full": "process_event", + "unique_pid": 2, + "unique_ppid": 0, + }, + } => 2, + Object { + "@timestamp": 1582233383000, + "agent": Object { + "id": "", + "type": "", + "version": "", + }, + "endgame": Object { + "event_subtype_full": "termination_event", + "event_type_full": "process_event", + "unique_pid": 8, + "unique_ppid": 0, + }, + } => 2, + Object { + "@timestamp": 1582233383000, + "agent": Object { + "id": "", + "type": "", + "version": "", + }, + "endgame": Object { + "event_subtype_full": "creation_event", + "event_type_full": "process_event", + "unique_pid": 3, + "unique_ppid": 1, + }, + } => 3, + Object { + "@timestamp": 1582233383000, + "agent": Object { + "id": "", + "type": "", + "version": "", + }, + "endgame": Object { + "event_subtype_full": "creation_event", + "event_type_full": "process_event", + "unique_pid": 4, + "unique_ppid": 1, + }, + } => 3, + Object { + "@timestamp": 1582233383000, + "agent": Object { + "id": "", + "type": "", + "version": "", + }, + "endgame": Object { + "event_subtype_full": "creation_event", + "event_type_full": "process_event", + "unique_pid": 5, + "unique_ppid": 2, + }, + } => 3, + Object { + "@timestamp": 1582233383000, + "agent": Object { + "id": "", + "type": "", + "version": "", + }, + "endgame": Object { + "event_subtype_full": "creation_event", + "event_type_full": "process_event", + "unique_pid": 6, + "unique_ppid": 2, + }, + } => 3, + Object { + "@timestamp": 1582233383000, + "agent": Object { + "id": "", + "type": "", + "version": "", + }, + "endgame": Object { + "event_subtype_full": "creation_event", + "event_type_full": "process_event", + "unique_pid": 7, + "unique_ppid": 6, + }, + } => 4, + }, "edgeLineSegments": Array [ Object { "metadata": Object { @@ -406,6 +551,36 @@ Object { exports[`resolver graph layout when rendering two nodes, one being the parent of the other 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, + }, + } => 1, + 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, + }, + } => 2, + }, "edgeLineSegments": Array [ Object { "metadata": Object { 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 b322de0f34526..35a32d91d8a02 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 @@ -4,99 +4,46 @@ * you may not use this file except in compliance with the Elastic License. */ -import { uniquePidForProcess, uniqueParentPidForProcess } from '../process_event'; -import { IndexedProcessTree, AdjacentProcessMap } from '../../types'; +import { uniquePidForProcess, uniqueParentPidForProcess, orderByTime } from '../process_event'; +import { IndexedProcessTree } from '../../types'; import { ResolverEvent } from '../../../../common/endpoint/types'; import { levelOrder as baseLevelOrder } from '../../lib/tree_sequencers'; /** - * Create a new IndexedProcessTree from an array of ProcessEvents + * Create a new IndexedProcessTree from an array of ProcessEvents. + * siblings will be ordered by timestamp */ -export function factory(processes: ResolverEvent[]): IndexedProcessTree { +export function factory( + // Array of processes to index as a tree + processes: ResolverEvent[] +): IndexedProcessTree { const idToChildren = new Map(); const idToValue = new Map(); - const idToAdjacent = new Map(); - - function emptyAdjacencyMap(id: string): AdjacentProcessMap { - return { - self: id, - parent: null, - firstChild: null, - previousSibling: null, - nextSibling: null, - level: 1, - }; - } - - const roots: ResolverEvent[] = []; for (const process of processes) { const uniqueProcessPid = uniquePidForProcess(process); idToValue.set(uniqueProcessPid, process); - const currentProcessAdjacencyMap: AdjacentProcessMap = - idToAdjacent.get(uniqueProcessPid) || emptyAdjacencyMap(uniqueProcessPid); - idToAdjacent.set(uniqueProcessPid, currentProcessAdjacencyMap); - const uniqueParentPid = uniqueParentPidForProcess(process); - const currentProcessSiblings = idToChildren.get(uniqueParentPid); - - if (currentProcessSiblings) { - const previousProcessId = uniquePidForProcess( - currentProcessSiblings[currentProcessSiblings.length - 1] - ); - currentProcessSiblings.push(process); - /** - * Update adjacency maps for current and previous entries - */ - idToAdjacent.get(previousProcessId)!.nextSibling = uniqueProcessPid; - currentProcessAdjacencyMap.previousSibling = previousProcessId; - if (uniqueParentPid) { - currentProcessAdjacencyMap.parent = uniqueParentPid; + // if its defined and not '' + if (uniqueParentPid) { + let siblings = idToChildren.get(uniqueParentPid); + if (!siblings) { + siblings = []; + idToChildren.set(uniqueParentPid, siblings); } - } else { - if (uniqueParentPid) { - idToChildren.set(uniqueParentPid, [process]); - /** - * Get the parent's map, otherwise set an empty one - */ - const parentAdjacencyMap = - idToAdjacent.get(uniqueParentPid) || - (idToAdjacent.set(uniqueParentPid, emptyAdjacencyMap(uniqueParentPid)), - idToAdjacent.get(uniqueParentPid))!; - // set firstChild for parent - parentAdjacencyMap.firstChild = uniqueProcessPid; - // set parent for current - currentProcessAdjacencyMap.parent = uniqueParentPid || null; - } else { - // In this case (no unique parent id), it must be a root - roots.push(process); - } - } - } - - /** - * Scan adjacency maps from the top down and assign levels - */ - function traverseLevels(currentProcessMap: AdjacentProcessMap, level: number = 1): void { - const nextLevel = level + 1; - if (currentProcessMap.nextSibling) { - traverseLevels(idToAdjacent.get(currentProcessMap.nextSibling)!, level); - } - if (currentProcessMap.firstChild) { - traverseLevels(idToAdjacent.get(currentProcessMap.firstChild)!, nextLevel); + siblings.push(process); } - currentProcessMap.level = level; } - for (const treeRoot of roots) { - traverseLevels(idToAdjacent.get(uniquePidForProcess(treeRoot))!); + // sort the children of each node + for (const siblings of idToChildren.values()) { + siblings.sort(orderByTime); } return { idToChildren, idToProcess: idToValue, - idToAdjacent, }; } @@ -109,6 +56,13 @@ export function children(tree: IndexedProcessTree, process: ResolverEvent): Reso return currentProcessSiblings === undefined ? [] : currentProcessSiblings; } +/** + * Get the indexed process event for the ID + */ +export function processEvent(tree: IndexedProcessTree, entityID: string): ResolverEvent | null { + return tree.idToProcess.get(entityID) ?? null; +} + /** * Returns the parent ProcessEvent, if any, for the passed in `childProcess` */ @@ -124,6 +78,31 @@ export function parent( } } +/** + * Returns the following sibling + */ +export function nextSibling( + tree: IndexedProcessTree, + sibling: ResolverEvent +): ResolverEvent | undefined { + const parentNode = parent(tree, sibling); + if (parentNode) { + // The siblings of `sibling` are the children of its parent. + const siblings = children(tree, parentNode); + + // Find the sibling + const index = siblings.indexOf(sibling); + + // if the sibling wasn't found, or if it was the last element in the array, return undefined + if (index === -1 || index === siblings.length - 1) { + return undefined; + } + + // return the next sibling + return siblings[index + 1]; + } +} + /** * Number of processes in the tree */ @@ -138,7 +117,10 @@ export function root(tree: IndexedProcessTree) { if (size(tree) === 0) { return null; } + // any node will do let current: ResolverEvent = tree.idToProcess.values().next().value; + + // iteratively swap current w/ its parent while (parent(tree, current) !== undefined) { current = parent(tree, current)!; } 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 72d8e878465f7..bd534dcb989e3 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 @@ -148,5 +148,24 @@ describe('resolver graph layout', () => { it('renders right', () => { expect(layout()).toMatchSnapshot(); }); + it('should have node a at level 1', () => { + expect(layout().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); + }); + 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); + }); + it('should have node h at level 4', () => { + expect(layout().ariaLevels.get(processH)).toBe(4); + }); + it('should have 9 items in the map of aria levels', () => { + expect(layout().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 61363ffa05d94..6058a40037ad2 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 @@ -73,9 +73,34 @@ export function isometricTaxiLayout(indexedProcessTree: IndexedProcessTree): Iso return { processNodePositions: transformedPositions, edgeLineSegments: transformedEdgeLineSegments, + ariaLevels: ariaLevels(indexedProcessTree), }; } +/** + * Calculate a level (starting at 1) for each node. + */ +function ariaLevels(indexedProcessTree: IndexedProcessTree): Map { + const map: Map = new Map(); + for (const node of model.levelOrder(indexedProcessTree)) { + const parentNode = model.parent(indexedProcessTree, node); + if (parentNode === undefined) { + // nodes at the root have a level of 1 + map.set(node, 1); + } else { + const parentLevel: number | undefined = map.get(parentNode); + + // because we're iterating in level order, we should have processed the parent of any node that has one. + if (parentLevel === undefined) { + throw new Error('failed to calculate aria levels'); + } + + map.set(node, parentLevel + 1); + } + } + return map; +} + /** * In laying out the graph, we precalculate the 'width' of each subtree. The 'width' of the subtree is determined by its * descedants and the rule that each process node must be at least 1 unit apart. Enforcing that all nodes are at least 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 04a3f9ccff772..7eb692851bc9b 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 @@ -3,10 +3,10 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { eventType } from './process_event'; +import { eventType, orderByTime } from './process_event'; import { mockProcessEvent } from './process_event_test_helpers'; -import { LegacyEndpointEvent } from '../../../common/endpoint/types'; +import { LegacyEndpointEvent, ResolverEvent } from '../../../common/endpoint/types'; describe('process event', () => { describe('eventType', () => { @@ -24,4 +24,86 @@ describe('process event', () => { expect(eventType(event)).toEqual('processCreated'); }); }); + describe('orderByTime', () => { + let mock: (time: number, eventID: string) => ResolverEvent; + let events: ResolverEvent[]; + beforeEach(() => { + mock = (time, eventID) => { + return { + '@timestamp': time, + event: { + id: eventID, + }, + } as ResolverEvent; + }; + // 2 events each for numbers -1, 0, 1, and NaN + // each event has a unique id, a through h + // order is arbitrary + events = [ + mock(-1, 'a'), + mock(0, 'c'), + mock(1, 'e'), + mock(NaN, 'g'), + mock(-1, 'b'), + mock(0, 'd'), + mock(1, 'f'), + mock(NaN, 'h'), + ]; + }); + it('sorts events as expected', () => { + events.sort(orderByTime); + expect(events).toMatchInlineSnapshot(` + Array [ + Object { + "@timestamp": -1, + "event": Object { + "id": "a", + }, + }, + Object { + "@timestamp": -1, + "event": Object { + "id": "b", + }, + }, + Object { + "@timestamp": 0, + "event": Object { + "id": "c", + }, + }, + Object { + "@timestamp": 0, + "event": Object { + "id": "d", + }, + }, + Object { + "@timestamp": 1, + "event": Object { + "id": "e", + }, + }, + Object { + "@timestamp": 1, + "event": Object { + "id": "f", + }, + }, + Object { + "@timestamp": NaN, + "event": Object { + "id": "g", + }, + }, + Object { + "@timestamp": NaN, + "event": Object { + "id": "h", + }, + }, + ] + `); + }); + }); }); 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 0286cca93b43f..4f8df87b3ac0b 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 @@ -28,6 +28,19 @@ export function isTerminatedProcess(passedEvent: ResolverEvent) { return eventType(passedEvent) === 'processTerminated'; } +/** + * ms since unix epoc, based on timestamp. + * may return NaN if the timestamp wasn't present or was invalid. + */ +export function datetime(passedEvent: ResolverEvent): number | null { + const timestamp = event.eventTimestamp(passedEvent); + + const time = timestamp === undefined ? 0 : new Date(timestamp).getTime(); + + // if the date could not be parsed, return null + return isNaN(time) ? null : time; +} + /** * Returns a custom event type for a process event based on the event's metadata. */ @@ -161,3 +174,22 @@ export function argsForProcess(passedEvent: ResolverEvent): string | undefined { } return passedEvent?.process?.args; } + +/** + * used to sort events + */ +export function orderByTime(first: ResolverEvent, second: ResolverEvent): 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(event.eventId(first)).localeCompare(String(event.eventId(second))); + } else if (firstDatetime === null || secondDatetime === null) { + // sort `null`'s as higher than numbers + return (firstDatetime === null ? 1 : 0) - (secondDatetime === null ? 1 : 0); + } else { + // sort in ascending order. + return firstDatetime - secondDatetime; + } +} 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 475546cfc3966..dc17fc70ef8af 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 @@ -8,7 +8,6 @@ import rbush from 'rbush'; import { createSelector, defaultMemoize } from 'reselect'; import { DataState, - AdjacentProcessMap, Vector2, IndexedEntity, IndexedEdgeLineSegment, @@ -21,7 +20,7 @@ import { isTerminatedProcess, uniquePidForProcess, } from '../../models/process_event'; -import { factory as indexedProcessTreeFactory } from '../../models/indexed_process_tree'; +import * as indexedProcessTreeModel from '../../models/indexed_process_tree'; import { isEqual } from '../../models/aabb'; import { @@ -107,7 +106,7 @@ export const indexedProcessTree = createSelector(graphableProcesses, function in graphableProcesses /* eslint-enable no-shadow */ ) { - return indexedProcessTreeFactory(graphableProcesses); + return indexedProcessTreeModel.factory(graphableProcesses); }); /** @@ -170,27 +169,6 @@ export function relatedEventsReady(data: DataState): Map { return data.relatedEventsReady; } -export const processAdjacencies = createSelector( - indexedProcessTree, - graphableProcesses, - function selectProcessAdjacencies( - /* eslint-disable no-shadow */ - indexedProcessTree, - graphableProcesses - /* eslint-enable no-shadow */ - ) { - const processToAdjacencyMap = new Map(); - const { idToAdjacent } = indexedProcessTree; - - for (const graphableProcess of graphableProcesses) { - const processPid = uniquePidForProcess(graphableProcess); - const adjacencyMap = idToAdjacent.get(processPid)!; - processToAdjacencyMap.set(graphableProcess, adjacencyMap); - } - return { processToAdjacencyMap }; - } -); - /** * `true` if there were more children than we got in the last request. */ @@ -230,7 +208,7 @@ export const relatedEventInfoByEntityId: ( ) { if (!relatedEventsStats) { // If there are no related event stats, there are no related event info objects - return (entityId: string) => null; + return () => null; } return (entityId) => { const stats = relatedEventsStats.get(entityId); @@ -334,7 +312,8 @@ export function databaseDocumentIDToFetch(state: DataState): string | null { return null; } } -export const processNodePositionsAndEdgeLineSegments = createSelector( + +export const layout = createSelector( indexedProcessTree, function processNodePositionsAndEdgeLineSegments( /* eslint-disable no-shadow */ @@ -345,9 +324,62 @@ export const processNodePositionsAndEdgeLineSegments = createSelector( } ); -const indexedProcessNodePositionsAndEdgeLineSegments = createSelector( - processNodePositionsAndEdgeLineSegments, - function visibleProcessNodePositionsAndEdgeLineSegments({ +/** + * 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: DataState +) => (nodeID: string) => ResolverEvent | null = createSelector( + indexedProcessTree, + (tree) => (nodeID: string) => indexedProcessTreeModel.processEvent(tree, nodeID) +); + +/** + * Takes a nodeID (aka entity_id) and returns the associated aria level as a number or null if the node ID isn't in the tree. + */ +export const ariaLevel: (state: DataState) => (nodeID: string) => number | null = createSelector( + layout, + processEventForID, + ({ ariaLevels }, processEventGetter) => (nodeID: string) => { + const node = processEventGetter(nodeID); + return node ? ariaLevels.get(node) ?? null : null; + } +); + +/** + * Returns the following sibling if there is one, or `null`. + */ +export const followingSibling: ( + state: DataState +) => (nodeID: string) => string | null = createSelector( + indexedProcessTree, + processEventForID, + (tree, eventGetter) => { + return (nodeID: string) => { + const event = eventGetter(nodeID); + + // event not found + if (event === null) { + return null; + } + const nextSibling = indexedProcessTreeModel.nextSibling(tree, event); + + // next sibling not found + if (nextSibling === undefined) { + return null; + } + + // return the node ID + return uniquePidForProcess(nextSibling); + }; + } +); + +const spatiallyIndexedLayout: (state: DataState) => rbush = createSelector( + layout, + function ({ /* eslint-disable no-shadow */ processNodePositions, edgeLineSegments, @@ -394,47 +426,46 @@ const indexedProcessNodePositionsAndEdgeLineSegments = createSelector( } ); -export const visibleProcessNodePositionsAndEdgeLineSegments = createSelector( - indexedProcessNodePositionsAndEdgeLineSegments, - function visibleProcessNodePositionsAndEdgeLineSegments(tree) { - // memoize the results of this call to avoid unnecessarily rerunning - let lastBoundingBox: AABB | null = null; - let currentlyVisible: VisibleEntites = { - processNodePositions: new Map(), - connectingEdgeLineSegments: [], - }; - return (boundingBox: AABB) => { - if (lastBoundingBox !== null && isEqual(lastBoundingBox, boundingBox)) { - return currentlyVisible; - } else { - const { - minimum: [minX, minY], - maximum: [maxX, maxY], - } = boundingBox; - const entities = tree.search({ - minX, - minY, - maxX, - maxY, - }); - const visibleProcessNodePositions = new Map( - entities - .filter((entity): entity is IndexedProcessNode => entity.type === 'processNode') - .map((node) => [node.entity, node.position]) - ); - const connectingEdgeLineSegments = entities - .filter((entity): entity is IndexedEdgeLineSegment => entity.type === 'edgeLine') - .map((node) => node.entity); - currentlyVisible = { - processNodePositions: visibleProcessNodePositions, - connectingEdgeLineSegments, - }; - lastBoundingBox = boundingBox; - return currentlyVisible; - } - }; - } -); +export const nodesAndEdgelines: ( + state: DataState +) => (query: AABB) => VisibleEntites = createSelector(spatiallyIndexedLayout, function (tree) { + // memoize the results of this call to avoid unnecessarily rerunning + let lastBoundingBox: AABB | null = null; + let currentlyVisible: VisibleEntites = { + processNodePositions: new Map(), + connectingEdgeLineSegments: [], + }; + return (boundingBox: AABB) => { + if (lastBoundingBox !== null && isEqual(lastBoundingBox, boundingBox)) { + return currentlyVisible; + } else { + const { + minimum: [minX, minY], + maximum: [maxX, maxY], + } = boundingBox; + const entities = tree.search({ + minX, + minY, + maxX, + maxY, + }); + const visibleProcessNodePositions = new Map( + entities + .filter((entity): entity is IndexedProcessNode => entity.type === 'processNode') + .map((node) => [node.entity, node.position]) + ); + const connectingEdgeLineSegments = entities + .filter((entity): entity is IndexedEdgeLineSegment => entity.type === 'edgeLine') + .map((node) => node.entity); + currentlyVisible = { + processNodePositions: visibleProcessNodePositions, + connectingEdgeLineSegments, + }; + lastBoundingBox = boundingBox; + return currentlyVisible; + } + }; +}); /** * If there is a pending request that's for a entity ID that doesn't matche the `entityID`, then we should cancel it. 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 eb2b402a694a5..e91c455c9445f 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 @@ -9,7 +9,7 @@ import { ResolverAction } from '../actions'; import { resolverReducer } from '../reducer'; import { ResolverState } from '../../types'; import { LegacyEndpointEvent, ResolverEvent } from '../../../../common/endpoint/types'; -import { visibleProcessNodePositionsAndEdgeLineSegments } from '../selectors'; +import { visibleNodesAndEdgeLines } from '../selectors'; import { mockProcessEvent } from '../../models/process_event_test_helpers'; import { mock as mockResolverTree } from '../../models/resolver_tree'; @@ -119,15 +119,11 @@ describe('resolver visible entities', () => { store.dispatch(cameraAction); }); it('the visibleProcessNodePositions list should only include 2 nodes', () => { - const { processNodePositions } = visibleProcessNodePositionsAndEdgeLineSegments( - store.getState() - )(0); + const { processNodePositions } = visibleNodesAndEdgeLines(store.getState())(0); expect([...processNodePositions.keys()].length).toEqual(2); }); it('the visibleEdgeLineSegments list should only include one edge line', () => { - const { connectingEdgeLineSegments } = visibleProcessNodePositionsAndEdgeLineSegments( - store.getState() - )(0); + const { connectingEdgeLineSegments } = visibleNodesAndEdgeLines(store.getState())(0); expect(connectingEdgeLineSegments.length).toEqual(1); }); }); @@ -151,15 +147,11 @@ describe('resolver visible entities', () => { store.dispatch(cameraAction); }); it('the visibleProcessNodePositions list should include all process nodes', () => { - const { processNodePositions } = visibleProcessNodePositionsAndEdgeLineSegments( - store.getState() - )(0); + const { processNodePositions } = visibleNodesAndEdgeLines(store.getState())(0); expect([...processNodePositions.keys()].length).toEqual(5); }); it('the visibleEdgeLineSegments list include all lines', () => { - const { connectingEdgeLineSegments } = visibleProcessNodePositionsAndEdgeLineSegments( - store.getState() - )(0); + const { connectingEdgeLineSegments } = visibleNodesAndEdgeLines(store.getState())(0); expect(connectingEdgeLineSegments.length).toEqual(4); }); }); diff --git a/x-pack/plugins/security_solution/public/resolver/store/methods.ts b/x-pack/plugins/security_solution/public/resolver/store/methods.ts index 3890770259156..ad06ddf36161a 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/methods.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/methods.ts @@ -5,7 +5,7 @@ */ import { animatePanning } from './camera/methods'; -import { processNodePositionsAndEdgeLineSegments } from './selectors'; +import { layout } from './selectors'; import { ResolverState } from '../types'; import { ResolverEvent } from '../../../common/endpoint/types'; @@ -19,7 +19,7 @@ export function animateProcessIntoView( startTime: number, process: ResolverEvent ): ResolverState { - const { processNodePositions } = processNodePositionsAndEdgeLineSegments(state); + const { processNodePositions } = layout(state); const position = processNodePositions.get(process); if (position) { return { 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 new file mode 100644 index 0000000000000..ba4a5a169c549 --- /dev/null +++ b/x-pack/plugins/security_solution/public/resolver/store/selectors.test.ts @@ -0,0 +1,259 @@ +/* + * 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 { ResolverState } from '../types'; +import { createStore } from 'redux'; +import { ResolverAction } from './actions'; +import { resolverReducer } from './reducer'; +import * as selectors from './selectors'; +import { EndpointEvent, ResolverEvent, ResolverTree } from '../../../common/endpoint/types'; + +describe('resolver selectors', () => { + const actions: ResolverAction[] = []; + + /** + * Get state, given an ordered collection of actions. + */ + const state: () => ResolverState = () => { + const store = createStore(resolverReducer); + for (const action of actions) { + store.dispatch(action); + } + return store.getState(); + }; + describe('ariaFlowtoNodeID', () => { + describe('with a tree with no descendants and 2 ancestors', () => { + const originID = 'c'; + const firstAncestorID = 'b'; + const secondAncestorID = 'a'; + beforeEach(() => { + actions.push({ + type: 'serverReturnedResolverData', + payload: { + result: treeWith2AncestorsAndNoChildren({ + originID, + firstAncestorID, + secondAncestorID, + }), + // this value doesn't matter + databaseDocumentID: '', + }, + }); + }); + describe('when all nodes are in view', () => { + beforeEach(() => { + const size = 1000000; + actions.push({ + // set the size of the camera + type: 'userSetRasterSize', + payload: [size, size], + }); + }); + it('should return no flowto for the second ancestor', () => { + expect(selectors.ariaFlowtoNodeID(state())(0)(secondAncestorID)).toBe(null); + }); + it('should return no flowto for the first ancestor', () => { + expect(selectors.ariaFlowtoNodeID(state())(0)(firstAncestorID)).toBe(null); + }); + it('should return no flowto for the origin', () => { + expect(selectors.ariaFlowtoNodeID(state())(0)(originID)).toBe(null); + }); + }); + }); + describe('with a tree with 2 children and no ancestors', () => { + const originID = 'c'; + const firstChildID = 'd'; + const secondChildID = 'e'; + beforeEach(() => { + actions.push({ + type: 'serverReturnedResolverData', + payload: { + result: treeWithNoAncestorsAnd2Children({ originID, firstChildID, secondChildID }), + // this value doesn't matter + databaseDocumentID: '', + }, + }); + }); + describe('when all nodes are in view', () => { + beforeEach(() => { + const rasterSize = 1000000; + actions.push({ + // set the size of the camera + type: 'userSetRasterSize', + payload: [rasterSize, rasterSize], + }); + }); + it('should return no flowto for the origin', () => { + expect(selectors.ariaFlowtoNodeID(state())(0)(originID)).toBe(null); + }); + it('should return the second child as the flowto for the first child', () => { + expect(selectors.ariaFlowtoNodeID(state())(0)(firstChildID)).toBe(secondChildID); + }); + it('should return no flowto for second child', () => { + expect(selectors.ariaFlowtoNodeID(state())(0)(secondChildID)).toBe(null); + }); + }); + describe('when only the origin and first child are in view', () => { + beforeEach(() => { + // set the raster size + const rasterSize = 1000000; + actions.push({ + // set the size of the camera + type: 'userSetRasterSize', + payload: [rasterSize, rasterSize], + }); + + // get the layout + const layout = selectors.layout(state()); + + // find the position of the second child + const secondChild = selectors.processEventForID(state())(secondChildID); + const positionOfSecondChild = layout.processNodePositions.get(secondChild!)!; + + // the child is indexed by an AABB that extends -720/2 to the left + const leftSideOfSecondChildAABB = positionOfSecondChild[0] - 720 / 2; + + // adjust the camera so that it doesn't quite see the second child + actions.push({ + // set the position of the camera so that the left edge of the second child is at the right edge + // of the viewable area + type: 'userSetPositionOfCamera', + payload: [rasterSize / -2 + leftSideOfSecondChildAABB, 0], + }); + }); + it('the origin should be in view', () => { + const origin = selectors.processEventForID(state())(originID)!; + expect( + selectors.visibleNodesAndEdgeLines(state())(0).processNodePositions.has(origin) + ).toBe(true); + }); + it('the first child should be in view', () => { + const firstChild = selectors.processEventForID(state())(firstChildID)!; + expect( + selectors.visibleNodesAndEdgeLines(state())(0).processNodePositions.has(firstChild) + ).toBe(true); + }); + it('the second child should not be in view', () => { + const secondChild = selectors.processEventForID(state())(secondChildID)!; + expect( + selectors.visibleNodesAndEdgeLines(state())(0).processNodePositions.has(secondChild) + ).toBe(false); + }); + it('should return nothing as the flowto for the first child', () => { + expect(selectors.ariaFlowtoNodeID(state())(0)(firstChildID)).toBe(null); + }); + }); + }); + }); +}); +/** + * Simple mock endpoint event that works for tree layouts. + */ +function mockEndpointEvent({ + entityID, + name, + parentEntityId, + timestamp, +}: { + entityID: string; + name: string; + parentEntityId: string; + timestamp: number; +}): EndpointEvent { + return { + '@timestamp': timestamp, + event: { + type: 'start', + category: 'process', + }, + process: { + entity_id: entityID, + name, + parent: { + entity_id: parentEntityId, + }, + }, + } as EndpointEvent; +} + +function treeWith2AncestorsAndNoChildren({ + originID, + firstAncestorID, + secondAncestorID, +}: { + secondAncestorID: string; + firstAncestorID: string; + originID: string; +}): ResolverTree { + const secondAncestor: ResolverEvent = mockEndpointEvent({ + entityID: secondAncestorID, + name: 'a', + parentEntityId: 'none', + timestamp: 0, + }); + const firstAncestor: ResolverEvent = mockEndpointEvent({ + entityID: firstAncestorID, + name: 'b', + parentEntityId: secondAncestorID, + timestamp: 1, + }); + const originEvent: ResolverEvent = mockEndpointEvent({ + entityID: originID, + name: 'c', + parentEntityId: firstAncestorID, + timestamp: 2, + }); + return ({ + entityID: originID, + children: { + childNodes: [], + }, + ancestry: { + ancestors: [{ lifecycle: [secondAncestor] }, { lifecycle: [firstAncestor] }], + }, + lifecycle: [originEvent], + } as unknown) as ResolverTree; +} + +function treeWithNoAncestorsAnd2Children({ + originID, + firstChildID, + secondChildID, +}: { + originID: string; + firstChildID: string; + secondChildID: string; +}): ResolverTree { + const origin: ResolverEvent = mockEndpointEvent({ + entityID: originID, + name: 'c', + parentEntityId: 'none', + timestamp: 0, + }); + const firstChild: ResolverEvent = mockEndpointEvent({ + entityID: firstChildID, + name: 'd', + parentEntityId: originID, + timestamp: 1, + }); + const secondChild: ResolverEvent = mockEndpointEvent({ + entityID: secondChildID, + name: 'e', + parentEntityId: originID, + timestamp: 2, + }); + + return ({ + entityID: originID, + children: { + childNodes: [{ lifecycle: [firstChild] }, { lifecycle: [secondChild] }], + }, + ancestry: { + ancestors: [], + }, + lifecycle: [origin], + } as unknown) as ResolverTree; +} 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 945b2bfed3cfb..ff2179dc3a2ae 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/selectors.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/selectors.ts @@ -4,11 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ -import { createSelector } from 'reselect'; +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 } from '../types'; +import { ResolverState, IsometricTaxiLayout } from '../types'; +import { uniquePidForProcess } from '../models/process_event'; +import { ResolverEvent } from '../../../common/endpoint/types'; /** * A matrix that when applied to a Vector2 will convert it from world coordinates to screen coordinates. @@ -51,9 +53,24 @@ export const userIsPanning = composeSelectors(cameraStateSelector, cameraSelecto */ export const isAnimating = composeSelectors(cameraStateSelector, cameraSelectors.isAnimating); -export const processNodePositionsAndEdgeLineSegments = composeSelectors( +/** + * 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) => ResolverEvent | null = composeSelectors( dataStateSelector, - dataSelectors.processNodePositionsAndEdgeLineSegments + dataSelectors.processEventForID +); + +/** + * The position of nodes and edges. + */ +export const layout: (state: ResolverState) => IsometricTaxiLayout = composeSelectors( + dataStateSelector, + dataSelectors.layout ); /** @@ -74,11 +91,6 @@ export const resolverComponentInstanceID = composeSelectors( dataSelectors.resolverComponentInstanceID ); -export const processAdjacencies = composeSelectors( - dataStateSelector, - dataSelectors.processAdjacencies -); - export const terminatedProcesses = composeSelectors( dataStateSelector, dataSelectors.terminatedProcesses @@ -212,10 +224,8 @@ function composeSelectors( } const boundingBox = composeSelectors(cameraStateSelector, cameraSelectors.viewableBoundingBox); -const indexedProcessNodesAndEdgeLineSegments = composeSelectors( - dataStateSelector, - dataSelectors.visibleProcessNodePositionsAndEdgeLineSegments -); + +const nodesAndEdgelines = composeSelectors(dataStateSelector, dataSelectors.nodesAndEdgelines); /** * Total count of related events for a process. @@ -230,15 +240,50 @@ export const relatedEventTotalForProcess = composeSelectors( * The bounding box represents what the camera can see. The camera position is a function of time because it can be * animated. So in order to get the currently visible entities, we need to pass in time. */ -export const visibleProcessNodePositionsAndEdgeLineSegments = createSelector( - indexedProcessNodesAndEdgeLineSegments, - boundingBox, - function ( - /* eslint-disable no-shadow */ - indexedProcessNodesAndEdgeLineSegments, - boundingBox - /* eslint-enable no-shadow */ - ) { - return (time: number) => indexedProcessNodesAndEdgeLineSegments(boundingBox(time)); +export const visibleNodesAndEdgeLines = createSelector(nodesAndEdgelines, boundingBox, function ( + /* eslint-disable no-shadow */ + nodesAndEdgelines, + boundingBox + /* eslint-enable no-shadow */ +) { + return (time: number) => nodesAndEdgelines(boundingBox(time)); +}); + +/** + * Takes a nodeID (aka entity_id) and returns the associated aria level as a number or null if the node ID isn't in the tree. + */ +export const ariaLevel: ( + state: ResolverState +) => (nodeID: string) => number | null = composeSelectors( + dataStateSelector, + dataSelectors.ariaLevel +); + +/** + * Takes a nodeID (aka entity_id) and returns the node ID of the node that aria should 'flowto' or null + * If the node has a following sibling that is currently visible, that will be returned, otherwise null. + */ +export const ariaFlowtoNodeID: ( + state: ResolverState +) => (time: number) => (nodeID: string) => string | null = createSelector( + visibleNodesAndEdgeLines, + composeSelectors(dataStateSelector, dataSelectors.followingSibling), + (visibleNodesAndEdgeLinesAtTime, followingSibling) => { + return defaultMemoize((time: number) => { + // get the visible nodes at `time` + const { processNodePositions } = visibleNodesAndEdgeLinesAtTime(time); + + // get a `Set` containing their node IDs + const nodesVisibleAtTime: Set = new Set( + [...processNodePositions.keys()].map(uniquePidForProcess) + ); + + // return the ID of `nodeID`'s following sibling, if it is visible + return (nodeID: string): string | null => { + const sibling: string | null = followingSibling(nodeID); + + return sibling === null || nodesVisibleAtTime.has(sibling) === false ? null : sibling; + }; + }); } ); diff --git a/x-pack/plugins/security_solution/public/resolver/types.ts b/x-pack/plugins/security_solution/public/resolver/types.ts index 064634472bbbe..0272de0d8fd2a 100644 --- a/x-pack/plugins/security_solution/public/resolver/types.ts +++ b/x-pack/plugins/security_solution/public/resolver/types.ts @@ -269,38 +269,18 @@ export interface ProcessEvent { }; } -/** - * A map of Process Ids that indicate which processes are adjacent to a given process along - * directions in two axes: up/down and previous/next. - */ -export interface AdjacentProcessMap { - readonly self: string; - parent: string | null; - firstChild: string | null; - previousSibling: string | null; - nextSibling: string | null; - /** - * To support aria-level, this must be >= 1 - */ - level: number; -} - /** * A represention of a process tree with indices for O(1) access to children and values by id. */ export interface IndexedProcessTree { /** - * Map of ID to a process's children + * Map of ID to a process's ordered children */ idToChildren: Map; /** * Map of ID to process */ idToProcess: Map; - /** - * Map of ID to adjacent processes - */ - idToAdjacent: Map; } /** @@ -454,4 +434,9 @@ export interface IsometricTaxiLayout { * A map of edgline segments, which graphically connect nodes. */ edgeLineSegments: EdgeLineSegment[]; + + /** + * defines the aria levels for nodes. + */ + ariaLevels: Map; } diff --git a/x-pack/plugins/security_solution/public/resolver/view/map.tsx b/x-pack/plugins/security_solution/public/resolver/view/map.tsx index 000bf23c5f49d..b366e2f220652 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/map.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/map.tsx @@ -53,10 +53,13 @@ export const ResolverMap = React.memo(function ({ useStateSyncingActions({ databaseDocumentID, resolverComponentInstanceID }); const { timestamp } = useContext(SideEffectContext); + + // use this for the entire render in order to keep things in sync + const timeAtRender = timestamp(); + const { processNodePositions, connectingEdgeLineSegments } = useSelector( - selectors.visibleProcessNodePositionsAndEdgeLineSegments - )(timestamp()); - const { processToAdjacencyMap } = useSelector(selectors.processAdjacencies); + selectors.visibleNodesAndEdgeLines + )(timeAtRender); const relatedEventsStats = useSelector(selectors.relatedEventsStats); const terminatedProcesses = useSelector(selectors.terminatedProcesses); const { projectionMatrix, ref, onMouseDown } = useCamera(); @@ -100,24 +103,19 @@ export const ResolverMap = React.memo(function ({ /> ))} {[...processNodePositions].map(([processEvent, position]) => { - const adjacentNodeMap = processToAdjacencyMap.get(processEvent); const processEntityId = entityId(processEvent); - if (!adjacentNodeMap) { - // This should never happen - throw new Error('Issue calculating adjacency node map.'); - } return ( ); })} diff --git a/x-pack/plugins/security_solution/public/resolver/view/panels/panel_content_process_list.tsx b/x-pack/plugins/security_solution/public/resolver/view/panels/panel_content_process_list.tsx index 0ed677885775f..6f9bfad8c08c2 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/panels/panel_content_process_list.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/panels/panel_content_process_list.tsx @@ -146,7 +146,7 @@ export const ProcessListWithCounts = memo(function ProcessListWithCounts({ [pushToQueryParams, handleBringIntoViewClick, isProcessOrigin, isProcessTerminated] ); - const { processNodePositions } = useSelector(selectors.processNodePositionsAndEdgeLineSegments); + const { processNodePositions } = useSelector(selectors.layout); const processTableView: ProcessTableView[] = useMemo( () => [...processNodePositions.keys()].map((processEvent) => { diff --git a/x-pack/plugins/security_solution/public/resolver/view/panels/panel_content_related_list.tsx b/x-pack/plugins/security_solution/public/resolver/view/panels/panel_content_related_list.tsx index 0878ead72b2a4..9a477fd998bb3 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/panels/panel_content_related_list.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/panels/panel_content_related_list.tsx @@ -38,7 +38,6 @@ interface MatchingEventEntry { eventType: string; eventCategory: string; name: { subject: string; descriptor?: string }; - entityId: string; setQueryParams: () => void; } @@ -202,9 +201,11 @@ export const ProcessEventListNarrowedByType = memo(function ProcessEventListNarr eventCategory: `${eventType}`, eventType: `${event.ecsEventType(resolverEvent)}`, name: event.descriptiveName(resolverEvent), - entityId, setQueryParams: () => { - pushToQueryParams({ crumbId: entityId, crumbEvent: processEntityId }); + pushToQueryParams({ + crumbId: entityId === undefined ? '' : String(entityId), + crumbEvent: processEntityId, + }); }, }; }); 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 e20f06ccf0f72..7666d1ac7c88a 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 @@ -12,11 +12,12 @@ import { htmlIdGenerator, EuiButton, EuiI18nNumber, EuiFlexGroup, EuiFlexItem } import { useSelector } from 'react-redux'; import { NodeSubMenu, subMenuAssets } from './submenu'; import { applyMatrix3 } from '../models/vector2'; -import { Vector2, Matrix3, AdjacentProcessMap } from '../types'; +import { Vector2, Matrix3 } from '../types'; import { SymbolIds, useResolverTheme, calculateResolverFontSize } from './assets'; import { ResolverEvent, ResolverNodeStats } from '../../../common/endpoint/types'; import { useResolverDispatch } from './use_resolver_dispatch'; import * as eventModel from '../../../common/endpoint/models/event'; +import * as processEventModel from '../models/process_event'; import * as selectors from '../store/selectors'; import { useResolverQueryParams } from './use_resolver_query_params'; @@ -70,10 +71,10 @@ const UnstyledProcessEventDot = React.memo( position, event, projectionMatrix, - adjacentNodeMap, isProcessTerminated, isProcessOrigin, relatedEventsStatsForProcess, + timeAtRender, }: { /** * A `className` string provided by `styled` @@ -91,10 +92,6 @@ const UnstyledProcessEventDot = React.memo( * projectionMatrix which can be used to convert `position` to screen coordinates. */ projectionMatrix: Matrix3; - /** - * map of what nodes are "adjacent" to this one in "up, down, previous, next" directions - */ - adjacentNodeMap: AdjacentProcessMap; /** * Whether or not to show the process as terminated. */ @@ -109,7 +106,16 @@ const UnstyledProcessEventDot = React.memo( * Statistics for the number of related events and alerts for this process node */ relatedEventsStatsForProcess?: ResolverNodeStats; + + /** + * The time (unix epoch) at render. + */ + timeAtRender: number; }) => { + const resolverComponentInstanceID = useSelector(selectors.resolverComponentInstanceID); + // This should be unique to each instance of Resolver + const htmlIDPrefix = `resolver:${resolverComponentInstanceID}`; + /** * Convert the position, which is in 'world' coordinates, to screen coordinates. */ @@ -118,12 +124,22 @@ const UnstyledProcessEventDot = React.memo( const [xScale] = projectionMatrix; // Node (html id=) IDs - const selfId = adjacentNodeMap.self; const activeDescendantId = useSelector(selectors.uiActiveDescendantId); const selectedDescendantId = useSelector(selectors.uiSelectedDescendantId); + const nodeID = processEventModel.uniquePidForProcess(event); - // Entity ID of self - const selfEntityId = eventModel.entityId(event); + // 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 + const nodeHTMLID = useCallback((id: string) => htmlIdGenerator(htmlIDPrefix)(`${id}:node`), [ + htmlIDPrefix, + ]); + + const ariaLevel: number | null = useSelector(selectors.ariaLevel)(nodeID); + + // the node ID to 'flowto' + const ariaFlowtoNodeID: string | null = useSelector(selectors.ariaFlowtoNodeID)(timeAtRender)( + nodeID + ); const isShowingEventActions = xScale > 0.8; const isShowingDescriptionText = xScale >= 0.55; @@ -204,16 +220,10 @@ const UnstyledProcessEventDot = React.memo( strokeColor, } = cubeAssetsForNode(isProcessTerminated, isProcessOrigin); - const resolverNodeIdGenerator = useMemo(() => htmlIdGenerator('resolverNode'), []); + const labelHTMLID = htmlIdGenerator('resolver')(`${nodeID}:label`); - const nodeId = useMemo(() => resolverNodeIdGenerator(selfId), [ - resolverNodeIdGenerator, - selfId, - ]); - const labelId = useMemo(() => resolverNodeIdGenerator(), [resolverNodeIdGenerator]); - const descriptionId = useMemo(() => resolverNodeIdGenerator(), [resolverNodeIdGenerator]); - const isActiveDescendant = nodeId === activeDescendantId; - const isSelectedDescendant = nodeId === selectedDescendantId; + const isAriaCurrent = nodeID === activeDescendantId; + const isAriaSelected = nodeID === selectedDescendantId; const dispatch = useResolverDispatch(); @@ -221,34 +231,35 @@ const UnstyledProcessEventDot = React.memo( dispatch({ type: 'userFocusedOnResolverNode', payload: { - nodeId, + nodeId: nodeHTMLID(nodeID), }, }); - }, [dispatch, nodeId]); + }, [dispatch, nodeHTMLID, nodeID]); const handleRelatedEventRequest = useCallback(() => { dispatch({ type: 'userRequestedRelatedEventData', - payload: selfId, + payload: nodeID, }); - }, [dispatch, selfId]); + }, [dispatch, nodeID]); const { pushToQueryParams } = useResolverQueryParams(); const handleClick = useCallback(() => { if (animationTarget.current !== null) { + // This works but the types are missing in the typescript DOM lib // eslint-disable-next-line @typescript-eslint/no-explicit-any (animationTarget.current as any).beginElement(); } dispatch({ type: 'userSelectedResolverNode', payload: { - nodeId, - selectedProcessId: selfId, + nodeId: nodeHTMLID(nodeID), + selectedProcessId: nodeID, }, }); - pushToQueryParams({ crumbId: selfEntityId, crumbEvent: 'all' }); - }, [animationTarget, dispatch, nodeId, selfEntityId, pushToQueryParams, selfId]); + pushToQueryParams({ crumbId: nodeID, crumbEvent: 'all' }); + }, [animationTarget, dispatch, pushToQueryParams, nodeID, nodeHTMLID]); /** * Enumerates the stats for related events to display with the node as options, @@ -280,12 +291,12 @@ const UnstyledProcessEventDot = React.memo( }, }); - pushToQueryParams({ crumbId: selfEntityId, crumbEvent: category }); + pushToQueryParams({ crumbId: nodeID, crumbEvent: category }); }, }); } return relatedStatsList; - }, [relatedEventsStatsForProcess, dispatch, event, pushToQueryParams, selfEntityId]); + }, [relatedEventsStatsForProcess, dispatch, event, pushToQueryParams, nodeID]); const relatedEventStatusOrOptions = !relatedEventsStatsForProcess ? subMenuAssets.initialMenuStatus @@ -302,15 +313,14 @@ const UnstyledProcessEventDot = React.memo( data-test-subj={'resolverNode'} className={`${className} kbn-resetFocusState`} role="treeitem" - aria-level={adjacentNodeMap.level} - aria-flowto={adjacentNodeMap.nextSibling === null ? undefined : adjacentNodeMap.nextSibling} - aria-labelledby={labelId} - aria-describedby={descriptionId} - aria-haspopup={'true'} - aria-current={isActiveDescendant ? 'true' : undefined} - aria-selected={isSelectedDescendant ? 'true' : undefined} + aria-level={ariaLevel === null ? undefined : ariaLevel} + aria-flowto={ariaFlowtoNodeID === null ? undefined : nodeHTMLID(ariaFlowtoNodeID)} + aria-labelledby={labelHTMLID} + aria-haspopup="true" + aria-current={isAriaCurrent ? 'true' : undefined} + aria-selected={isAriaSelected ? 'true' : undefined} style={nodeViewportStyle} - id={nodeId} + id={nodeHTMLID(nodeID)} tabIndex={-1} >
= 2 ? 'euiButton' : 'euiButton euiButton--small'} - data-test-subject="nodeLabel" - id={labelId} + id={labelHTMLID} onClick={handleClick} onFocus={handleFocus} tabIndex={-1} @@ -386,9 +395,7 @@ const UnstyledProcessEventDot = React.memo( > { throw new Error('failed to create tree'); } const processes: ResolverEvent[] = [ - ...selectors - .processNodePositionsAndEdgeLineSegments(store.getState()) - .processNodePositions.keys(), + ...selectors.layout(store.getState()).processNodePositions.keys(), ]; process = processes[processes.length - 1]; if (!process) { diff --git a/x-pack/plugins/security_solution/public/resolver/view/use_resolver_query_params.ts b/x-pack/plugins/security_solution/public/resolver/view/use_resolver_query_params.ts index 70baef5fa88ea..21bf70aebc75b 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/use_resolver_query_params.ts +++ b/x-pack/plugins/security_solution/public/resolver/view/use_resolver_query_params.ts @@ -20,8 +20,8 @@ export function useResolverQueryParams() { const history = useHistory(); const urlSearch = useLocation().search; const resolverComponentInstanceID = useSelector(selectors.resolverComponentInstanceID); - const uniqueCrumbIdKey: string = `${resolverComponentInstanceID}CrumbId`; - const uniqueCrumbEventKey: string = `${resolverComponentInstanceID}CrumbEvent`; + const uniqueCrumbIdKey: string = `resolver-id:${resolverComponentInstanceID}`; + const uniqueCrumbEventKey: string = `resolver-event:${resolverComponentInstanceID}`; const pushToQueryParams = useCallback( (newCrumbs: CrumbInfo) => { // Construct a new set of params from the current set (minus empty params) diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/utils/pagination.ts b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/utils/pagination.ts index 2b107ab1b6db4..150f56cbd70cc 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/utils/pagination.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/utils/pagination.ts @@ -61,7 +61,7 @@ export class PaginationBuilder { const lastResult = results[results.length - 1]; const cursor = { timestamp: lastResult['@timestamp'], - eventID: eventId(lastResult), + eventID: eventId(lastResult) === undefined ? '' : String(eventId(lastResult)), }; return PaginationBuilder.urlEncodeCursor(cursor); }