diff --git a/x-pack/plugins/security_solution/common/endpoint/generate_data.ts b/x-pack/plugins/security_solution/common/endpoint/generate_data.ts index 7944d7d365ed8..499c300ebfc69 100644 --- a/x-pack/plugins/security_solution/common/endpoint/generate_data.ts +++ b/x-pack/plugins/security_solution/common/endpoint/generate_data.ts @@ -26,6 +26,9 @@ interface EventOptions { eventType?: string; eventCategory?: string | string[]; processName?: string; + pid?: number; + parentPid?: number; + extensions?: object; } const Windows: HostOS[] = [ @@ -452,12 +455,36 @@ export class EndpointDocGenerator { * @param options - Allows event field values to be specified */ public generateEvent(options: EventOptions = {}): EndpointEvent { + const processName = options.processName ? options.processName : randomProcessName(); + const detailRecordForEventType = + options.extensions || + ((eventCategory) => { + if (eventCategory === 'registry') { + return { registry: { key: `HKLM/Windows/Software/${this.randomString(5)}` } }; + } + if (eventCategory === 'network') { + return { + network: { + direction: this.randomChoice(['inbound', 'outbound']), + forwarded_ip: `${this.randomIP()}`, + }, + }; + } + if (eventCategory === 'file') { + return { file: { path: 'C:\\My Documents\\business\\January\\processName' } }; + } + if (eventCategory === 'dns') { + return { dns: { question: { name: `${this.randomIP()}` } } }; + } + return {}; + })(options.eventCategory); return { '@timestamp': options.timestamp ? options.timestamp : new Date().getTime(), agent: { ...this.commonInfo.agent, type: 'endpoint' }, ecs: { version: '1.4.0', }, + ...detailRecordForEventType, event: { category: options.eventCategory ? options.eventCategory : 'process', kind: 'event', @@ -466,9 +493,30 @@ export class EndpointDocGenerator { }, host: this.commonInfo.host, process: { + pid: + 'pid' in options && typeof options.pid !== 'undefined' ? options.pid : this.randomN(5000), + executable: `C:\\${processName}`, + args: `"C:\\${processName}" \\${this.randomString(3)}`, + code_signature: { + status: 'trusted', + subject_name: 'Microsoft', + }, + hash: { md5: this.seededUUIDv4() }, entity_id: options.entityID ? options.entityID : this.randomString(10), - parent: options.parentEntityID ? { entity_id: options.parentEntityID } : undefined, - name: options.processName ? options.processName : randomProcessName(), + parent: options.parentEntityID + ? { + entity_id: options.parentEntityID, + pid: + 'parentPid' in options && typeof options.parentPid !== 'undefined' + ? options.parentPid + : this.randomN(5000), + } + : undefined, + name: processName, + }, + user: { + domain: this.randomString(10), + name: this.randomString(10), }, }; } @@ -701,6 +749,8 @@ export class EndpointDocGenerator { ancestor = this.generateEvent({ timestamp, parentEntityID: ancestor.process.entity_id, + parentPid: ancestor.process.pid, + pid: this.randomN(5000), }); events.push(ancestor); timestamp = timestamp + 1000; @@ -1126,7 +1176,7 @@ export class EndpointDocGenerator { return [...this.randomNGenerator(255, 6)].map((x) => x.toString(16)).join('-'); } - private randomIP(): string { + public randomIP(): string { return [10, ...this.randomNGenerator(255, 3)].map((x) => x.toString()).join('.'); } diff --git a/x-pack/plugins/security_solution/common/endpoint/models/event.test.ts b/x-pack/plugins/security_solution/common/endpoint/models/event.test.ts new file mode 100644 index 0000000000000..a0bf00f0274e6 --- /dev/null +++ b/x-pack/plugins/security_solution/common/endpoint/models/event.test.ts @@ -0,0 +1,41 @@ +/* + * 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 { EndpointDocGenerator } from '../generate_data'; +import { descriptiveName } from './event'; + +describe('Event descriptive names', () => { + let generator: EndpointDocGenerator; + beforeEach(() => { + generator = new EndpointDocGenerator('seed'); + }); + + 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(descriptiveName(event)).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 }); + expect(descriptiveName(event)).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 }); + expect(descriptiveName(event)).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 }); + expect(descriptiveName(event)).toEqual({ subject: extensions.dns.question.name }); + }); +}); diff --git a/x-pack/plugins/security_solution/common/endpoint/models/event.ts b/x-pack/plugins/security_solution/common/endpoint/models/event.ts index 9eea6bf320db8..3f07bf77abf20 100644 --- a/x-pack/plugins/security_solution/common/endpoint/models/event.ts +++ b/x-pack/plugins/security_solution/common/endpoint/models/event.ts @@ -52,3 +52,95 @@ export function parentEntityId(event: ResolverEvent): string | undefined { } return event.process.parent?.entity_id; } + +/** + * @param event The event to get the category for + */ +export function primaryEventCategory(event: ResolverEvent): string | undefined { + // Returning "Process" as a catch-all here because it seems pretty general + 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; + } +} + +/** + * 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 { + if (isLegacyEvent(event)) { + return [event.endgame.event_subtype_full]; + } + return typeof event.event.type === 'string' ? [event.event.type] : 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. + */ +type DeepPartial = T extends object ? { [K in keyof T]?: DeepPartial } : T; +/** + * Based on the ECS category of the event, attempt to provide a more descriptive name + * (e.g. the `event.registry.key` for `registry` or the `dns.question.name` for `dns`, etc.). + * This function returns the data in the form of `{subject, descriptor}` where `subject` will + * tend to be the more distinctive term (e.g. 137.213.212.7 for a network event) and the + * `descriptor` can be used to present more useful/meaningful view (e.g. `inbound 137.213.212.7` + * in the example above). + * see: https://www.elastic.co/guide/en/ecs/current/ecs-field-reference.html + * @param event The ResolverEvent to get the descriptive name for + * @returns { descriptiveName } An attempt at providing a readable name to the user + */ +export function descriptiveName(event: ResolverEvent): { subject: string; descriptor?: string } { + if (isLegacyEvent(event)) { + return { subject: eventName(event) }; + } + + // To be somewhat defensive, we'll check for the presence of these. + const partialEvent: DeepPartial = event; + + /** + * This list of attempts can be expanded/adjusted as the underlying model changes over time: + */ + + // Stable, per ECS 1.5: https://www.elastic.co/guide/en/ecs/current/ecs-allowed-values-event-category.html + + if (partialEvent.network?.forwarded_ip) { + return { + subject: String(partialEvent.network?.forwarded_ip), + descriptor: String(partialEvent.network?.direction), + }; + } + + if (partialEvent.file?.path) { + return { + subject: String(partialEvent.file?.path), + }; + } + + // Extended categories (per ECS 1.5): + const pathOrKey = partialEvent.registry?.path || partialEvent.registry?.key; + if (pathOrKey) { + return { + subject: String(pathOrKey), + }; + } + + if (partialEvent.dns?.question?.name) { + return { subject: String(partialEvent.dns?.question?.name) }; + } + + // Fall back on entityId if we can't fish a more descriptive name out. + return { subject: entityId(event) }; +} diff --git a/x-pack/plugins/security_solution/common/endpoint/types.ts b/x-pack/plugins/security_solution/common/endpoint/types.ts index 0341b7593caf0..b839f3b41772e 100644 --- a/x-pack/plugins/security_solution/common/endpoint/types.ts +++ b/x-pack/plugins/security_solution/common/endpoint/types.ts @@ -438,14 +438,38 @@ export interface EndpointEvent { kind: string; }; host: Host; + network?: { + direction: unknown; + forwarded_ip: unknown; + }; + dns?: { + question: { name: unknown }; + }; process: { entity_id: string; name: string; + executable?: string; + args?: string; + code_signature?: { + status?: string; + subject_name: string; + }; + pid?: number; + hash?: { + md5: string; + }; parent?: { entity_id: string; name?: string; + pid?: number; }; }; + user?: { + domain?: string; + name: string; + }; + file?: { path: unknown }; + registry?: { path: unknown; key: unknown }; } export type ResolverEvent = EndpointEvent | LegacyEndpointEvent; diff --git a/x-pack/plugins/security_solution/public/endpoint_alerts/store/middleware.ts b/x-pack/plugins/security_solution/public/endpoint_alerts/store/middleware.ts index cd7ed93d22d9f..ae5d36ce0d1ff 100644 --- a/x-pack/plugins/security_solution/public/endpoint_alerts/store/middleware.ts +++ b/x-pack/plugins/security_solution/public/endpoint_alerts/store/middleware.ts @@ -13,11 +13,12 @@ import { } from '../../../common/endpoint_alerts/types'; import { ImmutableMiddlewareFactory } from '../../common/store'; import { cloneHttpFetchQuery } from '../../common/utils/clone_http_fetch_query'; + import { isOnAlertPage, apiQueryParams, - hasSelectedAlert, uiQueryParams, + hasSelectedAlert, isAlertPageTabChange, } from './selectors'; @@ -25,6 +26,26 @@ export const alertMiddlewareFactory: ImmutableMiddlewareFactory coreStart, depsStart ) => { + let lastSelectedAlert: string | null = null; + /** + * @returns true once per change of `selectedAlert` in query params. + * + * As opposed to `hasSelectedAlert` which always returns true if the alert is present + * query params, which can cause unnecessary requests and re-renders in some cases. + */ + const selectedAlertHasChanged = (params: ReturnType): boolean => { + const { selected_alert: selectedAlert } = params; + const shouldNotChange = selectedAlert === lastSelectedAlert; + if (shouldNotChange) { + return false; + } + if (typeof selectedAlert !== 'string') { + return false; + } + lastSelectedAlert = selectedAlert; + return true; + }; + async function fetchIndexPatterns(): Promise { const { indexPatterns } = depsStart.data; const fields = await indexPatterns.getFieldsForWildcard({ @@ -50,7 +71,7 @@ export const alertMiddlewareFactory: ImmutableMiddlewareFactory }); api.dispatch({ type: 'serverReturnedAlertsData', payload: listResponse }); - if (hasSelectedAlert(state)) { + if (hasSelectedAlert(state) && selectedAlertHasChanged(uiQueryParams(state))) { const uiParams = uiQueryParams(state); const detailsResponse: AlertDetails = await coreStart.http.get( `/api/endpoint/alerts/${uiParams.selected_alert}` diff --git a/x-pack/plugins/security_solution/public/resolver/models/indexed_process_tree.ts b/x-pack/plugins/security_solution/public/resolver/models/indexed_process_tree.ts index 3102b450e54f8..db00ca2d59968 100644 --- a/x-pack/plugins/security_solution/public/resolver/models/indexed_process_tree.ts +++ b/x-pack/plugins/security_solution/public/resolver/models/indexed_process_tree.ts @@ -55,9 +55,8 @@ export function factory(processes: ResolverEvent[]): IndexedProcessTree { currentProcessAdjacencyMap.parent = uniqueParentPid; } } else { - idToChildren.set(uniqueParentPid, [process]); - if (uniqueParentPid) { + idToChildren.set(uniqueParentPid, [process]); /** * Get the parent's map, otherwise set an empty one */ diff --git a/x-pack/plugins/security_solution/public/resolver/models/process_event.ts b/x-pack/plugins/security_solution/public/resolver/models/process_event.ts index 038e5b90b2170..1094fee6da249 100644 --- a/x-pack/plugins/security_solution/public/resolver/models/process_event.ts +++ b/x-pack/plugins/security_solution/public/resolver/models/process_event.ts @@ -78,6 +78,17 @@ 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; + } +} + /** * Returns the process event's parent pid */ @@ -88,3 +99,61 @@ export function uniqueParentPidForProcess(passedEvent: ResolverEvent): string | 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; + } +} + +/** + * Returns the username for the account that ran the process + */ +export function userInfoForProcess( + passedEvent: ResolverEvent +): { user?: string; domain?: string } | undefined { + return passedEvent.user; +} + +/** + * Returns the MD5 hash for the `passedEvent` param, 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 + * + * @param {ResolverEvent} passedEvent The `ResolverEvent` to get the arguemnts value for + * @returns {string | undefined} The arguments (including the path) used to run the process + */ +export function argsForProcess(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?.args; +} diff --git a/x-pack/plugins/security_solution/public/resolver/store/actions.ts b/x-pack/plugins/security_solution/public/resolver/store/actions.ts index 0963118ce14b8..c633d791e8bf2 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/actions.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/actions.ts @@ -24,6 +24,35 @@ interface UserBroughtProcessIntoView { }; } +/** + * Dispatched to notify state that a different panel needs to be displayed + */ +interface AppDisplayedDifferentPanel { + readonly type: 'appDisplayedDifferentPanel'; + /** + * The name of the panel to display + */ + readonly payload: string; +} + +/** + * 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; + /** + * The time (since epoch in milliseconds) when the action was dispatched. + */ + readonly time: number; + }; +} + /** * Used when the alert list selects an alert and the flyout shows resolver. */ @@ -45,12 +74,21 @@ interface 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`) + * The action dispatched when the app requests related event data for one + * subject (whose entity_id should be included as `payload`) */ interface UserRequestedRelatedEventData { readonly type: 'userRequestedRelatedEventData'; - readonly payload: ResolverEvent; + 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; } /** @@ -80,9 +118,13 @@ interface UserSelectedResolverNode { readonly type: 'userSelectedResolverNode'; readonly payload: { /** - * Used to identify the process node that the user selected + * The HTML ID used to identify the process node's element that the user selected */ readonly nodeId: string; + /** + * The process entity_id for the process the node represents + */ + readonly selectedProcessId: string; }; } @@ -118,4 +160,7 @@ export type ResolverAction = | UserSelectedResolverNode | UserRequestedRelatedEventData | UserSelectedRelatedEventCategory - | UserSelectedRelatedAlerts; + | UserSelectedRelatedAlerts + | AppDetectedNewIdFromQueryParams + | AppDisplayedDifferentPanel + | AppDetectedMissingEventData; diff --git a/x-pack/plugins/security_solution/public/resolver/store/data/action.ts b/x-pack/plugins/security_solution/public/resolver/store/data/action.ts index 96552fbed6207..fbeeefe1ab9f2 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/data/action.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/data/action.ts @@ -4,7 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -import { ResolverEvent, ResolverNodeStats } from '../../../../common/endpoint/types'; +import { + ResolverEvent, + ResolverNodeStats, + ResolverRelatedEvents, +} from '../../../../common/endpoint/types'; interface ServerReturnedResolverData { readonly type: 'serverReturnedResolverData'; @@ -21,10 +25,19 @@ interface ServerFailedToReturnResolverData { */ interface ServerFailedToReturnRelatedEventData { readonly type: 'serverFailedToReturnRelatedEventData'; - readonly payload: ResolverEvent; + readonly payload: string; +} + +/** + * When related events are returned from the server + */ +interface ServerReturnedRelatedEventData { + readonly type: 'serverReturnedRelatedEventData'; + readonly payload: ResolverRelatedEvents; } export type DataAction = | ServerReturnedResolverData | ServerFailedToReturnResolverData - | ServerFailedToReturnRelatedEventData; + | ServerFailedToReturnRelatedEventData + | ServerReturnedRelatedEventData; diff --git a/x-pack/plugins/security_solution/public/resolver/store/data/reducer.ts b/x-pack/plugins/security_solution/public/resolver/store/data/reducer.ts index 0c29411c316d4..3e897a91a74c6 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/data/reducer.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/data/reducer.ts @@ -11,6 +11,8 @@ function initialState(): DataState { return { results: [], relatedEventsStats: new Map(), + relatedEvents: new Map(), + relatedEventsReady: new Map(), isLoading: false, hasError: false, }; @@ -36,6 +38,20 @@ export const dataReducer: Reducer = (state = initialS ...state, hasError: true, }; + } else if ( + action.type === 'userRequestedRelatedEventData' || + action.type === 'appDetectedMissingEventData' + ) { + return { + ...state, + relatedEventsReady: new Map([...state.relatedEventsReady, [action.payload, false]]), + }; + } else if (action.type === 'serverReturnedRelatedEventData') { + return { + ...state, + relatedEventsReady: new Map([...state.relatedEventsReady, [action.payload.entityID, true]]), + relatedEvents: new Map([...state.relatedEvents, [action.payload.entityID, action.payload]]), + }; } else { return state; } diff --git a/x-pack/plugins/security_solution/public/resolver/store/data/selectors.ts b/x-pack/plugins/security_solution/public/resolver/store/data/selectors.ts index 672b3fb2c7293..2873993cc645f 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/data/selectors.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/data/selectors.ts @@ -435,6 +435,21 @@ export function relatedEventsStats(data: DataState) { return data.relatedEventsStats; } +/** + * returns {Map} a map of entity_ids to related event data. + */ +export function relatedEventsByEntityId(data: DataState) { + return data.relatedEvents; +} + +/** + * returns {Map} 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` + */ +export function relatedEventsReady(data: DataState) { + return data.relatedEventsReady; +} + export const processAdjacencies = createSelector( indexedProcessTree, graphableProcesses, diff --git a/x-pack/plugins/security_solution/public/resolver/store/middleware.ts b/x-pack/plugins/security_solution/public/resolver/store/middleware.ts index 079eecf0315a5..7f6f58dac7158 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/middleware.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/middleware.ts @@ -14,6 +14,7 @@ import { ResolverAncestry, LifecycleNode, ResolverNodeStats, + ResolverRelatedEvents, } from '../../../common/endpoint/types'; import * as event from '../../../common/endpoint/models/event'; @@ -92,6 +93,31 @@ export const resolverMiddlewareFactory: MiddlewareFactory = (context) => { }); } } + } else if ( + (action.type === 'userRequestedRelatedEventData' || + action.type === 'appDetectedMissingEventData') && + context + ) { + const entityIdToFetchFor = action.payload; + let result: ResolverRelatedEvents; + try { + result = await context.services.http.get( + `/api/endpoint/resolver/${entityIdToFetchFor}/events`, + { + query: { events: 100 }, + } + ); + + api.dispatch({ + type: 'serverReturnedRelatedEventData', + payload: result, + }); + } catch (e) { + api.dispatch({ + type: 'serverFailedToReturnRelatedEventData', + payload: action.payload, + }); + } } }; }; diff --git a/x-pack/plugins/security_solution/public/resolver/store/reducer.ts b/x-pack/plugins/security_solution/public/resolver/store/reducer.ts index 82206d77f8349..77dffd79ea094 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/reducer.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/reducer.ts @@ -19,7 +19,12 @@ import { uniquePidForProcess } from '../models/process_event'; const resolverNodeIdGenerator = htmlIdGenerator('resolverNode'); const uiReducer: Reducer = ( - uiState = { activeDescendantId: null, selectedDescendantId: null }, + uiState = { + activeDescendantId: null, + selectedDescendantId: null, + processEntityIdOfSelectedDescendant: null, + panelToDisplay: null, + }, action ) => { if (action.type === 'userFocusedOnResolverNode') { @@ -31,17 +36,29 @@ const uiReducer: Reducer = ( return { ...uiState, selectedDescendantId: action.payload.nodeId, + processEntityIdOfSelectedDescendant: action.payload.selectedProcessId, }; - } else if (action.type === 'userBroughtProcessIntoView') { + } else if (action.type === 'appDisplayedDifferentPanel') { + return { + ...uiState, + panelToDisplay: action.payload, + }; + } else if ( + action.type === 'userBroughtProcessIntoView' || + action.type === 'appDetectedNewIdFromQueryParams' + ) { /** * This action has a process payload (instead of a processId), so we use * `uniquePidForProcess` and `resolverNodeIdGenerator` to resolve the determinant * html id of the node being brought into view. */ - const processNodeId = resolverNodeIdGenerator(uniquePidForProcess(action.payload.process)); + const processEntityId = uniquePidForProcess(action.payload.process); + const processNodeId = resolverNodeIdGenerator(processEntityId); return { ...uiState, activeDescendantId: processNodeId, + selectedDescendantId: processNodeId, + processEntityIdOfSelectedDescendant: processEntityId, }; } else { return uiState; @@ -56,7 +73,10 @@ const concernReducers = combineReducers({ export const resolverReducer: Reducer = (state, action) => { const nextState = concernReducers(state, action); - if (action.type === 'userBroughtProcessIntoView') { + if ( + action.type === 'userBroughtProcessIntoView' || + action.type === 'appDetectedNewIdFromQueryParams' + ) { return animateProcessIntoView(nextState, action.payload.time, action.payload.process); } else { return nextState; diff --git a/x-pack/plugins/security_solution/public/resolver/store/selectors.ts b/x-pack/plugins/security_solution/public/resolver/store/selectors.ts index 270a84d24a991..bff30c62864f2 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/selectors.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/selectors.ts @@ -68,6 +68,22 @@ export const relatedEventsStats = composeSelectors( dataSelectors.relatedEventsStats ); +/** + * Map of related events... by entity id + */ +export const relatedEventsByEntityId = composeSelectors( + dataStateSelector, + dataSelectors.relatedEventsByEntityId +); + +/** + * Entity ids to booleans for waiting status + */ +export const relatedEventsReady = composeSelectors( + dataStateSelector, + dataSelectors.relatedEventsReady +); + /** * Returns the id of the "current" tree node (fake-focused) */ @@ -84,6 +100,19 @@ export const uiSelectedDescendantId = composeSelectors( uiSelectors.selectedDescendantId ); +/** + * Returns the entity_id of the "selected" tree node's process + */ +export const uiSelectedDescendantProcessId = composeSelectors( + uiStateSelector, + uiSelectors.selectedDescendantProcessId +); + +/** + * The current panel to display + */ +export const currentPanelView = composeSelectors(uiStateSelector, uiSelectors.currentPanelView); + /** * Returns the camera state from within ResolverState */ @@ -115,6 +144,14 @@ export const isLoading = composeSelectors(dataStateSelector, dataSelectors.isLoa */ export const hasError = composeSelectors(dataStateSelector, dataSelectors.hasError); +/** + * An array containing all the processes currently in the Resolver than can be graphed + */ +export const graphableProcesses = composeSelectors( + dataStateSelector, + dataSelectors.graphableProcesses +); + /** * Calls the `secondSelector` with the result of the `selector`. Use this when re-exporting a * concern-specific selector. `selector` should return the concern-specific state. diff --git a/x-pack/plugins/security_solution/public/resolver/store/ui/selectors.ts b/x-pack/plugins/security_solution/public/resolver/store/ui/selectors.ts index 196e834c406b3..bddc7d34abf1c 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/ui/selectors.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/ui/selectors.ts @@ -28,3 +28,19 @@ export const selectedDescendantId = createSelector( return selectedDescendantId; } ); + +/** + * id of the currently "selected" tree node + */ +export const selectedDescendantProcessId = createSelector( + (uiState: ResolverUIState) => uiState, + /* eslint-disable no-shadow */ + ({ processEntityIdOfSelectedDescendant }: ResolverUIState) => { + return processEntityIdOfSelectedDescendant; + } +); + +// Select the current panel to be displayed +export const currentPanelView = (uiState: ResolverUIState) => { + return uiState.panelToDisplay; +}; diff --git a/x-pack/plugins/security_solution/public/resolver/types.ts b/x-pack/plugins/security_solution/public/resolver/types.ts index 765b2b2a26ada..a48f3b59b0f6d 100644 --- a/x-pack/plugins/security_solution/public/resolver/types.ts +++ b/x-pack/plugins/security_solution/public/resolver/types.ts @@ -8,7 +8,11 @@ import { Store } from 'redux'; import { ResolverAction } from './store/actions'; export { ResolverAction } from './store/actions'; -import { ResolverEvent, ResolverNodeStats } from '../../common/endpoint/types'; +import { + ResolverEvent, + ResolverNodeStats, + ResolverRelatedEvents, +} from '../../common/endpoint/types'; /** * Redux state for the Resolver feature. Properties on this interface are populated via multiple reducers using redux's `combineReducers`. @@ -42,6 +46,14 @@ export interface ResolverUIState { * The ID attribute of the resolver's currently selected descendant. */ readonly selectedDescendantId: string | null; + /** + * The entity_id of the process for the resolver's currently selected descendant. + */ + readonly processEntityIdOfSelectedDescendant: string | null; + /** + * Which panel the ui should display + */ + readonly panelToDisplay: string | null; } /** @@ -136,6 +148,8 @@ export type CameraState = { export interface DataState { readonly results: readonly ResolverEvent[]; readonly relatedEventsStats: Map; + readonly relatedEvents: Map; + readonly relatedEventsReady: Map; isLoading: boolean; hasError: boolean; } diff --git a/x-pack/plugins/security_solution/public/resolver/view/assets.tsx b/x-pack/plugins/security_solution/public/resolver/view/assets.tsx index 150ab3d93a8c7..82f969b755b2f 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/assets.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/assets.tsx @@ -12,6 +12,9 @@ import styled from 'styled-components'; import { i18n } from '@kbn/i18n'; import { useUiSetting } from '../../common/lib/kibana'; import { DEFAULT_DARK_MODE } from '../../../common/constants'; +import { ResolverEvent } from '../../../common/endpoint/types'; +import * as processModel from '../models/process_event'; +import { ResolverProcessType } from '../types'; type ResolverColorNames = | 'descriptionText' @@ -405,7 +408,37 @@ export const SymbolDefinitions = styled(SymbolDefinitionsComponent)` height: 0; `; -export const useResolverTheme = (): { colorMap: ColorMap; nodeAssets: NodeStyleMap } => { +const processTypeToCube: Record = { + processCreated: 'runningProcessCube', + processRan: 'runningProcessCube', + processTerminated: 'terminatedProcessCube', + unknownProcessEvent: 'runningProcessCube', + processCausedAlert: 'runningTriggerCube', + unknownEvent: 'runningProcessCube', +}; + +/** + * This will return which type the ResolverEvent will display as in the Node component + * it will be something like 'runningProcessCube' or 'terminatedProcessCube' + * + * @param processEvent {ResolverEvent} the event to get the Resolver Component Node type of + */ +export function nodeType(processEvent: ResolverEvent): keyof NodeStyleMap { + const processType = processModel.eventType(processEvent); + if (processType in processTypeToCube) { + return processTypeToCube[processType]; + } + return 'runningProcessCube'; +} + +/** + * A hook to bring Resolver theming information into components. + */ +export const useResolverTheme = (): { + colorMap: ColorMap; + nodeAssets: NodeStyleMap; + cubeAssetsForNode: (arg0: ResolverEvent) => NodeStyleConfig; +} => { const isDarkMode = useUiSetting(DEFAULT_DARK_MODE); const theme = isDarkMode ? euiThemeAmsterdamDark : euiThemeAmsterdamLight; @@ -478,7 +511,15 @@ export const useResolverTheme = (): { colorMap: ColorMap; nodeAssets: NodeStyleM }, }; - return { colorMap, nodeAssets }; + /** + * Export assets to reuse symbols/icons in other places in the app (e.g. tables, etc.) + * @param processEvent : The process event to fetch node assets for + */ + function cubeAssetsForNode(processEvent: ResolverEvent) { + return nodeAssets[nodeType(processEvent)]; + } + + return { colorMap, nodeAssets, cubeAssetsForNode }; }; export const calculateResolverFontSize = ( diff --git a/x-pack/plugins/security_solution/public/resolver/view/index.tsx b/x-pack/plugins/security_solution/public/resolver/view/index.tsx index 0e15cd5c4e1da..9dfc9a45fafeb 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/index.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/index.tsx @@ -53,9 +53,9 @@ const StyledResolver = styled.div` const StyledPanel = styled(Panel)` position: absolute; - left: 1em; - top: 1em; - max-height: calc(100% - 2em); + left: 0; + top: 0; + bottom: 0; overflow: auto; width: 25em; max-width: 50%; diff --git a/x-pack/plugins/security_solution/public/resolver/view/panel.tsx b/x-pack/plugins/security_solution/public/resolver/view/panel.tsx index 2b67a4ac16d4e..4bef2f4d2a10e 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/panel.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/panel.tsx @@ -3,191 +3,283 @@ * 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, { memo, useCallback, useMemo, useContext } from 'react'; -import { - EuiPanel, - EuiBadge, - EuiBasicTableColumn, - EuiTitle, - EuiHorizontalRule, - EuiInMemoryTable, -} from '@elastic/eui'; -import euiVars from '@elastic/eui/dist/eui_theme_light.json'; + +import React, { + memo, + useCallback, + useMemo, + useContext, + useLayoutEffect, + useState, + useEffect, +} from 'react'; import { useSelector } from 'react-redux'; -import { i18n } from '@kbn/i18n'; -import { SideEffectContext } from './side_effect_context'; -import { ResolverEvent } from '../../../common/endpoint/types'; -import * as event from '../../../common/endpoint/models/event'; -import { useResolverDispatch } from './use_resolver_dispatch'; +import { useHistory } from 'react-router-dom'; +// eslint-disable-next-line import/no-nodejs-modules +import querystring from 'querystring'; +import { EuiPanel } from '@elastic/eui'; +import { displayNameRecord } from './process_event_dot'; import * as selectors from '../store/selectors'; +import { useResolverDispatch } from './use_resolver_dispatch'; +import * as event from '../../../common/endpoint/models/event'; +import { ResolverEvent } from '../../../common/endpoint/types'; +import { SideEffectContext } from './side_effect_context'; +import { ProcessEventListNarrowedByType } from './panels/panel_content_related_list'; +import { EventCountsForProcess } from './panels/panel_content_related_counts'; +import { ProcessDetails } from './panels/panel_content_process_detail'; +import { ProcessListWithCounts } from './panels/panel_content_process_list'; +import { RelatedEventDetail } from './panels/panel_content_related_detail'; +import { CrumbInfo } from './panels/panel_content_utilities'; -const HorizontalRule = memo(function HorizontalRule() { - return ( - - ); -}); - -export const Panel = memo(function Event({ className }: { className?: string }) { - interface ProcessTableView { - name: string; - timestamp?: Date; - event: ResolverEvent; - } +/** + * The team decided to use this table to determine which breadcrumbs/view to display: + * + * | Crumb/Table | &crumbId | &crumbEvent | + * | :--------------------- | :------------------------- | :---------------------- | + * | all processes/default | null | null | + * | process detail | entity_id of process | null | + * | relateds count by type | entity_id of process | 'all' | + * | relateds list 1 type | entity_id of process | valid related event type | + * | related event detail | event_id of related event | entity_id of process | + * + * 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 + */ +const PanelContent = memo(function PanelContent() { + const history = useHistory(); + const urlSearch = history.location.search; + const dispatch = useResolverDispatch(); - const { processNodePositions } = useSelector(selectors.processNodePositionsAndEdgeLineSegments); - const { timestamp } = useContext(SideEffectContext); + const queryParams: CrumbInfo = useMemo(() => { + return { crumbId: '', crumbEvent: '', ...querystring.parse(urlSearch.slice(1)) }; + }, [urlSearch]); - const processTableView: ProcessTableView[] = useMemo( - () => - [...processNodePositions.keys()].map((processEvent) => { - let dateTime; - const eventTime = event.eventTimestamp(processEvent); - const name = event.eventName(processEvent); - if (eventTime) { - const date = new Date(eventTime); - if (isFinite(date.getTime())) { - dateTime = date; - } - } - return { - name, - timestamp: dateTime, - event: processEvent, - }; - }), - [processNodePositions] - ); + const graphableProcesses = useSelector(selectors.graphableProcesses); + const graphableProcessEntityIds = useMemo(() => { + return new Set(graphableProcesses.map(event.entityId)); + }, [graphableProcesses]); + // The entity id in query params of a graphable process (or false if none is found) + // For 1 case (the related detail, see below), the process id will be in crumbEvent instead of crumbId + const idFromParams = useMemo(() => { + if (graphableProcessEntityIds.has(queryParams.crumbId)) { + return queryParams.crumbId; + } + if (graphableProcessEntityIds.has(queryParams.crumbEvent)) { + return queryParams.crumbEvent; + } + return ''; + }, [queryParams, graphableProcessEntityIds]); - const formatter = new Intl.DateTimeFormat(i18n.getLocale(), { - year: 'numeric', - month: '2-digit', - day: '2-digit', - hour: '2-digit', - minute: '2-digit', - second: '2-digit', - }); + // The "selected" node (and its corresponding event) in the tree control. + // It may need to be synchronized with the ID indicated as selected via the `idFromParams` + // memo above. When this is the case, it is handled by the layout effect below. + const selectedDescendantProcessId = useSelector(selectors.uiSelectedDescendantProcessId); + const uiSelectedEvent = useMemo(() => { + return graphableProcesses.find((evt) => event.entityId(evt) === selectedDescendantProcessId); + }, [graphableProcesses, selectedDescendantProcessId]); - const dispatch = useResolverDispatch(); + // Until an event is dispatched during update, the event indicated as selected by params may + // be different than the one in state. + const paramsSelectedEvent = useMemo(() => { + return graphableProcesses.find((evt) => event.entityId(evt) === idFromParams); + }, [graphableProcesses, idFromParams]); + const { timestamp } = useContext(SideEffectContext); + const [lastUpdatedProcess, setLastUpdatedProcess] = useState(null); - const handleBringIntoViewClick = useCallback( - (processTableViewItem) => { + /** + * When the ui-selected node is _not_ the one indicated by the query params, but the id from params _is_ in the current tree, + * dispatch a selection action to amend the UI state to hold the query id as "selected". + * This is to cover cases where users e.g. share links to reconstitute a Resolver state or + * an effect pushes a new process id to the query params. + */ + useLayoutEffect(() => { + if ( + paramsSelectedEvent && + // Check state to ensure we don't dispatch this in a way that causes unnecessary re-renders, or disrupts animation: + paramsSelectedEvent !== lastUpdatedProcess && + paramsSelectedEvent !== uiSelectedEvent + ) { + setLastUpdatedProcess(paramsSelectedEvent); dispatch({ - type: 'userBroughtProcessIntoView', + type: 'appDetectedNewIdFromQueryParams', payload: { time: timestamp(), - process: processTableViewItem.event, + process: paramsSelectedEvent, }, }); + } + }, [dispatch, uiSelectedEvent, paramsSelectedEvent, lastUpdatedProcess, timestamp]); + + /** + * This updates the breadcrumb nav and the panel view. It's supplied to each + * panel content view to allow them to dispatch transitions to each other. + */ + const pushToQueryParams = useCallback( + (newCrumbs: CrumbInfo) => { + // Construct a new set of params from the current set (minus empty params) + // by assigning the new set of params provided in `newCrumbs` + const crumbsToPass = { + ...querystring.parse(urlSearch.slice(1)), + ...newCrumbs, + }; + + // If either was passed in as empty, remove it from the record + if (crumbsToPass.crumbId === '') { + delete crumbsToPass.crumbId; + } + if (crumbsToPass.crumbEvent === '') { + delete crumbsToPass.crumbEvent; + } + + const relativeURL = { search: querystring.stringify(crumbsToPass) }; + // We probably don't want to nuke the user's history with a huge + // trail of these, thus `.replace` instead of `.push` + return history.replace(relativeURL); }, - [dispatch, timestamp] + [history, urlSearch] ); - const columns = useMemo>>( - () => [ - { - field: 'name', - name: i18n.translate( - 'xpack.securitySolution.endpoint.resolver.panel.tabel.row.processNameTitle', - { - defaultMessage: 'Process Name', - } - ), - sortable: true, - truncateText: true, - render(name: string) { - return name === '' ? ( - - {i18n.translate( - 'xpack.securitySolution.endpoint.resolver.panel.table.row.valueMissingDescription', - { - defaultMessage: 'Value is missing', - } - )} - - ) : ( - name - ); - }, - }, - { - field: 'timestamp', - name: i18n.translate( - 'xpack.securitySolution.endpoint.resolver.panel.tabel.row.timestampTitle', - { - defaultMessage: 'Timestamp', - } - ), - dataType: 'date', - sortable: true, - render(eventDate?: Date) { - return eventDate ? ( - formatter.format(eventDate) - ) : ( - - {i18n.translate( - 'xpack.securitySolution.endpoint.resolver.panel.tabel.row.timestampInvalidLabel', - { - defaultMessage: 'invalid', - } - )} - - ); - }, - }, - { - name: i18n.translate( - 'xpack.securitySolution.endpoint.resolver.panel.tabel.row.actionsTitle', - { - defaultMessage: 'Actions', - } - ), - actions: [ - { - name: i18n.translate( - 'xpack.securitySolution.endpoint.resolver.panel.tabel.row.actions.bringIntoViewButtonLabel', - { - defaultMessage: 'Bring into view', - } - ), - description: i18n.translate( - 'xpack.securitySolution.endpoint.resolver.panel.tabel.row.bringIntoViewLabel', - { - defaultMessage: 'Bring the process into view on the map.', - } - ), - type: 'icon', - icon: 'flag', - onClick: handleBringIntoViewClick, - }, - ], - }, - ], - [formatter, handleBringIntoViewClick] - ); + // GO JONNY GO + const relatedEventStats = useSelector(selectors.relatedEventsStats); + const { crumbId, crumbEvent } = queryParams; + const relatedStatsForIdFromParams = useMemo(() => { + if (idFromParams) { + return relatedEventStats.get(idFromParams); + } + return undefined; + }, [relatedEventStats, idFromParams]); + + /** + * Determine which set of breadcrumbs to display based on the query parameters + * for the table & breadcrumb nav. + * + */ + const panelToShow = useMemo(() => { + if (crumbEvent === '' && crumbId === '') { + /** + * | Crumb/Table | &crumbId | &crumbEvent | + * | :--------------------- | :------------------------- | :---------------------- | + * | all processes/default | null | null | + */ + return 'processListWithCounts'; + } + + if (graphableProcessEntityIds.has(crumbId)) { + /** + * | Crumb/Table | &crumbId | &crumbEvent | + * | :--------------------- | :------------------------- | :---------------------- | + * | process detail | entity_id of process | null | + */ + if (crumbEvent === '' && uiSelectedEvent) { + return 'processDetails'; + } + + /** + * | Crumb/Table | &crumbId | &crumbEvent | + * | :--------------------- | :------------------------- | :---------------------- | + * | relateds count by type | entity_id of process | 'all' | + */ + + if (crumbEvent === 'all' && uiSelectedEvent) { + return 'eventCountsForProcess'; + } + + /** + * | Crumb/Table | &crumbId | &crumbEvent | + * | :--------------------- | :------------------------- | :---------------------- | + * | relateds list 1 type | entity_id of process | valid related event type | + */ + + if (crumbEvent in displayNameRecord && uiSelectedEvent) { + return 'processEventListNarrowedByType'; + } + } + + if (graphableProcessEntityIds.has(crumbEvent)) { + /** + * | Crumb/Table | &crumbId | &crumbEvent | + * | :--------------------- | :------------------------- | :---------------------- | + * | related event detail | event_id of related event | entity_id of process | + */ + return 'relatedEventDetail'; + } + + // The default 'Event List' / 'List of all processes' view + return 'processListWithCounts'; + }, [uiSelectedEvent, crumbEvent, crumbId, graphableProcessEntityIds]); + + useEffect(() => { + // dispatch `appDisplayedDifferentPanel` to sync state with which panel gets displayed + dispatch({ + type: 'appDisplayedDifferentPanel', + payload: panelToShow, + }); + }, [panelToShow, dispatch]); + + const currentPanelView = useSelector(selectors.currentPanelView); + + const panelInstance = useMemo(() => { + if (currentPanelView === 'processDetails') { + return ( + + ); + } + + if (currentPanelView === 'eventCountsForProcess') { + return ( + + ); + } + + if (currentPanelView === 'processEventListNarrowedByType') { + return ( + + ); + } + + if (currentPanelView === 'relatedEventDetail') { + const parentCount: number = Object.values( + relatedStatsForIdFromParams?.events.byCategory || {} + ).reduce((sum, val) => sum + val, 0); + return ( + + ); + } + // The default 'Event List' / 'List of all processes' view + return ; + }, [ + uiSelectedEvent, + crumbEvent, + crumbId, + pushToQueryParams, + relatedStatsForIdFromParams, + currentPanelView, + ]); + + return <>{panelInstance}; +}); +PanelContent.displayName = 'PanelContent'; + +export const Panel = memo(function Event({ className }: { className?: string }) { return ( - -

- {i18n.translate('xpack.securitySolution.endpoint.resolver.panel.title', { - defaultMessage: 'Processes', - })} -

-
- - items={processTableView} columns={columns} sorting /> +
); }); +Panel.displayName = 'Panel'; diff --git a/x-pack/plugins/security_solution/public/resolver/view/panels/panel_content_error.tsx b/x-pack/plugins/security_solution/public/resolver/view/panels/panel_content_error.tsx new file mode 100644 index 0000000000000..c9a536fd5932d --- /dev/null +++ b/x-pack/plugins/security_solution/public/resolver/view/panels/panel_content_error.tsx @@ -0,0 +1,61 @@ +/* + * 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 { EuiSpacer, EuiText, EuiButtonEmpty } from '@elastic/eui'; +import React, { memo, useMemo } from 'react'; +import { CrumbInfo, StyledBreadcrumbs } from './panel_content_utilities'; + +/** + * Display an error in the panel when something goes wrong and give the user a way to "retreat" back to a default state. + * + * @param {function} pushToQueryparams A function to update the hash value in the URL to control panel state + * @param {string} translatedErrorMessage The message to display in the panel when something goes wrong + */ +export const PanelContentError = memo(function ({ + translatedErrorMessage, + pushToQueryParams, +}: { + translatedErrorMessage: string; + pushToQueryParams: (arg0: CrumbInfo) => unknown; +}) { + const crumbs = useMemo(() => { + return [ + { + text: i18n.translate('xpack.securitySolution.endpoint.resolver.panel.error.events', { + defaultMessage: 'Events', + }), + onClick: () => { + pushToQueryParams({ crumbId: '', crumbEvent: '' }); + }, + }, + { + text: i18n.translate('xpack.securitySolution.endpoint.resolver.panel.error.error', { + defaultMessage: 'Error', + }), + onClick: () => {}, + }, + ]; + }, [pushToQueryParams]); + return ( + <> + + + {translatedErrorMessage} + + { + pushToQueryParams({ crumbId: '', crumbEvent: '' }); + }} + > + {i18n.translate('xpack.securitySolution.endpoint.resolver.panel.error.goBack', { + defaultMessage: 'Click this link to return to the list of all processes.', + })} + + + ); +}); +PanelContentError.displayName = 'TableServiceError'; diff --git a/x-pack/plugins/security_solution/public/resolver/view/panels/panel_content_process_detail.tsx b/x-pack/plugins/security_solution/public/resolver/view/panels/panel_content_process_detail.tsx new file mode 100644 index 0000000000000..fcb7bf1d12e1b --- /dev/null +++ b/x-pack/plugins/security_solution/public/resolver/view/panels/panel_content_process_detail.tsx @@ -0,0 +1,211 @@ +/* + * 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, { memo, useMemo } from 'react'; +import { i18n } from '@kbn/i18n'; +import { + htmlIdGenerator, + EuiSpacer, + EuiTitle, + EuiText, + EuiTextColor, + EuiDescriptionList, +} from '@elastic/eui'; +import styled from 'styled-components'; +import { FormattedMessage } from 'react-intl'; +import * as event from '../../../../common/endpoint/models/event'; +import { CrumbInfo, formatDate, StyledBreadcrumbs } from './panel_content_utilities'; +import { + processPath, + processPid, + userInfoForProcess, + processParentPid, + md5HashForProcess, + argsForProcess, +} from '../../models/process_event'; +import { CubeForProcess } from './process_cube_icon'; +import { ResolverEvent } from '../../../../common/endpoint/types'; +import { useResolverTheme } from '../assets'; + +const StyledDescriptionList = styled(EuiDescriptionList)` + &.euiDescriptionList.euiDescriptionList--column dt.euiDescriptionList__title.desc-title { + max-width: 8em; + } +`; + +/** + * A description list view of all the Metadata that goes with a particular process event, like: + * Created, Pid, User/Domain, etc. + */ +export const ProcessDetails = memo(function ProcessDetails({ + processEvent, + pushToQueryParams, +}: { + processEvent: ResolverEvent; + pushToQueryParams: (arg0: CrumbInfo) => unknown; +}) { + const processName = event.eventName(processEvent); + const processInfoEntry = useMemo(() => { + const eventTime = event.eventTimestamp(processEvent); + const dateTime = eventTime ? formatDate(eventTime) : ''; + + const createdEntry = { + title: i18n.translate( + 'xpack.securitySolution.endpoint.resolver.panel.processDescList.created', + { + defaultMessage: 'Created', + } + ), + description: dateTime, + }; + + const pathEntry = { + title: i18n.translate('xpack.securitySolution.endpoint.resolver.panel.processDescList.path', { + defaultMessage: 'Path', + }), + description: processPath(processEvent), + }; + + const pidEntry = { + title: i18n.translate('xpack.securitySolution.endpoint.resolver.panel.processDescList.pid', { + defaultMessage: 'PID', + }), + description: processPid(processEvent), + }; + + const userEntry = { + title: i18n.translate('xpack.securitySolution.endpoint.resolver.panel.processDescList.user', { + defaultMessage: 'User', + }), + description: (userInfoForProcess(processEvent) as { name: string }).name, + }; + + const domainEntry = { + title: i18n.translate( + 'xpack.securitySolution.endpoint.resolver.panel.processDescList.domain', + { + defaultMessage: 'Domain', + } + ), + description: (userInfoForProcess(processEvent) as { domain: string }).domain, + }; + + const parentPidEntry = { + title: i18n.translate( + 'xpack.securitySolution.endpoint.resolver.panel.processDescList.parentPid', + { + defaultMessage: 'Parent PID', + } + ), + description: processParentPid(processEvent), + }; + + const md5Entry = { + title: i18n.translate( + 'xpack.securitySolution.endpoint.resolver.panel.processDescList.md5hash', + { + defaultMessage: 'MD5', + } + ), + description: md5HashForProcess(processEvent), + }; + + const commandLineEntry = { + title: i18n.translate( + 'xpack.securitySolution.endpoint.resolver.panel.processDescList.commandLine', + { + defaultMessage: 'Command Line', + } + ), + description: argsForProcess(processEvent), + }; + + // This is the data in {title, description} form for the EUIDescriptionList to display + const processDescriptionListData = [ + createdEntry, + pathEntry, + pidEntry, + userEntry, + domainEntry, + parentPidEntry, + md5Entry, + commandLineEntry, + ] + .filter((entry) => { + return entry.description; + }) + .map((entry) => { + return { + ...entry, + description: String(entry.description), + }; + }); + + return processDescriptionListData; + }, [processEvent]); + + const crumbs = useMemo(() => { + return [ + { + text: i18n.translate( + 'xpack.securitySolution.endpoint.resolver.panel.processDescList.events', + { + defaultMessage: 'Events', + } + ), + onClick: () => { + pushToQueryParams({ crumbId: '', crumbEvent: '' }); + }, + }, + { + text: ( + <> + + + ), + onClick: () => {}, + }, + ]; + }, [processName, pushToQueryParams]); + const { cubeAssetsForNode } = useResolverTheme(); + const { descriptionText } = useMemo(() => { + if (!processEvent) { + return { descriptionText: '' }; + } + return cubeAssetsForNode(processEvent); + }, [processEvent, cubeAssetsForNode]); + + const titleId = useMemo(() => htmlIdGenerator('resolverTable')(), []); + return ( + <> + + + +

+ + {processName} +

+
+ + + {descriptionText} + + + + + + ); +}); +ProcessDetails.displayName = 'ProcessDetails'; diff --git a/x-pack/plugins/security_solution/public/resolver/view/panels/panel_content_process_list.tsx b/x-pack/plugins/security_solution/public/resolver/view/panels/panel_content_process_list.tsx new file mode 100644 index 0000000000000..86ae10b3b38c8 --- /dev/null +++ b/x-pack/plugins/security_solution/public/resolver/view/panels/panel_content_process_list.tsx @@ -0,0 +1,164 @@ +/* + * 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, { memo, useContext, useCallback, useMemo } from 'react'; +import { + EuiBasicTableColumn, + EuiBadge, + EuiButtonEmpty, + EuiSpacer, + EuiInMemoryTable, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { useSelector } from 'react-redux'; +import * as event from '../../../../common/endpoint/models/event'; +import * as selectors from '../../store/selectors'; +import { CrumbInfo, formatter, StyledBreadcrumbs } from './panel_content_utilities'; +import { useResolverDispatch } from '../use_resolver_dispatch'; +import { SideEffectContext } from '../side_effect_context'; +import { CubeForProcess } from './process_cube_icon'; +import { ResolverEvent } from '../../../../common/endpoint/types'; + +/** + * The "default" view for the panel: A list of all the processes currently in the graph. + * + * @param {function} pushToQueryparams A function to update the hash value in the URL to control panel state + */ +export const ProcessListWithCounts = memo(function ProcessListWithCounts({ + pushToQueryParams, +}: { + pushToQueryParams: (arg0: CrumbInfo) => unknown; +}) { + interface ProcessTableView { + name: string; + timestamp?: Date; + event: ResolverEvent; + } + + const dispatch = useResolverDispatch(); + const { timestamp } = useContext(SideEffectContext); + const handleBringIntoViewClick = useCallback( + (processTableViewItem) => { + dispatch({ + type: 'userBroughtProcessIntoView', + payload: { + time: timestamp(), + process: processTableViewItem.event, + }, + }); + pushToQueryParams({ crumbId: event.entityId(processTableViewItem.event), crumbEvent: '' }); + }, + [dispatch, timestamp, pushToQueryParams] + ); + + const columns = useMemo>>( + () => [ + { + field: 'name', + name: i18n.translate( + 'xpack.securitySolution.endpoint.resolver.panel.table.row.processNameTitle', + { + defaultMessage: 'Process Name', + } + ), + sortable: true, + truncateText: true, + render(name: string, item: ProcessTableView) { + return name === '' ? ( + + {i18n.translate( + 'xpack.securitySolution.endpoint.resolver.panel.table.row.valueMissingDescription', + { + defaultMessage: 'Value is missing', + } + )} + + ) : ( + { + handleBringIntoViewClick(item); + pushToQueryParams({ crumbId: event.entityId(item.event), crumbEvent: '' }); + }} + > + + {name} + + ); + }, + }, + { + field: 'timestamp', + name: i18n.translate( + 'xpack.securitySolution.endpoint.resolver.panel.table.row.timestampTitle', + { + defaultMessage: 'Timestamp', + } + ), + dataType: 'date', + sortable: true, + render(eventDate?: Date) { + return eventDate ? ( + formatter.format(eventDate) + ) : ( + + {i18n.translate( + 'xpack.securitySolution.endpoint.resolver.panel.table.row.timestampInvalidLabel', + { + defaultMessage: 'invalid', + } + )} + + ); + }, + }, + ], + [pushToQueryParams, handleBringIntoViewClick] + ); + + const { processNodePositions } = useSelector(selectors.processNodePositionsAndEdgeLineSegments); + const processTableView: ProcessTableView[] = useMemo( + () => + [...processNodePositions.keys()].map((processEvent) => { + let dateTime; + const eventTime = event.eventTimestamp(processEvent); + const name = event.eventName(processEvent); + if (eventTime) { + const date = new Date(eventTime); + if (isFinite(date.getTime())) { + dateTime = date; + } + } + return { + name, + timestamp: dateTime, + event: processEvent, + }; + }), + [processNodePositions] + ); + + const crumbs = useMemo(() => { + return [ + { + text: i18n.translate( + 'xpack.securitySolution.endpoint.resolver.panel.processListWithCounts.events', + { + defaultMessage: 'All Process Events', + } + ), + onClick: () => {}, + }, + ]; + }, []); + + return ( + <> + + + items={processTableView} columns={columns} sorting /> + + ); +}); +ProcessListWithCounts.displayName = 'ProcessListWithCounts'; diff --git a/x-pack/plugins/security_solution/public/resolver/view/panels/panel_content_related_counts.tsx b/x-pack/plugins/security_solution/public/resolver/view/panels/panel_content_related_counts.tsx new file mode 100644 index 0000000000000..2e4211f568ffe --- /dev/null +++ b/x-pack/plugins/security_solution/public/resolver/view/panels/panel_content_related_counts.tsx @@ -0,0 +1,144 @@ +/* + * 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, { memo, useMemo } from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiBasicTableColumn, EuiButtonEmpty, EuiSpacer, EuiInMemoryTable } from '@elastic/eui'; +import { FormattedMessage } from 'react-intl'; +import { CrumbInfo, StyledBreadcrumbs } from './panel_content_utilities'; + +import * as event from '../../../../common/endpoint/models/event'; +import { ResolverEvent, ResolverNodeStats } from '../../../../common/endpoint/types'; + +/** + * This view gives counts for all the related events of a process grouped by related event type. + * It should look something like: + * + * | Count | Event Type | + * | :--------------------- | :------------------------- | + * | 5 | DNS | + * | 12 | Registry | + * | 2 | Network | + * + */ +export const EventCountsForProcess = memo(function EventCountsForProcess({ + processEvent, + pushToQueryParams, + relatedStats, +}: { + processEvent: ResolverEvent; + pushToQueryParams: (arg0: CrumbInfo) => unknown; + relatedStats: ResolverNodeStats; +}) { + interface EventCountsTableView { + name: 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 crumbs = useMemo(() => { + return [ + { + text: eventsString, + onClick: () => { + pushToQueryParams({ crumbId: '', crumbEvent: '' }); + }, + }, + { + text: processName, + onClick: () => { + pushToQueryParams({ crumbId: processEntityId, crumbEvent: '' }); + }, + }, + { + text: ( + <> + + + ), + onClick: () => { + pushToQueryParams({ crumbId: processEntityId, crumbEvent: '' }); + }, + }, + ]; + }, [processName, totalCount, processEntityId, pushToQueryParams, eventsString]); + const rows = useMemo(() => { + return Object.entries(relatedEventsState.stats).map( + ([eventType, count]): EventCountsTableView => { + return { + name: eventType, + count, + }; + } + ); + }, [relatedEventsState]); + const columns = useMemo>>( + () => [ + { + field: 'count', + name: i18n.translate('xpack.securitySolution.endpoint.resolver.panel.table.row.count', { + defaultMessage: 'Count', + }), + width: '20%', + sortable: true, + }, + { + field: 'name', + name: i18n.translate('xpack.securitySolution.endpoint.resolver.panel.table.row.eventType', { + defaultMessage: 'Event Type', + }), + width: '80%', + sortable: true, + render(name: string) { + return ( + { + pushToQueryParams({ crumbId: event.entityId(processEvent), crumbEvent: name }); + }} + > + {name} + + ); + }, + }, + ], + [pushToQueryParams, processEvent] + ); + return ( + <> + + + items={rows} columns={columns} sorting /> + + ); +}); +EventCountsForProcess.displayName = 'EventCountsForProcess'; diff --git a/x-pack/plugins/security_solution/public/resolver/view/panels/panel_content_related_detail.tsx b/x-pack/plugins/security_solution/public/resolver/view/panels/panel_content_related_detail.tsx new file mode 100644 index 0000000000000..1fe6599e0829a --- /dev/null +++ b/x-pack/plugins/security_solution/public/resolver/view/panels/panel_content_related_detail.tsx @@ -0,0 +1,365 @@ +/* + * 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, { memo, useMemo, useEffect, 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 { CrumbInfo, formatDate, StyledBreadcrumbs, BoldCode } from './panel_content_utilities'; +import * as event from '../../../../common/endpoint/models/event'; +import { ResolverEvent } from '../../../../common/endpoint/types'; +import * as selectors from '../../store/selectors'; +import { useResolverDispatch } from '../use_resolver_dispatch'; +import { PanelContentError } from './panel_content_error'; + +/** + * 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 `
` entries + */ +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); + } + } +}; + +// 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; + } + &.euiDescriptionList.euiDescriptionList--column dd.euiDescriptionList__description { + max-width: calc(100% - 8.5em); + overflow-wrap: break-word; + } +`); + +// Styling subtitles, per UX review: +const StyledFlexTitle = memo(styled('h3')` + display: flex; + flex-flow: row; + font-size: 1.2em; +`); +const StyledTitleRule = memo(styled('hr')` + &.euiHorizontalRule.euiHorizontalRule--full.euiHorizontalRule--marginSmall.override { + display: block; + flex: 1; + margin-left: 0.5em; + } +`); + +const TitleHr = memo(() => { + return ( + + ); +}); +TitleHr.displayName = 'TitleHR'; + +/** + * 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 RelatedEventDetail = memo(function RelatedEventDetail({ + relatedEventId, + parentEvent, + pushToQueryParams, + countForParent, +}: { + relatedEventId: string; + parentEvent: ResolverEvent; + pushToQueryParams: (arg0: CrumbInfo) => unknown; + countForParent: number | undefined; +}) { + 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(); + + /** + * If we don't have the related events for the parent yet, use this effect + * to request them. + */ + useEffect(() => { + if (typeof relatedsReady === 'undefined') { + dispatch({ + type: 'appDetectedMissingEventData', + payload: processEntityId, + }); + } + }, [relatedsReady, dispatch, processEntityId]); + + const relatedEventsForThisProcess = useSelector(selectors.relatedEventsByEntityId).get( + processEntityId! + ); + + const [relatedEventToShowDetailsFor, countBySameCategory, relatedEventCategory] = useMemo(() => { + if (!relatedEventsForThisProcess) { + return [undefined, 0]; + } + const specificEvent = relatedEventsForThisProcess.events.find( + (evt) => event.eventId(evt) === relatedEventId + ); + // For breadcrumbs: + const specificCategory = specificEvent && event.primaryEventCategory(specificEvent); + const countOfCategory = relatedEventsForThisProcess.events.reduce((sumtotal, evt) => { + return event.primaryEventCategory(evt) === specificCategory ? sumtotal + 1 : sumtotal; + }, 0); + return [specificEvent, countOfCategory, specificCategory || naString]; + }, [relatedEventsForThisProcess, naString, relatedEventId]); + + const [sections, formattedDate] = useMemo(() => { + if (!relatedEventToShowDetailsFor) { + // This could happen if user relaods from URL param and requests an eventId that no longer exists + return [[], naString]; + } + // Assuming these details (agent, ecs, process) aren't as helpful, can revisit + const { + agent, + ecs, + process, + ...relevantData + } = relatedEventToShowDetailsFor as ResolverEvent & { + ecs: unknown; + }; + let displayDate = ''; + const sectionData: Array<{ + sectionTitle: string; + entries: Array<{ title: string; description: string }>; + }> = 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 [sectionData, displayDate]; + }, [relatedEventToShowDetailsFor, naString]); + + const waitCrumbs = useMemo(() => { + return [ + { + text: eventsString, + onClick: () => { + pushToQueryParams({ crumbId: '', crumbEvent: '' }); + }, + }, + ]; + }, [pushToQueryParams, eventsString]); + + const { subject = '', descriptor = '' } = relatedEventToShowDetailsFor + ? event.descriptiveName(relatedEventToShowDetailsFor) + : {}; + const crumbs = useMemo(() => { + return [ + { + text: eventsString, + onClick: () => { + pushToQueryParams({ crumbId: '', crumbEvent: '' }); + }, + }, + { + text: processName, + onClick: () => { + pushToQueryParams({ crumbId: processEntityId!, crumbEvent: '' }); + }, + }, + { + text: ( + <> + + + ), + onClick: () => { + pushToQueryParams({ crumbId: processEntityId!, crumbEvent: 'all' }); + }, + }, + { + text: ( + <> + + + ), + onClick: () => { + pushToQueryParams({ + crumbId: processEntityId!, + crumbEvent: relatedEventCategory || 'all', + }); + }, + }, + { + text: relatedEventToShowDetailsFor ? ( + + ) : ( + naString + ), + onClick: () => {}, + }, + ]; + }, [ + processName, + processEntityId, + eventsString, + pushToQueryParams, + totalCount, + countBySameCategory, + naString, + relatedEventCategory, + relatedEventToShowDetailsFor, + subject, + descriptor, + ]); + + /** + * If the ship hasn't come in yet, wait on the dock + */ + if (!relatedsReady) { + const waitingString = i18n.translate( + 'xpack.securitySolution.endpoint.resolver.panel.relatedDetail.wait', + { + defaultMessage: 'Waiting For Events...', + } + ); + return ( + <> + + + +

{waitingString}

+
+ + ); + } + + /** + * 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 ( + + ); + } + + return ( + <> + + + + + + + + + + + + + + {sections.map(({ sectionTitle, entries }, index) => { + return ( + + {index === 0 ? null : } + + + + {sectionTitle} + + + + + + {index === sections.length - 1 ? null : } + + ); + })} + + ); +}); +RelatedEventDetail.displayName = 'RelatedEventDetail'; diff --git a/x-pack/plugins/security_solution/public/resolver/view/panels/panel_content_related_list.tsx b/x-pack/plugins/security_solution/public/resolver/view/panels/panel_content_related_list.tsx new file mode 100644 index 0000000000000..c9c303010d10d --- /dev/null +++ b/x-pack/plugins/security_solution/public/resolver/view/panels/panel_content_related_list.tsx @@ -0,0 +1,247 @@ +/* + * 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, { memo, useMemo, useEffect, Fragment } from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiTitle, EuiSpacer, EuiText, EuiButtonEmpty, EuiHorizontalRule } from '@elastic/eui'; +import { useSelector } from 'react-redux'; +import { FormattedMessage } from 'react-intl'; +import { CrumbInfo, formatDate, StyledBreadcrumbs, BoldCode } from './panel_content_utilities'; +import * as event from '../../../../common/endpoint/models/event'; +import { ResolverEvent, ResolverNodeStats } from '../../../../common/endpoint/types'; +import * as selectors from '../../store/selectors'; +import { useResolverDispatch } from '../use_resolver_dispatch'; + +/** + * 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* | + */ + +interface MatchingEventEntry { + formattedDate: string; + eventType: string; + eventCategory: string; + name: { subject: string; descriptor?: string }; + entityId: string; + setQueryParams: () => void; +} + +const DisplayList = memo(function DisplayList({ + crumbs, + matchingEventEntries, +}: { + crumbs: Array<{ text: string | JSX.Element; onClick: () => void }>; + matchingEventEntries: MatchingEventEntry[]; +}) { + return ( + <> + + + <> + {matchingEventEntries.map((eventView, index) => { + const { subject, descriptor = '' } = eventView.name; + return ( + + + + + + + + + + + + {index === matchingEventEntries.length - 1 ? null : } + + ); + })} + + + ); +}); + +export const ProcessEventListNarrowedByType = memo(function ProcessEventListNarrowedByType({ + processEvent, + eventType, + relatedStats, + pushToQueryParams, +}: { + processEvent: ResolverEvent; + pushToQueryParams: (arg0: CrumbInfo) => unknown; + eventType: string; + relatedStats: ResolverNodeStats; +}) { + const processName = processEvent && event.eventName(processEvent); + const processEntityId = event.entityId(processEvent); + const totalCount = Object.values(relatedStats.events.byCategory).reduce( + (sum, val) => sum + val, + 0 + ); + const eventsString = i18n.translate( + 'xpack.securitySolution.endpoint.resolver.panel.processEventListByType.events', + { + defaultMessage: 'Events', + } + ); + const waitingString = i18n.translate( + 'xpack.securitySolution.endpoint.resolver.panel.processEventListByType.wait', + { + defaultMessage: 'Waiting For Events...', + } + ); + + const relatedsReadyMap = useSelector(selectors.relatedEventsReady); + const relatedsReady = relatedsReadyMap.get(processEntityId); + + const relatedEventsForThisProcess = useSelector(selectors.relatedEventsByEntityId).get( + processEntityId + ); + const dispatch = useResolverDispatch(); + + useEffect(() => { + if (typeof relatedsReady === 'undefined') { + dispatch({ + type: 'appDetectedMissingEventData', + payload: processEntityId, + }); + } + }, [relatedsReady, dispatch, processEntityId]); + + const waitCrumbs = useMemo(() => { + return [ + { + text: eventsString, + onClick: () => { + pushToQueryParams({ crumbId: '', crumbEvent: '' }); + }, + }, + ]; + }, [pushToQueryParams, eventsString]); + + const relatedEventsToDisplay = useMemo(() => { + return relatedEventsForThisProcess?.events || []; + }, [relatedEventsForThisProcess?.events]); + + /** + * A list entry will be displayed for each of these + */ + const matchingEventEntries: MatchingEventEntry[] = useMemo(() => { + const relateds = relatedEventsToDisplay + .reduce((a: ResolverEvent[], candidate) => { + if (event.primaryEventCategory(candidate) === eventType) { + a.push(candidate); + } + return a; + }, []) + .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), + entityId, + setQueryParams: () => { + pushToQueryParams({ crumbId: entityId, crumbEvent: processEntityId }); + }, + }; + }); + return relateds; + }, [relatedEventsToDisplay, eventType, processEntityId, pushToQueryParams]); + + const crumbs = useMemo(() => { + return [ + { + text: eventsString, + onClick: () => { + pushToQueryParams({ crumbId: '', crumbEvent: '' }); + }, + }, + { + text: processName, + onClick: () => { + pushToQueryParams({ crumbId: processEntityId, crumbEvent: '' }); + }, + }, + { + text: ( + <> + + + ), + onClick: () => { + pushToQueryParams({ crumbId: processEntityId, crumbEvent: 'all' }); + }, + }, + { + text: ( + <> + + + ), + onClick: () => {}, + }, + ]; + }, [ + eventType, + eventsString, + matchingEventEntries.length, + processEntityId, + processName, + pushToQueryParams, + totalCount, + ]); + + /** + * Wait here until the effect resolves... + */ + if (!relatedsReady) { + return ( + <> + + + +

{waitingString}

+
+ + ); + } + + return ; +}); +ProcessEventListNarrowedByType.displayName = 'ProcessEventListNarrowedByType'; diff --git a/x-pack/plugins/security_solution/public/resolver/view/panels/panel_content_utilities.tsx b/x-pack/plugins/security_solution/public/resolver/view/panels/panel_content_utilities.tsx new file mode 100644 index 0000000000000..65422d3d705d0 --- /dev/null +++ b/x-pack/plugins/security_solution/public/resolver/view/panels/panel_content_utilities.tsx @@ -0,0 +1,90 @@ +/* + * 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 { EuiBreadcrumbs, Breadcrumb, EuiCode } from '@elastic/eui'; +import styled from 'styled-components'; +import React, { memo } from 'react'; +import { useResolverTheme } from '../assets'; + +/** + * A bold version of EuiCode to display certain titles with + */ +export const BoldCode = styled(EuiCode)` + &.euiCodeBlock code.euiCodeBlock__code { + font-weight: 900; + } +`; + +/** + * The two query parameters we read/write on to control which view the table presents: + */ +export interface CrumbInfo { + readonly crumbId: string; + readonly crumbEvent: string; +} + +const ThemedBreadcrumbs = styled(EuiBreadcrumbs)<{ background: string; text: string }>` + &.euiBreadcrumbs.euiBreadcrumbs--responsive { + background-color: ${(props) => props.background}; + color: ${(props) => props.text}; + padding: 1em; + } +`; + +/** + * Breadcrumb menu with adjustments per direction from UX team + */ +export const StyledBreadcrumbs = memo(function StyledBreadcrumbs({ + breadcrumbs, + truncate, +}: { + breadcrumbs: Breadcrumb[]; + truncate?: boolean; +}) { + const { + colorMap: { resolverEdge, resolverEdgeText }, + } = useResolverTheme(); + return ( + + ); +}); + +/** + * Long formatter (to second) for DateTime + */ +export const formatter = new Intl.DateTimeFormat(i18n.getLocale(), { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', +}); + +const invalidDateText = i18n.translate( + 'xpack.securitySolution.enpdoint.resolver.panelutils.invaliddate', + { + defaultMessage: 'Invalid Date', + } +); +/** + * @param {ConstructorParameters[0]} timestamp To be passed through Date->Intl.DateTimeFormat + * @returns {string} A nicely formatted string for a date + */ +export function formatDate(timestamp: ConstructorParameters[0]) { + const date = new Date(timestamp); + if (isFinite(date.getTime())) { + return formatter.format(date); + } else { + return invalidDateText; + } +} diff --git a/x-pack/plugins/security_solution/public/resolver/view/panels/process_cube_icon.tsx b/x-pack/plugins/security_solution/public/resolver/view/panels/process_cube_icon.tsx new file mode 100644 index 0000000000000..29ffe154d5719 --- /dev/null +++ b/x-pack/plugins/security_solution/public/resolver/view/panels/process_cube_icon.tsx @@ -0,0 +1,46 @@ +/* + * 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, { memo } from 'react'; +import { ResolverEvent } from '../../../../common/endpoint/types'; +import { useResolverTheme } from '../assets'; + +/** + * During user testing, one user indicated they wanted to see stronger visual relationships between + * Nodes on the graph and what's in the table. Using the same symbol in both places (as below) could help with that. + */ +export const CubeForProcess = memo(function CubeForProcess({ + processEvent, +}: { + processEvent: ResolverEvent; +}) { + const { cubeAssetsForNode } = useResolverTheme(); + const { cubeSymbol, descriptionText } = cubeAssetsForNode(processEvent); + + return ( + <> + + {descriptionText} + + + + ); +}); diff --git a/x-pack/plugins/security_solution/public/resolver/view/process_event_dot.tsx b/x-pack/plugins/security_solution/public/resolver/view/process_event_dot.tsx index 7b463f0bed26a..78b70611a6972 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/process_event_dot.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/process_event_dot.tsx @@ -7,195 +7,185 @@ import React, { useCallback, useMemo } from 'react'; import styled from 'styled-components'; import { i18n } from '@kbn/i18n'; -import { - htmlIdGenerator, - EuiButton, - EuiI18nNumber, - EuiKeyboardAccessible, - EuiFlexGroup, - EuiFlexItem, -} from '@elastic/eui'; +import { htmlIdGenerator, EuiButton, EuiI18nNumber, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { useSelector } from 'react-redux'; +import { useHistory } from 'react-router-dom'; +// eslint-disable-next-line import/no-nodejs-modules +import querystring from 'querystring'; import { NodeSubMenu, subMenuAssets } from './submenu'; import { applyMatrix3 } from '../lib/vector2'; -import { Vector2, Matrix3, AdjacentProcessMap, ResolverProcessType } from '../types'; -import { SymbolIds, useResolverTheme, NodeStyleMap, calculateResolverFontSize } from './assets'; +import { Vector2, Matrix3, AdjacentProcessMap } from '../types'; +import { SymbolIds, useResolverTheme, calculateResolverFontSize, nodeType } from './assets'; import { ResolverEvent, ResolverNodeStats } from '../../../common/endpoint/types'; import { useResolverDispatch } from './use_resolver_dispatch'; import * as eventModel from '../../../common/endpoint/models/event'; -import * as processModel from '../models/process_event'; import * as selectors from '../store/selectors'; +import { CrumbInfo } from './panels/panel_content_utilities'; + +/** + * A map of all known event types (in ugly schema format) to beautifully i18n'd display names + */ +export const displayNameRecord = { + application: i18n.translate( + 'xpack.securitySolution.endpoint.resolver.applicationEventTypeDisplayName', + { + defaultMessage: 'Application', + } + ), + apm: i18n.translate('xpack.securitySolution.endpoint.resolver.apmEventTypeDisplayName', { + defaultMessage: 'APM', + }), + audit: i18n.translate('xpack.securitySolution.endpoint.resolver.auditEventTypeDisplayName', { + defaultMessage: 'Audit', + }), + authentication: i18n.translate( + 'xpack.securitySolution.endpoint.resolver.authenticationEventTypeDisplayName', + { + defaultMessage: 'Authentication', + } + ), + certificate: i18n.translate( + 'xpack.securitySolution.endpoint.resolver.certificateEventTypeDisplayName', + { + defaultMessage: 'Certificate', + } + ), + cloud: i18n.translate('xpack.securitySolution.endpoint.resolver.cloudEventTypeDisplayName', { + defaultMessage: 'Cloud', + }), + database: i18n.translate( + 'xpack.securitySolution.endpoint.resolver.databaseEventTypeDisplayName', + { + defaultMessage: 'Database', + } + ), + driver: i18n.translate('xpack.securitySolution.endpoint.resolver.driverEventTypeDisplayName', { + defaultMessage: 'Driver', + }), + email: i18n.translate('xpack.securitySolution.endpoint.resolver.emailEventTypeDisplayName', { + defaultMessage: 'Email', + }), + file: i18n.translate('xpack.securitySolution.endpoint.resolver.fileEventTypeDisplayName', { + defaultMessage: 'File', + }), + host: i18n.translate('xpack.securitySolution.endpoint.resolver.hostEventTypeDisplayName', { + defaultMessage: 'Host', + }), + iam: i18n.translate('xpack.securitySolution.endpoint.resolver.iamEventTypeDisplayName', { + defaultMessage: 'IAM', + }), + iam_group: i18n.translate( + 'xpack.securitySolution.endpoint.resolver.iam_groupEventTypeDisplayName', + { + defaultMessage: 'IAM Group', + } + ), + intrusion_detection: i18n.translate( + 'xpack.securitySolution.endpoint.resolver.intrusion_detectionEventTypeDisplayName', + { + defaultMessage: 'Intrusion Detection', + } + ), + malware: i18n.translate('xpack.securitySolution.endpoint.resolver.malwareEventTypeDisplayName', { + defaultMessage: 'Malware', + }), + network_flow: i18n.translate( + 'xpack.securitySolution.endpoint.resolver.network_flowEventTypeDisplayName', + { + defaultMessage: 'Network Flow', + } + ), + network: i18n.translate('xpack.securitySolution.endpoint.resolver.networkEventTypeDisplayName', { + defaultMessage: 'Network', + }), + package: i18n.translate('xpack.securitySolution.endpoint.resolver.packageEventTypeDisplayName', { + defaultMessage: 'Package', + }), + process: i18n.translate('xpack.securitySolution.endpoint.resolver.processEventTypeDisplayName', { + defaultMessage: 'Process', + }), + registry: i18n.translate( + 'xpack.securitySolution.endpoint.resolver.registryEventTypeDisplayName', + { + defaultMessage: 'Registry', + } + ), + session: i18n.translate('xpack.securitySolution.endpoint.resolver.sessionEventTypeDisplayName', { + defaultMessage: 'Session', + }), + service: i18n.translate('xpack.securitySolution.endpoint.resolver.serviceEventTypeDisplayName', { + defaultMessage: 'Service', + }), + socket: i18n.translate('xpack.securitySolution.endpoint.resolver.socketEventTypeDisplayName', { + defaultMessage: 'Socket', + }), + vulnerability: i18n.translate( + 'xpack.securitySolution.endpoint.resolver.vulnerabilityEventTypeDisplayName', + { + defaultMessage: 'Vulnerability', + } + ), + web: i18n.translate('xpack.securitySolution.endpoint.resolver.webEventTypeDisplayName', { + defaultMessage: 'Web', + }), + alert: i18n.translate('xpack.securitySolution.endpoint.resolver.alertEventTypeDisplayName', { + defaultMessage: 'Alert', + }), + security: i18n.translate( + 'xpack.securitySolution.endpoint.resolver.securityEventTypeDisplayName', + { + defaultMessage: 'Security', + } + ), + dns: i18n.translate('xpack.securitySolution.endpoint.resolver.dnsEventTypeDisplayName', { + defaultMessage: 'DNS', + }), + clr: i18n.translate('xpack.securitySolution.endpoint.resolver.clrEventTypeDisplayName', { + defaultMessage: 'CLR', + }), + image_load: i18n.translate( + 'xpack.securitySolution.endpoint.resolver.image_loadEventTypeDisplayName', + { + defaultMessage: 'Image Load', + } + ), + powershell: i18n.translate( + 'xpack.securitySolution.endpoint.resolver.powershellEventTypeDisplayName', + { + defaultMessage: 'Powershell', + } + ), + wmi: i18n.translate('xpack.securitySolution.endpoint.resolver.wmiEventTypeDisplayName', { + defaultMessage: 'WMI', + }), + api: i18n.translate('xpack.securitySolution.endpoint.resolver.apiEventTypeDisplayName', { + defaultMessage: 'API', + }), + user: i18n.translate('xpack.securitySolution.endpoint.resolver.userEventTypeDisplayName', { + defaultMessage: 'User', + }), +} as const; + +const unknownEventTypeMessage = i18n.translate( + 'xpack.securitySolution.endpoint.resolver.userEventTypeDisplayUnknown', + { + defaultMessage: 'Unknown', + } +); + +type EventDisplayName = typeof displayNameRecord[keyof typeof displayNameRecord] & + typeof unknownEventTypeMessage; /** * Take a gross `schemaName` and return a beautiful translated one. */ -const getDisplayName: (schemaName: string) => string = function nameInSchemaToDisplayName( - schemaName: string +const getDisplayName: (schemaName: string) => EventDisplayName = function nameInSchemaToDisplayName( + schemaName ) { - const displayNameRecord: Record = { - application: i18n.translate( - 'xpack.securitySolution.endpoint.resolver.applicationEventTypeDisplayName', - { - defaultMessage: 'Application', - } - ), - apm: i18n.translate('xpack.securitySolution.endpoint.resolver.apmEventTypeDisplayName', { - defaultMessage: 'APM', - }), - audit: i18n.translate('xpack.securitySolution.endpoint.resolver.auditEventTypeDisplayName', { - defaultMessage: 'Audit', - }), - authentication: i18n.translate( - 'xpack.securitySolution.endpoint.resolver.authenticationEventTypeDisplayName', - { - defaultMessage: 'Authentication', - } - ), - certificate: i18n.translate( - 'xpack.securitySolution.endpoint.resolver.certificateEventTypeDisplayName', - { - defaultMessage: 'Certificate', - } - ), - cloud: i18n.translate('xpack.securitySolution.endpoint.resolver.cloudEventTypeDisplayName', { - defaultMessage: 'Cloud', - }), - database: i18n.translate( - 'xpack.securitySolution.endpoint.resolver.databaseEventTypeDisplayName', - { - defaultMessage: 'Database', - } - ), - driver: i18n.translate('xpack.securitySolution.endpoint.resolver.driverEventTypeDisplayName', { - defaultMessage: 'Driver', - }), - email: i18n.translate('xpack.securitySolution.endpoint.resolver.emailEventTypeDisplayName', { - defaultMessage: 'Email', - }), - file: i18n.translate('xpack.securitySolution.endpoint.resolver.fileEventTypeDisplayName', { - defaultMessage: 'File', - }), - host: i18n.translate('xpack.securitySolution.endpoint.resolver.hostEventTypeDisplayName', { - defaultMessage: 'Host', - }), - iam: i18n.translate('xpack.securitySolution.endpoint.resolver.iamEventTypeDisplayName', { - defaultMessage: 'IAM', - }), - iam_group: i18n.translate( - 'xpack.securitySolution.endpoint.resolver.iam_groupEventTypeDisplayName', - { - defaultMessage: 'IAM Group', - } - ), - intrusion_detection: i18n.translate( - 'xpack.securitySolution.endpoint.resolver.intrusion_detectionEventTypeDisplayName', - { - defaultMessage: 'Intrusion Detection', - } - ), - malware: i18n.translate( - 'xpack.securitySolution.endpoint.resolver.malwareEventTypeDisplayName', - { - defaultMessage: 'Malware', - } - ), - network_flow: i18n.translate( - 'xpack.securitySolution.endpoint.resolver.network_flowEventTypeDisplayName', - { - defaultMessage: 'Network Flow', - } - ), - network: i18n.translate( - 'xpack.securitySolution.endpoint.resolver.networkEventTypeDisplayName', - { - defaultMessage: 'Network', - } - ), - package: i18n.translate( - 'xpack.securitySolution.endpoint.resolver.packageEventTypeDisplayName', - { - defaultMessage: 'Package', - } - ), - process: i18n.translate( - 'xpack.securitySolution.endpoint.resolver.processEventTypeDisplayName', - { - defaultMessage: 'Process', - } - ), - registry: i18n.translate( - 'xpack.securitySolution.endpoint.resolver.registryEventTypeDisplayName', - { - defaultMessage: 'Registry', - } - ), - session: i18n.translate( - 'xpack.securitySolution.endpoint.resolver.sessionEventTypeDisplayName', - { - defaultMessage: 'Session', - } - ), - service: i18n.translate( - 'xpack.securitySolution.endpoint.resolver.serviceEventTypeDisplayName', - { - defaultMessage: 'Service', - } - ), - socket: i18n.translate('xpack.securitySolution.endpoint.resolver.socketEventTypeDisplayName', { - defaultMessage: 'Socket', - }), - vulnerability: i18n.translate( - 'xpack.securitySolution.endpoint.resolver.vulnerabilityEventTypeDisplayName', - { - defaultMessage: 'Vulnerability', - } - ), - web: i18n.translate('xpack.securitySolution.endpoint.resolver.webEventTypeDisplayName', { - defaultMessage: 'Web', - }), - alert: i18n.translate('xpack.securitySolution.endpoint.resolver.alertEventTypeDisplayName', { - defaultMessage: 'Alert', - }), - security: i18n.translate( - 'xpack.securitySolution.endpoint.resolver.securityEventTypeDisplayName', - { - defaultMessage: 'Security', - } - ), - dns: i18n.translate('xpack.securitySolution.endpoint.resolver.dnsEventTypeDisplayName', { - defaultMessage: 'DNS', - }), - clr: i18n.translate('xpack.securitySolution.endpoint.resolver.clrEventTypeDisplayName', { - defaultMessage: 'CLR', - }), - image_load: i18n.translate( - 'xpack.securitySolution.endpoint.resolver.image_loadEventTypeDisplayName', - { - defaultMessage: 'Image Load', - } - ), - powershell: i18n.translate( - 'xpack.securitySolution.endpoint.resolver.powershellEventTypeDisplayName', - { - defaultMessage: 'Powershell', - } - ), - wmi: i18n.translate('xpack.securitySolution.endpoint.resolver.wmiEventTypeDisplayName', { - defaultMessage: 'WMI', - }), - api: i18n.translate('xpack.securitySolution.endpoint.resolver.apiEventTypeDisplayName', { - defaultMessage: 'API', - }), - user: i18n.translate('xpack.securitySolution.endpoint.resolver.userEventTypeDisplayName', { - defaultMessage: 'User', - }), - }; - return ( - displayNameRecord[schemaName] || - i18n.translate('xpack.securitySolution.endpoint.resolver.userEventTypeDisplayUnknown', { - defaultMessage: 'Unknown', - }) - ); + if (schemaName in displayNameRecord) { + return displayNameRecord[schemaName as keyof typeof displayNameRecord]; + } + return unknownEventTypeMessage; }; interface StyledActionsContainer { @@ -283,11 +273,14 @@ const ProcessEventDotComponents = React.memo( const [magFactorX] = projectionMatrix; + // Node (html id=) IDs const selfId = adjacentNodeMap.self; - const activeDescendantId = useSelector(selectors.uiActiveDescendantId); const selectedDescendantId = useSelector(selectors.uiSelectedDescendantId); + // Entity ID of self + const selfEntityId = eventModel.entityId(event); + const isShowingEventActions = magFactorX > 0.8; const isShowingDescriptionText = magFactorX >= 0.55; @@ -401,6 +394,50 @@ const ProcessEventDotComponents = React.memo( }); }, [dispatch, nodeId]); + const handleRelatedEventRequest = useCallback(() => { + dispatch({ + type: 'userRequestedRelatedEventData', + payload: selfId, + }); + }, [dispatch, selfId]); + + const handleRelatedAlertsRequest = useCallback(() => { + dispatch({ + type: 'userSelectedRelatedAlerts', + payload: event, + }); + }, [dispatch, event]); + + const history = useHistory(); + const urlSearch = history.location.search; + + /** + * This updates the breadcrumb nav, the table view + */ + const pushToQueryParams = useCallback( + (newCrumbs: CrumbInfo) => { + // Construct a new set of params from the current set (minus empty params) + // by assigning the new set of params provided in `newCrumbs` + const crumbsToPass = { + ...querystring.parse(urlSearch.slice(1)), + ...newCrumbs, + }; + + // If either was passed in as empty, remove it from the record + if (crumbsToPass.crumbId === '') { + delete crumbsToPass.crumbId; + } + if (crumbsToPass.crumbEvent === '') { + delete crumbsToPass.crumbEvent; + } + + const relativeURL = { search: querystring.stringify(crumbsToPass) }; + + return history.replace(relativeURL); + }, + [history, urlSearch] + ); + const handleClick = useCallback(() => { if (animationTarget.current !== null) { // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -410,39 +447,31 @@ const ProcessEventDotComponents = React.memo( type: 'userSelectedResolverNode', payload: { nodeId, + selectedProcessId: selfId, }, }); - }, [animationTarget, dispatch, nodeId]); + pushToQueryParams({ crumbId: selfEntityId, crumbEvent: 'all' }); + }, [animationTarget, dispatch, nodeId, selfEntityId, pushToQueryParams, selfId]); - 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(() => { + + const [relatedEventOptions, grandTotal] = useMemo(() => { const relatedStatsList = []; if (!relatedEventsStats) { // Return an empty set of options if there are no stats to report - return []; + return [[], 0]; } + let runningTotal = 0; // If we have entries to show, map them into options to display in the selectable list for (const category in relatedEventsStats.events.byCategory) { if (Object.hasOwnProperty.call(relatedEventsStats.events.byCategory, category)) { const total = relatedEventsStats.events.byCategory[category]; + runningTotal += total; const displayName = getDisplayName(category); relatedStatsList.push({ prefix: , @@ -455,12 +484,14 @@ const ProcessEventDotComponents = React.memo( category, }, }); + + pushToQueryParams({ crumbId: selfEntityId, crumbEvent: category }); }, }); } } - return relatedStatsList; - }, [relatedEventsStats, dispatch, event]); + return [relatedStatsList, runningTotal]; + }, [relatedEventsStats, dispatch, event, pushToQueryParams, selfEntityId]); const relatedEventStatusOrOptions = (() => { if (!relatedEventsStats) { @@ -475,144 +506,144 @@ const ProcessEventDotComponents = React.memo( * Key event handling (e.g. 'Enter'/'Space') is provisioned by the `EuiKeyboardAccessible` component */ return ( - -
+ + + + + + + + + - + {descriptionText} + +
= 2 ? 'euiButton' : 'euiButton euiButton--small'} + data-test-subject="nodeLabel" + id={labelId} + onClick={handleClick} + onFocus={handleFocus} + tabIndex={-1} style={{ - display: 'block', - width: '100%', - height: '100%', - position: 'absolute', - top: '0', - left: '0', + backgroundColor: colorMap.resolverBackground, + alignSelf: 'flex-start', + padding: 0, }} > - - - - - - - - - - {descriptionText} - -
- - - - {eventModel.eventName(event)} - + + + {eventModel.eventName(event)} - -
- - - - - - - - -
-
- + + +
+ + + + + + + + + + ); /* eslint-enable jsx-a11y/click-events-have-key-events */ } @@ -673,21 +704,3 @@ export const ProcessEventDot = styled(ProcessEventDotComponents)` color: white; } `; - -const processTypeToCube: Record = { - processCreated: 'runningProcessCube', - processRan: 'runningProcessCube', - processTerminated: 'terminatedProcessCube', - unknownProcessEvent: 'runningProcessCube', - processCausedAlert: 'runningTriggerCube', - unknownEvent: 'runningProcessCube', -}; - -function nodeType(processEvent: ResolverEvent): keyof NodeStyleMap { - const processType = processModel.eventType(processEvent); - - if (processType in processTypeToCube) { - return processTypeToCube[processType]; - } - return 'runningProcessCube'; -} diff --git a/x-pack/plugins/security_solution/public/resolver/view/submenu.tsx b/x-pack/plugins/security_solution/public/resolver/view/submenu.tsx index 861c170b8b0b8..8f972dd737af6 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/submenu.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/submenu.tsx @@ -6,7 +6,14 @@ import { i18n } from '@kbn/i18n'; import React, { ReactNode, useState, useMemo, useCallback } from 'react'; -import { EuiSelectable, EuiButton, EuiPopover, ButtonColor, htmlIdGenerator } from '@elastic/eui'; +import { + EuiI18nNumber, + EuiSelectable, + EuiButton, + EuiPopover, + ButtonColor, + htmlIdGenerator, +} from '@elastic/eui'; import styled from 'styled-components'; /** @@ -74,21 +81,44 @@ const OptionList = React.memo( }; }) ); - return useMemo( - () => ( - { - setOptions(newOptions); - }} - listProps={{ showIcons: true, bordered: true }} - isLoading={isLoading} - > - {(list) => {list}} - - ), - [isLoading, options] + + const actionsByLabel: Record unknown> = useMemo(() => { + if (typeof subMenuOptions !== 'object') { + return {}; + } + return subMenuOptions.reduce((titleActionRecord, opt) => { + const { optionTitle, action } = opt; + return { ...titleActionRecord, [optionTitle]: action }; + }, {}); + }, [subMenuOptions]); + + type ChangeOptions = Array<{ label: string; prepend?: ReactNode; checked?: string }>; + const selectableProps = useMemo(() => { + return { + listProps: { showIcons: true, bordered: true }, + onChange: (newOptions: ChangeOptions) => { + const selectedOption = newOptions.find((opt) => opt.checked === 'on'); + if (selectedOption) { + const { label } = selectedOption; + const actionToTake = actionsByLabel[label]; + if (typeof actionToTake === 'function') { + actionToTake(); + } + } + setOptions(newOptions); + }, + }; + }, [actionsByLabel]); + + return ( + + {(list) => {list}} + ); } ); @@ -102,6 +132,7 @@ OptionList.displayName = 'OptionList'; */ const NodeSubMenuComponents = React.memo( ({ + count, buttonBorderColor, menuTitle, menuAction, @@ -113,6 +144,7 @@ const NodeSubMenuComponents = React.memo( menuAction?: () => unknown; buttonBorderColor: ButtonColor; buttonFill: string; + count?: number; } & { optionsWithActions?: ResolverSubmenuOptionList | string | undefined; }) => { @@ -176,7 +208,7 @@ const NodeSubMenuComponents = React.memo( iconSide="right" tabIndex={-1} > - {menuTitle} + {count ? : ''} {menuTitle} );