[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:
Kfir Peled 2024-11-12 23:54:09 +00:00 committed by GitHub
parent 07c218d020
commit f3de593049
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 1419 additions and 152 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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