mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 09:48:58 -04:00
[Cloud Security] Added popover support for graph component (#199053)
## Summary Added popover support to the graph component. In order to scale the rendering component of nodes, we prefer not to add popover per node but to manage a single popover for each use-case. In the popover stories you can see an example of two different popovers being triggered by different buttons on the node. <details> <summary>Popover support 📹 </summary> https://github.com/user-attachments/assets/cb5bc2ce-037a-4f9b-b71a-f95a9362dde0 </details> <details> <summary>Dark mode support 📹 </summary> https://github.com/user-attachments/assets/a55f2a88-ed07-40e2-9404-30a2042bf4fc </details> ### How to test To test this PR you can run ``` yarn storybook cloud_security_posture_packages ``` And to test the alerts flyout (for regression test): Toggle feature flag in kibana.dev.yml ```yaml 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 ``` 1. Go to the alerts page 2. Change the query time range to show alerts from the 13th of October 2024 (**IMPORTANT**) 3. Open the alerts flyout 5. Scroll to see the graph visualization : D ### Related PRs - https://github.com/elastic/kibana/pull/196034 - https://github.com/elastic/kibana/pull/195307 ### Checklist - [ ] [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 - [ ] 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)
This commit is contained in:
parent
07c218d020
commit
f3de593049
18 changed files with 1419 additions and 152 deletions
|
@ -128,7 +128,7 @@ export const SvgDefsMarker = () => {
|
|||
const { euiTheme } = useEuiTheme();
|
||||
|
||||
return (
|
||||
<svg style={{ position: 'absolute', top: 0, left: 0 }}>
|
||||
<svg style={{ position: 'absolute', width: 0, height: 0 }}>
|
||||
<defs>
|
||||
<Marker id="primary" color={euiTheme.colors.primary} />
|
||||
<Marker id="danger" color={euiTheme.colors.danger} />
|
||||
|
|
|
@ -5,7 +5,8 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import React, { useMemo, useRef, useState, useCallback } from 'react';
|
||||
import React, { useState, useCallback, useEffect, useRef } from 'react';
|
||||
import { size, isEmpty, isEqual, xorWith } from 'lodash';
|
||||
import {
|
||||
Background,
|
||||
Controls,
|
||||
|
@ -14,7 +15,8 @@ import {
|
|||
useEdgesState,
|
||||
useNodesState,
|
||||
} from '@xyflow/react';
|
||||
import type { Edge, Node } from '@xyflow/react';
|
||||
import type { Edge, FitViewOptions, Node, ReactFlowInstance } from '@xyflow/react';
|
||||
import { useGeneratedHtmlId } from '@elastic/eui';
|
||||
import type { CommonProps } from '@elastic/eui';
|
||||
import { SvgDefsMarker } from '../edge/styles';
|
||||
import {
|
||||
|
@ -33,9 +35,23 @@ import type { EdgeViewModel, NodeViewModel } from '../types';
|
|||
import '@xyflow/react/dist/style.css';
|
||||
|
||||
export interface GraphProps extends CommonProps {
|
||||
/**
|
||||
* Array of node view models to be rendered in the graph.
|
||||
*/
|
||||
nodes: NodeViewModel[];
|
||||
/**
|
||||
* Array of edge view models to be rendered in the graph.
|
||||
*/
|
||||
edges: EdgeViewModel[];
|
||||
/**
|
||||
* Determines whether the graph is interactive (allows panning, zooming, etc.).
|
||||
* When set to false, the graph is locked and user interactions are disabled, effectively putting it in view-only mode.
|
||||
*/
|
||||
interactive: boolean;
|
||||
/**
|
||||
* Determines whether the graph is locked. Nodes and edges are still interactive, but the graph itself is not.
|
||||
*/
|
||||
isLocked?: boolean;
|
||||
}
|
||||
|
||||
const nodeTypes = {
|
||||
|
@ -66,28 +82,47 @@ const edgeTypes = {
|
|||
*
|
||||
* @returns {JSX.Element} The rendered Graph component.
|
||||
*/
|
||||
export const Graph: React.FC<GraphProps> = ({ nodes, edges, interactive, ...rest }) => {
|
||||
const layoutCalled = useRef(false);
|
||||
const [isGraphLocked, setIsGraphLocked] = useState(interactive);
|
||||
const { initialNodes, initialEdges } = useMemo(
|
||||
() => processGraph(nodes, edges, isGraphLocked),
|
||||
[nodes, edges, isGraphLocked]
|
||||
);
|
||||
export const Graph: React.FC<GraphProps> = ({
|
||||
nodes,
|
||||
edges,
|
||||
interactive,
|
||||
isLocked = false,
|
||||
...rest
|
||||
}) => {
|
||||
const backgroundId = useGeneratedHtmlId();
|
||||
const fitViewRef = useRef<
|
||||
((fitViewOptions?: FitViewOptions<Node> | undefined) => Promise<boolean>) | null
|
||||
>(null);
|
||||
const currNodesRef = useRef<NodeViewModel[]>([]);
|
||||
const currEdgesRef = useRef<EdgeViewModel[]>([]);
|
||||
const [isGraphInteractive, setIsGraphInteractive] = useState(interactive);
|
||||
const [nodesState, setNodes, onNodesChange] = useNodesState<Node<NodeViewModel>>([]);
|
||||
const [edgesState, setEdges, onEdgesChange] = useEdgesState<Edge<EdgeViewModel>>([]);
|
||||
|
||||
const [nodesState, setNodes, onNodesChange] = useNodesState(initialNodes);
|
||||
const [edgesState, _setEdges, onEdgesChange] = useEdgesState(initialEdges);
|
||||
useEffect(() => {
|
||||
// On nodes or edges changes reset the graph and re-layout
|
||||
if (
|
||||
!isArrayOfObjectsEqual(nodes, currNodesRef.current) ||
|
||||
!isArrayOfObjectsEqual(edges, currEdgesRef.current)
|
||||
) {
|
||||
const { initialNodes, initialEdges } = processGraph(nodes, edges, isGraphInteractive);
|
||||
const { nodes: layoutedNodes } = layoutGraph(initialNodes, initialEdges);
|
||||
|
||||
if (!layoutCalled.current) {
|
||||
const { nodes: layoutedNodes } = layoutGraph(nodesState, edgesState);
|
||||
setNodes(layoutedNodes);
|
||||
layoutCalled.current = true;
|
||||
}
|
||||
setNodes(layoutedNodes);
|
||||
setEdges(initialEdges);
|
||||
currNodesRef.current = nodes;
|
||||
currEdgesRef.current = edges;
|
||||
setTimeout(() => {
|
||||
fitViewRef.current?.();
|
||||
}, 30);
|
||||
}
|
||||
}, [nodes, edges, setNodes, setEdges, isGraphInteractive]);
|
||||
|
||||
const onInteractiveStateChange = useCallback(
|
||||
(interactiveStatus: boolean): void => {
|
||||
setIsGraphLocked(interactiveStatus);
|
||||
setNodes((prevNodes) =>
|
||||
prevNodes.map((node) => ({
|
||||
setIsGraphInteractive(interactiveStatus);
|
||||
setNodes((currNodes) =>
|
||||
currNodes.map((node) => ({
|
||||
...node,
|
||||
data: {
|
||||
...node.data,
|
||||
|
@ -99,23 +134,29 @@ export const Graph: React.FC<GraphProps> = ({ nodes, edges, interactive, ...rest
|
|||
[setNodes]
|
||||
);
|
||||
|
||||
const onInitCallback = useCallback(
|
||||
(xyflow: ReactFlowInstance<Node<NodeViewModel>, Edge<EdgeViewModel>>) => {
|
||||
window.requestAnimationFrame(() => xyflow.fitView());
|
||||
fitViewRef.current = xyflow.fitView;
|
||||
|
||||
// When the graph is not initialized as interactive, we need to fit the view on resize
|
||||
if (!interactive) {
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
xyflow.fitView();
|
||||
});
|
||||
resizeObserver.observe(document.querySelector('.react-flow') as Element);
|
||||
return () => resizeObserver.disconnect();
|
||||
}
|
||||
},
|
||||
[interactive]
|
||||
);
|
||||
|
||||
return (
|
||||
<div {...rest}>
|
||||
<SvgDefsMarker />
|
||||
<ReactFlow
|
||||
fitView={true}
|
||||
onInit={(xyflow) => {
|
||||
window.requestAnimationFrame(() => xyflow.fitView());
|
||||
|
||||
// When the graph is not initialized as interactive, we need to fit the view on resize
|
||||
if (!interactive) {
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
xyflow.fitView();
|
||||
});
|
||||
resizeObserver.observe(document.querySelector('.react-flow') as Element);
|
||||
return () => resizeObserver.disconnect();
|
||||
}
|
||||
}}
|
||||
onInit={onInitCallback}
|
||||
nodeTypes={nodeTypes}
|
||||
edgeTypes={edgeTypes}
|
||||
nodes={nodesState}
|
||||
|
@ -123,16 +164,17 @@ export const Graph: React.FC<GraphProps> = ({ nodes, edges, interactive, ...rest
|
|||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
proOptions={{ hideAttribution: true }}
|
||||
panOnDrag={isGraphLocked}
|
||||
zoomOnScroll={isGraphLocked}
|
||||
zoomOnPinch={isGraphLocked}
|
||||
zoomOnDoubleClick={isGraphLocked}
|
||||
preventScrolling={isGraphLocked}
|
||||
nodesDraggable={interactive && isGraphLocked}
|
||||
panOnDrag={isGraphInteractive && !isLocked}
|
||||
zoomOnScroll={isGraphInteractive && !isLocked}
|
||||
zoomOnPinch={isGraphInteractive && !isLocked}
|
||||
zoomOnDoubleClick={isGraphInteractive && !isLocked}
|
||||
preventScrolling={interactive}
|
||||
nodesDraggable={interactive && isGraphInteractive && !isLocked}
|
||||
maxZoom={1.3}
|
||||
minZoom={0.1}
|
||||
>
|
||||
{interactive && <Controls onInteractiveChange={onInteractiveStateChange} />}
|
||||
<Background />
|
||||
<Background id={backgroundId} />{' '}
|
||||
</ReactFlow>
|
||||
</div>
|
||||
);
|
||||
|
@ -173,32 +215,41 @@ const processGraph = (
|
|||
return node;
|
||||
});
|
||||
|
||||
const initialEdges: Array<Edge<EdgeViewModel>> = edgesModel.map((edgeData) => {
|
||||
const isIn =
|
||||
nodesById[edgeData.source].shape !== 'label' && nodesById[edgeData.target].shape === 'group';
|
||||
const isInside =
|
||||
nodesById[edgeData.source].shape === 'group' && nodesById[edgeData.target].shape === 'label';
|
||||
const isOut =
|
||||
nodesById[edgeData.source].shape === 'label' && nodesById[edgeData.target].shape === 'group';
|
||||
const isOutside =
|
||||
nodesById[edgeData.source].shape === 'group' && nodesById[edgeData.target].shape !== 'label';
|
||||
const initialEdges: Array<Edge<EdgeViewModel>> = edgesModel
|
||||
.filter((edgeData) => nodesById[edgeData.source] && nodesById[edgeData.target])
|
||||
.map((edgeData) => {
|
||||
const isIn =
|
||||
nodesById[edgeData.source].shape !== 'label' &&
|
||||
nodesById[edgeData.target].shape === 'group';
|
||||
const isInside =
|
||||
nodesById[edgeData.source].shape === 'group' &&
|
||||
nodesById[edgeData.target].shape === 'label';
|
||||
const isOut =
|
||||
nodesById[edgeData.source].shape === 'label' &&
|
||||
nodesById[edgeData.target].shape === 'group';
|
||||
const isOutside =
|
||||
nodesById[edgeData.source].shape === 'group' &&
|
||||
nodesById[edgeData.target].shape !== 'label';
|
||||
|
||||
return {
|
||||
id: edgeData.id,
|
||||
type: 'default',
|
||||
source: edgeData.source,
|
||||
sourceHandle: isInside ? 'inside' : isOutside ? 'outside' : undefined,
|
||||
target: edgeData.target,
|
||||
targetHandle: isIn ? 'in' : isOut ? 'out' : undefined,
|
||||
focusable: false,
|
||||
selectable: false,
|
||||
data: {
|
||||
...edgeData,
|
||||
sourceShape: nodesById[edgeData.source].shape,
|
||||
targetShape: nodesById[edgeData.target].shape,
|
||||
},
|
||||
};
|
||||
});
|
||||
return {
|
||||
id: edgeData.id,
|
||||
type: 'default',
|
||||
source: edgeData.source,
|
||||
sourceHandle: isInside ? 'inside' : isOutside ? 'outside' : undefined,
|
||||
target: edgeData.target,
|
||||
targetHandle: isIn ? 'in' : isOut ? 'out' : undefined,
|
||||
focusable: false,
|
||||
selectable: false,
|
||||
data: {
|
||||
...edgeData,
|
||||
sourceShape: nodesById[edgeData.source].shape,
|
||||
targetShape: nodesById[edgeData.target].shape,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
return { initialNodes, initialEdges };
|
||||
};
|
||||
|
||||
const isArrayOfObjectsEqual = (x: object[], y: object[]) =>
|
||||
size(x) === size(y) && isEmpty(xorWith(x, y, isEqual));
|
||||
|
|
|
@ -0,0 +1,209 @@
|
|||
/*
|
||||
* 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, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { ThemeProvider } from '@emotion/react';
|
||||
import { Story } from '@storybook/react';
|
||||
import { css } from '@emotion/react';
|
||||
import { EuiListGroup, EuiHorizontalRule } from '@elastic/eui';
|
||||
import type { EntityNodeViewModel, NodeProps } from '..';
|
||||
import { Graph } from '..';
|
||||
import { GraphPopover } from './graph_popover';
|
||||
import { ExpandButtonClickCallback } from '../types';
|
||||
import { useGraphPopover } from './use_graph_popover';
|
||||
import { ExpandPopoverListItem } from '../styles';
|
||||
|
||||
export default {
|
||||
title: 'Components/Graph Components/Graph Popovers',
|
||||
description: 'CDR - Graph visualization',
|
||||
argTypes: {},
|
||||
};
|
||||
|
||||
const useExpandButtonPopover = () => {
|
||||
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);
|
||||
|
||||
const onNodeExpandButtonClick: ExpandButtonClickCallback = useCallback(
|
||||
(e, node, unToggleCallback) => {
|
||||
if (selectedNode.current?.id === node.id) {
|
||||
// If the same node is clicked again, close the popover
|
||||
selectedNode.current = null;
|
||||
unToggleCallbackRef.current?.();
|
||||
unToggleCallbackRef.current = null;
|
||||
closePopover();
|
||||
} else {
|
||||
// Close the current popover if open
|
||||
selectedNode.current = null;
|
||||
unToggleCallbackRef.current?.();
|
||||
unToggleCallbackRef.current = null;
|
||||
|
||||
// Set the pending open state
|
||||
setPendingOpen({ node, el: e.currentTarget, unToggleCallback });
|
||||
|
||||
closePopover();
|
||||
}
|
||||
},
|
||||
[closePopover]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!state.isOpen && pendingOpen) {
|
||||
const { node, el, unToggleCallback } = pendingOpen;
|
||||
|
||||
selectedNode.current = node;
|
||||
unToggleCallbackRef.current = unToggleCallback;
|
||||
openPopover(el);
|
||||
|
||||
setPendingOpen(null);
|
||||
}
|
||||
}, [state.isOpen, pendingOpen, openPopover]);
|
||||
|
||||
const closePopoverHandler = useCallback(() => {
|
||||
selectedNode.current = null;
|
||||
unToggleCallbackRef.current?.();
|
||||
unToggleCallbackRef.current = null;
|
||||
closePopover();
|
||||
}, [closePopover]);
|
||||
|
||||
const PopoverComponent = memo(() => (
|
||||
<GraphPopover
|
||||
panelPaddingSize="s"
|
||||
anchorPosition="rightCenter"
|
||||
isOpen={state.isOpen}
|
||||
anchorElement={state.anchorElement}
|
||||
closePopover={closePopoverHandler}
|
||||
>
|
||||
<EuiListGroup color="primary" gutterSize="none" bordered={false} flush={true}>
|
||||
<ExpandPopoverListItem
|
||||
iconType="visTagCloud"
|
||||
label="Explore related entities"
|
||||
onClick={() => {}}
|
||||
/>
|
||||
<ExpandPopoverListItem
|
||||
iconType="users"
|
||||
label="Show actions by this entity"
|
||||
onClick={() => {}}
|
||||
/>
|
||||
<ExpandPopoverListItem
|
||||
iconType="storage"
|
||||
label="Show actions on this entity"
|
||||
onClick={() => {}}
|
||||
/>
|
||||
<EuiHorizontalRule margin="xs" />
|
||||
<ExpandPopoverListItem iconType="expand" label="View entity details" onClick={() => {}} />
|
||||
</EuiListGroup>
|
||||
</GraphPopover>
|
||||
));
|
||||
|
||||
const actionsWithClose = useMemo(
|
||||
() => ({
|
||||
...actions,
|
||||
closePopover: closePopoverHandler,
|
||||
}),
|
||||
[actions, closePopoverHandler]
|
||||
);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
onNodeExpandButtonClick,
|
||||
Popover: PopoverComponent,
|
||||
id,
|
||||
actions: actionsWithClose,
|
||||
state,
|
||||
}),
|
||||
[PopoverComponent, actionsWithClose, id, onNodeExpandButtonClick, state]
|
||||
);
|
||||
};
|
||||
|
||||
const useNodePopover = () => {
|
||||
const { id, state, actions } = useGraphPopover('node-popover');
|
||||
|
||||
const PopoverComponent = memo(() => (
|
||||
<GraphPopover
|
||||
panelPaddingSize="s"
|
||||
anchorPosition="upCenter"
|
||||
isOpen={state.isOpen}
|
||||
anchorElement={state.anchorElement}
|
||||
closePopover={actions.closePopover}
|
||||
>
|
||||
TODO
|
||||
</GraphPopover>
|
||||
));
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
onNodeClick: (e: React.MouseEvent<HTMLElement>) => actions.openPopover(e.currentTarget),
|
||||
Popover: PopoverComponent,
|
||||
id,
|
||||
actions,
|
||||
state,
|
||||
}),
|
||||
[PopoverComponent, actions, id, state]
|
||||
);
|
||||
};
|
||||
|
||||
const Template: Story = () => {
|
||||
const expandNodePopover = useExpandButtonPopover();
|
||||
const nodePopover = useNodePopover();
|
||||
const popovers = [expandNodePopover, nodePopover];
|
||||
const isPopoverOpen = popovers.some((popover) => popover.state.isOpen);
|
||||
|
||||
const popoverOpenWrapper = (cb: Function, ...args: any[]) => {
|
||||
[expandNodePopover.actions.closePopover, nodePopover.actions.closePopover].forEach(
|
||||
(closePopover) => {
|
||||
closePopover();
|
||||
}
|
||||
);
|
||||
cb.apply(null, args);
|
||||
};
|
||||
|
||||
const expandButtonClickHandler = (...args: any[]) =>
|
||||
popoverOpenWrapper(expandNodePopover.onNodeExpandButtonClick, ...args);
|
||||
const nodeClickHandler = (...args: any[]) => popoverOpenWrapper(nodePopover.onNodeClick, ...args);
|
||||
|
||||
const nodes: EntityNodeViewModel[] = useMemo(
|
||||
() =>
|
||||
(['hexagon', 'ellipse', 'rectangle', 'pentagon', 'diamond'] as const).map((shape, idx) => ({
|
||||
id: `${idx}`,
|
||||
label: `Node ${idx}`,
|
||||
color: 'primary',
|
||||
icon: 'okta',
|
||||
interactive: true,
|
||||
shape,
|
||||
expandButtonClick: expandButtonClickHandler,
|
||||
nodeClick: nodeClickHandler,
|
||||
})),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<ThemeProvider theme={{ darkMode: false }}>
|
||||
<Graph
|
||||
css={css`
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
`}
|
||||
nodes={nodes}
|
||||
edges={[]}
|
||||
interactive={true}
|
||||
isLocked={isPopoverOpen}
|
||||
/>
|
||||
{popovers?.map((popover) => popover.Popover && <popover.Popover key={popover.id} />)}
|
||||
</ThemeProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export const GraphPopovers = Template.bind({});
|
|
@ -0,0 +1,85 @@
|
|||
/*
|
||||
* 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, { type PropsWithChildren } from 'react';
|
||||
import type { CommonProps, EuiWrappingPopoverProps } from '@elastic/eui';
|
||||
import { EuiWrappingPopover, useEuiTheme } from '@elastic/eui';
|
||||
import { css } from '@emotion/react';
|
||||
|
||||
export interface GraphPopoverProps
|
||||
extends PropsWithChildren,
|
||||
CommonProps,
|
||||
Pick<
|
||||
EuiWrappingPopoverProps,
|
||||
'anchorPosition' | 'panelClassName' | 'panelPaddingSize' | 'panelStyle'
|
||||
> {
|
||||
isOpen: boolean;
|
||||
anchorElement: HTMLElement | null;
|
||||
closePopover: () => void;
|
||||
}
|
||||
|
||||
export const GraphPopover: React.FC<GraphPopoverProps> = ({
|
||||
isOpen,
|
||||
anchorElement,
|
||||
closePopover,
|
||||
children,
|
||||
...rest
|
||||
}) => {
|
||||
const { euiTheme } = useEuiTheme();
|
||||
|
||||
if (!anchorElement) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<EuiWrappingPopover
|
||||
{...rest}
|
||||
panelProps={{
|
||||
css: css`
|
||||
.euiPopover__arrow[data-popover-arrow='left']:before {
|
||||
border-inline-start-color: ${euiTheme.colors?.body};
|
||||
}
|
||||
|
||||
.euiPopover__arrow[data-popover-arrow='right']:before {
|
||||
border-inline-end-color: ${euiTheme.colors?.body};
|
||||
}
|
||||
|
||||
.euiPopover__arrow[data-popover-arrow='bottom']:before {
|
||||
border-block-end-color: ${euiTheme.colors?.body};
|
||||
}
|
||||
|
||||
.euiPopover__arrow[data-popover-arrow='top']:before {
|
||||
border-block-start-color: ${euiTheme.colors?.body};
|
||||
}
|
||||
|
||||
background-color: ${euiTheme.colors?.body};
|
||||
`,
|
||||
}}
|
||||
color={euiTheme.colors?.body}
|
||||
isOpen={isOpen}
|
||||
closePopover={closePopover}
|
||||
button={anchorElement}
|
||||
ownFocus={true}
|
||||
focusTrapProps={{
|
||||
clickOutsideDisables: false,
|
||||
disabled: false,
|
||||
crossFrame: true,
|
||||
noIsolation: false,
|
||||
returnFocus: (_el) => {
|
||||
anchorElement.focus();
|
||||
return false;
|
||||
},
|
||||
preventScrollOnFocus: true,
|
||||
onClickOutside: () => {
|
||||
closePopover();
|
||||
},
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</EuiWrappingPopover>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,57 @@
|
|||
/*
|
||||
* 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, useState } from 'react';
|
||||
|
||||
export interface PopoverActions {
|
||||
openPopover: (anchorElement: HTMLElement) => void;
|
||||
closePopover: () => void;
|
||||
}
|
||||
|
||||
export interface PopoverState {
|
||||
isOpen: boolean;
|
||||
anchorElement: HTMLElement | null;
|
||||
}
|
||||
|
||||
export interface GraphPopoverState {
|
||||
id: string;
|
||||
actions: PopoverActions;
|
||||
state: PopoverState;
|
||||
}
|
||||
|
||||
export const useGraphPopover = (id: string): GraphPopoverState => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [anchorElement, setAnchorElement] = useState<HTMLElement | null>(null);
|
||||
|
||||
// Memoize actions to prevent them from changing on re-renders
|
||||
const openPopover = useCallback((anchor: HTMLElement) => {
|
||||
setAnchorElement(anchor);
|
||||
setIsOpen(true);
|
||||
}, []);
|
||||
|
||||
const closePopover = useCallback(() => {
|
||||
setIsOpen(false);
|
||||
setAnchorElement(null);
|
||||
}, []);
|
||||
|
||||
// Memoize the context values
|
||||
const actions: PopoverActions = useMemo(
|
||||
() => ({ openPopover, closePopover }),
|
||||
[openPopover, closePopover]
|
||||
);
|
||||
|
||||
const state: PopoverState = useMemo(() => ({ isOpen, anchorElement }), [isOpen, anchorElement]);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
id,
|
||||
actions,
|
||||
state,
|
||||
}),
|
||||
[id, actions, state]
|
||||
);
|
||||
};
|
|
@ -6,6 +6,8 @@
|
|||
*/
|
||||
|
||||
export { Graph } from './graph/graph';
|
||||
export { GraphPopover } from './graph/graph_popover';
|
||||
export { useGraphPopover } from './graph/use_graph_popover';
|
||||
export type { GraphProps } from './graph/graph';
|
||||
export type {
|
||||
NodeViewModel,
|
||||
|
|
|
@ -19,12 +19,13 @@ import {
|
|||
HandleStyleOverride,
|
||||
} from './styles';
|
||||
import { DiamondHoverShape, DiamondShape } from './shapes/diamond_shape';
|
||||
import { NodeExpandButton } from './node_expand_button';
|
||||
|
||||
const NODE_WIDTH = 99;
|
||||
const NODE_HEIGHT = 98;
|
||||
|
||||
export const DiamondNode: React.FC<NodeProps> = memo((props: NodeProps) => {
|
||||
const { id, color, icon, label, interactive, expandButtonClick } =
|
||||
const { id, color, icon, label, interactive, expandButtonClick, nodeClick } =
|
||||
props.data as EntityNodeViewModel;
|
||||
const { euiTheme } = useEuiTheme();
|
||||
return (
|
||||
|
@ -55,11 +56,14 @@ export const DiamondNode: React.FC<NodeProps> = memo((props: NodeProps) => {
|
|||
{icon && <NodeIcon x="14.5" y="14.5" icon={icon} color={color} />}
|
||||
</NodeShapeSvg>
|
||||
{interactive && (
|
||||
<NodeButton
|
||||
onClick={(e) => expandButtonClick?.(e, props)}
|
||||
x={`${NODE_WIDTH - NodeButton.ExpandButtonSize}px`}
|
||||
y={`${(NODE_HEIGHT - NodeButton.ExpandButtonSize) / 2 - 4}px`}
|
||||
/>
|
||||
<>
|
||||
<NodeButton onClick={(e) => nodeClick?.(e, props)} />
|
||||
<NodeExpandButton
|
||||
onClick={(e, unToggleCallback) => expandButtonClick?.(e, props, unToggleCallback)}
|
||||
x={`${NODE_WIDTH - NodeExpandButton.ExpandButtonSize}px`}
|
||||
y={`${(NODE_HEIGHT - NodeExpandButton.ExpandButtonSize) / 2 - 4}px`}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<Handle
|
||||
type="target"
|
||||
|
|
|
@ -19,12 +19,13 @@ import {
|
|||
} from './styles';
|
||||
import type { EntityNodeViewModel, NodeProps } from '../types';
|
||||
import { EllipseHoverShape, EllipseShape } from './shapes/ellipse_shape';
|
||||
import { NodeExpandButton } from './node_expand_button';
|
||||
|
||||
const NODE_WIDTH = 90;
|
||||
const NODE_HEIGHT = 90;
|
||||
|
||||
export const EllipseNode: React.FC<NodeProps> = memo((props: NodeProps) => {
|
||||
const { id, color, icon, label, interactive, expandButtonClick } =
|
||||
const { id, color, icon, label, interactive, expandButtonClick, nodeClick } =
|
||||
props.data as EntityNodeViewModel;
|
||||
const { euiTheme } = useEuiTheme();
|
||||
return (
|
||||
|
@ -55,11 +56,14 @@ export const EllipseNode: React.FC<NodeProps> = memo((props: NodeProps) => {
|
|||
{icon && <NodeIcon x="11" y="12" icon={icon} color={color} />}
|
||||
</NodeShapeSvg>
|
||||
{interactive && (
|
||||
<NodeButton
|
||||
onClick={(e) => expandButtonClick?.(e, props)}
|
||||
x={`${NODE_WIDTH - NodeButton.ExpandButtonSize / 2}px`}
|
||||
y={`${(NODE_HEIGHT - NodeButton.ExpandButtonSize) / 2}px`}
|
||||
/>
|
||||
<>
|
||||
<NodeButton onClick={(e) => nodeClick?.(e, props)} />
|
||||
<NodeExpandButton
|
||||
onClick={(e, unToggleCallback) => expandButtonClick?.(e, props, unToggleCallback)}
|
||||
x={`${NODE_WIDTH - NodeExpandButton.ExpandButtonSize / 2}px`}
|
||||
y={`${(NODE_HEIGHT - NodeExpandButton.ExpandButtonSize) / 2}px`}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<Handle
|
||||
type="target"
|
||||
|
|
|
@ -19,12 +19,13 @@ import {
|
|||
} from './styles';
|
||||
import type { EntityNodeViewModel, NodeProps } from '../types';
|
||||
import { HexagonHoverShape, HexagonShape } from './shapes/hexagon_shape';
|
||||
import { NodeExpandButton } from './node_expand_button';
|
||||
|
||||
const NODE_WIDTH = 87;
|
||||
const NODE_HEIGHT = 96;
|
||||
|
||||
export const HexagonNode: React.FC<NodeProps> = memo((props: NodeProps) => {
|
||||
const { id, color, icon, label, interactive, expandButtonClick } =
|
||||
const { id, color, icon, label, interactive, expandButtonClick, nodeClick } =
|
||||
props.data as EntityNodeViewModel;
|
||||
const { euiTheme } = useEuiTheme();
|
||||
return (
|
||||
|
@ -55,11 +56,14 @@ export const HexagonNode: React.FC<NodeProps> = memo((props: NodeProps) => {
|
|||
{icon && <NodeIcon x="11" y="15" icon={icon} color={color} />}
|
||||
</NodeShapeSvg>
|
||||
{interactive && (
|
||||
<NodeButton
|
||||
onClick={(e) => expandButtonClick?.(e, props)}
|
||||
x={`${NODE_WIDTH - NodeButton.ExpandButtonSize / 2 + 2}px`}
|
||||
y={`${(NODE_HEIGHT - NodeButton.ExpandButtonSize) / 2 - 2}px`}
|
||||
/>
|
||||
<>
|
||||
<NodeButton onClick={(e) => nodeClick?.(e, props)} />
|
||||
<NodeExpandButton
|
||||
onClick={(e, unToggleCallback) => expandButtonClick?.(e, props, unToggleCallback)}
|
||||
x={`${NODE_WIDTH - NodeExpandButton.ExpandButtonSize / 2 + 2}px`}
|
||||
y={`${(NODE_HEIGHT - NodeExpandButton.ExpandButtonSize) / 2 - 2}px`}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<Handle
|
||||
type="target"
|
||||
|
|
|
@ -0,0 +1,43 @@
|
|||
/*
|
||||
* 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, { useCallback, useState } from 'react';
|
||||
import { StyledNodeExpandButton, RoundEuiButtonIcon, ExpandButtonSize } from './styles';
|
||||
|
||||
export interface NodeExpandButtonProps {
|
||||
x?: string;
|
||||
y?: string;
|
||||
onClick?: (e: React.MouseEvent<HTMLElement>, unToggleCallback: () => void) => void;
|
||||
}
|
||||
|
||||
export const NodeExpandButton = ({ x, y, onClick }: NodeExpandButtonProps) => {
|
||||
// State to track whether the icon is "plus" or "minus"
|
||||
const [isToggled, setIsToggled] = useState(false);
|
||||
|
||||
const unToggleCallback = useCallback(() => {
|
||||
setIsToggled(false);
|
||||
}, []);
|
||||
|
||||
const onClickHandler = (e: React.MouseEvent<HTMLElement>) => {
|
||||
setIsToggled((currIsToggled) => !currIsToggled);
|
||||
onClick?.(e, unToggleCallback);
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledNodeExpandButton x={x} y={y} className={isToggled ? 'toggled' : undefined}>
|
||||
<RoundEuiButtonIcon
|
||||
color="primary"
|
||||
iconType={isToggled ? 'minusInCircleFilled' : 'plusInCircleFilled'}
|
||||
onClick={onClickHandler}
|
||||
iconSize="m"
|
||||
aria-label="Open or close node actions"
|
||||
/>
|
||||
</StyledNodeExpandButton>
|
||||
);
|
||||
};
|
||||
|
||||
NodeExpandButton.ExpandButtonSize = ExpandButtonSize;
|
|
@ -20,6 +20,7 @@ import {
|
|||
} from './styles';
|
||||
import type { EntityNodeViewModel, NodeProps } from '../types';
|
||||
import { PentagonHoverShape, PentagonShape } from './shapes/pentagon_shape';
|
||||
import { NodeExpandButton } from './node_expand_button';
|
||||
|
||||
const PentagonShapeOnHover = styled(NodeShapeOnHoverSvg)`
|
||||
transform: translate(-50%, -51.5%);
|
||||
|
@ -29,7 +30,7 @@ const NODE_WIDTH = 91;
|
|||
const NODE_HEIGHT = 88;
|
||||
|
||||
export const PentagonNode: React.FC<NodeProps> = memo((props: NodeProps) => {
|
||||
const { id, color, icon, label, interactive, expandButtonClick } =
|
||||
const { id, color, icon, label, interactive, expandButtonClick, nodeClick } =
|
||||
props.data as EntityNodeViewModel;
|
||||
const { euiTheme } = useEuiTheme();
|
||||
return (
|
||||
|
@ -60,11 +61,14 @@ export const PentagonNode: React.FC<NodeProps> = memo((props: NodeProps) => {
|
|||
{icon && <NodeIcon x="12.5" y="14.5" icon={icon} color={color} />}
|
||||
</NodeShapeSvg>
|
||||
{interactive && (
|
||||
<NodeButton
|
||||
onClick={(e) => expandButtonClick?.(e, props)}
|
||||
x={`${NODE_WIDTH - NodeButton.ExpandButtonSize / 2}px`}
|
||||
y={`${(NODE_HEIGHT - NodeButton.ExpandButtonSize) / 2}px`}
|
||||
/>
|
||||
<>
|
||||
<NodeButton onClick={(e) => nodeClick?.(e, props)} />
|
||||
<NodeExpandButton
|
||||
onClick={(e, unToggleCallback) => expandButtonClick?.(e, props, unToggleCallback)}
|
||||
x={`${NODE_WIDTH - NodeExpandButton.ExpandButtonSize / 2}px`}
|
||||
y={`${(NODE_HEIGHT - NodeExpandButton.ExpandButtonSize) / 2}px`}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<Handle
|
||||
type="target"
|
||||
|
|
|
@ -19,12 +19,13 @@ import {
|
|||
} from './styles';
|
||||
import type { EntityNodeViewModel, NodeProps } from '../types';
|
||||
import { RectangleHoverShape, RectangleShape } from './shapes/rectangle_shape';
|
||||
import { NodeExpandButton } from './node_expand_button';
|
||||
|
||||
const NODE_WIDTH = 81;
|
||||
const NODE_HEIGHT = 80;
|
||||
|
||||
export const RectangleNode: React.FC<NodeProps> = memo((props: NodeProps) => {
|
||||
const { id, color, icon, label, interactive, expandButtonClick } =
|
||||
const { id, color, icon, label, interactive, expandButtonClick, nodeClick } =
|
||||
props.data as EntityNodeViewModel;
|
||||
const { euiTheme } = useEuiTheme();
|
||||
return (
|
||||
|
@ -55,11 +56,14 @@ export const RectangleNode: React.FC<NodeProps> = memo((props: NodeProps) => {
|
|||
{icon && <NodeIcon x="8" y="7" icon={icon} color={color} />}
|
||||
</NodeShapeSvg>
|
||||
{interactive && (
|
||||
<NodeButton
|
||||
onClick={(e) => expandButtonClick?.(e, props)}
|
||||
x={`${NODE_WIDTH - NodeButton.ExpandButtonSize / 4}px`}
|
||||
y={`${(NODE_HEIGHT - NodeButton.ExpandButtonSize / 2) / 2}px`}
|
||||
/>
|
||||
<>
|
||||
<NodeButton onClick={(e) => nodeClick?.(e, props)} />
|
||||
<NodeExpandButton
|
||||
onClick={(e, unToggleCallback) => expandButtonClick?.(e, props, unToggleCallback)}
|
||||
x={`${NODE_WIDTH - NodeExpandButton.ExpandButtonSize / 4}px`}
|
||||
y={`${(NODE_HEIGHT - NodeExpandButton.ExpandButtonSize / 2) / 2}px`}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<Handle
|
||||
type="target"
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import React from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
import {
|
||||
type EuiIconProps,
|
||||
|
@ -18,6 +18,7 @@ import {
|
|||
} from '@elastic/eui';
|
||||
import { rgba } from 'polished';
|
||||
import { getSpanIcon } from './get_span_icon';
|
||||
import type { NodeExpandButtonProps } from './node_expand_button';
|
||||
|
||||
export const LABEL_PADDING_X = 15;
|
||||
export const LABEL_BORDER_WIDTH = 1;
|
||||
|
@ -100,6 +101,54 @@ export const NodeShapeSvg = styled.svg`
|
|||
z-index: 1;
|
||||
`;
|
||||
|
||||
export interface NodeButtonProps {
|
||||
onClick?: (e: React.MouseEvent<HTMLElement>) => void;
|
||||
}
|
||||
|
||||
export const NodeButton: React.FC<NodeButtonProps> = ({ onClick }) => (
|
||||
<StyledNodeContainer>
|
||||
<StyledNodeButton onClick={onClick} />
|
||||
</StyledNodeContainer>
|
||||
);
|
||||
|
||||
const StyledNodeContainer = styled.div`
|
||||
position: absolute;
|
||||
width: ${NODE_WIDTH}px;
|
||||
height: ${NODE_HEIGHT}px;
|
||||
z-index: 1;
|
||||
`;
|
||||
|
||||
const StyledNodeButton = styled.div`
|
||||
width: ${NODE_WIDTH}px;
|
||||
height: ${NODE_HEIGHT}px;
|
||||
`;
|
||||
|
||||
export const StyledNodeExpandButton = styled.div<NodeExpandButtonProps>`
|
||||
opacity: 0; /* Hidden by default */
|
||||
transition: opacity 0.2s ease; /* Smooth transition */
|
||||
${(props: NodeExpandButtonProps) =>
|
||||
(Boolean(props.x) || Boolean(props.y)) &&
|
||||
`transform: translate(${props.x ?? '0'}, ${props.y ?? '0'});`}
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
|
||||
&.toggled {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
${NodeShapeContainer}:hover & {
|
||||
opacity: 1; /* Show on hover */
|
||||
}
|
||||
|
||||
&:has(button:focus) {
|
||||
opacity: 1; /* Show when button is active */
|
||||
}
|
||||
|
||||
.react-flow__node:focus:focus-visible & {
|
||||
opacity: 1; /* Show on node focus */
|
||||
}
|
||||
`;
|
||||
|
||||
export const NodeShapeOnHoverSvg = styled(NodeShapeSvg)`
|
||||
opacity: 0; /* Hidden by default */
|
||||
transition: opacity 0.2s ease; /* Smooth transition */
|
||||
|
@ -108,6 +157,10 @@ export const NodeShapeOnHoverSvg = styled(NodeShapeSvg)`
|
|||
opacity: 1; /* Show on hover */
|
||||
}
|
||||
|
||||
${NodeShapeContainer}:has(${StyledNodeExpandButton}.toggled) & {
|
||||
opacity: 1; /* Show on hover */
|
||||
}
|
||||
|
||||
.react-flow__node:focus:focus-visible & {
|
||||
opacity: 1; /* Show on hover */
|
||||
}
|
||||
|
@ -145,9 +198,9 @@ NodeLabel.defaultProps = {
|
|||
textAlign: 'center',
|
||||
};
|
||||
|
||||
const ExpandButtonSize = 18;
|
||||
export const ExpandButtonSize = 18;
|
||||
|
||||
const RoundEuiButtonIcon = styled(EuiButtonIcon)`
|
||||
export const RoundEuiButtonIcon = styled(EuiButtonIcon)`
|
||||
border-radius: 50%;
|
||||
background-color: ${(_props) => useEuiBackgroundColor('plain')};
|
||||
width: ${ExpandButtonSize}px;
|
||||
|
@ -164,57 +217,6 @@ const RoundEuiButtonIcon = styled(EuiButtonIcon)`
|
|||
}
|
||||
`;
|
||||
|
||||
export const StyledNodeButton = styled.div<NodeButtonProps>`
|
||||
opacity: 0; /* Hidden by default */
|
||||
transition: opacity 0.2s ease; /* Smooth transition */
|
||||
${(props: NodeButtonProps) =>
|
||||
(Boolean(props.x) || Boolean(props.y)) &&
|
||||
`transform: translate(${props.x ?? '0'}, ${props.y ?? '0'});`}
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
|
||||
${NodeShapeContainer}:hover & {
|
||||
opacity: 1; /* Show on hover */
|
||||
}
|
||||
|
||||
&:has(button:focus) {
|
||||
opacity: 1; /* Show when button is active */
|
||||
}
|
||||
|
||||
.react-flow__node:focus:focus-visible & {
|
||||
opacity: 1; /* Show on node focus */
|
||||
}
|
||||
`;
|
||||
|
||||
export interface NodeButtonProps {
|
||||
x?: string;
|
||||
y?: string;
|
||||
onClick?: (e: React.MouseEvent<HTMLElement>) => void;
|
||||
}
|
||||
|
||||
export const NodeButton = ({ x, y, onClick }: NodeButtonProps) => {
|
||||
// State to track whether the icon is "plus" or "minus"
|
||||
const [isToggled, setIsToggled] = useState(false);
|
||||
|
||||
const onClickHandler = (e: React.MouseEvent<HTMLElement>) => {
|
||||
setIsToggled(!isToggled);
|
||||
onClick?.(e);
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledNodeButton x={x} y={y}>
|
||||
<RoundEuiButtonIcon
|
||||
color="primary"
|
||||
iconType={isToggled ? 'minusInCircleFilled' : 'plusInCircleFilled'}
|
||||
onClick={onClickHandler}
|
||||
iconSize="m"
|
||||
/>
|
||||
</StyledNodeButton>
|
||||
);
|
||||
};
|
||||
|
||||
NodeButton.ExpandButtonSize = ExpandButtonSize;
|
||||
|
||||
export const HandleStyleOverride: React.CSSProperties = {
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
|
|
|
@ -0,0 +1,80 @@
|
|||
/*
|
||||
* 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 from 'react';
|
||||
import {
|
||||
EuiIcon,
|
||||
useEuiBackgroundColor,
|
||||
useEuiTheme,
|
||||
type EuiIconProps,
|
||||
type _EuiBackgroundColor,
|
||||
EuiListGroupItemProps,
|
||||
EuiListGroupItem,
|
||||
EuiText,
|
||||
} from '@elastic/eui';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
interface EuiColorProps {
|
||||
color: keyof ReturnType<typeof useEuiTheme>['euiTheme']['colors'];
|
||||
background: _EuiBackgroundColor;
|
||||
}
|
||||
|
||||
type IconContainerProps = EuiColorProps;
|
||||
|
||||
const IconContainer = styled.div<IconContainerProps>`
|
||||
position: relative;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
color: ${({ color }) => {
|
||||
const { euiTheme } = useEuiTheme();
|
||||
return euiTheme.colors[color];
|
||||
}};
|
||||
background-color: ${({ background }) => useEuiBackgroundColor(background)};
|
||||
border: 1px solid
|
||||
${({ color }) => {
|
||||
const { euiTheme } = useEuiTheme();
|
||||
return euiTheme.colors[color];
|
||||
}};
|
||||
margin-right: 8px;
|
||||
`;
|
||||
|
||||
const StyleEuiIcon = styled(EuiIcon)`
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
`;
|
||||
|
||||
type RoundedEuiIconProps = EuiIconProps & EuiColorProps;
|
||||
|
||||
const RoundedEuiIcon: React.FC<RoundedEuiIconProps> = ({ color, background, ...rest }) => (
|
||||
<IconContainer color={color} background={background}>
|
||||
<StyleEuiIcon color={color} {...rest} />
|
||||
</IconContainer>
|
||||
);
|
||||
|
||||
export const ExpandPopoverListItem: React.FC<
|
||||
Pick<EuiListGroupItemProps, 'iconType' | 'label' | 'onClick'>
|
||||
> = (props) => {
|
||||
const { euiTheme } = useEuiTheme();
|
||||
return (
|
||||
<EuiListGroupItem
|
||||
icon={
|
||||
props.iconType ? (
|
||||
<RoundedEuiIcon color="primary" background="primary" type={props.iconType} size="s" />
|
||||
) : undefined
|
||||
}
|
||||
label={
|
||||
<EuiText size="s" color={euiTheme.colors.primaryText}>
|
||||
{props.label}
|
||||
</EuiText>
|
||||
}
|
||||
onClick={props.onClick}
|
||||
/>
|
||||
);
|
||||
};
|
|
@ -5,6 +5,7 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import type {
|
||||
EntityNodeDataModel,
|
||||
GroupNodeDataModel,
|
||||
|
@ -24,11 +25,20 @@ interface BaseNodeDataViewModel {
|
|||
interactive?: boolean;
|
||||
}
|
||||
|
||||
export type NodeClickCallback = (e: React.MouseEvent<HTMLElement>, node: NodeProps) => void;
|
||||
|
||||
export type ExpandButtonClickCallback = (
|
||||
e: React.MouseEvent<HTMLElement>,
|
||||
node: NodeProps,
|
||||
unToggleCallback: () => void
|
||||
) => void;
|
||||
|
||||
export interface EntityNodeViewModel
|
||||
extends Record<string, unknown>,
|
||||
EntityNodeDataModel,
|
||||
BaseNodeDataViewModel {
|
||||
expandButtonClick?: (e: React.MouseEvent<HTMLElement>, node: NodeProps) => void;
|
||||
expandButtonClick?: ExpandButtonClickCallback;
|
||||
nodeClick?: NodeClickCallback;
|
||||
}
|
||||
|
||||
export interface GroupNodeViewModel
|
||||
|
@ -40,7 +50,7 @@ export interface LabelNodeViewModel
|
|||
extends Record<string, unknown>,
|
||||
LabelNodeDataModel,
|
||||
BaseNodeDataViewModel {
|
||||
expandButtonClick?: (e: React.MouseEvent<HTMLElement>, node: NodeProps) => void;
|
||||
expandButtonClick?: ExpandButtonClickCallback;
|
||||
}
|
||||
|
||||
export type NodeViewModel = EntityNodeViewModel | GroupNodeViewModel | LabelNodeViewModel;
|
||||
|
|
|
@ -0,0 +1,708 @@
|
|||
{
|
||||
"type": "doc",
|
||||
"value": {
|
||||
"id": "589e086d7ceec7d4b353340578bd607e96fbac7eab9e2926f110990be15122f1",
|
||||
"index": ".internal.alerts-security.alerts-default-000001",
|
||||
"source": {
|
||||
"@timestamp": "2024-09-01T20:44:02.109Z",
|
||||
"actor": {
|
||||
"entity": {
|
||||
"id": "admin@example.com"
|
||||
}
|
||||
},
|
||||
"client": {
|
||||
"user": {
|
||||
"email": "admin@example.com"
|
||||
}
|
||||
},
|
||||
"cloud": {
|
||||
"project": {
|
||||
"id": "your-project-id"
|
||||
},
|
||||
"provider": "gcp"
|
||||
},
|
||||
"ecs": {
|
||||
"version": "8.11.0"
|
||||
},
|
||||
"event": {
|
||||
"action": "google.iam.admin.v1.CreateRole",
|
||||
"agent_id_status": "missing",
|
||||
"category": [
|
||||
"session",
|
||||
"network",
|
||||
"configuration"
|
||||
],
|
||||
"dataset": "gcp.audit",
|
||||
"id": "kabcd1234efgh5678",
|
||||
"ingested": "2024-09-01T20:40:17Z",
|
||||
"module": "gcp",
|
||||
"outcome": "success",
|
||||
"provider": "activity",
|
||||
"type": [
|
||||
"end",
|
||||
"access",
|
||||
"allowed"
|
||||
]
|
||||
},
|
||||
"event.kind": "signal",
|
||||
"gcp": {
|
||||
"audit": {
|
||||
"authorization_info": [
|
||||
{
|
||||
"granted": true,
|
||||
"permission": "iam.roles.create",
|
||||
"resource": "projects/your-project-id"
|
||||
}
|
||||
],
|
||||
"logentry_operation": {
|
||||
"id": "operation-0987654321"
|
||||
},
|
||||
"request": {
|
||||
"@type": "type.googleapis.com/google.iam.admin.v1.CreateRoleRequest",
|
||||
"parent": "projects/your-project-id",
|
||||
"role": {
|
||||
"description": "A custom role with specific permissions",
|
||||
"includedPermissions": [
|
||||
"resourcemanager.projects.get",
|
||||
"resourcemanager.projects.list"
|
||||
],
|
||||
"name": "projects/your-project-id/roles/customRole",
|
||||
"title": "Custom Role"
|
||||
},
|
||||
"roleId": "customRole"
|
||||
},
|
||||
"resource_name": "projects/your-project-id/roles/customRole",
|
||||
"response": {
|
||||
"@type": "type.googleapis.com/google.iam.admin.v1.Role",
|
||||
"description": "A custom role with specific permissions",
|
||||
"includedPermissions": [
|
||||
"resourcemanager.projects.get",
|
||||
"resourcemanager.projects.list"
|
||||
],
|
||||
"name": "projects/your-project-id/roles/customRole",
|
||||
"stage": "GA",
|
||||
"title": "Custom Role"
|
||||
},
|
||||
"type": "type.googleapis.com/google.cloud.audit.AuditLog"
|
||||
}
|
||||
},
|
||||
"kibana.alert.ancestors": [
|
||||
{
|
||||
"depth": 0,
|
||||
"id": "MhKch5IBGYRrfvcTQNbO",
|
||||
"index": ".ds-logs-gcp.audit-default-2024.10.13-000001",
|
||||
"type": "event"
|
||||
}
|
||||
],
|
||||
"kibana.alert.depth": 1,
|
||||
"kibana.alert.intended_timestamp": "2024-09-01T20:44:02.117Z",
|
||||
"kibana.alert.last_detected": "2024-09-01T20:44:02.117Z",
|
||||
"kibana.alert.original_event.action": "google.iam.admin.v1.CreateRole",
|
||||
"kibana.alert.original_event.agent_id_status": "missing",
|
||||
"kibana.alert.original_event.category": [
|
||||
"session",
|
||||
"network",
|
||||
"configuration"
|
||||
],
|
||||
"kibana.alert.original_event.dataset": "gcp.audit",
|
||||
"kibana.alert.original_event.id": "kabcd1234efgh5678",
|
||||
"kibana.alert.original_event.ingested": "2024-09-01T20:40:17Z",
|
||||
"kibana.alert.original_event.kind": "event",
|
||||
"kibana.alert.original_event.module": "gcp",
|
||||
"kibana.alert.original_event.outcome": "success",
|
||||
"kibana.alert.original_event.provider": "activity",
|
||||
"kibana.alert.original_event.type": [
|
||||
"end",
|
||||
"access",
|
||||
"allowed"
|
||||
],
|
||||
"kibana.alert.original_time": "2024-09-01T12:34:56.789Z",
|
||||
"kibana.alert.reason": "session, network, configuration event with source 10.0.0.1 created medium alert GCP IAM Custom Role Creation.",
|
||||
"kibana.alert.risk_score": 47,
|
||||
"kibana.alert.rule.actions": [
|
||||
],
|
||||
"kibana.alert.rule.author": [
|
||||
"Elastic"
|
||||
],
|
||||
"kibana.alert.rule.category": "Custom Query Rule",
|
||||
"kibana.alert.rule.consumer": "siem",
|
||||
"kibana.alert.rule.created_at": "2024-09-01T20:38:49.650Z",
|
||||
"kibana.alert.rule.created_by": "elastic",
|
||||
"kibana.alert.rule.description": "Identifies an Identity and Access Management (IAM) custom role creation in Google Cloud Platform (GCP). Custom roles are user-defined, and allow for the bundling of one or more supported permissions to meet specific needs. Custom roles will not be updated automatically and could lead to privilege creep if not carefully scrutinized.",
|
||||
"kibana.alert.rule.enabled": true,
|
||||
"kibana.alert.rule.exceptions_list": [
|
||||
],
|
||||
"kibana.alert.rule.execution.timestamp": "2024-09-01T20:44:02.117Z",
|
||||
"kibana.alert.rule.execution.uuid": "a440f349-1900-4087-b507-f2b98c6cfa79",
|
||||
"kibana.alert.rule.false_positives": [
|
||||
"Custom role creations may be done by a system or network administrator. Verify whether the user email, resource name, and/or hostname should be making changes in your environment. Role creations by unfamiliar users or hosts should be investigated. If known behavior is causing false positives, it can be exempted from the rule."
|
||||
],
|
||||
"kibana.alert.rule.from": "now-6m",
|
||||
"kibana.alert.rule.immutable": true,
|
||||
"kibana.alert.rule.indices": [
|
||||
"filebeat-*",
|
||||
"logs-gcp*"
|
||||
],
|
||||
"kibana.alert.rule.interval": "5m",
|
||||
"kibana.alert.rule.license": "Elastic License v2",
|
||||
"kibana.alert.rule.max_signals": 100,
|
||||
"kibana.alert.rule.name": "GCP IAM Custom Role Creation",
|
||||
"kibana.alert.rule.note": "",
|
||||
"kibana.alert.rule.parameters": {
|
||||
"author": [
|
||||
"Elastic"
|
||||
],
|
||||
"description": "Identifies an Identity and Access Management (IAM) custom role creation in Google Cloud Platform (GCP). Custom roles are user-defined, and allow for the bundling of one or more supported permissions to meet specific needs. Custom roles will not be updated automatically and could lead to privilege creep if not carefully scrutinized.",
|
||||
"exceptions_list": [
|
||||
],
|
||||
"false_positives": [
|
||||
"Custom role creations may be done by a system or network administrator. Verify whether the user email, resource name, and/or hostname should be making changes in your environment. Role creations by unfamiliar users or hosts should be investigated. If known behavior is causing false positives, it can be exempted from the rule."
|
||||
],
|
||||
"from": "now-6m",
|
||||
"immutable": true,
|
||||
"index": [
|
||||
"filebeat-*",
|
||||
"logs-gcp*"
|
||||
],
|
||||
"language": "kuery",
|
||||
"license": "Elastic License v2",
|
||||
"max_signals": 100,
|
||||
"note": "",
|
||||
"query": "event.dataset:gcp.audit and event.action:google.iam.admin.v*.CreateRole and event.outcome:success\n",
|
||||
"references": [
|
||||
"https://cloud.google.com/iam/docs/understanding-custom-roles"
|
||||
],
|
||||
"related_integrations": [
|
||||
{
|
||||
"integration": "audit",
|
||||
"package": "gcp",
|
||||
"version": "^2.0.0"
|
||||
}
|
||||
],
|
||||
"required_fields": [
|
||||
{
|
||||
"ecs": true,
|
||||
"name": "event.action",
|
||||
"type": "keyword"
|
||||
},
|
||||
{
|
||||
"ecs": true,
|
||||
"name": "event.dataset",
|
||||
"type": "keyword"
|
||||
},
|
||||
{
|
||||
"ecs": true,
|
||||
"name": "event.outcome",
|
||||
"type": "keyword"
|
||||
}
|
||||
],
|
||||
"risk_score": 47,
|
||||
"risk_score_mapping": [
|
||||
],
|
||||
"rule_id": "aa8007f0-d1df-49ef-8520-407857594827",
|
||||
"rule_source": {
|
||||
"is_customized": false,
|
||||
"type": "external"
|
||||
},
|
||||
"setup": "The GCP Fleet integration, Filebeat module, or similarly structured data is required to be compatible with this rule.",
|
||||
"severity": "medium",
|
||||
"severity_mapping": [
|
||||
],
|
||||
"threat": [
|
||||
{
|
||||
"framework": "MITRE ATT&CK",
|
||||
"tactic": {
|
||||
"id": "TA0001",
|
||||
"name": "Initial Access",
|
||||
"reference": "https://attack.mitre.org/tactics/TA0001/"
|
||||
},
|
||||
"technique": [
|
||||
{
|
||||
"id": "T1078",
|
||||
"name": "Valid Accounts",
|
||||
"reference": "https://attack.mitre.org/techniques/T1078/"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"framework": "MITRE ATT&CK",
|
||||
"tactic": {
|
||||
"id": "TA0003",
|
||||
"name": "Persistence",
|
||||
"reference": "https://attack.mitre.org/tactics/TA0003/"
|
||||
},
|
||||
"technique": [
|
||||
{
|
||||
"id": "T1078",
|
||||
"name": "Valid Accounts",
|
||||
"reference": "https://attack.mitre.org/techniques/T1078/"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"timestamp_override": "event.ingested",
|
||||
"to": "now",
|
||||
"type": "query",
|
||||
"version": 104
|
||||
},
|
||||
"kibana.alert.rule.producer": "siem",
|
||||
"kibana.alert.rule.references": [
|
||||
"https://cloud.google.com/iam/docs/understanding-custom-roles"
|
||||
],
|
||||
"kibana.alert.rule.revision": 0,
|
||||
"kibana.alert.rule.risk_score": 47,
|
||||
"kibana.alert.rule.risk_score_mapping": [
|
||||
],
|
||||
"kibana.alert.rule.rule_id": "aa8007f0-d1df-49ef-8520-407857594827",
|
||||
"kibana.alert.rule.rule_type_id": "siem.queryRule",
|
||||
"kibana.alert.rule.severity": "medium",
|
||||
"kibana.alert.rule.severity_mapping": [
|
||||
],
|
||||
"kibana.alert.rule.tags": [
|
||||
"Domain: Cloud",
|
||||
"Data Source: GCP",
|
||||
"Data Source: Google Cloud Platform",
|
||||
"Use Case: Identity and Access Audit",
|
||||
"Tactic: Initial Access"
|
||||
],
|
||||
"kibana.alert.rule.threat": [
|
||||
{
|
||||
"framework": "MITRE ATT&CK",
|
||||
"tactic": {
|
||||
"id": "TA0001",
|
||||
"name": "Initial Access",
|
||||
"reference": "https://attack.mitre.org/tactics/TA0001/"
|
||||
},
|
||||
"technique": [
|
||||
{
|
||||
"id": "T1078",
|
||||
"name": "Valid Accounts",
|
||||
"reference": "https://attack.mitre.org/techniques/T1078/"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"framework": "MITRE ATT&CK",
|
||||
"tactic": {
|
||||
"id": "TA0003",
|
||||
"name": "Persistence",
|
||||
"reference": "https://attack.mitre.org/tactics/TA0003/"
|
||||
},
|
||||
"technique": [
|
||||
{
|
||||
"id": "T1078",
|
||||
"name": "Valid Accounts",
|
||||
"reference": "https://attack.mitre.org/techniques/T1078/"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"kibana.alert.rule.timestamp_override": "event.ingested",
|
||||
"kibana.alert.rule.to": "now",
|
||||
"kibana.alert.rule.type": "query",
|
||||
"kibana.alert.rule.updated_at": "2024-09-01T20:39:00.099Z",
|
||||
"kibana.alert.rule.updated_by": "elastic",
|
||||
"kibana.alert.rule.uuid": "c6f64115-5941-46ef-bfa3-61a4ecb4f3ba",
|
||||
"kibana.alert.rule.version": 104,
|
||||
"kibana.alert.severity": "medium",
|
||||
"kibana.alert.start": "2024-09-01T20:44:02.117Z",
|
||||
"kibana.alert.status": "active",
|
||||
"kibana.alert.uuid": "589e086d7ceec7d4b353340578bd607e96fbac7eab9e2926f110990be15122f1",
|
||||
"kibana.alert.workflow_assignee_ids": [
|
||||
],
|
||||
"kibana.alert.workflow_status": "open",
|
||||
"kibana.alert.workflow_tags": [
|
||||
],
|
||||
"kibana.space_ids": [
|
||||
"default"
|
||||
],
|
||||
"kibana.version": "9.0.0",
|
||||
"log": {
|
||||
"level": "NOTICE",
|
||||
"logger": "projects/your-project-id/logs/cloudaudit.googleapis.com%2Factivity"
|
||||
},
|
||||
"related": {
|
||||
"ip": [
|
||||
"10.0.0.1"
|
||||
],
|
||||
"user": [
|
||||
"admin@example.com"
|
||||
]
|
||||
},
|
||||
"service": {
|
||||
"name": "iam.googleapis.com"
|
||||
},
|
||||
"source": {
|
||||
"ip": "10.0.0.1"
|
||||
},
|
||||
"tags": [
|
||||
"_geoip_database_unavailable_GeoLite2-City.mmdb",
|
||||
"_geoip_database_unavailable_GeoLite2-ASN.mmdb"
|
||||
],
|
||||
"target": {
|
||||
"entity": {
|
||||
"id": "projects/your-project-id/roles/customRole"
|
||||
}
|
||||
},
|
||||
"user_agent": {
|
||||
"device": {
|
||||
"name": "Other"
|
||||
},
|
||||
"name": "Other",
|
||||
"original": "google-cloud-sdk/324.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
"type": "doc",
|
||||
"value": {
|
||||
"id": "838ea37ab43ab7d2754d007fbe8191be53d7d637bea62f6189f8db1503c0e250",
|
||||
"index": ".internal.alerts-security.alerts-default-000001",
|
||||
"source": {
|
||||
"@timestamp": "2024-09-01T20:39:03.646Z",
|
||||
"actor": {
|
||||
"entity": {
|
||||
"id": "admin@example.com"
|
||||
}
|
||||
},
|
||||
"client": {
|
||||
"user": {
|
||||
"email": "admin@example.com"
|
||||
}
|
||||
},
|
||||
"cloud": {
|
||||
"project": {
|
||||
"id": "your-project-id"
|
||||
},
|
||||
"provider": "gcp"
|
||||
},
|
||||
"ecs": {
|
||||
"version": "8.11.0"
|
||||
},
|
||||
"event": {
|
||||
"action": "google.iam.admin.v1.CreateRole",
|
||||
"agent_id_status": "missing",
|
||||
"category": [
|
||||
"session",
|
||||
"network",
|
||||
"configuration"
|
||||
],
|
||||
"dataset": "gcp.audit",
|
||||
"id": "kabcd1234efgh5678",
|
||||
"ingested": "2024-09-01T20:38:13Z",
|
||||
"module": "gcp",
|
||||
"outcome": "success",
|
||||
"provider": "activity",
|
||||
"type": [
|
||||
"end",
|
||||
"access",
|
||||
"allowed"
|
||||
]
|
||||
},
|
||||
"event.kind": "signal",
|
||||
"gcp": {
|
||||
"audit": {
|
||||
"authorization_info": [
|
||||
{
|
||||
"granted": true,
|
||||
"permission": "iam.roles.create",
|
||||
"resource": "projects/your-project-id"
|
||||
}
|
||||
],
|
||||
"logentry_operation": {
|
||||
"id": "operation-0987654321"
|
||||
},
|
||||
"request": {
|
||||
"@type": "type.googleapis.com/google.iam.admin.v1.CreateRoleRequest",
|
||||
"parent": "projects/your-project-id",
|
||||
"role": {
|
||||
"description": "A custom role with specific permissions",
|
||||
"includedPermissions": [
|
||||
"resourcemanager.projects.get",
|
||||
"resourcemanager.projects.list"
|
||||
],
|
||||
"name": "projects/your-project-id/roles/customRole",
|
||||
"title": "Custom Role"
|
||||
},
|
||||
"roleId": "customRole"
|
||||
},
|
||||
"resource_name": "projects/your-project-id/roles/customRole",
|
||||
"response": {
|
||||
"@type": "type.googleapis.com/google.iam.admin.v1.Role",
|
||||
"description": "A custom role with specific permissions",
|
||||
"includedPermissions": [
|
||||
"resourcemanager.projects.get",
|
||||
"resourcemanager.projects.list"
|
||||
],
|
||||
"name": "projects/your-project-id/roles/customRole",
|
||||
"stage": "GA",
|
||||
"title": "Custom Role"
|
||||
},
|
||||
"type": "type.googleapis.com/google.cloud.audit.AuditLog"
|
||||
}
|
||||
},
|
||||
"kibana.alert.ancestors": [
|
||||
{
|
||||
"depth": 0,
|
||||
"id": "rhKah5IBGYRrfvcTXtWe",
|
||||
"index": ".ds-logs-gcp.audit-default-2024.10.13-000001",
|
||||
"type": "event"
|
||||
}
|
||||
],
|
||||
"kibana.alert.depth": 1,
|
||||
"kibana.alert.intended_timestamp": "2024-09-01T20:39:03.657Z",
|
||||
"kibana.alert.last_detected": "2024-09-01T20:39:03.657Z",
|
||||
"kibana.alert.original_event.action": "google.iam.admin.v1.CreateRole",
|
||||
"kibana.alert.original_event.agent_id_status": "missing",
|
||||
"kibana.alert.original_event.category": [
|
||||
"session",
|
||||
"network",
|
||||
"configuration"
|
||||
],
|
||||
"kibana.alert.original_event.dataset": "gcp.audit",
|
||||
"kibana.alert.original_event.id": "kabcd1234efgh5678",
|
||||
"kibana.alert.original_event.ingested": "2024-09-01T20:38:13Z",
|
||||
"kibana.alert.original_event.kind": "event",
|
||||
"kibana.alert.original_event.module": "gcp",
|
||||
"kibana.alert.original_event.outcome": "success",
|
||||
"kibana.alert.original_event.provider": "activity",
|
||||
"kibana.alert.original_event.type": [
|
||||
"end",
|
||||
"access",
|
||||
"allowed"
|
||||
],
|
||||
"kibana.alert.original_time": "2024-09-01T12:34:56.789Z",
|
||||
"kibana.alert.reason": "session, network, configuration event with source 10.0.0.1 created medium alert GCP IAM Custom Role Creation.",
|
||||
"kibana.alert.risk_score": 47,
|
||||
"kibana.alert.rule.actions": [
|
||||
],
|
||||
"kibana.alert.rule.author": [
|
||||
"Elastic"
|
||||
],
|
||||
"kibana.alert.rule.category": "Custom Query Rule",
|
||||
"kibana.alert.rule.consumer": "siem",
|
||||
"kibana.alert.rule.created_at": "2024-09-01T20:38:49.650Z",
|
||||
"kibana.alert.rule.created_by": "elastic",
|
||||
"kibana.alert.rule.description": "Identifies an Identity and Access Management (IAM) custom role creation in Google Cloud Platform (GCP). Custom roles are user-defined, and allow for the bundling of one or more supported permissions to meet specific needs. Custom roles will not be updated automatically and could lead to privilege creep if not carefully scrutinized.",
|
||||
"kibana.alert.rule.enabled": true,
|
||||
"kibana.alert.rule.exceptions_list": [
|
||||
],
|
||||
"kibana.alert.rule.execution.timestamp": "2024-09-01T20:39:03.657Z",
|
||||
"kibana.alert.rule.execution.uuid": "939d34e1-1e74-480d-90ae-24079d9b40d3",
|
||||
"kibana.alert.rule.false_positives": [
|
||||
"Custom role creations may be done by a system or network administrator. Verify whether the user email, resource name, and/or hostname should be making changes in your environment. Role creations by unfamiliar users or hosts should be investigated. If known behavior is causing false positives, it can be exempted from the rule."
|
||||
],
|
||||
"kibana.alert.rule.from": "now-6m",
|
||||
"kibana.alert.rule.immutable": true,
|
||||
"kibana.alert.rule.indices": [
|
||||
"filebeat-*",
|
||||
"logs-gcp*"
|
||||
],
|
||||
"kibana.alert.rule.interval": "5m",
|
||||
"kibana.alert.rule.license": "Elastic License v2",
|
||||
"kibana.alert.rule.max_signals": 100,
|
||||
"kibana.alert.rule.name": "GCP IAM Custom Role Creation",
|
||||
"kibana.alert.rule.note": "",
|
||||
"kibana.alert.rule.parameters": {
|
||||
"author": [
|
||||
"Elastic"
|
||||
],
|
||||
"description": "Identifies an Identity and Access Management (IAM) custom role creation in Google Cloud Platform (GCP). Custom roles are user-defined, and allow for the bundling of one or more supported permissions to meet specific needs. Custom roles will not be updated automatically and could lead to privilege creep if not carefully scrutinized.",
|
||||
"exceptions_list": [
|
||||
],
|
||||
"false_positives": [
|
||||
"Custom role creations may be done by a system or network administrator. Verify whether the user email, resource name, and/or hostname should be making changes in your environment. Role creations by unfamiliar users or hosts should be investigated. If known behavior is causing false positives, it can be exempted from the rule."
|
||||
],
|
||||
"from": "now-6m",
|
||||
"immutable": true,
|
||||
"index": [
|
||||
"filebeat-*",
|
||||
"logs-gcp*"
|
||||
],
|
||||
"language": "kuery",
|
||||
"license": "Elastic License v2",
|
||||
"max_signals": 100,
|
||||
"note": "",
|
||||
"query": "event.dataset:gcp.audit and event.action:google.iam.admin.v*.CreateRole and event.outcome:success\n",
|
||||
"references": [
|
||||
"https://cloud.google.com/iam/docs/understanding-custom-roles"
|
||||
],
|
||||
"related_integrations": [
|
||||
{
|
||||
"integration": "audit",
|
||||
"package": "gcp",
|
||||
"version": "^2.0.0"
|
||||
}
|
||||
],
|
||||
"required_fields": [
|
||||
{
|
||||
"ecs": true,
|
||||
"name": "event.action",
|
||||
"type": "keyword"
|
||||
},
|
||||
{
|
||||
"ecs": true,
|
||||
"name": "event.dataset",
|
||||
"type": "keyword"
|
||||
},
|
||||
{
|
||||
"ecs": true,
|
||||
"name": "event.outcome",
|
||||
"type": "keyword"
|
||||
}
|
||||
],
|
||||
"risk_score": 47,
|
||||
"risk_score_mapping": [
|
||||
],
|
||||
"rule_id": "aa8007f0-d1df-49ef-8520-407857594827",
|
||||
"rule_source": {
|
||||
"is_customized": false,
|
||||
"type": "external"
|
||||
},
|
||||
"setup": "The GCP Fleet integration, Filebeat module, or similarly structured data is required to be compatible with this rule.",
|
||||
"severity": "medium",
|
||||
"severity_mapping": [
|
||||
],
|
||||
"threat": [
|
||||
{
|
||||
"framework": "MITRE ATT&CK",
|
||||
"tactic": {
|
||||
"id": "TA0001",
|
||||
"name": "Initial Access",
|
||||
"reference": "https://attack.mitre.org/tactics/TA0001/"
|
||||
},
|
||||
"technique": [
|
||||
{
|
||||
"id": "T1078",
|
||||
"name": "Valid Accounts",
|
||||
"reference": "https://attack.mitre.org/techniques/T1078/"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"framework": "MITRE ATT&CK",
|
||||
"tactic": {
|
||||
"id": "TA0003",
|
||||
"name": "Persistence",
|
||||
"reference": "https://attack.mitre.org/tactics/TA0003/"
|
||||
},
|
||||
"technique": [
|
||||
{
|
||||
"id": "T1078",
|
||||
"name": "Valid Accounts",
|
||||
"reference": "https://attack.mitre.org/techniques/T1078/"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"timestamp_override": "event.ingested",
|
||||
"to": "now",
|
||||
"type": "query",
|
||||
"version": 104
|
||||
},
|
||||
"kibana.alert.rule.producer": "siem",
|
||||
"kibana.alert.rule.references": [
|
||||
"https://cloud.google.com/iam/docs/understanding-custom-roles"
|
||||
],
|
||||
"kibana.alert.rule.revision": 0,
|
||||
"kibana.alert.rule.risk_score": 47,
|
||||
"kibana.alert.rule.risk_score_mapping": [
|
||||
],
|
||||
"kibana.alert.rule.rule_id": "aa8007f0-d1df-49ef-8520-407857594827",
|
||||
"kibana.alert.rule.rule_type_id": "siem.queryRule",
|
||||
"kibana.alert.rule.severity": "medium",
|
||||
"kibana.alert.rule.severity_mapping": [
|
||||
],
|
||||
"kibana.alert.rule.tags": [
|
||||
"Domain: Cloud",
|
||||
"Data Source: GCP",
|
||||
"Data Source: Google Cloud Platform",
|
||||
"Use Case: Identity and Access Audit",
|
||||
"Tactic: Initial Access"
|
||||
],
|
||||
"kibana.alert.rule.threat": [
|
||||
{
|
||||
"framework": "MITRE ATT&CK",
|
||||
"tactic": {
|
||||
"id": "TA0001",
|
||||
"name": "Initial Access",
|
||||
"reference": "https://attack.mitre.org/tactics/TA0001/"
|
||||
},
|
||||
"technique": [
|
||||
{
|
||||
"id": "T1078",
|
||||
"name": "Valid Accounts",
|
||||
"reference": "https://attack.mitre.org/techniques/T1078/"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"framework": "MITRE ATT&CK",
|
||||
"tactic": {
|
||||
"id": "TA0003",
|
||||
"name": "Persistence",
|
||||
"reference": "https://attack.mitre.org/tactics/TA0003/"
|
||||
},
|
||||
"technique": [
|
||||
{
|
||||
"id": "T1078",
|
||||
"name": "Valid Accounts",
|
||||
"reference": "https://attack.mitre.org/techniques/T1078/"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"kibana.alert.rule.timestamp_override": "event.ingested",
|
||||
"kibana.alert.rule.to": "now",
|
||||
"kibana.alert.rule.type": "query",
|
||||
"kibana.alert.rule.updated_at": "2024-09-01T20:39:00.099Z",
|
||||
"kibana.alert.rule.updated_by": "elastic",
|
||||
"kibana.alert.rule.uuid": "c6f64115-5941-46ef-bfa3-61a4ecb4f3ba",
|
||||
"kibana.alert.rule.version": 104,
|
||||
"kibana.alert.severity": "medium",
|
||||
"kibana.alert.start": "2024-09-01T20:39:03.657Z",
|
||||
"kibana.alert.status": "active",
|
||||
"kibana.alert.uuid": "838ea37ab43ab7d2754d007fbe8191be53d7d637bea62f6189f8db1503c0e250",
|
||||
"kibana.alert.workflow_assignee_ids": [
|
||||
],
|
||||
"kibana.alert.workflow_status": "open",
|
||||
"kibana.alert.workflow_tags": [
|
||||
],
|
||||
"kibana.space_ids": [
|
||||
"default"
|
||||
],
|
||||
"kibana.version": "9.0.0",
|
||||
"log": {
|
||||
"level": "NOTICE",
|
||||
"logger": "projects/your-project-id/logs/cloudaudit.googleapis.com%2Factivity"
|
||||
},
|
||||
"related": {
|
||||
"ip": [
|
||||
"10.0.0.1"
|
||||
],
|
||||
"user": [
|
||||
"admin@example.com"
|
||||
]
|
||||
},
|
||||
"service": {
|
||||
"name": "iam.googleapis.com"
|
||||
},
|
||||
"source": {
|
||||
"ip": "10.0.0.1"
|
||||
},
|
||||
"tags": [
|
||||
"_geoip_database_unavailable_GeoLite2-City.mmdb",
|
||||
"_geoip_database_unavailable_GeoLite2-ASN.mmdb"
|
||||
],
|
||||
"user_agent": {
|
||||
"device": {
|
||||
"name": "Other"
|
||||
},
|
||||
"name": "Other",
|
||||
"original": "google-cloud-sdk/324.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
Binary file not shown.
|
@ -34,8 +34,8 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) {
|
|||
// Setting the timerange to fit the data and open the flyout for a specific alert
|
||||
await alertsPage.navigateToAlertsPage(
|
||||
`${alertsPage.getAbsoluteTimerangeFilter(
|
||||
'2024-10-13T00:00:00.000Z',
|
||||
'2024-10-14T00:00:00.000Z'
|
||||
'2024-09-01T00:00:00.000Z',
|
||||
'2024-09-02T00:00:00.000Z'
|
||||
)}&${alertsPage.getFlyoutFilter(
|
||||
'589e086d7ceec7d4b353340578bd607e96fbac7eab9e2926f110990be15122f1'
|
||||
)}`
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue