diff --git a/x-pack/packages/kbn-cloud-security-posture/graph/index.ts b/x-pack/packages/kbn-cloud-security-posture/graph/index.ts index c50969cfd6402..45575316b29d9 100644 --- a/x-pack/packages/kbn-cloud-security-posture/graph/index.ts +++ b/x-pack/packages/kbn-cloud-security-posture/graph/index.ts @@ -6,3 +6,4 @@ */ export * from './src/components'; +export { useFetchGraphData } from './src/hooks'; diff --git a/x-pack/packages/kbn-cloud-security-posture/graph/src/common/constants.ts b/x-pack/packages/kbn-cloud-security-posture/graph/src/common/constants.ts new file mode 100644 index 0000000000000..307cbd65123e4 --- /dev/null +++ b/x-pack/packages/kbn-cloud-security-posture/graph/src/common/constants.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const EVENT_GRAPH_VISUALIZATION_API = '/internal/cloud_security_posture/graph' as const; + +export const RELATED_ENTITY = 'related.entity' as const; +export const ACTOR_ENTITY_ID = 'actor.entity.id' as const; +export const TARGET_ENTITY_ID = 'target.entity.id' as const; diff --git a/x-pack/packages/kbn-cloud-security-posture/graph/src/components/graph/graph.tsx b/x-pack/packages/kbn-cloud-security-posture/graph/src/components/graph/graph.tsx index 0b956cb19e10d..a97a1c74698ca 100644 --- a/x-pack/packages/kbn-cloud-security-posture/graph/src/components/graph/graph.tsx +++ b/x-pack/packages/kbn-cloud-security-posture/graph/src/components/graph/graph.tsx @@ -174,7 +174,7 @@ export const Graph: React.FC = ({ minZoom={0.1} > {interactive && } - {' '} + ); diff --git a/x-pack/packages/kbn-cloud-security-posture/graph/src/components/graph/use_graph_popover.tsx b/x-pack/packages/kbn-cloud-security-posture/graph/src/components/graph/use_graph_popover.tsx index f5bca30d1e5ae..dd8a5f0c56a72 100644 --- a/x-pack/packages/kbn-cloud-security-posture/graph/src/components/graph/use_graph_popover.tsx +++ b/x-pack/packages/kbn-cloud-security-posture/graph/src/components/graph/use_graph_popover.tsx @@ -46,12 +46,9 @@ export const useGraphPopover = (id: string): GraphPopoverState => { const state: PopoverState = useMemo(() => ({ isOpen, anchorElement }), [isOpen, anchorElement]); - return useMemo( - () => ({ - id, - actions, - state, - }), - [id, actions, state] - ); + return { + id, + actions, + state, + }; }; diff --git a/x-pack/packages/kbn-cloud-security-posture/graph/src/components/graph_investigation/graph_investigation.tsx b/x-pack/packages/kbn-cloud-security-posture/graph/src/components/graph_investigation/graph_investigation.tsx new file mode 100644 index 0000000000000..081b4ec28c6a5 --- /dev/null +++ b/x-pack/packages/kbn-cloud-security-posture/graph/src/components/graph_investigation/graph_investigation.tsx @@ -0,0 +1,258 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo, useCallback, useMemo, useState } from 'react'; +import { SearchBar } from '@kbn/unified-search-plugin/public'; +import { useKibana } from '@kbn/kibana-react-plugin/public'; +import type { DataView } from '@kbn/data-views-plugin/public'; +import { + BooleanRelation, + buildEsQuery, + isCombinedFilter, + buildCombinedFilter, + isFilter, + FilterStateStore, +} from '@kbn/es-query'; +import type { Filter, Query, TimeRange, PhraseFilter } from '@kbn/es-query'; +import { css } from '@emotion/react'; +import { getEsQueryConfig } from '@kbn/data-service'; +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { Graph } from '../../..'; +import { useGraphNodeExpandPopover } from './use_graph_node_expand_popover'; +import { useFetchGraphData } from '../../hooks/use_fetch_graph_data'; +import { GRAPH_INVESTIGATION_TEST_ID } from '../test_ids'; +import { ACTOR_ENTITY_ID, RELATED_ENTITY, TARGET_ENTITY_ID } from '../../common/constants'; + +const CONTROLLED_BY_GRAPH_INVESTIGATION_FILTER = 'graph-investigation'; + +const buildPhraseFilter = (field: string, value: string, dataViewId?: string): PhraseFilter => ({ + meta: { + key: field, + index: dataViewId, + negate: false, + disabled: false, + type: 'phrase', + field, + controlledBy: CONTROLLED_BY_GRAPH_INVESTIGATION_FILTER, + params: { + query: value, + }, + }, + query: { + match_phrase: { + [field]: value, + }, + }, +}); + +/** + * Adds a filter to the existing list of filters based on the provided key and value. + * It will always use the first filter in the list to build a combined filter with the new filter. + * + * @param dataViewId - The ID of the data view to which the filter belongs. + * @param prev - The previous list of filters. + * @param key - The key for the filter. + * @param value - The value for the filter. + * @returns A new list of filters with the added filter. + */ +const addFilter = (dataViewId: string, prev: Filter[], key: string, value: string) => { + const [firstFilter, ...otherFilters] = prev; + + if (isCombinedFilter(firstFilter) && firstFilter?.meta?.relation === BooleanRelation.OR) { + return [ + { + ...firstFilter, + meta: { + ...firstFilter.meta, + params: [ + ...(Array.isArray(firstFilter.meta.params) ? firstFilter.meta.params : []), + buildPhraseFilter(key, value), + ], + }, + }, + ...otherFilters, + ]; + } else if (isFilter(firstFilter) && firstFilter.meta?.type !== 'custom') { + return [ + buildCombinedFilter(BooleanRelation.OR, [firstFilter, buildPhraseFilter(key, value)], { + id: dataViewId, + }), + ...otherFilters, + ]; + } else { + return [ + { + $state: { + store: FilterStateStore.APP_STATE, + }, + ...buildPhraseFilter(key, value, dataViewId), + }, + ...prev, + ]; + } +}; + +const useGraphPopovers = ( + dataViewId: string, + setSearchFilters: React.Dispatch> +) => { + const nodeExpandPopover = useGraphNodeExpandPopover({ + onExploreRelatedEntitiesClick: (node) => { + setSearchFilters((prev) => addFilter(dataViewId, prev, RELATED_ENTITY, node.id)); + }, + onShowActionsByEntityClick: (node) => { + setSearchFilters((prev) => addFilter(dataViewId, prev, ACTOR_ENTITY_ID, node.id)); + }, + onShowActionsOnEntityClick: (node) => { + setSearchFilters((prev) => addFilter(dataViewId, prev, TARGET_ENTITY_ID, node.id)); + }, + }); + + const openPopoverCallback = useCallback( + (cb: Function, ...args: unknown[]) => { + [nodeExpandPopover].forEach(({ actions: { closePopover } }) => { + closePopover(); + }); + cb(...args); + }, + [nodeExpandPopover] + ); + + return { nodeExpandPopover, openPopoverCallback }; +}; + +interface GraphInvestigationProps { + dataView: DataView; + eventIds: string[]; + timestamp: string | null; +} + +/** + * Graph investigation view allows the user to expand nodes and view related entities. + */ +export const GraphInvestigation: React.FC = memo( + ({ dataView, eventIds, timestamp = new Date().toISOString() }: GraphInvestigationProps) => { + const [searchFilters, setSearchFilters] = useState(() => []); + const [timeRange, setTimeRange] = useState({ + from: `${timestamp}||-30m`, + to: `${timestamp}||+30m`, + }); + + const { + services: { uiSettings }, + } = useKibana(); + const query = useMemo( + () => + buildEsQuery( + dataView, + [], + [...searchFilters], + getEsQueryConfig(uiSettings as Parameters[0]) + ), + [searchFilters, dataView, uiSettings] + ); + + const { nodeExpandPopover, openPopoverCallback } = useGraphPopovers( + dataView?.id ?? '', + setSearchFilters + ); + const expandButtonClickHandler = (...args: unknown[]) => + openPopoverCallback(nodeExpandPopover.onNodeExpandButtonClick, ...args); + const isPopoverOpen = [nodeExpandPopover].some(({ state: { isOpen } }) => isOpen); + const { data, refresh, isFetching } = useFetchGraphData({ + req: { + query: { + eventIds, + esQuery: query, + start: timeRange.from, + end: timeRange.to, + }, + }, + options: { + refetchOnWindowFocus: false, + keepPreviousData: true, + }, + }); + + const nodes = useMemo(() => { + return ( + data?.nodes.map((node) => { + const nodeHandlers = + node.shape !== 'label' && node.shape !== 'group' + ? { + expandButtonClick: expandButtonClickHandler, + } + : undefined; + return { ...node, ...nodeHandlers }; + }) ?? [] + ); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [data?.nodes]); + + return ( + <> + + {dataView && ( + + + {...{ + appName: 'graph-investigation', + intl: null, + showFilterBar: true, + showDatePicker: true, + showAutoRefreshOnly: false, + showSaveQuery: false, + showQueryInput: false, + isLoading: isFetching, + isAutoRefreshDisabled: true, + dateRangeFrom: timeRange.from, + dateRangeTo: timeRange.to, + query: { query: '', language: 'kuery' }, + indexPatterns: [dataView], + filters: searchFilters, + submitButtonStyle: 'iconOnly', + onFiltersUpdated: (newFilters) => { + setSearchFilters(newFilters); + }, + onQuerySubmit: (payload, isUpdate) => { + if (isUpdate) { + setTimeRange({ ...payload.dateRange }); + } else { + refresh(); + } + }, + }} + /> + + )} + + + + + + + ); + } +); + +GraphInvestigation.displayName = 'GraphInvestigation'; diff --git a/x-pack/packages/kbn-cloud-security-posture/graph/src/components/graph_investigation/graph_node_expand_popover.tsx b/x-pack/packages/kbn-cloud-security-posture/graph/src/components/graph_investigation/graph_node_expand_popover.tsx new file mode 100644 index 0000000000000..c22f8dbe51ace --- /dev/null +++ b/x-pack/packages/kbn-cloud-security-posture/graph/src/components/graph_investigation/graph_node_expand_popover.tsx @@ -0,0 +1,78 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo } from 'react'; +import { EuiListGroup } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { ExpandPopoverListItem } from '../styles'; +import { GraphPopover } from '../../..'; +import { + GRAPH_NODE_EXPAND_POPOVER_TEST_ID, + GRAPH_NODE_POPOVER_SHOW_RELATED_ITEM_ID, + GRAPH_NODE_POPOVER_SHOW_ACTIONS_BY_ITEM_ID, + GRAPH_NODE_POPOVER_SHOW_ACTIONS_ON_ITEM_ID, +} from '../test_ids'; + +interface GraphNodeExpandPopoverProps { + isOpen: boolean; + anchorElement: HTMLElement | null; + closePopover: () => void; + onShowRelatedEntitiesClick: () => void; + onShowActionsByEntityClick: () => void; + onShowActionsOnEntityClick: () => void; +} + +export const GraphNodeExpandPopover: React.FC = memo( + ({ + isOpen, + anchorElement, + closePopover, + onShowRelatedEntitiesClick, + onShowActionsByEntityClick, + onShowActionsOnEntityClick, + }) => { + return ( + + + + + + + + ); + } +); + +GraphNodeExpandPopover.displayName = 'GraphNodeExpandPopover'; diff --git a/x-pack/packages/kbn-cloud-security-posture/graph/src/components/graph_investigation/use_graph_node_expand_popover.tsx b/x-pack/packages/kbn-cloud-security-posture/graph/src/components/graph_investigation/use_graph_node_expand_popover.tsx new file mode 100644 index 0000000000000..90e8f66510cc0 --- /dev/null +++ b/x-pack/packages/kbn-cloud-security-posture/graph/src/components/graph_investigation/use_graph_node_expand_popover.tsx @@ -0,0 +1,107 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo, useCallback, useRef, useState } from 'react'; +import { useGraphPopover } from '../../..'; +import type { ExpandButtonClickCallback, NodeProps } from '../types'; +import { GraphNodeExpandPopover } from './graph_node_expand_popover'; + +interface UseGraphNodeExpandPopoverArgs { + onExploreRelatedEntitiesClick: (node: NodeProps) => void; + onShowActionsByEntityClick: (node: NodeProps) => void; + onShowActionsOnEntityClick: (node: NodeProps) => void; +} + +export const useGraphNodeExpandPopover = ({ + onExploreRelatedEntitiesClick, + onShowActionsByEntityClick, + onShowActionsOnEntityClick, +}: UseGraphNodeExpandPopoverArgs) => { + const { id, state, actions } = useGraphPopover('node-expand-popover'); + const { openPopover, closePopover } = actions; + + const selectedNode = useRef(null); + const unToggleCallbackRef = useRef<(() => void) | null>(null); + const [pendingOpen, setPendingOpen] = useState<{ + node: NodeProps; + el: HTMLElement; + unToggleCallback: () => void; + } | null>(null); + + // Handler to close the popover, reset selected node and unToggle callback + const closePopoverHandler = useCallback(() => { + selectedNode.current = null; + unToggleCallbackRef.current?.(); + unToggleCallbackRef.current = null; + closePopover(); + }, [closePopover]); + + /** + * Handles the click event on the node expand button. + * Closes the current popover if open and sets the pending open state + * if the clicked node is different from the currently selected node. + */ + const onNodeExpandButtonClick: ExpandButtonClickCallback = useCallback( + (e, node, unToggleCallback) => { + // Close the current popover if open + closePopoverHandler(); + + if (selectedNode.current?.id !== node.id) { + // Set the pending open state + setPendingOpen({ node, el: e.currentTarget, unToggleCallback }); + } + }, + [closePopoverHandler] + ); + + // PopoverComponent is a memoized component that renders the GraphNodeExpandPopover + // It handles the display of the popover and the actions that can be performed on the node + const PopoverComponent = memo(() => ( + { + onExploreRelatedEntitiesClick(selectedNode.current as NodeProps); + closePopoverHandler(); + }} + onShowActionsByEntityClick={() => { + onShowActionsByEntityClick(selectedNode.current as NodeProps); + closePopoverHandler(); + }} + onShowActionsOnEntityClick={() => { + onShowActionsOnEntityClick(selectedNode.current as NodeProps); + closePopoverHandler(); + }} + /> + )); + + // Open pending popover if the popover is not open + // This block checks if there is a pending popover to be opened. + // If the popover is not currently open and there is a pending popover, + // it sets the selected node, stores the unToggle callback, and opens the popover. + if (!state.isOpen && pendingOpen) { + const { node, el, unToggleCallback } = pendingOpen; + + selectedNode.current = node; + unToggleCallbackRef.current = unToggleCallback; + openPopover(el); + + setPendingOpen(null); + } + + return { + onNodeExpandButtonClick, + PopoverComponent, + id, + actions: { + ...actions, + closePopover: closePopoverHandler, + }, + state, + }; +}; diff --git a/x-pack/packages/kbn-cloud-security-posture/graph/src/components/index.ts b/x-pack/packages/kbn-cloud-security-posture/graph/src/components/index.ts index 2b050aa55429f..d3cd397764e60 100644 --- a/x-pack/packages/kbn-cloud-security-posture/graph/src/components/index.ts +++ b/x-pack/packages/kbn-cloud-security-posture/graph/src/components/index.ts @@ -6,6 +6,7 @@ */ export { Graph } from './graph/graph'; +export { GraphInvestigation } from './graph_investigation/graph_investigation'; export { GraphPopover } from './graph/graph_popover'; export { useGraphPopover } from './graph/use_graph_popover'; export type { GraphProps } from './graph/graph'; diff --git a/x-pack/packages/kbn-cloud-security-posture/graph/src/components/node/node_expand_button.tsx b/x-pack/packages/kbn-cloud-security-posture/graph/src/components/node/node_expand_button.tsx index 522b7d1b5d45b..07581c1e3d3dd 100644 --- a/x-pack/packages/kbn-cloud-security-posture/graph/src/components/node/node_expand_button.tsx +++ b/x-pack/packages/kbn-cloud-security-posture/graph/src/components/node/node_expand_button.tsx @@ -35,6 +35,7 @@ export const NodeExpandButton = ({ x, y, onClick }: NodeExpandButtonProps) => { onClick={onClickHandler} iconSize="m" aria-label="Open or close node actions" + data-test-subj="nodeExpandButton" /> ); diff --git a/x-pack/packages/kbn-cloud-security-posture/graph/src/components/styles.tsx b/x-pack/packages/kbn-cloud-security-posture/graph/src/components/styles.tsx index 0efff1c88456c..be776d57be12a 100644 --- a/x-pack/packages/kbn-cloud-security-posture/graph/src/components/styles.tsx +++ b/x-pack/packages/kbn-cloud-security-posture/graph/src/components/styles.tsx @@ -6,13 +6,16 @@ */ import React from 'react'; +import type { + EuiIconProps, + _EuiBackgroundColor, + CommonProps, + EuiListGroupItemProps, +} from '@elastic/eui'; import { - EuiIcon, useEuiBackgroundColor, useEuiTheme, - type EuiIconProps, - type _EuiBackgroundColor, - EuiListGroupItemProps, + EuiIcon, EuiListGroupItem, EuiText, } from '@elastic/eui'; @@ -59,22 +62,24 @@ const RoundedEuiIcon: React.FC = ({ color, background, ...r ); export const ExpandPopoverListItem: React.FC< - Pick + CommonProps & Pick > = (props) => { + const { iconType, label, onClick, ...rest } = props; const { euiTheme } = useEuiTheme(); return ( + iconType ? ( + ) : undefined } label={ - {props.label} + {label} } - onClick={props.onClick} + onClick={onClick} /> ); }; diff --git a/x-pack/packages/kbn-cloud-security-posture/graph/src/components/test_ids.ts b/x-pack/packages/kbn-cloud-security-posture/graph/src/components/test_ids.ts new file mode 100644 index 0000000000000..96e399d670907 --- /dev/null +++ b/x-pack/packages/kbn-cloud-security-posture/graph/src/components/test_ids.ts @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const PREFIX = 'cloudSecurityGraph' as const; + +export const GRAPH_INVESTIGATION_TEST_ID = `${PREFIX}GraphInvestigation` as const; +export const GRAPH_NODE_EXPAND_POPOVER_TEST_ID = + `${GRAPH_INVESTIGATION_TEST_ID}GraphNodeExpandPopover` as const; +export const GRAPH_NODE_POPOVER_SHOW_RELATED_ITEM_ID = + `${GRAPH_INVESTIGATION_TEST_ID}ExploreRelatedEntities` as const; +export const GRAPH_NODE_POPOVER_SHOW_ACTIONS_BY_ITEM_ID = + `${GRAPH_INVESTIGATION_TEST_ID}ShowActionsByEntity` as const; +export const GRAPH_NODE_POPOVER_SHOW_ACTIONS_ON_ITEM_ID = + `${GRAPH_INVESTIGATION_TEST_ID}ShowActionsOnEntity` as const; diff --git a/x-pack/packages/kbn-cloud-security-posture/graph/src/hooks/index.ts b/x-pack/packages/kbn-cloud-security-posture/graph/src/hooks/index.ts new file mode 100644 index 0000000000000..6d75dc8beefee --- /dev/null +++ b/x-pack/packages/kbn-cloud-security-posture/graph/src/hooks/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { useFetchGraphData } from './use_fetch_graph_data'; diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/hooks/use_fetch_graph_data.test.tsx b/x-pack/packages/kbn-cloud-security-posture/graph/src/hooks/use_fetch_graph_data.test.tsx similarity index 66% rename from x-pack/plugins/security_solution/public/flyout/document_details/right/hooks/use_fetch_graph_data.test.tsx rename to x-pack/packages/kbn-cloud-security-posture/graph/src/hooks/use_fetch_graph_data.test.tsx index c22ec0caa82c5..e494ff0957ecb 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/hooks/use_fetch_graph_data.test.tsx +++ b/x-pack/packages/kbn-cloud-security-posture/graph/src/hooks/use_fetch_graph_data.test.tsx @@ -13,15 +13,22 @@ const mockUseQuery = jest.fn(); jest.mock('@tanstack/react-query', () => { return { useQuery: (...args: unknown[]) => mockUseQuery(...args), + useQueryClient: jest.fn(), }; }); +const defaultOptions = { + enabled: true, + refetchOnWindowFocus: true, + keepPreviousData: false, +}; + describe('useFetchGraphData', () => { beforeEach(() => { jest.clearAllMocks(); }); - it('Should pass default options when options are not provided', () => { + it('should pass default options when options are not provided', () => { renderHook(() => { return useFetchGraphData({ req: { @@ -36,12 +43,11 @@ describe('useFetchGraphData', () => { expect(mockUseQuery.mock.calls).toHaveLength(1); expect(mockUseQuery.mock.calls[0][2]).toEqual({ - enabled: true, - refetchOnWindowFocus: true, + ...defaultOptions, }); }); - it('Should should not be enabled when enabled set to false', () => { + it('should not be enabled when enabled set to false', () => { renderHook(() => { return useFetchGraphData({ req: { @@ -59,12 +65,12 @@ describe('useFetchGraphData', () => { expect(mockUseQuery.mock.calls).toHaveLength(1); expect(mockUseQuery.mock.calls[0][2]).toEqual({ + ...defaultOptions, enabled: false, - refetchOnWindowFocus: true, }); }); - it('Should should not be refetchOnWindowFocus when refetchOnWindowFocus set to false', () => { + it('should not be refetchOnWindowFocus when refetchOnWindowFocus set to false', () => { renderHook(() => { return useFetchGraphData({ req: { @@ -82,8 +88,31 @@ describe('useFetchGraphData', () => { expect(mockUseQuery.mock.calls).toHaveLength(1); expect(mockUseQuery.mock.calls[0][2]).toEqual({ - enabled: true, + ...defaultOptions, refetchOnWindowFocus: false, }); }); + + it('should keepPreviousData when keepPreviousData set to true', () => { + renderHook(() => { + return useFetchGraphData({ + req: { + query: { + eventIds: [], + start: '2021-09-01T00:00:00.000Z', + end: '2021-09-01T23:59:59.999Z', + }, + }, + options: { + keepPreviousData: true, + }, + }); + }); + + expect(mockUseQuery.mock.calls).toHaveLength(1); + expect(mockUseQuery.mock.calls[0][2]).toEqual({ + ...defaultOptions, + keepPreviousData: true, + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/hooks/use_fetch_graph_data.ts b/x-pack/packages/kbn-cloud-security-posture/graph/src/hooks/use_fetch_graph_data.ts similarity index 64% rename from x-pack/plugins/security_solution/public/flyout/document_details/right/hooks/use_fetch_graph_data.ts rename to x-pack/packages/kbn-cloud-security-posture/graph/src/hooks/use_fetch_graph_data.ts index 9a0e270a9b2e0..74cca4693e801 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/hooks/use_fetch_graph_data.ts +++ b/x-pack/packages/kbn-cloud-security-posture/graph/src/hooks/use_fetch_graph_data.ts @@ -5,14 +5,14 @@ * 2.0. */ -import { useQuery } from '@tanstack/react-query'; +import { useMemo } from 'react'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; import type { GraphRequest, GraphResponse, } from '@kbn/cloud-security-posture-common/types/graph/latest'; -import { useMemo } from 'react'; -import { EVENT_GRAPH_VISUALIZATION_API } from '../../../../../common/constants'; -import { useHttp } from '../../../../common/lib/kibana'; +import { useKibana } from '@kbn/kibana-react-plugin/public'; +import { EVENT_GRAPH_VISUALIZATION_API } from '../common/constants'; /** * Interface for the input parameters of the useFetchGraphData hook. @@ -36,6 +36,11 @@ export interface UseFetchGraphDataParams { * Defaults to true. */ refetchOnWindowFocus?: boolean; + /** + * If true, the query will keep previous data till new data received. + * Defaults to false. + */ + keepPreviousData?: boolean; }; } @@ -44,9 +49,13 @@ export interface UseFetchGraphDataParams { */ export interface UseFetchGraphDataResult { /** - * Indicates if the query is currently loading. + * Indicates if the query is currently being fetched for the first time. */ isLoading: boolean; + /** + * Indicates if the query is currently being fetched. Regardless of whether it is the initial fetch or a refetch. + */ + isFetching: boolean; /** * Indicates if there was an error during the query. */ @@ -55,6 +64,10 @@ export interface UseFetchGraphDataResult { * The data returned from the query. */ data?: GraphResponse; + /** + * Function to manually refresh the query. + */ + refresh: () => void; } /** @@ -67,16 +80,23 @@ export const useFetchGraphData = ({ req, options, }: UseFetchGraphDataParams): UseFetchGraphDataResult => { - const { eventIds, start, end, esQuery } = req.query; - const http = useHttp(); + const queryClient = useQueryClient(); + const { esQuery, eventIds, start, end } = req.query; + const { + services: { http }, + } = useKibana(); const QUERY_KEY = useMemo( () => ['useFetchGraphData', eventIds, start, end, esQuery], [end, esQuery, eventIds, start] ); - const { isLoading, isError, data } = useQuery( + const { isLoading, isError, data, isFetching } = useQuery( QUERY_KEY, () => { + if (!http) { + return Promise.reject(new Error('Http service is not available')); + } + return http.post(EVENT_GRAPH_VISUALIZATION_API, { version: '1', body: JSON.stringify(req), @@ -85,12 +105,17 @@ export const useFetchGraphData = ({ { enabled: options?.enabled ?? true, refetchOnWindowFocus: options?.refetchOnWindowFocus ?? true, + keepPreviousData: options?.keepPreviousData ?? false, } ); return { isLoading, + isFetching, isError, data, + refresh: () => { + queryClient.invalidateQueries(QUERY_KEY); + }, }; }; diff --git a/x-pack/packages/kbn-cloud-security-posture/graph/tsconfig.json b/x-pack/packages/kbn-cloud-security-posture/graph/tsconfig.json index d0056e29e6784..e56b9aabf16a9 100644 --- a/x-pack/packages/kbn-cloud-security-posture/graph/tsconfig.json +++ b/x-pack/packages/kbn-cloud-security-posture/graph/tsconfig.json @@ -12,7 +12,13 @@ ], "kbn_references": [ "@kbn/cloud-security-posture-common", - "@kbn/utility-types", + "@kbn/data-views-plugin", + "@kbn/kibana-react-plugin", "@kbn/ui-theme", + "@kbn/utility-types", + "@kbn/unified-search-plugin", + "@kbn/es-query", + "@kbn/data-service", + "@kbn/i18n", ] } diff --git a/x-pack/plugins/cloud_security_posture/server/routes/graph/route.ts b/x-pack/plugins/cloud_security_posture/server/routes/graph/route.ts index 9fb817b275a0d..f9544b656f927 100644 --- a/x-pack/plugins/cloud_security_posture/server/routes/graph/route.ts +++ b/x-pack/plugins/cloud_security_posture/server/routes/graph/route.ts @@ -12,7 +12,7 @@ import { import { transformError } from '@kbn/securitysolution-es-utils'; import type { GraphRequest } from '@kbn/cloud-security-posture-common/types/graph/v1'; import { GRAPH_ROUTE_PATH } from '../../../common/constants'; -import { CspRouter } from '../../types'; +import { CspRequestHandlerContext, CspRouter } from '../../types'; import { getGraph as getGraphV1 } from './v1'; export const defineGraphRoute = (router: CspRouter) => @@ -39,10 +39,11 @@ export const defineGraphRoute = (router: CspRouter) => }, }, }, - async (context, request, response) => { + async (context: CspRequestHandlerContext, request, response) => { + const cspContext = await context.csp; + const { nodesLimit, showUnknownTarget = false } = request.body; const { eventIds, start, end, esQuery } = request.body.query as GraphRequest['query']; - const cspContext = await context.csp; const spaceId = (await cspContext.spaces?.spacesService?.getActiveSpace(request))?.id; try { diff --git a/x-pack/plugins/cloud_security_posture/server/types.ts b/x-pack/plugins/cloud_security_posture/server/types.ts index df812fe534722..d5dc022bfcf66 100644 --- a/x-pack/plugins/cloud_security_posture/server/types.ts +++ b/x-pack/plugins/cloud_security_posture/server/types.ts @@ -22,6 +22,7 @@ import type { Logger, SavedObjectsClientContract, IScopedClusterClient, + CoreRequestHandlerContext, } from '@kbn/core/server'; import type { AgentService, @@ -88,6 +89,7 @@ export type CspRequestHandlerContext = CustomRequestHandlerContext<{ csp: CspApiRequestHandlerContext; fleet: FleetRequestHandlerContext['fleet']; alerting: AlertingApiRequestHandlerContext; + core: Promise; }>; /** diff --git a/x-pack/plugins/security_solution/common/constants.ts b/x-pack/plugins/security_solution/common/constants.ts index 265af5a47e1fe..3818813d94a72 100644 --- a/x-pack/plugins/security_solution/common/constants.ts +++ b/x-pack/plugins/security_solution/common/constants.ts @@ -282,8 +282,6 @@ export const PINNED_EVENT_URL = '/api/pinned_event' as const; export const SOURCERER_API_URL = '/internal/security_solution/sourcerer' as const; export const RISK_SCORE_INDEX_STATUS_API_URL = '/internal/risk_score/index_status' as const; -export const EVENT_GRAPH_VISUALIZATION_API = '/internal/cloud_security_posture/graph' as const; - /** * Default signals index key for kibana.dev.yml */ diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/left/components/graph_visualization.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/left/components/graph_visualization.tsx new file mode 100644 index 0000000000000..96374b81e18d5 --- /dev/null +++ b/x-pack/plugins/security_solution/public/flyout/document_details/left/components/graph_visualization.tsx @@ -0,0 +1,56 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo } from 'react'; +import { css } from '@emotion/css'; +import { EuiLoadingSpinner } from '@elastic/eui'; +import { useGetScopedSourcererDataView } from '../../../../sourcerer/components/use_get_sourcerer_data_view'; +import { SourcererScopeName } from '../../../../sourcerer/store/model'; +import { useDocumentDetailsContext } from '../../shared/context'; +import { GRAPH_VISUALIZATION_TEST_ID } from './test_ids'; +import { useGraphPreview } from '../../shared/hooks/use_graph_preview'; + +const GraphInvestigationLazy = React.lazy(() => + import('@kbn/cloud-security-posture-graph').then((module) => ({ + default: module.GraphInvestigation, + })) +); + +export const GRAPH_ID = 'graph-visualization' as const; + +/** + * Graph visualization view displayed in the document details expandable flyout left section under the Visualize tab + */ +export const GraphVisualization: React.FC = memo(() => { + const dataView = useGetScopedSourcererDataView({ + sourcererScope: SourcererScopeName.default, + }); + const { getFieldsData, dataAsNestedObject } = useDocumentDetailsContext(); + const { eventIds, timestamp } = useGraphPreview({ + getFieldsData, + ecsData: dataAsNestedObject, + }); + + return ( +
+ {dataView && ( + }> + + + )} +
+ ); +}); + +GraphVisualization.displayName = 'GraphVisualization'; diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/left/components/test_ids.ts b/x-pack/plugins/security_solution/public/flyout/document_details/left/components/test_ids.ts index 8669b504f6861..6979fa9cfa053 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/left/components/test_ids.ts +++ b/x-pack/plugins/security_solution/public/flyout/document_details/left/components/test_ids.ts @@ -11,6 +11,7 @@ import { PREFIX } from '../../../shared/test_ids'; export const ANALYZER_GRAPH_TEST_ID = `${PREFIX}AnalyzerGraph` as const; export const SESSION_VIEW_TEST_ID = `${PREFIX}SessionView` as const; +export const GRAPH_VISUALIZATION_TEST_ID = `${PREFIX}GraphVisualization` as const; /* Insights tab */ diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/left/tabs/test_ids.ts b/x-pack/plugins/security_solution/public/flyout/document_details/left/tabs/test_ids.ts index eb64c91b2143d..bc1ee586606de 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/left/tabs/test_ids.ts +++ b/x-pack/plugins/security_solution/public/flyout/document_details/left/tabs/test_ids.ts @@ -13,6 +13,8 @@ export const VISUALIZE_TAB_SESSION_VIEW_BUTTON_TEST_ID = `${VISUALIZE_TAB_TEST_ID}SessionViewButton` as const; export const VISUALIZE_TAB_GRAPH_ANALYZER_BUTTON_TEST_ID = `${VISUALIZE_TAB_TEST_ID}GraphAnalyzerButton` as const; +export const VISUALIZE_TAB_GRAPH_VISUALIZATION_BUTTON_TEST_ID = + `${VISUALIZE_TAB_TEST_ID}GraphVisualizationButton` as const; const INSIGHTS_TAB_TEST_ID = `${PREFIX}InsightsTab` as const; export const INSIGHTS_TAB_BUTTON_GROUP_TEST_ID = `${INSIGHTS_TAB_TEST_ID}ButtonGroup` as const; export const INSIGHTS_TAB_ENTITIES_BUTTON_TEST_ID = diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/left/tabs/visualize_tab.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/left/tabs/visualize_tab.tsx index 0dad444ee6ece..89e00e06e3a49 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/left/tabs/visualize_tab.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/left/tabs/visualize_tab.tsx @@ -11,12 +11,14 @@ import type { EuiButtonGroupOptionProps } from '@elastic/eui/src/components/butt import { useExpandableFlyoutApi, useExpandableFlyoutState } from '@kbn/expandable-flyout'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; +import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features'; import { useDocumentDetailsContext } from '../../shared/context'; import { useWhichFlyout } from '../../shared/hooks/use_which_flyout'; import { DocumentDetailsAnalyzerPanelKey } from '../../shared/constants/panel_keys'; import { VISUALIZE_TAB_BUTTON_GROUP_TEST_ID, VISUALIZE_TAB_GRAPH_ANALYZER_BUTTON_TEST_ID, + VISUALIZE_TAB_GRAPH_VISUALIZATION_BUTTON_TEST_ID, VISUALIZE_TAB_SESSION_VIEW_BUTTON_TEST_ID, } from './test_ids'; import { @@ -27,6 +29,9 @@ import { import { SESSION_VIEW_ID, SessionView } from '../components/session_view'; import { ALERTS_ACTIONS } from '../../../../common/lib/apm/user_actions'; import { useStartTransaction } from '../../../../common/lib/apm/use_start_transaction'; +import { GRAPH_ID, GraphVisualization } from '../components/graph_visualization'; +import { useGraphPreview } from '../../shared/hooks/use_graph_preview'; +import { GRAPH_VISUALIZATION_IN_FLYOUT_ENABLED_EXPERIMENTAL_FEATURE } from '../../shared/constants/experimental_features'; const visualizeButtons: EuiButtonGroupOptionProps[] = [ { @@ -51,11 +56,39 @@ const visualizeButtons: EuiButtonGroupOptionProps[] = [ }, ]; +const graphVisualizationButton: EuiButtonGroupOptionProps = { + id: GRAPH_ID, + iconType: 'beaker', + iconSide: 'right', + toolTipProps: { + title: ( + + ), + }, + toolTipContent: i18n.translate( + 'xpack.securitySolution.flyout.left.visualize.graphVisualizationButton.technicalPreviewTooltip', + { + defaultMessage: + 'This functionality is in technical preview and may be changed or removed completely in a future release. Elastic will work to fix any issues, but features in technical preview are not subject to the support SLA of official GA features.', + } + ), + label: ( + + ), + 'data-test-subj': VISUALIZE_TAB_GRAPH_VISUALIZATION_BUTTON_TEST_ID, +}; + /** * Visualize view displayed in the document details expandable flyout left section */ export const VisualizeTab = memo(() => { - const { scopeId } = useDocumentDetailsContext(); + const { scopeId, getFieldsData, dataAsNestedObject } = useDocumentDetailsContext(); const { openPreviewPanel } = useExpandableFlyoutApi(); const panels = useExpandableFlyoutState(); const [activeVisualizationId, setActiveVisualizationId] = useState( @@ -86,6 +119,22 @@ export const VisualizeTab = memo(() => { } }, [panels.left?.path?.subTab]); + // Decide whether to show the graph preview or not + const { hasGraphRepresentation } = useGraphPreview({ + getFieldsData, + ecsData: dataAsNestedObject, + }); + + const isGraphFeatureEnabled = useIsExperimentalFeatureEnabled( + GRAPH_VISUALIZATION_IN_FLYOUT_ENABLED_EXPERIMENTAL_FEATURE + ); + + const options = [...visualizeButtons]; + + if (hasGraphRepresentation && isGraphFeatureEnabled) { + options.push(graphVisualizationButton); + } + return ( <> { defaultMessage: 'Visualize options', } )} - options={visualizeButtons} + options={options} idSelected={activeVisualizationId} onChange={(id) => onChangeCompressed(id)} buttonSize="compressed" @@ -107,6 +156,7 @@ export const VisualizeTab = memo(() => { {activeVisualizationId === SESSION_VIEW_ID && } {activeVisualizationId === ANALYZE_GRAPH_ID && } + {activeVisualizationId === GRAPH_ID && } ); }); diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/graph_preview.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/graph_preview.test.tsx index 22ac27eaa4e00..2142d19c82870 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/graph_preview.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/graph_preview.test.tsx @@ -36,10 +36,19 @@ describe('', () => { }); it('shows graph preview correctly when data is loaded', async () => { - const graphProps = { + const graphProps: GraphPreviewProps = { isLoading: false, isError: false, - data: { nodes: [], edges: [] }, + data: { + nodes: [ + { + id: '1', + color: 'primary', + shape: 'ellipse', + }, + ], + edges: [], + }, }; const { findByTestId } = renderGraphPreview(mockContextValue, graphProps); @@ -69,4 +78,15 @@ describe('', () => { expect(getByText(ERROR_MESSAGE)).toBeInTheDocument(); }); + + it('shows error message when data is empty', () => { + const graphProps = { + isLoading: false, + isError: false, + }; + + const { getByText } = renderGraphPreview(mockContextValue, graphProps); + + expect(getByText(ERROR_MESSAGE)).toBeInTheDocument(); + }); }); diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/graph_preview.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/graph_preview.tsx index c3c6d65c7e986..dec3c40790ad8 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/graph_preview.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/graph_preview.tsx @@ -5,7 +5,7 @@ * 2.0. */ import React, { memo, useMemo } from 'react'; -import { EuiSkeletonText } from '@elastic/eui'; +import { EuiPanel, EuiSkeletonText } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { css } from '@emotion/react'; import { FormattedMessage } from '@kbn/i18n-react'; @@ -71,13 +71,19 @@ export const GraphPreview: React.FC = memo( return isLoading ? ( - ) : isError ? ( + ) : isError || memoizedNodes.length === 0 ? ( ) : ( - }> + + + + } + > ({ +const mockUseUiSetting = jest.fn().mockReturnValue([true]); +jest.mock('@kbn/kibana-react-plugin/public', () => { + const original = jest.requireActual('@kbn/kibana-react-plugin/public'); + return { + ...original, + useUiSetting$: () => mockUseUiSetting(), + }; +}); + +jest.mock('../../../../common/hooks/use_experimental_features', () => ({ + useIsExperimentalFeatureEnabled: jest.fn(), +})); + +const useIsExperimentalFeatureEnabledMock = useIsExperimentalFeatureEnabled as jest.Mock; + +jest.mock('../../shared/hooks/use_graph_preview'); +jest.mock('@kbn/cloud-security-posture-graph/src/hooks', () => ({ useFetchGraphData: jest.fn(), })); const mockUseFetchGraphData = useFetchGraphData as jest.Mock; @@ -43,16 +59,25 @@ const renderGraphPreview = (context = mockContextValue) => ); +const DEFAULT_NODES = [ + { + id: '1', + color: 'primary', + shape: 'ellipse', + }, +]; + describe('', () => { beforeEach(() => { jest.clearAllMocks(); + useIsExperimentalFeatureEnabledMock.mockReturnValue(true); }); it('should render component and link in header', async () => { mockUseFetchGraphData.mockReturnValue({ isLoading: false, isError: false, - data: { nodes: [], edges: [] }, + data: { nodes: DEFAULT_NODES, edges: [] }, }); const timestamp = new Date().toISOString(); @@ -60,11 +85,64 @@ describe('', () => { (useGraphPreview as jest.Mock).mockReturnValue({ timestamp, eventIds: [], - isAuditLog: true, + hasGraphRepresentation: true, }); const { getByTestId, queryByTestId, findByTestId } = renderGraphPreview(); + // Using findByTestId to wait for the component to be rendered because it is a lazy loaded component + expect(await findByTestId(GRAPH_PREVIEW_TEST_ID)).toBeInTheDocument(); + expect( + getByTestId(EXPANDABLE_PANEL_HEADER_TITLE_LINK_TEST_ID(GRAPH_PREVIEW_TEST_ID)) + ).toBeInTheDocument(); + expect( + queryByTestId(EXPANDABLE_PANEL_TOGGLE_ICON_TEST_ID(GRAPH_PREVIEW_TEST_ID)) + ).not.toBeInTheDocument(); + expect( + getByTestId(EXPANDABLE_PANEL_HEADER_TITLE_ICON_TEST_ID(GRAPH_PREVIEW_TEST_ID)) + ).toBeInTheDocument(); + expect( + getByTestId(EXPANDABLE_PANEL_CONTENT_TEST_ID(GRAPH_PREVIEW_TEST_ID)) + ).toBeInTheDocument(); + expect( + queryByTestId(EXPANDABLE_PANEL_HEADER_TITLE_TEXT_TEST_ID(GRAPH_PREVIEW_TEST_ID)) + ).not.toBeInTheDocument(); + expect(mockUseFetchGraphData).toHaveBeenCalled(); + expect(mockUseFetchGraphData.mock.calls[0][0]).toEqual({ + req: { + query: { + eventIds: [], + start: `${timestamp}||-30m`, + end: `${timestamp}||+30m`, + }, + }, + options: { + enabled: true, + refetchOnWindowFocus: false, + }, + }); + }); + + it('should render component and without link in header in preview panel', async () => { + mockUseFetchGraphData.mockReturnValue({ + isLoading: false, + isError: false, + data: { nodes: DEFAULT_NODES, edges: [] }, + }); + + const timestamp = new Date().toISOString(); + + (useGraphPreview as jest.Mock).mockReturnValue({ + timestamp, + eventIds: [], + hasGraphRepresentation: true, + }); + + const { getByTestId, queryByTestId, findByTestId } = renderGraphPreview({ + ...mockContextValue, + isPreviewMode: true, + }); + // Using findByTestId to wait for the component to be rendered because it is a lazy loaded component expect(await findByTestId(GRAPH_PREVIEW_TEST_ID)).toBeInTheDocument(); expect( @@ -98,21 +176,159 @@ describe('', () => { }); }); - it('should not render when graph data is not available', () => { + it('should render component and without link in header in rule preview', async () => { + mockUseFetchGraphData.mockReturnValue({ + isLoading: false, + isError: false, + data: { nodes: DEFAULT_NODES, edges: [] }, + }); + + const timestamp = new Date().toISOString(); + + (useGraphPreview as jest.Mock).mockReturnValue({ + timestamp, + eventIds: [], + hasGraphRepresentation: true, + }); + + const { getByTestId, queryByTestId, findByTestId } = renderGraphPreview({ + ...mockContextValue, + isPreview: true, + }); + + // Using findByTestId to wait for the component to be rendered because it is a lazy loaded component + expect(await findByTestId(GRAPH_PREVIEW_TEST_ID)).toBeInTheDocument(); + expect( + queryByTestId(EXPANDABLE_PANEL_HEADER_TITLE_LINK_TEST_ID(GRAPH_PREVIEW_TEST_ID)) + ).not.toBeInTheDocument(); + expect( + queryByTestId(EXPANDABLE_PANEL_TOGGLE_ICON_TEST_ID(GRAPH_PREVIEW_TEST_ID)) + ).not.toBeInTheDocument(); + expect( + getByTestId(EXPANDABLE_PANEL_HEADER_TITLE_ICON_TEST_ID(GRAPH_PREVIEW_TEST_ID)) + ).toBeInTheDocument(); + expect( + getByTestId(EXPANDABLE_PANEL_CONTENT_TEST_ID(GRAPH_PREVIEW_TEST_ID)) + ).toBeInTheDocument(); + expect( + getByTestId(EXPANDABLE_PANEL_HEADER_TITLE_TEXT_TEST_ID(GRAPH_PREVIEW_TEST_ID)) + ).toBeInTheDocument(); + expect(mockUseFetchGraphData).toHaveBeenCalled(); + expect(mockUseFetchGraphData.mock.calls[0][0]).toEqual({ + req: { + query: { + eventIds: [], + start: `${timestamp}||-30m`, + end: `${timestamp}||+30m`, + }, + }, + options: { + enabled: true, + refetchOnWindowFocus: false, + }, + }); + }); + + it('should render component and without link in header when expanding flyout feature is disabled', async () => { + mockUseUiSetting.mockReturnValue([false]); + mockUseFetchGraphData.mockReturnValue({ + isLoading: false, + isError: false, + data: { nodes: DEFAULT_NODES, edges: [] }, + }); + + const timestamp = new Date().toISOString(); + + (useGraphPreview as jest.Mock).mockReturnValue({ + timestamp, + eventIds: [], + hasGraphRepresentation: true, + }); + + const { getByTestId, queryByTestId, findByTestId } = renderGraphPreview(); + + // Using findByTestId to wait for the component to be rendered because it is a lazy loaded component + expect(await findByTestId(GRAPH_PREVIEW_TEST_ID)).toBeInTheDocument(); + expect( + queryByTestId(EXPANDABLE_PANEL_HEADER_TITLE_LINK_TEST_ID(GRAPH_PREVIEW_TEST_ID)) + ).not.toBeInTheDocument(); + expect( + queryByTestId(EXPANDABLE_PANEL_TOGGLE_ICON_TEST_ID(GRAPH_PREVIEW_TEST_ID)) + ).not.toBeInTheDocument(); + expect( + getByTestId(EXPANDABLE_PANEL_HEADER_TITLE_ICON_TEST_ID(GRAPH_PREVIEW_TEST_ID)) + ).toBeInTheDocument(); + expect( + getByTestId(EXPANDABLE_PANEL_CONTENT_TEST_ID(GRAPH_PREVIEW_TEST_ID)) + ).toBeInTheDocument(); + expect( + getByTestId(EXPANDABLE_PANEL_HEADER_TITLE_TEXT_TEST_ID(GRAPH_PREVIEW_TEST_ID)) + ).toBeInTheDocument(); + expect(mockUseFetchGraphData).toHaveBeenCalled(); + expect(mockUseFetchGraphData.mock.calls[0][0]).toEqual({ + req: { + query: { + eventIds: [], + start: `${timestamp}||-30m`, + end: `${timestamp}||+30m`, + }, + }, + options: { + enabled: true, + refetchOnWindowFocus: false, + }, + }); + }); + + it('should not render when graph data is not available', async () => { mockUseFetchGraphData.mockReturnValue({ isLoading: false, isError: false, data: undefined, }); + const timestamp = new Date().toISOString(); + (useGraphPreview as jest.Mock).mockReturnValue({ - isAuditLog: false, + timestamp, + eventIds: [], + hasGraphRepresentation: false, }); - const { queryByTestId } = renderGraphPreview(); + const { getByTestId, queryByTestId, findByTestId } = renderGraphPreview(); + // Using findByTestId to wait for the component to be rendered because it is a lazy loaded component expect( - queryByTestId(EXPANDABLE_PANEL_HEADER_TITLE_TEXT_TEST_ID(GRAPH_PREVIEW_TEST_ID)) + await findByTestId(EXPANDABLE_PANEL_CONTENT_TEST_ID(GRAPH_PREVIEW_TEST_ID)) + ).toBeInTheDocument(); + expect( + queryByTestId(EXPANDABLE_PANEL_HEADER_TITLE_LINK_TEST_ID(GRAPH_PREVIEW_TEST_ID)) + ).not.toBeInTheDocument(); + expect( + queryByTestId(EXPANDABLE_PANEL_TOGGLE_ICON_TEST_ID(GRAPH_PREVIEW_TEST_ID)) ).not.toBeInTheDocument(); + expect( + getByTestId(EXPANDABLE_PANEL_HEADER_TITLE_ICON_TEST_ID(GRAPH_PREVIEW_TEST_ID)) + ).toBeInTheDocument(); + expect( + getByTestId(EXPANDABLE_PANEL_CONTENT_TEST_ID(GRAPH_PREVIEW_TEST_ID)) + ).toBeInTheDocument(); + expect( + getByTestId(EXPANDABLE_PANEL_HEADER_TITLE_TEXT_TEST_ID(GRAPH_PREVIEW_TEST_ID)) + ).toBeInTheDocument(); + expect(mockUseFetchGraphData).toHaveBeenCalled(); + expect(mockUseFetchGraphData.mock.calls[0][0]).toEqual({ + req: { + query: { + eventIds: [], + start: `${timestamp}||-30m`, + end: `${timestamp}||+30m`, + }, + }, + options: { + enabled: false, + refetchOnWindowFocus: false, + }, + }); }); }); diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/graph_preview_container.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/graph_preview_container.tsx index 0b881b8f8d439..90a0218778549 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/graph_preview_container.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/graph_preview_container.tsx @@ -7,23 +7,48 @@ import React from 'react'; import { FormattedMessage } from '@kbn/i18n-react'; +import { useUiSetting$ } from '@kbn/kibana-react-plugin/public'; +import { EuiBetaBadge } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { useFetchGraphData } from '@kbn/cloud-security-posture-graph/src/hooks'; +import { ENABLE_VISUALIZATIONS_IN_FLYOUT_SETTING } from '../../../../../common/constants'; import { useDocumentDetailsContext } from '../../shared/context'; import { GRAPH_PREVIEW_TEST_ID } from './test_ids'; import { GraphPreview } from './graph_preview'; -import { useFetchGraphData } from '../hooks/use_fetch_graph_data'; -import { useGraphPreview } from '../hooks/use_graph_preview'; +import { useGraphPreview } from '../../shared/hooks/use_graph_preview'; +import { useNavigateToGraphVisualization } from '../../shared/hooks/use_navigate_to_graph_visualization'; import { ExpandablePanel } from '../../../shared/components/expandable_panel'; /** * Graph preview under Overview, Visualizations. It shows a graph representation of entities. */ export const GraphPreviewContainer: React.FC = () => { - const { dataAsNestedObject, getFieldsData } = useDocumentDetailsContext(); + const { + dataAsNestedObject, + getFieldsData, + eventId, + indexName, + scopeId, + isPreview, + isPreviewMode, + } = useDocumentDetailsContext(); + + const [visualizationInFlyoutEnabled] = useUiSetting$( + ENABLE_VISUALIZATIONS_IN_FLYOUT_SETTING + ); + const allowFlyoutExpansion = visualizationInFlyoutEnabled && !isPreviewMode && !isPreview; + + const { navigateToGraphVisualization } = useNavigateToGraphVisualization({ + eventId, + indexName, + isFlyoutOpen: true, + scopeId, + }); const { eventIds, timestamp = new Date().toISOString(), - isAuditLog, + hasGraphRepresentation, } = useGraphPreview({ getFieldsData, ecsData: dataAsNestedObject, @@ -39,35 +64,64 @@ export const GraphPreviewContainer: React.FC = () => { }, }, options: { - enabled: isAuditLog, + enabled: hasGraphRepresentation, refetchOnWindowFocus: false, }, }); return ( - isAuditLog && ( - - ), - iconType: 'indexMapping', - }} - data-test-subj={GRAPH_PREVIEW_TEST_ID} - content={ - !isLoading && !isError - ? { - paddingSize: 'none', + + ), + headerContent: ( + - - - ) + )} + /> + ), + iconType: allowFlyoutExpansion ? 'arrowStart' : 'indexMapping', + ...(allowFlyoutExpansion && { + link: { + callback: navigateToGraphVisualization, + tooltip: ( + + ), + }, + }), + }} + data-test-subj={GRAPH_PREVIEW_TEST_ID} + content={ + !isLoading && !isError + ? { + paddingSize: 'none', + } + : undefined + } + > + + ); }; diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/visualizations_section.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/visualizations_section.test.tsx index 3aeb7d30f8e48..6fb4d5d30b897 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/visualizations_section.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/visualizations_section.test.tsx @@ -8,6 +8,7 @@ import React from 'react'; import { __IntlProvider as IntlProvider } from '@kbn/i18n-react'; import { render } from '@testing-library/react'; +import { useFetchGraphData } from '@kbn/cloud-security-posture-graph/src/hooks'; import { ANALYZER_PREVIEW_TEST_ID, SESSION_PREVIEW_TEST_ID, @@ -25,9 +26,8 @@ import { TestProvider } from '@kbn/expandable-flyout/src/test/provider'; import { useExpandSection } from '../hooks/use_expand_section'; import { useInvestigateInTimeline } from '../../../../detections/components/alerts_table/timeline_actions/use_investigate_in_timeline'; import { useIsInvestigateInResolverActionEnabled } from '../../../../detections/components/alerts_table/timeline_actions/investigate_in_resolver'; +import { useGraphPreview } from '../../shared/hooks/use_graph_preview'; import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features'; -import { useGraphPreview } from '../hooks/use_graph_preview'; -import { useFetchGraphData } from '../hooks/use_fetch_graph_data'; jest.mock('../hooks/use_expand_section'); jest.mock('../../shared/hooks/use_alert_prevalence_from_process_tree', () => ({ @@ -53,6 +53,7 @@ jest.mock( jest.mock( '../../../../detections/components/alerts_table/timeline_actions/investigate_in_resolver' ); + jest.mock('../../../../common/hooks/use_experimental_features', () => ({ useIsExperimentalFeatureEnabled: jest.fn(), })); @@ -67,11 +68,11 @@ jest.mock('@kbn/kibana-react-plugin/public', () => { useUiSetting$: () => mockUseUiSetting(), }; }); -jest.mock('../hooks/use_graph_preview'); +jest.mock('../../shared/hooks/use_graph_preview'); const mockUseGraphPreview = useGraphPreview as jest.Mock; -jest.mock('../hooks/use_fetch_graph_data', () => ({ +jest.mock('@kbn/cloud-security-posture-graph/src/hooks', () => ({ useFetchGraphData: jest.fn(), })); @@ -95,6 +96,7 @@ const renderVisualizationsSection = (contextValue = panelContextValue) => describe('', () => { beforeEach(() => { + mockUseUiSetting.mockReturnValue([false]); mockUseTimelineDataFilters.mockReturnValue({ selectedPatterns: ['index'] }); mockUseAlertPrevalenceFromProcessTree.mockReturnValue({ loading: false, @@ -103,7 +105,7 @@ describe('', () => { statsNodes: undefined, }); mockUseGraphPreview.mockReturnValue({ - isAuditLog: true, + hasGraphRepresentation: true, }); mockUseFetchGraphData.mockReturnValue({ isLoading: false, @@ -136,6 +138,7 @@ describe('', () => { }); (useIsInvestigateInResolverActionEnabled as jest.Mock).mockReturnValue(true); (useExpandSection as jest.Mock).mockReturnValue(true); + mockUseUiSetting.mockReturnValue([false]); useIsExperimentalFeatureEnabledMock.mockReturnValue(false); const { getByTestId, queryByTestId } = renderVisualizationsSection(); @@ -148,10 +151,31 @@ describe('', () => { it('should render the graph preview component if the feature is enabled', () => { (useExpandSection as jest.Mock).mockReturnValue(true); + mockUseUiSetting.mockReturnValue([true]); useIsExperimentalFeatureEnabledMock.mockReturnValue(true); const { getByTestId } = renderVisualizationsSection(); expect(getByTestId(`${GRAPH_PREVIEW_TEST_ID}LeftSection`)).toBeInTheDocument(); }); + + it('should not render the graph preview component if the experimental feature is disabled', () => { + (useExpandSection as jest.Mock).mockReturnValue(true); + mockUseUiSetting.mockReturnValue([true]); + useIsExperimentalFeatureEnabledMock.mockReturnValue(false); + + const { queryByTestId } = renderVisualizationsSection(); + + expect(queryByTestId(`${GRAPH_PREVIEW_TEST_ID}LeftSection`)).not.toBeInTheDocument(); + }); + + it('should not render the graph preview component if the flyout feature is disabled', () => { + (useExpandSection as jest.Mock).mockReturnValue(true); + mockUseUiSetting.mockReturnValue([false]); + useIsExperimentalFeatureEnabledMock.mockReturnValue(true); + + const { queryByTestId } = renderVisualizationsSection(); + + expect(queryByTestId(`${GRAPH_PREVIEW_TEST_ID}LeftSection`)).not.toBeInTheDocument(); + }); }); diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/visualizations_section.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/visualizations_section.tsx index c328036eece43..23bea1f8fecdd 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/components/visualizations_section.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/right/components/visualizations_section.tsx @@ -8,15 +8,18 @@ import React, { memo } from 'react'; import { EuiSpacer } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; +import { useUiSetting$ } from '@kbn/kibana-react-plugin/public'; import { useExpandSection } from '../hooks/use_expand_section'; import { AnalyzerPreviewContainer } from './analyzer_preview_container'; import { SessionPreviewContainer } from './session_preview_container'; import { ExpandableSection } from './expandable_section'; import { VISUALIZATIONS_TEST_ID } from './test_ids'; import { GraphPreviewContainer } from './graph_preview_container'; -import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features'; import { useDocumentDetailsContext } from '../../shared/context'; -import { useGraphPreview } from '../hooks/use_graph_preview'; +import { useGraphPreview } from '../../shared/hooks/use_graph_preview'; +import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features'; +import { ENABLE_VISUALIZATIONS_IN_FLYOUT_SETTING } from '../../../../../common/constants'; +import { GRAPH_VISUALIZATION_IN_FLYOUT_ENABLED_EXPERIMENTAL_FEATURE } from '../../shared/constants/experimental_features'; const KEY = 'visualizations'; @@ -25,18 +28,25 @@ const KEY = 'visualizations'; */ export const VisualizationsSection = memo(() => { const expanded = useExpandSection({ title: KEY, defaultValue: false }); - const graphVisualizationInFlyoutEnabled = useIsExperimentalFeatureEnabled( - 'graphVisualizationInFlyoutEnabled' + const { dataAsNestedObject, getFieldsData } = useDocumentDetailsContext(); + + const [visualizationInFlyoutEnabled] = useUiSetting$( + ENABLE_VISUALIZATIONS_IN_FLYOUT_SETTING ); - const { dataAsNestedObject, getFieldsData } = useDocumentDetailsContext(); + const isGraphFeatureEnabled = useIsExperimentalFeatureEnabled( + GRAPH_VISUALIZATION_IN_FLYOUT_ENABLED_EXPERIMENTAL_FEATURE + ); // Decide whether to show the graph preview or not - const { isAuditLog: isGraphPreviewEnabled } = useGraphPreview({ + const { hasGraphRepresentation } = useGraphPreview({ getFieldsData, ecsData: dataAsNestedObject, }); + const shouldShowGraphPreview = + visualizationInFlyoutEnabled && isGraphFeatureEnabled && hasGraphRepresentation; + return ( { - {graphVisualizationInFlyoutEnabled && isGraphPreviewEnabled && ( + {shouldShowGraphPreview && ( <> diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/shared/constants/experimental_features.ts b/x-pack/plugins/security_solution/public/flyout/document_details/shared/constants/experimental_features.ts new file mode 100644 index 0000000000000..aeb8b899ef6ea --- /dev/null +++ b/x-pack/plugins/security_solution/public/flyout/document_details/shared/constants/experimental_features.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +/** This security solution experimental feature allows user to enable/disable the graph visualization in Flyout feature (depends on securitySolution:enableVisualizationsInFlyout) */ +export const GRAPH_VISUALIZATION_IN_FLYOUT_ENABLED_EXPERIMENTAL_FEATURE = + 'graphVisualizationInFlyoutEnabled' as const; diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/hooks/use_graph_preview.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_graph_preview.test.tsx similarity index 61% rename from x-pack/plugins/security_solution/public/flyout/document_details/right/hooks/use_graph_preview.test.tsx rename to x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_graph_preview.test.tsx index 7fa0741a85118..453f897d4e188 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/hooks/use_graph_preview.test.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_graph_preview.test.tsx @@ -9,18 +9,30 @@ import type { RenderHookResult } from '@testing-library/react'; import { renderHook } from '@testing-library/react'; import type { UseGraphPreviewParams, UseGraphPreviewResult } from './use_graph_preview'; import { useGraphPreview } from './use_graph_preview'; -import type { GetFieldsData } from '../../shared/hooks/use_get_fields_data'; -import { mockFieldData } from '../../shared/mocks/mock_get_fields_data'; +import type { GetFieldsData } from './use_get_fields_data'; +import { mockFieldData } from '../mocks/mock_get_fields_data'; + +const mockGetFieldsData: GetFieldsData = (field: string) => { + if (field === 'kibana.alert.original_event.id') { + return 'eventId'; + } else if (field === 'actor.entity.id') { + return 'actorId'; + } else if (field === 'target.entity.id') { + return 'targetId'; + } + + return mockFieldData[field]; +}; describe('useGraphPreview', () => { let hookResult: RenderHookResult; it(`should return false when missing actor`, () => { const getFieldsData: GetFieldsData = (field: string) => { - if (field === 'kibana.alert.original_event.id') { - return 'eventId'; + if (field === 'actor.entity.id') { + return; } - return mockFieldData[field]; + return mockGetFieldsData(field); }; hookResult = renderHook((props: UseGraphPreviewParams) => useGraphPreview(props), { @@ -35,22 +47,42 @@ describe('useGraphPreview', () => { }, }); - const { isAuditLog, timestamp, eventIds, actorIds, action } = hookResult.result.current; - expect(isAuditLog).toEqual(false); + const { hasGraphRepresentation, timestamp, eventIds, actorIds, action, targetIds } = + hookResult.result.current; + expect(hasGraphRepresentation).toEqual(false); expect(timestamp).toEqual(mockFieldData['@timestamp'][0]); expect(eventIds).toEqual(['eventId']); expect(actorIds).toEqual([]); + expect(targetIds).toEqual(['targetId']); expect(action).toEqual(['action']); }); it(`should return false when missing event.action`, () => { + hookResult = renderHook((props: UseGraphPreviewParams) => useGraphPreview(props), { + initialProps: { + getFieldsData: mockGetFieldsData, + ecsData: { + _id: 'id', + }, + }, + }); + + const { hasGraphRepresentation, timestamp, eventIds, actorIds, action, targetIds } = + hookResult.result.current; + expect(hasGraphRepresentation).toEqual(false); + expect(timestamp).toEqual(mockFieldData['@timestamp'][0]); + expect(eventIds).toEqual(['eventId']); + expect(actorIds).toEqual(['actorId']); + expect(targetIds).toEqual(['targetId']); + expect(action).toEqual(undefined); + }); + + it(`should return false when missing target`, () => { const getFieldsData: GetFieldsData = (field: string) => { - if (field === 'kibana.alert.original_event.id') { - return 'eventId'; - } else if (field === 'actor.entity.id') { - return 'actorId'; + if (field === 'target.entity.id') { + return; } - return mockFieldData[field]; + return mockGetFieldsData(field); }; hookResult = renderHook((props: UseGraphPreviewParams) => useGraphPreview(props), { @@ -62,20 +94,23 @@ describe('useGraphPreview', () => { }, }); - const { isAuditLog, timestamp, eventIds, actorIds, action } = hookResult.result.current; - expect(isAuditLog).toEqual(false); + const { hasGraphRepresentation, timestamp, eventIds, actorIds, action, targetIds } = + hookResult.result.current; + expect(hasGraphRepresentation).toEqual(false); expect(timestamp).toEqual(mockFieldData['@timestamp'][0]); expect(eventIds).toEqual(['eventId']); expect(actorIds).toEqual(['actorId']); + expect(targetIds).toEqual([]); expect(action).toEqual(undefined); }); it(`should return false when missing original_event.id`, () => { const getFieldsData: GetFieldsData = (field: string) => { - if (field === 'actor.entity.id') { - return 'actorId'; + if (field === 'kibana.alert.original_event.id') { + return; } - return mockFieldData[field]; + + return mockGetFieldsData(field); }; hookResult = renderHook((props: UseGraphPreviewParams) => useGraphPreview(props), { @@ -90,11 +125,13 @@ describe('useGraphPreview', () => { }, }); - const { isAuditLog, timestamp, eventIds, actorIds, action } = hookResult.result.current; - expect(isAuditLog).toEqual(false); + const { hasGraphRepresentation, timestamp, eventIds, actorIds, action, targetIds } = + hookResult.result.current; + expect(hasGraphRepresentation).toEqual(false); expect(timestamp).toEqual(mockFieldData['@timestamp'][0]); expect(eventIds).toEqual([]); expect(actorIds).toEqual(['actorId']); + expect(targetIds).toEqual(['targetId']); expect(action).toEqual(['action']); }); @@ -102,13 +139,9 @@ describe('useGraphPreview', () => { const getFieldsData: GetFieldsData = (field: string) => { if (field === '@timestamp') { return; - } else if (field === 'kibana.alert.original_event.id') { - return 'eventId'; - } else if (field === 'actor.entity.id') { - return 'actorId'; } - return mockFieldData[field]; + return mockGetFieldsData(field); }; hookResult = renderHook((props: UseGraphPreviewParams) => useGraphPreview(props), { @@ -123,28 +156,20 @@ describe('useGraphPreview', () => { }, }); - const { isAuditLog, timestamp, eventIds, actorIds, action } = hookResult.result.current; - expect(isAuditLog).toEqual(false); + const { hasGraphRepresentation, timestamp, eventIds, actorIds, action, targetIds } = + hookResult.result.current; + expect(hasGraphRepresentation).toEqual(false); expect(timestamp).toEqual(null); expect(eventIds).toEqual(['eventId']); expect(actorIds).toEqual(['actorId']); + expect(targetIds).toEqual(['targetId']); expect(action).toEqual(['action']); }); it(`should return true when alert is has graph preview`, () => { - const getFieldsData: GetFieldsData = (field: string) => { - if (field === 'kibana.alert.original_event.id') { - return 'eventId'; - } else if (field === 'actor.entity.id') { - return 'actorId'; - } - - return mockFieldData[field]; - }; - hookResult = renderHook((props: UseGraphPreviewParams) => useGraphPreview(props), { initialProps: { - getFieldsData, + getFieldsData: mockGetFieldsData, ecsData: { _id: 'id', event: { @@ -154,11 +179,13 @@ describe('useGraphPreview', () => { }, }); - const { isAuditLog, timestamp, eventIds, actorIds, action } = hookResult.result.current; - expect(isAuditLog).toEqual(true); + const { hasGraphRepresentation, timestamp, eventIds, actorIds, action, targetIds } = + hookResult.result.current; + expect(hasGraphRepresentation).toEqual(true); expect(timestamp).toEqual(mockFieldData['@timestamp'][0]); expect(eventIds).toEqual(['eventId']); expect(actorIds).toEqual(['actorId']); + expect(targetIds).toEqual(['targetId']); expect(action).toEqual(['action']); }); @@ -168,6 +195,8 @@ describe('useGraphPreview', () => { return ['id1', 'id2']; } else if (field === 'actor.entity.id') { return ['actorId1', 'actorId2']; + } else if (field === 'target.entity.id') { + return ['targetId1', 'targetId2']; } return mockFieldData[field]; @@ -185,11 +214,13 @@ describe('useGraphPreview', () => { }, }); - const { isAuditLog, timestamp, eventIds, actorIds, action } = hookResult.result.current; - expect(isAuditLog).toEqual(true); + const { hasGraphRepresentation, timestamp, eventIds, actorIds, action, targetIds } = + hookResult.result.current; + expect(hasGraphRepresentation).toEqual(true); expect(timestamp).toEqual(mockFieldData['@timestamp'][0]); expect(eventIds).toEqual(['id1', 'id2']); expect(actorIds).toEqual(['actorId1', 'actorId2']); expect(action).toEqual(['action1', 'action2']); + expect(targetIds).toEqual(['targetId1', 'targetId2']); }); }); diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/right/hooks/use_graph_preview.ts b/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_graph_preview.ts similarity index 71% rename from x-pack/plugins/security_solution/public/flyout/document_details/right/hooks/use_graph_preview.ts rename to x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_graph_preview.ts index bbaeb808c9e2a..48233afab02df 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/right/hooks/use_graph_preview.ts +++ b/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_graph_preview.ts @@ -7,8 +7,8 @@ import type { EcsSecurityExtension as Ecs } from '@kbn/securitysolution-ecs'; import { get } from 'lodash/fp'; -import type { GetFieldsData } from '../../shared/hooks/use_get_fields_data'; -import { getField, getFieldArray } from '../../shared/utils'; +import type { GetFieldsData } from './use_get_fields_data'; +import { getField, getFieldArray } from '../utils'; export interface UseGraphPreviewParams { /** @@ -40,15 +40,20 @@ export interface UseGraphPreviewResult { */ actorIds: string[]; + /** + * Array of target entity IDs associated with the alert + */ + targetIds: string[]; + /** * Action associated with the event */ action?: string[]; /** - * Boolean indicating if the event is an audit log (contains event ids, actor ids and action) + * Boolean indicating if the event is has a graph representation (contains event ids, actor ids and action) */ - isAuditLog: boolean; + hasGraphRepresentation: boolean; } /** @@ -64,9 +69,14 @@ export const useGraphPreview = ({ const eventIds = originalEventId ? getFieldArray(originalEventId) : getFieldArray(eventId); const actorIds = getFieldArray(getFieldsData('actor.entity.id')); + const targetIds = getFieldArray(getFieldsData('target.entity.id')); const action: string[] | undefined = get(['event', 'action'], ecsData); - const isAuditLog = - Boolean(timestamp) && actorIds.length > 0 && Boolean(action?.length) && eventIds.length > 0; + const hasGraphRepresentation = + Boolean(timestamp) && + Boolean(action?.length) && + actorIds.length > 0 && + eventIds.length > 0 && + targetIds.length > 0; - return { timestamp, eventIds, actorIds, action, isAuditLog }; + return { timestamp, eventIds, actorIds, action, targetIds, hasGraphRepresentation }; }; diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_navigate_to_analyzer.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_navigate_to_analyzer.tsx index a4539ed7e6415..2137ce83527a8 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_navigate_to_analyzer.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_navigate_to_analyzer.tsx @@ -49,7 +49,7 @@ export interface UseNavigateToAnalyzerResult { } /** - * Hook that returns the a callback to navigate to the analyzer in the flyout + * Hook that returns a callback to navigate to the analyzer in the flyout */ export const useNavigateToAnalyzer = ({ isFlyoutOpen, diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_navigate_to_graph_visualization.test.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_navigate_to_graph_visualization.test.tsx new file mode 100644 index 0000000000000..929dc208f3b38 --- /dev/null +++ b/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_navigate_to_graph_visualization.test.tsx @@ -0,0 +1,94 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { renderHook } from '@testing-library/react-hooks'; +import { useExpandableFlyoutApi } from '@kbn/expandable-flyout'; +import { mockFlyoutApi } from '../mocks/mock_flyout_context'; +import { useWhichFlyout } from './use_which_flyout'; +import { useKibana as mockUseKibana } from '../../../../common/lib/kibana/__mocks__'; +import { useKibana } from '../../../../common/lib/kibana'; +import { DocumentDetailsRightPanelKey, DocumentDetailsLeftPanelKey } from '../constants/panel_keys'; +import { useNavigateToGraphVisualization } from './use_navigate_to_graph_visualization'; +import { GRAPH_ID } from '../../left/components/graph_visualization'; + +jest.mock('@kbn/expandable-flyout'); +jest.mock('../../../../common/lib/kibana'); +jest.mock('./use_which_flyout'); + +const mockedUseKibana = mockUseKibana(); +(useKibana as jest.Mock).mockReturnValue(mockedUseKibana); + +const mockUseWhichFlyout = useWhichFlyout as jest.Mock; +const FLYOUT_KEY = 'SecuritySolution'; + +const eventId = 'eventId1'; +const indexName = 'index1'; +const scopeId = 'scopeId1'; + +describe('useNavigateToGraphVisualization', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.mocked(useExpandableFlyoutApi).mockReturnValue(mockFlyoutApi); + }); + + it('when isFlyoutOpen is true, should return callback that opens left and preview panels', () => { + mockUseWhichFlyout.mockReturnValue(FLYOUT_KEY); + const hookResult = renderHook(() => + useNavigateToGraphVisualization({ isFlyoutOpen: true, eventId, indexName, scopeId }) + ); + + // Act + hookResult.result.current.navigateToGraphVisualization(); + + expect(mockFlyoutApi.openLeftPanel).toHaveBeenCalledWith({ + id: DocumentDetailsLeftPanelKey, + path: { + tab: 'visualize', + subTab: GRAPH_ID, + }, + params: { + id: eventId, + indexName, + scopeId, + }, + }); + }); + + it('when isFlyoutOpen is false and scopeId is not timeline, should return callback that opens a new flyout', () => { + mockUseWhichFlyout.mockReturnValue(null); + + const hookResult = renderHook(() => + useNavigateToGraphVisualization({ isFlyoutOpen: false, eventId, indexName, scopeId }) + ); + + // Act + hookResult.result.current.navigateToGraphVisualization(); + + expect(mockFlyoutApi.openFlyout).toHaveBeenCalledWith({ + right: { + id: DocumentDetailsRightPanelKey, + params: { + id: eventId, + indexName, + scopeId, + }, + }, + left: { + id: DocumentDetailsLeftPanelKey, + path: { + tab: 'visualize', + subTab: GRAPH_ID, + }, + params: { + id: eventId, + indexName, + scopeId, + }, + }, + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_navigate_to_graph_visualization.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_navigate_to_graph_visualization.tsx new file mode 100644 index 0000000000000..bb61ae6f97073 --- /dev/null +++ b/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_navigate_to_graph_visualization.tsx @@ -0,0 +1,105 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useCallback, useMemo } from 'react'; +import type { FlyoutPanelProps } from '@kbn/expandable-flyout'; +import { useExpandableFlyoutApi } from '@kbn/expandable-flyout'; +import type { Maybe } from '@kbn/timelines-plugin/common/search_strategy/common'; +import { useKibana } from '../../../../common/lib/kibana'; +import { DocumentDetailsLeftPanelKey, DocumentDetailsRightPanelKey } from '../constants/panel_keys'; +import { DocumentEventTypes } from '../../../../common/lib/telemetry'; +import { GRAPH_ID } from '../../left/components/graph_visualization'; + +export interface UseNavigateToGraphVisualizationParams { + /** + * When flyout is already open, call open left panel only + * When flyout is not open, open a new flyout + */ + isFlyoutOpen: boolean; + /** + * Id of the document + */ + eventId: string; + /** + * Name of the index used in the parent's page + */ + indexName: Maybe | undefined; + /** + * Scope id of the page + */ + scopeId: string; +} + +export interface UseNavigateToGraphVisualizationResult { + /** + * Callback to open analyzer in visualize tab + */ + navigateToGraphVisualization: () => void; +} + +/** + * Hook that returns a callback to navigate to the graph visualization in the flyout + */ +export const useNavigateToGraphVisualization = ({ + isFlyoutOpen, + eventId, + indexName, + scopeId, +}: UseNavigateToGraphVisualizationParams): UseNavigateToGraphVisualizationResult => { + const { telemetry } = useKibana().services; + const { openLeftPanel, openFlyout } = useExpandableFlyoutApi(); + + const right: FlyoutPanelProps = useMemo( + () => ({ + id: DocumentDetailsRightPanelKey, + params: { + id: eventId, + indexName, + scopeId, + }, + }), + [eventId, indexName, scopeId] + ); + + const left: FlyoutPanelProps = useMemo( + () => ({ + id: DocumentDetailsLeftPanelKey, + params: { + id: eventId, + indexName, + scopeId, + }, + path: { + tab: 'visualize', + subTab: GRAPH_ID, + }, + }), + [eventId, indexName, scopeId] + ); + + const navigateToGraphVisualization = useCallback(() => { + if (isFlyoutOpen) { + openLeftPanel(left); + telemetry.reportEvent(DocumentEventTypes.DetailsFlyoutTabClicked, { + location: scopeId, + panel: 'left', + tabId: 'visualize', + }); + } else { + openFlyout({ + right, + left, + }); + telemetry.reportEvent(DocumentEventTypes.DetailsFlyoutOpened, { + location: scopeId, + panel: 'left', + }); + } + }, [openFlyout, openLeftPanel, right, left, scopeId, telemetry, isFlyoutOpen]); + + return useMemo(() => ({ navigateToGraphVisualization }), [navigateToGraphVisualization]); +}; diff --git a/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_navigate_to_session_view.tsx b/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_navigate_to_session_view.tsx index f0b2733998c97..d98ce5f489e38 100644 --- a/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_navigate_to_session_view.tsx +++ b/x-pack/plugins/security_solution/public/flyout/document_details/shared/hooks/use_navigate_to_session_view.tsx @@ -42,7 +42,7 @@ export interface UseNavigateToSessionViewResult { } /** - * Hook that returns the a callback to navigate to session view in the flyout + * Hook that returns a callback to navigate to session view in the flyout */ export const useNavigateToSessionView = ({ isFlyoutOpen, diff --git a/x-pack/test/api_integration/apis/cloud_security_posture/config.ts b/x-pack/test/api_integration/apis/cloud_security_posture/config.ts index 5f335f116fefe..28ac1f643041b 100644 --- a/x-pack/test/api_integration/apis/cloud_security_posture/config.ts +++ b/x-pack/test/api_integration/apis/cloud_security_posture/config.ts @@ -5,13 +5,27 @@ * 2.0. */ -import { FtrConfigProviderContext } from '@kbn/test'; +import { FtrConfigProviderContext, getKibanaCliLoggers } from '@kbn/test'; export default async function ({ readConfigFile }: FtrConfigProviderContext) { - const baseIntegrationTestsConfig = await readConfigFile(require.resolve('../../config.ts')); + const baseConfig = await readConfigFile(require.resolve('../../config.ts')); return { - ...baseIntegrationTestsConfig.getAll(), + ...baseConfig.getAll(), testFiles: [require.resolve('.')], + kbnTestServer: { + ...baseConfig.get('kbnTestServer'), + serverArgs: [ + ...baseConfig.get('kbnTestServer.serverArgs'), + `--logging.loggers=${JSON.stringify([ + ...getKibanaCliLoggers(baseConfig.get('kbnTestServer.serverArgs')), + { + name: 'plugins.cloudSecurityPosture', + level: 'all', + appenders: ['default'], + }, + ])}`, + ], + }, }; } diff --git a/x-pack/test/api_integration/apis/cloud_security_posture/graph.ts b/x-pack/test/api_integration/apis/cloud_security_posture/graph.ts new file mode 100644 index 0000000000000..4ff483bff343d --- /dev/null +++ b/x-pack/test/api_integration/apis/cloud_security_posture/graph.ts @@ -0,0 +1,51 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + ELASTIC_HTTP_VERSION_HEADER, + X_ELASTIC_INTERNAL_ORIGIN_REQUEST, +} from '@kbn/core-http-common'; +import type { Agent } from 'supertest'; +import type { GraphRequest } from '@kbn/cloud-security-posture-common/types/graph/latest'; +import { FtrProviderContext } from '@kbn/ftr-common-functional-services'; +import { result } from '../../../cloud_security_posture_api/utils'; + +export default function (providerContext: FtrProviderContext) { + const { getService } = providerContext; + + const logger = getService('log'); + const supertest = getService('supertest'); + + const postGraph = (agent: Agent, body: GraphRequest, auth?: { user: string; pass: string }) => { + let req = agent + .post('/internal/cloud_security_posture/graph') + .set(ELASTIC_HTTP_VERSION_HEADER, '1') + .set(X_ELASTIC_INTERNAL_ORIGIN_REQUEST, 'kibana') + .set('kbn-xsrf', 'xxxx'); + + if (auth) { + req = req.auth(auth.user, auth.pass); + } + + return req.send(body); + }; + + describe('POST /internal/cloud_security_posture/graph', () => { + // TODO: fix once feature flag is enabled for the API + describe.skip('Feature flag', () => { + it('should return 404 when feature flag is not toggled', async () => { + await postGraph(supertest, { + query: { + eventIds: [], + start: 'now-1d/d', + end: 'now/d', + }, + }).expect(result(404, logger)); + }); + }); + }); +} diff --git a/x-pack/test/api_integration/apis/cloud_security_posture/index.ts b/x-pack/test/api_integration/apis/cloud_security_posture/index.ts index fa11aab67b279..46f594be3ed38 100644 --- a/x-pack/test/api_integration/apis/cloud_security_posture/index.ts +++ b/x-pack/test/api_integration/apis/cloud_security_posture/index.ts @@ -20,6 +20,7 @@ export default function ({ loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./benchmark/v2')); loadTestFile(require.resolve('./rules/v1')); loadTestFile(require.resolve('./rules/v2')); + loadTestFile(require.resolve('./graph')); // Place your tests files under this directory and add the following here: // loadTestFile(require.resolve('./your test name')); diff --git a/x-pack/test/cloud_security_posture_api/routes/graph.ts b/x-pack/test/cloud_security_posture_api/routes/graph.ts index 95625b24fa59a..08adf73839ea2 100644 --- a/x-pack/test/cloud_security_posture_api/routes/graph.ts +++ b/x-pack/test/cloud_security_posture_api/routes/graph.ts @@ -28,14 +28,14 @@ export default function (providerContext: FtrProviderContext) { const cspSecurity = CspSecurityCommonProvider(providerContext); const postGraph = (agent: Agent, body: GraphRequest, auth?: { user: string; pass: string }) => { - const req = agent + let req = agent .post('/internal/cloud_security_posture/graph') .set(ELASTIC_HTTP_VERSION_HEADER, '1') .set(X_ELASTIC_INTERNAL_ORIGIN_REQUEST, 'kibana') .set('kbn-xsrf', 'xxxx'); if (auth) { - req.auth(auth.user, auth.pass); + req = req.auth(auth.user, auth.pass); } return req.send(body); diff --git a/x-pack/test/cloud_security_posture_functional/config.ts b/x-pack/test/cloud_security_posture_functional/config.ts index 7e80788ffccfe..bea81dd38dc15 100644 --- a/x-pack/test/cloud_security_posture_functional/config.ts +++ b/x-pack/test/cloud_security_posture_functional/config.ts @@ -38,6 +38,7 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { * 1. release a new package to EPR * 2. merge the updated version number change to kibana */ + `--uiSettings.overrides.securitySolution:enableVisualizationsInFlyout=true`, `--xpack.securitySolution.enableExperimental=${JSON.stringify([ 'graphVisualizationInFlyoutEnabled', ])}`, diff --git a/x-pack/test/cloud_security_posture_functional/es_archives/logs_gcp_audit/data.json b/x-pack/test/cloud_security_posture_functional/es_archives/logs_gcp_audit/data.json index 5e3d4cdfdffd5..e5b83d55c15ae 100644 --- a/x-pack/test/cloud_security_posture_functional/es_archives/logs_gcp_audit/data.json +++ b/x-pack/test/cloud_security_posture_functional/es_archives/logs_gcp_audit/data.json @@ -90,6 +90,11 @@ "logger": "projects/your-project-id/logs/cloudaudit.googleapis.com%2Factivity" }, "related": { + "entity": [ + "10.0.0.1", + "projects/your-project-id/roles/customRole", + "admin@example.com" + ], "ip": [ "10.0.0.1" ], @@ -215,6 +220,11 @@ "logger": "projects/your-project-id/logs/cloudaudit.googleapis.com%2Factivity" }, "related": { + "entity": [ + "10.0.0.1", + "projects/your-project-id/roles/customRole", + "admin2@example.com" + ], "ip": [ "10.0.0.1" ], @@ -340,6 +350,11 @@ "logger": "projects/your-project-id/logs/cloudaudit.googleapis.com%2Factivity" }, "related": { + "entity": [ + "10.0.0.1", + "projects/your-project-id/roles/customRole", + "admin3@example.com" + ], "ip": [ "10.0.0.1" ], @@ -465,6 +480,11 @@ "logger": "projects/your-project-id/logs/cloudaudit.googleapis.com%2Factivity" }, "related": { + "entity": [ + "10.0.0.1", + "projects/your-project-id/roles/customRole", + "admin3@example.com" + ], "ip": [ "10.0.0.1" ], @@ -599,6 +619,11 @@ "logger": "projects/your-project-id/logs/cloudaudit.googleapis.com%2Factivity" }, "related": { + "entity": [ + "10.0.0.1", + "projects/your-project-id/roles/customRole", + "admin4@example.com" + ], "ip": [ "10.0.0.1" ], diff --git a/x-pack/test/cloud_security_posture_functional/page_objects/alerts_page.ts b/x-pack/test/cloud_security_posture_functional/page_objects/alerts_page.ts index f3a9f7b1448a8..6ebd496fca365 100644 --- a/x-pack/test/cloud_security_posture_functional/page_objects/alerts_page.ts +++ b/x-pack/test/cloud_security_posture_functional/page_objects/alerts_page.ts @@ -10,7 +10,7 @@ import { FtrService } from '../../functional/ftr_provider_context'; const ALERT_TABLE_ROW_CSS_SELECTOR = '[data-test-subj="alertsTable"] .euiDataGridRow'; const VISUALIZATIONS_SECTION_HEADER_TEST_ID = 'securitySolutionFlyoutVisualizationsHeader'; -const GRAPH_PREVIEW_TEST_ID = 'securitySolutionFlyoutGraphPreview'; +const GRAPH_PREVIEW_CONTENT_TEST_ID = 'securitySolutionFlyoutGraphPreviewContent'; const GRAPH_PREVIEW_LOADING_TEST_ID = 'securitySolutionFlyoutGraphPreviewLoading'; export class AlertsPageObject extends FtrService { @@ -89,12 +89,12 @@ export class AlertsPageObject extends FtrService { }, assertGraphPreviewVisible: async () => { - return await this.testSubjects.existOrFail(GRAPH_PREVIEW_TEST_ID); + return await this.testSubjects.existOrFail(GRAPH_PREVIEW_CONTENT_TEST_ID); }, assertGraphNodesNumber: async (expected: number) => { await this.flyout.waitGraphIsLoaded(); - const graph = await this.testSubjects.find(GRAPH_PREVIEW_TEST_ID); + const graph = await this.testSubjects.find(GRAPH_PREVIEW_CONTENT_TEST_ID); await graph.scrollIntoView(); const nodes = await graph.findAllByCssSelector('.react-flow__nodes .react-flow__node'); expect(nodes.length).to.be(expected); diff --git a/x-pack/test/cloud_security_posture_functional/page_objects/expanded_flyout.ts b/x-pack/test/cloud_security_posture_functional/page_objects/expanded_flyout.ts new file mode 100644 index 0000000000000..5829e9083a8cf --- /dev/null +++ b/x-pack/test/cloud_security_posture_functional/page_objects/expanded_flyout.ts @@ -0,0 +1,112 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import type { WebElementWrapper } from '@kbn/ftr-common-functional-ui-services'; +import type { FilterBarService } from '@kbn/test-suites-src/functional/services/filter_bar'; +import { FtrService } from '../../functional/ftr_provider_context'; + +const GRAPH_PREVIEW_TITLE_LINK_TEST_ID = 'securitySolutionFlyoutGraphPreviewTitleLink'; +const NODE_EXPAND_BUTTON_TEST_ID = 'nodeExpandButton'; +const GRAPH_INVESTIGATION_TEST_ID = 'cloudSecurityGraphGraphInvestigation'; +const GRAPH_NODE_EXPAND_POPOVER_TEST_ID = `${GRAPH_INVESTIGATION_TEST_ID}GraphNodeExpandPopover`; +const GRAPH_NODE_POPOVER_EXPLORE_RELATED_TEST_ID = `${GRAPH_INVESTIGATION_TEST_ID}ExploreRelatedEntities`; +const GRAPH_NODE_POPOVER_SHOW_ACTIONS_BY_TEST_ID = `${GRAPH_INVESTIGATION_TEST_ID}ShowActionsByEntity`; +const GRAPH_NODE_POPOVER_SHOW_ACTIONS_ON_TEST_ID = `${GRAPH_INVESTIGATION_TEST_ID}ShowActionsOnEntity`; +type Filter = Parameters[0]; + +export class ExpandedFlyout extends FtrService { + private readonly pageObjects = this.ctx.getPageObjects(['common', 'header']); + private readonly testSubjects = this.ctx.getService('testSubjects'); + private readonly filterBar = this.ctx.getService('filterBar'); + + async expandGraph(): Promise { + await this.testSubjects.click(GRAPH_PREVIEW_TITLE_LINK_TEST_ID); + } + + async waitGraphIsLoaded(): Promise { + await this.testSubjects.existOrFail(GRAPH_INVESTIGATION_TEST_ID, { timeout: 10000 }); + } + + async assertGraphNodesNumber(expected: number): Promise { + await this.waitGraphIsLoaded(); + const graph = await this.testSubjects.find(GRAPH_INVESTIGATION_TEST_ID); + await graph.scrollIntoView(); + const nodes = await graph.findAllByCssSelector('.react-flow__nodes .react-flow__node'); + expect(nodes.length).to.be(expected); + } + + async selectNode(nodeId: string): Promise { + await this.waitGraphIsLoaded(); + const graph = await this.testSubjects.find(GRAPH_INVESTIGATION_TEST_ID); + await graph.scrollIntoView(); + const nodes = await graph.findAllByCssSelector( + `.react-flow__nodes .react-flow__node[data-id="${nodeId}"]` + ); + expect(nodes.length).to.be(1); + await nodes[0].moveMouseTo(); + return nodes[0]; + } + + async clickOnNodeExpandButton(nodeId: string): Promise { + const node = await this.selectNode(nodeId); + const expandButton = await node.findByTestSubject(NODE_EXPAND_BUTTON_TEST_ID); + await expandButton.click(); + await this.testSubjects.existOrFail(GRAPH_NODE_EXPAND_POPOVER_TEST_ID); + } + + async showActionsByEntity(nodeId: string): Promise { + await this.clickOnNodeExpandButton(nodeId); + await this.testSubjects.click(GRAPH_NODE_POPOVER_SHOW_ACTIONS_BY_TEST_ID); + await this.pageObjects.header.waitUntilLoadingHasFinished(); + } + + async showActionsOnEntity(nodeId: string): Promise { + await this.clickOnNodeExpandButton(nodeId); + await this.testSubjects.click(GRAPH_NODE_POPOVER_SHOW_ACTIONS_ON_TEST_ID); + await this.pageObjects.header.waitUntilLoadingHasFinished(); + } + + async exploreRelatedEntities(nodeId: string): Promise { + await this.clickOnNodeExpandButton(nodeId); + await this.testSubjects.click(GRAPH_NODE_POPOVER_EXPLORE_RELATED_TEST_ID); + await this.pageObjects.header.waitUntilLoadingHasFinished(); + } + + async expectFilterTextEquals(filterIdx: number, expected: string): Promise { + const filters = await this.filterBar.getFiltersLabel(); + expect(filters.length).to.be.greaterThan(filterIdx); + expect(filters[filterIdx]).to.be(expected); + } + + async expectFilterPreviewEquals(filterIdx: number, expected: string): Promise { + await this.clickEditFilter(filterIdx); + + const filterPreview = await this.filterBar.getFilterEditorPreview(); + expect(filterPreview).to.be(expected); + + await this.filterBar.ensureFieldEditorModalIsClosed(); + } + + async clickEditFilter(filterIdx: number): Promise { + await this.filterBar.clickEditFilterById(filterIdx.toString()); + } + + async clearAllFilters(): Promise { + await this.testSubjects.click(`${GRAPH_INVESTIGATION_TEST_ID} > showQueryBarMenu`); + await this.testSubjects.click('filter-sets-removeAllFilters'); + await this.pageObjects.header.waitUntilLoadingHasFinished(); + } + + async addFilter(filter: Filter): Promise { + await this.testSubjects.click(`${GRAPH_INVESTIGATION_TEST_ID} > addFilter`); + await this.filterBar.createFilter(filter); + await this.testSubjects.scrollIntoView('saveFilter'); + await this.testSubjects.clickWhenNotDisabled('saveFilter'); + await this.pageObjects.header.waitUntilLoadingHasFinished(); + } +} diff --git a/x-pack/test/cloud_security_posture_functional/page_objects/index.ts b/x-pack/test/cloud_security_posture_functional/page_objects/index.ts index b7c20632e82f5..fdc904e31aac0 100644 --- a/x-pack/test/cloud_security_posture_functional/page_objects/index.ts +++ b/x-pack/test/cloud_security_posture_functional/page_objects/index.ts @@ -14,9 +14,13 @@ import { BenchmarkPagePageProvider } from './benchmark_page'; import { CspSecurityCommonProvider } from './security_common'; import { RulePagePageProvider } from './rule_page'; import { AlertsPageObject } from './alerts_page'; +import { NetworkEventsPageObject } from './network_events_page'; +import { ExpandedFlyout } from './expanded_flyout'; export const cloudSecurityPosturePageObjects = { alerts: AlertsPageObject, + networkEvents: NetworkEventsPageObject, + expandedFlyout: ExpandedFlyout, findings: FindingsPageProvider, cloudPostureDashboard: CspDashboardPageProvider, cisAddIntegration: AddCisIntegrationFormPageProvider, diff --git a/x-pack/test/cloud_security_posture_functional/page_objects/network_events_page.ts b/x-pack/test/cloud_security_posture_functional/page_objects/network_events_page.ts new file mode 100644 index 0000000000000..8e03fae7eb7e0 --- /dev/null +++ b/x-pack/test/cloud_security_posture_functional/page_objects/network_events_page.ts @@ -0,0 +1,107 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import { FtrService } from '../../functional/ftr_provider_context'; + +const EVENTS_TABLE_ROW_CSS_SELECTOR = '[data-test-subj="events-viewer-panel"] .euiDataGridRow'; +const VISUALIZATIONS_SECTION_HEADER_TEST_ID = 'securitySolutionFlyoutVisualizationsHeader'; +const GRAPH_PREVIEW_CONTENT_TEST_ID = 'securitySolutionFlyoutGraphPreviewContent'; +const GRAPH_PREVIEW_LOADING_TEST_ID = 'securitySolutionFlyoutGraphPreviewLoading'; + +export class NetworkEventsPageObject extends FtrService { + private readonly retry = this.ctx.getService('retry'); + private readonly pageObjects = this.ctx.getPageObjects(['common', 'header']); + private readonly testSubjects = this.ctx.getService('testSubjects'); + private readonly defaultTimeoutMs = this.ctx.getService('config').get('timeouts.waitFor'); + + async navigateToNetworkEventsPage(urlQueryParams: string = ''): Promise { + await this.pageObjects.common.navigateToUrlWithBrowserHistory( + 'securitySolution', + '/network/events', + `${urlQueryParams && `?${urlQueryParams}`}`, + { + ensureCurrentUrl: false, + } + ); + await this.pageObjects.header.waitUntilLoadingHasFinished(); + } + + getAbsoluteTimerangeFilter(from: string, to: string) { + return `timerange=(global:(linkTo:!(),timerange:(from:%27${from}%27,kind:absolute,to:%27${to}%27)))`; + } + + getFlyoutFilter(eventId: string) { + return `flyout=(preview:!(),right:(id:document-details-right,params:(id:%27${eventId}%27,indexName:logs-gcp.audit-default,scopeId:network-page-events)))`; + } + + /** + * Clicks the refresh button on the network events page and waits for it to complete + */ + async clickRefresh(): Promise { + await this.ensureOnNetworkEventsPage(); + await this.testSubjects.click('querySubmitButton'); + + // wait for refresh to complete + await this.retry.waitFor( + 'Network events pages refresh button to be enabled', + async (): Promise => { + const refreshButton = await this.testSubjects.find('querySubmitButton'); + + return (await refreshButton.isDisplayed()) && (await refreshButton.isEnabled()); + } + ); + } + + async ensureOnNetworkEventsPage(): Promise { + await this.testSubjects.existOrFail('network-details-headline'); + } + + async waitForListToHaveEvents(timeoutMs?: number): Promise { + const allEventRows = await this.testSubjects.findService.allByCssSelector( + EVENTS_TABLE_ROW_CSS_SELECTOR + ); + + if (!Boolean(allEventRows.length)) { + await this.retry.waitForWithTimeout( + 'waiting for events to show up on network events page', + timeoutMs ?? this.defaultTimeoutMs, + async (): Promise => { + await this.clickRefresh(); + + const allEventRowsInner = await this.testSubjects.findService.allByCssSelector( + EVENTS_TABLE_ROW_CSS_SELECTOR + ); + + return Boolean(allEventRowsInner.length); + } + ); + } + } + + flyout = { + expandVisualizations: async (): Promise => { + await this.testSubjects.click(VISUALIZATIONS_SECTION_HEADER_TEST_ID); + }, + + assertGraphPreviewVisible: async () => { + return await this.testSubjects.existOrFail(GRAPH_PREVIEW_CONTENT_TEST_ID); + }, + + assertGraphNodesNumber: async (expected: number) => { + await this.flyout.waitGraphIsLoaded(); + const graph = await this.testSubjects.find(GRAPH_PREVIEW_CONTENT_TEST_ID); + await graph.scrollIntoView(); + const nodes = await graph.findAllByCssSelector('.react-flow__nodes .react-flow__node'); + expect(nodes.length).to.be(expected); + }, + + waitGraphIsLoaded: async () => { + await this.testSubjects.missingOrFail(GRAPH_PREVIEW_LOADING_TEST_ID, { timeout: 10000 }); + }, + }; +} diff --git a/x-pack/test/cloud_security_posture_functional/pages/alerts_flyout.ts b/x-pack/test/cloud_security_posture_functional/pages/alerts_flyout.ts index 63eafc4107bc1..35f9578929ada 100644 --- a/x-pack/test/cloud_security_posture_functional/pages/alerts_flyout.ts +++ b/x-pack/test/cloud_security_posture_functional/pages/alerts_flyout.ts @@ -14,8 +14,9 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { const logger = getService('log'); const supertest = getService('supertest'); const esArchiver = getService('esArchiver'); - const pageObjects = getPageObjects(['common', 'header', 'alerts']); + const pageObjects = getPageObjects(['common', 'header', 'alerts', 'expandedFlyout']); const alertsPage = pageObjects.alerts; + const expandedFlyout = pageObjects.expandedFlyout; describe('Security Alerts Page - Graph visualization', function () { this.tags(['cloud_security_posture_graph_viz']); @@ -54,9 +55,52 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { ); }); - it('should render graph visualization', async () => { + it('expanded flyout - filter by node', async () => { await alertsPage.flyout.assertGraphPreviewVisible(); await alertsPage.flyout.assertGraphNodesNumber(3); + + await expandedFlyout.expandGraph(); + await expandedFlyout.waitGraphIsLoaded(); + await expandedFlyout.assertGraphNodesNumber(3); + + // Show actions by entity + await expandedFlyout.showActionsByEntity('admin@example.com'); + await expandedFlyout.expectFilterTextEquals(0, 'actor.entity.id: admin@example.com'); + await expandedFlyout.expectFilterPreviewEquals(0, 'actor.entity.id: admin@example.com'); + + // Show actions on entity + await expandedFlyout.showActionsOnEntity('admin@example.com'); + await expandedFlyout.expectFilterTextEquals( + 0, + 'actor.entity.id: admin@example.com OR target.entity.id: admin@example.com' + ); + await expandedFlyout.expectFilterPreviewEquals( + 0, + 'actor.entity.id: admin@example.com OR target.entity.id: admin@example.com' + ); + + // Explore related entities + await expandedFlyout.exploreRelatedEntities('admin@example.com'); + await expandedFlyout.expectFilterTextEquals( + 0, + 'actor.entity.id: admin@example.com OR target.entity.id: admin@example.com OR related.entity: admin@example.com' + ); + await expandedFlyout.expectFilterPreviewEquals( + 0, + 'actor.entity.id: admin@example.com OR target.entity.id: admin@example.com OR related.entity: admin@example.com' + ); + + // Clear filters + await expandedFlyout.clearAllFilters(); + + // Add custom filter + await expandedFlyout.addFilter({ + field: 'actor.entity.id', + operation: 'is', + value: 'admin2@example.com', + }); + await pageObjects.header.waitUntilLoadingHasFinished(); + await expandedFlyout.assertGraphNodesNumber(5); }); }); } diff --git a/x-pack/test/cloud_security_posture_functional/pages/events_flyout.ts b/x-pack/test/cloud_security_posture_functional/pages/events_flyout.ts new file mode 100644 index 0000000000000..0848307ca26d2 --- /dev/null +++ b/x-pack/test/cloud_security_posture_functional/pages/events_flyout.ts @@ -0,0 +1,98 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { waitForPluginInitialized } from '../../cloud_security_posture_api/utils'; +import type { FtrProviderContext } from '../ftr_provider_context'; + +// eslint-disable-next-line import/no-default-export +export default function ({ getPageObjects, getService }: FtrProviderContext) { + const retry = getService('retry'); + const logger = getService('log'); + const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); + const pageObjects = getPageObjects(['common', 'header', 'networkEvents', 'expandedFlyout']); + const networkEventsPage = pageObjects.networkEvents; + const expandedFlyout = pageObjects.expandedFlyout; + + describe('Security Network Page - Graph visualization', function () { + this.tags(['cloud_security_posture_graph_viz']); + + before(async () => { + await esArchiver.load( + 'x-pack/test/cloud_security_posture_functional/es_archives/logs_gcp_audit' + ); + + await waitForPluginInitialized({ retry, supertest, logger }); + + // Setting the timerange to fit the data and open the flyout for a specific alert + await networkEventsPage.navigateToNetworkEventsPage( + `${networkEventsPage.getAbsoluteTimerangeFilter( + '2024-09-01T00:00:00.000Z', + '2024-09-02T00:00:00.000Z' + )}&${networkEventsPage.getFlyoutFilter('1')}` + ); + + await networkEventsPage.waitForListToHaveEvents(); + + await networkEventsPage.flyout.expandVisualizations(); + }); + + after(async () => { + await esArchiver.unload( + 'x-pack/test/cloud_security_posture_functional/es_archives/logs_gcp_audit' + ); + }); + + it('expanded flyout - filter by node', async () => { + await networkEventsPage.flyout.assertGraphPreviewVisible(); + await networkEventsPage.flyout.assertGraphNodesNumber(3); + + await expandedFlyout.expandGraph(); + await expandedFlyout.waitGraphIsLoaded(); + await expandedFlyout.assertGraphNodesNumber(3); + + // Show actions by entity + await expandedFlyout.showActionsByEntity('admin@example.com'); + await expandedFlyout.expectFilterTextEquals(0, 'actor.entity.id: admin@example.com'); + await expandedFlyout.expectFilterPreviewEquals(0, 'actor.entity.id: admin@example.com'); + + // Show actions on entity + await expandedFlyout.showActionsOnEntity('admin@example.com'); + await expandedFlyout.expectFilterTextEquals( + 0, + 'actor.entity.id: admin@example.com OR target.entity.id: admin@example.com' + ); + await expandedFlyout.expectFilterPreviewEquals( + 0, + 'actor.entity.id: admin@example.com OR target.entity.id: admin@example.com' + ); + + // Explore related entities + await expandedFlyout.exploreRelatedEntities('admin@example.com'); + await expandedFlyout.expectFilterTextEquals( + 0, + 'actor.entity.id: admin@example.com OR target.entity.id: admin@example.com OR related.entity: admin@example.com' + ); + await expandedFlyout.expectFilterPreviewEquals( + 0, + 'actor.entity.id: admin@example.com OR target.entity.id: admin@example.com OR related.entity: admin@example.com' + ); + + // Clear filters + await expandedFlyout.clearAllFilters(); + + // Add custom filter + await expandedFlyout.addFilter({ + field: 'actor.entity.id', + operation: 'is', + value: 'admin2@example.com', + }); + await pageObjects.header.waitUntilLoadingHasFinished(); + await expandedFlyout.assertGraphNodesNumber(5); + }); + }); +} diff --git a/x-pack/test/cloud_security_posture_functional/pages/index.ts b/x-pack/test/cloud_security_posture_functional/pages/index.ts index 0114b6a8ce4dc..67c06d979002f 100644 --- a/x-pack/test/cloud_security_posture_functional/pages/index.ts +++ b/x-pack/test/cloud_security_posture_functional/pages/index.ts @@ -37,5 +37,6 @@ export default function ({ getPageObjects, loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./vulnerabilities_grouping')); loadTestFile(require.resolve('./benchmark')); loadTestFile(require.resolve('./alerts_flyout')); + loadTestFile(require.resolve('./events_flyout')); }); }