diff --git a/x-pack/packages/kbn-cloud-security-posture/graph/src/components/edge/styles.tsx b/x-pack/packages/kbn-cloud-security-posture/graph/src/components/edge/styles.tsx index 8f7c5e29ec3fe..58ecea3bc3bea 100644 --- a/x-pack/packages/kbn-cloud-security-posture/graph/src/components/edge/styles.tsx +++ b/x-pack/packages/kbn-cloud-security-posture/graph/src/components/edge/styles.tsx @@ -128,7 +128,7 @@ export const SvgDefsMarker = () => { const { euiTheme } = useEuiTheme(); return ( - + diff --git a/x-pack/packages/kbn-cloud-security-posture/graph/src/components/graph/graph.tsx b/x-pack/packages/kbn-cloud-security-posture/graph/src/components/graph/graph.tsx index eca9872d73897..0b956cb19e10d 100644 --- a/x-pack/packages/kbn-cloud-security-posture/graph/src/components/graph/graph.tsx +++ b/x-pack/packages/kbn-cloud-security-posture/graph/src/components/graph/graph.tsx @@ -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 = ({ nodes, edges, interactive, ...rest }) => { - const layoutCalled = useRef(false); - const [isGraphLocked, setIsGraphLocked] = useState(interactive); - const { initialNodes, initialEdges } = useMemo( - () => processGraph(nodes, edges, isGraphLocked), - [nodes, edges, isGraphLocked] - ); - - const [nodesState, setNodes, onNodesChange] = useNodesState(initialNodes); - const [edgesState, _setEdges, onEdgesChange] = useEdgesState(initialEdges); - - if (!layoutCalled.current) { - const { nodes: layoutedNodes } = layoutGraph(nodesState, edgesState); - setNodes(layoutedNodes); - layoutCalled.current = true; - } +export const Graph: React.FC = ({ + nodes, + edges, + interactive, + isLocked = false, + ...rest +}) => { + const backgroundId = useGeneratedHtmlId(); + const fitViewRef = useRef< + ((fitViewOptions?: FitViewOptions | undefined) => Promise) | null + >(null); + const currNodesRef = useRef([]); + const currEdgesRef = useRef([]); + const [isGraphInteractive, setIsGraphInteractive] = useState(interactive); + const [nodesState, setNodes, onNodesChange] = useNodesState>([]); + const [edgesState, setEdges, onEdgesChange] = useEdgesState>([]); + + 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); + + 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 = ({ nodes, edges, interactive, ...rest [setNodes] ); + const onInitCallback = useCallback( + (xyflow: ReactFlowInstance, Edge>) => { + 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 (
{ - 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 = ({ 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 && } - + {' '}
); @@ -173,32 +215,41 @@ const processGraph = ( return node; }); - const initialEdges: Array> = 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'; - - 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, - }, - }; - }); + const initialEdges: Array> = 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 { initialNodes, initialEdges }; }; + +const isArrayOfObjectsEqual = (x: object[], y: object[]) => + size(x) === size(y) && isEmpty(xorWith(x, y, isEqual)); diff --git a/x-pack/packages/kbn-cloud-security-posture/graph/src/components/graph/graph_popover.stories.tsx b/x-pack/packages/kbn-cloud-security-posture/graph/src/components/graph/graph_popover.stories.tsx new file mode 100644 index 0000000000000..6d5b3c1b372fc --- /dev/null +++ b/x-pack/packages/kbn-cloud-security-posture/graph/src/components/graph/graph_popover.stories.tsx @@ -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(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(() => ( + + + {}} + /> + {}} + /> + {}} + /> + + {}} /> + + + )); + + 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(() => ( + + TODO + + )); + + return useMemo( + () => ({ + onNodeClick: (e: React.MouseEvent) => 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 ( + + + {popovers?.map((popover) => popover.Popover && )} + + ); +}; + +export const GraphPopovers = Template.bind({}); diff --git a/x-pack/packages/kbn-cloud-security-posture/graph/src/components/graph/graph_popover.tsx b/x-pack/packages/kbn-cloud-security-posture/graph/src/components/graph/graph_popover.tsx new file mode 100644 index 0000000000000..570c1332a8834 --- /dev/null +++ b/x-pack/packages/kbn-cloud-security-posture/graph/src/components/graph/graph_popover.tsx @@ -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 = ({ + isOpen, + anchorElement, + closePopover, + children, + ...rest +}) => { + const { euiTheme } = useEuiTheme(); + + if (!anchorElement) { + return null; + } + + return ( + { + anchorElement.focus(); + return false; + }, + preventScrollOnFocus: true, + onClickOutside: () => { + closePopover(); + }, + }} + > + {children} + + ); +}; diff --git a/x-pack/packages/kbn-cloud-security-posture/graph/src/components/graph/use_graph_popover.tsx b/x-pack/packages/kbn-cloud-security-posture/graph/src/components/graph/use_graph_popover.tsx new file mode 100644 index 0000000000000..f5bca30d1e5ae --- /dev/null +++ b/x-pack/packages/kbn-cloud-security-posture/graph/src/components/graph/use_graph_popover.tsx @@ -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(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] + ); +}; diff --git a/x-pack/packages/kbn-cloud-security-posture/graph/src/components/index.ts b/x-pack/packages/kbn-cloud-security-posture/graph/src/components/index.ts index 5b2f8d71323bb..2b050aa55429f 100644 --- a/x-pack/packages/kbn-cloud-security-posture/graph/src/components/index.ts +++ b/x-pack/packages/kbn-cloud-security-posture/graph/src/components/index.ts @@ -6,6 +6,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, diff --git a/x-pack/packages/kbn-cloud-security-posture/graph/src/components/node/diamond_node.tsx b/x-pack/packages/kbn-cloud-security-posture/graph/src/components/node/diamond_node.tsx index f96068061a433..75ad989b625e8 100644 --- a/x-pack/packages/kbn-cloud-security-posture/graph/src/components/node/diamond_node.tsx +++ b/x-pack/packages/kbn-cloud-security-posture/graph/src/components/node/diamond_node.tsx @@ -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 = 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 = memo((props: NodeProps) => { {icon && } {interactive && ( - expandButtonClick?.(e, props)} - x={`${NODE_WIDTH - NodeButton.ExpandButtonSize}px`} - y={`${(NODE_HEIGHT - NodeButton.ExpandButtonSize) / 2 - 4}px`} - /> + <> + nodeClick?.(e, props)} /> + expandButtonClick?.(e, props, unToggleCallback)} + x={`${NODE_WIDTH - NodeExpandButton.ExpandButtonSize}px`} + y={`${(NODE_HEIGHT - NodeExpandButton.ExpandButtonSize) / 2 - 4}px`} + /> + )} = 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 = memo((props: NodeProps) => { {icon && } {interactive && ( - expandButtonClick?.(e, props)} - x={`${NODE_WIDTH - NodeButton.ExpandButtonSize / 2}px`} - y={`${(NODE_HEIGHT - NodeButton.ExpandButtonSize) / 2}px`} - /> + <> + nodeClick?.(e, props)} /> + expandButtonClick?.(e, props, unToggleCallback)} + x={`${NODE_WIDTH - NodeExpandButton.ExpandButtonSize / 2}px`} + y={`${(NODE_HEIGHT - NodeExpandButton.ExpandButtonSize) / 2}px`} + /> + )} = 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 = memo((props: NodeProps) => { {icon && } {interactive && ( - expandButtonClick?.(e, props)} - x={`${NODE_WIDTH - NodeButton.ExpandButtonSize / 2 + 2}px`} - y={`${(NODE_HEIGHT - NodeButton.ExpandButtonSize) / 2 - 2}px`} - /> + <> + nodeClick?.(e, props)} /> + expandButtonClick?.(e, props, unToggleCallback)} + x={`${NODE_WIDTH - NodeExpandButton.ExpandButtonSize / 2 + 2}px`} + y={`${(NODE_HEIGHT - NodeExpandButton.ExpandButtonSize) / 2 - 2}px`} + /> + )} , 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) => { + setIsToggled((currIsToggled) => !currIsToggled); + onClick?.(e, unToggleCallback); + }; + + return ( + + + + ); +}; + +NodeExpandButton.ExpandButtonSize = ExpandButtonSize; diff --git a/x-pack/packages/kbn-cloud-security-posture/graph/src/components/node/pentagon_node.tsx b/x-pack/packages/kbn-cloud-security-posture/graph/src/components/node/pentagon_node.tsx index f2282e9fa2d7d..f2745cef7ec80 100644 --- a/x-pack/packages/kbn-cloud-security-posture/graph/src/components/node/pentagon_node.tsx +++ b/x-pack/packages/kbn-cloud-security-posture/graph/src/components/node/pentagon_node.tsx @@ -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 = 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 = memo((props: NodeProps) => { {icon && } {interactive && ( - expandButtonClick?.(e, props)} - x={`${NODE_WIDTH - NodeButton.ExpandButtonSize / 2}px`} - y={`${(NODE_HEIGHT - NodeButton.ExpandButtonSize) / 2}px`} - /> + <> + nodeClick?.(e, props)} /> + expandButtonClick?.(e, props, unToggleCallback)} + x={`${NODE_WIDTH - NodeExpandButton.ExpandButtonSize / 2}px`} + y={`${(NODE_HEIGHT - NodeExpandButton.ExpandButtonSize) / 2}px`} + /> + )} = 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 = memo((props: NodeProps) => { {icon && } {interactive && ( - expandButtonClick?.(e, props)} - x={`${NODE_WIDTH - NodeButton.ExpandButtonSize / 4}px`} - y={`${(NODE_HEIGHT - NodeButton.ExpandButtonSize / 2) / 2}px`} - /> + <> + nodeClick?.(e, props)} /> + expandButtonClick?.(e, props, unToggleCallback)} + x={`${NODE_WIDTH - NodeExpandButton.ExpandButtonSize / 4}px`} + y={`${(NODE_HEIGHT - NodeExpandButton.ExpandButtonSize / 2) / 2}px`} + /> + )} ) => void; +} + +export const NodeButton: React.FC = ({ onClick }) => ( + + + +); + +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` + 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` - 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) => 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) => { - setIsToggled(!isToggled); - onClick?.(e); - }; - - return ( - - - - ); -}; - -NodeButton.ExpandButtonSize = ExpandButtonSize; - export const HandleStyleOverride: React.CSSProperties = { background: 'none', border: 'none', diff --git a/x-pack/packages/kbn-cloud-security-posture/graph/src/components/styles.tsx b/x-pack/packages/kbn-cloud-security-posture/graph/src/components/styles.tsx new file mode 100644 index 0000000000000..0efff1c88456c --- /dev/null +++ b/x-pack/packages/kbn-cloud-security-posture/graph/src/components/styles.tsx @@ -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['euiTheme']['colors']; + background: _EuiBackgroundColor; +} + +type IconContainerProps = EuiColorProps; + +const IconContainer = styled.div` + 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 = ({ color, background, ...rest }) => ( + + + +); + +export const ExpandPopoverListItem: React.FC< + Pick +> = (props) => { + const { euiTheme } = useEuiTheme(); + return ( + + ) : undefined + } + label={ + + {props.label} + + } + onClick={props.onClick} + /> + ); +}; diff --git a/x-pack/packages/kbn-cloud-security-posture/graph/src/components/types.ts b/x-pack/packages/kbn-cloud-security-posture/graph/src/components/types.ts index 27ec18f35f45b..328829ee3fabe 100644 --- a/x-pack/packages/kbn-cloud-security-posture/graph/src/components/types.ts +++ b/x-pack/packages/kbn-cloud-security-posture/graph/src/components/types.ts @@ -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, node: NodeProps) => void; + +export type ExpandButtonClickCallback = ( + e: React.MouseEvent, + node: NodeProps, + unToggleCallback: () => void +) => void; + export interface EntityNodeViewModel extends Record, EntityNodeDataModel, BaseNodeDataViewModel { - expandButtonClick?: (e: React.MouseEvent, node: NodeProps) => void; + expandButtonClick?: ExpandButtonClickCallback; + nodeClick?: NodeClickCallback; } export interface GroupNodeViewModel @@ -40,7 +50,7 @@ export interface LabelNodeViewModel extends Record, LabelNodeDataModel, BaseNodeDataViewModel { - expandButtonClick?: (e: React.MouseEvent, node: NodeProps) => void; + expandButtonClick?: ExpandButtonClickCallback; } export type NodeViewModel = EntityNodeViewModel | GroupNodeViewModel | LabelNodeViewModel; diff --git a/x-pack/test/cloud_security_posture_functional/es_archives/security_alerts/data.json b/x-pack/test/cloud_security_posture_functional/es_archives/security_alerts/data.json new file mode 100644 index 0000000000000..94ecc85bfd234 --- /dev/null +++ b/x-pack/test/cloud_security_posture_functional/es_archives/security_alerts/data.json @@ -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" + } + } + } +} diff --git a/x-pack/test/cloud_security_posture_functional/es_archives/security_alerts/data.json.gz b/x-pack/test/cloud_security_posture_functional/es_archives/security_alerts/data.json.gz deleted file mode 100644 index 93b2c20b81c86..0000000000000 Binary files a/x-pack/test/cloud_security_posture_functional/es_archives/security_alerts/data.json.gz and /dev/null differ diff --git a/x-pack/test/cloud_security_posture_functional/pages/alerts_flyout.ts b/x-pack/test/cloud_security_posture_functional/pages/alerts_flyout.ts index 023d4d1436b22..619990d8e8281 100644 --- a/x-pack/test/cloud_security_posture_functional/pages/alerts_flyout.ts +++ b/x-pack/test/cloud_security_posture_functional/pages/alerts_flyout.ts @@ -35,8 +35,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' )}`