diff --git a/x-pack/plugins/endpoint/common/models/event.ts b/x-pack/plugins/endpoint/common/models/event.ts index 47f39d2d117976..192daba4a717d9 100644 --- a/x-pack/plugins/endpoint/common/models/event.ts +++ b/x-pack/plugins/endpoint/common/models/event.ts @@ -3,7 +3,6 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ - import { LegacyEndpointEvent, ResolverEvent } from '../types'; export function isLegacyEvent(event: ResolverEvent): event is LegacyEndpointEvent { @@ -46,3 +45,23 @@ export function parentEntityId(event: ResolverEvent): string | undefined { } return event.process.parent?.entity_id; } + +export function eventType(event: ResolverEvent): string { + // Returning "Process" as a catch-all here because it seems pretty general + let eventCategoryToReturn: string = 'Process'; + if (isLegacyEvent(event)) { + const legacyFullType = event.endgame.event_type_full; + if (legacyFullType) { + return legacyFullType; + } + } else { + const eventCategories = event.event.category; + const eventCategory = + typeof eventCategories === 'string' ? eventCategories : eventCategories[0] || ''; + + if (eventCategory) { + eventCategoryToReturn = eventCategory; + } + } + return eventCategoryToReturn; +} diff --git a/x-pack/plugins/endpoint/public/embeddables/resolver/store/actions.ts b/x-pack/plugins/endpoint/public/embeddables/resolver/store/actions.ts index a26f43e1f8cc08..462f6e251d5d09 100644 --- a/x-pack/plugins/endpoint/public/embeddables/resolver/store/actions.ts +++ b/x-pack/plugins/endpoint/public/embeddables/resolver/store/actions.ts @@ -44,6 +44,15 @@ interface AppRequestedResolverData { readonly type: 'appRequestedResolverData'; } +/** + * The action dispatched when the app requests related event data for one or more + * subjects (whose ids should be included as an array @ `payload`) + */ +interface UserRequestedRelatedEventData { + readonly type: 'userRequestedRelatedEventData'; + readonly payload: ResolverEvent; +} + /** * When the user switches the "active descendant" of the Resolver. * The "active descendant" (from the point of view of the parent element) @@ -77,6 +86,28 @@ interface UserSelectedResolverNode { }; } +/** + * This action should dispatch to indicate that the user chose to + * focus on examining the related events of a particular ResolverEvent. + * Optionally, this can be bound by a category of related events (e.g. 'file' or 'dns') + */ +interface UserSelectedRelatedEventCategory { + readonly type: 'userSelectedRelatedEventCategory'; + readonly payload: { + subject: ResolverEvent; + category?: string; + }; +} + +/** + * This action should dispatch to indicate that the user chose to focus + * on examining alerts related to a particular ResolverEvent + */ +interface UserSelectedRelatedAlerts { + readonly type: 'userSelectedRelatedAlerts'; + readonly payload: ResolverEvent; +} + export type ResolverAction = | CameraAction | DataAction @@ -84,4 +115,7 @@ export type ResolverAction = | UserChangedSelectedEvent | AppRequestedResolverData | UserFocusedOnResolverNode - | UserSelectedResolverNode; + | UserSelectedResolverNode + | UserRequestedRelatedEventData + | UserSelectedRelatedEventCategory + | UserSelectedRelatedAlerts; diff --git a/x-pack/plugins/endpoint/public/embeddables/resolver/store/data/action.ts b/x-pack/plugins/endpoint/public/embeddables/resolver/store/data/action.ts index 3ec15f2f1985da..8c84d8f82b874f 100644 --- a/x-pack/plugins/endpoint/public/embeddables/resolver/store/data/action.ts +++ b/x-pack/plugins/endpoint/public/embeddables/resolver/store/data/action.ts @@ -5,6 +5,7 @@ */ import { ResolverEvent } from '../../../../../common/types'; +import { RelatedEventDataEntry } from '../../types'; interface ServerReturnedResolverData { readonly type: 'serverReturnedResolverData'; @@ -15,4 +16,24 @@ interface ServerFailedToReturnResolverData { readonly type: 'serverFailedToReturnResolverData'; } -export type DataAction = ServerReturnedResolverData | ServerFailedToReturnResolverData; +/** + * Will occur when a request for related event data is fulfilled by the API. + */ +interface ServerReturnedRelatedEventData { + readonly type: 'serverReturnedRelatedEventData'; + readonly payload: Map; +} + +/** + * Will occur when a request for related event data is unsuccessful. + */ +interface ServerFailedToReturnRelatedEventData { + readonly type: 'serverFailedToReturnRelatedEventData'; + readonly payload: ResolverEvent; +} + +export type DataAction = + | ServerReturnedResolverData + | ServerFailedToReturnResolverData + | ServerReturnedRelatedEventData + | ServerFailedToReturnRelatedEventData; diff --git a/x-pack/plugins/endpoint/public/embeddables/resolver/store/data/reducer.ts b/x-pack/plugins/endpoint/public/embeddables/resolver/store/data/reducer.ts index fc307002819a92..9dd6bcdf385ae8 100644 --- a/x-pack/plugins/endpoint/public/embeddables/resolver/store/data/reducer.ts +++ b/x-pack/plugins/endpoint/public/embeddables/resolver/store/data/reducer.ts @@ -12,6 +12,7 @@ function initialState(): DataState { results: [], isLoading: false, hasError: false, + resultsEnrichedWithRelatedEventInfo: new Map(), }; } @@ -23,6 +24,26 @@ export const dataReducer: Reducer = (state = initialS isLoading: false, hasError: false, }; + } else if (action.type === 'userRequestedRelatedEventData') { + const resolverEvent = action.payload; + const currentStatsMap = new Map(state.resultsEnrichedWithRelatedEventInfo); + /** + * Set the waiting indicator for this event to indicate that related event results are pending. + * It will be replaced by the actual results from the API when they are returned. + */ + currentStatsMap.set(resolverEvent, 'waitingForRelatedEventData'); + return { ...state, resultsEnrichedWithRelatedEventInfo: currentStatsMap }; + } else if (action.type === 'serverFailedToReturnRelatedEventData') { + const currentStatsMap = new Map(state.resultsEnrichedWithRelatedEventInfo); + const resolverEvent = action.payload; + currentStatsMap.set(resolverEvent, 'error'); + return { ...state, resultsEnrichedWithRelatedEventInfo: currentStatsMap }; + } else if (action.type === 'serverReturnedRelatedEventData') { + const relatedDataEntries = new Map([ + ...state.resultsEnrichedWithRelatedEventInfo, + ...action.payload, + ]); + return { ...state, resultsEnrichedWithRelatedEventInfo: relatedDataEntries }; } else if (action.type === 'appRequestedResolverData') { return { ...state, diff --git a/x-pack/plugins/endpoint/public/embeddables/resolver/store/data/selectors.test.ts b/x-pack/plugins/endpoint/public/embeddables/resolver/store/data/selectors.test.ts new file mode 100644 index 00000000000000..561b0da12bcb10 --- /dev/null +++ b/x-pack/plugins/endpoint/public/embeddables/resolver/store/data/selectors.test.ts @@ -0,0 +1,59 @@ +/* + * 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 { Store, createStore } from 'redux'; +import { DataAction } from './action'; +import { dataReducer } from './reducer'; +import { + DataState, + RelatedEventDataEntry, + RelatedEventDataEntryWithStats, + RelatedEventData, +} from '../../types'; +import { ResolverEvent } from '../../../../../common/types'; +import { relatedEventStats, relatedEvents } from './selectors'; + +describe('resolver data selectors', () => { + const store: Store = createStore(dataReducer, undefined); + describe('when related event data is reduced into state with no results', () => { + let relatedEventInfoBeforeAction: RelatedEventData; + beforeEach(() => { + relatedEventInfoBeforeAction = new Map(relatedEvents(store.getState()) || []); + const payload: Map = new Map(); + const action: DataAction = { type: 'serverReturnedRelatedEventData', payload }; + store.dispatch(action); + }); + it('should have the same related info as before the action', () => { + const relatedInfoAfterAction = relatedEvents(store.getState()); + expect(relatedInfoAfterAction).toEqual(relatedEventInfoBeforeAction); + }); + }); + describe('when related event data is reduced into state with 2 dns results', () => { + let mockBaseEvent: ResolverEvent; + beforeEach(() => { + mockBaseEvent = {} as ResolverEvent; + function dnsRelatedEventEntry() { + const fakeEvent = {} as ResolverEvent; + return { relatedEvent: fakeEvent, relatedEventType: 'dns' }; + } + const payload: Map = new Map([ + [ + mockBaseEvent, + { + relatedEvents: [dnsRelatedEventEntry(), dnsRelatedEventEntry()], + }, + ], + ]); + const action: DataAction = { type: 'serverReturnedRelatedEventData', payload }; + store.dispatch(action); + }); + it('should compile stats reflecting a count of 2 for dns', () => { + const actualStats = relatedEventStats(store.getState()); + const statsForFakeEvent = actualStats.get(mockBaseEvent)! as RelatedEventDataEntryWithStats; + expect(statsForFakeEvent.stats).toEqual({ dns: 2 }); + }); + }); +}); diff --git a/x-pack/plugins/endpoint/public/embeddables/resolver/store/data/selectors.ts b/x-pack/plugins/endpoint/public/embeddables/resolver/store/data/selectors.ts index 59ee4b3b875055..413f4db1cc99e0 100644 --- a/x-pack/plugins/endpoint/public/embeddables/resolver/store/data/selectors.ts +++ b/x-pack/plugins/endpoint/public/embeddables/resolver/store/data/selectors.ts @@ -14,6 +14,8 @@ import { ProcessWithWidthMetadata, Matrix3, AdjacentProcessMap, + RelatedEventData, + RelatedEventDataEntryWithStats, } from '../../types'; import { ResolverEvent } from '../../../../../common/types'; import { Vector2 } from '../../types'; @@ -405,6 +407,86 @@ export const indexedProcessTree = createSelector(graphableProcesses, function in return indexedProcessTreeFactory(graphableProcesses); }); +/** + * Process events that will be graphed. + */ +export const relatedEventResults = function(data: DataState) { + return data.resultsEnrichedWithRelatedEventInfo; +}; + +/** + * This selector compiles the related event data attached in `relatedEventResults` + * into a `RelatedEventData` map of ResolverEvents to statistics about their related events + */ +export const relatedEventStats = createSelector(relatedEventResults, function getRelatedEvents( + /* eslint-disable no-shadow */ + relatedEventResults + /* eslint-enable no-shadow */ +) { + /* eslint-disable no-shadow */ + const relatedEventStats: RelatedEventData = new Map(); + /* eslint-enable no-shadow */ + if (!relatedEventResults) { + return relatedEventStats; + } + + for (const updatedEvent of relatedEventResults.keys()) { + const newStatsEntry = relatedEventResults.get(updatedEvent); + if (newStatsEntry === 'error') { + // If the entry is an error, return it as is + relatedEventStats.set(updatedEvent, newStatsEntry); + continue; + } + if (typeof newStatsEntry === 'object') { + /** + * Otherwise, it should be a valid stats entry. + * Do the work to compile the stats. + * Folowing reduction, this will be a record like + * {DNS: 10, File: 2} etc. + */ + const statsForEntry = newStatsEntry?.relatedEvents.reduce( + (compiledStats: Record, relatedEvent: { relatedEventType: string }) => { + compiledStats[relatedEvent.relatedEventType] = + (compiledStats[relatedEvent.relatedEventType] || 0) + 1; + return compiledStats; + }, + {} + ); + + const newRelatedEventStats: RelatedEventDataEntryWithStats = Object.assign(newStatsEntry, { + stats: statsForEntry, + }); + relatedEventStats.set(updatedEvent, newRelatedEventStats); + } + } + return relatedEventStats; +}); + +/** + * This selects `RelatedEventData` maps specifically for graphable processes + */ +export const relatedEvents = createSelector( + graphableProcesses, + relatedEventStats, + function getRelatedEvents( + /* eslint-disable no-shadow */ + graphableProcesses, + relatedEventStats + /* eslint-enable no-shadow */ + ) { + const eventsRelatedByProcess: RelatedEventData = new Map(); + /* eslint-disable no-shadow */ + return graphableProcesses.reduce((relatedEvents, graphableProcess) => { + /* eslint-enable no-shadow */ + const relatedEventDataEntry = relatedEventStats?.get(graphableProcess); + if (relatedEventDataEntry) { + relatedEvents.set(graphableProcess, relatedEventDataEntry); + } + return relatedEvents; + }, eventsRelatedByProcess); + } +); + export const processAdjacencies = createSelector( indexedProcessTree, graphableProcesses, diff --git a/x-pack/plugins/endpoint/public/embeddables/resolver/store/middleware.ts b/x-pack/plugins/endpoint/public/embeddables/resolver/store/middleware.ts index c7177c6387e7a9..06758022b05c53 100644 --- a/x-pack/plugins/endpoint/public/embeddables/resolver/store/middleware.ts +++ b/x-pack/plugins/endpoint/public/embeddables/resolver/store/middleware.ts @@ -5,9 +5,10 @@ */ import { Dispatch, MiddlewareAPI } from 'redux'; +import { HttpHandler } from 'kibana/public'; import { KibanaReactContextValue } from '../../../../../../../src/plugins/kibana_react/public'; import { EndpointPluginServices } from '../../../plugin'; -import { ResolverState, ResolverAction } from '../types'; +import { ResolverState, ResolverAction, RelatedEventDataEntry } from '../types'; import { ResolverEvent, ResolverNode } from '../../../../common/types'; import * as event from '../../../../common/models/event'; @@ -30,6 +31,33 @@ function flattenEvents(children: ResolverNode[], events: ResolverEvent[] = []): }, events); } +type RelatedEventAPIResponse = 'error' | { events: ResolverEvent[] }; +/** + * As the design goal of this stopgap was to prevent saturating the server with /events + * requests, this generator intentionally processes events in serial rather than in parallel. + * @param eventsToFetch + * events to run against the /id/events API + * @param httpGetter + * the HttpHandler to use + */ +async function* getEachRelatedEventsResult( + eventsToFetch: ResolverEvent[], + httpGetter: HttpHandler +): AsyncGenerator<[ResolverEvent, RelatedEventAPIResponse]> { + for (const eventToQueryForRelateds of eventsToFetch) { + const id = event.entityId(eventToQueryForRelateds); + let result: RelatedEventAPIResponse; + try { + result = await httpGetter(`/api/endpoint/resolver/${id}/events`, { + query: { events: 100 }, + }); + } catch (e) { + result = 'error'; + } + yield [eventToQueryForRelateds, result]; + } +} + export const resolverMiddlewareFactory: MiddlewareFactory = context => { return api => next => async (action: ResolverAction) => { next(action); @@ -78,5 +106,44 @@ export const resolverMiddlewareFactory: MiddlewareFactory = context => { } } } + + if (action.type === 'userRequestedRelatedEventData') { + if (typeof context !== 'undefined') { + const response: Map = new Map(); + for await (const results of getEachRelatedEventsResult( + [action.payload], + context.services.http.get + )) { + /** + * results here will take the shape of + * [event requested , response of event against the /related api] + */ + const [baseEvent, apiResults] = results; + if (apiResults === 'error') { + api.dispatch({ + type: 'serverFailedToReturnRelatedEventData', + payload: results[0], + }); + continue; + } + + const fetchedResults = apiResults.events; + // pack up the results into response + const relatedEventEntry = fetchedResults.map(relatedEvent => { + return { + relatedEvent, + relatedEventType: event.eventType(relatedEvent), + }; + }); + + response.set(baseEvent, { relatedEvents: relatedEventEntry }); + } + + api.dispatch({ + type: 'serverReturnedRelatedEventData', + payload: response, + }); + } + } }; }; diff --git a/x-pack/plugins/endpoint/public/embeddables/resolver/store/selectors.ts b/x-pack/plugins/endpoint/public/embeddables/resolver/store/selectors.ts index 7d09d90881da94..493c23621a6cf3 100644 --- a/x-pack/plugins/endpoint/public/embeddables/resolver/store/selectors.ts +++ b/x-pack/plugins/endpoint/public/embeddables/resolver/store/selectors.ts @@ -60,6 +60,11 @@ export const processAdjacencies = composeSelectors( dataSelectors.processAdjacencies ); +/** + * Returns a map of `ResolverEvent`s to their related `ResolverEvent`s + */ +export const relatedEvents = composeSelectors(dataStateSelector, dataSelectors.relatedEvents); + /** * Returns the id of the "current" tree node (fake-focused) */ diff --git a/x-pack/plugins/endpoint/public/embeddables/resolver/types.ts b/x-pack/plugins/endpoint/public/embeddables/resolver/types.ts index 17aa598720c593..32fefba8f0f207 100644 --- a/x-pack/plugins/endpoint/public/embeddables/resolver/types.ts +++ b/x-pack/plugins/endpoint/public/embeddables/resolver/types.ts @@ -9,6 +9,7 @@ import { Store } from 'redux'; import { ResolverAction } from './store/actions'; export { ResolverAction } from './store/actions'; import { ResolverEvent } from '../../../common/types'; +import { eventType } from '../../../common/models/event'; /** * Redux state for the Resolver feature. Properties on this interface are populated via multiple reducers using redux's `combineReducers`. @@ -130,6 +131,52 @@ export type CameraState = { } ); +/** + * This represents all the raw data (sans statistics, metadata, etc.) + * about a particular subject's related events + */ +export interface RelatedEventDataEntry { + relatedEvents: Array<{ + relatedEvent: ResolverEvent; + relatedEventType: ReturnType; + }>; +} + +/** + * Represents the status of the request for related event data, which will be either the data, + * a value indicating that it's still waiting for the data or an Error indicating the data can't be retrieved as expected + */ +export type RelatedEventDataResults = + | RelatedEventDataEntry + | 'waitingForRelatedEventData' + | 'error'; + +/** + * This represents the raw related events data enhanced with statistics + * (e.g. counts of items grouped by their related event types) + */ +export type RelatedEventDataEntryWithStats = RelatedEventDataEntry & { + stats: Record; +}; + +/** + * The status or value of any particular event's related events w.r.t. their valence to the current view. + * One of: + * `RelatedEventDataEntryWithStats` when results have been received and processed and are ready to display + * `waitingForRelatedEventData` when related events have been requested but have not yet matriculated + * `Error` when the request for any event encounters an error during service + */ +export type RelatedEventEntryWithStatsOrWaiting = + | RelatedEventDataEntryWithStats + | `waitingForRelatedEventData` + | 'error'; + +/** + * This represents a Map that will return either a `RelatedEventDataEntryWithStats` + * or a `waitingForRelatedEventData` symbol when referenced with a unique event. + */ +export type RelatedEventData = Map; + /** * State for `data` reducer which handles receiving Resolver data from the backend. */ @@ -137,6 +184,7 @@ export interface DataState { readonly results: readonly ResolverEvent[]; isLoading: boolean; hasError: boolean; + resultsEnrichedWithRelatedEventInfo: Map; } export type Vector2 = readonly [number, number]; diff --git a/x-pack/plugins/endpoint/public/embeddables/resolver/view/index.tsx b/x-pack/plugins/endpoint/public/embeddables/resolver/view/index.tsx index 2e7ca65c92dc1e..5275ba3ec5b4c9 100644 --- a/x-pack/plugins/endpoint/public/embeddables/resolver/view/index.tsx +++ b/x-pack/plugins/endpoint/public/embeddables/resolver/view/index.tsx @@ -57,7 +57,7 @@ export const Resolver = styled( const dispatch: (action: ResolverAction) => unknown = useDispatch(); const { processToAdjacencyMap } = useSelector(selectors.processAdjacencies); - + const relatedEvents = useSelector(selectors.relatedEvents); const { projectionMatrix, ref, onMouseDown } = useCamera(); const isLoading = useSelector(selectors.isLoading); const hasError = useSelector(selectors.hasError); @@ -116,6 +116,7 @@ export const Resolver = styled( projectionMatrix={projectionMatrix} event={processEvent} adjacentNodeMap={adjacentNodeMap} + relatedEvents={relatedEvents.get(processEvent)} /> ); })} diff --git a/x-pack/plugins/endpoint/public/embeddables/resolver/view/process_event_dot.tsx b/x-pack/plugins/endpoint/public/embeddables/resolver/view/process_event_dot.tsx index 27844f09e22720..32928d511a1f90 100644 --- a/x-pack/plugins/endpoint/public/embeddables/resolver/view/process_event_dot.tsx +++ b/x-pack/plugins/endpoint/public/embeddables/resolver/view/process_event_dot.tsx @@ -9,14 +9,21 @@ import styled from 'styled-components'; import { i18n } from '@kbn/i18n'; import { htmlIdGenerator, + EuiI18nNumber, EuiKeyboardAccessible, - EuiButton, EuiFlexGroup, EuiFlexItem, } from '@elastic/eui'; import { useSelector } from 'react-redux'; +import { NodeSubMenu, subMenuAssets } from './submenu'; import { applyMatrix3 } from '../lib/vector2'; -import { Vector2, Matrix3, AdjacentProcessMap, ResolverProcessType } from '../types'; +import { + Vector2, + Matrix3, + AdjacentProcessMap, + ResolverProcessType, + RelatedEventEntryWithStatsOrWaiting, +} from '../types'; import { SymbolIds, NamedColors } from './defs'; import { ResolverEvent } from '../../../../common/types'; import { useResolverDispatch } from './use_resolver_dispatch'; @@ -59,46 +66,129 @@ const nodeAssets = { }, }; -const ChildEventsButton = React.memo(() => { - return ( - ) => { - clickEvent.preventDefault(); - clickEvent.stopPropagation(); - }, [])} - color="ghost" - size="s" - iconType="arrowDown" - iconSide="right" - tabIndex={-1} - > - {i18n.translate('xpack.endpoint.resolver.relatedEvents', { - defaultMessage: 'Events', - })} - - ); -}); - -const RelatedAlertsButton = React.memo(() => { +/** + * Take a gross `schemaName` and return a beautiful translated one. + */ +const getDisplayName: (schemaName: string) => string = function nameInSchemaToDisplayName( + schemaName: string +) { + const displayNameRecord: Record = { + application: i18n.translate('xpack.endpoint.resolver.applicationEventTypeDisplayName', { + defaultMessage: 'Application', + }), + apm: i18n.translate('xpack.endpoint.resolver.apmEventTypeDisplayName', { + defaultMessage: 'APM', + }), + audit: i18n.translate('xpack.endpoint.resolver.auditEventTypeDisplayName', { + defaultMessage: 'Audit', + }), + authentication: i18n.translate('xpack.endpoint.resolver.authenticationEventTypeDisplayName', { + defaultMessage: 'Authentication', + }), + certificate: i18n.translate('xpack.endpoint.resolver.certificateEventTypeDisplayName', { + defaultMessage: 'Certificate', + }), + cloud: i18n.translate('xpack.endpoint.resolver.cloudEventTypeDisplayName', { + defaultMessage: 'Cloud', + }), + database: i18n.translate('xpack.endpoint.resolver.databaseEventTypeDisplayName', { + defaultMessage: 'Database', + }), + driver: i18n.translate('xpack.endpoint.resolver.driverEventTypeDisplayName', { + defaultMessage: 'Driver', + }), + email: i18n.translate('xpack.endpoint.resolver.emailEventTypeDisplayName', { + defaultMessage: 'Email', + }), + file: i18n.translate('xpack.endpoint.resolver.fileEventTypeDisplayName', { + defaultMessage: 'File', + }), + host: i18n.translate('xpack.endpoint.resolver.hostEventTypeDisplayName', { + defaultMessage: 'Host', + }), + iam: i18n.translate('xpack.endpoint.resolver.iamEventTypeDisplayName', { + defaultMessage: 'IAM', + }), + iam_group: i18n.translate('xpack.endpoint.resolver.iam_groupEventTypeDisplayName', { + defaultMessage: 'IAM Group', + }), + intrusion_detection: i18n.translate( + 'xpack.endpoint.resolver.intrusion_detectionEventTypeDisplayName', + { + defaultMessage: 'Intrusion Detection', + } + ), + malware: i18n.translate('xpack.endpoint.resolver.malwareEventTypeDisplayName', { + defaultMessage: 'Malware', + }), + network_flow: i18n.translate('xpack.endpoint.resolver.network_flowEventTypeDisplayName', { + defaultMessage: 'Network Flow', + }), + network: i18n.translate('xpack.endpoint.resolver.networkEventTypeDisplayName', { + defaultMessage: 'Network', + }), + package: i18n.translate('xpack.endpoint.resolver.packageEventTypeDisplayName', { + defaultMessage: 'Package', + }), + process: i18n.translate('xpack.endpoint.resolver.processEventTypeDisplayName', { + defaultMessage: 'Process', + }), + registry: i18n.translate('xpack.endpoint.resolver.registryEventTypeDisplayName', { + defaultMessage: 'Registry', + }), + session: i18n.translate('xpack.endpoint.resolver.sessionEventTypeDisplayName', { + defaultMessage: 'Session', + }), + service: i18n.translate('xpack.endpoint.resolver.serviceEventTypeDisplayName', { + defaultMessage: 'Service', + }), + socket: i18n.translate('xpack.endpoint.resolver.socketEventTypeDisplayName', { + defaultMessage: 'Socket', + }), + vulnerability: i18n.translate('xpack.endpoint.resolver.vulnerabilityEventTypeDisplayName', { + defaultMessage: 'Vulnerability', + }), + web: i18n.translate('xpack.endpoint.resolver.webEventTypeDisplayName', { + defaultMessage: 'Web', + }), + alert: i18n.translate('xpack.endpoint.resolver.alertEventTypeDisplayName', { + defaultMessage: 'Alert', + }), + security: i18n.translate('xpack.endpoint.resolver.securityEventTypeDisplayName', { + defaultMessage: 'Security', + }), + dns: i18n.translate('xpack.endpoint.resolver.dnsEventTypeDisplayName', { + defaultMessage: 'DNS', + }), + clr: i18n.translate('xpack.endpoint.resolver.clrEventTypeDisplayName', { + defaultMessage: 'CLR', + }), + image_load: i18n.translate('xpack.endpoint.resolver.image_loadEventTypeDisplayName', { + defaultMessage: 'Image Load', + }), + powershell: i18n.translate('xpack.endpoint.resolver.powershellEventTypeDisplayName', { + defaultMessage: 'Powershell', + }), + wmi: i18n.translate('xpack.endpoint.resolver.wmiEventTypeDisplayName', { + defaultMessage: 'WMI', + }), + api: i18n.translate('xpack.endpoint.resolver.apiEventTypeDisplayName', { + defaultMessage: 'API', + }), + user: i18n.translate('xpack.endpoint.resolver.userEventTypeDisplayName', { + defaultMessage: 'User', + }), + }; return ( - ) => { - clickEvent.preventDefault(); - clickEvent.stopPropagation(); - }, [])} - color="ghost" - size="s" - tabIndex={-1} - > - {i18n.translate('xpack.endpoint.resolver.relatedAlerts', { - defaultMessage: 'Related Alerts', - })} - + displayNameRecord[schemaName] || + i18n.translate('xpack.endpoint.resolver.userEventTypeDisplayUnknown', { + defaultMessage: 'Unknown', + }) ); -}); +}; /** - * An artefact that represents a process node. + * An artifact that represents a process node and the things associated with it in the Resolver */ export const ProcessEventDot = styled( React.memo( @@ -108,6 +198,7 @@ export const ProcessEventDot = styled( event, projectionMatrix, adjacentNodeMap, + relatedEvents, }: { /** * A `className` string provided by `styled` @@ -129,6 +220,11 @@ export const ProcessEventDot = styled( * map of what nodes are "adjacent" to this one in "up, down, previous, next" directions */ adjacentNodeMap: AdjacentProcessMap; + /** + * A collection of events related to the current node and statistics (e.g. counts indexed by event type) + * to provide the user some visibility regarding the contents thereof. + */ + relatedEvents?: RelatedEventEntryWithStatsOrWaiting; }) => { /** * Convert the position, which is in 'world' coordinates, to screen coordinates. @@ -203,7 +299,6 @@ export const ProcessEventDot = styled( ]); const labelId = useMemo(() => resolverNodeIdGenerator(), [resolverNodeIdGenerator]); const descriptionId = useMemo(() => resolverNodeIdGenerator(), [resolverNodeIdGenerator]); - const isActiveDescendant = nodeId === activeDescendantId; const isSelectedDescendant = nodeId === selectedDescendantId; @@ -230,6 +325,70 @@ export const ProcessEventDot = styled( }); }, [animationTarget, dispatch, nodeId]); + const handleRelatedEventRequest = useCallback(() => { + dispatch({ + type: 'userRequestedRelatedEventData', + payload: event, + }); + }, [dispatch, event]); + + const handleRelatedAlertsRequest = useCallback(() => { + dispatch({ + type: 'userSelectedRelatedAlerts', + payload: event, + }); + }, [dispatch, event]); + /** + * Enumerates the stats for related events to display with the node as options, + * generally in the form `number of related events in category` `category title` + * e.g. "10 DNS", "230 File" + */ + const relatedEventOptions = useMemo(() => { + if (relatedEvents === 'error') { + // Return an empty set of options if there was an error requesting them + return []; + } + const relatedStats = typeof relatedEvents === 'object' && relatedEvents.stats; + if (!relatedStats) { + // Return an empty set of options if there are no stats to report + return []; + } + // If we have entries to show, map them into options to display in the selectable list + return Object.entries(relatedStats).map(statsEntry => { + const displayName = getDisplayName(statsEntry[0]); + return { + prefix: , + optionTitle: `${displayName}`, + action: () => { + dispatch({ + type: 'userSelectedRelatedEventCategory', + payload: { + subject: event, + category: statsEntry[0], + }, + }); + }, + }; + }); + }, [relatedEvents, dispatch, event]); + + const relatedEventStatusOrOptions = (() => { + if (!relatedEvents) { + // If related events have not yet been requested + return subMenuAssets.initialMenuStatus; + } + if (relatedEvents === 'error') { + // If there was an error when we tried to request the events + return subMenuAssets.menuError; + } + if (relatedEvents === 'waitingForRelatedEventData') { + // If we're waiting for events to be returned + // Pass on the waiting symbol + return relatedEvents; + } + return relatedEventOptions; + })(); + /* eslint-disable jsx-a11y/click-events-have-key-events */ /** * Key event handling (e.g. 'Enter'/'Space') is provisioned by the `EuiKeyboardAccessible` component @@ -359,11 +518,18 @@ export const ProcessEventDot = styled( {magFactorX >= 2 && ( - - + + - + )} @@ -383,9 +549,10 @@ export const ProcessEventDot = styled( border-radius: 10%; white-space: nowrap; will-change: left, top, width, height; - contain: strict; + contain: layout; min-width: 280px; min-height: 90px; + overflow-y: visible; //dasharray & dashoffset should be equal to "pull" the stroke back //when it is transitioned. @@ -400,10 +567,27 @@ export const ProcessEventDot = styled( transition-duration: 1s; stroke-dashoffset: 0; } + + & .related-dropdown { + width: 4.5em; + } + & .euiSelectableList-bordered { + border-top-right-radius: 0px; + border-top-left-radius: 0px; + } + & .euiSelectableListItem { + background-color: black; + } + & .euiSelectableListItem path { + fill: white; + } + & .euiSelectableListItem__text { + color: white; + } `; const processTypeToCube: Record = { - processCreated: 'terminatedProcessCube', + processCreated: 'runningProcessCube', processRan: 'runningProcessCube', processTerminated: 'terminatedProcessCube', unknownProcessEvent: 'runningProcessCube', diff --git a/x-pack/plugins/endpoint/public/embeddables/resolver/view/submenu.tsx b/x-pack/plugins/endpoint/public/embeddables/resolver/view/submenu.tsx new file mode 100644 index 00000000000000..9f6427d801ce43 --- /dev/null +++ b/x-pack/plugins/endpoint/public/embeddables/resolver/view/submenu.tsx @@ -0,0 +1,178 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import React, { ReactNode, useState, useMemo, useCallback } from 'react'; +import { EuiSelectable, EuiButton } from '@elastic/eui'; +import styled from 'styled-components'; + +/** + * i18n-translated titles for submenus and identifiers for display of states: + * initialMenuStatus: submenu before it has been opened / requested data + * menuError: if the submenu requested data, but received an error + */ +export const subMenuAssets = { + initialMenuStatus: i18n.translate('xpack.endpoint.resolver.relatedNotRetrieved', { + defaultMessage: 'Related Events have not yet been retrieved.', + }), + menuError: i18n.translate('xpack.endpoint.resolver.relatedRetrievalError', { + defaultMessage: 'There was an error retrieving related events.', + }), + relatedAlerts: { + title: i18n.translate('xpack.endpoint.resolver.relatedAlerts', { + defaultMessage: 'Related Alerts', + }), + }, + relatedEvents: { + title: i18n.translate('xpack.endpoint.resolver.relatedEvents', { + defaultMessage: 'Events', + }), + }, +}; + +interface ResolverSubmenuOption { + optionTitle: string; + action: () => unknown; + prefix?: number | JSX.Element; +} + +export type ResolverSubmenuOptionList = ResolverSubmenuOption[] | string; + +const OptionList = React.memo( + ({ + subMenuOptions, + isLoading, + }: { + subMenuOptions: ResolverSubmenuOptionList; + isLoading: boolean; + }) => { + const [options, setOptions] = useState(() => + typeof subMenuOptions !== 'object' + ? [] + : subMenuOptions.map((opt: ResolverSubmenuOption): { + label: string; + prepend?: ReactNode; + } => { + return opt.prefix + ? { + label: opt.optionTitle, + prepend: {opt.prefix} , + } + : { + label: opt.optionTitle, + prepend: , + }; + }) + ); + return useMemo( + () => ( + { + setOptions(newOptions); + }} + listProps={{ showIcons: true, bordered: true }} + isLoading={isLoading} + > + {list => list} + + ), + [isLoading, options] + ); + } +); + +/** + * A Submenu to be displayed in one of two forms: + * 1) Provided a collection of `optionsWithActions`: it will call `menuAction` then - if and when menuData becomes available - display each item with an optional prefix and call the supplied action for the options when that option is clicked. + * 2) Provided `optionsWithActions` is undefined, it will call the supplied `menuAction` when its host button is clicked. + */ +export const NodeSubMenu = styled( + React.memo( + ({ + menuTitle, + menuAction, + optionsWithActions, + className, + }: { menuTitle: string; className?: string; menuAction: () => unknown } & { + optionsWithActions?: ResolverSubmenuOptionList | string | undefined; + }) => { + const [menuIsOpen, setMenuOpen] = useState(false); + const handleMenuOpenClick = useCallback( + (clickEvent: React.MouseEvent) => { + // stopping propagation/default to prevent other node animations from triggering + clickEvent.preventDefault(); + clickEvent.stopPropagation(); + setMenuOpen(!menuIsOpen); + }, + [menuIsOpen] + ); + const handleMenuActionClick = useCallback( + (clickEvent: React.MouseEvent) => { + // stopping propagation/default to prevent other node animations from triggering + clickEvent.preventDefault(); + clickEvent.stopPropagation(); + if (typeof menuAction === 'function') menuAction(); + setMenuOpen(true); + }, + [menuAction] + ); + + const isMenuLoading = optionsWithActions === 'waitingForRelatedEventData'; + + if (!optionsWithActions) { + /** + * When called with a `menuAction` + * Render without dropdown and call the supplied action when host button is clicked + */ + return ( +
+ + {menuTitle} + +
+ ); + } + /** + * When called with a set of `optionsWithActions`: + * Render with a panel of options that appear when the menu host button is clicked + */ + return ( +
+ + {menuTitle} + + {menuIsOpen && typeof optionsWithActions === 'object' && ( + + )} +
+ ); + } + ) +)` + margin: 0; + padding: 0; + border: none; + display: flex; + flex-flow: column; + &.is-open .euiButton { + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; + } + &.is-open .euiSelectableListItem__prepend { + color: white; + } +`;