Merge/restyle nodes table (#69098)

Adds panel views and drilldowns to Resolver
This commit is contained in:
Brent Kimmel 2020-06-18 16:33:57 -04:00 committed by GitHub
parent bdb65920f0
commit 700f53d3a3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
29 changed files with 2560 additions and 556 deletions

View file

@ -26,6 +26,9 @@ interface EventOptions {
eventType?: string;
eventCategory?: string | string[];
processName?: string;
pid?: number;
parentPid?: number;
extensions?: object;
}
const Windows: OSFields[] = [
@ -471,12 +474,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',
@ -485,9 +512,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),
},
};
}
@ -692,6 +740,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;
@ -1117,7 +1167,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('.');
}

View file

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

View file

@ -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<string | undefined> {
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> = T extends object ? { [K in keyof T]?: DeepPartial<T[K]> } : T;
/**
* Based on the ECS category of the event, attempt to provide a more descriptive name
* (e.g. the `event.registry.key` for `registry` or the `dns.question.name` for `dns`, etc.).
* This function returns the data in the form of `{subject, descriptor}` where `subject` will
* tend to be the more distinctive term (e.g. 137.213.212.7 for a network event) and the
* `descriptor` can be used to present more useful/meaningful view (e.g. `inbound 137.213.212.7`
* in the example above).
* see: https://www.elastic.co/guide/en/ecs/current/ecs-field-reference.html
* @param event The ResolverEvent to get the descriptive name for
* @returns { descriptiveName } An attempt at providing a readable name to the user
*/
export function descriptiveName(event: ResolverEvent): { subject: string; descriptor?: string } {
if (isLegacyEvent(event)) {
return { subject: eventName(event) };
}
// To be somewhat defensive, we'll check for the presence of these.
const partialEvent: DeepPartial<ResolverEvent> = event;
/**
* This list of attempts can be expanded/adjusted as the underlying model changes over time:
*/
// Stable, per ECS 1.5: https://www.elastic.co/guide/en/ecs/current/ecs-allowed-values-event-category.html
if (partialEvent.network?.forwarded_ip) {
return {
subject: String(partialEvent.network?.forwarded_ip),
descriptor: String(partialEvent.network?.direction),
};
}
if (partialEvent.file?.path) {
return {
subject: String(partialEvent.file?.path),
};
}
// Extended categories (per ECS 1.5):
const pathOrKey = partialEvent.registry?.path || partialEvent.registry?.key;
if (pathOrKey) {
return {
subject: String(pathOrKey),
};
}
if (partialEvent.dns?.question?.name) {
return { subject: String(partialEvent.dns?.question?.name) };
}
// Fall back on entityId if we can't fish a more descriptive name out.
return { subject: entityId(event) };
}

View file

@ -444,14 +444,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;

View file

@ -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<AlertListState>
coreStart,
depsStart
) => {
let lastSelectedAlert: string | null = null;
/**
* @returns <boolean> 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<typeof uiQueryParams>): 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<IIndexPattern[]> {
const { indexPatterns } = depsStart.data;
const fields = await indexPatterns.getFieldsForWildcard({
@ -50,7 +71,7 @@ export const alertMiddlewareFactory: ImmutableMiddlewareFactory<AlertListState>
});
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}`

View file

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

View file

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

View file

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

View file

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

View file

@ -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<DataState, ResolverAction> = (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;
}

View file

@ -435,6 +435,21 @@ export function relatedEventsStats(data: DataState) {
return data.relatedEventsStats;
}
/**
* returns {Map<string, ResolverRelatedEvents>} a map of entity_ids to related event data.
*/
export function relatedEventsByEntityId(data: DataState) {
return data.relatedEvents;
}
/**
* returns {Map<string, boolean>} 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,

View file

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

View file

@ -19,7 +19,12 @@ import { uniquePidForProcess } from '../models/process_event';
const resolverNodeIdGenerator = htmlIdGenerator('resolverNode');
const uiReducer: Reducer<ResolverUIState, ResolverAction> = (
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<ResolverUIState, ResolverAction> = (
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<ResolverState, ResolverAction> = (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;

View file

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

View file

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

View file

@ -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<string, ResolverNodeStats>;
readonly relatedEvents: Map<string, ResolverRelatedEvents>;
readonly relatedEventsReady: Map<string, boolean>;
isLoading: boolean;
hasError: boolean;
}

View file

@ -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<ResolverProcessType, keyof NodeStyleMap> = {
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<boolean>(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 = (

View file

@ -53,9 +53,9 @@ const StyledResolver = styled.div<StyledResolver>`
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%;

View file

@ -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 (
<EuiHorizontalRule
style={{
/**
* Cannot use `styled` to override this because the specificity of EuiHorizontalRule's
* CSS selectors is too high.
*/
marginLeft: `-${euiVars.euiPanelPaddingModifiers.paddingMedium}`,
marginRight: `-${euiVars.euiPanelPaddingModifiers.paddingMedium}`,
/**
* The default width is 100%, but this should be greater.
*/
width: 'auto',
}}
/>
);
});
export const Panel = memo(function Event({ className }: { className?: string }) {
interface ProcessTableView {
name: string;
timestamp?: Date;
event: ResolverEvent;
}
const { processNodePositions } = useSelector(selectors.processNodePositionsAndEdgeLineSegments);
const { timestamp } = useContext(SideEffectContext);
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 formatter = new Intl.DateTimeFormat(i18n.getLocale(), {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
/**
* 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 handleBringIntoViewClick = useCallback(
(processTableViewItem) => {
const queryParams: CrumbInfo = useMemo(() => {
return { crumbId: '', crumbEvent: '', ...querystring.parse(urlSearch.slice(1)) };
}, [urlSearch]);
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]);
// 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]);
// 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 | ResolverEvent>(null);
/**
* 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<Array<EuiBasicTableColumn<ProcessTableView>>>(
() => [
{
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 === '' ? (
<EuiBadge color="warning">
{i18n.translate(
'xpack.securitySolution.endpoint.resolver.panel.table.row.valueMissingDescription',
{
defaultMessage: 'Value is missing',
}
)}
</EuiBadge>
) : (
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)
) : (
<EuiBadge color="warning">
{i18n.translate(
'xpack.securitySolution.endpoint.resolver.panel.tabel.row.timestampInvalidLabel',
{
defaultMessage: 'invalid',
}
)}
</EuiBadge>
);
},
},
{
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 (
<ProcessDetails processEvent={uiSelectedEvent!} pushToQueryParams={pushToQueryParams} />
);
}
if (currentPanelView === 'eventCountsForProcess') {
return (
<EventCountsForProcess
processEvent={uiSelectedEvent!}
pushToQueryParams={pushToQueryParams}
relatedStats={relatedStatsForIdFromParams!}
/>
);
}
if (currentPanelView === 'processEventListNarrowedByType') {
return (
<ProcessEventListNarrowedByType
processEvent={uiSelectedEvent!}
pushToQueryParams={pushToQueryParams}
relatedStats={relatedStatsForIdFromParams!}
eventType={crumbEvent}
/>
);
}
if (currentPanelView === 'relatedEventDetail') {
const parentCount: number = Object.values(
relatedStatsForIdFromParams?.events.byCategory || {}
).reduce((sum, val) => sum + val, 0);
return (
<RelatedEventDetail
relatedEventId={crumbId}
parentEvent={uiSelectedEvent!}
pushToQueryParams={pushToQueryParams}
countForParent={parentCount}
/>
);
}
// The default 'Event List' / 'List of all processes' view
return <ProcessListWithCounts pushToQueryParams={pushToQueryParams} />;
}, [
uiSelectedEvent,
crumbEvent,
crumbId,
pushToQueryParams,
relatedStatsForIdFromParams,
currentPanelView,
]);
return <>{panelInstance}</>;
});
PanelContent.displayName = 'PanelContent';
export const Panel = memo(function Event({ className }: { className?: string }) {
return (
<EuiPanel className={className}>
<EuiTitle size="xs">
<h4>
{i18n.translate('xpack.securitySolution.endpoint.resolver.panel.title', {
defaultMessage: 'Processes',
})}
</h4>
</EuiTitle>
<HorizontalRule />
<EuiInMemoryTable<ProcessTableView> items={processTableView} columns={columns} sorting />
<PanelContent />
</EuiPanel>
);
});
Panel.displayName = 'Panel';

View file

@ -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 (
<>
<StyledBreadcrumbs breadcrumbs={crumbs} />
<EuiSpacer size="l" />
<EuiText textAlign="center">{translatedErrorMessage}</EuiText>
<EuiSpacer size="l" />
<EuiButtonEmpty
onClick={() => {
pushToQueryParams({ crumbId: '', crumbEvent: '' });
}}
>
{i18n.translate('xpack.securitySolution.endpoint.resolver.panel.error.goBack', {
defaultMessage: 'Click this link to return to the list of all processes.',
})}
</EuiButtonEmpty>
</>
);
});
PanelContentError.displayName = 'TableServiceError';

View file

@ -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: (
<>
<FormattedMessage
id="xpack.securitySolution.endpoint.resolver.panel.relatedEventDetail.detailsForProcessName"
values={{ processName }}
defaultMessage="Details for: {processName}"
/>
</>
),
onClick: () => {},
},
];
}, [processName, pushToQueryParams]);
const { cubeAssetsForNode } = useResolverTheme();
const { descriptionText } = useMemo(() => {
if (!processEvent) {
return { descriptionText: '' };
}
return cubeAssetsForNode(processEvent);
}, [processEvent, cubeAssetsForNode]);
const titleId = useMemo(() => htmlIdGenerator('resolverTable')(), []);
return (
<>
<StyledBreadcrumbs breadcrumbs={crumbs} />
<EuiSpacer size="l" />
<EuiTitle size="xs">
<h4 aria-describedby={titleId}>
<CubeForProcess processEvent={processEvent} />
{processName}
</h4>
</EuiTitle>
<EuiText>
<EuiTextColor color="subdued">
<span id={titleId}>{descriptionText}</span>
</EuiTextColor>
</EuiText>
<EuiSpacer size="l" />
<StyledDescriptionList
type="column"
align="left"
titleProps={{ className: 'desc-title' }}
compressed
listItems={processInfoEntry}
/>
</>
);
});
ProcessDetails.displayName = 'ProcessDetails';

View file

@ -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<Array<EuiBasicTableColumn<ProcessTableView>>>(
() => [
{
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 === '' ? (
<EuiBadge color="warning">
{i18n.translate(
'xpack.securitySolution.endpoint.resolver.panel.table.row.valueMissingDescription',
{
defaultMessage: 'Value is missing',
}
)}
</EuiBadge>
) : (
<EuiButtonEmpty
onClick={() => {
handleBringIntoViewClick(item);
pushToQueryParams({ crumbId: event.entityId(item.event), crumbEvent: '' });
}}
>
<CubeForProcess processEvent={item.event} />
{name}
</EuiButtonEmpty>
);
},
},
{
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)
) : (
<EuiBadge color="warning">
{i18n.translate(
'xpack.securitySolution.endpoint.resolver.panel.table.row.timestampInvalidLabel',
{
defaultMessage: 'invalid',
}
)}
</EuiBadge>
);
},
},
],
[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 (
<>
<StyledBreadcrumbs breadcrumbs={crumbs} />
<EuiSpacer size="l" />
<EuiInMemoryTable<ProcessTableView> items={processTableView} columns={columns} sorting />
</>
);
});
ProcessListWithCounts.displayName = 'ProcessListWithCounts';

View file

@ -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: (
<>
<FormattedMessage
id="xpack.securitySolution.endpoint.resolver.panel.relatedCounts.numberOfEventsInCrumb"
values={{ totalCount }}
defaultMessage="{totalCount} Events"
/>
</>
),
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<Array<EuiBasicTableColumn<EventCountsTableView>>>(
() => [
{
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 (
<EuiButtonEmpty
onClick={() => {
pushToQueryParams({ crumbId: event.entityId(processEvent), crumbEvent: name });
}}
>
{name}
</EuiButtonEmpty>
);
},
},
],
[pushToQueryParams, processEvent]
);
return (
<>
<StyledBreadcrumbs breadcrumbs={crumbs} />
<EuiSpacer size="l" />
<EuiInMemoryTable<EventCountsTableView> items={rows} columns={columns} sorting />
</>
);
});
EventCountsForProcess.displayName = 'EventCountsForProcess';

View file

@ -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 `<dt><dd>` 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 (
<StyledTitleRule className="euiHorizontalRule euiHorizontalRule--full euiHorizontalRule--marginSmall override" />
);
});
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: (
<>
<FormattedMessage
id="xpack.securitySolution.endpoint.resolver.panel.relatedEventDetail.numberOfEvents"
values={{ totalCount }}
defaultMessage="{totalCount} Events"
/>
</>
),
onClick: () => {
pushToQueryParams({ crumbId: processEntityId!, crumbEvent: 'all' });
},
},
{
text: (
<>
<FormattedMessage
id="xpack.securitySolution.endpoint.resolver.panel.relatedEventDetail.countByCategory"
values={{ count: countBySameCategory, category: relatedEventCategory }}
defaultMessage="{count} {category}"
/>
</>
),
onClick: () => {
pushToQueryParams({
crumbId: processEntityId!,
crumbEvent: relatedEventCategory || 'all',
});
},
},
{
text: relatedEventToShowDetailsFor ? (
<FormattedMessage
id="xpack.securitySolution.endpoint.resolver.panel.relatedEventDetail.eventDescriptiveName"
values={{ subject, descriptor }}
defaultMessage="{descriptor} {subject}"
/>
) : (
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 (
<>
<StyledBreadcrumbs breadcrumbs={waitCrumbs} />
<EuiSpacer size="l" />
<EuiTitle>
<h4>{waitingString}</h4>
</EuiTitle>
</>
);
}
/**
* Could happen if user e.g. loads a URL with a bad crumbEvent
*/
if (!relatedEventToShowDetailsFor) {
const errString = i18n.translate(
'xpack.securitySolution.endpoint.resolver.panel.relatedDetail.missing',
{
defaultMessage: 'Related event not found.',
}
);
return (
<PanelContentError translatedErrorMessage={errString} pushToQueryParams={pushToQueryParams} />
);
}
return (
<>
<StyledBreadcrumbs truncate={false} breadcrumbs={crumbs} />
<EuiSpacer size="l" />
<EuiText size="s">
<BoldCode>
<FormattedMessage
id="xpack.securitySolution.endpoint.resolver.panel.relatedEventDetail.categoryAndType"
values={{
category: relatedEventCategory,
eventType: String(event.ecsEventType(relatedEventToShowDetailsFor)),
}}
defaultMessage="{category} {eventType}"
/>
</BoldCode>
<FormattedMessage
id="xpack.securitySolution.endpoint.resolver.panel.relatedEventDetail.atTime"
values={{ date: formattedDate }}
defaultMessage="@ {date}"
/>
</EuiText>
<EuiSpacer size="m" />
<EuiText>
<FormattedMessage
id="xpack.securitySolution.endpoint.resolver.panel.relatedEventDetail.eventDescriptiveNameInTitle"
values={{ subject, descriptor }}
defaultMessage="{descriptor} {subject}"
/>
</EuiText>
<EuiSpacer size="l" />
{sections.map(({ sectionTitle, entries }, index) => {
return (
<Fragment key={index}>
{index === 0 ? null : <EuiSpacer size="m" />}
<EuiTitle size="xs">
<EuiTextColor color="secondary">
<StyledFlexTitle>
{sectionTitle}
<TitleHr />
</StyledFlexTitle>
</EuiTextColor>
</EuiTitle>
<StyledDescriptionList
type="column"
align="left"
titleProps={{ className: 'desc-title' }}
compressed
listItems={entries}
/>
{index === sections.length - 1 ? null : <EuiSpacer size="m" />}
</Fragment>
);
})}
</>
);
});
RelatedEventDetail.displayName = 'RelatedEventDetail';

View file

@ -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 (
<>
<StyledBreadcrumbs breadcrumbs={crumbs} />
<EuiSpacer size="l" />
<>
{matchingEventEntries.map((eventView, index) => {
const { subject, descriptor = '' } = eventView.name;
return (
<Fragment key={index}>
<EuiText>
<BoldCode>
<FormattedMessage
id="xpack.securitySolution.endpoint.resolver.panel.relatedEventDetail.categoryAndType"
values={{
category: eventView.eventCategory,
eventType: eventView.eventType,
}}
defaultMessage="{category} {eventType}"
/>
</BoldCode>
<FormattedMessage
id="xpack.securitySolution.endpoint.resolver.panel.relatedEventDetail.atTime"
values={{ date: eventView.formattedDate }}
defaultMessage="@ {date}"
/>
</EuiText>
<EuiSpacer size="xs" />
<EuiButtonEmpty onClick={eventView.setQueryParams}>
<FormattedMessage
id="xpack.securitySolution.endpoint.resolver.panel.processEventListByType.eventDescriptiveName"
values={{ subject, descriptor }}
defaultMessage="{descriptor} {subject}"
/>
</EuiButtonEmpty>
{index === matchingEventEntries.length - 1 ? null : <EuiHorizontalRule margin="m" />}
</Fragment>
);
})}
</>
</>
);
});
export 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: (
<>
<FormattedMessage
id="xpack.securitySolution.endpoint.resolver.panel.relatedEventList.numberOfEvents"
values={{ totalCount }}
defaultMessage="{totalCount} Events"
/>
</>
),
onClick: () => {
pushToQueryParams({ crumbId: processEntityId, crumbEvent: 'all' });
},
},
{
text: (
<>
<FormattedMessage
id="xpack.securitySolution.endpoint.resolver.panel.relatedEventList.countByCategory"
values={{ count: matchingEventEntries.length, category: eventType }}
defaultMessage="{count} {category}"
/>
</>
),
onClick: () => {},
},
];
}, [
eventType,
eventsString,
matchingEventEntries.length,
processEntityId,
processName,
pushToQueryParams,
totalCount,
]);
/**
* Wait here until the effect resolves...
*/
if (!relatedsReady) {
return (
<>
<StyledBreadcrumbs breadcrumbs={waitCrumbs} />
<EuiSpacer size="l" />
<EuiTitle>
<h4>{waitingString}</h4>
</EuiTitle>
</>
);
}
return <DisplayList crumbs={crumbs} matchingEventEntries={matchingEventEntries} />;
});
ProcessEventListNarrowedByType.displayName = 'ProcessEventListNarrowedByType';

View file

@ -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 (
<ThemedBreadcrumbs
background={resolverEdge}
text={resolverEdgeText}
breadcrumbs={breadcrumbs}
truncate={truncate}
/>
);
});
/**
* 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<typeof Date>[0]} timestamp To be passed through Date->Intl.DateTimeFormat
* @returns {string} A nicely formatted string for a date
*/
export function formatDate(timestamp: ConstructorParameters<typeof Date>[0]) {
const date = new Date(timestamp);
if (isFinite(date.getTime())) {
return formatter.format(date);
} else {
return invalidDateText;
}
}

View file

@ -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 (
<>
<svg
style={{ position: 'relative', top: '0.4em', marginRight: '.25em' }}
className="table-process-icon"
width="1.5em"
height="1.5em"
viewBox="0 0 1 1"
>
<desc>{descriptionText}</desc>
<use
role="presentation"
xlinkHref={cubeSymbol}
x={0}
y={0}
width={1}
height={1}
opacity="1"
className="cube"
/>
</svg>
</>
);
});

View file

@ -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<string, string> = {
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: <EuiI18nNumber value={total || 0} />,
@ -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 (
<EuiKeyboardAccessible>
<div
data-test-subj={'resolverNode'}
className={`${className} kbn-resetFocusState`}
role="treeitem"
aria-level={adjacentNodeMap.level}
aria-flowto={
adjacentNodeMap.nextSibling === null ? undefined : adjacentNodeMap.nextSibling
}
aria-labelledby={labelId}
aria-describedby={descriptionId}
aria-haspopup={'true'}
aria-current={isActiveDescendant ? 'true' : undefined}
aria-selected={isSelectedDescendant ? 'true' : undefined}
style={nodeViewportStyle}
id={nodeId}
onClick={handleClick}
onFocus={handleFocus}
tabIndex={-1}
<div
data-test-subj={'resolverNode'}
className={`${className} kbn-resetFocusState`}
role="treeitem"
aria-level={adjacentNodeMap.level}
aria-flowto={adjacentNodeMap.nextSibling === null ? undefined : adjacentNodeMap.nextSibling}
aria-labelledby={labelId}
aria-describedby={descriptionId}
aria-haspopup={'true'}
aria-current={isActiveDescendant ? 'true' : undefined}
aria-selected={isSelectedDescendant ? 'true' : undefined}
style={nodeViewportStyle}
id={nodeId}
tabIndex={-1}
>
<svg
viewBox="-15 -15 90 30"
preserveAspectRatio="xMidYMid meet"
style={{
display: 'block',
width: '100%',
height: '100%',
position: 'absolute',
top: '0',
left: '0',
}}
>
<svg
viewBox="-15 -15 90 30"
preserveAspectRatio="xMidYMid meet"
<g>
<use
xlinkHref={`#${SymbolIds.processCubeActiveBacking}`}
fill={backingFill} // Only visible on hover
x={-15.35}
y={-15.35}
stroke={strokeColor}
width={markerSize * 1.5}
height={markerSize * 1.5}
className="backing"
/>
<use
role="presentation"
xlinkHref={cubeSymbol}
x={markerPositionXOffset}
y={markerPositionYOffset}
width={markerSize}
height={markerSize}
opacity="1"
className="cube"
>
<animateTransform
attributeType="XML"
attributeName="transform"
type="scale"
values="1 1; 1 .83; 1 .8; 1 .83; 1 1"
dur="0.2s"
repeatCount="1"
className="squish"
ref={animationTarget}
/>
</use>
</g>
</svg>
<StyledActionsContainer
color={colorMap.full}
fontSize={scaledTypeSize}
topPct={actionableButtonsTopOffset}
>
<StyledDescriptionText
backgroundColor={colorMap.resolverBackground}
color={colorMap.descriptionText}
isDisplaying={isShowingDescriptionText}
>
{descriptionText}
</StyledDescriptionText>
<div
className={magFactorX >= 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,
}}
>
<g>
<use
xlinkHref={`#${SymbolIds.processCubeActiveBacking}`}
fill={backingFill} // Only visible on hover
x={-15.35}
y={-15.35}
stroke={strokeColor}
width={markerSize * 1.5}
height={markerSize * 1.5}
className="backing"
/>
<use
role="presentation"
xlinkHref={cubeSymbol}
x={markerPositionXOffset}
y={markerPositionYOffset}
width={markerSize}
height={markerSize}
opacity="1"
className="cube"
>
<animateTransform
attributeType="XML"
attributeName="transform"
type="scale"
values="1 1; 1 .83; 1 .8; 1 .83; 1 1"
dur="0.2s"
begin="click"
repeatCount="1"
className="squish"
ref={animationTarget}
/>
</use>
</g>
</svg>
<StyledActionsContainer
color={colorMap.full}
fontSize={scaledTypeSize}
topPct={actionableButtonsTopOffset}
>
<StyledDescriptionText
backgroundColor={colorMap.resolverBackground}
color={colorMap.descriptionText}
isDisplaying={isShowingDescriptionText}
>
{descriptionText}
</StyledDescriptionText>
<div
<EuiButton
color={labelButtonFill}
data-test-subject="nodeLabel"
fill={isLabelFilled}
id={labelId}
size="s"
style={{
backgroundColor: colorMap.resolverBackground,
alignSelf: 'flex-start',
padding: 0,
maxHeight: `${Math.min(26 + magFactorX * 3, 32)}px`,
maxWidth: `${isShowingEventActions ? 400 : 210 * magFactorX}px`,
}}
tabIndex={-1}
title={eventModel.eventName(event)}
>
<EuiButton
color={labelButtonFill}
data-test-subject="nodeLabel"
fill={isLabelFilled}
id={labelId}
size="s"
style={{
maxHeight: `${Math.min(26 + magFactorX * 3, 32)}px`,
maxWidth: `${isShowingEventActions ? 400 : 210 * magFactorX}px`,
}}
tabIndex={-1}
title={eventModel.eventName(event)}
>
<span className="euiButton__content">
<span className="euiButton__text" data-test-subj={'euiButton__text'}>
{eventModel.eventName(event)}
</span>
<span className="euiButton__content">
<span className="euiButton__text" data-test-subj={'euiButton__text'}>
{eventModel.eventName(event)}
</span>
</EuiButton>
</div>
<EuiFlexGroup
justifyContent="flexStart"
gutterSize="xs"
style={{
alignSelf: 'flex-start',
background: colorMap.resolverBackground,
display: `${isShowingEventActions ? 'flex' : 'none'}`,
margin: 0,
padding: 0,
}}
>
<EuiFlexItem grow={false} className="related-dropdown">
<NodeSubMenu
buttonBorderColor={labelButtonFill}
buttonFill={colorMap.resolverBackground}
menuAction={handleRelatedEventRequest}
menuTitle={subMenuAssets.relatedEvents.title}
optionsWithActions={relatedEventStatusOrOptions}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<NodeSubMenu
buttonBorderColor={labelButtonFill}
buttonFill={colorMap.resolverBackground}
menuTitle={subMenuAssets.relatedAlerts.title}
menuAction={handleRelatedAlertsRequest}
/>
</EuiFlexItem>
</EuiFlexGroup>
</StyledActionsContainer>
</div>
</EuiKeyboardAccessible>
</span>
</EuiButton>
</div>
<EuiFlexGroup
justifyContent="flexStart"
gutterSize="xs"
style={{
alignSelf: 'flex-start',
background: colorMap.resolverBackground,
display: `${isShowingEventActions ? 'flex' : 'none'}`,
margin: 0,
padding: 0,
}}
>
<EuiFlexItem grow={false} className="related-dropdown">
<NodeSubMenu
count={grandTotal}
buttonBorderColor={labelButtonFill}
buttonFill={colorMap.resolverBackground}
menuAction={handleRelatedEventRequest}
menuTitle={subMenuAssets.relatedEvents.title}
optionsWithActions={relatedEventStatusOrOptions}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<NodeSubMenu
buttonBorderColor={labelButtonFill}
buttonFill={colorMap.resolverBackground}
menuTitle={subMenuAssets.relatedAlerts.title}
menuAction={handleRelatedAlertsRequest}
/>
</EuiFlexItem>
</EuiFlexGroup>
</StyledActionsContainer>
</div>
);
/* eslint-enable jsx-a11y/click-events-have-key-events */
}
@ -673,21 +704,3 @@ export const ProcessEventDot = styled(ProcessEventDotComponents)`
color: white;
}
`;
const processTypeToCube: Record<ResolverProcessType, keyof NodeStyleMap> = {
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';
}

View file

@ -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(
() => (
<EuiSelectable
singleSelection={true}
options={options}
onChange={(newOptions) => {
setOptions(newOptions);
}}
listProps={{ showIcons: true, bordered: true }}
isLoading={isLoading}
>
{(list) => <OptionListItem>{list}</OptionListItem>}
</EuiSelectable>
),
[isLoading, options]
const actionsByLabel: Record<string, () => 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 (
<EuiSelectable
singleSelection={true}
options={options}
{...selectableProps}
isLoading={isLoading}
>
{(list) => <OptionListItem>{list}</OptionListItem>}
</EuiSelectable>
);
}
);
@ -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 ? <EuiI18nNumber value={count} /> : ''} {menuTitle}
</EuiButton>
);