Skip to content

Commit

Permalink
[Cloud Security] Show graph visualization in expanded flyout (elastic…
Browse files Browse the repository at this point in the history
…#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 <[email protected]>
  • Loading branch information
kfirpeled and kibanamachine authored Dec 12, 2024
1 parent b1363d9 commit 749eeec
Show file tree
Hide file tree
Showing 48 changed files with 1,849 additions and 155 deletions.
1 change: 1 addition & 0 deletions x-pack/packages/kbn-cloud-security-posture/graph/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@
*/

export * from './src/components';
export { useFetchGraphData } from './src/hooks';
Original file line number Diff line number Diff line change
@@ -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;
Original file line number Diff line number Diff line change
Expand Up @@ -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>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
};
Original file line number Diff line number Diff line change
@@ -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';
Original file line number Diff line number Diff line change
@@ -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';
Loading

0 comments on commit 749eeec

Please sign in to comment.