Skip to content

Commit

Permalink
[Resolver] Refactoring panel view (#77928) (#78295)
Browse files Browse the repository at this point in the history
* 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.
  • Loading branch information
Robert Austin authored Sep 23, 2020
1 parent 6aa03b7 commit a72580c
Show file tree
Hide file tree
Showing 58 changed files with 1,668 additions and 1,916 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -4,59 +4,15 @@
* 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;
beforeEach(() => {
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({
Expand Down
180 changes: 78 additions & 102 deletions x-pack/plugins/security_solution/common/endpoint/models/event.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,18 +104,73 @@ 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 {
return event.process.name;
}
}

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

/**
Expand Down Expand Up @@ -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;
Expand All @@ -300,107 +350,33 @@ 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
);
}

/**
* ECS event type will be things like 'creation', 'deletion', 'access', etc.
* 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) };
}

// 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) };
export function eventKind(event: SafeResolverEvent): string[] {
if (isLegacyEventSafeVersion(event)) {
return [];
} else {
return values(event.event?.kind);
}

// Fall back on entityId if we can't fish a more descriptive name out.
return { subject: entityId(event) };
}
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,7 @@ export interface ResolverTree {
relatedEvents: Omit<ResolverRelatedEvents, 'entityID'>;
relatedAlerts: Omit<ResolverRelatedAlerts, 'entityID'>;
ancestry: ResolverAncestry;
lifecycle: ResolverEvent[];
lifecycle: SafeResolverEvent[];
stats: ResolverNodeStats;
}

Expand All @@ -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
*/
Expand Down Expand Up @@ -263,7 +263,7 @@ export interface SafeResolverAncestry {
*/
export interface ResolverRelatedEvents {
entityID: string;
events: ResolverEvent[];
events: SafeResolverEvent[];
nextEvent: string | null;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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,
});
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ export function noAncestorsTwoChildenInIndexCalledAwesomeIndex(): {
events: [
mockEndpointEvent({
entityID,
name: 'event',
processName: 'event',
timestamp: 0,
}),
],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ export function noAncestorsTwoChildrenWithRelatedEventsOnOrigin(): {
entityID,
events,
nextEvent: null,
} as ResolverRelatedEvents);
});
},

/**
Expand Down
4 changes: 2 additions & 2 deletions x-pack/plugins/security_solution/public/resolver/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -23,7 +23,7 @@ export function resolverPluginSetup(): ResolverPluginSetup {
ResolverWithoutProviders,
mocks: {
dataAccessLayer: {
noAncestorsTwoChildren,
noAncestorsTwoChildrenWithRelatedEventsOnOrigin,
},
},
};
Expand Down
Loading

0 comments on commit a72580c

Please sign in to comment.