From a72580c7bfa409eb130df4dd84f0940976d6bdf4 Mon Sep 17 00:00:00 2001 From: Robert Austin Date: Wed, 23 Sep 2020 11:58:17 -0400 Subject: [PATCH] [Resolver] Refactoring panel view (#77928) (#78295) * Moved `descriptiveName` from the 'common' event model into the panel view. It is now a component. Each type of event has its own translation string. Translation placeholders have more specific names. * Reorganized 'breadcrumb' components. * Use safer types many places * Add `useLinkProps` hook. It takes `PanelViewAndParameters` and returns `onClick` and `href`. Remove a bunch of copy-pasted code that did the same. * Add new common event methods to safely expose fields that were being read directly (`processPID`, `userName`, `userDomain`, `parentPID`, `md5HashForProcess`, `argsForProcess` * Removed 'primaryEventCategory' from the event model. * Removed the 'aggregate' total count concept from the panel * The mock data access layer calle no_ancestors_two_children now has related events. This will allow the click through to test all panels and it will allow the resolver test plugin to view all panels. * The `mockEndpointEvent` factory can now return events of any type instead of just process events. * Several mocks that were using unsafe casting now return the correct types. The unsafe casting was fine for testing but it made refactoring difficult because typescript couldn't find issues. * The mock helper function `withRelatedEventsOnOrigin` now takes the related events to add to the origin instead of an array describing events to be created. * The data state's `tree` field was optional but the initial state incorrectly set it to an invalid object. Now code checks for the presence of a tree object. * Added a selector called `eventByID` which is used to get the event shown in the event detail panel. This will be replaced with an API call in the near future. * Added a selector called `relatedEventCountByType` which finds the count of related events for a type from the `byCategory` structure returned from the API. We should consider changing this as it requires metaprogramming as it is. * Created a new middleware 'fetcher' to fetch related events. This is a stop-gap implementation that we expect to replace before release. * Removed the action called `appDetectedNewIdFromQueryParams`. Use `appReceivedNewExternal...` instead. * Added the first simulator test for a graph node. It checks that the origin node has 'Analyzed Event' in the label. * Added a new panel test that navigates to the nodeEvents panel view and verifies the items in the list. * Added a new panel component called 'Breadcrumbs'. * Fixed an issue where the CubeForProcess component was using `0 0 100% 100%` in the `viewBox` attribute. * The logic that calculates the 'entries' to show when viewing the details of an event was moved into a separate function and unit tested. It is called `deepObjectEntries`. * The code that shows the name of an event is now a component called `DescriptiveName`. It has an enzyme test. Each event type has its own `i18n` string which includes more descriptive placeholders. I'm not sure, but I think this will make it possible for translators to provide better contextual formatting around the values. * Refactored most panel views. They have loading components and breadcrumb components. Links are moved to their own components, allowing them to call `useLinkProps`. * Introduced a hook called `useLinkProps` which combines the `relativeHref` selector with the `useNavigateOrReplace` hook. * Removed the hook called `useRelatedEventDetailNavigation`. Use `useLinkProps` instead. * Move various styled-components into `styles` modules. * The graph node label wasn't translating 'Analyzed Event'. It now does so using a `select` expression in the ICU message. * Renamed a method on the common event model from `getAncestryAsArray` to `ancestry` for consistency. It no longer takes `undefined` for the event it operates on. * Some translations were removed due to code de-duping. --- .../common/endpoint/models/event.test.ts | 48 +- .../common/endpoint/models/event.ts | 180 +++---- .../common/endpoint/types/index.ts | 6 +- .../mocks/no_ancestors_two_children.ts | 9 +- ..._children_in_index_called_awesome_index.ts | 2 +- ..._children_with_related_events_on_origin.ts | 2 +- .../public/resolver/index.ts | 4 +- .../public/resolver/mocks/endpoint_event.ts | 35 +- .../public/resolver/mocks/resolver_tree.ts | 222 ++++---- .../resolver/models/process_event.test.ts | 26 +- .../public/resolver/models/process_event.ts | 94 ++-- .../public/resolver/models/resolver_tree.ts | 14 +- .../public/resolver/store/actions.ts | 35 +- .../public/resolver/store/data/action.ts | 9 - .../resolver/store/data/reducer.test.ts | 148 +----- .../public/resolver/store/data/reducer.ts | 16 +- .../resolver/store/data/selectors.test.ts | 4 +- .../public/resolver/store/data/selectors.ts | 424 +++++----------- .../store/data/visible_entities.test.ts | 6 +- .../public/resolver/store/methods.ts | 6 +- .../public/resolver/store/middleware/index.ts | 28 +- .../middleware/related_events_fetcher.ts | 49 ++ .../public/resolver/store/reducer.ts | 30 +- .../public/resolver/store/selectors.ts | 52 +- .../public/resolver/store/ui/selectors.ts | 10 +- .../public/resolver/types.ts | 6 +- .../public/resolver/view/edge_line.tsx | 8 +- .../public/resolver/view/graph_controls.tsx | 8 +- .../public/resolver/view/limit_warnings.tsx | 36 +- .../public/resolver/view/node.test.tsx | 42 ++ .../public/resolver/view/panel.test.tsx | 41 +- .../resolver/view/panels/breadcrumbs.tsx | 40 ++ .../resolver/view/panels/cube_for_process.tsx | 2 +- .../view/panels/deep_object_entries.test.ts | 36 ++ .../view/panels/deep_object_entries.ts | 44 ++ .../view/panels/descriptive_name.test.tsx | 50 ++ .../resolver/view/panels/descriptive_name.tsx | 114 +++++ .../resolver/view/panels/event_detail.tsx | 472 +++++++++--------- .../public/resolver/view/panels/index.tsx | 8 +- .../{node_details.tsx => node_detail.tsx} | 76 ++- .../resolver/view/panels/node_events.tsx | 218 ++++---- .../view/panels/node_events_of_type.tsx | 426 +++++++--------- .../public/resolver/view/panels/node_list.tsx | 175 +++---- .../view/panels/panel_content_error.tsx | 19 +- .../view/panels/panel_content_utilities.tsx | 62 +-- .../resolver/view/panels/panel_loading.tsx | 16 +- .../public/resolver/view/panels/styles.tsx | 50 ++ .../resolver/view/process_event_dot.tsx | 23 +- .../public/resolver/view/styles.tsx | 25 +- .../public/resolver/view/use_camera.test.tsx | 12 +- .../public/resolver/view/use_link_props.ts | 32 ++ ...se_related_event_by_category_navigation.ts | 2 +- .../use_related_event_detail_navigation.ts | 40 -- .../resolver/utils/ancestry_query_handler.ts | 7 +- .../routes/resolver/utils/children_helper.ts | 19 +- .../translations/translations/ja-JP.json | 5 - .../translations/translations/zh-CN.json | 5 - .../applications/resolver_test/index.tsx | 6 +- 58 files changed, 1668 insertions(+), 1916 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/resolver/store/middleware/related_events_fetcher.ts create mode 100644 x-pack/plugins/security_solution/public/resolver/view/node.test.tsx create mode 100644 x-pack/plugins/security_solution/public/resolver/view/panels/breadcrumbs.tsx create mode 100644 x-pack/plugins/security_solution/public/resolver/view/panels/deep_object_entries.test.ts create mode 100644 x-pack/plugins/security_solution/public/resolver/view/panels/deep_object_entries.ts create mode 100644 x-pack/plugins/security_solution/public/resolver/view/panels/descriptive_name.test.tsx create mode 100644 x-pack/plugins/security_solution/public/resolver/view/panels/descriptive_name.tsx rename x-pack/plugins/security_solution/public/resolver/view/panels/{node_details.tsx => node_detail.tsx} (74%) create mode 100644 x-pack/plugins/security_solution/public/resolver/view/use_link_props.ts delete mode 100644 x-pack/plugins/security_solution/public/resolver/view/use_related_event_detail_navigation.ts diff --git a/x-pack/plugins/security_solution/common/endpoint/models/event.test.ts b/x-pack/plugins/security_solution/common/endpoint/models/event.test.ts index 2b0aa1601ab3..fed32293e00a 100644 --- a/x-pack/plugins/security_solution/common/endpoint/models/event.test.ts +++ b/x-pack/plugins/security_solution/common/endpoint/models/event.test.ts @@ -4,8 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ import { EndpointDocGenerator } from '../generate_data'; -import { descriptiveName, isProcessRunning } from './event'; -import { ResolverEvent, SafeResolverEvent } from '../types'; +import { isProcessRunning } from './event'; +import { SafeResolverEvent } from '../types'; describe('Generated documents', () => { let generator: EndpointDocGenerator; @@ -13,50 +13,6 @@ describe('Generated documents', () => { generator = new EndpointDocGenerator('seed'); }); - describe('Event descriptive names', () => { - it('returns the right name for a registry event', () => { - const extensions = { registry: { key: `HKLM/Windows/Software/abc` } }; - const event = generator.generateEvent({ eventCategory: 'registry', extensions }); - // casting to ResolverEvent here because the `descriptiveName` function is used by the frontend is still relies - // on the unsafe ResolverEvent type. Once it's switched over to the safe version we can remove this cast. - expect(descriptiveName(event as ResolverEvent)).toEqual({ - subject: `HKLM/Windows/Software/abc`, - }); - }); - - it('returns the right name for a network event', () => { - const randomIP = `${generator.randomIP()}`; - const extensions = { network: { direction: 'outbound', forwarded_ip: randomIP } }; - const event = generator.generateEvent({ eventCategory: 'network', extensions }); - // casting to ResolverEvent here because the `descriptiveName` function is used by the frontend is still relies - // on the unsafe ResolverEvent type. Once it's switched over to the safe version we can remove this cast. - expect(descriptiveName(event as ResolverEvent)).toEqual({ - subject: `${randomIP}`, - descriptor: 'outbound', - }); - }); - - it('returns the right name for a file event', () => { - const extensions = { file: { path: 'C:\\My Documents\\business\\January\\processName' } }; - const event = generator.generateEvent({ eventCategory: 'file', extensions }); - // casting to ResolverEvent here because the `descriptiveName` function is used by the frontend is still relies - // on the unsafe ResolverEvent type. Once it's switched over to the safe version we can remove this cast. - expect(descriptiveName(event as ResolverEvent)).toEqual({ - subject: 'C:\\My Documents\\business\\January\\processName', - }); - }); - - it('returns the right name for a dns event', () => { - const extensions = { dns: { question: { name: `${generator.randomIP()}` } } }; - const event = generator.generateEvent({ eventCategory: 'dns', extensions }); - // casting to ResolverEvent here because the `descriptiveName` function is used by the frontend is still relies - // on the unsafe ResolverEvent type. Once it's switched over to the safe version we can remove this cast. - expect(descriptiveName(event as ResolverEvent)).toEqual({ - subject: extensions.dns.question.name, - }); - }); - }); - describe('Process running events', () => { it('is a running event when event.type is a string', () => { const event: SafeResolverEvent = generator.generateEvent({ 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 9634659b1a5d..00eb48bb62a5 100644 --- a/x-pack/plugins/security_solution/common/endpoint/models/event.ts +++ b/x-pack/plugins/security_solution/common/endpoint/models/event.ts @@ -104,11 +104,14 @@ export function timestampAsDateSafeVersion(event: TimestampFields): Date | undef } } -export function eventTimestamp(event: ResolverEvent): string | undefined | number { - return event['@timestamp']; +export function eventTimestamp(event: SafeResolverEvent): string | undefined | number { + return firstNonNullValue(event['@timestamp']); } -export function eventName(event: ResolverEvent): string { +/** + * Find the name of the related process. + */ +export function processName(event: ResolverEvent): string { if (isLegacyEvent(event)) { return event.endgame.process_name ? event.endgame.process_name : ''; } else { @@ -116,6 +119,58 @@ export function eventName(event: ResolverEvent): string { } } +/** + * First non-null value in the `user.name` field. + */ +export function userName(event: SafeResolverEvent): string | undefined { + if (isLegacyEventSafeVersion(event)) { + return undefined; + } else { + return firstNonNullValue(event.user?.name); + } +} + +/** + * Returns the process event's parent PID + */ +export function parentPID(event: SafeResolverEvent): number | undefined { + return firstNonNullValue( + isLegacyEventSafeVersion(event) ? event.endgame.ppid : event.process?.parent?.pid + ); +} + +/** + * First non-null value for the `process.hash.md5` field. + */ +export function md5HashForProcess(event: SafeResolverEvent): string | undefined { + return firstNonNullValue(isLegacyEventSafeVersion(event) ? undefined : event.process?.hash?.md5); +} + +/** + * First non-null value for the `event.process.args` field. + */ +export function argsForProcess(event: SafeResolverEvent): string | undefined { + if (isLegacyEventSafeVersion(event)) { + // There is not currently a key for this on Legacy event types + return undefined; + } + return firstNonNullValue(event.process?.args); +} + +/** + * First non-null value in the `user.name` field. + */ +export function userDomain(event: SafeResolverEvent): string | undefined { + if (isLegacyEventSafeVersion(event)) { + return undefined; + } else { + return firstNonNullValue(event.user?.domain); + } +} + +/** + * Find the name of the related process. + */ export function processNameSafeVersion(event: SafeResolverEvent): string | undefined { if (isLegacyEventSafeVersion(event)) { return firstNonNullValue(event.endgame.process_name); @@ -124,11 +179,10 @@ export function processNameSafeVersion(event: SafeResolverEvent): string | undef } } -export function eventId(event: ResolverEvent): number | undefined | string { - if (isLegacyEvent(event)) { - return event.endgame.serial_event_id; - } - return event.event.id; +export function eventID(event: SafeResolverEvent): number | undefined | string { + return firstNonNullValue( + isLegacyEventSafeVersion(event) ? event.endgame.serial_event_id : event.event?.id + ); } /** @@ -275,18 +329,14 @@ export function ancestryArray(event: AncestryArrayFields): string[] | undefined /** * Minimum fields needed from the `SafeResolverEvent` type for the function below to operate correctly. */ -type GetAncestryArrayFields = AncestryArrayFields & ParentEntityIDFields; +type AncestryFields = AncestryArrayFields & ParentEntityIDFields; /** * Returns an array of strings representing the ancestry for a process. * * @param event an ES document */ -export function getAncestryAsArray(event: GetAncestryArrayFields | undefined): string[] { - if (!event) { - return []; - } - +export function ancestry(event: AncestryFields): string[] { const ancestors = ancestryArray(event); if (ancestors) { return ancestors; @@ -300,35 +350,13 @@ export function getAncestryAsArray(event: GetAncestryArrayFields | undefined): s return []; } -/** - * @param event The event to get the category for - */ -export function primaryEventCategory(event: ResolverEvent): string | undefined { - if (isLegacyEvent(event)) { - const legacyFullType = event.endgame.event_type_full; - if (legacyFullType) { - return legacyFullType; - } - } else { - const eventCategories = event.event.category; - const category = typeof eventCategories === 'string' ? eventCategories : eventCategories[0]; - - return category; - } -} - /** * @param event The event to get the full ECS category for */ -export function allEventCategories(event: ResolverEvent): string | string[] | undefined { - if (isLegacyEvent(event)) { - const legacyFullType = event.endgame.event_type_full; - if (legacyFullType) { - return legacyFullType; - } - } else { - return event.event.category; - } +export function eventCategory(event: SafeResolverEvent): string[] { + return values( + isLegacyEventSafeVersion(event) ? event.endgame.event_type_full : event.event?.category + ); } /** @@ -336,71 +364,19 @@ export function allEventCategories(event: ResolverEvent): string | string[] | un * see: https://www.elastic.co/guide/en/ecs/current/ecs-event.html * @param event The ResolverEvent to get the ecs type for */ -export function ecsEventType(event: ResolverEvent): Array { - if (isLegacyEvent(event)) { - return [event.endgame.event_subtype_full]; - } - return typeof event.event.type === 'string' ? [event.event.type] : event.event.type; +export function eventType(event: SafeResolverEvent): string[] { + return values( + isLegacyEventSafeVersion(event) ? event.endgame.event_subtype_full : event.event?.type + ); } /** - * #Descriptive Names For Related Events: - * - * The following section provides facilities for deriving **Descriptive Names** for ECS-compliant event data. - * There are drawbacks to trying to do this: It *will* require ongoing maintenance. It presents temptations to overarticulate. - * On balance, however, it seems that the benefit of giving the user some form of information they can recognize & scan outweighs the drawbacks. + * event.kind as an array. */ -type DeepPartial = T extends object ? { [K in keyof T]?: DeepPartial } : T; -/** - * Based on the ECS category of the event, attempt to provide a more descriptive name - * (e.g. the `event.registry.key` for `registry` or the `dns.question.name` for `dns`, etc.). - * This function returns the data in the form of `{subject, descriptor}` where `subject` will - * tend to be the more distinctive term (e.g. 137.213.212.7 for a network event) and the - * `descriptor` can be used to present more useful/meaningful view (e.g. `inbound 137.213.212.7` - * in the example above). - * see: https://www.elastic.co/guide/en/ecs/current/ecs-field-reference.html - * @param event The ResolverEvent to get the descriptive name for - * @returns { descriptiveName } An attempt at providing a readable name to the user - */ -export function descriptiveName(event: ResolverEvent): { subject: string; descriptor?: string } { - if (isLegacyEvent(event)) { - return { subject: eventName(event) }; - } - - // To be somewhat defensive, we'll check for the presence of these. - const partialEvent: DeepPartial = event; - - /** - * This list of attempts can be expanded/adjusted as the underlying model changes over time: - */ - - // Stable, per ECS 1.5: https://www.elastic.co/guide/en/ecs/current/ecs-allowed-values-event-category.html - - if (partialEvent.network?.forwarded_ip) { - return { - subject: String(partialEvent.network?.forwarded_ip), - descriptor: String(partialEvent.network?.direction), - }; - } - - if (partialEvent.file?.path) { - return { - subject: String(partialEvent.file?.path), - }; - } - - // Extended categories (per ECS 1.5): - const pathOrKey = partialEvent.registry?.path || partialEvent.registry?.key; - if (pathOrKey) { - return { - subject: String(pathOrKey), - }; - } - - if (partialEvent.dns?.question?.name) { - return { subject: String(partialEvent.dns?.question?.name) }; +export function eventKind(event: SafeResolverEvent): string[] { + if (isLegacyEventSafeVersion(event)) { + return []; + } else { + return values(event.event?.kind); } - - // Fall back on entityId if we can't fish a more descriptive name out. - return { subject: entityId(event) }; } 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 6afec7590347..d97fdfbf7d18 100644 --- a/x-pack/plugins/security_solution/common/endpoint/types/index.ts +++ b/x-pack/plugins/security_solution/common/endpoint/types/index.ts @@ -183,7 +183,7 @@ export interface ResolverTree { relatedEvents: Omit; relatedAlerts: Omit; ancestry: ResolverAncestry; - lifecycle: ResolverEvent[]; + lifecycle: SafeResolverEvent[]; stats: ResolverNodeStats; } @@ -209,7 +209,7 @@ export interface SafeResolverTree { */ export interface ResolverLifecycleNode { entityID: string; - lifecycle: ResolverEvent[]; + lifecycle: SafeResolverEvent[]; /** * stats are only set when the entire tree is being fetched */ @@ -263,7 +263,7 @@ export interface SafeResolverAncestry { */ export interface ResolverRelatedEvents { entityID: string; - events: ResolverEvent[]; + events: SafeResolverEvent[]; nextEvent: string | null; } 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 0883a3787fcc..fd086bd9b984 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 @@ -9,7 +9,6 @@ import { ResolverTree, ResolverEntityIndex, } from '../../../../common/endpoint/types'; -import { mockEndpointEvent } from '../../mocks/endpoint_event'; import { mockTreeWithNoAncestorsAnd2Children } from '../../mocks/resolver_tree'; import { DataAccessLayer } from '../../types'; @@ -54,13 +53,7 @@ export function noAncestorsTwoChildren(): { dataAccessLayer: DataAccessLayer; me relatedEvents(entityID: string): Promise { return Promise.resolve({ entityID, - events: [ - mockEndpointEvent({ - entityID, - name: 'event', - timestamp: 0, - }), - ], + events: [], nextEvent: null, }); }, 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 ec0fa9348578..86450b25eb1d 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 @@ -61,7 +61,7 @@ export function noAncestorsTwoChildenInIndexCalledAwesomeIndex(): { events: [ mockEndpointEvent({ entityID, - name: 'event', + processName: 'event', timestamp: 0, }), ], 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 95ec0cd1a5f7..ec773a09ae8e 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 @@ -66,7 +66,7 @@ export function noAncestorsTwoChildrenWithRelatedEventsOnOrigin(): { entityID, events, nextEvent: null, - } as ResolverRelatedEvents); + }); }, /** diff --git a/x-pack/plugins/security_solution/public/resolver/index.ts b/x-pack/plugins/security_solution/public/resolver/index.ts index 409f82c9d156..08a3722f4049 100644 --- a/x-pack/plugins/security_solution/public/resolver/index.ts +++ b/x-pack/plugins/security_solution/public/resolver/index.ts @@ -7,7 +7,7 @@ import { Provider } from 'react-redux'; import { ResolverPluginSetup } from './types'; import { resolverStoreFactory } from './store/index'; import { ResolverWithoutProviders } from './view/resolver_without_providers'; -import { noAncestorsTwoChildren } from './data_access_layer/mocks/no_ancestors_two_children'; +import { noAncestorsTwoChildrenWithRelatedEventsOnOrigin } from './data_access_layer/mocks/no_ancestors_two_children_with_related_events_on_origin'; /** * These exports are used by the plugin 'resolverTest' defined in x-pack's plugin_functional suite. @@ -23,7 +23,7 @@ export function resolverPluginSetup(): ResolverPluginSetup { ResolverWithoutProviders, mocks: { dataAccessLayer: { - noAncestorsTwoChildren, + noAncestorsTwoChildrenWithRelatedEventsOnOrigin, }, }, }; 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 083f6b8baa59..d19ca285ff3f 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 @@ -4,31 +4,36 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EndpointEvent } from '../../../common/endpoint/types'; +import { SafeResolverEvent } from '../../../common/endpoint/types'; /** * Simple mock endpoint event that works for tree layouts. */ export function mockEndpointEvent({ entityID, - name, - parentEntityId, - timestamp, - lifecycleType, + processName = 'process name', + parentEntityID, + timestamp = 0, + eventType = 'start', + eventCategory = 'process', pid = 0, + eventID = 'event id', }: { entityID: string; - name: string; - parentEntityId?: string; - timestamp: number; - lifecycleType?: string; + processName?: string; + parentEntityID?: string; + timestamp?: number; + eventType?: string; + eventCategory?: string; pid?: number; -}): EndpointEvent { + eventID?: string; +}): SafeResolverEvent { return { '@timestamp': timestamp, event: { - type: lifecycleType ? lifecycleType : 'start', - category: 'process', + type: eventType, + category: eventCategory, + id: eventID, }, agent: { id: 'agent.id', @@ -46,15 +51,15 @@ export function mockEndpointEvent({ entity_id: entityID, executable: 'executable', args: 'args', - name, + name: processName, pid, hash: { md5: 'hash.md5', }, parent: { pid: 0, - entity_id: parentEntityId, + entity_id: parentEntityID, }, }, - } as EndpointEvent; + }; } 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 8bd5953e9cb4..8691ecac4d1c 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,7 +5,8 @@ */ import { mockEndpointEvent } from './endpoint_event'; -import { ResolverTree, ResolverEvent, SafeResolverEvent } from '../../../common/endpoint/types'; +import { ResolverTree, SafeResolverEvent } from '../../../common/endpoint/types'; +import * as eventModel from '../../../common/endpoint/models/event'; export function mockTreeWith2AncestorsAndNoChildren({ originID, @@ -16,34 +17,42 @@ export function mockTreeWith2AncestorsAndNoChildren({ firstAncestorID: string; originID: string; }): ResolverTree { - const secondAncestor: ResolverEvent = mockEndpointEvent({ + const secondAncestor: SafeResolverEvent = mockEndpointEvent({ entityID: secondAncestorID, - name: 'a', - parentEntityId: 'none', + processName: 'a', + parentEntityID: 'none', timestamp: 0, }); - const firstAncestor: ResolverEvent = mockEndpointEvent({ + const firstAncestor: SafeResolverEvent = mockEndpointEvent({ entityID: firstAncestorID, - name: 'b', - parentEntityId: secondAncestorID, + processName: 'b', + parentEntityID: secondAncestorID, timestamp: 1, }); - const originEvent: ResolverEvent = mockEndpointEvent({ + const originEvent: SafeResolverEvent = mockEndpointEvent({ entityID: originID, - name: 'c', - parentEntityId: firstAncestorID, + processName: 'c', + parentEntityID: firstAncestorID, timestamp: 2, }); - return ({ + return { entityID: originID, children: { childNodes: [], + nextChild: null, }, ancestry: { - ancestors: [{ lifecycle: [secondAncestor] }, { lifecycle: [firstAncestor] }], + nextAncestor: null, + ancestors: [ + { entityID: secondAncestorID, lifecycle: [secondAncestor] }, + { entityID: firstAncestorID, lifecycle: [firstAncestor] }, + ], }, lifecycle: [originEvent], - } as unknown) as ResolverTree; + relatedEvents: { events: [], nextEvent: null }, + relatedAlerts: { alerts: [], nextAlert: null }, + stats: { events: { total: 2, byCategory: {} }, totalAlerts: 0 }, + }; } export function mockTreeWithAllProcessesTerminated({ @@ -55,44 +64,44 @@ export function mockTreeWithAllProcessesTerminated({ firstAncestorID: string; originID: string; }): ResolverTree { - const secondAncestor: ResolverEvent = mockEndpointEvent({ + const secondAncestor: SafeResolverEvent = mockEndpointEvent({ entityID: secondAncestorID, - name: 'a', - parentEntityId: 'none', + processName: 'a', + parentEntityID: 'none', timestamp: 0, }); - const firstAncestor: ResolverEvent = mockEndpointEvent({ + const firstAncestor: SafeResolverEvent = mockEndpointEvent({ entityID: firstAncestorID, - name: 'b', - parentEntityId: secondAncestorID, + processName: 'b', + parentEntityID: secondAncestorID, timestamp: 1, }); - const originEvent: ResolverEvent = mockEndpointEvent({ + const originEvent: SafeResolverEvent = mockEndpointEvent({ entityID: originID, - name: 'c', - parentEntityId: firstAncestorID, + processName: 'c', + parentEntityID: firstAncestorID, timestamp: 2, }); - const secondAncestorTermination: ResolverEvent = mockEndpointEvent({ + const secondAncestorTermination: SafeResolverEvent = mockEndpointEvent({ entityID: secondAncestorID, - name: 'a', - parentEntityId: 'none', + processName: 'a', + parentEntityID: 'none', timestamp: 0, - lifecycleType: 'end', + eventType: 'end', }); - const firstAncestorTermination: ResolverEvent = mockEndpointEvent({ + const firstAncestorTermination: SafeResolverEvent = mockEndpointEvent({ entityID: firstAncestorID, - name: 'b', - parentEntityId: secondAncestorID, + processName: 'b', + parentEntityID: secondAncestorID, timestamp: 1, - lifecycleType: 'end', + eventType: 'end', }); - const originEventTermination: ResolverEvent = mockEndpointEvent({ + const originEventTermination: SafeResolverEvent = mockEndpointEvent({ entityID: originID, - name: 'c', - parentEntityId: firstAncestorID, + processName: 'c', + parentEntityID: firstAncestorID, timestamp: 2, - lifecycleType: 'end', + eventType: 'end', }); return ({ entityID: originID, @@ -109,26 +118,10 @@ export function mockTreeWithAllProcessesTerminated({ } as unknown) as ResolverTree; } -/** - * A valid category for a related event. E.g. "registry", "network", "file" - */ -type RelatedEventCategory = string; -/** - * A valid type for a related event. E.g. "start", "end", "access" - */ -type RelatedEventType = string; - /** * Add/replace related event info (on origin node) for any mock ResolverTree - * - * @param treeToAddRelatedEventsTo the ResolverTree to modify - * @param relatedEventsToAddByCategoryAndType Iterable of `[category, type]` pairs describing related events. e.g. [['dns','info'],['registry','access']] */ -function withRelatedEventsOnOrigin( - treeToAddRelatedEventsTo: ResolverTree, - relatedEventsToAddByCategoryAndType: Iterable<[RelatedEventCategory, RelatedEventType]> -): ResolverTree { - const events: SafeResolverEvent[] = []; +function withRelatedEventsOnOrigin(tree: ResolverTree, events: SafeResolverEvent[]): ResolverTree { const byCategory: Record = {}; const stats = { totalAlerts: 0, @@ -137,29 +130,19 @@ function withRelatedEventsOnOrigin( byCategory, }, }; - for (const [category, type] of relatedEventsToAddByCategoryAndType) { - events.push({ - '@timestamp': 1, - event: { - kind: 'event', - type, - category, - id: 'xyz', - }, - process: { - entity_id: treeToAddRelatedEventsTo.entityID, - }, - }); + for (const event of events) { stats.events.total++; - stats.events.byCategory[category] = stats.events.byCategory[category] - ? stats.events.byCategory[category] + 1 - : 1; + for (const category of eventModel.eventCategory(event)) { + stats.events.byCategory[category] = stats.events.byCategory[category] + ? stats.events.byCategory[category] + 1 + : 1; + } } return { - ...treeToAddRelatedEventsTo, + ...tree, stats, relatedEvents: { - events: events as ResolverEvent[], + events, nextEvent: null, }, }; @@ -174,38 +157,46 @@ export function mockTreeWithNoAncestorsAnd2Children({ firstChildID: string; secondChildID: string; }): ResolverTree { - const origin: ResolverEvent = mockEndpointEvent({ + const origin: SafeResolverEvent = mockEndpointEvent({ pid: 0, entityID: originID, - name: 'c.ext', - parentEntityId: 'none', + processName: 'c.ext', + parentEntityID: 'none', timestamp: 0, }); - const firstChild: ResolverEvent = mockEndpointEvent({ + const firstChild: SafeResolverEvent = mockEndpointEvent({ pid: 1, entityID: firstChildID, - name: 'd', - parentEntityId: originID, + processName: 'd', + parentEntityID: originID, timestamp: 1, }); - const secondChild: ResolverEvent = mockEndpointEvent({ + const secondChild: SafeResolverEvent = mockEndpointEvent({ pid: 2, entityID: secondChildID, - name: 'e', - parentEntityId: originID, + processName: 'e', + parentEntityID: originID, timestamp: 2, }); - return ({ + return { entityID: originID, children: { - childNodes: [{ lifecycle: [firstChild] }, { lifecycle: [secondChild] }], + childNodes: [ + { entityID: firstChildID, lifecycle: [firstChild] }, + { entityID: secondChildID, lifecycle: [secondChild] }, + ], + nextChild: null, }, ancestry: { ancestors: [], + nextAncestor: null, }, lifecycle: [origin], - } as unknown) as ResolverTree; + relatedEvents: { events: [], nextEvent: null }, + relatedAlerts: { alerts: [], nextAlert: null }, + stats: { events: { total: 2, byCategory: {} }, totalAlerts: 0 }, + }; } /** @@ -222,52 +213,52 @@ export function mockTreeWith1AncestorAnd2ChildrenAndAllNodesHave2GraphableEvents firstChildID: string; secondChildID: string; }): ResolverTree { - const ancestor: ResolverEvent = mockEndpointEvent({ + const ancestor: SafeResolverEvent = mockEndpointEvent({ entityID: ancestorID, - name: ancestorID, + processName: ancestorID, timestamp: 1, - parentEntityId: undefined, + parentEntityID: undefined, }); - const ancestorClone: ResolverEvent = mockEndpointEvent({ + const ancestorClone: SafeResolverEvent = mockEndpointEvent({ entityID: ancestorID, - name: ancestorID, + processName: ancestorID, timestamp: 1, - parentEntityId: undefined, + parentEntityID: undefined, }); - const origin: ResolverEvent = mockEndpointEvent({ + const origin: SafeResolverEvent = mockEndpointEvent({ entityID: originID, - name: originID, - parentEntityId: ancestorID, + processName: originID, + parentEntityID: ancestorID, timestamp: 0, }); - const originClone: ResolverEvent = mockEndpointEvent({ + const originClone: SafeResolverEvent = mockEndpointEvent({ entityID: originID, - name: originID, - parentEntityId: ancestorID, + processName: originID, + parentEntityID: ancestorID, timestamp: 0, }); - const firstChild: ResolverEvent = mockEndpointEvent({ + const firstChild: SafeResolverEvent = mockEndpointEvent({ entityID: firstChildID, - name: firstChildID, - parentEntityId: originID, + processName: firstChildID, + parentEntityID: originID, timestamp: 1, }); - const firstChildClone: ResolverEvent = mockEndpointEvent({ + const firstChildClone: SafeResolverEvent = mockEndpointEvent({ entityID: firstChildID, - name: firstChildID, - parentEntityId: originID, + processName: firstChildID, + parentEntityID: originID, timestamp: 1, }); - const secondChild: ResolverEvent = mockEndpointEvent({ + const secondChild: SafeResolverEvent = mockEndpointEvent({ entityID: secondChildID, - name: secondChildID, - parentEntityId: originID, + processName: secondChildID, + parentEntityID: originID, timestamp: 2, }); - const secondChildClone: ResolverEvent = mockEndpointEvent({ + const secondChildClone: SafeResolverEvent = mockEndpointEvent({ entityID: secondChildID, - name: secondChildID, - parentEntityId: originID, + processName: secondChildID, + parentEntityID: originID, timestamp: 2, }); @@ -330,9 +321,22 @@ export function mockTreeWithNoAncestorsAndTwoChildrenAndRelatedEventsOnOrigin({ firstChildID, secondChildID, }); - const withRelatedEvents: Array<[string, string]> = [ - ['registry', 'access'], - ['registry', 'access'], + const parentEntityID = eventModel.parentEntityIDSafeVersion(baseTree.lifecycle[0]); + const relatedEvents = [ + mockEndpointEvent({ + entityID: originID, + parentEntityID, + eventID: 'first related event', + eventType: 'access', + eventCategory: 'registry', + }), + mockEndpointEvent({ + entityID: originID, + parentEntityID, + eventID: 'second related event', + eventType: 'access', + eventCategory: 'registry', + }), ]; - return withRelatedEventsOnOrigin(baseTree, withRelatedEvents); + return withRelatedEventsOnOrigin(baseTree, relatedEvents); } 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 4d48b34fb284..380b15cf9da4 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,11 +6,7 @@ import { eventType, orderByTime, userInfoForProcess } from './process_event'; import { mockProcessEvent } from './process_event_test_helpers'; -import { - LegacyEndpointEvent, - ResolverEvent, - SafeResolverEvent, -} from '../../../common/endpoint/types'; +import { LegacyEndpointEvent, SafeResolverEvent } from '../../../common/endpoint/types'; describe('process event', () => { describe('eventType', () => { @@ -45,7 +41,7 @@ describe('process event', () => { }); }); describe('orderByTime', () => { - let mock: (time: number, eventID: string) => ResolverEvent; + let mock: (time: number, eventID: string) => SafeResolverEvent; let events: SafeResolverEvent[]; beforeEach(() => { mock = (time, eventID) => { @@ -54,20 +50,20 @@ describe('process event', () => { 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') as SafeResolverEvent, - mock(0, 'c') as SafeResolverEvent, - mock(1, 'e') as SafeResolverEvent, - mock(NaN, 'g') as SafeResolverEvent, - mock(-1, 'b') as SafeResolverEvent, - mock(0, 'd') as SafeResolverEvent, - mock(1, 'f') as SafeResolverEvent, - mock(NaN, 'h') as SafeResolverEvent, + 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', () => { 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 ea588731a55c..1510fc7f9f36 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 @@ -4,7 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import * as event from '../../../common/endpoint/models/event'; +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 { ResolverProcessType } from '../types'; @@ -12,19 +14,11 @@ 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: ResolverEvent) { +export function isGraphableProcess(passedEvent: SafeResolverEvent) { return eventType(passedEvent) === 'processCreated' || eventType(passedEvent) === 'processRan'; } -function isValue(field: string | string[], value: string) { - if (field instanceof Array) { - return field.length === 1 && field[0] === value; - } else { - return field === value; - } -} - -export function isTerminatedProcess(passedEvent: ResolverEvent) { +export function isTerminatedProcess(passedEvent: SafeResolverEvent) { return eventType(passedEvent) === 'processTerminated'; } @@ -33,7 +27,7 @@ export function isTerminatedProcess(passedEvent: ResolverEvent) { * may return NaN if the timestamp wasn't present or was invalid. */ export function datetime(passedEvent: SafeResolverEvent): number | null { - const timestamp = event.timestampSafeVersion(passedEvent); + const timestamp = eventModel.timestampSafeVersion(passedEvent); const time = timestamp === undefined ? 0 : new Date(timestamp).getTime(); @@ -44,8 +38,8 @@ export function datetime(passedEvent: SafeResolverEvent): number | null { /** * Returns a custom event type for a process event based on the event's metadata. */ -export function eventType(passedEvent: ResolverEvent): ResolverProcessType { - if (event.isLegacyEvent(passedEvent)) { +export function eventType(passedEvent: SafeResolverEvent): ResolverProcessType { + if (eventModel.isLegacyEventSafeVersion(passedEvent)) { const { endgame: { event_type_full: type, event_subtype_full: subType }, } = passedEvent; @@ -64,20 +58,20 @@ export function eventType(passedEvent: ResolverEvent): ResolverProcessType { return 'processCausedAlert'; } } else { - const { - event: { type, category, kind }, - } = passedEvent; - if (isValue(category, 'process')) { - if (isValue(type, 'start') || isValue(type, 'change') || isValue(type, 'creation')) { + const type = new Set(eventModel.eventType(passedEvent)); + const category = new Set(eventModel.eventCategory(passedEvent)); + const kind = new Set(eventModel.eventKind(passedEvent)); + if (category.has('process')) { + if (type.has('start') || type.has('change') || type.has('creation')) { return 'processCreated'; - } else if (isValue(type, 'info')) { + } else if (type.has('info')) { return 'processRan'; - } else if (isValue(type, 'end')) { + } else if (type.has('end')) { return 'processTerminated'; } else { return 'unknownProcessEvent'; } - } else if (kind === 'alert') { + } else if (kind.has('alert')) { return 'processCausedAlert'; } } @@ -88,7 +82,7 @@ export function eventType(passedEvent: ResolverEvent): ResolverProcessType { * Returns the process event's PID */ export function uniquePidForProcess(passedEvent: ResolverEvent): string { - if (event.isLegacyEvent(passedEvent)) { + if (eventModel.isLegacyEvent(passedEvent)) { return String(passedEvent.endgame.unique_pid); } else { return passedEvent.process.entity_id; @@ -98,45 +92,32 @@ export function uniquePidForProcess(passedEvent: ResolverEvent): string { /** * Returns the PID for the process on the host */ -export function processPid(passedEvent: ResolverEvent): number | undefined { - if (event.isLegacyEvent(passedEvent)) { - return passedEvent.endgame.pid; - } else { - return passedEvent.process.pid; - } +export function processPID(event: SafeResolverEvent): number | undefined { + return firstNonNullValue( + eventModel.isLegacyEventSafeVersion(event) ? event.endgame.pid : event.process?.pid + ); } /** * Returns the process event's parent PID */ export function uniqueParentPidForProcess(passedEvent: ResolverEvent): string | undefined { - if (event.isLegacyEvent(passedEvent)) { + if (eventModel.isLegacyEvent(passedEvent)) { return String(passedEvent.endgame.unique_ppid); } else { return passedEvent.process.parent?.entity_id; } } -/** - * Returns the process event's parent PID - */ -export function processParentPid(passedEvent: ResolverEvent): number | undefined { - if (event.isLegacyEvent(passedEvent)) { - return passedEvent.endgame.ppid; - } else { - return passedEvent.process.parent?.pid; - } -} - /** * Returns the process event's path on its host */ -export function processPath(passedEvent: ResolverEvent): string | undefined { - if (event.isLegacyEvent(passedEvent)) { - return passedEvent.endgame.process_path; - } else { - return passedEvent.process.executable; - } +export function processPath(passedEvent: SafeResolverEvent): string | undefined { + return firstNonNullValue( + eventModel.isLegacyEventSafeVersion(passedEvent) + ? passedEvent.endgame.process_path + : passedEvent.process?.executable + ); } /** @@ -148,19 +129,6 @@ export function userInfoForProcess( return passedEvent.user; } -/** - * Returns the MD5 hash for the `passedEvent` parameter, or undefined if it can't be located - * @param {ResolverEvent} passedEvent The `ResolverEvent` to get the MD5 value for - * @returns {string | undefined} The MD5 string for the event - */ -export function md5HashForProcess(passedEvent: ResolverEvent): string | undefined { - if (event.isLegacyEvent(passedEvent)) { - // There is not currently a key for this on Legacy event types - return undefined; - } - return passedEvent?.process?.hash?.md5; -} - /** * Returns the command line path and arguments used to run the `passedEvent` if any * @@ -168,7 +136,7 @@ export function md5HashForProcess(passedEvent: ResolverEvent): string | undefine * @returns {string | undefined} The arguments (including the path) used to run the process */ export function argsForProcess(passedEvent: ResolverEvent): string | undefined { - if (event.isLegacyEvent(passedEvent)) { + if (eventModel.isLegacyEvent(passedEvent)) { // There is not currently a key for this on Legacy event types return undefined; } @@ -184,8 +152,8 @@ export function orderByTime(first: SafeResolverEvent, second: SafeResolverEvent) if (firstDatetime === secondDatetime) { // break ties using an arbitrary (stable) comparison of `eventId` (which should be unique) - return String(event.eventIDSafeVersion(first)).localeCompare( - String(event.eventIDSafeVersion(second)) + return String(eventModel.eventIDSafeVersion(first)).localeCompare( + String(eventModel.eventIDSafeVersion(second)) ); } else if (firstDatetime === null || secondDatetime === null) { // sort `null`'s as higher than numbers 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 446e371832d3..775b88246b61 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 @@ -6,12 +6,12 @@ import { ResolverTree, - ResolverEvent, ResolverNodeStats, ResolverLifecycleNode, ResolverChildNode, + SafeResolverEvent, } from '../../../common/endpoint/types'; -import { uniquePidForProcess } from './process_event'; +import * as eventModel from '../../../common/endpoint/models/event'; /** * ResolverTree is a type returned by the server. @@ -29,7 +29,7 @@ function lifecycleNodes(tree: ResolverTree): ResolverLifecycleNode[] { * All the process events */ export function lifecycleEvents(tree: ResolverTree) { - const events: ResolverEvent[] = [...tree.lifecycle]; + const events: SafeResolverEvent[] = [...tree.lifecycle]; for (const { lifecycle } of tree.children.childNodes) { events.push(...lifecycle); } @@ -66,7 +66,7 @@ export function mock({ /** * Events represented by the ResolverTree. */ - events: ResolverEvent[]; + events: SafeResolverEvent[]; children?: ResolverChildNode[]; /** * Optionally provide cursors for the 'children' and 'ancestry' edges. @@ -77,8 +77,12 @@ export function mock({ return null; } const first = events[0]; + const entityID = eventModel.entityIDSafeVersion(first); + if (!entityID) { + throw new Error('first mock event must include an entityID.'); + } return { - entityID: uniquePidForProcess(first), + entityID, // Required children: { childNodes: children, diff --git a/x-pack/plugins/security_solution/public/resolver/store/actions.ts b/x-pack/plugins/security_solution/public/resolver/store/actions.ts index 6a02d5b76bc4..3348c962efde 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/actions.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/actions.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ import { CameraAction } from './camera'; -import { ResolverEvent } from '../../../common/endpoint/types'; +import { SafeResolverEvent } from '../../../common/endpoint/types'; import { DataAction } from './data/action'; /** @@ -16,25 +16,7 @@ interface UserBroughtProcessIntoView { /** * Used to identify the process node that should be brought into view. */ - readonly process: ResolverEvent; - /** - * The time (since epoch in milliseconds) when the action was dispatched. - */ - readonly time: number; - }; -} - -/** - * When an examination of query params in the UI indicates that state needs to - * be updated to reflect the new selection - */ -interface AppDetectedNewIdFromQueryParams { - readonly type: 'appDetectedNewIdFromQueryParams'; - readonly payload: { - /** - * Used to identify the process the process that should be synced with state. - */ - readonly process: ResolverEvent; + readonly process: SafeResolverEvent; /** * The time (since epoch in milliseconds) when the action was dispatched. */ @@ -51,15 +33,6 @@ interface UserRequestedRelatedEventData { readonly payload: string; } -/** - * The action dispatched when the app requests related event data for one - * subject (whose entity_id should be included as `payload`) - */ -interface AppDetectedMissingEventData { - readonly type: 'appDetectedMissingEventData'; - readonly payload: string; -} - /** * When the user switches the "active descendant" of the Resolver. * The "active descendant" (from the point of view of the parent element) @@ -127,6 +100,4 @@ export type ResolverAction = | UserBroughtProcessIntoView | UserFocusedOnResolverNode | UserSelectedResolverNode - | UserRequestedRelatedEventData - | AppDetectedNewIdFromQueryParams - | AppDetectedMissingEventData; + | UserRequestedRelatedEventData; 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 59d1494ae8c2..0cb1cd1cec77 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 @@ -45,14 +45,6 @@ interface AppAbortedResolverDataRequest { readonly payload: TreeFetcherParameters; } -/** - * Will occur when a request for related event data is unsuccessful. - */ -interface ServerFailedToReturnRelatedEventData { - readonly type: 'serverFailedToReturnRelatedEventData'; - readonly payload: string; -} - /** * When related events are returned from the server */ @@ -64,7 +56,6 @@ interface ServerReturnedRelatedEventData { export type DataAction = | ServerReturnedResolverData | ServerFailedToReturnResolverData - | ServerFailedToReturnRelatedEventData | ServerReturnedRelatedEventData | AppRequestedResolverData | AppAbortedResolverDataRequest; 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 1e2de06ea4af..5714345de043 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 @@ -10,8 +10,7 @@ import { dataReducer } from './reducer'; import * as selectors from './selectors'; import { DataState } from '../../types'; import { DataAction } from './action'; -import { ResolverChildNode, ResolverEvent, ResolverTree } from '../../../../common/endpoint/types'; -import * as eventModel from '../../../../common/endpoint/models/event'; +import { ResolverChildNode, ResolverTree } from '../../../../common/endpoint/types'; import { values } from '../../../../common/endpoint/models/ecs_safety_helpers'; import { mockTreeFetcherParameters } from '../../mocks/tree_fetcher_parameters'; @@ -43,7 +42,7 @@ describe('Resolver Data Middleware', () => { 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 as ResolverEvent[], + events: baseTree.allEvents, cursors: { childrenNextChild: 'aValidChildCursor', ancestryNextAncestor: 'aValidAncestorCursor', @@ -61,9 +60,6 @@ describe('Resolver Data Middleware', () => { describe('when data was received with stats mocked for the first child node', () => { let firstChildNodeInTree: TreeNode; - let eventStatsForFirstChildNode: { total: number; byCategory: Record }; - let categoryToOverCount: string; - let aggregateCategoryTotalForFirstChildNode: number; let tree: ResolverTree; /** @@ -73,13 +69,7 @@ describe('Resolver Data Middleware', () => { */ beforeEach(() => { - ({ - tree, - firstChildNodeInTree, - eventStatsForFirstChildNode, - categoryToOverCount, - aggregateCategoryTotalForFirstChildNode, - } = mockedTree()); + ({ tree, firstChildNodeInTree } = mockedTree()); if (tree) { dispatchTree(tree); } @@ -94,7 +84,7 @@ describe('Resolver Data Middleware', () => { 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 as ResolverEvent[], + events: firstChildNodeInTree.relatedEvents, nextEvent: null, }, }; @@ -108,121 +98,6 @@ describe('Resolver Data Middleware', () => { expect(selectedEventsForFirstChildNode).toBe(firstChildNodeInTree.relatedEvents); }); - it('should indicate the correct related event count for each category', () => { - const selectedRelatedInfo = selectors.relatedEventInfoByEntityId(store.getState()); - const displayCountsForCategory = selectedRelatedInfo(firstChildNodeInTree.id) - ?.numberActuallyDisplayedForCategory!; - - const eventCategoriesForNode: string[] = Object.keys( - eventStatsForFirstChildNode.byCategory - ); - - for (const eventCategory of eventCategoriesForNode) { - expect(`${eventCategory}:${displayCountsForCategory(eventCategory)}`).toBe( - `${eventCategory}:${eventStatsForFirstChildNode.byCategory[eventCategory]}` - ); - } - }); - /** - * The general approach reflected here is to _avoid_ showing a limit warning - even if we hit - * the overall related event limit - as long as the number in our category matches what the stats - * say we have. E.g. If the stats say you have 20 dns events, and we receive 20 dns events, we - * don't need to display a limit warning for that, even if we hit some overall event limit of e.g. 100 - * while we were fetching the 20. - */ - it('should not indicate the limit has been exceeded because the number of related events received for the category is greater or equal to the stats count', () => { - const selectedRelatedInfo = selectors.relatedEventInfoByEntityId(store.getState()); - const shouldShowLimit = selectedRelatedInfo(firstChildNodeInTree.id) - ?.shouldShowLimitForCategory!; - for (const typeCounted of Object.keys(eventStatsForFirstChildNode.byCategory)) { - expect(shouldShowLimit(typeCounted)).toBe(false); - } - }); - it('should not indicate that there are any related events missing because the number of related events received for the category is greater or equal to the stats count', () => { - const selectedRelatedInfo = selectors.relatedEventInfoByEntityId(store.getState()); - const notDisplayed = selectedRelatedInfo(firstChildNodeInTree.id) - ?.numberNotDisplayedForCategory!; - for (const typeCounted of Object.keys(eventStatsForFirstChildNode.byCategory)) { - expect(notDisplayed(typeCounted)).toBe(0); - } - }); - it('should return an overall correct count for the number of related events', () => { - const aggregateTotalByEntityId = selectors.relatedEventAggregateTotalByEntityId( - store.getState() - ); - const countForId = aggregateTotalByEntityId(firstChildNodeInTree.id); - expect(countForId).toBe(aggregateCategoryTotalForFirstChildNode); - }); - }); - describe('when data was received and stats show more related events than the API can provide', () => { - beforeEach(() => { - // Add 1 to the stats for an event category so that the selectors think we are missing data. - // This mutates `tree`, and then we re-dispatch it - eventStatsForFirstChildNode.byCategory[categoryToOverCount] = - eventStatsForFirstChildNode.byCategory[categoryToOverCount] + 1; - - if (tree) { - dispatchTree(tree); - 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 as ResolverEvent[], - nextEvent: 'aValidNextEventCursor', - }, - }; - store.dispatch(relatedAction); - } - }); - 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 return related events for the category equal to the number of events of that type provided', () => { - const relatedEventsByCategory = selectors.relatedEventsByCategory(store.getState()); - const relatedEventsForOvercountedCategory = relatedEventsByCategory( - firstChildNodeInTree.id - )(categoryToOverCount); - expect(relatedEventsForOvercountedCategory.length).toBe( - eventStatsForFirstChildNode.byCategory[categoryToOverCount] - 1 - ); - }); - it('should return the correct related event detail metadata for a given related event', () => { - const relatedEventsByCategory = selectors.relatedEventsByCategory(store.getState()); - const someRelatedEventForTheFirstChild = relatedEventsByCategory(firstChildNodeInTree.id)( - categoryToOverCount - )[0]; - const relatedEventID = eventModel.eventId(someRelatedEventForTheFirstChild)!; - const relatedDisplayInfo = selectors.relatedEventDisplayInfoByEntityAndSelfID( - store.getState() - )(firstChildNodeInTree.id, relatedEventID); - const [, countOfSameType, , sectionData] = relatedDisplayInfo; - const hostEntries = sectionData.filter((section) => { - return section.sectionTitle === 'host'; - })[0].entries; - expect(hostEntries).toContainEqual({ title: 'os.platform', description: 'Windows' }); - expect(countOfSameType).toBe( - eventStatsForFirstChildNode.byCategory[categoryToOverCount] - 1 - ); - }); - it('should indicate the limit has been exceeded because the number of related events received for the category is less than what the stats count said it would be', () => { - const selectedRelatedInfo = selectors.relatedEventInfoByEntityId(store.getState()); - const shouldShowLimit = selectedRelatedInfo(firstChildNodeInTree.id) - ?.shouldShowLimitForCategory!; - expect(shouldShowLimit(categoryToOverCount)).toBe(true); - }); - it('should indicate that there are related events missing because the number of related events received for the category is less than what the stats count said it would be', () => { - const selectedRelatedInfo = selectors.relatedEventInfoByEntityId(store.getState()); - const notDisplayed = selectedRelatedInfo(firstChildNodeInTree.id) - ?.numberNotDisplayedForCategory!; - expect(notDisplayed(categoryToOverCount)).toBe(1); - }); }); }); }); @@ -241,7 +116,7 @@ function mockedTree() { 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 as ResolverEvent[], + 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. @@ -255,7 +130,7 @@ function mockedTree() { 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 as ResolverEvent[]; + childNode.lifecycle = node.lifecycle; // `TreeNode` has `id` which is the same as `entityID`. // The `ResolverChildNode` calls the entityID as `entityID`. @@ -281,8 +156,6 @@ function mockedTree() { return { tree: tree!, firstChildNodeInTree, - eventStatsForFirstChildNode: statsResults.eventStats, - aggregateCategoryTotalForFirstChildNode: statsResults.aggregateCategoryTotal, categoryToOverCount: statsResults.firstCategory, }; } @@ -309,7 +182,6 @@ function compileStatsForChild( }; /** The category of the first event. */ firstCategory: string; - aggregateCategoryTotal: number; } { const totalRelatedEvents = node.relatedEvents.length; // For the purposes of testing, we pick one category to fake an extra event for @@ -317,12 +189,6 @@ function compileStatsForChild( let firstCategory: string | undefined; - // This is the "aggregate total" which is displayed to users as the total count - // of related events for the node. It is tallied by incrementing for every discrete - // event.category in an event.category array (or just 1 for a plain string). E.g. two events - // categories 'file' and ['dns','network'] would have an `aggregate total` of 3. - let aggregateCategoryTotal: number = 0; - const compiledStats = node.relatedEvents.reduce( (counts: Record, relatedEvent) => { // get an array of categories regardless of whether category is a string or string[] @@ -336,7 +202,6 @@ function compileStatsForChild( // Increment the count of events with this category counts[category] = counts[category] ? counts[category] + 1 : 1; - aggregateCategoryTotal++; } return counts; }, @@ -354,6 +219,5 @@ function compileStatsForChild( byCategory: compiledStats, }, firstCategory, - aggregateCategoryTotal, }; } 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 c8df95aaee6f..1819407a1951 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 @@ -11,9 +11,7 @@ import * as treeFetcherParameters from '../../models/tree_fetcher_parameters'; const initialState: DataState = { relatedEvents: new Map(), - relatedEventsReady: new Map(), resolverComponentInstanceID: undefined, - tree: {}, }; export const dataReducer: Reducer = (state = initialState, action) => { @@ -44,7 +42,7 @@ export const dataReducer: Reducer = (state = initialS }; return nextState; } else if (action.type === 'appAbortedResolverDataRequest') { - if (treeFetcherParameters.equal(action.payload, state.tree.pendingRequestParameters)) { + if (treeFetcherParameters.equal(action.payload, state.tree?.pendingRequestParameters)) { // the request we were awaiting was aborted const nextState: DataState = { ...state, @@ -81,7 +79,7 @@ export const dataReducer: Reducer = (state = initialS return nextState; } else if (action.type === 'serverFailedToReturnResolverData') { /** Only handle this if we are expecting a response */ - if (state.tree.pendingRequestParameters !== undefined) { + if (state.tree?.pendingRequestParameters !== undefined) { const nextState: DataState = { ...state, tree: { @@ -97,19 +95,9 @@ export const dataReducer: Reducer = (state = initialS } else { return state; } - } else if ( - action.type === 'userRequestedRelatedEventData' || - action.type === 'appDetectedMissingEventData' - ) { - const nextState: DataState = { - ...state, - relatedEventsReady: new Map([...state.relatedEventsReady, [action.payload, false]]), - }; - return nextState; } else if (action.type === 'serverReturnedRelatedEventData') { const nextState: DataState = { ...state, - relatedEventsReady: new Map([...state.relatedEventsReady, [action.payload.entityID, true]]), relatedEvents: new Map([...state.relatedEvents, [action.payload.entityID, action.payload]]), }; return nextState; 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 539325faffdf..d9717b52d9ce 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 @@ -16,7 +16,7 @@ import { mockTreeWithAllProcessesTerminated, mockTreeWithNoProcessEvents, } from '../../mocks/resolver_tree'; -import { uniquePidForProcess } from '../../models/process_event'; +import * as eventModel from '../../../../common/endpoint/models/event'; import { EndpointEvent } from '../../../../common/endpoint/types'; import { mockTreeFetcherParameters } from '../../mocks/tree_fetcher_parameters'; @@ -411,7 +411,7 @@ describe('data state', () => { expect(graphables.length).toBe(3); for (const event of graphables) { expect(() => { - selectors.ariaFlowtoCandidate(state())(uniquePidForProcess(event)); + selectors.ariaFlowtoCandidate(state())(eventModel.entityIDSafeVersion(event)!); }).not.toThrow(); } }); 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 d714ddb18147..fe7e8b5f22f1 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 @@ -14,46 +14,36 @@ import { IndexedProcessNode, AABB, VisibleEntites, - SectionData, TreeFetcherParameters, } from '../../types'; -import { - isGraphableProcess, - isTerminatedProcess, - uniquePidForProcess, - uniqueParentPidForProcess, -} from '../../models/process_event'; +import { isGraphableProcess, isTerminatedProcess } from '../../models/process_event'; import * as indexedProcessTreeModel from '../../models/indexed_process_tree'; +import * as eventModel from '../../../../common/endpoint/models/event'; import { - ResolverEvent, ResolverTree, ResolverNodeStats, ResolverRelatedEvents, SafeResolverEvent, - EndpointEvent, - LegacyEndpointEvent, } 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 eventModel from '../../../../common/endpoint/models/event'; import * as vector2 from '../../models/vector2'; -import { formatDate } from '../../view/panels/panel_content_utilities'; /** * If there is currently a request. */ export function isTreeLoading(state: DataState): boolean { - return state.tree.pendingRequestParameters !== undefined; + return state.tree?.pendingRequestParameters !== undefined; } /** * If a request was made and it threw an error or returned a failure response code. */ export function hadErrorLoadingTree(state: DataState): boolean { - if (state.tree.lastResponse) { - return !state.tree.lastResponse.successful; + if (state.tree?.lastResponse) { + return !state.tree?.lastResponse.successful; } return false; } @@ -70,7 +60,7 @@ export function resolverComponentInstanceID(state: DataState): string { * we're currently interested in. */ const resolverTreeResponse = (state: DataState): ResolverTree | undefined => { - return state.tree.lastResponse?.successful ? state.tree.lastResponse.result : undefined; + return state.tree?.lastResponse?.successful ? state.tree?.lastResponse.result : undefined; }; /** @@ -102,7 +92,7 @@ export const terminatedProcesses = createSelector(resolverTreeResponse, function .lifecycleEvents(tree) .filter(isTerminatedProcess) .map((terminatedEvent) => { - return uniquePidForProcess(terminatedEvent); + return eventModel.entityIDSafeVersion(terminatedEvent); }) ); }); @@ -115,8 +105,8 @@ export const isProcessTerminated = createSelector(terminatedProcesses, function terminatedProcesses /* eslint-enable no-shadow */ ) { - return (entityId: string) => { - return terminatedProcesses.has(entityId); + return (entityID: string) => { + return terminatedProcesses.has(entityID); }; }); @@ -125,12 +115,14 @@ export const isProcessTerminated = createSelector(terminatedProcesses, function */ 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(); + const events: Map = new Map(); if (tree) { for (const event of resolverTreeModel.lifecycleEvents(tree)) { if (isGraphableProcess(event)) { - const entityID = uniquePidForProcess(event); - events.set(entityID, event); + const entityID = eventModel.entityIDSafeVersion(event); + if (entityID !== undefined) { + events.set(entityID, event); + } } } return [...events.values()]; @@ -147,7 +139,7 @@ export const tree = createSelector(graphableProcesses, function indexedTree( graphableProcesses /* eslint-enable no-shadow */ ) { - return indexedProcessTreeModel.factory(graphableProcesses as SafeResolverEvent[]); + return indexedProcessTreeModel.factory(graphableProcesses); }); /** @@ -169,24 +161,18 @@ export const relatedEventsStats: ( ); /** - * This returns the "aggregate total" for related events, tallied as the sum - * of their individual `event.category`s. E.g. a [DNS, Network] would count as two - * towards the aggregate total. + * The total number of events related to a node. */ -export const relatedEventAggregateTotalByEntityId: ( +export const relatedEventTotalCount: ( state: DataState -) => (entityId: string) => number = createSelector(relatedEventsStats, (relatedStats) => { - return (entityId) => { - const statsForEntity = relatedStats(entityId); - if (statsForEntity === undefined) { - return 0; - } - return Object.values(statsForEntity?.events?.byCategory || {}).reduce( - (sum, val) => sum + val, - 0 - ); - }; -}); +) => (entityID: string) => number | undefined = createSelector( + relatedEventsStats, + (relatedStats) => { + return (entityID) => { + return relatedStats(entityID)?.events?.total; + }; + } +); /** * returns a map of entity_ids to related event data. @@ -197,98 +183,36 @@ export function relatedEventsByEntityId(data: DataState): Map
` entries - * @deprecated + * Get an event (from memory) by its `event.id`. + * @deprecated Use the API to find events by ID */ -const objectToDescriptionListEntries = function* ( - obj: object, - prefix = '' -): Generator<{ title: string; description: string }> { - const nextPrefix = prefix.length ? `${prefix}.` : ''; - for (const [metaKey, metaValue] of Object.entries(obj)) { - if (typeof metaValue === 'number' || typeof metaValue === 'string') { - yield { title: nextPrefix + metaKey, description: `${metaValue}` }; - } else if (metaValue instanceof Array) { - yield { - title: nextPrefix + metaKey, - description: metaValue - .filter((arrayEntry) => { - return typeof arrayEntry === 'number' || typeof arrayEntry === 'string'; - }) - .join(','), - }; - } else if (typeof metaValue === 'object') { - yield* objectToDescriptionListEntries(metaValue, nextPrefix + metaKey); +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; } - } -}; - -/** - * Returns a function that returns the information needed to display related event details based on - * the related event's entityID and its own ID. - * @deprecated - */ -export const relatedEventDisplayInfoByEntityAndSelfID: ( - state: DataState -) => ( - entityId: string, - relatedEventId: string | number -) => [ - EndpointEvent | LegacyEndpointEvent | undefined, - number, - string | undefined, - SectionData, - string -] = createSelector(relatedEventsByEntityId, function relatedEventDetails( - /* eslint-disable no-shadow */ - relatedEventsByEntityId - /* eslint-enable no-shadow */ -) { - return defaultMemoize((entityId: string, relatedEventId: string | number) => { - const relatedEventsForThisProcess = relatedEventsByEntityId.get(entityId); - if (!relatedEventsForThisProcess) { - return [undefined, 0, undefined, [], '']; - } - const specificEvent = relatedEventsForThisProcess.events.find( - (evt) => eventModel.eventId(evt) === relatedEventId - ); - // For breadcrumbs: - const specificCategory = specificEvent && eventModel.primaryEventCategory(specificEvent); - const countOfCategory = relatedEventsForThisProcess.events.reduce((sumtotal, evt) => { - return eventModel.primaryEventCategory(evt) === specificCategory ? sumtotal + 1 : sumtotal; - }, 0); - // Assuming these details (agent, ecs, process) aren't as helpful, can revisit - const { agent, ecs, process, ...relevantData } = specificEvent as SafeResolverEvent & { - // Type this with various unknown keys so that ts will let us delete those keys - ecs: unknown; - process: unknown; - }; - - let displayDate = ''; - const sectionData: SectionData = Object.entries(relevantData) - .map(([sectionTitle, val]) => { - if (sectionTitle === '@timestamp') { - displayDate = formatDate(val); - return { sectionTitle: '', entries: [] }; + // 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); } - if (typeof val !== 'object') { - return { sectionTitle, entries: [{ title: sectionTitle, description: `${val}` }] }; - } - return { sectionTitle, entries: [...objectToDescriptionListEntries(val)] }; - }) - .filter((v) => v.sectionTitle !== '' && v.entries.length); - - return [specificEvent, countOfCategory, specificCategory, sectionData, displayDate]; - }); + } + memo.set(nodeID, map); + } + const eventMap = memo.get(nodeID); + if (!eventMap) { + // This shouldn't be possible. + return undefined; + } + return eventMap.get(eventID); + }; }); /** @@ -298,44 +222,65 @@ export const relatedEventDisplayInfoByEntityAndSelfID: ( */ export const relatedEventsByCategory: ( state: DataState -) => (entityID: string) => (ecsCategory: string) => ResolverEvent[] = createSelector( +) => (node: string, eventCategory: string) => SafeResolverEvent[] = createSelector( relatedEventsByEntityId, function ( /* eslint-disable no-shadow */ relatedEventsByEntityId /* eslint-enable no-shadow */ ) { - return defaultMemoize((entityId: string) => { - return defaultMemoize((ecsCategory: string) => { - const relatedById = relatedEventsByEntityId.get(entityId); - // With no related events, we can't return related by category - if (!relatedById) { - return []; + // 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); } - return relatedById.events.reduce( - (eventsByCategory: ResolverEvent[], candidate: ResolverEvent) => { - if ( - [candidate && eventModel.allEventCategories(candidate)].flat().includes(ecsCategory) - ) { - eventsByCategory.push(candidate); - } - return eventsByCategory; - }, - [] - ); - }); - }); + } + } + + // 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; + }; } ); -/** - * returns a map of entity_ids to booleans indicating if it is waiting on related event - * A value of `undefined` can be interpreted as `not yet requested` - * @deprecated - */ -export function relatedEventsReady(data: DataState): Map { - return data.relatedEventsReady; -} +export const relatedEventCountByType: ( + state: DataState +) => (nodeID: string, eventType: string) => number | undefined = createSelector( + relatedEventsStats, + (statsMap) => { + return (nodeID: string, eventType: string): number | undefined => { + const stats = statsMap(nodeID); + if (stats) { + const value = Object.prototype.hasOwnProperty.call(stats.events.byCategory, eventType); + if (typeof value === 'number') { + return value; + } + } + }; + } +); /** * `true` if there were more children than we got in the last request. @@ -355,113 +300,6 @@ export function hasMoreAncestors(state: DataState): boolean { return resolverTree ? resolverTreeModel.hasMoreAncestors(resolverTree) : false; } -interface RelatedInfoFunctions { - shouldShowLimitForCategory: (category: string) => boolean; - numberNotDisplayedForCategory: (category: string) => number; - numberActuallyDisplayedForCategory: (category: string) => number; -} -/** - * A map of `entity_id`s to functions that provide information about - * related events by ECS `.category` Primarily to avoid having business logic - * in UI components. - * @deprecated - */ -export const relatedEventInfoByEntityId: ( - state: DataState -) => (entityID: string) => RelatedInfoFunctions | null = createSelector( - relatedEventsByEntityId, - relatedEventsStats, - function selectLineageLimitInfo( - /* eslint-disable no-shadow */ - relatedEventsByEntityId, - relatedEventsStats - /* eslint-enable no-shadow */ - ) { - return (entityId) => { - const stats = relatedEventsStats(entityId); - if (!stats) { - return null; - } - const eventsResponseForThisEntry = relatedEventsByEntityId.get(entityId); - const hasMoreEvents = - eventsResponseForThisEntry && eventsResponseForThisEntry.nextEvent !== null; - /** - * Get the "aggregate" total for the event category (i.e. _all_ events that would qualify as being "in category") - * For a set like `[DNS,File][File,DNS][Registry]` The first and second events would contribute to the aggregate total for DNS being 2. - * This is currently aligned with how the backed provides this information. - * - * @param eventCategory {string} The ECS category like 'file','dns',etc. - */ - const aggregateTotalForCategory = (eventCategory: string): number => { - return stats.events.byCategory[eventCategory] || 0; - }; - - /** - * Get all the related events in the category provided. - * - * @param eventCategory {string} The ECS category like 'file','dns',etc. - */ - const unmemoizedMatchingEventsForCategory = (eventCategory: string): ResolverEvent[] => { - if (!eventsResponseForThisEntry) { - return []; - } - return eventsResponseForThisEntry.events.filter((resolverEvent) => { - for (const category of [eventModel.allEventCategories(resolverEvent)].flat()) { - if (category === eventCategory) { - return true; - } - } - return false; - }); - }; - - const matchingEventsForCategory = unmemoizedMatchingEventsForCategory; - - /** - * The number of events that occurred before the API limit was reached. - * The number of events that came back form the API that have `eventCategory` in their list of categories. - * - * @param eventCategory {string} The ECS category like 'file','dns',etc. - */ - const numberActuallyDisplayedForCategory = (eventCategory: string): number => { - return matchingEventsForCategory(eventCategory)?.length || 0; - }; - - /** - * The total number counted by the backend - the number displayed - * - * @param eventCategory {string} The ECS category like 'file','dns',etc. - */ - const numberNotDisplayedForCategory = (eventCategory: string): number => { - return ( - aggregateTotalForCategory(eventCategory) - - numberActuallyDisplayedForCategory(eventCategory) - ); - }; - - /** - * `true` when the `nextEvent` cursor appeared in the results and we are short on the number needed to - * fullfill the aggregate count. - * - * @param eventCategory {string} The ECS category like 'file','dns',etc. - */ - const shouldShowLimitForCategory = (eventCategory: string): boolean => { - if (hasMoreEvents && numberNotDisplayedForCategory(eventCategory) > 0) { - return true; - } - return false; - }; - - const entryValue = { - shouldShowLimitForCategory, - numberNotDisplayedForCategory, - numberActuallyDisplayedForCategory, - }; - return entryValue; - }; - } -); - /** * If the tree resource needs to be fetched then these are the parameters that should be used. */ @@ -470,14 +308,14 @@ export function treeParametersToFetch(state: DataState): TreeFetcherParameters | * If there are current tree parameters that don't match the parameters used in the pending request (if there is a pending request) and that don't match the parameters used in the last completed request (if there was a last completed request) then we need to fetch the tree resource using the current parameters. */ if ( - state.tree.currentParameters !== undefined && + state.tree?.currentParameters !== undefined && !treeFetcherParametersModel.equal( - state.tree.currentParameters, - state.tree.lastResponse?.parameters + state.tree?.currentParameters, + state.tree?.lastResponse?.parameters ) && !treeFetcherParametersModel.equal( - state.tree.currentParameters, - state.tree.pendingRequestParameters + state.tree?.currentParameters, + state.tree?.pendingRequestParameters ) ) { return state.tree.currentParameters; @@ -533,10 +371,11 @@ export const layout = createSelector( */ export const processEventForID: ( state: DataState -) => (nodeID: string) => ResolverEvent | null = createSelector( +) => (nodeID: string) => SafeResolverEvent | null = createSelector( tree, - (indexedProcessTree) => (nodeID: string) => - indexedProcessTreeModel.processEvent(indexedProcessTree, nodeID) as ResolverEvent + (indexedProcessTree) => (nodeID: string) => { + return indexedProcessTreeModel.processEvent(indexedProcessTree, nodeID); + } ); /** @@ -547,7 +386,7 @@ export const ariaLevel: (state: DataState) => (nodeID: string) => number | null processEventForID, ({ ariaLevels }, processEventGetter) => (nodeID: string) => { const node = processEventGetter(nodeID); - return node ? ariaLevels.get(node as SafeResolverEvent) ?? null : null; + return node ? ariaLevels.get(node) ?? null : null; } ); @@ -582,7 +421,7 @@ 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: ResolverEvent | null = eventGetter(nodeID); + const nodeEvent: SafeResolverEvent | null = eventGetter(nodeID); if (!nodeEvent) { // this should never happen. @@ -592,23 +431,30 @@ export const ariaFlowtoCandidate: ( // nodes with the same parent ID const children = indexedProcessTreeModel.children( indexedProcessTree, - uniqueParentPidForProcess(nodeEvent) + eventModel.parentEntityIDSafeVersion(nodeEvent) ); - let previousChild: ResolverEvent | null = null; + let previousChild: SafeResolverEvent | 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`. - memo.set(uniquePidForProcess(previousChild), uniquePidForProcess(child as ResolverEvent)); + const previousChildEntityID = eventModel.entityIDSafeVersion(previousChild); + const followingSiblingEntityID = eventModel.entityIDSafeVersion(child); + if (previousChildEntityID !== undefined && followingSiblingEntityID !== undefined) { + memo.set(previousChildEntityID, followingSiblingEntityID); + } } // Set the child as the previous child. - previousChild = child as ResolverEvent; + previousChild = child; } if (previousChild) { // if there is a previous child, it has no following sibling. - memo.set(uniquePidForProcess(previousChild), null); + const entityID = eventModel.entityIDSafeVersion(previousChild); + if (entityID !== undefined) { + memo.set(entityID, null); + } } return memoizedGetter(nodeID); @@ -708,10 +554,10 @@ export function treeRequestParametersToAbort(state: DataState): TreeFetcherParam * If there is a pending request, and its not for the current parameters (even, if the current parameters are undefined) then we should abort the request. */ if ( - state.tree.pendingRequestParameters !== undefined && + state.tree?.pendingRequestParameters !== undefined && !treeFetcherParametersModel.equal( - state.tree.pendingRequestParameters, - state.tree.currentParameters + state.tree?.pendingRequestParameters, + state.tree?.currentParameters ) ) { return state.tree.pendingRequestParameters; @@ -725,19 +571,19 @@ export function treeRequestParametersToAbort(state: DataState): TreeFetcherParam */ export const relatedEventTotalForProcess: ( state: DataState -) => (event: ResolverEvent) => number | null = createSelector( +) => (event: SafeResolverEvent) => number | null = createSelector( relatedEventsStats, (statsForProcess) => { - return (event: ResolverEvent) => { - const stats = statsForProcess(uniquePidForProcess(event)); - if (!stats) { + return (event: SafeResolverEvent) => { + const nodeID = eventModel.entityIDSafeVersion(event); + if (nodeID === undefined) { return null; } - let total = 0; - for (const value of Object.values(stats.events.byCategory)) { - total += value; + const stats = statsForProcess(nodeID); + if (!stats) { + return null; } - return total; + return stats.events.total; }; } ); 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 28948debae89..506acefe5167 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,7 +8,7 @@ import { Store, createStore } from 'redux'; import { ResolverAction } from '../actions'; import { resolverReducer } from '../reducer'; import { ResolverState } from '../../types'; -import { LegacyEndpointEvent, ResolverEvent } from '../../../../common/endpoint/types'; +import { LegacyEndpointEvent, SafeResolverEvent } from '../../../../common/endpoint/types'; import { visibleNodesAndEdgeLines } from '../selectors'; import { mockProcessEvent } from '../../models/process_event_test_helpers'; import { mock as mockResolverTree } from '../../models/resolver_tree'; @@ -102,7 +102,7 @@ describe('resolver visible entities', () => { }); describe('when rendering a large tree with a small viewport', () => { beforeEach(() => { - const events: ResolverEvent[] = [ + const events: SafeResolverEvent[] = [ processA, processB, processC, @@ -130,7 +130,7 @@ describe('resolver visible entities', () => { }); describe('when rendering a large tree with a large viewport', () => { beforeEach(() => { - const events: ResolverEvent[] = [ + const events: SafeResolverEvent[] = [ processA, processB, processC, 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 8dd15b1a44d0..f121b2aa8688 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/methods.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/methods.ts @@ -7,7 +7,7 @@ import { animatePanning } from './camera/methods'; import { layout } from './selectors'; import { ResolverState } from '../types'; -import { ResolverEvent, SafeResolverEvent } from '../../../common/endpoint/types'; +import { SafeResolverEvent } from '../../../common/endpoint/types'; const animationDuration = 1000; @@ -17,10 +17,10 @@ const animationDuration = 1000; export function animateProcessIntoView( state: ResolverState, startTime: number, - process: ResolverEvent + process: SafeResolverEvent ): ResolverState { const { processNodePositions } = layout(state); - const position = processNodePositions.get(process as SafeResolverEvent); + const position = processNodePositions.get(process); if (position) { return { ...state, 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 ef6b1f5eb3c6..5dca858b4fab 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 @@ -6,9 +6,10 @@ import { Dispatch, MiddlewareAPI } from 'redux'; import { ResolverState, DataAccessLayer } from '../../types'; -import { ResolverRelatedEvents } from '../../../../common/endpoint/types'; import { ResolverTreeFetcher } from './resolver_tree_fetcher'; + import { ResolverAction } from '../actions'; +import { RelatedEventsFetcher } from './related_events_fetcher'; type MiddlewareFactory = ( dataAccessLayer: DataAccessLayer @@ -25,33 +26,12 @@ type MiddlewareFactory = ( export const resolverMiddlewareFactory: MiddlewareFactory = (dataAccessLayer: DataAccessLayer) => { return (api) => (next) => { const resolverTreeFetcher = ResolverTreeFetcher(dataAccessLayer, api); + const relatedEventsFetcher = RelatedEventsFetcher(dataAccessLayer, api); return async (action: ResolverAction) => { next(action); resolverTreeFetcher(); - - if ( - action.type === 'userRequestedRelatedEventData' || - action.type === 'appDetectedMissingEventData' - ) { - const entityIdToFetchFor = action.payload; - let result: ResolverRelatedEvents | undefined; - try { - result = await dataAccessLayer.relatedEvents(entityIdToFetchFor); - } catch { - api.dispatch({ - type: 'serverFailedToReturnRelatedEventData', - payload: action.payload, - }); - } - - if (result) { - api.dispatch({ - type: 'serverReturnedRelatedEventData', - payload: result, - }); - } - } + relatedEventsFetcher(); }; }; }; 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 new file mode 100644 index 000000000000..b83e3cff9073 --- /dev/null +++ b/x-pack/plugins/security_solution/public/resolver/store/middleware/related_events_fetcher.ts @@ -0,0 +1,49 @@ +/* + * 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 { isEqual } from 'lodash'; +import { ResolverRelatedEvents } from '../../../../common/endpoint/types'; + +import { ResolverState, DataAccessLayer, PanelViewAndParameters } from '../../types'; +import * as selectors from '../selectors'; +import { ResolverAction } from '../actions'; + +export function RelatedEventsFetcher( + dataAccessLayer: DataAccessLayer, + api: MiddlewareAPI, ResolverState> +): () => void { + let last: PanelViewAndParameters | undefined; + + // Call this after each state change. + // This fetches the ResolverTree for the current entityID + // if the entityID changes while + return async () => { + const state = api.getState(); + + const newParams = selectors.panelViewAndParameters(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; + + // If the panel view params have changed and the current panel view is either `nodeEventsOfType` or `eventDetail`, then fetch the related events for that nodeID. + if ( + !isEqual(newParams, oldParams) && + (newParams.panelView === 'nodeEventsOfType' || newParams.panelView === 'eventDetail') + ) { + const nodeID = newParams.panelParameters.nodeID; + + const result: ResolverRelatedEvents | undefined = await dataAccessLayer.relatedEvents(nodeID); + + if (result) { + api.dispatch({ + type: 'serverReturnedRelatedEventData', + payload: result, + }); + } + } + }; +} 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 bf62fd0e60df..ae1e9a58a209 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/reducer.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/reducer.ts @@ -9,7 +9,7 @@ import { cameraReducer } from './camera/reducer'; import { dataReducer } from './data/reducer'; import { ResolverAction } from './actions'; import { ResolverState, ResolverUIState } from '../types'; -import { uniquePidForProcess } from '../models/process_event'; +import * as eventModel from '../../../common/endpoint/models/event'; const uiReducer: Reducer = ( state = { @@ -37,17 +37,18 @@ const uiReducer: Reducer = ( selectedNode: action.payload, }; return next; - } else if ( - action.type === 'userBroughtProcessIntoView' || - action.type === 'appDetectedNewIdFromQueryParams' - ) { - const nodeID = uniquePidForProcess(action.payload.process); - const next: ResolverUIState = { - ...state, - ariaActiveDescendant: nodeID, - selectedNode: nodeID, - }; - return next; + } else if (action.type === 'userBroughtProcessIntoView') { + const nodeID = eventModel.entityIDSafeVersion(action.payload.process); + if (nodeID !== undefined) { + const next: ResolverUIState = { + ...state, + ariaActiveDescendant: nodeID, + selectedNode: nodeID, + }; + return next; + } else { + return state; + } } else if (action.type === 'appReceivedNewExternalProperties') { const next: ResolverUIState = { ...state, @@ -68,10 +69,7 @@ const concernReducers = combineReducers({ export const resolverReducer: Reducer = (state, action) => { const nextState = concernReducers(state, action); - if ( - action.type === 'userBroughtProcessIntoView' || - action.type === 'appDetectedNewIdFromQueryParams' - ) { + if (action.type === 'userBroughtProcessIntoView') { return animateProcessIntoView(nextState, action.payload.time, action.payload.process); } else { return nextState; 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 96b080206b61..3c99a186ac0c 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/selectors.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/selectors.ts @@ -9,7 +9,7 @@ import * as cameraSelectors from './camera/selectors'; import * as dataSelectors from './data/selectors'; import * as uiSelectors from './ui/selectors'; import { ResolverState, IsometricTaxiLayout } from '../types'; -import { ResolverEvent, ResolverNodeStats } from '../../../common/endpoint/types'; +import { ResolverNodeStats, SafeResolverEvent } from '../../../common/endpoint/types'; import { entityIDSafeVersion } from '../../../common/endpoint/models/event'; /** @@ -61,6 +61,11 @@ export const isProcessTerminated = composeSelectors( 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 @@ -68,7 +73,7 @@ export const isProcessTerminated = composeSelectors( */ export const processEventForID: ( state: ResolverState -) => (nodeID: string) => ResolverEvent | null = composeSelectors( +) => (nodeID: string) => SafeResolverEvent | null = composeSelectors( dataStateSelector, dataSelectors.processEventForID ); @@ -119,30 +124,27 @@ export const relatedEventsStats: ( * of their individual `event.category`s. E.g. a [DNS, Network] would count as two * towards the aggregate total. */ -export const relatedEventAggregateTotalByEntityId: ( +export const relatedEventTotalCount: ( state: ResolverState -) => (nodeID: string) => number = composeSelectors( +) => (nodeID: string) => number | undefined = composeSelectors( dataStateSelector, - dataSelectors.relatedEventAggregateTotalByEntityId + dataSelectors.relatedEventTotalCount ); -/** - * Map of related events... by entity id - * @deprecated - */ -export const relatedEventsByEntityId = composeSelectors( +export const relatedEventCountByType: ( + state: ResolverState +) => (nodeID: string, eventType: string) => number | undefined = composeSelectors( dataStateSelector, - dataSelectors.relatedEventsByEntityId + dataSelectors.relatedEventCountByType ); /** - * Returns a function that returns the information needed to display related event details based on - * the related event's entityID and its own ID. + * Map of related events... by entity id * @deprecated */ -export const relatedEventDisplayInfoByEntityAndSelfId = composeSelectors( +export const relatedEventsByEntityId = composeSelectors( dataStateSelector, - dataSelectors.relatedEventDisplayInfoByEntityAndSelfID + dataSelectors.relatedEventsByEntityId ); /** @@ -155,26 +157,6 @@ export const relatedEventsByCategory = composeSelectors( dataSelectors.relatedEventsByCategory ); -/** - * Entity ids to booleans for waiting status - * @deprecated - */ -export const relatedEventsReady = composeSelectors( - dataStateSelector, - dataSelectors.relatedEventsReady -); - -/** - * Business logic lookup functions by ECS category by entity id. - * Example usage: - * const numberOfFileEvents = infoByEntityId.get(`someEntityId`)?.getAggregateTotalForCategory(`file`); - * @deprecated - */ -export const relatedEventInfoByEntityId = composeSelectors( - dataStateSelector, - dataSelectors.relatedEventInfoByEntityId -); - /** * Returns the id of the "current" tree node (fake-focused) */ diff --git a/x-pack/plugins/security_solution/public/resolver/store/ui/selectors.ts b/x-pack/plugins/security_solution/public/resolver/store/ui/selectors.ts index 6bc41832b92f..a8882d835fce 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/ui/selectors.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/ui/selectors.ts @@ -8,9 +8,9 @@ import { decode, encode } from 'rison-node'; import { createSelector } from 'reselect'; import { PanelViewAndParameters, ResolverUIState } from '../../types'; -import { ResolverEvent } from '../../../../common/endpoint/types'; +import { SafeResolverEvent } from '../../../../common/endpoint/types'; import { isPanelViewAndParameters } from '../../models/location_search'; -import { eventId } from '../../../../common/endpoint/models/event'; +import { eventID } from '../../../../common/endpoint/models/event'; /** * id of the "current" tree node (fake-focused) @@ -124,12 +124,12 @@ export const relatedEventDetailHrefs: ( ) => ( category: string, nodeID: string, - events: ResolverEvent[] + events: SafeResolverEvent[] ) => Map = createSelector(relativeHref, (relativeHref) => { - return (category: string, nodeID: string, events: ResolverEvent[]) => { + return (category: string, nodeID: string, events: SafeResolverEvent[]) => { const hrefsByEntityID = new Map(); events.map((event) => { - const entityID = String(eventId(event)); + const entityID = String(eventID(event)); const eventDetailPanelParams: PanelViewAndParameters = { panelView: 'eventDetail', panelParameters: { diff --git a/x-pack/plugins/security_solution/public/resolver/types.ts b/x-pack/plugins/security_solution/public/resolver/types.ts index 952a1c5764d8..4dc614abe334 100644 --- a/x-pack/plugins/security_solution/public/resolver/types.ts +++ b/x-pack/plugins/security_solution/public/resolver/types.ts @@ -211,9 +211,8 @@ export interface TreeFetcherParameters { */ export interface DataState { readonly relatedEvents: Map; - readonly relatedEventsReady: Map; - readonly tree: { + readonly tree?: { /** * The parameters passed from the resolver properties */ @@ -614,8 +613,9 @@ export interface ResolverPluginSetup { dataAccessLayer: { /** * A mock `DataAccessLayer` that returns a tree that has no ancestor nodes but which has 2 children nodes. + * The origin has 2 related registry events */ - noAncestorsTwoChildren: () => { dataAccessLayer: DataAccessLayer }; + noAncestorsTwoChildrenWithRelatedEventsOnOrigin: () => { dataAccessLayer: DataAccessLayer }; }; }; } diff --git a/x-pack/plugins/security_solution/public/resolver/view/edge_line.tsx b/x-pack/plugins/security_solution/public/resolver/view/edge_line.tsx index 53b889004798..777a7292e9c2 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/edge_line.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/edge_line.tsx @@ -4,6 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ +/* eslint-disable react/display-name */ + import React from 'react'; import styled from 'styled-components'; import { FormattedMessage } from '@kbn/i18n/react'; @@ -53,7 +55,7 @@ const StyledElapsedTime = styled.div` /** * A placeholder line segment view that connects process nodes. */ -const EdgeLineComponent = React.memo( +export const EdgeLine = React.memo( ({ className, edgeLineMetadata, @@ -155,7 +157,3 @@ const EdgeLineComponent = React.memo( ); } ); - -EdgeLineComponent.displayName = 'EdgeLine'; - -export const EdgeLine = EdgeLineComponent; 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 75aecf6747cc..dbeca840a4b6 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 @@ -4,6 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ +/* eslint-disable react/display-name */ + /* eslint-disable react/button-has-type */ import React, { useCallback, useMemo, useContext } from 'react'; @@ -54,7 +56,7 @@ const StyledGraphControls = styled.div` /** * Controls for zooming, panning, and centering in Resolver */ -const GraphControlsComponent = React.memo( +export const GraphControls = React.memo( ({ className, }: { @@ -204,7 +206,3 @@ const GraphControlsComponent = React.memo( ); } ); - -GraphControlsComponent.displayName = 'GraphControlsComponent'; - -export const GraphControls = GraphControlsComponent; diff --git a/x-pack/plugins/security_solution/public/resolver/view/limit_warnings.tsx b/x-pack/plugins/security_solution/public/resolver/view/limit_warnings.tsx index 3f2b7c769cad..bc57c4e28b9c 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/limit_warnings.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/limit_warnings.tsx @@ -4,9 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ +/* eslint-disable react/display-name */ + import React from 'react'; -import { EuiCallOut } from '@elastic/eui'; import { FormattedMessage } from 'react-intl'; +import { LimitWarningsEuiCallOut } from './styles'; const lineageLimitMessage = ( ); -const LineageTitleMessage = React.memo(function LineageTitleMessage({ - numberOfEntries, -}: { - numberOfEntries: number; -}) { +const LineageTitleMessage = React.memo(function ({ numberOfEntries }: { numberOfEntries: number }) { return (

- + ); }); /** * Limit warning for hitting a limit of nodes in the tree */ -export const LimitWarning = React.memo(function LimitWarning({ - className, - numberDisplayed, -}: { - className?: string; - numberDisplayed: number; -}) { +export const LimitWarning = React.memo(function ({ numberDisplayed }: { numberDisplayed: number }) { return ( - } >

{lineageLimitMessage}

-
+ ); }); diff --git a/x-pack/plugins/security_solution/public/resolver/view/node.test.tsx b/x-pack/plugins/security_solution/public/resolver/view/node.test.tsx new file mode 100644 index 000000000000..0b381f6771f0 --- /dev/null +++ b/x-pack/plugins/security_solution/public/resolver/view/node.test.tsx @@ -0,0 +1,42 @@ +/* + * 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 { noAncestorsTwoChildren } from '../data_access_layer/mocks/no_ancestors_two_children'; +import { Simulator } from '../test_utilities/simulator'; +// Extend jest with a custom matcher +import '../test_utilities/extend_jest'; + +let simulator: Simulator; +let databaseDocumentID: string; + +// the resolver component instance ID, used by the react code to distinguish piece of global state from those used by other resolver instances +const resolverComponentInstanceID = 'resolverComponentInstanceID'; + +describe('Resolver, when analyzing a tree that has no ancestors and 2 children', () => { + beforeEach(async () => { + // create a mock data access layer + const { metadata: dataAccessLayerMetadata, dataAccessLayer } = noAncestorsTwoChildren(); + + // save a reference to the `_id` supported by the mock data layer + databaseDocumentID = dataAccessLayerMetadata.databaseDocumentID; + + // create a resolver simulator, using the data access layer and an arbitrary component instance ID + simulator = new Simulator({ + databaseDocumentID, + dataAccessLayer, + resolverComponentInstanceID, + indices: [], + }); + }); + + it('shows 1 node with the words "Analyzed Event" in the label', async () => { + await expect( + simulator.map(() => { + return simulator.testSubject('resolver:node:description').map((element) => element.text()); + }) + ).toYieldEqualTo(['Analyzed Event · Running Process', 'Running Process', 'Running Process']); + }); +}); diff --git a/x-pack/plugins/security_solution/public/resolver/view/panel.test.tsx b/x-pack/plugins/security_solution/public/resolver/view/panel.test.tsx index 7cfbd9a79466..2f23469606ac 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/panel.test.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/panel.test.tsx @@ -5,7 +5,7 @@ */ import { createMemoryHistory, History as HistoryPackageHistoryInterface } from 'history'; -import { noAncestorsTwoChildren } from '../data_access_layer/mocks/no_ancestors_two_children'; +import { noAncestorsTwoChildrenWithRelatedEventsOnOrigin } from '../data_access_layer/mocks/no_ancestors_two_children_with_related_events_on_origin'; import { Simulator } from '../test_utilities/simulator'; // Extend jest with a custom matcher import '../test_utilities/extend_jest'; @@ -14,7 +14,7 @@ import { urlSearch } from '../test_utilities/url_search'; // the resolver component instance ID, used by the react code to distinguish piece of global state from those used by other resolver instances const resolverComponentInstanceID = 'resolverComponentInstanceID'; -describe(`Resolver: when analyzing a tree with no ancestors and two children, and when the component instance ID is ${resolverComponentInstanceID}`, () => { +describe(`Resolver: when analyzing a tree with no ancestors and two children and two related registry event on the origin, and when the component instance ID is ${resolverComponentInstanceID}`, () => { /** * Get (or lazily create and get) the simulator. */ @@ -32,7 +32,10 @@ describe(`Resolver: when analyzing a tree with no ancestors and two children, an beforeEach(() => { // create a mock data access layer - const { metadata: dataAccessLayerMetadata, dataAccessLayer } = noAncestorsTwoChildren(); + const { + metadata: dataAccessLayerMetadata, + dataAccessLayer, + } = noAncestorsTwoChildrenWithRelatedEventsOnOrigin(); entityIDs = dataAccessLayerMetadata.entityIDs; @@ -184,6 +187,38 @@ describe(`Resolver: when analyzing a tree with no ancestors and two children, an }) ); }); + describe("and when the user clicks the link to the node's events", () => { + beforeEach(async () => { + const nodeEventsListLink = await simulator().resolve( + 'resolver:node-detail:node-events-link' + ); + + if (nodeEventsListLink) { + nodeEventsListLink.simulate('click', { button: 0 }); + } + }); + it('should show a link to view 2 registry events', async () => { + await expect( + simulator().map(() => { + // The link text is split across two columns. The first column is the count and the second column has the type. + const type = simulator().testSubject('resolver:panel:node-events:event-type-count'); + const link = simulator().testSubject('resolver:panel:node-events:event-type-link'); + return { + typeLength: type.length, + linkLength: link.length, + typeText: type.text(), + linkText: link.text(), + }; + }) + ).toYieldEqualTo({ + typeLength: 1, + linkLength: 1, + linkText: 'registry', + // EUI's Table adds the column name to the value. + typeText: 'Count2', + }); + }); + }); describe('and when the node list link has been clicked', () => { beforeEach(async () => { const nodeListLink = await simulator().resolve( diff --git a/x-pack/plugins/security_solution/public/resolver/view/panels/breadcrumbs.tsx b/x-pack/plugins/security_solution/public/resolver/view/panels/breadcrumbs.tsx new file mode 100644 index 000000000000..ed3919800936 --- /dev/null +++ b/x-pack/plugins/security_solution/public/resolver/view/panels/breadcrumbs.tsx @@ -0,0 +1,40 @@ +/* + * 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. + */ + +/* eslint-disable react/display-name */ + +import { i18n } from '@kbn/i18n'; +import { EuiBreadcrumb, EuiBetaBadge } from '@elastic/eui'; +import React, { memo } from 'react'; +import { BetaHeader, ThemedBreadcrumbs } from './styles'; +import { useColors } from '../use_colors'; + +/** + * Breadcrumb menu + */ +export const Breadcrumbs = memo(function ({ breadcrumbs }: { breadcrumbs: EuiBreadcrumb[] }) { + const { resolverBreadcrumbBackground, resolverEdgeText } = useColors(); + return ( + <> + + + + + + ); +}); 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 4e9d64f5a76a..cc5f39e985d9 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 @@ -43,7 +43,7 @@ export const CubeForProcess = memo(function ({ className={className} width="2.15em" height="2.15em" - viewBox="0 0 100% 100%" + viewBox="0 0 34 34" data-test-subj={dataTestSubj} isOrigin={isOrigin} > diff --git a/x-pack/plugins/security_solution/public/resolver/view/panels/deep_object_entries.test.ts b/x-pack/plugins/security_solution/public/resolver/view/panels/deep_object_entries.test.ts new file mode 100644 index 000000000000..1c4e1f4199bc --- /dev/null +++ b/x-pack/plugins/security_solution/public/resolver/view/panels/deep_object_entries.test.ts @@ -0,0 +1,36 @@ +/* + * 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 { deepObjectEntries } from './deep_object_entries'; + +describe('deepObjectEntries', () => { + const valuesAndExpected: Array<[ + objectValue: object, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expected: Array<[path: Array, fieldValue: unknown]> + ]> = [ + [{}, []], // No 'field' values found + [{ a: {} }, []], // No 'field' values found + [{ a: { b: undefined } }, []], // No 'field' values found + [{ a: { b: undefined, c: [] } }, []], // No 'field' values found + [{ a: { b: undefined, c: [null] } }, []], // No 'field' values found + [{ a: { b: undefined, c: [null, undefined, 1] } }, [[['a', 'c'], 1]]], // Only `1` is a non-null value. It is under `a.c` because we ignore array indices + [ + { a: { b: undefined, c: [null, undefined, 1, { d: ['e'] }] } }, + [ + // 1 and 'e' are valid fields. + [['a', 'c'], 1], + [['a', 'c', 'd'], 'e'], + ], + ], + ]; + + describe.each(valuesAndExpected)('when passed %j', (value, expected) => { + it(`should return ${JSON.stringify(expected)}`, () => { + expect(deepObjectEntries(value)).toEqual(expected); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/resolver/view/panels/deep_object_entries.ts b/x-pack/plugins/security_solution/public/resolver/view/panels/deep_object_entries.ts new file mode 100644 index 000000000000..a508b00be573 --- /dev/null +++ b/x-pack/plugins/security_solution/public/resolver/view/panels/deep_object_entries.ts @@ -0,0 +1,44 @@ +/* + * 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. + */ +/** + * Sort of like object entries, but does a DFS of an object. + * Instead of getting a key, an array of keys is returned. + * The array of keys represents the path to the value. + * `undefined` and `null` values are omitted. + */ +export function deepObjectEntries(root: object): Array<[path: string[], value: unknown]> { + const queue: Array<{ path: string[]; value: unknown }> = [{ path: [], value: root }]; + const result: Array<[path: string[], value: unknown]> = []; + while (queue.length) { + const next = queue.shift(); + if (next === undefined) { + // this should be impossible + throw new Error(); + } + const { path, value } = next; + if (Array.isArray(value)) { + // branch on arrays + queue.push( + ...value.map((element) => ({ + path: [...path], // unlike with object paths, don't add the number indices to `path` + value: element, + })) + ); + } else if (typeof value === 'object' && value !== null) { + // branch on non-null objects + queue.push( + ...Object.keys(value).map((key) => ({ + path: [...path, key], + value: (value as Record)[key], + })) + ); + } else if (value !== undefined && value !== null) { + // emit other non-null, defined, values + result.push([path, value]); + } + } + return result; +} diff --git a/x-pack/plugins/security_solution/public/resolver/view/panels/descriptive_name.test.tsx b/x-pack/plugins/security_solution/public/resolver/view/panels/descriptive_name.test.tsx new file mode 100644 index 000000000000..e869ab1ecd45 --- /dev/null +++ b/x-pack/plugins/security_solution/public/resolver/view/panels/descriptive_name.test.tsx @@ -0,0 +1,50 @@ +/* + * 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 React from 'react'; +import { EndpointDocGenerator } from '../../../../common/endpoint/generate_data'; +import { DescriptiveName } from './descriptive_name'; +import { SafeResolverEvent } from '../../../../common/endpoint/types'; +import { mount, ReactWrapper } from 'enzyme'; +import { I18nProvider } from '@kbn/i18n/react'; + +describe('DescriptiveName', () => { + let generator: EndpointDocGenerator; + let wrapper: (event: SafeResolverEvent) => ReactWrapper; + beforeEach(() => { + generator = new EndpointDocGenerator('seed'); + wrapper = (event: SafeResolverEvent) => + mount( + + + + ); + }); + it('returns the right name for a registry event', () => { + const extensions = { registry: { key: `HKLM/Windows/Software/abc` } }; + const event = generator.generateEvent({ eventCategory: 'registry', extensions }); + expect(wrapper(event).text()).toEqual(`HKLM/Windows/Software/abc`); + }); + + it('returns the right name for a network event', () => { + const randomIP = `${generator.randomIP()}`; + const extensions = { network: { direction: 'outbound', forwarded_ip: randomIP } }; + const event = generator.generateEvent({ eventCategory: 'network', extensions }); + expect(wrapper(event).text()).toEqual(`outbound ${randomIP}`); + }); + + it('returns the right name for a file event', () => { + const extensions = { file: { path: 'C:\\My Documents\\business\\January\\processName' } }; + const event = generator.generateEvent({ eventCategory: 'file', extensions }); + expect(wrapper(event).text()).toEqual('C:\\My Documents\\business\\January\\processName'); + }); + + it('returns the right name for a dns event', () => { + const extensions = { dns: { question: { name: `${generator.randomIP()}` } } }; + const event = generator.generateEvent({ eventCategory: 'dns', extensions }); + expect(wrapper(event).text()).toEqual(extensions.dns.question.name); + }); +}); diff --git a/x-pack/plugins/security_solution/public/resolver/view/panels/descriptive_name.tsx b/x-pack/plugins/security_solution/public/resolver/view/panels/descriptive_name.tsx new file mode 100644 index 000000000000..195ebceee061 --- /dev/null +++ b/x-pack/plugins/security_solution/public/resolver/view/panels/descriptive_name.tsx @@ -0,0 +1,114 @@ +/* + * 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 { FormattedMessage } from 'react-intl'; + +import React from 'react'; + +import { + isLegacyEventSafeVersion, + processNameSafeVersion, + entityIDSafeVersion, +} from '../../../../common/endpoint/models/event'; +import { SafeResolverEvent } from '../../../../common/endpoint/types'; + +/** + * Based on the ECS category of the event, attempt to provide a more descriptive name + * (e.g. the `event.registry.key` for `registry` or the `dns.question.name` for `dns`, etc.). + * This function returns the data in the form of `{subject, descriptor}` where `subject` will + * tend to be the more distinctive term (e.g. 137.213.212.7 for a network event) and the + * `descriptor` can be used to present more useful/meaningful view (e.g. `inbound 137.213.212.7` + * in the example above). + * see: https://www.elastic.co/guide/en/ecs/current/ecs-field-reference.html + * @param event The ResolverEvent to get the descriptive name for + */ +export function DescriptiveName({ event }: { event: SafeResolverEvent }) { + if (isLegacyEventSafeVersion(event)) { + return ( + + ); + } + + /** + * This list of attempts can be expanded/adjusted as the underlying model changes over time: + */ + + // Stable, per ECS 1.5: https://www.elastic.co/guide/en/ecs/current/ecs-allowed-values-event-category.html + + if (event.network?.forwarded_ip) { + return ( + + ); + } + + if (event.file?.path) { + return ( + + ); + } + + if (event.registry?.path) { + return ( + + ); + } + + if (event.registry?.key) { + return ( + + ); + } + + if (event.dns?.question?.name) { + return ( + + ); + } + return ( + + ); +} 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 24d2a4a8f43f..72f0d54d51fa 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 @@ -4,275 +4,103 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { memo, useMemo, useEffect, Fragment } from 'react'; +/* eslint-disable no-continue */ + +/* eslint-disable react/display-name */ + +import React, { memo, useMemo, Fragment } from 'react'; import { i18n } from '@kbn/i18n'; import { EuiSpacer, EuiText, EuiDescriptionList, EuiTextColor, EuiTitle } from '@elastic/eui'; import styled from 'styled-components'; import { useSelector } from 'react-redux'; import { FormattedMessage } from 'react-intl'; import { StyledPanel } from '../styles'; -import { StyledBreadcrumbs, BoldCode, StyledTime, GeneratedText } from './panel_content_utilities'; -import * as event from '../../../../common/endpoint/models/event'; +import { BoldCode, StyledTime, GeneratedText, formatDate } from './panel_content_utilities'; +import { Breadcrumbs } from './breadcrumbs'; +import * as eventModel from '../../../../common/endpoint/models/event'; import * as selectors from '../../store/selectors'; -import { useResolverDispatch } from '../use_resolver_dispatch'; -import { PanelContentError } from './panel_content_error'; import { PanelLoading } from './panel_loading'; import { ResolverState } from '../../types'; -import { useNavigateOrReplace } from '../use_navigate_or_replace'; - -// Adding some styles to prevent horizontal scrollbars, per request from UX review -const StyledDescriptionList = memo(styled(EuiDescriptionList)` - &.euiDescriptionList.euiDescriptionList--column dt.euiDescriptionList__title.desc-title { - max-width: 8em; - overflow-wrap: break-word; - } - &.euiDescriptionList.euiDescriptionList--column dd.euiDescriptionList__description { - max-width: calc(100% - 8.5em); - overflow-wrap: break-word; - } -`); - -// Also prevents horizontal scrollbars on long descriptive names -const StyledDescriptiveName = memo(styled(EuiText)` - padding-right: 1em; - overflow-wrap: break-word; -`); - -// Styling subtitles, per UX review: -const StyledFlexTitle = memo(styled('h3')` - display: flex; - flex-flow: row; - font-size: 1.2em; -`); -const StyledTitleRule = memo(styled('hr')` - &.euiHorizontalRule.euiHorizontalRule--full.euiHorizontalRule--marginSmall.override { - display: block; - flex: 1; - margin-left: 0.5em; - } -`); +import { DescriptiveName } from './descriptive_name'; +import { useLinkProps } from '../use_link_props'; +import { SafeResolverEvent } from '../../../../common/endpoint/types'; +import { deepObjectEntries } from './deep_object_entries'; -const TitleHr = memo(() => { - return ( - +export const EventDetail = memo(function EventDetail({ + nodeID, + eventID, + eventType, +}: { + nodeID: string; + eventID: string; + /** The event type to show in the breadcrumbs */ + eventType: string; +}) { + const event = useSelector((state: ResolverState) => + selectors.eventByID(state)({ nodeID, eventID }) ); + const processEvent = useSelector((state: ResolverState) => + selectors.processEventForID(state)(nodeID) + ); + if (event && processEvent) { + return ( + + ); + } else { + return ( + + + + ); + } }); -TitleHr.displayName = 'TitleHR'; - -/** - * Take description list entries and prepare them for display by - * seeding with `` tags. - * - * @param entries {title: string, description: string}[] - */ -function entriesForDisplay(entries: Array<{ title: string; description: string }>) { - return entries.map((entry) => { - return { - description: {entry.description}, - title: {entry.title}, - }; - }); -} /** * This view presents a detailed view of all the available data for a related event, split and titled by the "section" * it appears in the underlying ResolverEvent */ -export const EventDetail = memo(function ({ +const EventDetailContents = memo(function ({ nodeID, - eventID, + event, + eventType, + processEvent, }: { nodeID: string; - eventID: string; -}) { - const parentEvent = useSelector((state: ResolverState) => - selectors.processEventForID(state)(nodeID) - ); - - const relatedEventsStats = useSelector((state: ResolverState) => - selectors.relatedEventsStats(state)(nodeID) - ); - const countForParent: number = Object.values(relatedEventsStats?.events.byCategory || {}).reduce( - (sum, val) => sum + val, - 0 - ); - const processName = (parentEvent && event.eventName(parentEvent)) || '*'; - const processEntityId = (parentEvent && event.entityId(parentEvent)) || ''; - const totalCount = countForParent || 0; - const eventsString = i18n.translate( - 'xpack.securitySolution.endpoint.resolver.panel.relatedEventDetail.events', - { - defaultMessage: 'Events', - } - ); - const naString = i18n.translate( - 'xpack.securitySolution.endpoint.resolver.panel.relatedEventDetail.NA', - { - defaultMessage: 'N/A', - } - ); - - const relatedsReadyMap = useSelector(selectors.relatedEventsReady); - const relatedsReady = relatedsReadyMap.get(processEntityId!); - const dispatch = useResolverDispatch(); - const nodesHref = useSelector((state: ResolverState) => - selectors.relativeHref(state)({ panelView: 'nodes' }) - ); - const nodesLinkNavProps = useNavigateOrReplace({ - search: nodesHref, - }); - + event: SafeResolverEvent; /** - * If we don't have the related events for the parent yet, use this effect - * to request them. + * Event type to use in the breadcrumbs */ - useEffect(() => { - if ( - typeof relatedsReady === 'undefined' && - processEntityId !== null && - processEntityId !== undefined - ) { - dispatch({ - type: 'appDetectedMissingEventData', - payload: processEntityId, - }); + eventType: string; + processEvent: SafeResolverEvent; +}) { + const formattedDate = useMemo(() => { + const timestamp = eventModel.timestampSafeVersion(event); + if (timestamp !== undefined) { + return formatDate(new Date(timestamp)); } - }, [relatedsReady, dispatch, processEntityId]); - - const [ - relatedEventToShowDetailsFor, - countBySameCategory, - relatedEventCategory = naString, - sections, - formattedDate, - ] = useSelector((state: ResolverState) => - selectors.relatedEventDisplayInfoByEntityAndSelfId(state)(nodeID, eventID) - ); - - const { subject = '', descriptor = '' } = relatedEventToShowDetailsFor - ? event.descriptiveName(relatedEventToShowDetailsFor) - : {}; - - const nodeDetailHref = useSelector((state: ResolverState) => - selectors.relativeHref(state)({ - panelView: 'nodeDetail', - panelParameters: { nodeID: processEntityId }, - }) - ); - const nodeDetailLinkNavProps = useNavigateOrReplace({ - search: nodeDetailHref, - }); - - const nodeEventsHref = useSelector((state: ResolverState) => - selectors.relativeHref(state)({ - panelView: 'nodeEvents', - panelParameters: { nodeID: processEntityId }, - }) - ); - const nodeEventsLinkNavProps = useNavigateOrReplace({ - search: nodeEventsHref, - }); - - const nodeEventsOfTypeHref = useSelector((state: ResolverState) => - selectors.relativeHref(state)({ - panelView: 'nodeEventsOfType', - panelParameters: { nodeID: processEntityId, eventType: relatedEventCategory }, - }) - ); - const nodeEventsOfTypeLinkNavProps = useNavigateOrReplace({ - search: nodeEventsOfTypeHref, - }); - const crumbs = useMemo(() => { - return [ - { - text: eventsString, - ...nodesLinkNavProps, - }, - { - text: processName, - ...nodeDetailLinkNavProps, - }, - { - text: ( - <> - - - ), - ...nodeEventsLinkNavProps, - }, - { - text: ( - <> - - - ), - ...nodeEventsOfTypeLinkNavProps, - }, - { - text: relatedEventToShowDetailsFor ? ( - - ) : ( - naString - ), - onClick: () => {}, - }, - ]; - }, [ - processName, - eventsString, - totalCount, - countBySameCategory, - naString, - relatedEventCategory, - relatedEventToShowDetailsFor, - subject, - descriptor, - nodeEventsOfTypeLinkNavProps, - nodeEventsLinkNavProps, - nodeDetailLinkNavProps, - nodesLinkNavProps, - ]); - - if (!relatedsReady) { - return ; - } - - /** - * Could happen if user e.g. loads a URL with a bad crumbEvent - */ - if (!relatedEventToShowDetailsFor) { - const errString = i18n.translate( - 'xpack.securitySolution.endpoint.resolver.panel.relatedDetail.missing', - { - defaultMessage: 'Related event not found.', - } - ); - return ; - } + }, [event]); return ( - + @@ -288,23 +116,49 @@ export const EventDetail = memo(function ({ - + - {sections.map(({ sectionTitle, entries }, index) => { - const displayEntries = entriesForDisplay(entries); + + + ); +}); + +function EventDetailFields({ event }: { event: SafeResolverEvent }) { + const sections = useMemo(() => { + const returnValue: Array<{ + namespace: React.ReactNode; + descriptions: Array<{ title: React.ReactNode; description: React.ReactNode }>; + }> = []; + for (const [key, value] of Object.entries(event)) { + // ignore these keys + if (key === 'agent' || key === 'ecs' || key === 'process' || key === '@timestamp') { + continue; + } + + const section = { + // Group the fields by their top-level namespace + namespace: {key}, + descriptions: deepObjectEntries(value).map(([path, fieldValue]) => ({ + title: {path.join('.')}, + description: {String(fieldValue)}, + })), + }; + returnValue.push(section); + } + return returnValue; + }, [event]); + return ( + <> + {sections.map(({ namespace, descriptions }, index) => { return ( {index === 0 ? null : } - {sectionTitle} + {namespace} @@ -315,12 +169,136 @@ export const EventDetail = memo(function ({ align="left" titleProps={{ className: 'desc-title' }} compressed - listItems={displayEntries} + listItems={descriptions} /> {index === sections.length - 1 ? null : } ); })} - + + ); +} + +function EventDetailBreadcrumbs({ + nodeID, + nodeName, + event, + breadcrumbEventCategory, +}: { + nodeID: string; + nodeName?: string; + event: SafeResolverEvent; + breadcrumbEventCategory: string; +}) { + const countByCategory = useSelector((state: ResolverState) => + selectors.relatedEventCountByType(state)(nodeID, breadcrumbEventCategory) + ); + const relatedEventCount: number | undefined = useSelector((state: ResolverState) => + selectors.relatedEventTotalCount(state)(nodeID) + ); + const nodesLinkNavProps = useLinkProps({ + panelView: 'nodes', + }); + + const nodeDetailLinkNavProps = useLinkProps({ + panelView: 'nodeDetail', + panelParameters: { nodeID }, + }); + + const nodeEventsLinkNavProps = useLinkProps({ + panelView: 'nodeEvents', + panelParameters: { nodeID }, + }); + + const nodeEventsOfTypeLinkNavProps = useLinkProps({ + panelView: 'nodeEventsOfType', + panelParameters: { nodeID, eventType: breadcrumbEventCategory }, + }); + const breadcrumbs = useMemo(() => { + return [ + { + text: i18n.translate( + 'xpack.securitySolution.endpoint.resolver.panel.relatedEventDetail.events', + { + defaultMessage: 'Events', + } + ), + ...nodesLinkNavProps, + }, + { + text: nodeName, + ...nodeDetailLinkNavProps, + }, + { + text: ( + + ), + ...nodeEventsLinkNavProps, + }, + { + text: ( + + ), + ...nodeEventsOfTypeLinkNavProps, + }, + { + text: , + }, + ]; + }, [ + breadcrumbEventCategory, + countByCategory, + event, + nodeDetailLinkNavProps, + nodeEventsLinkNavProps, + nodeName, + relatedEventCount, + nodesLinkNavProps, + nodeEventsOfTypeLinkNavProps, + ]); + return ; +} + +const StyledDescriptionList = memo(styled(EuiDescriptionList)` + &.euiDescriptionList.euiDescriptionList--column dt.euiDescriptionList__title.desc-title { + max-width: 8em; + overflow-wrap: break-word; + } + &.euiDescriptionList.euiDescriptionList--column dd.euiDescriptionList__description { + max-width: calc(100% - 8.5em); + overflow-wrap: break-word; + } +`); + +// Also prevents horizontal scrollbars on long descriptive names +const StyledDescriptiveName = memo(styled(EuiText)` + padding-right: 1em; + overflow-wrap: break-word; +`); + +const StyledFlexTitle = memo(styled('h3')` + display: flex; + flex-flow: row; + font-size: 1.2em; +`); +const StyledTitleRule = memo(styled('hr')` + &.euiHorizontalRule.euiHorizontalRule--full.euiHorizontalRule--marginSmall.override { + display: block; + flex: 1; + margin-left: 0.5em; + } +`); + +const TitleHr = memo(() => { + return ( + ); }); 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 da5cb1acfed6..df9cbe9ced54 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 @@ -11,16 +11,13 @@ import { useSelector } from 'react-redux'; import * as selectors from '../../store/selectors'; import { NodeEventsOfType } from './node_events_of_type'; import { NodeEvents } from './node_events'; -import { NodeDetail } from './node_details'; +import { NodeDetail } from './node_detail'; import { NodeList } from './node_list'; import { EventDetail } from './event_detail'; import { PanelViewAndParameters } from '../../types'; /** - * - * This component implements the strategy laid out above by determining the "right" view and doing some other housekeeping e.g. effects to keep the UI-selected node in line with what's indicated by the URL parameters. - * - * @returns {JSX.Element} The "right" table content to show based on the query params as described above + * Show the panel that matches the `panelViewAndParameters` (derived from the browser's location.search) */ export const PanelRouter = memo(function () { const params: PanelViewAndParameters = useSelector(selectors.panelViewAndParameters); @@ -40,6 +37,7 @@ export const PanelRouter = memo(function () { ); } else { diff --git a/x-pack/plugins/security_solution/public/resolver/view/panels/node_details.tsx b/x-pack/plugins/security_solution/public/resolver/view/panels/node_detail.tsx similarity index 74% rename from x-pack/plugins/security_solution/public/resolver/view/panels/node_details.tsx rename to x-pack/plugins/security_solution/public/resolver/view/panels/node_detail.tsx index 48d5089eb564..04e9de61f625 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/panels/node_details.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/panels/node_detail.tsx @@ -15,23 +15,17 @@ import styled from 'styled-components'; import { EuiDescriptionListProps } from '@elastic/eui/src/components/description_list/description_list'; import { StyledDescriptionList, StyledTitle } from './styles'; import * as selectors from '../../store/selectors'; -import * as event from '../../../../common/endpoint/models/event'; -import { formatDate, StyledBreadcrumbs, GeneratedText } from './panel_content_utilities'; -import { - processPath, - processPid, - userInfoForProcess, - processParentPid, - md5HashForProcess, - argsForProcess, -} from '../../models/process_event'; +import * as eventModel from '../../../../common/endpoint/models/event'; +import { formatDate, GeneratedText } from './panel_content_utilities'; +import { Breadcrumbs } from './breadcrumbs'; +import { processPath, processPID } from '../../models/process_event'; import { CubeForProcess } from './cube_for_process'; -import { ResolverEvent } from '../../../../common/endpoint/types'; +import { SafeResolverEvent } from '../../../../common/endpoint/types'; import { useCubeAssets } from '../use_cube_assets'; import { ResolverState } from '../../types'; import { PanelLoading } from './panel_loading'; import { StyledPanel } from '../styles'; -import { useNavigateOrReplace } from '../use_navigate_or_replace'; +import { useLinkProps } from '../use_link_props'; const StyledCubeForProcess = styled(CubeForProcess)` position: relative; @@ -44,7 +38,11 @@ export const NodeDetail = memo(function ({ nodeID }: { nodeID: string }) { ); return ( - {processEvent === null ? : } + {processEvent === null ? ( + + ) : ( + + )} ); }); @@ -53,21 +51,22 @@ export const NodeDetail = memo(function ({ nodeID }: { nodeID: string }) { * A description list view of all the Metadata that goes with a particular process event, like: * Created, PID, User/Domain, etc. */ -const NodeDetailView = memo(function NodeDetailView({ +const NodeDetailView = memo(function ({ processEvent, + nodeID, }: { - processEvent: ResolverEvent; + processEvent: SafeResolverEvent; + nodeID: string; }) { - const processName = event.eventName(processEvent); - const entityId = event.entityId(processEvent); + const processName = eventModel.processNameSafeVersion(processEvent); const isProcessTerminated = useSelector((state: ResolverState) => - selectors.isProcessTerminated(state)(entityId) + selectors.isProcessTerminated(state)(nodeID) ); const relatedEventTotal = useSelector((state: ResolverState) => { - return selectors.relatedEventAggregateTotalByEntityId(state)(entityId); + return selectors.relatedEventTotalCount(state)(nodeID); }); const processInfoEntry: EuiDescriptionListProps['listItems'] = useMemo(() => { - const eventTime = event.eventTimestamp(processEvent); + const eventTime = eventModel.eventTimestamp(processEvent); const dateTime = eventTime === undefined ? null : formatDate(eventTime); const createdEntry = { @@ -82,32 +81,32 @@ const NodeDetailView = memo(function NodeDetailView({ const pidEntry = { title: 'process.pid', - description: processPid(processEvent), + description: processPID(processEvent), }; const userEntry = { title: 'user.name', - description: userInfoForProcess(processEvent)?.name, + description: eventModel.userName(processEvent), }; const domainEntry = { title: 'user.domain', - description: userInfoForProcess(processEvent)?.domain, + description: eventModel.userDomain(processEvent), }; const parentPidEntry = { title: 'process.parent.pid', - description: processParentPid(processEvent), + description: eventModel.parentPID(processEvent), }; const md5Entry = { title: 'process.hash.md5', - description: md5HashForProcess(processEvent), + description: eventModel.md5HashForProcess(processEvent), }; const commandLineEntry = { title: 'process.args', - description: argsForProcess(processEvent), + description: eventModel.argsForProcess(processEvent), }; // This is the data in {title, description} form for the EuiDescriptionList to display @@ -134,12 +133,8 @@ const NodeDetailView = memo(function NodeDetailView({ return processDescriptionListData; }, [processEvent]); - const nodesHref = useSelector((state: ResolverState) => - selectors.relativeHref(state)({ panelView: 'nodes' }) - ); - - const nodesLinkNavProps = useNavigateOrReplace({ - search: nodesHref, + const nodesLinkNavProps = useLinkProps({ + panelView: 'nodes', }); const crumbs = useMemo(() => { @@ -162,27 +157,20 @@ const NodeDetailView = memo(function NodeDetailView({ defaultMessage="Details for: {processName}" /> ), - onClick: () => {}, }, ]; }, [processName, nodesLinkNavProps]); const { descriptionText } = useCubeAssets(isProcessTerminated, false); - const nodeDetailHref = useSelector((state: ResolverState) => - selectors.relativeHref(state)({ - panelView: 'nodeEvents', - panelParameters: { nodeID: entityId }, - }) - ); - - const nodeDetailNavProps = useNavigateOrReplace({ - search: nodeDetailHref!, + const nodeDetailNavProps = useLinkProps({ + panelView: 'nodeEvents', + panelParameters: { nodeID }, }); const titleID = useMemo(() => htmlIdGenerator('resolverTable')(), []); return ( <> - + @@ -201,7 +189,7 @@ const NodeDetailView = memo(function NodeDetailView({ - + @@ -26,11 +28,21 @@ export function NodeEvents({ nodeID }: { nodeID: string }) { selectors.relatedEventsStats(state)(nodeID) ); if (processEvent === null || relatedEventsStats === undefined) { - return ; + return ( + + + + ); } else { return ( - + + + ); } @@ -47,120 +59,29 @@ export function NodeEvents({ nodeID }: { nodeID: string }) { * | 2 | Network | * */ -const EventCountsForProcess = memo(function EventCountsForProcess({ - processEvent, +const EventCategoryLinks = memo(function ({ + nodeID, relatedStats, }: { - processEvent: ResolverEvent; + nodeID: string; relatedStats: ResolverNodeStats; }) { interface EventCountsTableView { - name: string; + eventType: string; count: number; } - const relatedEventsState = { stats: relatedStats.events.byCategory }; - const processName = processEvent && event.eventName(processEvent); - const processEntityId = event.entityId(processEvent); - /** - * totalCount: This will reflect the aggregated total by category for all related events - * e.g. [dns,file],[dns,file],[registry] will have an aggregate total of 5. This is to keep the - * total number consistent with the "broken out" totals we see elsewhere in the app. - * E.g. on the rleated list by type, the above would show as: - * 2 dns - * 2 file - * 1 registry - * So it would be extremely disorienting to show the user a "3" above that as a total. - */ - const totalCount = Object.values(relatedStats.events.byCategory).reduce( - (sum, val) => sum + val, - 0 - ); - const eventsString = i18n.translate( - 'xpack.securitySolution.endpoint.resolver.panel.processEventCounts.events', - { - defaultMessage: 'Events', - } - ); - const eventsHref = useSelector((state: ResolverState) => - selectors.relativeHref(state)({ panelView: 'nodes' }) - ); - - const eventLinkNavProps = useNavigateOrReplace({ - search: eventsHref, - }); - - const processDetailHref = useSelector((state: ResolverState) => - selectors.relativeHref(state)({ - panelView: 'nodeDetail', - panelParameters: { nodeID: processEntityId }, - }) - ); - - const processDetailNavProps = useNavigateOrReplace({ - search: processDetailHref, - }); - - const nodeDetailHref = useSelector((state: ResolverState) => - selectors.relativeHref(state)({ - panelView: 'nodeEvents', - panelParameters: { nodeID: processEntityId }, - }) - ); - - const nodeDetailNavProps = useNavigateOrReplace({ - search: nodeDetailHref!, - }); - const crumbs = useMemo(() => { - return [ - { - text: eventsString, - ...eventLinkNavProps, - }, - { - text: processName, - ...processDetailNavProps, - }, - { - text: ( - - ), - ...nodeDetailNavProps, - }, - ]; - }, [ - processName, - totalCount, - eventsString, - eventLinkNavProps, - nodeDetailNavProps, - processDetailNavProps, - ]); const rows = useMemo(() => { - return Object.entries(relatedEventsState.stats).map( + return Object.entries(relatedStats.events.byCategory).map( ([eventType, count]): EventCountsTableView => { return { - name: eventType, + eventType, count, }; } ); - }, [relatedEventsState]); - - const eventDetailHref = useSelector((state: ResolverState) => - selectors.relativeHref(state)({ - panelView: 'eventDetail', - panelParameters: { nodeID: processEntityId, eventType: name, eventID: processEntityId }, - }) - ); + }, [relatedStats.events.byCategory]); - const eventDetailNavProps = useNavigateOrReplace({ - search: eventDetailHref, - }); const columns = useMemo>>( () => [ { @@ -168,29 +89,100 @@ const EventCountsForProcess = memo(function EventCountsForProcess({ name: i18n.translate('xpack.securitySolution.endpoint.resolver.panel.table.row.count', { defaultMessage: 'Count', }), + 'data-test-subj': 'resolver:panel:node-events:event-type-count', width: '20%', sortable: true, }, { - field: 'name', + field: 'eventType', name: i18n.translate('xpack.securitySolution.endpoint.resolver.panel.table.row.eventType', { defaultMessage: 'Event Type', }), width: '80%', sortable: true, - render(name: string) { - return {name}; + render(eventType: string) { + return ( + + {eventType} + + ); }, }, ], - [eventDetailNavProps] + [nodeID] ); + return items={rows} columns={columns} sorting />; +}); + +const NodeEventsBreadcrumbs = memo(function ({ + nodeID, + nodeName, + totalEventCount, +}: { + nodeID: string; + nodeName: React.ReactNode; + totalEventCount: number; +}) { return ( - <> - - - items={rows} columns={columns} sorting /> - + + ), + ...useLinkProps({ + panelView: 'nodeEvents', + panelParameters: { nodeID }, + }), + }, + ]} + /> ); }); -EventCountsForProcess.displayName = 'EventCountsForProcess'; + +const NodeEventsLink = memo( + ({ + nodeID, + eventType, + children, + }: { + nodeID: string; + eventType: string; + children: React.ReactNode; + }) => { + const props = useLinkProps({ + panelView: 'nodeEventsOfType', + panelParameters: { + nodeID, + eventType, + }, + }); + return ( + + {children} + + ); + } +); 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 afff8d4b75c1..281794ac24d2 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 @@ -4,297 +4,225 @@ * you may not use this file except in compliance with the Elastic License. */ -/* eslint-disable react/display-name */ - -import React, { memo, useMemo, useEffect, Fragment } from 'react'; +import React, { memo, useCallback, Fragment } from 'react'; import { i18n } from '@kbn/i18n'; import { EuiSpacer, EuiText, EuiButtonEmpty, EuiHorizontalRule } from '@elastic/eui'; import { useSelector } from 'react-redux'; import { FormattedMessage } from 'react-intl'; -import styled from 'styled-components'; import { StyledPanel } from '../styles'; -import { formatDate, StyledBreadcrumbs, BoldCode, StyledTime } from './panel_content_utilities'; -import * as event from '../../../../common/endpoint/models/event'; -import { ResolverEvent, ResolverNodeStats } from '../../../../common/endpoint/types'; +import { formatDate, BoldCode, StyledTime } from './panel_content_utilities'; +import { Breadcrumbs } from './breadcrumbs'; +import * as eventModel from '../../../../common/endpoint/models/event'; +import { SafeResolverEvent } from '../../../../common/endpoint/types'; import * as selectors from '../../store/selectors'; -import { useResolverDispatch } from '../use_resolver_dispatch'; -import { RelatedEventLimitWarning } from '../limit_warnings'; import { ResolverState } from '../../types'; -import { useNavigateOrReplace } from '../use_navigate_or_replace'; -import { useRelatedEventDetailNavigation } from '../use_related_event_detail_navigation'; import { PanelLoading } from './panel_loading'; +import { DescriptiveName } from './descriptive_name'; +import { useLinkProps } from '../use_link_props'; /** - * This view presents a list of related events of a given type for a given process. - * It will appear like: - * - * | | - * | :----------------------------------------------------- | - * | **registry deletion** @ *3:32PM..* *HKLM/software...* | - * | **file creation** @ *3:34PM..* *C:/directory/file.exe* | + * Render a list of events that are related to `nodeID` and that have a category of `eventType`. */ - -interface MatchingEventEntry { - formattedDate: string; - eventType: string; - eventCategory: string; - name: { subject: string; descriptor?: string }; - setQueryParams: () => void; -} - -const StyledRelatedLimitWarning = styled(RelatedEventLimitWarning)` - flex-flow: row wrap; - display: block; - align-items: baseline; - margin-top: 1em; - - & .euiCallOutHeader { - display: inline; - margin-right: 0.25em; - } - - & .euiText { - display: inline; - } - - & .euiText p { - display: inline; - } -`; - -const NodeCategoryEntries = memo(function ({ - crumbs, - matchingEventEntries, +export const NodeEventsOfType = memo(function NodeEventsOfType({ + nodeID, eventType, - processEntityId, }: { - crumbs: Array<{ - text: string | JSX.Element | null; - onClick: (event: React.MouseEvent) => void; - href?: string; - }>; - matchingEventEntries: MatchingEventEntry[]; + nodeID: string; eventType: string; - processEntityId: string; }) { - const relatedLookupsByCategory = useSelector(selectors.relatedEventInfoByEntityId); - const lookupsForThisNode = relatedLookupsByCategory(processEntityId); - const shouldShowLimitWarning = lookupsForThisNode?.shouldShowLimitForCategory(eventType); - const numberDisplayed = lookupsForThisNode?.numberActuallyDisplayedForCategory(eventType); - const numberMissing = lookupsForThisNode?.numberNotDisplayedForCategory(eventType); - - return ( - <> - - {shouldShowLimitWarning && typeof numberDisplayed !== 'undefined' && numberMissing ? ( - - ) : null} - - <> - {matchingEventEntries.map((eventView, index) => { - const { subject, descriptor = '' } = eventView.name; - return ( - - - - - - - - - - - - - - {index === matchingEventEntries.length - 1 ? null : } - - ); - })} - - - ); -}); - -export function NodeEventsOfType({ nodeID, eventType }: { nodeID: string; eventType: string }) { const processEvent = useSelector((state: ResolverState) => selectors.processEventForID(state)(nodeID) ); - const relatedEventsStats = useSelector((state: ResolverState) => - selectors.relatedEventsStats(state)(nodeID) + const eventCount = useSelector( + (state: ResolverState) => selectors.relatedEventsStats(state)(nodeID)?.events.total + ); + const eventsInCategoryCount = useSelector( + (state: ResolverState) => + selectors.relatedEventsStats(state)(nodeID)?.events.byCategory[eventType] + ); + const events = useSelector( + useCallback( + (state: ResolverState) => { + return selectors.relatedEventsByCategory(state)(nodeID, eventType); + }, + [eventType, nodeID] + ) ); return ( - + {eventCount === undefined || processEvent === null ? ( + + ) : ( + <> + + + + + )} ); -} +}); -const NodeEventList = memo(function ({ - processEvent, +/** + * Rendered for each event in the list. + */ +const NodeEventsListItem = memo(function ({ + event, + nodeID, eventType, - relatedStats, }: { - processEvent: ResolverEvent | null; + event: SafeResolverEvent; + nodeID: string; eventType: string; - relatedStats: ResolverNodeStats | undefined; }) { - const processName = processEvent && event.eventName(processEvent); - const processEntityId = processEvent ? event.entityId(processEvent) : ''; - const nodesHref = useSelector((state: ResolverState) => - selectors.relativeHref(state)({ panelView: 'nodes' }) - ); - - const nodesLinkNavProps = useNavigateOrReplace({ - search: nodesHref, + const timestamp = eventModel.eventTimestamp(event); + const date = timestamp !== undefined ? formatDate(timestamp) : timestamp; + const linkProps = useLinkProps({ + panelView: 'eventDetail', + panelParameters: { + nodeID, + eventType, + eventID: String(eventModel.eventID(event)), + }, }); - const totalCount = relatedStats - ? Object.values(relatedStats.events.byCategory).reduce((sum, val) => sum + val, 0) - : 0; - const eventsString = i18n.translate( - 'xpack.securitySolution.endpoint.resolver.panel.processEventListByType.events', - { - defaultMessage: 'Events', - } + return ( + <> + + + + + + + + + + + + + ); +}); - const relatedsReadyMap = useSelector(selectors.relatedEventsReady); - const relatedsReady = processEntityId && relatedsReadyMap.get(processEntityId); - - const dispatch = useResolverDispatch(); - - useEffect(() => { - if (typeof relatedsReady === 'undefined') { - dispatch({ - type: 'appDetectedMissingEventData', - payload: processEntityId, - }); - } - }, [relatedsReady, dispatch, processEntityId]); - - const relatedByCategory = useSelector(selectors.relatedEventsByCategory); - const eventsForCurrentCategory = relatedByCategory(processEntityId)(eventType); - const relatedEventDetailNavigation = useRelatedEventDetailNavigation({ - nodeID: processEntityId, - category: eventType, - events: eventsForCurrentCategory, - }); - +/** + * Renders a list of events with a separator in between. + */ +const NodeEventList = memo(function NodeEventList({ + eventType, + events, + nodeID, +}: { + eventType: string; /** - * A list entry will be displayed for each of these + * The events to list. */ - const matchingEventEntries: MatchingEventEntry[] = useMemo(() => { - return eventsForCurrentCategory.map((resolverEvent) => { - const eventTime = event.eventTimestamp(resolverEvent); - const formattedDate = typeof eventTime === 'undefined' ? '' : formatDate(eventTime); - const entityId = event.eventId(resolverEvent); - return { - formattedDate, - eventCategory: `${eventType}`, - eventType: `${event.ecsEventType(resolverEvent)}`, - name: event.descriptiveName(resolverEvent), - setQueryParams: () => relatedEventDetailNavigation(entityId), - }; - }); - }, [eventType, eventsForCurrentCategory, relatedEventDetailNavigation]); - - const nodeDetailHref = useSelector((state: ResolverState) => - selectors.relativeHref(state)({ - panelView: 'nodeDetail', - panelParameters: { nodeID: processEntityId }, - }) + events: SafeResolverEvent[]; + nodeID: string; +}) { + return ( + <> + {events.map((event, index) => ( + + + {index === events.length - 1 ? null : } + + ))} + ); +}); - const nodeDetailNavProps = useNavigateOrReplace({ - search: nodeDetailHref, +/** + * Renders `Breadcrumbs`. + */ +const NodeEventsOfTypeBreadcrumbs = memo(function ({ + nodeName, + eventType, + eventCount, + nodeID, + /** + * The count of events in the category that this list is showing. + */ + eventsInCategoryCount, +}: { + nodeName: React.ReactNode; + eventType: string; + /** + * The events to list. + */ + eventCount: number; + nodeID: string; + /** + * The count of events in the category that this list is showing. + */ + eventsInCategoryCount: number | undefined; +}) { + const nodesLinkNavProps = useLinkProps({ + panelView: 'nodes', }); - const nodeEventsHref = useSelector((state: ResolverState) => - selectors.relativeHref(state)({ - panelView: 'nodeEvents', - panelParameters: { nodeID: processEntityId }, - }) - ); - - const nodeEventsNavProps = useNavigateOrReplace({ - search: nodeEventsHref, + const nodeDetailNavProps = useLinkProps({ + panelView: 'nodeDetail', + panelParameters: { nodeID }, }); - const crumbs = useMemo(() => { - return [ - { - text: eventsString, - ...nodesLinkNavProps, - }, - { - text: processName, - ...nodeDetailNavProps, - }, - { - text: ( - - ), - ...nodeEventsNavProps, - }, - { - text: ( - - ), - onClick: () => {}, - }, - ]; - }, [ - eventType, - eventsString, - matchingEventEntries.length, - processName, - totalCount, - nodeDetailNavProps, - nodesLinkNavProps, - nodeEventsNavProps, - ]); - if (!relatedsReady) { - return ; - } + const nodeEventsNavProps = useLinkProps({ + panelView: 'nodeEvents', + panelParameters: { nodeID }, + }); return ( - + ), + ...nodeEventsNavProps, + }, + { + text: ( + + ), + }, + ]} /> ); }); diff --git a/x-pack/plugins/security_solution/public/resolver/view/panels/node_list.tsx b/x-pack/plugins/security_solution/public/resolver/view/panels/node_list.tsx index 6113cea4c4ed..8fc6e7cc66c7 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/panels/node_list.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/panels/node_list.tsx @@ -4,9 +4,15 @@ * you may not use this file except in compliance with the Elastic License. */ +/* eslint-disable @elastic/eui/href-or-on-click */ + +/* eslint-disable no-duplicate-imports */ + +import { useDispatch } from 'react-redux'; + /* eslint-disable react/display-name */ -import React, { memo, useMemo } from 'react'; +import React, { memo, useMemo, useCallback, useContext } from 'react'; import { EuiBasicTableColumn, EuiBadge, @@ -16,71 +22,31 @@ import { } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { useSelector } from 'react-redux'; -import styled from 'styled-components'; +import { SideEffectContext } from '../side_effect_context'; import { StyledPanel } from '../styles'; -import * as event from '../../../../common/endpoint/models/event'; +import { + StyledLabelTitle, + StyledAnalyzedEvent, + StyledLabelContainer, + StyledButtonTextContainer, +} from './styles'; +import * as eventModel from '../../../../common/endpoint/models/event'; import * as selectors from '../../store/selectors'; -import { formatter, StyledBreadcrumbs } from './panel_content_utilities'; +import { formatter } from './panel_content_utilities'; +import { Breadcrumbs } from './breadcrumbs'; import { CubeForProcess } from './cube_for_process'; -import { SafeResolverEvent } from '../../../../common/endpoint/types'; import { LimitWarning } from '../limit_warnings'; import { ResolverState } from '../../types'; -import { useNavigateOrReplace } from '../use_navigate_or_replace'; +import { useLinkProps } from '../use_link_props'; import { useColors } from '../use_colors'; - -const StyledLimitWarning = styled(LimitWarning)` - flex-flow: row wrap; - display: block; - align-items: baseline; - margin-top: 1em; - - & .euiCallOutHeader { - display: inline; - margin-right: 0.25em; - } - - & .euiText { - display: inline; - } - - & .euiText p { - display: inline; - } -`; - -const StyledButtonTextContainer = styled.div` - align-items: center; - display: flex; - flex-direction: row; -`; - -const StyledAnalyzedEvent = styled.div` - color: ${(props) => props.color}; - font-size: 10.5px; - font-weight: 700; -`; - -const StyledLabelTitle = styled.div``; - -const StyledLabelContainer = styled.div` - display: inline-block; - flex: 3; - min-width: 0; - - ${StyledAnalyzedEvent}, - ${StyledLabelTitle} { - overflow: hidden; - text-align: left; - text-overflow: ellipsis; - white-space: nowrap; - } -`; +import { SafeResolverEvent } from '../../../../common/endpoint/types'; +import { ResolverAction } from '../../store/actions'; interface ProcessTableView { name?: string; timestamp?: Date; + nodeID: string; event: SafeResolverEvent; - href: string | undefined; } /** @@ -99,8 +65,8 @@ export const NodeList = memo(() => { ), sortable: true, truncateText: true, - render(name: string, item: ProcessTableView) { - return ; + render(name: string | undefined, item: ProcessTableView) { + return ; }, }, { @@ -132,42 +98,26 @@ export const NodeList = memo(() => { [] ); - const { processNodePositions } = useSelector(selectors.layout); - const nodeHrefs: Map = useSelector( - (state: ResolverState) => { - const relativeHref = selectors.relativeHref(state); - return new Map( - [...processNodePositions.keys()].map((processEvent) => { - const nodeID = event.entityIDSafeVersion(processEvent); - if (nodeID === undefined) { - return [processEvent, null]; - } - return [ - processEvent, - relativeHref({ - panelView: 'nodeDetail', - panelParameters: { - nodeID, - }, - }), - ]; - }) - ); - } - ); - const processTableView: ProcessTableView[] = useMemo( - () => - [...processNodePositions.keys()].map((processEvent) => { - const name = event.processNameSafeVersion(processEvent); - return { - name, - timestamp: event.timestampAsDateSafeVersion(processEvent), - event: processEvent, - href: nodeHrefs.get(processEvent) ?? undefined, - }; - }), - [processNodePositions, nodeHrefs] + const processTableView: ProcessTableView[] = useSelector( + 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); + if (nodeID !== undefined) { + view.push({ + name, + timestamp: eventModel.timestampAsDateSafeVersion(processEvent), + nodeID, + event: processEvent, + }); + } + } + return view; + }, []) ); + const numberOfProcesses = processTableView.length; const crumbs = useMemo(() => { @@ -176,7 +126,6 @@ export const NodeList = memo(() => { text: i18n.translate('xpack.securitySolution.resolver.panel.nodeList.title', { defaultMessage: 'All Process Events', }), - onClick: () => {}, }, ]; }, []); @@ -187,8 +136,8 @@ export const NodeList = memo(() => { const rowProps = useMemo(() => ({ 'data-test-subj': 'resolver:node-list:item' }), []); return ( - - {showWarning && } + + {showWarning && } rowProps={rowProps} @@ -201,16 +150,40 @@ export const NodeList = memo(() => { ); }); -function NodeDetailLink({ name, item }: { name: string; item: ProcessTableView }) { - const entityID = event.entityIDSafeVersion(item.event); - const originID = useSelector(selectors.originID); - const isOrigin = originID === entityID; +function NodeDetailLink({ + name, + nodeID, + event, +}: { + name?: string; + nodeID: string; + event: SafeResolverEvent; +}) { + const isOrigin = useSelector((state: ResolverState) => { + return selectors.originID(state) === nodeID; + }); const isTerminated = useSelector((state: ResolverState) => - entityID === undefined ? false : selectors.isProcessTerminated(state)(entityID) + nodeID === undefined ? false : selectors.isProcessTerminated(state)(nodeID) ); const { descriptionText } = useColors(); + const linkProps = useLinkProps({ panelView: 'nodeDetail', panelParameters: { nodeID } }); + const dispatch: (action: ResolverAction) => void = useDispatch(); + const { timestamp } = useContext(SideEffectContext); + const handleOnClick = useCallback( + (mouseEvent: React.MouseEvent) => { + linkProps.onClick(mouseEvent); + dispatch({ + type: 'userBroughtProcessIntoView', + payload: { + process: event, + time: timestamp(), + }, + }); + }, + [timestamp, linkProps, dispatch, event] + ); return ( - + {name === '' ? ( {i18n.translate( diff --git a/x-pack/plugins/security_solution/public/resolver/view/panels/panel_content_error.tsx b/x-pack/plugins/security_solution/public/resolver/view/panels/panel_content_error.tsx index 3b10a8db2bf1..199758145f11 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/panels/panel_content_error.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/panels/panel_content_error.tsx @@ -7,11 +7,8 @@ import { i18n } from '@kbn/i18n'; import { EuiSpacer, EuiText, EuiButtonEmpty } from '@elastic/eui'; import React, { memo, useMemo } from 'react'; -import { useSelector } from 'react-redux'; -import { useNavigateOrReplace } from '../use_navigate_or_replace'; -import * as selectors from '../../store/selectors'; -import { ResolverState } from '../../types'; -import { StyledBreadcrumbs } from './panel_content_utilities'; +import { Breadcrumbs } from './breadcrumbs'; +import { useLinkProps } from '../use_link_props'; /** * Display an error in the panel when something goes wrong and give the user a way to "retreat" back to a default state. @@ -24,12 +21,10 @@ export const PanelContentError = memo(function ({ }: { translatedErrorMessage: string; }) { - const nodesHref = useSelector((state: ResolverState) => - selectors.relativeHref(state)({ panelView: 'nodes' }) - ); - const nodesLinkNavProps = useNavigateOrReplace({ - search: nodesHref, + const nodesLinkNavProps = useLinkProps({ + panelView: 'nodes', }); + const crumbs = useMemo(() => { return [ { @@ -42,13 +37,12 @@ export const PanelContentError = memo(function ({ text: i18n.translate('xpack.securitySolution.endpoint.resolver.panel.error.error', { defaultMessage: 'Error', }), - onClick: () => {}, }, ]; }, [nodesLinkNavProps]); return ( <> - + {translatedErrorMessage} @@ -60,4 +54,3 @@ export const PanelContentError = memo(function ({ ); }); -PanelContentError.displayName = 'TableServiceError'; diff --git a/x-pack/plugins/security_solution/public/resolver/view/panels/panel_content_utilities.tsx b/x-pack/plugins/security_solution/public/resolver/view/panels/panel_content_utilities.tsx index a7d76277c6ab..5ca34b33b239 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/panels/panel_content_utilities.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/panels/panel_content_utilities.tsx @@ -7,10 +7,9 @@ /* eslint-disable react/display-name */ import { i18n } from '@kbn/i18n'; -import { EuiBreadcrumbs, EuiCode, EuiBetaBadge } from '@elastic/eui'; +import { EuiCode } from '@elastic/eui'; import styled from 'styled-components'; import React, { memo } from 'react'; -import { useColors } from '../use_colors'; /** * A bold version of EuiCode to display certain titles with @@ -21,30 +20,6 @@ export const BoldCode = styled(EuiCode)` } `; -const BetaHeader = styled(`header`)` - margin-bottom: 1em; -`; - -const ThemedBreadcrumbs = styled(EuiBreadcrumbs)<{ background: string; text: string }>` - &.euiBreadcrumbs { - background-color: ${(props) => props.background}; - color: ${(props) => props.text}; - padding: 1em; - border-radius: 5px; - } - - & .euiBreadcrumbSeparator { - background: ${(props) => props.text}; - } -`; - -const betaBadgeLabel = i18n.translate( - 'xpack.securitySolution.enpdoint.resolver.panelutils.betaBadgeLabel', - { - defaultMessage: 'BETA', - } -); - /** * A component that renders an element with breaking opportunities (``s) * spliced into text children at word boundaries. @@ -85,31 +60,6 @@ export const StyledTime = memo(styled('time')` text-align: start; `); -type Breadcrumbs = Parameters[0]['breadcrumbs']; -/** - * Breadcrumb menu with adjustments per direction from UX team - */ -export const StyledBreadcrumbs = memo(function StyledBreadcrumbs({ - breadcrumbs, -}: { - breadcrumbs: Breadcrumbs; -}) { - const { resolverBreadcrumbBackground, resolverEdgeText } = useColors(); - return ( - <> - - - - - - ); -}); - /** * Long formatter (to second) for DateTime */ @@ -122,12 +72,6 @@ export const formatter = new Intl.DateTimeFormat(i18n.getLocale(), { second: '2-digit', }); -const invalidDateText = i18n.translate( - 'xpack.securitySolution.enpdoint.resolver.panelutils.invaliddate', - { - defaultMessage: 'Invalid Date', - } -); /** * @returns {string} A nicely formatted string for a date */ @@ -140,6 +84,8 @@ export function formatDate( if (isFinite(date.getTime())) { return formatter.format(date); } else { - return invalidDateText; + return i18n.translate('xpack.securitySolution.enpdoint.resolver.panelutils.invaliddate', { + defaultMessage: 'Invalid Date', + }); } } diff --git a/x-pack/plugins/security_solution/public/resolver/view/panels/panel_loading.tsx b/x-pack/plugins/security_solution/public/resolver/view/panels/panel_loading.tsx index 864990e4d96a..2de0bf5d320e 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/panels/panel_loading.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/panels/panel_loading.tsx @@ -5,13 +5,10 @@ */ import React, { useMemo } from 'react'; -import { useSelector } from 'react-redux'; import { i18n } from '@kbn/i18n'; import { EuiSpacer, EuiTitle } from '@elastic/eui'; -import * as selectors from '../../store/selectors'; -import { StyledBreadcrumbs } from './panel_content_utilities'; -import { useNavigateOrReplace } from '../use_navigate_or_replace'; -import { ResolverState } from '../../types'; +import { Breadcrumbs } from './breadcrumbs'; +import { useLinkProps } from '../use_link_props'; export function PanelLoading() { const waitingString = i18n.translate( @@ -26,11 +23,8 @@ export function PanelLoading() { defaultMessage: 'Events', } ); - const nodesHref = useSelector((state: ResolverState) => - selectors.relativeHref(state)({ panelView: 'nodes' }) - ); - const nodesLinkNavProps = useNavigateOrReplace({ - search: nodesHref, + const nodesLinkNavProps = useLinkProps({ + panelView: 'nodes', }); const waitCrumbs = useMemo(() => { return [ @@ -42,7 +36,7 @@ export function PanelLoading() { }, [nodesLinkNavProps, eventsString]); return ( <> - +

{waitingString}

diff --git a/x-pack/plugins/security_solution/public/resolver/view/panels/styles.tsx b/x-pack/plugins/security_solution/public/resolver/view/panels/styles.tsx index c5d5ae53a558..09a25ac125a2 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/panels/styles.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/panels/styles.tsx @@ -3,6 +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. */ + +/* eslint-disable no-duplicate-imports */ + +import { EuiBreadcrumbs } from '@elastic/eui'; + import styled from 'styled-components'; import { EuiDescriptionList } from '@elastic/eui'; @@ -15,3 +20,48 @@ export const StyledDescriptionList = styled(EuiDescriptionList)` export const StyledTitle = styled('h4')` overflow-wrap: break-word; `; + +export const BetaHeader = styled(`header`)` + margin-bottom: 1em; +`; + +export const ThemedBreadcrumbs = styled(EuiBreadcrumbs)<{ background: string; text: string }>` + &.euiBreadcrumbs { + background-color: ${(props) => props.background}; + color: ${(props) => props.text}; + padding: 1em; + border-radius: 5px; + } + + & .euiBreadcrumbSeparator { + background: ${(props) => props.text}; + } +`; + +export const StyledButtonTextContainer = styled.div` + align-items: center; + display: flex; + flex-direction: row; +`; + +export const StyledAnalyzedEvent = styled.div` + color: ${(props) => props.color}; + font-size: 10.5px; + font-weight: 700; +`; + +export const StyledLabelTitle = styled.div``; + +export const StyledLabelContainer = styled.div` + display: inline-block; + flex: 3; + min-width: 0; + + ${StyledAnalyzedEvent}, + ${StyledLabelTitle} { + overflow: hidden; + text-align: left; + text-overflow: ellipsis; + white-space: nowrap; + } +`; 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 65ec395080f8..4d647760edb9 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,15 +12,15 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { NodeSubMenu } from './submenu'; import { applyMatrix3 } from '../models/vector2'; import { Vector2, Matrix3, ResolverState } from '../types'; -import { ResolverEvent, SafeResolverEvent } from '../../../common/endpoint/types'; +import { SafeResolverEvent } from '../../../common/endpoint/types'; import { useResolverDispatch } from './use_resolver_dispatch'; import * as eventModel from '../../../common/endpoint/models/event'; import * as selectors from '../store/selectors'; -import { useNavigateOrReplace } from './use_navigate_or_replace'; import { fontSize } from './font_size'; import { useCubeAssets } from './use_cube_assets'; import { useSymbolIDs } from './use_symbol_ids'; import { useColors } from './use_colors'; +import { useLinkProps } from './use_link_props'; interface StyledActionsContainer { readonly color: string; @@ -192,7 +192,6 @@ const UnstyledProcessEventDot = React.memo( /** * Type in non-SVG components scales as follows: - * (These values were adjusted to match the proportions in the comps provided by UX/Design) * 18.75 : The smallest readable font size at which labels/descriptions can be read. Font size will not scale below this. * 12.5 : A 'slope' at which the font size will scale w.r.t. to zoom level otherwise */ @@ -239,15 +238,10 @@ const UnstyledProcessEventDot = React.memo( const isOrigin = nodeID === originID; const dispatch = useResolverDispatch(); - const processDetailHref = useSelector((state: ResolverState) => - selectors.relativeHref(state)({ - panelView: 'nodeDetail', - panelParameters: { nodeID }, - }) - ); - const processDetailNavProps = useNavigateOrReplace({ - search: processDetailHref, + const processDetailNavProps = useLinkProps({ + panelView: 'nodeDetail', + panelParameters: { nodeID }, }); const handleFocus = useCallback(() => { @@ -272,7 +266,7 @@ const UnstyledProcessEventDot = React.memo( ); const grandTotal: number | null = useSelector((state: ResolverState) => - selectors.relatedEventTotalForProcess(state)(event as ResolverEvent) + selectors.relatedEventTotalForProcess(state)(event) ); /* eslint-disable jsx-a11y/click-events-have-key-events */ @@ -376,12 +370,13 @@ const UnstyledProcessEventDot = React.memo( backgroundColor={colorMap.resolverBackground} color={colorMap.descriptionText} isDisplaying={isShowingDescriptionText} + data-test-subj="resolver:node:description" > diff --git a/x-pack/plugins/security_solution/public/resolver/view/styles.tsx b/x-pack/plugins/security_solution/public/resolver/view/styles.tsx index fb4d4d289d25..7def5d3362d4 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/styles.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/styles.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiPanel } from '@elastic/eui'; +import { EuiPanel, EuiCallOut } from '@elastic/eui'; import styled from 'styled-components'; @@ -62,3 +62,26 @@ export const GraphContainer = styled.div` flex-grow: 1; contain: layout; `; + +/** + * See `RelatedEventLimitWarning` + */ +export const LimitWarningsEuiCallOut = styled(EuiCallOut)` + flex-flow: row wrap; + display: block; + align-items: baseline; + margin-top: 1em; + + & .euiCallOutHeader { + display: inline; + margin-right: 0.25em; + } + + & .euiText { + display: inline; + } + + & .euiText p { + display: inline; + } +`; diff --git a/x-pack/plugins/security_solution/public/resolver/view/use_camera.test.tsx b/x-pack/plugins/security_solution/public/resolver/view/use_camera.test.tsx index 495cd238d22f..5406b444cee5 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/use_camera.test.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/use_camera.test.tsx @@ -11,7 +11,7 @@ import { useCamera, useAutoUpdatingClientRect } from './use_camera'; import { Provider } from 'react-redux'; import * as selectors from '../store/selectors'; import { Matrix3, ResolverStore, SideEffectSimulator } from '../types'; -import { ResolverEvent } from '../../../common/endpoint/types'; +import { SafeResolverEvent } from '../../../common/endpoint/types'; import { SideEffectContext } from './side_effect_context'; import { applyMatrix3 } from '../models/vector2'; import { sideEffectSimulatorFactory } from './side_effect_simulator_factory'; @@ -33,7 +33,7 @@ describe('useCamera on an unpainted element', () => { beforeEach(async () => { store = createStore(resolverReducer); - const Test = function Test() { + const Test = function () { const camera = useCamera(); const { ref, onMouseDown } = camera; projectionMatrix = camera.projectionMatrix; @@ -160,9 +160,9 @@ describe('useCamera on an unpainted element', () => { expect(simulator.mock.requestAnimationFrame).not.toHaveBeenCalled(); }); describe('when the camera begins animation', () => { - let process: ResolverEvent; + let process: SafeResolverEvent; beforeEach(() => { - const events: ResolverEvent[] = []; + const events: SafeResolverEvent[] = []; const numberOfEvents: number = 10; for (let index = 0; index < numberOfEvents; index++) { @@ -190,9 +190,9 @@ describe('useCamera on an unpainted element', () => { } else { throw new Error('failed to create tree'); } - const processes: ResolverEvent[] = [ + const processes: SafeResolverEvent[] = [ ...selectors.layout(store.getState()).processNodePositions.keys(), - ] as ResolverEvent[]; + ]; process = processes[processes.length - 1]; if (!process) { throw new Error('missing the process to bring into view'); diff --git a/x-pack/plugins/security_solution/public/resolver/view/use_link_props.ts b/x-pack/plugins/security_solution/public/resolver/view/use_link_props.ts new file mode 100644 index 000000000000..5645edec7e1c --- /dev/null +++ b/x-pack/plugins/security_solution/public/resolver/view/use_link_props.ts @@ -0,0 +1,32 @@ +/* + * 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 { useSelector } from 'react-redux'; +import { MouseEventHandler } from 'react'; +import { useNavigateOrReplace } from './use_navigate_or_replace'; + +import * as selectors from '../store/selectors'; +import { PanelViewAndParameters, ResolverState } from '../types'; + +type EventHandlerCallback = MouseEventHandler; + +/** + * Get an `onClick` function and an `href` string. Use these as props for `` elements. + * `onClick` will use navigate to the `panelViewAndParameters` using `history.push`. + * the `href` points to `panelViewAndParameters`. + * Existing `search` parameters are maintained. + */ +export function useLinkProps( + panelViewAndParameters: PanelViewAndParameters +): { href: string; onClick: EventHandlerCallback } { + const search = useSelector((state: ResolverState) => + selectors.relativeHref(state)(panelViewAndParameters) + ); + + return useNavigateOrReplace({ + search, + }); +} diff --git a/x-pack/plugins/security_solution/public/resolver/view/use_related_event_by_category_navigation.ts b/x-pack/plugins/security_solution/public/resolver/view/use_related_event_by_category_navigation.ts index f994350132c3..6810837ae031 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/use_related_event_by_category_navigation.ts +++ b/x-pack/plugins/security_solution/public/resolver/view/use_related_event_by_category_navigation.ts @@ -12,7 +12,7 @@ import * as selectors from '../store/selectors'; /** * A hook that takes a nodeID and a record of categories, and returns a function that * navigates to the proper url when called with a category. - * @deprecated + * @deprecated See `useLinkProps` */ export function useRelatedEventByCategoryNavigation({ nodeID, diff --git a/x-pack/plugins/security_solution/public/resolver/view/use_related_event_detail_navigation.ts b/x-pack/plugins/security_solution/public/resolver/view/use_related_event_detail_navigation.ts deleted file mode 100644 index 9fc74a7567c4..000000000000 --- a/x-pack/plugins/security_solution/public/resolver/view/use_related_event_detail_navigation.ts +++ /dev/null @@ -1,40 +0,0 @@ -/* - * 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 { useCallback } from 'react'; -import { useSelector } from 'react-redux'; -import { useHistory } from 'react-router-dom'; -import { ResolverState } from '../types'; -import { ResolverEvent } from '../../../common/endpoint/types'; -import * as selectors from '../store/selectors'; - -/** - * @deprecated - */ -export function useRelatedEventDetailNavigation({ - nodeID, - category, - events, -}: { - nodeID: string; - category: string; - events: ResolverEvent[]; -}) { - const relatedEventDetailUrls = useSelector((state: ResolverState) => - selectors.relatedEventDetailHrefs(state)(category, nodeID, events) - ); - const history = useHistory(); - return useCallback( - (entityID: string | number | undefined) => { - if (entityID !== undefined) { - const urlForEntityID = relatedEventDetailUrls.get(String(entityID)); - if (urlForEntityID !== null && urlForEntityID !== undefined) { - return history.replace({ search: urlForEntityID }); - } - } - }, - [history, relatedEventDetailUrls] - ); -} 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 b796913118c9..5bc911fb075b 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 @@ -9,7 +9,7 @@ import { ILegacyScopedClusterClient } from 'kibana/server'; import { parentEntityIDSafeVersion, entityIDSafeVersion, - getAncestryAsArray, + ancestry, } from '../../../../../common/endpoint/models/event'; import { SafeResolverAncestry, @@ -35,7 +35,8 @@ export class AncestryQueryHandler implements QueryHandler legacyEndpointID: string | undefined, originNode: SafeResolverLifecycleNode | undefined ) { - this.ancestorsToFind = getAncestryAsArray(originNode?.lifecycle[0]).slice(0, levels); + const event = originNode?.lifecycle[0]; + this.ancestorsToFind = (event ? ancestry(event) : []).slice(0, levels); this.query = new LifecycleQuery(indexPattern, legacyEndpointID); // add the origin node to the response if it exists @@ -108,7 +109,7 @@ export class AncestryQueryHandler implements QueryHandler this.levels = this.levels - ancestryNodes.size; // the results come back in ascending order on timestamp so the first entry in the // results should be the further ancestor (most distant grandparent) - this.ancestorsToFind = getAncestryAsArray(results[0]).slice(0, this.levels); + this.ancestorsToFind = ancestry(results[0]).slice(0, this.levels); }; /** diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/utils/children_helper.ts b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/utils/children_helper.ts index f54472141c1d..1a871891b1ed 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/utils/children_helper.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/utils/children_helper.ts @@ -4,12 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { - parentEntityIDSafeVersion, - isProcessRunning, - getAncestryAsArray, - entityIDSafeVersion, -} from '../../../../../common/endpoint/models/event'; +import * as eventModel from '../../../../../common/endpoint/models/event'; import { SafeResolverChildren, SafeResolverChildNode, @@ -72,7 +67,7 @@ export class ChildrenNodesHelper { */ addLifecycleEvents(lifecycle: SafeResolverEvent[]) { for (const event of lifecycle) { - const entityID = entityIDSafeVersion(event); + const entityID = eventModel.entityIDSafeVersion(event); if (entityID) { const cachedChild = this.getOrCreateChildNode(entityID); cachedChild.lifecycle.push(event); @@ -93,19 +88,19 @@ export class ChildrenNodesHelper { const nonLeafNodes: Set = new Set(); const isDistantGrandchild = (event: ChildEvent) => { - const ancestry = getAncestryAsArray(event); + const ancestry = eventModel.ancestry(event); return ancestry.length > 0 && queriedNodes.has(ancestry[ancestry.length - 1]); }; for (const event of startEvents) { - const parentID = parentEntityIDSafeVersion(event); - const entityID = entityIDSafeVersion(event); - if (parentID && entityID && isProcessRunning(event)) { + const parentID = eventModel.parentEntityIDSafeVersion(event); + const entityID = eventModel.entityIDSafeVersion(event); + if (parentID && entityID && eventModel.isProcessRunning(event)) { // don't actually add the start event to the node, because that'll be done in // a different call const childNode = this.getOrCreateChildNode(entityID); - const ancestry = getAncestryAsArray(event); + const ancestry = eventModel.ancestry(event); // This is to handle the following unlikely but possible scenario: // if an alert was generated by the kernel process (parent process of all other processes) then // the direct children of that process would only have an ancestry array of [parent_kernel], a single value in the array. diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 0ff132282034..b7809fa7659b 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -15898,19 +15898,14 @@ "xpack.securitySolution.endpoint.resolver.panel.error.goBack": "このリンクをクリックすると、すべてのプロセスのリストに戻ります。", "xpack.securitySolution.endpoint.resolver.panel.processDescList.events": "イベント", "xpack.securitySolution.endpoint.resolver.panel.processEventCounts.events": "イベント", - "xpack.securitySolution.endpoint.resolver.panel.processEventListByType.eventDescriptiveName": "{descriptor} {subject}", "xpack.securitySolution.endpoint.resolver.panel.processEventListByType.events": "イベント", "xpack.securitySolution.endpoint.resolver.panel.relatedCounts.numberOfEventsInCrumb": "{totalCount}件のイベント", - "xpack.securitySolution.endpoint.resolver.panel.relatedDetail.missing": "関連イベントが見つかりません。", "xpack.securitySolution.endpoint.resolver.panel.relatedDetail.wait": "イベントを待機しています...", "xpack.securitySolution.endpoint.resolver.panel.relatedEventDetail.atTime": "@ {date}", "xpack.securitySolution.endpoint.resolver.panel.relatedEventDetail.categoryAndType": "{category} {eventType}", "xpack.securitySolution.endpoint.resolver.panel.relatedEventDetail.countByCategory": "{count} {category}", "xpack.securitySolution.endpoint.resolver.panel.relatedEventDetail.detailsForProcessName": "詳細:{processName}", - "xpack.securitySolution.endpoint.resolver.panel.relatedEventDetail.eventDescriptiveName": "{descriptor} {subject}", - "xpack.securitySolution.endpoint.resolver.panel.relatedEventDetail.eventDescriptiveNameInTitle": "{descriptor} {subject}", "xpack.securitySolution.endpoint.resolver.panel.relatedEventDetail.events": "イベント", - "xpack.securitySolution.endpoint.resolver.panel.relatedEventDetail.NA": "N/A", "xpack.securitySolution.endpoint.resolver.panel.relatedEventDetail.numberOfEvents": "{totalCount}件のイベント", "xpack.securitySolution.endpoint.resolver.panel.relatedEventList.countByCategory": "{count} {category}", "xpack.securitySolution.endpoint.resolver.panel.relatedEventList.numberOfEvents": "{totalCount}件のイベント", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 683cbdbb6bc8..9d1d3cc28323 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -15908,19 +15908,14 @@ "xpack.securitySolution.endpoint.resolver.panel.error.goBack": "单击此链接以返回到所有进程的列表。", "xpack.securitySolution.endpoint.resolver.panel.processDescList.events": "事件", "xpack.securitySolution.endpoint.resolver.panel.processEventCounts.events": "事件", - "xpack.securitySolution.endpoint.resolver.panel.processEventListByType.eventDescriptiveName": "{descriptor} {subject}", "xpack.securitySolution.endpoint.resolver.panel.processEventListByType.events": "事件", "xpack.securitySolution.endpoint.resolver.panel.relatedCounts.numberOfEventsInCrumb": "{totalCount} 个事件", - "xpack.securitySolution.endpoint.resolver.panel.relatedDetail.missing": "找不到相关事件。", "xpack.securitySolution.endpoint.resolver.panel.relatedDetail.wait": "等候事件......", "xpack.securitySolution.endpoint.resolver.panel.relatedEventDetail.atTime": "@ {date}", "xpack.securitySolution.endpoint.resolver.panel.relatedEventDetail.categoryAndType": "{category} {eventType}", "xpack.securitySolution.endpoint.resolver.panel.relatedEventDetail.countByCategory": "{count} 个{category}", "xpack.securitySolution.endpoint.resolver.panel.relatedEventDetail.detailsForProcessName": "{processName} 的详情", - "xpack.securitySolution.endpoint.resolver.panel.relatedEventDetail.eventDescriptiveName": "{descriptor} {subject}", - "xpack.securitySolution.endpoint.resolver.panel.relatedEventDetail.eventDescriptiveNameInTitle": "{descriptor} {subject}", "xpack.securitySolution.endpoint.resolver.panel.relatedEventDetail.events": "事件", - "xpack.securitySolution.endpoint.resolver.panel.relatedEventDetail.NA": "不可用", "xpack.securitySolution.endpoint.resolver.panel.relatedEventDetail.numberOfEvents": "{totalCount} 个事件", "xpack.securitySolution.endpoint.resolver.panel.relatedEventList.countByCategory": "{count} 个{category}", "xpack.securitySolution.endpoint.resolver.panel.relatedEventList.numberOfEvents": "{totalCount} 个事件", diff --git a/x-pack/test/plugin_functional/plugins/resolver_test/public/applications/resolver_test/index.tsx b/x-pack/test/plugin_functional/plugins/resolver_test/public/applications/resolver_test/index.tsx index f3d1eb60bf1c..d70d46fcbc01 100644 --- a/x-pack/test/plugin_functional/plugins/resolver_test/public/applications/resolver_test/index.tsx +++ b/x-pack/test/plugin_functional/plugins/resolver_test/public/applications/resolver_test/index.tsx @@ -61,12 +61,12 @@ const AppRoot = React.memo( storeFactory, ResolverWithoutProviders, mocks: { - dataAccessLayer: { noAncestorsTwoChildren }, + dataAccessLayer: { noAncestorsTwoChildrenWithRelatedEventsOnOrigin }, }, } = resolverPluginSetup; const dataAccessLayer: DataAccessLayer = useMemo( - () => noAncestorsTwoChildren().dataAccessLayer, - [noAncestorsTwoChildren] + () => noAncestorsTwoChildrenWithRelatedEventsOnOrigin().dataAccessLayer, + [noAncestorsTwoChildrenWithRelatedEventsOnOrigin] ); const store = useMemo(() => {