[Resolver] Refactoring panel view (#77928)

* 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.
This commit is contained in:
Robert Austin 2020-09-23 09:57:41 -04:00 committed by GitHub
parent 0cf3bf2731
commit 35a6a230cd
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
58 changed files with 1696 additions and 1944 deletions

View file

@ -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({

View file

@ -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<string | undefined> {
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> = T extends object ? { [K in keyof T]?: DeepPartial<T[K]> } : 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) };
export function eventKind(event: SafeResolverEvent): string[] {
if (isLegacyEventSafeVersion(event)) {
return [];
} else {
return values(event.event?.kind);
}
// To be somewhat defensive, we'll check for the presence of these.
const partialEvent: DeepPartial<ResolverEvent> = 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) };
}
// Fall back on entityId if we can't fish a more descriptive name out.
return { subject: entityId(event) };
}

View file

@ -183,7 +183,7 @@ export interface ResolverTree {
relatedEvents: Omit<ResolverRelatedEvents, 'entityID'>;
relatedAlerts: Omit<ResolverRelatedAlerts, 'entityID'>;
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;
}

View file

@ -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<ResolverRelatedEvents> {
return Promise.resolve({
entityID,
events: [
mockEndpointEvent({
entityID,
name: 'event',
timestamp: 0,
}),
],
events: [],
nextEvent: null,
});
},

View file

@ -61,7 +61,7 @@ export function noAncestorsTwoChildenInIndexCalledAwesomeIndex(): {
events: [
mockEndpointEvent({
entityID,
name: 'event',
processName: 'event',
timestamp: 0,
}),
],

View file

@ -66,7 +66,7 @@ export function noAncestorsTwoChildrenWithRelatedEventsOnOrigin(): {
entityID,
events,
nextEvent: null,
} as ResolverRelatedEvents);
});
},
/**

View file

@ -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,
},
},
};

View file

@ -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;
};
}

View file

@ -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<string, number> = {};
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);
}

View file

@ -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', () => {

View file

@ -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

View file

@ -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,

View file

@ -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;

View file

@ -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;

View file

@ -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<string, number> };
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<ResolverChildNode> = {};
// 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<string, number>, 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,
};
}

View file

@ -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<DataState, ResolverAction> = (state = initialState, action) => {
@ -44,7 +42,7 @@ export const dataReducer: Reducer<DataState, ResolverAction> = (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<DataState, ResolverAction> = (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<DataState, ResolverAction> = (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;

View file

@ -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();
}
});

View file

@ -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<string, ResolverEvent> = new Map();
const events: Map<string, SafeResolverEvent> = 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<string, ResolverRe
}
/**
* A helper function to turn objects into EuiDescriptionList entries.
* This reflects the strategy of more or less "dumping" metadata for related processes
* in description lists with little/no 'prettification'. This has the obvious drawback of
* data perhaps appearing inscrutable/daunting, but the benefit of presenting these fields
* to the user "as they occur" in ECS, which may help them with e.g. EQL queries.
*
* Given an object like: {a:{b: 1}, c: 'd'} it will yield title/description entries like so:
* {title: "a.b", description: "1"}, {title: "c", description: "d"}
*
* @param {object} obj The object to turn into `<dt><dd>` 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<string, Map<string | number, SafeResolverEvent>>();
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, [], ''];
// 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<string | number, SafeResolverEvent>();
for (const event of eventsWrapper.events) {
const id = eventModel.eventIDSafeVersion(event);
if (id !== undefined) {
map.set(id, event);
}
}
memo.set(nodeID, map);
}
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: [] };
}
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];
});
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<string, Map<string, SafeResolverEvent[]>> = 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<string, boolean> {
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));
return (event: SafeResolverEvent) => {
const nodeID = eventModel.entityIDSafeVersion(event);
if (nodeID === undefined) {
return null;
}
const stats = statsForProcess(nodeID);
if (!stats) {
return null;
}
let total = 0;
for (const value of Object.values(stats.events.byCategory)) {
total += value;
}
return total;
return stats.events.total;
};
}
);

View file

@ -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,

View file

@ -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,

View file

@ -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<S = ResolverState> = (
dataAccessLayer: DataAccessLayer
@ -25,33 +26,12 @@ type MiddlewareFactory<S = ResolverState> = (
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();
};
};
};

View file

@ -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<Dispatch<ResolverAction>, 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,
});
}
}
};
}

View file

@ -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<ResolverUIState, ResolverAction> = (
state = {
@ -37,17 +37,18 @@ const uiReducer: Reducer<ResolverUIState, ResolverAction> = (
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<ResolverState, ResolverAction> = (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;

View file

@ -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,11 +124,18 @@ 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
);
export const relatedEventCountByType: (
state: ResolverState
) => (nodeID: string, eventType: string) => number | undefined = composeSelectors(
dataStateSelector,
dataSelectors.relatedEventCountByType
);
/**
@ -135,16 +147,6 @@ export const relatedEventsByEntityId = composeSelectors(
dataSelectors.relatedEventsByEntityId
);
/**
* 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 = composeSelectors(
dataStateSelector,
dataSelectors.relatedEventDisplayInfoByEntityAndSelfID
);
/**
* Returns a function that returns a function (when supplied with an entity id for a node)
* that returns related events for a node that match an event.category (when supplied with the category)
@ -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)
*/

View file

@ -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<string, string | undefined> = createSelector(relativeHref, (relativeHref) => {
return (category: string, nodeID: string, events: ResolverEvent[]) => {
return (category: string, nodeID: string, events: SafeResolverEvent[]) => {
const hrefsByEntityID = new Map<string, string | undefined>();
events.map((event) => {
const entityID = String(eventId(event));
const entityID = String(eventID(event));
const eventDetailPanelParams: PanelViewAndParameters = {
panelView: 'eventDetail',
panelParameters: {

View file

@ -211,9 +211,8 @@ export interface TreeFetcherParameters {
*/
export interface DataState {
readonly relatedEvents: Map<string, ResolverRelatedEvents>;
readonly relatedEventsReady: Map<string, boolean>;
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 };
};
};
}

View file

@ -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<StyledElapsedTime>`
/**
* 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;

View file

@ -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<StyledGraphControls>`
/**
* 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;

View file

@ -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 = (
<FormattedMessage
@ -15,11 +17,7 @@ const lineageLimitMessage = (
/>
);
const LineageTitleMessage = React.memo(function LineageTitleMessage({
numberOfEntries,
}: {
numberOfEntries: number;
}) {
const LineageTitleMessage = React.memo(function ({ numberOfEntries }: { numberOfEntries: number }) {
return (
<FormattedMessage
id="xpack.securitySolution.endpoint.resolver.relatedEventLimitTitle"
@ -29,7 +27,7 @@ const LineageTitleMessage = React.memo(function LineageTitleMessage({
);
});
const RelatedEventsLimitMessage = React.memo(function RelatedEventsLimitMessage({
const RelatedEventsLimitMessage = React.memo(function ({
category,
numberOfEventsMissing,
}: {
@ -45,7 +43,7 @@ const RelatedEventsLimitMessage = React.memo(function RelatedEventsLimitMessage(
);
});
const RelatedLimitTitleMessage = React.memo(function RelatedLimitTitleMessage({
const RelatedLimitTitleMessage = React.memo(function ({
category,
numberOfEventsDisplayed,
}: {
@ -64,13 +62,11 @@ const RelatedLimitTitleMessage = React.memo(function RelatedLimitTitleMessage({
/**
* Limit warning for hitting the /events API limit
*/
export const RelatedEventLimitWarning = React.memo(function RelatedEventLimitWarning({
className,
export const RelatedEventLimitWarning = React.memo(function ({
eventType,
numberActuallyDisplayed,
numberMissing,
}: {
className?: string;
eventType: string;
numberActuallyDisplayed: number;
numberMissing: number;
@ -79,9 +75,8 @@ export const RelatedEventLimitWarning = React.memo(function RelatedEventLimitWar
* Based on API limits, all related events may not be displayed.
*/
return (
<EuiCallOut
<LimitWarningsEuiCallOut
size="s"
className={className}
title={
<RelatedLimitTitleMessage
category={eventType}
@ -92,27 +87,20 @@ export const RelatedEventLimitWarning = React.memo(function RelatedEventLimitWar
<p>
<RelatedEventsLimitMessage category={eventType} numberOfEventsMissing={numberMissing} />
</p>
</EuiCallOut>
</LimitWarningsEuiCallOut>
);
});
/**
* 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 (
<EuiCallOut
<LimitWarningsEuiCallOut
size="s"
className={className}
title={<LineageTitleMessage numberOfEntries={numberDisplayed} />}
>
<p>{lineageLimitMessage}</p>
</EuiCallOut>
</LimitWarningsEuiCallOut>
);
});

View file

@ -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']);
});
});

View file

@ -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(

View file

@ -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 (
<>
<BetaHeader>
<EuiBetaBadge
label={i18n.translate(
'xpack.securitySolution.enpdoint.resolver.panelutils.betaBadgeLabel',
{
defaultMessage: 'BETA',
}
)}
/>
</BetaHeader>
<ThemedBreadcrumbs
background={resolverBreadcrumbBackground}
text={resolverEdgeText}
breadcrumbs={breadcrumbs}
truncate={false}
/>
</>
);
});

View file

@ -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}
>

View file

@ -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<keyof any>, 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);
});
});
});

View file

@ -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<string, unknown>)[key],
}))
);
} else if (value !== undefined && value !== null) {
// emit other non-null, defined, values
result.push([path, value]);
}
}
return result;
}

View file

@ -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(
<I18nProvider>
<DescriptiveName event={event} />
</I18nProvider>
);
});
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);
});
});

View file

@ -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 (
<FormattedMessage
id="xpack.securitySolution.resolver.eventDescription.legacyEventLabel"
defaultMessage="{ processName }"
values={{ processName: processNameSafeVersion(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 (event.network?.forwarded_ip) {
return (
<FormattedMessage
id="xpack.securitySolution.resolver.eventDescription.networkEventLabel"
defaultMessage="{ networkDirection } { forwardedIP }"
values={{
forwardedIP: String(event.network?.forwarded_ip),
networkDirection: String(event.network?.direction),
}}
/>
);
}
if (event.file?.path) {
return (
<FormattedMessage
id="xpack.securitySolution.resolver.eventDescription.fileEventLabel"
defaultMessage="{ filePath }"
values={{
filePath: String(event.file?.path),
}}
/>
);
}
if (event.registry?.path) {
return (
<FormattedMessage
id="xpack.securitySolution.resolver.eventDescription.registryPathLabel"
defaultMessage="{ registryPath }"
values={{
registryPath: String(event.registry?.path),
}}
/>
);
}
if (event.registry?.key) {
return (
<FormattedMessage
id="xpack.securitySolution.resolver.eventDescription.registryKeyLabel"
defaultMessage="{ registryKey }"
values={{
registryKey: String(event.registry?.key),
}}
/>
);
}
if (event.dns?.question?.name) {
return (
<FormattedMessage
id="xpack.securitySolution.resolver.eventDescription.dnsQuestionNameLabel"
defaultMessage="{ dnsQuestionName }"
values={{
dnsQuestionName: String(event.dns?.question?.name),
}}
/>
);
}
return (
<FormattedMessage
id="xpack.securitySolution.resolver.eventDescription.entityIDLabel"
defaultMessage="{ entityID }"
values={{
entityID: entityIDSafeVersion(event),
}}
/>
);
}

View file

@ -4,23 +4,269 @@
* 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';
import { DescriptiveName } from './descriptive_name';
import { useLinkProps } from '../use_link_props';
import { SafeResolverEvent } from '../../../../common/endpoint/types';
import { deepObjectEntries } from './deep_object_entries';
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 (
<EventDetailContents
nodeID={nodeID}
event={event}
processEvent={processEvent}
eventType={eventType}
/>
);
} else {
return (
<StyledPanel>
<PanelLoading />
</StyledPanel>
);
}
});
/**
* 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
*/
const EventDetailContents = memo(function ({
nodeID,
event,
eventType,
processEvent,
}: {
nodeID: string;
event: SafeResolverEvent;
/**
* Event type to use in the breadcrumbs
*/
eventType: string;
processEvent: SafeResolverEvent;
}) {
const formattedDate = useMemo(() => {
const timestamp = eventModel.timestampSafeVersion(event);
if (timestamp !== undefined) {
return formatDate(new Date(timestamp));
}
}, [event]);
return (
<StyledPanel>
<EventDetailBreadcrumbs
nodeID={nodeID}
nodeName={eventModel.processNameSafeVersion(processEvent)}
event={event}
breadcrumbEventCategory={eventType}
/>
<EuiSpacer size="l" />
<EuiText size="s">
<BoldCode>
<FormattedMessage
id="xpack.securitySolution.endpoint.resolver.panel.relatedEventDetail.categoryAndType"
values={{
category: eventType,
eventType: String(eventModel.eventType(event)),
}}
defaultMessage="{category} {eventType}"
/>
</BoldCode>
<StyledTime dateTime={formattedDate}>
<FormattedMessage
id="xpack.securitySolution.endpoint.resolver.panel.relatedEventDetail.atTime"
values={{ date: formattedDate }}
defaultMessage="@ {date}"
/>
</StyledTime>
</EuiText>
<EuiSpacer size="m" />
<StyledDescriptiveName>
<GeneratedText>
<DescriptiveName event={event} />
</GeneratedText>
</StyledDescriptiveName>
<EuiSpacer size="l" />
<EventDetailFields event={event} />
</StyledPanel>
);
});
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: <GeneratedText>{key}</GeneratedText>,
descriptions: deepObjectEntries(value).map(([path, fieldValue]) => ({
title: <GeneratedText>{path.join('.')}</GeneratedText>,
description: <GeneratedText>{String(fieldValue)}</GeneratedText>,
})),
};
returnValue.push(section);
}
return returnValue;
}, [event]);
return (
<>
{sections.map(({ namespace, descriptions }, index) => {
return (
<Fragment key={index}>
{index === 0 ? null : <EuiSpacer size="m" />}
<EuiTitle size="xxxs">
<EuiTextColor color="subdued">
<StyledFlexTitle>
{namespace}
<TitleHr />
</StyledFlexTitle>
</EuiTextColor>
</EuiTitle>
<EuiSpacer size="m" />
<StyledDescriptionList
type="column"
align="left"
titleProps={{ className: 'desc-title' }}
compressed
listItems={descriptions}
/>
{index === sections.length - 1 ? null : <EuiSpacer size="m" />}
</Fragment>
);
})}
</>
);
}
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: (
<FormattedMessage
id="xpack.securitySolution.endpoint.resolver.panel.relatedEventDetail.numberOfEvents"
values={{ totalCount: relatedEventCount }}
defaultMessage="{totalCount} Events"
/>
),
...nodeEventsLinkNavProps,
},
{
text: (
<FormattedMessage
id="xpack.securitySolution.endpoint.resolver.panel.relatedEventDetail.countByCategory"
values={{ count: countByCategory, category: breadcrumbEventCategory }}
defaultMessage="{count} {category}"
/>
),
...nodeEventsOfTypeLinkNavProps,
},
{
text: <DescriptiveName event={event} />,
},
];
}, [
breadcrumbEventCategory,
countByCategory,
event,
nodeDetailLinkNavProps,
nodeEventsLinkNavProps,
nodeName,
relatedEventCount,
nodesLinkNavProps,
nodeEventsOfTypeLinkNavProps,
]);
return <Breadcrumbs breadcrumbs={breadcrumbs} />;
}
// 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;
@ -38,7 +284,6 @@ const StyledDescriptiveName = memo(styled(EuiText)`
overflow-wrap: break-word;
`);
// Styling subtitles, per UX review:
const StyledFlexTitle = memo(styled('h3')`
display: flex;
flex-flow: row;
@ -57,270 +302,3 @@ const TitleHr = memo(() => {
<StyledTitleRule className="euiHorizontalRule euiHorizontalRule--full euiHorizontalRule--marginSmall override" />
);
});
TitleHr.displayName = 'TitleHR';
/**
* Take description list entries and prepare them for display by
* seeding with `<wbr />` tags.
*
* @param entries {title: string, description: string}[]
*/
function entriesForDisplay(entries: Array<{ title: string; description: string }>) {
return entries.map((entry) => {
return {
description: <GeneratedText>{entry.description}</GeneratedText>,
title: <GeneratedText>{entry.title}</GeneratedText>,
};
});
}
/**
* 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 ({
nodeID,
eventID,
}: {
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,
});
/**
* If we don't have the related events for the parent yet, use this effect
* to request them.
*/
useEffect(() => {
if (
typeof relatedsReady === 'undefined' &&
processEntityId !== null &&
processEntityId !== undefined
) {
dispatch({
type: 'appDetectedMissingEventData',
payload: processEntityId,
});
}
}, [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: (
<>
<FormattedMessage
id="xpack.securitySolution.endpoint.resolver.panel.relatedEventDetail.numberOfEvents"
values={{ totalCount }}
defaultMessage="{totalCount} Events"
/>
</>
),
...nodeEventsLinkNavProps,
},
{
text: (
<>
<FormattedMessage
id="xpack.securitySolution.endpoint.resolver.panel.relatedEventDetail.countByCategory"
values={{ count: countBySameCategory, category: relatedEventCategory }}
defaultMessage="{count} {category}"
/>
</>
),
...nodeEventsOfTypeLinkNavProps,
},
{
text: relatedEventToShowDetailsFor ? (
<FormattedMessage
id="xpack.securitySolution.endpoint.resolver.panel.relatedEventDetail.eventDescriptiveName"
values={{ subject, descriptor }}
defaultMessage="{descriptor} {subject}"
/>
) : (
naString
),
onClick: () => {},
},
];
}, [
processName,
eventsString,
totalCount,
countBySameCategory,
naString,
relatedEventCategory,
relatedEventToShowDetailsFor,
subject,
descriptor,
nodeEventsOfTypeLinkNavProps,
nodeEventsLinkNavProps,
nodeDetailLinkNavProps,
nodesLinkNavProps,
]);
if (!relatedsReady) {
return <PanelLoading />;
}
/**
* 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 <PanelContentError translatedErrorMessage={errString} />;
}
return (
<StyledPanel>
<StyledBreadcrumbs breadcrumbs={crumbs} />
<EuiSpacer size="l" />
<EuiText size="s">
<BoldCode>
<FormattedMessage
id="xpack.securitySolution.endpoint.resolver.panel.relatedEventDetail.categoryAndType"
values={{
category: relatedEventCategory,
eventType: String(event.ecsEventType(relatedEventToShowDetailsFor)),
}}
defaultMessage="{category} {eventType}"
/>
</BoldCode>
<StyledTime dateTime={formattedDate}>
<FormattedMessage
id="xpack.securitySolution.endpoint.resolver.panel.relatedEventDetail.atTime"
values={{ date: formattedDate }}
defaultMessage="@ {date}"
/>
</StyledTime>
</EuiText>
<EuiSpacer size="m" />
<StyledDescriptiveName>
<GeneratedText>
<FormattedMessage
id="xpack.securitySolution.endpoint.resolver.panel.relatedEventDetail.eventDescriptiveNameInTitle"
values={{ subject, descriptor }}
defaultMessage="{descriptor} {subject}"
/>
</GeneratedText>
</StyledDescriptiveName>
<EuiSpacer size="l" />
{sections.map(({ sectionTitle, entries }, index) => {
const displayEntries = entriesForDisplay(entries);
return (
<Fragment key={index}>
{index === 0 ? null : <EuiSpacer size="m" />}
<EuiTitle size="xxxs">
<EuiTextColor color="subdued">
<StyledFlexTitle>
{sectionTitle}
<TitleHr />
</StyledFlexTitle>
</EuiTextColor>
</EuiTitle>
<EuiSpacer size="m" />
<StyledDescriptionList
type="column"
align="left"
titleProps={{ className: 'desc-title' }}
compressed
listItems={displayEntries}
/>
{index === sections.length - 1 ? null : <EuiSpacer size="m" />}
</Fragment>
);
})}
</StyledPanel>
);
});

View file

@ -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 () {
<EventDetail
nodeID={params.panelParameters.nodeID}
eventID={params.panelParameters.eventID}
eventType={params.panelParameters.eventType}
/>
);
} else {

View file

@ -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 (
<StyledPanel>
{processEvent === null ? <PanelLoading /> : <NodeDetailView processEvent={processEvent} />}
{processEvent === null ? (
<PanelLoading />
) : (
<NodeDetailView nodeID={nodeID} processEvent={processEvent} />
)}
</StyledPanel>
);
});
@ -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 (
<>
<StyledBreadcrumbs breadcrumbs={crumbs} />
<Breadcrumbs breadcrumbs={crumbs} />
<EuiSpacer size="l" />
<EuiTitle size="xs">
<StyledTitle aria-describedby={titleID}>
@ -201,7 +189,7 @@ const NodeDetailView = memo(function NodeDetailView({
</EuiTextColor>
</EuiText>
<EuiSpacer size="s" />
<EuiLink {...nodeDetailNavProps}>
<EuiLink {...nodeDetailNavProps} data-test-subj="resolver:node-detail:node-events-link">
<FormattedMessage
id="xpack.securitySolution.endpoint.resolver.panel.processDescList.numberOfEvents"
values={{ relatedEventTotal }}

View file

@ -4,19 +4,21 @@
* you may not use this file except in compliance with the Elastic License.
*/
/* eslint-disable react/display-name */
import React, { memo, useMemo } from 'react';
import { i18n } from '@kbn/i18n';
import { EuiBasicTableColumn, EuiButtonEmpty, EuiSpacer, EuiInMemoryTable } from '@elastic/eui';
import { FormattedMessage } from 'react-intl';
import { useSelector } from 'react-redux';
import { StyledBreadcrumbs } from './panel_content_utilities';
import { Breadcrumbs } from './breadcrumbs';
import * as event from '../../../../common/endpoint/models/event';
import { ResolverEvent, ResolverNodeStats } from '../../../../common/endpoint/types';
import { ResolverNodeStats } from '../../../../common/endpoint/types';
import * as selectors from '../../store/selectors';
import { ResolverState } from '../../types';
import { StyledPanel } from '../styles';
import { useNavigateOrReplace } from '../use_navigate_or_replace';
import { PanelLoading } from './panel_loading';
import { useLinkProps } from '../use_link_props';
export function NodeEvents({ nodeID }: { nodeID: string }) {
const processEvent = useSelector((state: ResolverState) =>
@ -26,11 +28,21 @@ export function NodeEvents({ nodeID }: { nodeID: string }) {
selectors.relatedEventsStats(state)(nodeID)
);
if (processEvent === null || relatedEventsStats === undefined) {
return <PanelLoading />;
return (
<StyledPanel>
<PanelLoading />
</StyledPanel>
);
} else {
return (
<StyledPanel>
<EventCountsForProcess processEvent={processEvent} relatedStats={relatedEventsStats} />
<NodeEventsBreadcrumbs
nodeName={event.processNameSafeVersion(processEvent)}
nodeID={nodeID}
totalEventCount={relatedEventsStats.events.total}
/>
<EuiSpacer size="l" />
<EventCategoryLinks nodeID={nodeID} relatedStats={relatedEventsStats} />
</StyledPanel>
);
}
@ -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: (
<FormattedMessage
id="xpack.securitySolution.endpoint.resolver.panel.relatedCounts.numberOfEventsInCrumb"
values={{ totalCount }}
defaultMessage="{totalCount} Events"
/>
),
...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]);
}, [relatedStats.events.byCategory]);
const eventDetailHref = useSelector((state: ResolverState) =>
selectors.relativeHref(state)({
panelView: 'eventDetail',
panelParameters: { nodeID: processEntityId, eventType: name, eventID: processEntityId },
})
);
const eventDetailNavProps = useNavigateOrReplace({
search: eventDetailHref,
});
const columns = useMemo<Array<EuiBasicTableColumn<EventCountsTableView>>>(
() => [
{
@ -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 <EuiButtonEmpty {...eventDetailNavProps}>{name}</EuiButtonEmpty>;
render(eventType: string) {
return (
<NodeEventsLink nodeID={nodeID} eventType={eventType}>
{eventType}
</NodeEventsLink>
);
},
},
],
[eventDetailNavProps]
[nodeID]
);
return <EuiInMemoryTable<EventCountsTableView> items={rows} columns={columns} sorting />;
});
const NodeEventsBreadcrumbs = memo(function ({
nodeID,
nodeName,
totalEventCount,
}: {
nodeID: string;
nodeName: React.ReactNode;
totalEventCount: number;
}) {
return (
<>
<StyledBreadcrumbs breadcrumbs={crumbs} />
<EuiSpacer size="l" />
<EuiInMemoryTable<EventCountsTableView> items={rows} columns={columns} sorting />
</>
<Breadcrumbs
breadcrumbs={[
{
text: i18n.translate(
'xpack.securitySolution.endpoint.resolver.panel.processEventCounts.events',
{
defaultMessage: 'Events',
}
),
...useLinkProps({
panelView: 'nodes',
}),
},
{
text: nodeName,
...useLinkProps({
panelView: 'nodeDetail',
panelParameters: { nodeID },
}),
},
{
text: (
<FormattedMessage
id="xpack.securitySolution.endpoint.resolver.panel.relatedCounts.numberOfEventsInCrumb"
values={{ totalCount: totalEventCount }}
defaultMessage="{totalCount} Events"
/>
),
...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 (
<EuiButtonEmpty data-test-subj="resolver:panel:node-events:event-type-link" {...props}>
{children}
</EuiButtonEmpty>
);
}
);

View file

@ -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<HTMLAnchorElement | HTMLButtonElement, 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 (
<>
<StyledBreadcrumbs breadcrumbs={crumbs} />
{shouldShowLimitWarning && typeof numberDisplayed !== 'undefined' && numberMissing ? (
<StyledRelatedLimitWarning
eventType={eventType}
numberActuallyDisplayed={numberDisplayed}
numberMissing={numberMissing}
/>
) : null}
<EuiSpacer size="l" />
<>
{matchingEventEntries.map((eventView, index) => {
const { subject, descriptor = '' } = eventView.name;
return (
<Fragment key={index}>
<EuiText>
<BoldCode>
<FormattedMessage
id="xpack.securitySolution.endpoint.resolver.panel.relatedEventDetail.categoryAndType"
values={{
category: eventView.eventCategory,
eventType: eventView.eventType,
}}
defaultMessage="{category} {eventType}"
/>
</BoldCode>
<StyledTime dateTime={eventView.formattedDate}>
<FormattedMessage
id="xpack.securitySolution.endpoint.resolver.panel.relatedEventDetail.atTime"
values={{ date: eventView.formattedDate }}
defaultMessage="@ {date}"
/>
</StyledTime>
</EuiText>
<EuiSpacer size="xs" />
<EuiButtonEmpty onClick={eventView.setQueryParams}>
<FormattedMessage
id="xpack.securitySolution.endpoint.resolver.panel.processEventListByType.eventDescriptiveName"
values={{ subject, descriptor }}
defaultMessage="{descriptor} {subject}"
/>
</EuiButtonEmpty>
{index === matchingEventEntries.length - 1 ? null : <EuiHorizontalRule margin="m" />}
</Fragment>
);
})}
</>
</>
);
});
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 (
<StyledPanel>
<NodeEventList
processEvent={processEvent}
eventType={eventType}
relatedStats={relatedEventsStats}
/>
{eventCount === undefined || processEvent === null ? (
<PanelLoading />
) : (
<>
<NodeEventsOfTypeBreadcrumbs
nodeName={eventModel.processNameSafeVersion(processEvent)}
eventType={eventType}
eventCount={eventCount}
nodeID={nodeID}
eventsInCategoryCount={eventsInCategoryCount}
/>
<EuiSpacer size="l" />
<NodeEventList eventType={eventType} nodeID={nodeID} events={events} />
</>
)}
</StyledPanel>
);
}
});
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 (
<>
<EuiText>
<BoldCode>
<FormattedMessage
id="xpack.securitySolution.endpoint.resolver.panel.relatedEventDetail.categoryAndType"
values={{
category: eventModel.eventCategory(event).join(', '),
eventType: eventModel.eventType(event).join(', '),
}}
defaultMessage="{category} {eventType}"
/>
</BoldCode>
<StyledTime dateTime={date}>
<FormattedMessage
id="xpack.securitySolution.endpoint.resolver.panel.relatedEventDetail.atTime"
values={{ date }}
defaultMessage="@ {date}"
/>
</StyledTime>
</EuiText>
<EuiSpacer size="xs" />
<EuiButtonEmpty {...linkProps}>
<DescriptiveName event={event} />
</EuiButtonEmpty>
</>
);
});
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) => (
<Fragment key={index}>
<NodeEventsListItem nodeID={nodeID} eventType={eventType} event={event} />
{index === events.length - 1 ? null : <EuiHorizontalRule margin="m" />}
</Fragment>
))}
</>
);
});
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: (
<FormattedMessage
id="xpack.securitySolution.endpoint.resolver.panel.relatedEventList.numberOfEvents"
values={{ totalCount }}
defaultMessage="{totalCount} Events"
/>
),
...nodeEventsNavProps,
},
{
text: (
<FormattedMessage
id="xpack.securitySolution.endpoint.resolver.panel.relatedEventList.countByCategory"
values={{ count: matchingEventEntries.length, category: eventType }}
defaultMessage="{count} {category}"
/>
),
onClick: () => {},
},
];
}, [
eventType,
eventsString,
matchingEventEntries.length,
processName,
totalCount,
nodeDetailNavProps,
nodesLinkNavProps,
nodeEventsNavProps,
]);
if (!relatedsReady) {
return <PanelLoading />;
}
const nodeEventsNavProps = useLinkProps({
panelView: 'nodeEvents',
panelParameters: { nodeID },
});
return (
<NodeCategoryEntries
crumbs={crumbs}
processEntityId={processEntityId}
matchingEventEntries={matchingEventEntries}
eventType={eventType}
<Breadcrumbs
breadcrumbs={[
{
text: i18n.translate(
'xpack.securitySolution.endpoint.resolver.panel.processEventListByType.events',
{
defaultMessage: 'Events',
}
),
...nodesLinkNavProps,
},
{
text: nodeName,
...nodeDetailNavProps,
},
{
text: (
<FormattedMessage
id="xpack.securitySolution.endpoint.resolver.panel.relatedEventList.numberOfEvents"
values={{ totalCount: eventCount }}
defaultMessage="{totalCount} Events"
/>
),
...nodeEventsNavProps,
},
{
text: (
<FormattedMessage
id="xpack.securitySolution.endpoint.resolver.panel.relatedEventList.countByCategory"
values={{ count: eventsInCategoryCount, category: eventType }}
defaultMessage="{count} {category}"
/>
),
},
]}
/>
);
});

View file

@ -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 <NodeDetailLink name={name} item={item} />;
render(name: string | undefined, item: ProcessTableView) {
return <NodeDetailLink name={name} event={item.event} nodeID={item.nodeID} />;
},
},
{
@ -132,42 +98,26 @@ export const NodeList = memo(() => {
[]
);
const { processNodePositions } = useSelector(selectors.layout);
const nodeHrefs: Map<SafeResolverEvent, string | null | undefined> = 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 (
<StyledPanel>
<StyledBreadcrumbs breadcrumbs={crumbs} />
{showWarning && <StyledLimitWarning numberDisplayed={numberOfProcesses} />}
<Breadcrumbs breadcrumbs={crumbs} />
{showWarning && <LimitWarning numberDisplayed={numberOfProcesses} />}
<EuiSpacer size="l" />
<EuiInMemoryTable<ProcessTableView>
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<HTMLAnchorElement>) => {
linkProps.onClick(mouseEvent);
dispatch({
type: 'userBroughtProcessIntoView',
payload: {
process: event,
time: timestamp(),
},
});
},
[timestamp, linkProps, dispatch, event]
);
return (
<EuiButtonEmpty {...useNavigateOrReplace({ search: item.href })}>
<EuiButtonEmpty onClick={handleOnClick} href={linkProps.href}>
{name === '' ? (
<EuiBadge color="warning">
{i18n.translate(

View file

@ -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 (
<>
<StyledBreadcrumbs breadcrumbs={crumbs} />
<Breadcrumbs breadcrumbs={crumbs} />
<EuiSpacer size="l" />
<EuiText textAlign="center">{translatedErrorMessage}</EuiText>
<EuiSpacer size="l" />
@ -60,4 +54,3 @@ export const PanelContentError = memo(function ({
</>
);
});
PanelContentError.displayName = 'TableServiceError';

View file

@ -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 (`<wbr>`s)
* spliced into text children at word boundaries.
@ -85,31 +60,6 @@ export const StyledTime = memo(styled('time')`
text-align: start;
`);
type Breadcrumbs = Parameters<typeof EuiBreadcrumbs>[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 (
<>
<BetaHeader>
<EuiBetaBadge label={betaBadgeLabel} />
</BetaHeader>
<ThemedBreadcrumbs
background={resolverBreadcrumbBackground}
text={resolverEdgeText}
breadcrumbs={breadcrumbs}
truncate={false}
/>
</>
);
});
/**
* 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',
});
}
}

View file

@ -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 (
<>
<StyledBreadcrumbs breadcrumbs={waitCrumbs} />
<Breadcrumbs breadcrumbs={waitCrumbs} />
<EuiSpacer size="l" />
<EuiTitle>
<h4>{waitingString}</h4>

View file

@ -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;
}
`;

View file

@ -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"
>
<FormattedMessage
id="xpack.securitySolution.endpoint.resolver.processDescription"
defaultMessage="{originText}{descriptionText}"
defaultMessage="{isEventBeingAnalyzed, select, true {Analyzed Event · {descriptionText}} false {{descriptionText}}}"
values={{
originText: isOrigin ? 'Analyzed Event · ' : '',
isEventBeingAnalyzed: isOrigin,
descriptionText,
}}
/>

View file

@ -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;
}
`;

View file

@ -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');

View file

@ -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<HTMLButtonElement | HTMLAnchorElement>;
/**
* Get an `onClick` function and an `href` string. Use these as props for `<a />` 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,
});
}

View file

@ -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,

View file

@ -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]
);
}

View file

@ -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<SafeResolverAncestry>
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<SafeResolverAncestry>
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);
};
/**

View file

@ -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<SafeResolverChildNode> = 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.

View file

@ -15878,19 +15878,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}件のイベント",

View file

@ -15888,19 +15888,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} 个事件",

View file

@ -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(() => {