[Cloud Security] [Graph Vis] Implement UI enhancements (#222830)

## Summary

Closes:
- https://github.com/elastic/kibana/issues/222367

Enhance graph visualization UI with latest Figma update.

### Screenshots

<details><summary>Popover</summary>
<img width="370" alt="Screenshot 2025-06-12 at 10 21 50"
src="https://github.com/user-attachments/assets/8060860e-5b93-4d71-b330-1920afb75c6a"
/>
</details> 

<details><summary>Controls</summary>
<img width="63" alt="Screenshot 2025-06-11 at 17 36 27"
src="https://github.com/user-attachments/assets/78b93528-1821-4d77-9536-eb88fd68e3dc"
/>
</details>

<details><summary>Edges - new color, default to solid stroke, no start
marker</summary>
<img width="1078" alt="Screenshot 2025-06-12 at 17 22 25"
src="https://github.com/user-attachments/assets/53b46adb-2b79-4c65-ba48-9c74826f2fb0"
/>
</details>

### Videos

#### Snap nodes to 10px grid


https://github.com/user-attachments/assets/fc732784-1e3b-4277-9bf3-d7a6c9b43f88

#### Zoom / Fit to view transition


https://github.com/user-attachments/assets/8a7627c0-7c00-4321-a05c-ea9fa1910002

### Definition of done

- [x] Update popover container
- [x] Update popover action icons
- [x] Update popover action texts
- [x] Update fit-to-view icons
- [x] Update relationship arrow colors
- [x] Check if we can increase the current nodes limit - increased to
300
- [x] Snap to Grid: Enable nodes to snap by 10px
- [x] Implement smooth zoom and fit-to-view transitions (200ms duration)
- [x] Remove edge's start marker
- [x] Remove failure representation according to `event.outcome`

### How to test - In Kibana

1. Add this line to your `kibana.dev.yml`:

    ```yml
    uiSettings.overrides.securitySolution:enableGraphVisualization: true
    ```

2. Then, run these 2 commands while running Kibana with `yarn start
--no-base-path`. This is for setting up the local env with data.

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

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

3. Finally in Kibana, go to Alerts and update the date-picker to include
data from a year ago. Then check one of the alerts details opening the
right-side flyout and find the "Graph preview" section in it.

### How to test - In Storybook

1. Run in terminal:

    ```bash
    yarn storybook cloud_security_posture_graph
    ```

2. Open [http://localhost:9001/](http://localhost:9001/).

### Checklist

- [x] Any text added follows [EUI's writing
guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses
sentence case text and includes [i18n
support](https://github.com/elastic/kibana/blob/main/src/platform/packages/shared/kbn-i18n/README.md)
- [ ]
[Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html)
was added for features that require explanation or tutorials
- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
- [ ] If a plugin configuration key changed, check if it needs to be
allowlisted in the cloud and added to the [docker
list](https://github.com/elastic/kibana/blob/main/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker)
- [x] This was checked for breaking HTTP API changes, and any breaking
changes have been approved by the breaking-change committee. The
`release_note:breaking` label should be applied in these situations.
- [ ] [Flaky Test
Runner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1) was
used on any tests changed
- [x] The PR description includes the appropriate Release Notes section,
and the correct `release_note:*` label is applied per the
[guidelines](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)

### Identify risks

No risk, feature is gated under the
`securitySolution:enableGraphVisualization` UI setting.

---------

Co-authored-by: Kfir Peled <61654899+kfirpeled@users.noreply.github.com>
Co-authored-by: Kfir Peled <kfir.peled@elastic.co>
This commit is contained in:
Alberto Blázquez 2025-06-24 06:46:38 +02:00 committed by GitHub
parent 1d6a439448
commit 9b5cf91062
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
30 changed files with 452 additions and 268 deletions

View file

@ -42,12 +42,19 @@ export const graphResponseSchema = () =>
),
});
export const colorSchema = schema.oneOf([
export const nodeColorSchema = schema.oneOf([
schema.literal('primary'),
schema.literal('danger'),
schema.literal('warning'),
]);
export const edgeColorSchema = schema.oneOf([
schema.literal('primary'),
schema.literal('danger'),
schema.literal('warning'),
schema.literal('subdued'),
]);
export const nodeShapeSchema = schema.oneOf([
schema.literal('hexagon'),
schema.literal('pentagon'),
@ -67,7 +74,7 @@ export const nodeBaseDataSchema = schema.object({
export const entityNodeDataSchema = schema.allOf([
nodeBaseDataSchema,
schema.object({
color: colorSchema,
color: nodeColorSchema,
shape: schema.oneOf([
schema.literal('hexagon'),
schema.literal('pentagon'),
@ -90,7 +97,7 @@ export const labelNodeDataSchema = schema.allOf([
schema.object({
shape: schema.literal('label'),
parentId: schema.maybe(schema.string()),
color: colorSchema,
color: nodeColorSchema,
}),
]);
@ -98,6 +105,6 @@ export const edgeDataSchema = schema.object({
id: schema.string(),
source: schema.string(),
target: schema.string(),
color: colorSchema,
color: edgeColorSchema,
type: schema.maybe(schema.oneOf([schema.literal('solid'), schema.literal('dashed')])),
});

View file

@ -8,13 +8,14 @@
import type { TypeOf } from '@kbn/config-schema';
import type { BoolQuery } from '@kbn/es-query';
import {
colorSchema,
edgeColorSchema,
edgeDataSchema,
entityNodeDataSchema,
graphRequestSchema,
graphResponseSchema,
groupNodeDataSchema,
labelNodeDataSchema,
nodeColorSchema,
nodeShapeSchema,
} from '../../schema/graph/v1';
@ -25,7 +26,8 @@ export type GraphResponse = Omit<TypeOf<typeof graphResponseSchema>, 'messages'>
messages?: ApiMessageCode[];
};
export type Color = typeof colorSchema.type;
export type EdgeColor = typeof edgeColorSchema.type;
export type NodeColor = typeof nodeColorSchema.type;
export type NodeShape = TypeOf<typeof nodeShapeSchema>;

View file

@ -7489,14 +7489,6 @@
"securitySolutionPackages.csp.graph.controls.toggleSearchBar.tour.title": "Affinez votre vue avec la recherche",
"securitySolutionPackages.csp.graph.controls.zoomIn": "Zoom avant",
"securitySolutionPackages.csp.graph.controls.zoomOut": "Zoom arrière",
"securitySolutionPackages.csp.graph.graphLabelExpandPopover.hideEventsWithThisAction": "Masquer les événements avec cette action",
"securitySolutionPackages.csp.graph.graphLabelExpandPopover.showEventsWithThisAction": "Afficher les événements avec cette action",
"securitySolutionPackages.csp.graph.graphNodeExpandPopover.hideActionsByEntity": "Masquer les actions par cette entité",
"securitySolutionPackages.csp.graph.graphNodeExpandPopover.hideActionsOnEntity": "Masquer les actions sur cette entité",
"securitySolutionPackages.csp.graph.graphNodeExpandPopover.hideRelatedEntities": "Masquer les entités liées",
"securitySolutionPackages.csp.graph.graphNodeExpandPopover.showActionsByEntity": "Afficher les actions par cette entité",
"securitySolutionPackages.csp.graph.graphNodeExpandPopover.showActionsOnEntity": "Afficher les actions sur cette entité",
"securitySolutionPackages.csp.graph.graphNodeExpandPopover.showRelatedEntities": "Afficher les entités liées",
"securitySolutionPackages.csp.graph.investigation.errorBuildingQuery": "Impossible d'extraire les résultats de recherche",
"securitySolutionPackages.csp.graph.investigation.warningNegatedFilterContent": "Un ou plusieurs filtres sont annulés et ne renverront donc peut-être pas les résultats attendus.",
"securitySolutionPackages.csp.graph.investigation.warningNegatedFilterTitle": "Filtres annulés",

View file

@ -7489,14 +7489,6 @@
"securitySolutionPackages.csp.graph.controls.toggleSearchBar.tour.title": "検索でビューを絞り込む",
"securitySolutionPackages.csp.graph.controls.zoomIn": "ズームイン",
"securitySolutionPackages.csp.graph.controls.zoomOut": "ズームアウト",
"securitySolutionPackages.csp.graph.graphLabelExpandPopover.hideEventsWithThisAction": "このアクションのイベントを非表示",
"securitySolutionPackages.csp.graph.graphLabelExpandPopover.showEventsWithThisAction": "このアクションのイベントを表示",
"securitySolutionPackages.csp.graph.graphNodeExpandPopover.hideActionsByEntity": "このエンティティによるアクションを非表示",
"securitySolutionPackages.csp.graph.graphNodeExpandPopover.hideActionsOnEntity": "このエンティティに対するアクションを非表示",
"securitySolutionPackages.csp.graph.graphNodeExpandPopover.hideRelatedEntities": "関連するエンティティを非表示",
"securitySolutionPackages.csp.graph.graphNodeExpandPopover.showActionsByEntity": "このエンティティによるアクションを表示",
"securitySolutionPackages.csp.graph.graphNodeExpandPopover.showActionsOnEntity": "このエンティティに対するアクションを表示",
"securitySolutionPackages.csp.graph.graphNodeExpandPopover.showRelatedEntities": "関連するエンティティを表示",
"securitySolutionPackages.csp.graph.investigation.errorBuildingQuery": "検索結果を取得できません",
"securitySolutionPackages.csp.graph.investigation.warningNegatedFilterContent": "1つ以上のフィルターが否定され、意図した結果が返されない場合があります。",
"securitySolutionPackages.csp.graph.investigation.warningNegatedFilterTitle": "フィルターが否定されました",

View file

@ -7484,14 +7484,6 @@
"securitySolutionPackages.csp.graph.controls.toggleSearchBar.tour.title": "通过搜索优化您的视图",
"securitySolutionPackages.csp.graph.controls.zoomIn": "放大",
"securitySolutionPackages.csp.graph.controls.zoomOut": "缩小",
"securitySolutionPackages.csp.graph.graphLabelExpandPopover.hideEventsWithThisAction": "通过此操作隐藏事件",
"securitySolutionPackages.csp.graph.graphLabelExpandPopover.showEventsWithThisAction": "通过此操作显示事件",
"securitySolutionPackages.csp.graph.graphNodeExpandPopover.hideActionsByEntity": "隐藏此实体执行的操作",
"securitySolutionPackages.csp.graph.graphNodeExpandPopover.hideActionsOnEntity": "隐藏对此实体执行的操作",
"securitySolutionPackages.csp.graph.graphNodeExpandPopover.hideRelatedEntities": "隐藏相关实体",
"securitySolutionPackages.csp.graph.graphNodeExpandPopover.showActionsByEntity": "显示此实体执行的操作",
"securitySolutionPackages.csp.graph.graphNodeExpandPopover.showActionsOnEntity": "显示对此实体执行的操作",
"securitySolutionPackages.csp.graph.graphNodeExpandPopover.showRelatedEntities": "显示相关实体",
"securitySolutionPackages.csp.graph.investigation.errorBuildingQuery": "无法检索搜索结果",
"securitySolutionPackages.csp.graph.investigation.warningNegatedFilterContent": "一个或多个筛选已作废,可能无法返回预期结果。",
"securitySolutionPackages.csp.graph.investigation.warningNegatedFilterTitle": "筛选已作废",

View file

@ -0,0 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7.5393 11.7311V4.26895L6.29822 5.63447C6.11561 5.82722 5.81956 5.82722 5.63695 5.63447C5.45435 5.44173 5.45435 5.12923 5.63695 4.93649L7.0081 3.43367C7.55591 2.85544 8.44409 2.85544 8.9919 3.43367L10.363 4.93649C10.5457 5.12923 10.5457 5.44173 10.363 5.63447C10.1804 5.82722 9.88439 5.82722 9.70178 5.63447L8.47447 4.28348V11.7165L9.70178 10.3655C9.88439 10.1728 10.1804 10.1728 10.363 10.3655C10.5457 10.5583 10.5457 10.8708 10.363 11.0635L8.9919 12.5663C8.44409 13.1446 7.55591 13.1446 7.0081 12.5663L5.63695 11.0635C5.45435 10.8708 5.45435 10.5583 5.63695 10.3655C5.81956 10.1728 6.11561 10.1728 6.29822 10.3655L7.5393 11.7311Z" fill="#1D2A3E"/>
<path d="M12.7311 8.4607H3.26895L4.63447 9.70178C4.82722 9.88439 4.82722 10.1804 4.63447 10.363C4.44173 10.5457 4.12923 10.5457 3.93649 10.363L2.43367 8.9919C1.85544 8.44409 1.85544 7.55591 2.43367 7.0081L3.93649 5.63695C4.12923 5.45435 4.44173 5.45435 4.63447 5.63695C4.82722 5.81956 4.82722 6.11561 4.63447 6.29822L3.28348 7.52553H12.7165L11.3655 6.29822C11.1728 6.11561 11.1728 5.81956 11.3655 5.63695C11.5583 5.45435 11.8708 5.45435 12.0635 5.63695L13.5663 7.0081C14.1446 7.55591 14.1446 8.44409 13.5663 8.9919L12.0635 10.363C11.8708 10.5457 11.5583 10.5457 11.3655 10.363C11.1728 10.1804 11.1728 9.88439 11.3655 9.70178L12.7311 8.4607Z" fill="#1D2A3E"/>
<path d="M14 1C15.1046 1 16 1.89543 16 3V13L15.9893 13.2041C15.887 14.2128 15.0357 15 14 15H2L1.7959 14.9893C0.854346 14.8938 0.1062 14.1457 0.0107422 13.2041L0 13V3C0 1.96435 0.787223 1.113 1.7959 1.01074L2 1H14ZM2 2C1.44772 2 1 2.44772 1 3V13C1 13.5523 1.44772 14 2 14H14C14.5523 14 15 13.5523 15 13V3C15 2.44772 14.5523 2 14 2H2Z" fill="#1D2A3E"/>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View file

@ -18,4 +18,4 @@ export const SHOW_SEARCH_BAR_BUTTON_TOUR_STORAGE_KEY =
export const TOGGLE_SEARCH_BAR_STORAGE_KEY =
'securitySolution.graphInvestigation:toggleSearchBarState' as const;
export const GRAPH_NODES_LIMIT = 100;
export const GRAPH_NODES_LIMIT = 300;

View file

@ -133,7 +133,7 @@ export const Actions = ({
: undefined;
return (
<EuiFlexGroup direction="column" gutterSize={'none'} {...props}>
<EuiFlexGroup direction="column" gutterSize="none" {...props}>
{showToggleSearch && (
<EuiFlexItem grow={false}>
<EuiTourStep
@ -154,6 +154,7 @@ export const Actions = ({
css={[
css`
position: relative;
overflow: visible;
width: 40px;
`,
!searchToggled
@ -163,6 +164,11 @@ export const Actions = ({
`
: undefined,
]}
contentProps={{
css: css`
position: initial;
`,
}}
minWidth={false}
size="m"
aria-label={toggleSearchBarTooltip}
@ -209,7 +215,11 @@ export const Actions = ({
</EuiTourStep>
</EuiFlexItem>
)}
{showToggleSearch && showInvestigateInTimeline && <EuiHorizontalRule margin="xs" />}
{showToggleSearch && showInvestigateInTimeline && (
<EuiFlexItem grow={false}>
<EuiHorizontalRule margin="xs" />
</EuiFlexItem>
)}
{showInvestigateInTimeline && (
<EuiFlexItem grow={false}>
<EuiToolTip content={investigateInTimelineTooltip} position="left">

View file

@ -10,7 +10,8 @@ import {
EuiButtonIcon,
EuiFlexGroup,
EuiFlexItem,
EuiSpacer,
EuiHorizontalRule,
EuiIcon,
useEuiTheme,
type CommonProps,
} from '@elastic/eui';
@ -23,6 +24,7 @@ import {
GRAPH_CONTROLS_ZOOM_IN_ID,
GRAPH_CONTROLS_ZOOM_OUT_ID,
} from '../test_ids';
import fitToViewIcon from '../../assets/icons/fit_to_view.svg';
const selector = (s: ReactFlowState) => ({
minZoomReached: s.transform[2] <= s.minZoom,
@ -57,6 +59,8 @@ const CenterLabel = i18n.translate('securitySolutionPackages.csp.graph.controls.
defaultMessage: 'Center',
});
const fitToViewIconFn = () => <EuiIcon type={fitToViewIcon} size="m" color="text" />;
export const Controls = ({
showZoom = true,
showFitView = true,
@ -73,12 +77,12 @@ export const Controls = ({
const { maxZoomReached, minZoomReached } = useStore(selector);
const onZoomInHandler = () => {
zoomIn();
zoomIn({ duration: fitViewOptions?.duration });
onZoomIn?.();
};
const onZoomOutHandler = () => {
zoomOut();
zoomOut({ duration: fitViewOptions?.duration });
onZoomOut?.();
};
@ -88,10 +92,13 @@ export const Controls = ({
};
const btnCss = css`
border-radius: 0;
`;
const groupCss = css`
border: ${euiTheme.border.thin};
border-radius: ${euiTheme.border.radius.medium};
background-color: ${euiTheme.colors.backgroundBasePlain};
box-sizing: content-box;
`;
if (!showZoom && !showCenter && !showFitView) {
@ -99,46 +106,54 @@ export const Controls = ({
}
return (
<EuiFlexGroup direction="column" gutterSize={'none'} {...props}>
<EuiFlexGroup direction="column" gutterSize="none" css={groupCss} {...props}>
{showZoom && (
<EuiFlexItem grow={false} css={btnCss}>
<>
<EuiFlexItem grow={false}>
<EuiButtonIcon
iconType="plusInCircle"
aria-label={ZoomInLabel}
size="m"
color="text"
data-test-subj={GRAPH_CONTROLS_ZOOM_IN_ID}
disabled={maxZoomReached}
css={btnCss}
onClick={onZoomInHandler}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonIcon
iconType="minusInCircle"
aria-label={ZoomOutLabel}
size="m"
color="text"
data-test-subj={GRAPH_CONTROLS_ZOOM_OUT_ID}
disabled={minZoomReached}
css={btnCss}
onClick={onZoomOutHandler}
/>
</EuiFlexItem>
</>
)}
{showCenter && (
<EuiFlexItem grow={false}>
{showZoom ? <EuiHorizontalRule size="full" margin="none" /> : null}
<EuiButtonIcon
iconType="plusInCircle"
aria-label={ZoomInLabel}
iconType="bullseye"
aria-label={CenterLabel}
size="m"
color="text"
data-test-subj={GRAPH_CONTROLS_ZOOM_IN_ID}
disabled={maxZoomReached}
onClick={onZoomInHandler}
/>
<EuiButtonIcon
iconType="minusInCircle"
aria-label={ZoomOutLabel}
size="m"
color="text"
data-test-subj={GRAPH_CONTROLS_ZOOM_OUT_ID}
disabled={minZoomReached}
onClick={onZoomOutHandler}
data-test-subj={GRAPH_CONTROLS_CENTER_ID}
css={btnCss}
onClick={() => onCenter?.()}
/>
</EuiFlexItem>
)}
{showZoom && showCenter && <EuiSpacer size="xs" />}
{showCenter && (
<EuiButtonIcon
iconType="bullseye"
aria-label={CenterLabel}
size="m"
color="text"
data-test-subj={GRAPH_CONTROLS_CENTER_ID}
css={btnCss}
onClick={() => onCenter?.()}
/>
)}
{(showZoom || showCenter) && showFitView && <EuiSpacer size="xs" />}
{showFitView && (
<EuiFlexItem grow={false}>
{showZoom || showCenter ? <EuiHorizontalRule size="full" margin="none" /> : null}
<EuiButtonIcon
iconType="continuityWithin"
iconType={fitToViewIconFn}
aria-label={FitViewLabel}
size="m"
color="text"

View file

@ -64,6 +64,7 @@ const edgeTypes = {
const Template = (args: EdgeViewModel) => {
const isArrayOfObjectsEqual = (x: object[], y: object[]) =>
size(x) === size(y) && isEmpty(xorWith(x, y, isEqual));
const edgeData = pick(args, ['id', 'label', 'interactive', 'source', 'target', 'color', 'type']);
const nodes = useMemo(
() => [
@ -86,11 +87,14 @@ const Template = (args: EdgeViewModel) => {
{
id: args.id,
type: 'label',
data: pick(args, ['id', 'label', 'interactive', 'source', 'target', 'color', 'type']),
data: {
...edgeData,
color: edgeData.color === 'subdued' ? 'primary' : edgeData.color,
},
position: { x: 230, y: 6 },
},
],
[args]
[args, edgeData]
);
const edges = useMemo(
@ -172,7 +176,7 @@ export default {
render: Template,
argTypes: {
color: {
options: ['primary', 'danger', 'warning'],
options: ['primary', 'danger', 'warning', 'subdued'],
control: { type: 'radio' },
},
type: {

View file

@ -7,10 +7,10 @@
import React, { memo } from 'react';
import { BaseEdge, getSmoothStepPath } from '@xyflow/react';
import { useEuiTheme } from '@elastic/eui';
import type { EdgeProps, EdgeViewModel } from '../types';
import { getShapeHandlePosition } from './utils';
import { getMarkerStart, getMarkerEnd } from './markers';
import { getMarkerEnd } from './markers';
import { useEdgeColor } from './styles';
type EdgeColor = EdgeViewModel['color'];
@ -32,14 +32,9 @@ export const DefaultEdge = memo(
targetPosition,
data,
}: EdgeProps) => {
const { euiTheme } = useEuiTheme();
const color: EdgeColor = data?.color || 'primary';
const sourceMargin = getShapeHandlePosition(data?.sourceShape);
const targetMargin = getShapeHandlePosition(data?.targetShape);
const markerStart =
!data?.sourceShape || !NODES_WITHOUT_MARKER.includes(data?.sourceShape)
? getMarkerStart(color)
: undefined;
const markerEnd =
!data?.targetShape || !NODES_WITHOUT_MARKER.includes(data?.targetShape)
? getMarkerEnd(color)
@ -69,11 +64,10 @@ export const DefaultEdge = memo(
path={edgePath}
interactionWidth={0}
style={{
stroke: euiTheme.colors[color],
// Defaults to dashed when type is not available
...(!data?.type || data?.type === 'dashed' ? dashedStyle : {}),
stroke: useEdgeColor(color),
// Defaults to solid when type is not available
...(data?.type === 'dashed' ? dashedStyle : {}),
}}
markerStart={markerStart}
markerEnd={markerEnd}
/>
</>

View file

@ -6,7 +6,7 @@
*/
import React from 'react';
import { useEuiTheme } from '@elastic/eui';
import { useEdgeColor } from './styles';
const getArrowPoints = (width: number, height: number): string => {
return `${-width},${-height} 0,0 ${-width},${height} ${-width},${-height}`;
@ -48,29 +48,11 @@ const ArrowMarker = ({
);
};
const DotMarker = ({ id, color }: { id: string; color: string }) => {
return (
<marker id={id} markerWidth="6" markerHeight="6" refX="0.1" refY="3" orient="auto">
<circle cx="3" cy="3" r="3" fill={color} />
</marker>
);
};
const MarkerStartType = {
primary: 'url(#dotPrimary)',
danger: 'url(#dotDanger)',
warning: 'url(#dotWarning)',
};
const MarkerEndType = {
primary: 'url(#arrowPrimary)',
danger: 'url(#arrowDanger)',
subdued: 'url(#arrowSubdued)',
warning: 'url(#arrowWarning)',
};
export const getMarkerStart = (color: string) => {
const colorKey = color as keyof typeof MarkerStartType;
return MarkerStartType[colorKey] ?? MarkerStartType.primary;
danger: 'url(#arrowDanger)',
};
export const getMarkerEnd = (color: string) => {
@ -79,17 +61,13 @@ export const getMarkerEnd = (color: string) => {
};
export const SvgDefsMarker = () => {
const { euiTheme } = useEuiTheme();
return (
<svg css={{ position: 'absolute', width: 0, height: 0 }}>
<defs>
<ArrowMarker id="arrowPrimary" color={euiTheme.colors.primary} width={6} height={4.8} />
<ArrowMarker id="arrowDanger" color={euiTheme.colors.danger} width={6} height={4.8} />
<ArrowMarker id="arrowWarning" color={euiTheme.colors.warning} width={6} height={4.8} />
<DotMarker id="dotPrimary" color={euiTheme.colors.primary} />
<DotMarker id="dotDanger" color={euiTheme.colors.danger} />
<DotMarker id="dotWarning" color={euiTheme.colors.warning} />
<ArrowMarker id="arrowPrimary" color={useEdgeColor('primary')} width={6} height={4.8} />
<ArrowMarker id="arrowSubdued" color={useEdgeColor('subdued')} width={6} height={4.8} />
<ArrowMarker id="arrowWarning" color={useEdgeColor('warning')} width={6} height={4.8} />
<ArrowMarker id="arrowDanger" color={useEdgeColor('danger')} width={6} height={4.8} />
</defs>
</svg>
);

View file

@ -14,6 +14,7 @@ import {
type EuiTextProps,
type _EuiBackgroundColor,
} from '@elastic/eui';
import type { EdgeViewModel } from '../types';
export const EdgeLabelHeight = 24;
export const EdgeLabelWidth = 100;
@ -87,3 +88,18 @@ export const EdgeLabelOnHover = styled(EdgeLabel)<EdgeLabelProps & EdgeLabelCont
opacity: 1; /* Show on hover */
}
`;
export const useEdgeColor = (edgeColor: EdgeViewModel['color']) => {
const { euiTheme } = useEuiTheme();
switch (edgeColor) {
case 'danger':
return euiTheme.colors.danger;
case 'warning':
return euiTheme.colors.warning;
case 'primary':
return euiTheme.colors.primary;
case 'subdued':
default:
return euiTheme.colors.borderBaseFormsControl;
}
};

View file

@ -9,3 +9,5 @@
* Whether or not to instruct the graph component to only render nodes and edges that would be visible in the viewport.
*/
export const ONLY_RENDER_VISIBLE_ELEMENTS = true as const;
export const GRID_SIZE = 10; // in px

View file

@ -31,7 +31,7 @@ import {
import { layoutGraph } from './layout_graph';
import { DefaultEdge } from '../edge';
import type { EdgeViewModel, NodeViewModel } from '../types';
import { ONLY_RENDER_VISIBLE_ELEMENTS } from './constants';
import { ONLY_RENDER_VISIBLE_ELEMENTS, GRID_SIZE } from './constants';
import '@xyflow/react/dist/style.css';
import { Controls } from '../controls/controls';
@ -74,6 +74,10 @@ const edgeTypes = {
default: DefaultEdge,
};
const fitViewOptions: FitViewOptions<Node> = {
duration: 200,
};
/**
* Graph component renders a graph visualization using ReactFlow.
* It takes nodes and edges as input and provides interactive controls
@ -114,7 +118,7 @@ export const Graph = memo<GraphProps>(
currNodesRef.current = nodes;
currEdgesRef.current = edges;
setTimeout(() => {
fitViewRef.current?.();
fitViewRef.current?.(fitViewOptions);
}, 30);
}
}, [nodes, edges, setNodes, setEdges, isGraphInteractive]);
@ -150,7 +154,7 @@ export const Graph = memo<GraphProps>(
edgesFocusable={false}
onlyRenderVisibleElements={ONLY_RENDER_VISIBLE_ELEMENTS}
snapToGrid={true} // Snap to grid is enabled to avoid sub-pixel positioning
snapGrid={[1, 1]}
snapGrid={[GRID_SIZE, GRID_SIZE]} // Snap nodes to a 10px grid
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
proOptions={{ hideAttribution: true }}
@ -165,7 +169,7 @@ export const Graph = memo<GraphProps>(
>
{interactive && (
<Panel position="bottom-right">
<Controls showCenter={false} />
<Controls fitViewOptions={fitViewOptions} showCenter={false} />
</Panel>
)}
{children}

View file

@ -7,8 +7,7 @@
import React, { type PropsWithChildren } from 'react';
import type { CommonProps, EuiWrappingPopoverProps } from '@elastic/eui';
import { EuiWrappingPopover, useEuiTheme } from '@elastic/eui';
import { css } from '@emotion/react';
import { EuiWrappingPopover } from '@elastic/eui';
export interface GraphPopoverProps
extends PropsWithChildren,
@ -29,8 +28,6 @@ export const GraphPopover = ({
children,
...rest
}: GraphPopoverProps) => {
const { euiTheme } = useEuiTheme();
if (!anchorElement) {
return null;
}
@ -38,16 +35,6 @@ export const GraphPopover = ({
return (
<EuiWrappingPopover
{...rest}
panelProps={{
css: css`
.euiPopover__arrow {
--euiPopoverBackgroundColor: ${euiTheme.colors?.body};
}
background-color: ${euiTheme.colors?.body};
`,
}}
color={euiTheme.colors?.body}
isOpen={isOpen}
closePopover={closePopover}
button={anchorElement}

View file

@ -87,7 +87,7 @@ const showActionsByNode = (container: HTMLElement, nodeId: string) => {
expandNode(container, nodeId);
const btn = screen.getByTestId(GRAPH_NODE_POPOVER_SHOW_ACTIONS_BY_ITEM_ID);
expect(btn).toHaveTextContent('Show actions by this entity');
expect(btn).toHaveTextContent("Show this entity's actions");
btn.click();
};
@ -95,7 +95,7 @@ const hideActionsByNode = (container: HTMLElement, nodeId: string) => {
expandNode(container, nodeId);
const hideBtn = screen.getByTestId(GRAPH_NODE_POPOVER_SHOW_ACTIONS_BY_ITEM_ID);
expect(hideBtn).toHaveTextContent('Hide actions by this entity');
expect(hideBtn).toHaveTextContent("Hide this entity's actions");
hideBtn.click();
};
@ -254,7 +254,7 @@ describe('GraphInvestigation Component', () => {
expandNode(container, 'admin@example.com');
expect(getByTestId(GRAPH_NODE_POPOVER_SHOW_ACTIONS_BY_ITEM_ID)).toHaveTextContent(
'Show actions by this entity'
"Show this entity's actions"
);
});
@ -272,7 +272,7 @@ describe('GraphInvestigation Component', () => {
expandNode(container, 'admin@example.com');
expect(getByTestId(GRAPH_NODE_POPOVER_SHOW_ACTIONS_BY_ITEM_ID)).toHaveTextContent(
'Show actions by this entity'
"Show this entity's actions"
);
});
});

View file

@ -68,17 +68,17 @@ export const ListGroupGraphPopover = memo<ListGroupGraphPopoverProps>(
return (
<GraphPopover
panelPaddingSize="s"
panelPaddingSize="none"
anchorPosition="rightCenter"
isOpen={isOpen}
anchorElement={anchorElement}
closePopover={closePopover}
data-test-subj={testSubject}
>
<EuiListGroup gutterSize="none" bordered={false} flush={true}>
<EuiListGroup gutterSize="none" bordered={false} flush={true} size="l">
{listItems.map((item, index) => {
if (item.type === 'separator') {
return <EuiHorizontalRule key={index} margin="xs" />;
return <EuiHorizontalRule key={index} margin="none" size="full" />;
}
return (
<ExpandPopoverListItem

View file

@ -90,20 +90,20 @@ export const useEntityNodeExpandPopover = (
return [
{
type: 'item',
iconType: 'users',
iconType: 'sortRight',
testSubject: GRAPH_NODE_POPOVER_SHOW_ACTIONS_BY_ITEM_ID,
label:
actionsByEntityAction === 'show'
? i18n.translate(
'securitySolutionPackages.csp.graph.graphNodeExpandPopover.showActionsByEntity',
'securitySolutionPackages.csp.graph.graphNodeExpandPopover.showThisEntitysActions',
{
defaultMessage: 'Show actions by this entity',
defaultMessage: "Show this entity's actions",
}
)
: i18n.translate(
'securitySolutionPackages.csp.graph.graphNodeExpandPopover.hideActionsByEntity',
'securitySolutionPackages.csp.graph.graphNodeExpandPopover.hideThisEntitysActions',
{
defaultMessage: 'Hide actions by this entity',
defaultMessage: "Hide this entity's actions",
}
),
onClick: () => {
@ -112,20 +112,20 @@ export const useEntityNodeExpandPopover = (
},
{
type: 'item',
iconType: 'storage',
iconType: 'sortLeft',
testSubject: GRAPH_NODE_POPOVER_SHOW_ACTIONS_ON_ITEM_ID,
label:
actionsOnEntityAction === 'show'
? i18n.translate(
'securitySolutionPackages.csp.graph.graphNodeExpandPopover.showActionsOnEntity',
'securitySolutionPackages.csp.graph.graphNodeExpandPopover.showActionsDoneToThisEntity',
{
defaultMessage: 'Show actions on this entity',
defaultMessage: 'Show actions done to this entity',
}
)
: i18n.translate(
'securitySolutionPackages.csp.graph.graphNodeExpandPopover.hideActionsOnEntity',
'securitySolutionPackages.csp.graph.graphNodeExpandPopover.hideActionsDoneToThisEntity',
{
defaultMessage: 'Hide actions on this entity',
defaultMessage: 'Hide actions done to this entity',
}
),
onClick: () => {
@ -134,20 +134,20 @@ export const useEntityNodeExpandPopover = (
},
{
type: 'item',
iconType: 'visTagCloud',
iconType: 'analyzeEvent',
testSubject: GRAPH_NODE_POPOVER_SHOW_RELATED_ITEM_ID,
label:
relatedEntitiesAction === 'show'
? i18n.translate(
'securitySolutionPackages.csp.graph.graphNodeExpandPopover.showRelatedEntities',
{
defaultMessage: 'Show related entities',
defaultMessage: 'Show related events',
}
)
: i18n.translate(
'securitySolutionPackages.csp.graph.graphNodeExpandPopover.hideRelatedEntities',
{
defaultMessage: 'Hide related entities',
defaultMessage: 'Hide related events',
}
),
onClick: () => {

View file

@ -66,17 +66,17 @@ export const useLabelNodeExpandPopover = (
return [
{
type: 'item',
iconType: 'users',
iconType: 'analyzeEvent',
testSubject: GRAPH_LABEL_EXPAND_POPOVER_SHOW_EVENTS_WITH_THIS_ACTION_ITEM_ID,
label:
eventsWithThisActionToggleAction === 'show'
? i18n.translate(
'securitySolutionPackages.csp.graph.graphLabelExpandPopover.showEventsWithThisAction',
{ defaultMessage: 'Show events with this action' }
'securitySolutionPackages.csp.graph.graphLabelExpandPopover.showRelatedEvents',
{ defaultMessage: 'Show related events' }
)
: i18n.translate(
'securitySolutionPackages.csp.graph.graphLabelExpandPopover.hideEventsWithThisAction',
{ defaultMessage: 'Hide events with this action' }
'securitySolutionPackages.csp.graph.graphLabelExpandPopover.hideRelatedEvents',
{ defaultMessage: 'Hide related events' }
),
onClick: () => {
onShowEventsWithThisActionClick(node, eventsWithThisActionToggleAction);

View file

@ -331,6 +331,91 @@ export const GroupWithWarningAPIMock: Story = {
},
};
export const GroupWithAlertAPIMock: Story = {
args: {
...meta.args,
nodes: [
{
id: 'grp(a(admin@example.com)-b(projects/your-project-id/roles/customRole))',
shape: 'group',
},
{
id: 'admin@example.com',
color: 'primary',
shape: 'ellipse',
icon: 'user',
},
{
id: 'projects/your-project-id/roles/customRole',
color: 'primary',
shape: 'hexagon',
},
{
id: 'a(admin@example.com)-b(projects/your-project-id/roles/customRole)label(google.iam.admin.v1.CreateRole)',
label: 'google.iam.admin.v1.CreateRole',
color: 'danger',
shape: 'label',
parentId: 'grp(a(admin@example.com)-b(projects/your-project-id/roles/customRole))',
},
{
id: 'a(admin@example.com)-b(projects/your-project-id/roles/customRole)label(google.iam.admin.v1.UpdateRole)',
label: 'google.iam.admin.v1.UpdateRole',
color: 'primary',
shape: 'label',
parentId: 'grp(a(admin@example.com)-b(projects/your-project-id/roles/customRole))',
},
],
edges: [
{
id: 'a(admin@example.com)-b(grp(a(admin@example.com)-b(projects/your-project-id/roles/customRole)))',
source: 'admin@example.com',
target: 'grp(a(admin@example.com)-b(projects/your-project-id/roles/customRole))',
color: 'danger',
type: 'solid',
},
{
id: 'a(grp(a(admin@example.com)-b(projects/your-project-id/roles/customRole)))-b(projects/your-project-id/roles/customRole)',
source: 'grp(a(admin@example.com)-b(projects/your-project-id/roles/customRole))',
target: 'projects/your-project-id/roles/customRole',
color: 'danger',
type: 'solid',
},
{
id: 'a(grp(a(admin@example.com)-b(projects/your-project-id/roles/customRole)))-b(a(admin@example.com)-b(projects/your-project-id/roles/customRole)label(google.iam.admin.v1.CreateRole))',
source: 'grp(a(admin@example.com)-b(projects/your-project-id/roles/customRole))',
target:
'a(admin@example.com)-b(projects/your-project-id/roles/customRole)label(google.iam.admin.v1.CreateRole)',
color: 'danger',
type: 'solid',
},
{
id: 'a(a(admin@example.com)-b(projects/your-project-id/roles/customRole)label(google.iam.admin.v1.CreateRole))-b(grp(a(admin@example.com)-b(projects/your-project-id/roles/customRole)))',
source:
'a(admin@example.com)-b(projects/your-project-id/roles/customRole)label(google.iam.admin.v1.CreateRole)',
target: 'grp(a(admin@example.com)-b(projects/your-project-id/roles/customRole))',
color: 'danger',
type: 'solid',
},
{
id: 'a(grp(a(admin@example.com)-b(projects/your-project-id/roles/customRole)))-b(a(admin@example.com)-b(projects/your-project-id/roles/customRole)label(google.iam.admin.v1.UpdateRole))',
source: 'grp(a(admin@example.com)-b(projects/your-project-id/roles/customRole))',
target:
'a(admin@example.com)-b(projects/your-project-id/roles/customRole)label(google.iam.admin.v1.UpdateRole)',
color: 'subdued',
type: 'solid',
},
{
id: 'a(a(admin@example.com)-b(projects/your-project-id/roles/customRole)label(google.iam.admin.v1.UpdateRole))-b(grp(a(admin@example.com)-b(projects/your-project-id/roles/customRole)))',
source:
'a(admin@example.com)-b(projects/your-project-id/roles/customRole)label(google.iam.admin.v1.UpdateRole)',
target: 'grp(a(admin@example.com)-b(projects/your-project-id/roles/customRole))',
color: 'subdued',
type: 'solid',
},
],
},
};
const baseGraph: EnhancedNodeViewModel[] = [
{
id: 'siem-windows',

View file

@ -6,24 +6,12 @@
*/
import React from 'react';
import type {
EuiIconProps,
_EuiBackgroundColor,
CommonProps,
EuiListGroupItemProps,
} from '@elastic/eui';
import {
useEuiBackgroundColor,
useEuiTheme,
EuiIcon,
EuiListGroupItem,
EuiText,
} from '@elastic/eui';
import type { EuiIconProps, CommonProps, EuiListGroupItemProps } from '@elastic/eui';
import { useEuiTheme, EuiIcon, EuiListGroupItem, EuiText } from '@elastic/eui';
import styled from '@emotion/styled';
interface EuiColorProps {
color: keyof ReturnType<typeof useEuiTheme>['euiTheme']['colors'];
background: _EuiBackgroundColor;
}
type IconContainerProps = EuiColorProps;
@ -32,17 +20,10 @@ 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;
`;
@ -53,10 +34,10 @@ const StyleEuiIcon = styled(EuiIcon)`
transform: translate(-50%, -50%);
`;
type RoundedEuiIconProps = EuiIconProps & EuiColorProps;
type PopoverListItemIconProps = EuiIconProps & EuiColorProps;
const RoundedEuiIcon = ({ color, background, ...rest }: RoundedEuiIconProps) => (
<IconContainer color={color} background={background}>
const PopoverListItemIcon = ({ color, ...rest }: PopoverListItemIconProps) => (
<IconContainer color={color}>
<StyleEuiIcon color={color} {...rest} />
</IconContainer>
);
@ -65,20 +46,11 @@ export const ExpandPopoverListItem = (
props: CommonProps & Pick<EuiListGroupItemProps, 'iconType' | 'label' | 'onClick'>
) => {
const { iconType, label, onClick, ...rest } = props;
const { euiTheme } = useEuiTheme();
return (
<EuiListGroupItem
{...rest}
icon={
iconType ? (
<RoundedEuiIcon color="primary" background="primary" type={iconType} size="s" />
) : undefined
}
label={
<EuiText size="s" color={euiTheme.colors.primaryText}>
{label}
</EuiText>
}
icon={iconType ? <PopoverListItemIcon color="text" type={iconType} size="m" /> : undefined}
label={<EuiText>{label}</EuiText>}
onClick={onClick}
/>
);

View file

@ -12,7 +12,7 @@ import type {
LabelNodeDataModel,
EdgeDataModel,
NodeShape,
Color as NodeColor,
NodeColor,
} from '@kbn/cloud-security-posture-common/types/graph/latest';
import type { Node, NodeProps as xyNodeProps, Edge, EdgeProps as xyEdgeProps } from '@xyflow/react';

View file

@ -10,7 +10,7 @@ import { v4 as uuidv4 } from 'uuid';
import type { Logger, IScopedClusterClient } from '@kbn/core/server';
import { ApiMessageCode } from '@kbn/cloud-security-posture-common/types/graph/latest';
import type {
Color,
EdgeColor,
EdgeDataModel,
EntityNodeDataModel,
GraphRequest,
@ -32,7 +32,6 @@ interface GraphEdge {
actorIds: string[] | string;
action: string;
targetIds: string[] | string;
eventOutcome: string;
isOrigin: boolean;
isOriginAlert: boolean;
}
@ -154,7 +153,7 @@ const fetchGraph = async ({
esQuery?: EsQuery;
}): Promise<EsqlToRecords<GraphEdge>> => {
const originAlertIds = originEventIds.filter((originEventId) => originEventId.isAlert);
const query = `from logs-*
const query = `FROM logs-*
| WHERE event.action IS NOT NULL AND actor.entity.id IS NOT NULL
| EVAL isOrigin = ${
originEventIds.length > 0
@ -170,10 +169,9 @@ const fetchGraph = async ({
ips = VALUES(related.ip),
// hosts = VALUES(related.hosts),
users = VALUES(related.user)
by actorIds = actor.entity.id,
BY actorIds = actor.entity.id,
action = event.action,
targetIds = target.entity.id,
eventOutcome = event.outcome,
isOrigin,
isOriginAlert
| LIMIT 1000
@ -260,17 +258,7 @@ const createNodes = (records: GraphEdge[], context: Omit<ParseContext, 'edgesMap
break;
}
const {
ips,
hosts,
users,
actorIds,
action,
targetIds,
isOrigin,
isOriginAlert,
eventOutcome,
} = record;
const { ips, hosts, users, actorIds, action, targetIds, isOriginAlert } = record;
const actorIdsArray = castArray(actorIds);
const targetIdsArray = castArray(targetIds);
const unknownTargets: string[] = [];
@ -289,7 +277,7 @@ const createNodes = (records: GraphEdge[], context: Omit<ParseContext, 'edgesMap
nodesMap[id] = {
id,
label: unknownTargets.includes(id) ? 'Unknown' : undefined,
color: isOriginAlert ? 'danger' : 'primary',
color: 'primary',
...determineEntityNodeShape(
id,
castArray(ips ?? []),
@ -310,9 +298,9 @@ const createNodes = (records: GraphEdge[], context: Omit<ParseContext, 'edgesMap
}
const labelNode: LabelNodeDataModel = {
id: edgeId + `label(${action})outcome(${eventOutcome})`,
id: edgeId + `label(${action})`,
label: action,
color: isOriginAlert ? 'danger' : eventOutcome === 'failed' ? 'warning' : 'primary',
color: isOriginAlert ? 'danger' : 'primary',
shape: 'label',
};
@ -321,7 +309,7 @@ const createNodes = (records: GraphEdge[], context: Omit<ParseContext, 'edgesMap
labelEdges[labelNode.id] = {
source: actorId,
target: targetId,
edgeType: isOrigin ? 'solid' : 'dashed',
edgeType: 'solid',
};
}
}
@ -392,8 +380,19 @@ const createEdgesAndGroups = (context: ParseContext) => {
shape: 'group',
};
nodesMap[groupNode.id] = groupNode;
let groupEdgesColor: Color = 'primary';
let groupEdgesType: EdgeDataModel['type'] = 'dashed';
let groupEdgesColor: EdgeColor = 'subdued';
// Order of creation matters when using dagre layout, first create edges to the group node,
// then connect the group node to the label nodes
connectEntitiesAndLabelNode(
edgesMap,
nodesMap,
labelEdges[edgeLabelsIds[0]].source,
groupNode.id,
labelEdges[edgeLabelsIds[0]].target,
'solid',
groupEdgesColor
);
edgeLabelsIds.forEach((edgeLabelId) => {
(nodesMap[edgeLabelId] as Writable<LabelNodeDataModel>).parentId = groupNode.id;
@ -408,28 +407,8 @@ const createEdgesAndGroups = (context: ParseContext) => {
if ((nodesMap[edgeLabelId] as LabelNodeDataModel).color === 'danger') {
groupEdgesColor = 'danger';
} else if (
(nodesMap[edgeLabelId] as LabelNodeDataModel).color === 'warning' &&
groupEdgesColor !== 'danger'
) {
// Use warning only if there's no danger color
groupEdgesColor = 'warning';
}
if (labelEdges[edgeLabelId].edgeType === 'solid') {
groupEdgesType = 'solid';
}
});
connectEntitiesAndLabelNode(
edgesMap,
nodesMap,
labelEdges[edgeLabelsIds[0]].source,
groupNode.id,
labelEdges[edgeLabelsIds[0]].target,
groupEdgesType,
groupEdgesColor
);
}
});
};
@ -441,7 +420,7 @@ const connectEntitiesAndLabelNode = (
labelNodeId: string,
targetNodeId: string,
edgeType: EdgeDataModel['type'] = 'solid',
colorOverride?: Color
colorOverride?: EdgeColor
) => {
[
connectNodes(nodesMap, sourceNodeId, labelNodeId, edgeType, colorOverride),
@ -456,16 +435,15 @@ const connectNodes = (
sourceNodeId: string,
targetNodeId: string,
edgeType: EdgeDataModel['type'] = 'solid',
colorOverride?: Color
colorOverride?: EdgeColor
): EdgeDataModel => {
const sourceNode = nodesMap[sourceNodeId];
const targetNode = nodesMap[targetNodeId];
const color =
sourceNode.shape !== 'group' && targetNode.shape !== 'label'
? sourceNode.color
: targetNode.shape !== 'group'
? targetNode.color
: 'primary';
(sourceNode.shape === 'label' && sourceNode.color === 'danger') ||
(targetNode.shape === 'label' && targetNode.color === 'danger')
? 'danger'
: 'subdued';
return {
id: `a(${sourceNodeId})-b(${targetNodeId})`,

View file

@ -617,3 +617,128 @@
}
}
}
{
"type": "doc",
"value": {
"data_stream": "logs-gcp.audit-default",
"id": "6",
"index": ".ds-logs-gcp.audit-default-2024.10.07-000001",
"source": {
"@timestamp": "2024-09-01T12:34:56.789Z",
"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.UpdateRole",
"agent_id_status": "missing",
"category": [
"session",
"network",
"configuration"
],
"id": "grouped2-event2",
"ingested": "2024-10-07T17:47:35Z",
"kind": "event",
"outcome": "success",
"provider": "activity",
"type": [
"end",
"access",
"allowed"
]
},
"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"
}
},
"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"
}
}
}
}

View file

@ -148,6 +148,13 @@ export default function (providerContext: FtrProviderContext) {
},
},
],
must_not: [
{
match_phrase: {
'event.action': 'google.iam.admin.v1.UpdateRole',
},
},
],
},
},
},
@ -168,10 +175,10 @@ export default function (providerContext: FtrProviderContext) {
response.body.edges.forEach((edge: any) => {
expect(edge).to.have.property('color');
expect(edge.color).equal(
'primary',
'subdued',
`edge color mismatched [edge: ${edge.id}] [actual: ${edge.color}]`
);
expect(edge.type).equal('dashed');
expect(edge.type).equal('solid');
});
});
@ -191,7 +198,7 @@ export default function (providerContext: FtrProviderContext) {
response.body.nodes.forEach((node: any) => {
expect(node).to.have.property('color');
expect(node.color).equal(
'danger',
node.shape === 'label' ? 'danger' : 'primary',
`node color mismatched [node: ${node.id}] [actual: ${node.color}]`
);
});
@ -230,7 +237,7 @@ export default function (providerContext: FtrProviderContext) {
response.body.edges.forEach((edge: any) => {
expect(edge).to.have.property('color');
expect(edge.color).equal(
'primary',
'subdued',
`edge color mismatched [edge: ${edge.id}] [actual: ${edge.color}]`
);
expect(edge.type).equal('solid');
@ -253,7 +260,7 @@ export default function (providerContext: FtrProviderContext) {
response.body.nodes.forEach((node: any) => {
expect(node).to.have.property('color');
expect(node.color).equal(
'danger',
node.shape === 'label' ? 'danger' : 'primary',
`node color mismatched [node: ${node.id}] [actual: ${node.color}]`
);
});
@ -268,7 +275,7 @@ export default function (providerContext: FtrProviderContext) {
});
});
it('color of event of failed event should be warning', async () => {
it('color of event of failed event should be subdued', async () => {
const response = await postGraph(supertest, {
query: {
originEventIds: [],
@ -296,7 +303,7 @@ export default function (providerContext: FtrProviderContext) {
expect(node).to.have.property('color');
expect(node.color).equal(
node.shape === 'label' ? 'warning' : 'primary',
'primary',
`node color mismatched [node: ${node.id}] [actual: ${node.color}]`
);
});
@ -304,14 +311,14 @@ export default function (providerContext: FtrProviderContext) {
response.body.edges.forEach((edge: any) => {
expect(edge).to.have.property('color');
expect(edge.color).equal(
'warning',
'subdued',
`edge color mismatched [edge: ${edge.id}] [actual: ${edge.color}]`
);
expect(edge.type).equal('dashed');
expect(edge.type).equal('solid');
});
});
it('2 grouped events, 1 failed, 1 success', async () => {
it('2 grouped events', async () => {
const response = await postGraph(supertest, {
query: {
originEventIds: [],
@ -322,7 +329,7 @@ export default function (providerContext: FtrProviderContext) {
filter: [
{
match_phrase: {
'actor.entity.id': 'admin3@example.com',
'actor.entity.id': 'admin@example.com',
},
},
],
@ -341,7 +348,7 @@ export default function (providerContext: FtrProviderContext) {
if (node.shape !== 'group') {
expect(node).to.have.property('color');
expect(node.color).equal(
node.shape === 'label' && node.id.includes('outcome(failed)') ? 'warning' : 'primary',
'primary',
`node color mismatched [node: ${node.id}] [actual: ${node.color}]`
);
}
@ -350,13 +357,10 @@ export default function (providerContext: FtrProviderContext) {
response.body.edges.forEach((edge: any) => {
expect(edge).to.have.property('color');
expect(edge.color).equal(
edge.id.includes('outcome(failed)') ||
(edge.id.includes('grp(') && !edge.id.includes('outcome(success)'))
? 'warning'
: 'primary',
'subdued',
`edge color mismatched [edge: ${edge.id}] [actual: ${edge.color}]`
);
expect(edge.type).equal('dashed');
expect(edge.type).equal('solid');
});
});
@ -379,7 +383,7 @@ export default function (providerContext: FtrProviderContext) {
response.body.nodes.forEach((node: any) => {
expect(node).to.have.property('color');
expect(node.color).equal(
'danger',
node.shape === 'label' ? 'danger' : 'primary',
`node color mismatched [node: ${node.id}] [actual: ${node.color}]`
);
});
@ -421,10 +425,8 @@ export default function (providerContext: FtrProviderContext) {
response.body.nodes.forEach((node: any, idx: number) => {
expect(node).to.have.property('color');
expect(node.color).equal(
idx <= 2 // First 3 nodes are expected to be colored as danger (ORDER MATTERS, alerts are expected to be first)
idx === 2 // Only the label should be marked as danger (ORDER MATTERS, alerts are expected to be first)
? 'danger'
: node.shape === 'label' && node.id.includes('outcome(failed)')
? 'warning'
: 'primary',
`node color mismatched [node: ${node.id}] [actual: ${node.color}]`
);
@ -433,10 +435,10 @@ export default function (providerContext: FtrProviderContext) {
response.body.edges.forEach((edge: any, idx: number) => {
expect(edge).to.have.property('color');
expect(edge.color).equal(
idx <= 1 ? 'danger' : 'warning',
idx <= 1 ? 'danger' : 'subdued',
`edge color mismatched [edge: ${edge.id}] [actual: ${edge.color}]`
);
expect(edge.type).equal(idx <= 1 ? 'solid' : 'dashed');
expect(edge.type).equal('solid');
});
});

View file

@ -93,7 +93,7 @@ export class ExpandedFlyoutGraph extends GenericFtrService<SecurityTelemetryFtrP
const btnText = await this.testSubjects.getVisibleText(
GRAPH_NODE_POPOVER_SHOW_ACTIONS_ON_TEST_ID
);
expect(btnText).to.be('Hide actions on this entity');
expect(btnText).to.be('Hide actions done to this entity');
await this.testSubjects.click(GRAPH_NODE_POPOVER_SHOW_ACTIONS_ON_TEST_ID);
await this.pageObjects.header.waitUntilLoadingHasFinished();
}
@ -115,7 +115,7 @@ export class ExpandedFlyoutGraph extends GenericFtrService<SecurityTelemetryFtrP
const btnText = await this.testSubjects.getVisibleText(
GRAPH_LABEL_EXPAND_POPOVER_SHOW_EVENTS_WITH_THIS_ACTION_ITEM_ID
);
expect(btnText).to.be('Hide events with this action');
expect(btnText).to.be('Hide related events');
await this.testSubjects.click(GRAPH_LABEL_EXPAND_POPOVER_SHOW_EVENTS_WITH_THIS_ACTION_ITEM_ID);
await this.pageObjects.header.waitUntilLoadingHasFinished();
}

View file

@ -102,7 +102,7 @@ export default function ({ getPageObjects, getService }: SecurityTelemetryFtrPro
// Show events with the same action
await expandedFlyoutGraph.showEventsOfSameAction(
'a(admin@example.com)-b(projects/your-project-id/roles/customRole)label(google.iam.admin.v1.CreateRole)outcome(success)'
'a(admin@example.com)-b(projects/your-project-id/roles/customRole)label(google.iam.admin.v1.CreateRole)'
);
await expandedFlyoutGraph.expectFilterTextEquals(
0,
@ -115,7 +115,7 @@ export default function ({ getPageObjects, getService }: SecurityTelemetryFtrPro
// Hide events with the same action
await expandedFlyoutGraph.hideEventsOfSameAction(
'a(admin@example.com)-b(projects/your-project-id/roles/customRole)label(google.iam.admin.v1.CreateRole)outcome(success)'
'a(admin@example.com)-b(projects/your-project-id/roles/customRole)label(google.iam.admin.v1.CreateRole)'
);
await expandedFlyoutGraph.expectFilterTextEquals(
0,

View file

@ -93,7 +93,7 @@ export default function ({ getPageObjects, getService }: SecurityTelemetryFtrPro
// Show events with the same action
await expandedFlyoutGraph.showEventsOfSameAction(
'a(admin@example.com)-b(projects/your-project-id/roles/customRole)label(google.iam.admin.v1.CreateRole)outcome(success)'
'a(admin@example.com)-b(projects/your-project-id/roles/customRole)label(google.iam.admin.v1.CreateRole)'
);
await expandedFlyoutGraph.expectFilterTextEquals(
0,
@ -106,7 +106,7 @@ export default function ({ getPageObjects, getService }: SecurityTelemetryFtrPro
// Hide events with the same action
await expandedFlyoutGraph.hideEventsOfSameAction(
'a(admin@example.com)-b(projects/your-project-id/roles/customRole)label(google.iam.admin.v1.CreateRole)outcome(success)'
'a(admin@example.com)-b(projects/your-project-id/roles/customRole)label(google.iam.admin.v1.CreateRole)'
);
await expandedFlyoutGraph.expectFilterTextEquals(
0,

View file

@ -72,7 +72,20 @@ export default function ({ getService }: FtrProviderContext) {
end: '2024-09-02T00:00:00Z',
esQuery: {
bool: {
filter: [{ match_phrase: { 'actor.entity.id': 'admin@example.com' } }],
filter: [
{
match_phrase: {
'actor.entity.id': 'admin@example.com',
},
},
],
must_not: [
{
match_phrase: {
'event.action': 'google.iam.admin.v1.UpdateRole',
},
},
],
},
},
},
@ -89,6 +102,15 @@ export default function ({ getService }: FtrProviderContext) {
`node color mismatched [node: ${node.id}] [actual: ${node.color}]`
);
});
response.body.edges.forEach((edge: any) => {
expect(edge).to.have.property('color');
expect(edge.color).equal(
'subdued',
`edge color mismatched [edge: ${edge.id}] [actual: ${edge.color}]`
);
expect(edge.type).equal('solid');
});
});
});
});