[Cloud Security] Show graph visualization in expanded flyout (#198240)

## Summary

Added graph tab to the flyout visualization of alerts and events.

**A couple of included changes:**
- Added technical preview badge
- ~Feature is now toggled using
`securitySolution:enableVisualizationsInFlyout` advanced setting~
reverted back to use the experimental feature flag
- Added node popover to expand the graph
- Expanding a graph adds relevant filters
- Added e2e tests for both alerts flyout and events flyout (through
network page)

**List of known issues:**
- The graph API works queries `logs-*` while the filters bar works with
sourcerer current dataview Id
- I'm not sure how to write a UT for GraphVisualization / Popover which
uses ReactPortal that makes it tricky to test (I covered most scenarios
using E2E test)
- Expanding graph more than once adds another filter


**How to test this PR:**

- Enable the feature flag 

`kibana.dev.yml`:

```yaml
uiSettings.overrides.securitySolution:enableVisualizationsInFlyout: true
xpack.securitySolution.enableExperimental: ['graphVisualizationInFlyoutEnabled']
```

- Load mocked data:

```bash
node scripts/es_archiver load x-pack/test/cloud_security_posture_functional/es_archives/logs_gcp_audit \ 
  --es-url http://elastic:changeme@localhost:9200 \
  --kibana-url http://elastic:changeme@localhost:5601

node scripts/es_archiver load x-pack/test/cloud_security_posture_functional/es_archives/security_alerts \
  --es-url http://elastic:changeme@localhost:9200 \
  --kibana-url http://elastic:changeme@localhost:5601
```

- Make sure you include data from Oct 13 2024. (in the video I use Last
90 days)


https://github.com/user-attachments/assets/12e19ac7-0f61-4c0a-ac11-e304dfcc83d4



### Checklist

- [x] Any text added follows [EUI's writing
guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses
sentence case text and includes [i18n
support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md)
- [ ]
[Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html)
was added for features that require explanation or tutorials
- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
- [x] [Flaky Test
Runner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1) was
used on any tests changed
- [ ] Any UI touched in this PR is usable by keyboard only (learn more
about [keyboard accessibility](https://webaim.org/techniques/keyboard/))
- [ ] Any UI touched in this PR does not create any new axe failures
(run axe in browser:
[FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/),
[Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US))
- [ ] This renders correctly on smaller devices using a responsive
layout. (You can test this [in your
browser](https://www.browserstack.com/guide/responsive-testing-on-local-server))
- [ ] This was checked for [cross-browser
compatibility](https://www.elastic.co/support/matrix#matrix_browsers)

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Kfir Peled 2024-12-12 22:14:44 +00:00 committed by GitHub
parent b1363d925e
commit 749eeec4cc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
48 changed files with 1850 additions and 156 deletions

View file

@ -6,3 +6,4 @@
*/
export * from './src/components';
export { useFetchGraphData } from './src/hooks';

View file

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

View file

@ -174,7 +174,7 @@ export const Graph: React.FC<GraphProps> = ({
minZoom={0.1}
>
{interactive && <Controls onInteractiveChange={onInteractiveStateChange} />}
<Background id={backgroundId} />{' '}
<Background id={backgroundId} />
</ReactFlow>
</div>
);

View file

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

View file

@ -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<React.SetStateAction<Filter[]>>
) => {
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<GraphInvestigationProps> = memo(
({ dataView, eventIds, timestamp = new Date().toISOString() }: GraphInvestigationProps) => {
const [searchFilters, setSearchFilters] = useState<Filter[]>(() => []);
const [timeRange, setTimeRange] = useState<TimeRange>({
from: `${timestamp}||-30m`,
to: `${timestamp}||+30m`,
});
const {
services: { uiSettings },
} = useKibana();
const query = useMemo(
() =>
buildEsQuery(
dataView,
[],
[...searchFilters],
getEsQueryConfig(uiSettings as Parameters<typeof getEsQueryConfig>[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 (
<>
<EuiFlexGroup
data-test-subj={GRAPH_INVESTIGATION_TEST_ID}
direction="column"
gutterSize="none"
css={css`
height: 100%;
`}
>
{dataView && (
<EuiFlexItem grow={false}>
<SearchBar<Query>
{...{
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();
}
},
}}
/>
</EuiFlexItem>
)}
<EuiFlexItem>
<Graph
css={css`
height: 100%;
width: 100%;
`}
nodes={nodes}
edges={data?.edges ?? []}
interactive={true}
isLocked={isPopoverOpen}
/>
</EuiFlexItem>
</EuiFlexGroup>
<nodeExpandPopover.PopoverComponent />
</>
);
}
);
GraphInvestigation.displayName = 'GraphInvestigation';

View file

@ -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<GraphNodeExpandPopoverProps> = memo(
({
isOpen,
anchorElement,
closePopover,
onShowRelatedEntitiesClick,
onShowActionsByEntityClick,
onShowActionsOnEntityClick,
}) => {
return (
<GraphPopover
panelPaddingSize="s"
anchorPosition="rightCenter"
isOpen={isOpen}
anchorElement={anchorElement}
closePopover={closePopover}
data-test-subj={GRAPH_NODE_EXPAND_POPOVER_TEST_ID}
>
<EuiListGroup gutterSize="none" bordered={false} flush={true}>
<ExpandPopoverListItem
iconType="users"
label={i18n.translate('xpack.csp.graph.graphNodeExpandPopover.showActionsByEntity', {
defaultMessage: 'Show actions by this entity',
})}
onClick={onShowActionsByEntityClick}
data-test-subj={GRAPH_NODE_POPOVER_SHOW_ACTIONS_BY_ITEM_ID}
/>
<ExpandPopoverListItem
iconType="storage"
label={i18n.translate('xpack.csp.graph.graphNodeExpandPopover.showActionsOnEntity', {
defaultMessage: 'Show actions on this entity',
})}
onClick={onShowActionsOnEntityClick}
data-test-subj={GRAPH_NODE_POPOVER_SHOW_ACTIONS_ON_ITEM_ID}
/>
<ExpandPopoverListItem
iconType="visTagCloud"
label={i18n.translate('xpack.csp.graph.graphNodeExpandPopover.showRelatedEvents', {
defaultMessage: 'Show related events',
})}
onClick={onShowRelatedEntitiesClick}
data-test-subj={GRAPH_NODE_POPOVER_SHOW_RELATED_ITEM_ID}
/>
</EuiListGroup>
</GraphPopover>
);
}
);
GraphNodeExpandPopover.displayName = 'GraphNodeExpandPopover';

View file

@ -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<NodeProps | null>(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(() => (
<GraphNodeExpandPopover
isOpen={state.isOpen}
anchorElement={state.anchorElement}
closePopover={closePopoverHandler}
onShowRelatedEntitiesClick={() => {
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,
};
};

View file

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

View file

@ -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"
/>
</StyledNodeExpandButton>
);

View file

@ -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<RoundedEuiIconProps> = ({ color, background, ...r
);
export const ExpandPopoverListItem: React.FC<
Pick<EuiListGroupItemProps, 'iconType' | 'label' | 'onClick'>
CommonProps & Pick<EuiListGroupItemProps, 'iconType' | 'label' | 'onClick'>
> = (props) => {
const { iconType, label, onClick, ...rest } = props;
const { euiTheme } = useEuiTheme();
return (
<EuiListGroupItem
{...rest}
icon={
props.iconType ? (
<RoundedEuiIcon color="primary" background="primary" type={props.iconType} size="s" />
iconType ? (
<RoundedEuiIcon color="primary" background="primary" type={iconType} size="s" />
) : undefined
}
label={
<EuiText size="s" color={euiTheme.colors.primaryText}>
{props.label}
{label}
</EuiText>
}
onClick={props.onClick}
onClick={onClick}
/>
);
};

View file

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

View file

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

View file

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

View file

@ -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<GraphResponse>(
const { isLoading, isError, data, isFetching } = useQuery<GraphResponse>(
QUERY_KEY,
() => {
if (!http) {
return Promise.reject(new Error('Http service is not available'));
}
return http.post<GraphResponse>(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);
},
};
};

View file

@ -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",
]
}

View file

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

View file

@ -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<CoreRequestHandlerContext>;
}>;
/**

View file

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

View file

@ -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 (
<div
data-test-subj={GRAPH_VISUALIZATION_TEST_ID}
css={css`
height: calc(100vh - 250px);
min-height: 400px;
width: 100%;
`}
>
{dataView && (
<React.Suspense fallback={<EuiLoadingSpinner />}>
<GraphInvestigationLazy dataView={dataView} eventIds={eventIds} timestamp={timestamp} />
</React.Suspense>
)}
</div>
);
});
GraphVisualization.displayName = 'GraphVisualization';

View file

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

View file

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

View file

@ -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: (
<FormattedMessage
id="xpack.securitySolution.flyout.left.visualize.graphVisualizationButton.technicalPreviewLabel"
defaultMessage="Technical Preview"
/>
),
},
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: (
<FormattedMessage
id="xpack.securitySolution.flyout.left.visualize.graphVisualizationButtonLabel"
defaultMessage="Graph view"
/>
),
'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 (
<>
<EuiButtonGroup
@ -97,7 +146,7 @@ export const VisualizeTab = memo(() => {
defaultMessage: 'Visualize options',
}
)}
options={visualizeButtons}
options={options}
idSelected={activeVisualizationId}
onChange={(id) => onChangeCompressed(id)}
buttonSize="compressed"
@ -107,6 +156,7 @@ export const VisualizeTab = memo(() => {
<EuiSpacer size="m" />
{activeVisualizationId === SESSION_VIEW_ID && <SessionView />}
{activeVisualizationId === ANALYZE_GRAPH_ID && <AnalyzeGraph />}
{activeVisualizationId === GRAPH_ID && <GraphVisualization />}
</>
);
});

View file

@ -36,10 +36,19 @@ describe('<GraphPreview />', () => {
});
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('<GraphPreview />', () => {
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();
});
});

View file

@ -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<GraphPreviewProps> = memo(
return isLoading ? (
<LoadingComponent />
) : isError ? (
) : isError || memoizedNodes.length === 0 ? (
<FormattedMessage
id="xpack.securitySolution.flyout.right.visualizations.graphPreview.errorDescription"
defaultMessage="An error is preventing this alert from being visualized."
/>
) : (
<React.Suspense fallback={<LoadingComponent />}>
<React.Suspense
fallback={
<EuiPanel>
<LoadingComponent />
</EuiPanel>
}
>
<GraphLazy
css={css`
height: 300px;

View file

@ -6,14 +6,14 @@
*/
import { render } from '@testing-library/react';
import { useFetchGraphData } from '@kbn/cloud-security-posture-graph/src/hooks';
import { TestProviders } from '../../../../common/mock';
import React from 'react';
import { DocumentDetailsContext } from '../../shared/context';
import { mockContextValue } from '../../shared/mocks/mock_context';
import { GraphPreviewContainer } from './graph_preview_container';
import { GRAPH_PREVIEW_TEST_ID } from './test_ids';
import { useGraphPreview } from '../hooks/use_graph_preview';
import { useFetchGraphData } from '../hooks/use_fetch_graph_data';
import { useGraphPreview } from '../../shared/hooks/use_graph_preview';
import {
EXPANDABLE_PANEL_CONTENT_TEST_ID,
EXPANDABLE_PANEL_HEADER_TITLE_ICON_TEST_ID,
@ -21,9 +21,25 @@ import {
EXPANDABLE_PANEL_HEADER_TITLE_TEXT_TEST_ID,
EXPANDABLE_PANEL_TOGGLE_ICON_TEST_ID,
} from '../../../shared/components/test_ids';
import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features';
jest.mock('../hooks/use_graph_preview');
jest.mock('../hooks/use_fetch_graph_data', () => ({
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) =>
</TestProviders>
);
const DEFAULT_NODES = [
{
id: '1',
color: 'primary',
shape: 'ellipse',
},
];
describe('<GraphPreviewContainer />', () => {
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,7 +85,164 @@ describe('<GraphPreviewContainer />', () => {
(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(
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 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();
@ -98,21 +280,55 @@ describe('<GraphPreviewContainer />', () => {
});
});
it('should not render when graph data is not available', () => {
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,
},
});
});
});

View file

@ -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$<boolean>(
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 && (
<ExpandablePanel
header={{
title: (
<FormattedMessage
id="xpack.securitySolution.flyout.right.visualizations.graphPreview.graphPreviewTitle"
defaultMessage="Graph preview"
/>
),
iconType: 'indexMapping',
}}
data-test-subj={GRAPH_PREVIEW_TEST_ID}
content={
!isLoading && !isError
? {
paddingSize: 'none',
<ExpandablePanel
header={{
title: (
<FormattedMessage
id="xpack.securitySolution.flyout.right.visualizations.graphPreview.graphPreviewTitle"
defaultMessage="Graph preview"
/>
),
headerContent: (
<EuiBetaBadge
alignment="middle"
iconType="beaker"
data-test-subj="graphPreviewBetaBadge"
label={i18n.translate(
'xpack.securitySolution.flyout.right.visualizations.graphPreview.technicalPreviewLabel',
{
defaultMessage: 'Technical Preview',
}
: undefined
}
>
<GraphPreview isLoading={isLoading} isError={isError} data={data} />
</ExpandablePanel>
)
)}
tooltipContent={i18n.translate(
'xpack.securitySolution.flyout.right.visualizations.graphPreview.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.',
}
)}
/>
),
iconType: allowFlyoutExpansion ? 'arrowStart' : 'indexMapping',
...(allowFlyoutExpansion && {
link: {
callback: navigateToGraphVisualization,
tooltip: (
<FormattedMessage
id="xpack.securitySolution.flyout.right.visualizations.graphPreview.graphPreviewOpenGraphTooltip"
defaultMessage="Expand graph"
/>
),
},
}),
}}
data-test-subj={GRAPH_PREVIEW_TEST_ID}
content={
!isLoading && !isError
? {
paddingSize: 'none',
}
: undefined
}
>
<GraphPreview isLoading={isLoading} isError={isError} data={data} />
</ExpandablePanel>
);
};

View file

@ -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('<VisualizationsSection />', () => {
beforeEach(() => {
mockUseUiSetting.mockReturnValue([false]);
mockUseTimelineDataFilters.mockReturnValue({ selectedPatterns: ['index'] });
mockUseAlertPrevalenceFromProcessTree.mockReturnValue({
loading: false,
@ -103,7 +105,7 @@ describe('<VisualizationsSection />', () => {
statsNodes: undefined,
});
mockUseGraphPreview.mockReturnValue({
isAuditLog: true,
hasGraphRepresentation: true,
});
mockUseFetchGraphData.mockReturnValue({
isLoading: false,
@ -136,6 +138,7 @@ describe('<VisualizationsSection />', () => {
});
(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('<VisualizationsSection />', () => {
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();
});
});

View file

@ -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$<boolean>(
ENABLE_VISUALIZATIONS_IN_FLYOUT_SETTING
);
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 (
<ExpandableSection
expanded={expanded}
@ -52,7 +62,7 @@ export const VisualizationsSection = memo(() => {
<SessionPreviewContainer />
<EuiSpacer />
<AnalyzerPreviewContainer />
{graphVisualizationInFlyoutEnabled && isGraphPreviewEnabled && (
{shouldShowGraphPreview && (
<>
<EuiSpacer />
<GraphPreviewContainer />

View file

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

View file

@ -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<UseGraphPreviewResult, UseGraphPreviewParams>;
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']);
});
});

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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',
])}`,

View file

@ -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"
],

View file

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

View file

@ -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<FilterBarService['addFilter']>[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<void> {
await this.testSubjects.click(GRAPH_PREVIEW_TITLE_LINK_TEST_ID);
}
async waitGraphIsLoaded(): Promise<void> {
await this.testSubjects.existOrFail(GRAPH_INVESTIGATION_TEST_ID, { timeout: 10000 });
}
async assertGraphNodesNumber(expected: number): Promise<void> {
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<WebElementWrapper> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
await this.clickEditFilter(filterIdx);
const filterPreview = await this.filterBar.getFilterEditorPreview();
expect(filterPreview).to.be(expected);
await this.filterBar.ensureFieldEditorModalIsClosed();
}
async clickEditFilter(filterIdx: number): Promise<void> {
await this.filterBar.clickEditFilterById(filterIdx.toString());
}
async clearAllFilters(): Promise<void> {
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<void> {
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();
}
}

View file

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

View file

@ -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<void> {
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<void> {
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<boolean> => {
const refreshButton = await this.testSubjects.find('querySubmitButton');
return (await refreshButton.isDisplayed()) && (await refreshButton.isEnabled());
}
);
}
async ensureOnNetworkEventsPage(): Promise<void> {
await this.testSubjects.existOrFail('network-details-headline');
}
async waitForListToHaveEvents(timeoutMs?: number): Promise<void> {
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<boolean> => {
await this.clickRefresh();
const allEventRowsInner = await this.testSubjects.findService.allByCssSelector(
EVENTS_TABLE_ROW_CSS_SELECTOR
);
return Boolean(allEventRowsInner.length);
}
);
}
}
flyout = {
expandVisualizations: async (): Promise<void> => {
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 });
},
};
}

View file

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

View file

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

View file

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