diff --git a/x-pack/plugins/security_solution/common/endpoint/models/event.ts b/x-pack/plugins/security_solution/common/endpoint/models/event.ts index deeca309ac236f..efcc3e354bea8c 100644 --- a/x-pack/plugins/security_solution/common/endpoint/models/event.ts +++ b/x-pack/plugins/security_solution/common/endpoint/models/event.ts @@ -198,6 +198,14 @@ export function eventID(event: SafeResolverEvent): number | undefined | string { } } +export function documentID(event: SafeResolverEvent): string | undefined { + return firstNonNullValue(event._id); +} + +export function indexName(event: SafeResolverEvent): string | undefined { + return firstNonNullValue(event._index); +} + /** * Retrieve the record_id field from a winlog event. * diff --git a/x-pack/plugins/security_solution/common/endpoint/types/index.ts b/x-pack/plugins/security_solution/common/endpoint/types/index.ts index daa835402f73d6..45390e1f030601 100644 --- a/x-pack/plugins/security_solution/common/endpoint/types/index.ts +++ b/x-pack/plugins/security_solution/common/endpoint/types/index.ts @@ -705,6 +705,8 @@ export type WinlogEvent = Partial<{ * Safer version of ResolverEvent. Please use this going forward. */ export type SafeEndpointEvent = Partial<{ + _id: ECSField; + _index: ECSField; '@timestamp': ECSField; agent: Partial<{ id: ECSField; @@ -815,6 +817,8 @@ export type SafeEndpointEvent = Partial<{ }>; export interface SafeLegacyEndpointEvent { + _id: ECSField; + _index: ECSField; '@timestamp'?: ECSField; /** * 'legacy' events must have an `endgame` key. diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/analyzer_panels/index.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/analyzer_panels/index.tsx index cc4be9df602095..379ee6cafbd603 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/analyzer_panels/index.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/analyzer_panels/index.tsx @@ -5,11 +5,14 @@ * 2.0. */ -import React from 'react'; +import React, { useCallback } from 'react'; import type { FlyoutPanelProps } from '@kbn/expandable-flyout'; import { FlyoutBody } from '@kbn/security-solution-common'; +import { useExpandableFlyoutApi } from '@kbn/expandable-flyout'; import type { DocumentDetailsAnalyzerPanelKey } from '../shared/constants/panel_keys'; import { DetailsPanel } from '../../../resolver/view/details_panel'; +import { DocumentDetailsPreviewPanelKey } from '../shared/constants/panel_keys'; +import { ALERT_PREVIEW_BANNER } from '../preview/constants'; interface AnalyzerPanelProps extends Record { /** @@ -27,10 +30,32 @@ export interface AnalyzerPanelExpandableFlyoutProps extends FlyoutPanelProps { * Displays node details panel for analyzer */ export const AnalyzerPanel: React.FC = ({ resolverComponentInstanceID }) => { + const { openPreviewPanel } = useExpandableFlyoutApi(); + + const openPreview = useCallback( + ({ documentId, indexName, scopeId }) => + () => { + openPreviewPanel({ + id: DocumentDetailsPreviewPanelKey, + params: { + id: documentId, + indexName, + scopeId, + isPreviewMode: true, + banner: ALERT_PREVIEW_BANNER, + }, + }); + }, + [openPreviewPanel] + ); + return (
- +
); diff --git a/x-pack/plugins/security_solution/public/resolver/view/details_panel.tsx b/x-pack/plugins/security_solution/public/resolver/view/details_panel.tsx index b4158c85c1772e..8b1cc15f3d3e37 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/details_panel.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/details_panel.tsx @@ -13,65 +13,77 @@ import * as selectors from '../store/selectors'; import { PanelRouter } from './panels'; import { ResolverNoProcessEvents } from './resolver_no_process_events'; import type { State } from '../../common/store/types'; +import type { NodeEventOnClick } from './panels/node_events_of_type'; interface DetailsPanelProps { /** * Id that identify the scope of analyzer */ resolverComponentInstanceID: string; + /** + * Optional callback when a node event is clicked + */ + nodeEventOnClick?: NodeEventOnClick; } /** * Details panel component */ -const DetailsPanelComponent = React.memo(({ resolverComponentInstanceID }: DetailsPanelProps) => { - const isLoading = useSelector((state: State) => - selectors.isTreeLoading(state.analyzer[resolverComponentInstanceID]) - ); - const hasError = useSelector((state: State) => - selectors.hadErrorLoadingTree(state.analyzer[resolverComponentInstanceID]) - ); - const resolverTreeHasNodes = useSelector((state: State) => - selectors.resolverTreeHasNodes(state.analyzer[resolverComponentInstanceID]) - ); +const DetailsPanelComponent = React.memo( + ({ resolverComponentInstanceID, nodeEventOnClick }: DetailsPanelProps) => { + const isLoading = useSelector((state: State) => + selectors.isTreeLoading(state.analyzer[resolverComponentInstanceID]) + ); + const hasError = useSelector((state: State) => + selectors.hadErrorLoadingTree(state.analyzer[resolverComponentInstanceID]) + ); + const resolverTreeHasNodes = useSelector((state: State) => + selectors.resolverTreeHasNodes(state.analyzer[resolverComponentInstanceID]) + ); - return isLoading ? ( -
- -
- ) : hasError ? ( -
-
- {' '} - + return isLoading ? ( +
+
-
- ) : resolverTreeHasNodes ? ( - - ) : ( - - ); -}); + ) : hasError ? ( +
+
+ {' '} + +
+
+ ) : resolverTreeHasNodes ? ( + + ) : ( + + ); + } +); DetailsPanelComponent.displayName = 'DetailsPanelComponent'; /** * Stand alone details panel to be used when in split panel mode */ -export const DetailsPanel = React.memo(({ resolverComponentInstanceID }: DetailsPanelProps) => { - const isAnalyzerInitialized = useSelector((state: State) => - Boolean(state.analyzer[resolverComponentInstanceID]) - ); +export const DetailsPanel = React.memo( + ({ resolverComponentInstanceID, nodeEventOnClick }: DetailsPanelProps) => { + const isAnalyzerInitialized = useSelector((state: State) => + Boolean(state.analyzer[resolverComponentInstanceID]) + ); - return isAnalyzerInitialized ? ( - - ) : ( -
- -
- ); -}); + return isAnalyzerInitialized ? ( + + ) : ( +
+ +
+ ); + } +); DetailsPanel.displayName = 'DetailsPanel'; diff --git a/x-pack/plugins/security_solution/public/resolver/view/panels/index.tsx b/x-pack/plugins/security_solution/public/resolver/view/panels/index.tsx index 3367de214ab0ee..c5d91024195f21 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/panels/index.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/panels/index.tsx @@ -8,19 +8,26 @@ import React, { memo } from 'react'; import { useSelector } from 'react-redux'; import * as selectors from '../../store/selectors'; -import { NodeEventsInCategory } from './node_events_of_type'; +import { NodeEventsInCategory, type NodeEventOnClick } from './node_events_of_type'; import { NodeEvents } from './node_events'; import { NodeDetail } from './node_detail'; import { NodeList } from './node_list'; import { EventDetail } from './event_detail'; import type { PanelViewAndParameters } from '../../types'; import type { State } from '../../../common/store/types'; + /** * Show the panel that matches the `panelViewAndParameters` (derived from the browser's location.search) */ // eslint-disable-next-line react/display-name -export const PanelRouter = memo(function ({ id }: { id: string }) { +export const PanelRouter = memo(function ({ + id, + nodeEventOnClick, +}: { + id: string; + nodeEventOnClick?: NodeEventOnClick; +}) { const params: PanelViewAndParameters = useSelector((state: State) => selectors.panelViewAndParameters(state.analyzer[id]) ); @@ -34,6 +41,7 @@ export const PanelRouter = memo(function ({ id }: { id: string }) { id={id} nodeID={params.panelParameters.nodeID} eventCategory={params.panelParameters.eventCategory} + nodeEventOnClick={nodeEventOnClick} /> ); } else if (params.panelView === 'eventDetail') { diff --git a/x-pack/plugins/security_solution/public/resolver/view/panels/node_events_of_type.test.tsx b/x-pack/plugins/security_solution/public/resolver/view/panels/node_events_of_type.test.tsx index 953153b4f36571..65ca0e1e9531d8 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/panels/node_events_of_type.test.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/panels/node_events_of_type.test.tsx @@ -5,15 +5,21 @@ * 2.0. */ -import { act } from '@testing-library/react'; +import React from 'react'; +import { act, render } from '@testing-library/react'; import type { History as HistoryPackageHistoryInterface } from 'history'; import { createMemoryHistory } from 'history'; - +import { TestProviders } from '../../../common/mock'; +import { NodeEventsListItem } from './node_events_of_type'; import { oneNodeWithPaginatedEvents } from '../../data_access_layer/mocks/one_node_with_paginated_related_events'; import { Simulator } from '../../test_utilities/simulator'; // Extend jest with a custom matcher import '../../test_utilities/extend_jest'; import { urlSearch } from '../../test_utilities/url_search'; +import { useLinkProps } from '../use_link_props'; + +jest.mock('../use_link_props'); +const mockUseLinkProps = useLinkProps as jest.Mock; // the resolver component instance ID, used by the react code to distinguish piece of global state from those used by other resolver instances const resolverComponentInstanceID = 'resolverComponentInstanceID'; @@ -106,3 +112,35 @@ describe(`Resolver: when analyzing a tree with only the origin and paginated rel }); }); }); + +describe('', () => { + it('should call custom node onclick when it is available', () => { + const nodeEventOnClick = jest.fn(); + mockUseLinkProps.mockReturnValue({ href: '#', onClick: jest.fn() }); + const { getByTestId } = render( + + + + ); + expect(getByTestId('resolver:panel:node-events-in-category:event-link')).toBeInTheDocument(); + getByTestId('resolver:panel:node-events-in-category:event-link').click(); + expect(nodeEventOnClick).toBeCalledWith({ + documentId: 'test _id', + indexName: '_index', + scopeId: 'test', + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/resolver/view/panels/node_events_of_type.tsx b/x-pack/plugins/security_solution/public/resolver/view/panels/node_events_of_type.tsx index 5409eaede0a769..07a1ee9464cc15 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/panels/node_events_of_type.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/panels/node_events_of_type.tsx @@ -31,6 +31,16 @@ import { expandDottedObject } from '../../../../common/utils/expand_dotted'; import type { State } from '../../../common/store/types'; import { userRequestedAdditionalRelatedEvents } from '../../store/data/action'; +export type NodeEventOnClick = ({ + documentId, + indexName, + scopeId, +}: { + documentId: string | undefined; + indexName: string | undefined; + scopeId: string; +}) => () => void; + /** * Render a list of events that are related to `nodeID` and that have a category of `eventType`. */ @@ -39,10 +49,12 @@ export const NodeEventsInCategory = memo(function ({ id, nodeID, eventCategory, + nodeEventOnClick, }: { id: string; nodeID: string; eventCategory: string; + nodeEventOnClick?: NodeEventOnClick; }) { const node = useSelector((state: State) => selectors.graphNodeForID(state.analyzer[id])(nodeID)); const isLoading = useSelector((state: State) => @@ -84,7 +96,12 @@ export const NodeEventsInCategory = memo(function ({ nodeID={nodeID} /> - +
)} @@ -95,20 +112,24 @@ export const NodeEventsInCategory = memo(function ({ * Rendered for each event in the list. */ // eslint-disable-next-line react/display-name -const NodeEventsListItem = memo(function ({ +export const NodeEventsListItem = memo(function ({ id, event, nodeID, eventCategory, + nodeEventOnClick, }: { id: string; event: SafeResolverEvent; nodeID: string; eventCategory: string; + nodeEventOnClick?: NodeEventOnClick; }) { const expandedEvent = expandDottedObject(event); const timestamp = eventModel.eventTimestamp(expandedEvent); const eventID = eventModel.eventID(expandedEvent); + const documentId = eventModel.documentID(expandedEvent); + const indexName = eventModel.indexName(expandedEvent); const winlogRecordID = eventModel.winlogRecordID(expandedEvent); const date = useFormattedDate(timestamp) || @@ -125,6 +146,7 @@ const NodeEventsListItem = memo(function ({ winlogRecordID: String(winlogRecordID), }, }); + return ( <> @@ -147,12 +169,21 @@ const NodeEventsListItem = memo(function ({ - - - + {nodeEventOnClick ? ( + + + + ) : ( + + + + )} ); }); @@ -164,10 +195,12 @@ const NodeEventList = memo(function NodeEventList({ id, eventCategory, nodeID, + nodeEventOnClick, }: { id: string; eventCategory: string; nodeID: string; + nodeEventOnClick?: NodeEventOnClick; }) { const events = useSelector((state: State) => selectors.nodeEventsInCategory(state.analyzer[id])); const dispatch = useDispatch(); @@ -184,7 +217,13 @@ const NodeEventList = memo(function NodeEventList({ <> {events.map((event, index) => ( - + {index === events.length - 1 ? null : } ))} diff --git a/x-pack/plugins/security_solution/public/resolver/view/process_event_dot.tsx b/x-pack/plugins/security_solution/public/resolver/view/process_event_dot.tsx index 3846e1d7b19d1e..4779ea4eb4aedc 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/process_event_dot.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/process_event_dot.tsx @@ -131,6 +131,7 @@ const UnstyledProcessEventDot = React.memo( nodeID, projectionMatrix, timeAtRender, + onClick, }: { /** * Id that identify the scope of analyzer @@ -161,6 +162,11 @@ const UnstyledProcessEventDot = React.memo( * The time (unix epoch) at render. */ timeAtRender: number; + + /** + * Optional onClick to be called when clicking on a node + */ + onClick?: () => void | undefined; }) => { const resolverComponentInstanceID = id; // This should be unique to each instance of Resolver @@ -334,8 +340,12 @@ const UnstyledProcessEventDot = React.memo( ); processDetailNavProps.onClick(clickEvent); } + + if (onClick) { + onClick(); + } }, - [animationTarget, dispatch, nodeID, processDetailNavProps, nodeState, timestamp, id] + [animationTarget, dispatch, nodeID, processDetailNavProps, nodeState, timestamp, id, onClick] ); const grandTotal: number | null = useSelector((state: State) => @@ -533,6 +543,7 @@ const UnstyledProcessEventDot = React.memo( buttonFill={colorMap.resolverBackground} nodeStats={nodeStats} nodeID={nodeID} + onClick={onClick} /> )} diff --git a/x-pack/plugins/security_solution/public/resolver/view/resolver_without_providers.tsx b/x-pack/plugins/security_solution/public/resolver/view/resolver_without_providers.tsx index e9090dd7fa9df6..eda8c4993199e9 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/resolver_without_providers.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/resolver_without_providers.tsx @@ -160,6 +160,7 @@ export const ResolverWithoutProviders = React.memo( projectionMatrix={projectionMatrix} node={treeNode} timeAtRender={timeAtRender} + onClick={isSplitPanel ? showPanelOnClick : undefined} /> ); })} diff --git a/x-pack/plugins/security_solution/public/resolver/view/submenu.tsx b/x-pack/plugins/security_solution/public/resolver/view/submenu.tsx index 24a32f759ae7dc..76204d3a25a72b 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/submenu.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/submenu.tsx @@ -25,6 +25,7 @@ export const NodeSubMenuComponents = React.memo( className, nodeID, nodeStats, + onClick, }: { id: string; className?: string; @@ -35,6 +36,7 @@ export const NodeSubMenuComponents = React.memo( */ nodeID: string; nodeStats: EventStats | undefined; + onClick?: () => void; }) => { const relatedEventOptions = useMemo(() => { if (nodeStats === undefined) { @@ -61,7 +63,15 @@ export const NodeSubMenuComponents = React.memo( return opta.category.localeCompare(optb.category); }) .map((pill) => { - return ; + return ( + + ); })} ); @@ -72,10 +82,12 @@ const NodeSubmenuPill = ({ id, pill, nodeID, + onClick, }: { id: string; pill: { prefix: JSX.Element; category: string }; nodeID: string; + onClick?: () => void; }) => { const linkProps = useLinkProps(id, { panelView: 'nodeEventsInCategory', @@ -102,8 +114,13 @@ const NodeSubmenuPill = ({ time: timestamp(), }) ); + // onClick call back to open the details panel + // only used when in split mode + if (onClick) { + onClick(); + } }, - [timestamp, linkProps, dispatch, nodeID, id] + [timestamp, linkProps, dispatch, nodeID, id, onClick] ); return (
  • ( this.buildSearch(parsedFilters) ); - // @ts-expect-error @elastic/elasticsearch _source is optional - return response.hits.hits.map((hit) => hit._source); + return response.hits.hits.map((hit) => ({ + ...hit._source, + _id: hit._id, + _index: hit._index, + })); } else { const { eventID, entityType, agentId } = body; if (entityType === 'alertDetail') { const response = await alertsClient.find(this.alertDetailQuery(eventID)); // @ts-expect-error @elastic/elasticsearch _source is optional - return response.hits.hits.map((hit) => hit._source); + return response.hits.hits.map((hit) => ({ + ...hit._source, + _id: hit._id, + _index: hit._index, + })); } else { const response = await alertsClient.find(this.alertsForProcessQuery(eventID, agentId)); // @ts-expect-error @elastic/elasticsearch _source is optional - return response.hits.hits.map((hit) => hit._source); + return response.hits.hits.map((hit) => ({ + ...hit._source, + _id: hit._id, + _index: hit._index, + })); } } }