diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 34fb9003d837c..196d8f3f00056 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -92,6 +92,7 @@ x-pack/plugins/cloud_integrations/cloud_links @elastic/kibana-core x-pack/plugins/cloud @elastic/kibana-core x-pack/packages/kbn-cloud-security-posture @elastic/kibana-cloud-security-posture x-pack/packages/kbn-cloud-security-posture-common @elastic/kibana-cloud-security-posture +x-pack/packages/kbn-cloud-security-posture/graph @elastic/kibana-cloud-security-posture x-pack/plugins/cloud_security_posture @elastic/kibana-cloud-security-posture packages/shared-ux/code_editor/impl @elastic/appex-sharedux packages/shared-ux/code_editor/mocks @elastic/appex-sharedux diff --git a/package.json b/package.json index 4945976fb96b5..391f9d307a67e 100644 --- a/package.json +++ b/package.json @@ -106,6 +106,7 @@ "@aws-crypto/util": "^5.2.0", "@babel/runtime": "^7.24.7", "@cfworker/json-schema": "^1.12.7", + "@dagrejs/dagre": "^1.1.4", "@dnd-kit/core": "^6.1.0", "@dnd-kit/sortable": "^8.0.0", "@dnd-kit/utilities": "^3.2.2", @@ -219,6 +220,7 @@ "@kbn/cloud-plugin": "link:x-pack/plugins/cloud", "@kbn/cloud-security-posture": "link:x-pack/packages/kbn-cloud-security-posture", "@kbn/cloud-security-posture-common": "link:x-pack/packages/kbn-cloud-security-posture-common", + "@kbn/cloud-security-posture-graph": "link:x-pack/packages/kbn-cloud-security-posture/graph", "@kbn/cloud-security-posture-plugin": "link:x-pack/plugins/cloud_security_posture", "@kbn/code-editor": "link:packages/shared-ux/code_editor/impl", "@kbn/code-editor-mock": "link:packages/shared-ux/code_editor/mocks", @@ -1054,6 +1056,7 @@ "@turf/length": "^6.0.2", "@xstate/react": "^3.2.2", "@xstate5/react": "npm:@xstate/react@^4.1.2", + "@xyflow/react": "^12.3.0", "adm-zip": "^0.5.9", "ai": "^2.2.33", "ajv": "^8.12.0", @@ -1309,6 +1312,7 @@ "@babel/plugin-transform-class-properties": "^7.24.7", "@babel/plugin-transform-logical-assignment-operators": "^7.24.7", "@babel/plugin-transform-numeric-separator": "^7.24.7", + "@babel/plugin-transform-optional-chaining": "^7.24.8", "@babel/plugin-transform-runtime": "^7.24.7", "@babel/preset-env": "^7.24.7", "@babel/preset-react": "^7.24.7", diff --git a/renovate.json b/renovate.json index 614c3a90a8857..01023d69f47fe 100644 --- a/renovate.json +++ b/renovate.json @@ -536,6 +536,24 @@ "labels": ["Team:Cloud Security", "release_note:skip", "backport:skip"], "minimumReleaseAge": "7 days", "enabled": true + }, + { + "groupName": "@xyflow/react", + "matchPackageNames": ["@xyflow/react"], + "reviewers": ["team:kibana-cloud-security-posture"], + "matchBaseBranches": ["main"], + "labels": ["Team:Cloud Security", "release_note:skip", "backport:skip"], + "minimumReleaseAge": "7 days", + "enabled": true + }, + { + "groupName": "@dagrejs/dagre", + "matchPackageNames": ["@dagrejs/dagre"], + "reviewers": ["team:kibana-cloud-security-posture"], + "matchBaseBranches": ["main"], + "labels": ["Team:Cloud Security", "release_note:skip", "backport:skip"], + "minimumReleaseAge": "7 days", + "enabled": true } ], "customManagers": [ diff --git a/src/dev/storybook/aliases.ts b/src/dev/storybook/aliases.ts index 07e92a23f9bc5..ab71ff97619fa 100644 --- a/src/dev/storybook/aliases.ts +++ b/src/dev/storybook/aliases.ts @@ -16,6 +16,7 @@ export const storybookAliases = { canvas: 'x-pack/plugins/canvas/storybook', cases: 'packages/kbn-cases-components/.storybook', cell_actions: 'packages/kbn-cell-actions/.storybook', + cloud_security_posture_packages: 'x-pack/packages/kbn-cloud-security-posture/storybook/config', cloud: 'packages/cloud/.storybook', coloring: 'packages/kbn-coloring/.storybook', language_documentation_popover: 'packages/kbn-language-documentation/.storybook', diff --git a/tsconfig.base.json b/tsconfig.base.json index 2c7e52bd4f272..2e11f197f0ffe 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -178,6 +178,8 @@ "@kbn/cloud-security-posture/*": ["x-pack/packages/kbn-cloud-security-posture/*"], "@kbn/cloud-security-posture-common": ["x-pack/packages/kbn-cloud-security-posture-common"], "@kbn/cloud-security-posture-common/*": ["x-pack/packages/kbn-cloud-security-posture-common/*"], + "@kbn/cloud-security-posture-graph": ["x-pack/packages/kbn-cloud-security-posture/graph"], + "@kbn/cloud-security-posture-graph/*": ["x-pack/packages/kbn-cloud-security-posture/graph/*"], "@kbn/cloud-security-posture-plugin": ["x-pack/plugins/cloud_security_posture"], "@kbn/cloud-security-posture-plugin/*": ["x-pack/plugins/cloud_security_posture/*"], "@kbn/code-editor": ["packages/shared-ux/code_editor/impl"], diff --git a/x-pack/packages/kbn-cloud-security-posture-common/schema/graph/index.ts b/x-pack/packages/kbn-cloud-security-posture-common/schema/graph/index.ts new file mode 100644 index 0000000000000..f337f91593a24 --- /dev/null +++ b/x-pack/packages/kbn-cloud-security-posture-common/schema/graph/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export * as graphV1 from './v1'; diff --git a/x-pack/packages/kbn-cloud-security-posture-common/schema/graph/latest.ts b/x-pack/packages/kbn-cloud-security-posture-common/schema/graph/latest.ts new file mode 100644 index 0000000000000..25300c97a6d2e --- /dev/null +++ b/x-pack/packages/kbn-cloud-security-posture-common/schema/graph/latest.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export * from './v1'; diff --git a/x-pack/packages/kbn-cloud-security-posture-common/schema/graph/v1.ts b/x-pack/packages/kbn-cloud-security-posture-common/schema/graph/v1.ts new file mode 100644 index 0000000000000..f27ddb397c57c --- /dev/null +++ b/x-pack/packages/kbn-cloud-security-posture-common/schema/graph/v1.ts @@ -0,0 +1,89 @@ +/* + * 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 { schema } from '@kbn/config-schema'; + +export const graphRequestSchema = schema.object({ + query: schema.object({ + actorIds: schema.arrayOf(schema.string()), + eventIds: schema.arrayOf(schema.string()), + // TODO: use zod for range validation instead of config schema + start: schema.oneOf([schema.number(), schema.string()]), + end: schema.oneOf([schema.number(), schema.string()]), + }), +}); + +export const graphResponseSchema = () => + schema.object({ + nodes: schema.arrayOf( + schema.oneOf([entityNodeDataSchema, groupNodeDataSchema, labelNodeDataSchema]) + ), + edges: schema.arrayOf(edgeDataSchema), + }); + +export const colorSchema = schema.oneOf([ + schema.literal('primary'), + schema.literal('danger'), + schema.literal('warning'), +]); + +export const nodeShapeSchema = schema.oneOf([ + schema.literal('hexagon'), + schema.literal('pentagon'), + schema.literal('ellipse'), + schema.literal('rectangle'), + schema.literal('diamond'), + schema.literal('label'), + schema.literal('group'), +]); + +export const nodeBaseDataSchema = schema.object({ + id: schema.string(), + label: schema.maybe(schema.string()), + icon: schema.maybe(schema.string()), +}); + +export const entityNodeDataSchema = schema.allOf([ + nodeBaseDataSchema, + schema.object({ + color: colorSchema, + shape: schema.oneOf([ + schema.literal('hexagon'), + schema.literal('pentagon'), + schema.literal('ellipse'), + schema.literal('rectangle'), + schema.literal('diamond'), + ]), + }), +]); + +export const groupNodeDataSchema = schema.allOf([ + nodeBaseDataSchema, + schema.object({ + shape: schema.literal('group'), + }), +]); + +export const labelNodeDataSchema = schema.allOf([ + nodeBaseDataSchema, + schema.object({ + source: schema.string(), + target: schema.string(), + shape: schema.literal('label'), + parentId: schema.maybe(schema.string()), + color: colorSchema, + }), +]); + +export const edgeDataSchema = schema.object({ + id: schema.string(), + source: schema.string(), + sourceShape: nodeShapeSchema, + target: schema.string(), + targetShape: nodeShapeSchema, + color: colorSchema, +}); diff --git a/x-pack/packages/kbn-cloud-security-posture-common/types/graph/index.ts b/x-pack/packages/kbn-cloud-security-posture-common/types/graph/index.ts new file mode 100644 index 0000000000000..f337f91593a24 --- /dev/null +++ b/x-pack/packages/kbn-cloud-security-posture-common/types/graph/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export * as graphV1 from './v1'; diff --git a/x-pack/packages/kbn-cloud-security-posture-common/types/graph/latest.ts b/x-pack/packages/kbn-cloud-security-posture-common/types/graph/latest.ts new file mode 100644 index 0000000000000..25300c97a6d2e --- /dev/null +++ b/x-pack/packages/kbn-cloud-security-posture-common/types/graph/latest.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export * from './v1'; diff --git a/x-pack/packages/kbn-cloud-security-posture-common/types/graph/v1.ts b/x-pack/packages/kbn-cloud-security-posture-common/types/graph/v1.ts new file mode 100644 index 0000000000000..48d1d1c49fd03 --- /dev/null +++ b/x-pack/packages/kbn-cloud-security-posture-common/types/graph/v1.ts @@ -0,0 +1,35 @@ +/* + * 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 type { TypeOf } from '@kbn/config-schema'; +import { + colorSchema, + edgeDataSchema, + entityNodeDataSchema, + graphRequestSchema, + graphResponseSchema, + groupNodeDataSchema, + labelNodeDataSchema, + nodeShapeSchema, +} from '../../schema/graph/v1'; + +export type GraphRequest = TypeOf; +export type GraphResponse = TypeOf; + +export type Color = typeof colorSchema.type; + +export type NodeShape = TypeOf; + +export type EntityNodeDataModel = TypeOf; + +export type GroupNodeDataModel = TypeOf; + +export type LabelNodeDataModel = TypeOf; + +export type EdgeDataModel = TypeOf; + +export type NodeDataModel = EntityNodeDataModel | GroupNodeDataModel | LabelNodeDataModel; diff --git a/x-pack/packages/kbn-cloud-security-posture/README.md b/x-pack/packages/kbn-cloud-security-posture/README.md index 204ff85e41a41..29a14fbeb825b 100644 --- a/x-pack/packages/kbn-cloud-security-posture/README.md +++ b/x-pack/packages/kbn-cloud-security-posture/README.md @@ -4,6 +4,13 @@ This package includes - Hooks that's used on Flyout component that's used in Alerts page on Security Solution Plugins as well as components on CSP plugin - Utilities and types thats used for the Hooks above as well as in CSP plugins +## Storybook + +General look of the component can be checked visually running the following storybook: +`yarn storybook cloud_security_posture_graph` + +Note that all the interactions are mocked. + ## Maintainers Maintained by the Cloud Security Team \ No newline at end of file diff --git a/x-pack/packages/kbn-cloud-security-posture/graph/README.md b/x-pack/packages/kbn-cloud-security-posture/graph/README.md new file mode 100644 index 0000000000000..2e495ce4e6be8 --- /dev/null +++ b/x-pack/packages/kbn-cloud-security-posture/graph/README.md @@ -0,0 +1,25 @@ +# Cloud Security Posture's Graph + +## Motivation + +The idea behind this package is to have a reusable graph component, embedding the features available to alerts flyout in +security solution plugin. + +## How to use this + +Standalone examples will follow. In the meantime checkout storybook to view the graphs progress. + +## The most important public api members + +- GraphComponent itself (comming soon..) + +### Extras + +Be sure to check out provided helpers + +## Storybook + +General look of the component can be checked visually running the following storybook: +`yarn storybook cloud_security_posture_packages` + +Note that all the interactions are mocked. \ No newline at end of file diff --git a/x-pack/packages/kbn-cloud-security-posture/graph/index.ts b/x-pack/packages/kbn-cloud-security-posture/graph/index.ts new file mode 100644 index 0000000000000..1fec1c76430eb --- /dev/null +++ b/x-pack/packages/kbn-cloud-security-posture/graph/index.ts @@ -0,0 +1,6 @@ +/* + * 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. + */ diff --git a/x-pack/packages/kbn-cloud-security-posture/graph/jest.config.js b/x-pack/packages/kbn-cloud-security-posture/graph/jest.config.js new file mode 100644 index 0000000000000..9e295d0f4d626 --- /dev/null +++ b/x-pack/packages/kbn-cloud-security-posture/graph/jest.config.js @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +module.exports = { + preset: '@kbn/test', + roots: ['/x-pack/packages/kbn-cloud-security-posture/graph'], + rootDir: '../../../..', +}; diff --git a/x-pack/packages/kbn-cloud-security-posture/graph/kibana.jsonc b/x-pack/packages/kbn-cloud-security-posture/graph/kibana.jsonc new file mode 100644 index 0000000000000..455f1607a22a2 --- /dev/null +++ b/x-pack/packages/kbn-cloud-security-posture/graph/kibana.jsonc @@ -0,0 +1,5 @@ +{ + "type": "shared-browser", + "id": "@kbn/cloud-security-posture-graph", + "owner": "@elastic/kibana-cloud-security-posture" +} diff --git a/x-pack/packages/kbn-cloud-security-posture/graph/package.json b/x-pack/packages/kbn-cloud-security-posture/graph/package.json new file mode 100644 index 0000000000000..4ca6b111cf3a0 --- /dev/null +++ b/x-pack/packages/kbn-cloud-security-posture/graph/package.json @@ -0,0 +1,7 @@ +{ + "name": "@kbn/cloud-security-posture-graph", + "private": true, + "version": "1.0.0", + "license": "Elastic License 2.0", + "sideEffects": false +} diff --git a/x-pack/packages/kbn-cloud-security-posture/graph/src/assets/icons/aws.svg b/x-pack/packages/kbn-cloud-security-posture/graph/src/assets/icons/aws.svg new file mode 100644 index 0000000000000..45cd8aed50eb8 --- /dev/null +++ b/x-pack/packages/kbn-cloud-security-posture/graph/src/assets/icons/aws.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/x-pack/packages/kbn-cloud-security-posture/graph/src/assets/icons/aws_ec2.svg b/x-pack/packages/kbn-cloud-security-posture/graph/src/assets/icons/aws_ec2.svg new file mode 100644 index 0000000000000..4c16eb7c94602 --- /dev/null +++ b/x-pack/packages/kbn-cloud-security-posture/graph/src/assets/icons/aws_ec2.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/x-pack/packages/kbn-cloud-security-posture/graph/src/assets/icons/aws_s3.svg b/x-pack/packages/kbn-cloud-security-posture/graph/src/assets/icons/aws_s3.svg new file mode 100644 index 0000000000000..eb493f5f1ad2e --- /dev/null +++ b/x-pack/packages/kbn-cloud-security-posture/graph/src/assets/icons/aws_s3.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/x-pack/packages/kbn-cloud-security-posture/graph/src/assets/icons/okta.svg b/x-pack/packages/kbn-cloud-security-posture/graph/src/assets/icons/okta.svg new file mode 100644 index 0000000000000..dc0f17cd4e1a1 --- /dev/null +++ b/x-pack/packages/kbn-cloud-security-posture/graph/src/assets/icons/okta.svg @@ -0,0 +1,3 @@ + + + diff --git a/x-pack/packages/kbn-cloud-security-posture/graph/src/components/dagree_layout_graph.stories.tsx b/x-pack/packages/kbn-cloud-security-posture/graph/src/components/dagree_layout_graph.stories.tsx new file mode 100644 index 0000000000000..94bc7e8af353b --- /dev/null +++ b/x-pack/packages/kbn-cloud-security-posture/graph/src/components/dagree_layout_graph.stories.tsx @@ -0,0 +1,607 @@ +/* + * 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 { ThemeProvider } from '@emotion/react'; +import { + ReactFlow, + Controls, + Background, + Node, + Edge, + Position, + useNodesState, + useEdgesState, +} from '@xyflow/react'; +import { Story } from '@storybook/react'; +import type { + EdgeDataModel, + LabelNodeDataModel, + NodeDataModel, +} from '@kbn/cloud-security-posture-common/types/graph/latest'; +import { Writable } from '@kbn/utility-types'; +import { + HexagonNode, + PentagonNode, + EllipseNode, + RectangleNode, + DiamondNode, + LabelNode, + EdgeGroupNode, +} from './node'; +import type { NodeViewModel } from './types'; +import { DefaultEdge } from './edge'; +import { SvgDefsMarker } from './edge/styles'; +import { GroupStyleOverride } from './node/styles'; + +import '@xyflow/react/dist/style.css'; +import { layoutGraph } from './graph/layout_graph'; + +export default { + title: 'Components/Graph Components/Dagree Layout Graph', + description: 'CDR - Graph visualization', +}; + +const nodeTypes = { + hexagon: HexagonNode, + pentagon: PentagonNode, + ellipse: EllipseNode, + rectangle: RectangleNode, + diamond: DiamondNode, + label: LabelNode, + group: EdgeGroupNode, +}; + +const edgeTypes = { + default: DefaultEdge, +}; + +interface GraphData { + nodes: NodeDataModel[]; + edges: EdgeDataModel[]; + interactive: boolean; +} + +const extractEdges = ( + graphData: NodeDataModel[] +): { nodes: NodeDataModel[]; edges: EdgeDataModel[] } => { + // Process nodes, transform nodes of id in the format of a(source)-b(target) to edges from a to label and from label to b + // If there are multiple edges from a to b, create a parent node and group the labels under it. The parent node will be a group node. + // Connect from a to the group node and from the group node to all the labels. and from the labels to the group again and from the group to b. + const nodesMetadata: { [key: string]: { edgesIn: number; edgesOut: number } } = {}; + const edgesMetadata: { + [key: string]: { source: string; target: string; edgesStacked: number; edges: string[] }; + } = {}; + const labelsMetadata: { + [key: string]: { source: string; target: string; labelsNodes: LabelNodeDataModel[] }; + } = {}; + const nodes: { [key: string]: NodeDataModel } = {}; + const edges: EdgeDataModel[] = []; + + graphData.forEach((node) => { + if (node.shape === 'label') { + const labelNode = { ...node, id: `${node.id}label(${node.label})` }; + const { source, target } = node; + + if (labelsMetadata[node.id]) { + labelsMetadata[node.id].labelsNodes.push(labelNode); + } else { + labelsMetadata[node.id] = { source, target, labelsNodes: [labelNode] }; + } + + nodes[labelNode.id] = labelNode; + + // Set metadata + const edgeId = node.id; + nodesMetadata[source].edgesOut += 1; // TODO: Check if source exists + nodesMetadata[target].edgesIn += 1; // TODO: Check if target exists + + if (edgesMetadata[edgeId]) { + edgesMetadata[edgeId].edgesStacked += 1; + edgesMetadata[edgeId].edges.push(edgeId); + } else { + edgesMetadata[edgeId] = { + source, + target, + edgesStacked: 1, + edges: [labelNode.id], + }; + } + } else { + nodes[node.id] = node; + nodesMetadata[node.id] = { edgesIn: 0, edgesOut: 0 }; + } + }); + + Object.values(labelsMetadata).forEach((edge) => { + if (edge.labelsNodes.length > 1) { + const groupNode: NodeDataModel = { + id: `grp(a(${edge.source})-b(${edge.target}))`, + shape: 'group', + }; + + nodes[groupNode.id] = groupNode; + edges.push({ + id: `a(${edge.source})-b(${groupNode.id})`, + source: edge.source, + sourceShape: nodes[edge.source].shape, + target: groupNode.id, + targetShape: groupNode.shape, + color: edge.labelsNodes[0].color, + }); + + edges.push({ + id: `a(${groupNode.id})-b(${edge.target})`, + source: groupNode.id, + sourceShape: groupNode.shape, + target: edge.target, + targetShape: nodes[edge.target].shape, + color: edge.labelsNodes[0].color, + }); + + edge.labelsNodes.forEach((labelNode: Writable) => { + labelNode.parentId = groupNode.id; + + edges.push({ + id: `a(${groupNode.id})-b(${labelNode.id})`, + source: groupNode.id, + sourceShape: groupNode.shape, + target: labelNode.id, + targetShape: labelNode.shape, + color: labelNode.color, + }); + + edges.push({ + id: `a(${labelNode.id})-b(${groupNode.id})`, + source: labelNode.id, + sourceShape: labelNode.shape, + target: groupNode.id, + targetShape: groupNode.shape, + color: labelNode.color, + }); + }); + } else { + edges.push({ + id: `a(${edge.source})-b(${edge.labelsNodes[0].id})`, + source: edge.source, + sourceShape: nodes[edge.source].shape, + target: edge.labelsNodes[0].id, + targetShape: edge.labelsNodes[0].shape, + color: edge.labelsNodes[0].color, + }); + + edges.push({ + id: `a(${edge.labelsNodes[0].id})-b(${edge.target})`, + source: edge.labelsNodes[0].id, + sourceShape: edge.labelsNodes[0].shape, + target: edge.target, + targetShape: nodes[edge.target].shape, + color: edge.labelsNodes[0].color, + }); + } + }); + + // Reversing order, groups like to be first in order :D + return { nodes: Object.values(nodes).reverse(), edges }; +}; + +const Template: Story = ({ nodes, edges }: GraphData) => { + const { initialNodes, initialEdges } = processGraph(nodes, edges); + + const [nodesState, _setNodes, onNodesChange] = useNodesState(initialNodes); + const [edgesState, _setEdges, onEdgesChange] = useEdgesState(initialEdges); + + return ( + + + + + + + + ); +}; + +export const SimpleAPIMock = Template.bind({}); +SimpleAPIMock.args = { + nodes: [ + { + id: 'admin@example.com', + label: 'admin@example.com', + color: 'primary', + shape: 'ellipse', + icon: 'user', + }, + { + id: 'projects/your-project-id/roles/customRole', + label: 'projects/your-project-id/roles/customRole', + color: 'primary', + shape: 'hexagon', + icon: 'questionInCircle', + }, + { + id: 'a(admin@example.com)-b(projects/your-project-id/roles/customRole)label(google.iam.admin.v1.CreateRole)', + label: 'google.iam.admin.v1.CreateRole', + source: 'admin@example.com', + target: 'projects/your-project-id/roles/customRole', + color: 'primary', + shape: 'label', + }, + ], + edges: [ + { + id: 'a(admin@example.com)-b(a(admin@example.com)-b(projects/your-project-id/roles/customRole)label(google.iam.admin.v1.CreateRole))', + source: 'admin@example.com', + sourceShape: 'ellipse', + target: + 'a(admin@example.com)-b(projects/your-project-id/roles/customRole)label(google.iam.admin.v1.CreateRole)', + targetShape: 'label', + color: 'primary', + }, + { + id: 'a(a(admin@example.com)-b(projects/your-project-id/roles/customRole)label(google.iam.admin.v1.CreateRole))-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)', + sourceShape: 'label', + target: 'projects/your-project-id/roles/customRole', + targetShape: 'hexagon', + color: 'primary', + }, + ], +}; + +export const GroupWithWarningAPIMock = Template.bind({}); +GroupWithWarningAPIMock.args = { + nodes: [ + { + id: 'grp(a(admin3@example.com)-b(projects/your-project-id/roles/customRole))', + shape: 'group', + }, + { + id: 'admin3@example.com', + label: 'admin3@example.com', + color: 'primary', + shape: 'ellipse', + icon: 'user', + }, + { + id: 'projects/your-project-id/roles/customRole', + label: 'projects/your-project-id/roles/customRole', + color: 'primary', + shape: 'hexagon', + icon: 'questionInCircle', + }, + { + id: 'a(admin3@example.com)-b(projects/your-project-id/roles/customRole)label(google.iam.admin.v1.CreateRole)outcome(failed)', + label: 'google.iam.admin.v1.CreateRole', + source: 'admin3@example.com', + target: 'projects/your-project-id/roles/customRole', + color: 'warning', + shape: 'label', + parentId: 'grp(a(admin3@example.com)-b(projects/your-project-id/roles/customRole))', + }, + { + id: 'a(admin3@example.com)-b(projects/your-project-id/roles/customRole)label(google.iam.admin.v1.CreateRole)outcome(success)', + label: 'google.iam.admin.v1.CreateRole', + source: 'admin3@example.com', + target: 'projects/your-project-id/roles/customRole', + color: 'primary', + shape: 'label', + parentId: 'grp(a(admin3@example.com)-b(projects/your-project-id/roles/customRole))', + }, + ], + edges: [ + { + id: 'a(admin3@example.com)-b(grp(a(admin3@example.com)-b(projects/your-project-id/roles/customRole)))', + source: 'admin3@example.com', + sourceShape: 'ellipse', + target: 'grp(a(admin3@example.com)-b(projects/your-project-id/roles/customRole))', + targetShape: 'group', + color: 'primary', + }, + { + id: 'a(grp(a(admin3@example.com)-b(projects/your-project-id/roles/customRole)))-b(projects/your-project-id/roles/customRole)', + source: 'grp(a(admin3@example.com)-b(projects/your-project-id/roles/customRole))', + sourceShape: 'group', + target: 'projects/your-project-id/roles/customRole', + targetShape: 'hexagon', + color: 'primary', + }, + { + id: 'a(grp(a(admin3@example.com)-b(projects/your-project-id/roles/customRole)))-b(a(admin3@example.com)-b(projects/your-project-id/roles/customRole)label(google.iam.admin.v1.CreateRole)outcome(failed))', + source: 'grp(a(admin3@example.com)-b(projects/your-project-id/roles/customRole))', + sourceShape: 'group', + target: + 'a(admin3@example.com)-b(projects/your-project-id/roles/customRole)label(google.iam.admin.v1.CreateRole)outcome(failed)', + targetShape: 'label', + color: 'warning', + }, + { + id: 'a(a(admin3@example.com)-b(projects/your-project-id/roles/customRole)label(google.iam.admin.v1.CreateRole)outcome(failed))-b(grp(a(admin3@example.com)-b(projects/your-project-id/roles/customRole)))', + source: + 'a(admin3@example.com)-b(projects/your-project-id/roles/customRole)label(google.iam.admin.v1.CreateRole)outcome(failed)', + sourceShape: 'label', + target: 'grp(a(admin3@example.com)-b(projects/your-project-id/roles/customRole))', + targetShape: 'group', + color: 'warning', + }, + { + id: 'a(grp(a(admin3@example.com)-b(projects/your-project-id/roles/customRole)))-b(a(admin3@example.com)-b(projects/your-project-id/roles/customRole)label(google.iam.admin.v1.CreateRole)outcome(success))', + source: 'grp(a(admin3@example.com)-b(projects/your-project-id/roles/customRole))', + sourceShape: 'group', + target: + 'a(admin3@example.com)-b(projects/your-project-id/roles/customRole)label(google.iam.admin.v1.CreateRole)outcome(success)', + targetShape: 'label', + color: 'primary', + }, + { + id: 'a(a(admin3@example.com)-b(projects/your-project-id/roles/customRole)label(google.iam.admin.v1.CreateRole)outcome(success))-b(grp(a(admin3@example.com)-b(projects/your-project-id/roles/customRole)))', + source: + 'a(admin3@example.com)-b(projects/your-project-id/roles/customRole)label(google.iam.admin.v1.CreateRole)outcome(success)', + sourceShape: 'label', + target: 'grp(a(admin3@example.com)-b(projects/your-project-id/roles/customRole))', + targetShape: 'group', + color: 'primary', + }, + ], +}; + +export const Graph = Template.bind({}); +const baseGraph: NodeDataModel[] = [ + { + id: 'siem-windows', + label: '', + color: 'danger', + shape: 'hexagon', + icon: 'storage', + }, + { + id: '213.180.204.3', + label: 'IP: 213.180.204.3', + color: 'danger', + shape: 'diamond', + icon: 'globe', + }, + { + id: 'user', + label: '', + color: 'danger', + shape: 'ellipse', + icon: 'user', + }, + { + id: 'oktauser', + label: 'pluni@elastic.co', + color: 'primary', + shape: 'ellipse', + icon: 'user', + }, + { + id: 'hackeruser', + label: 'Hacker', + color: 'primary', + shape: 'ellipse', + icon: 'user', + }, + { + id: 's3', + label: 'Customer PII Data', + color: 'primary', + shape: 'rectangle', + icon: 'aws_s3', + }, + { + id: 'ec2', + label: 'AWS::EC2', + color: 'primary', + shape: 'rectangle', + icon: 'aws_ec2', + }, + { + id: 'aws', + label: 'AWS CloudTrail', + color: 'primary', + shape: 'rectangle', + icon: 'aws', + }, + { + id: 'a(siem-windows)-b(user)', + source: 'siem-windows', + target: 'user', + label: 'User login to OKTA', + color: 'danger', + shape: 'label', + }, + { + id: 'a(213.180.204.3)-b(user)', + source: '213.180.204.3', + target: 'user', + label: 'User login to OKTA', + color: 'danger', + shape: 'label', + }, + { + id: 'a(user)-b(oktauser)', + source: 'user', + target: 'oktauser', + label: 'user.authentication.sso', + color: 'primary', + shape: 'label', + }, + { + id: 'a(user)-b(oktauser)', + source: 'user', + target: 'oktauser', + label: 'AssumeRoleWithSAML', + color: 'primary', + shape: 'label', + }, + { + id: 'a(oktauser)-b(hackeruser)', + source: 'oktauser', + target: 'hackeruser', + label: 'CreateUser', + color: 'primary', + shape: 'label', + }, + { + id: 'a(oktauser)-b(s3)', + source: 'oktauser', + target: 's3', + label: 'PutObject', + color: 'primary', + shape: 'label', + }, + { + id: 'a(oktauser)-b(ec2)', + source: 'oktauser', + target: 'ec2', + label: 'RunInstances', + color: 'primary', + shape: 'label', + }, + { + id: 'a(oktauser)-b(aws)', + source: 'oktauser', + target: 'aws', + label: 'DeleteTrail (Failed)', + color: 'warning', + shape: 'label', + }, +]; + +Graph.args = { + ...extractEdges(baseGraph), +}; + +export const GraphLabelOverlayCases = Template.bind({}); + +GraphLabelOverlayCases.args = { + ...extractEdges([ + ...baseGraph, + { + id: 'newnode', + label: 'New Node', + color: 'primary', + shape: 'ellipse', + icon: 'user', + }, + { + id: 'a(newnode)-b(hackeruser)', + source: 'newnode', + target: 'hackeruser', + label: 'Overlay Label', + color: 'danger', + shape: 'label', + }, + { + id: 'a(newnode)-b(s3)', + source: 'newnode', + target: 's3', + label: 'Overlay Label', + color: 'danger', + shape: 'label', + }, + ]), +}; + +export const GraphStackedEdgeCases = Template.bind({}); + +GraphStackedEdgeCases.args = { + ...extractEdges([ + ...baseGraph, + { + id: 'a(oktauser)-b(hackeruser)', + source: 'oktauser', + target: 'hackeruser', + label: 'CreateUser2', + color: 'primary', + shape: 'label', + }, + { + id: 'a(siem-windows)-b(user)', + source: 'siem-windows', + target: 'user', + label: 'User login to OKTA2', + color: 'danger', + shape: 'label', + }, + ]), +}; + +function processGraph( + nodesModel: NodeDataModel[], + edgesModel: EdgeDataModel[] +): { + initialNodes: Node[]; + initialEdges: Edge[]; +} { + const { nodes: nodesViewModel } = layoutGraph(nodesModel, edgesModel); + + const nodesById: { [key: string]: NodeViewModel } = {}; + + const initialNodes = nodesViewModel.map((nodeData) => { + nodesById[nodeData.id] = nodeData; + + const node: Node = { + id: nodeData.id, + type: nodeData.shape, + data: { ...nodeData, interactive: true }, + position: nodeData.position, + draggable: true, + }; + + if (node.type === 'group' && nodeData.shape === 'group') { + node.sourcePosition = Position.Right; + node.targetPosition = Position.Left; + node.resizing = false; + node.style = GroupStyleOverride({ + width: nodeData.size?.width ?? 0, + height: nodeData.size?.height ?? 0, + }); + } else if (nodeData.shape === 'label' && nodeData.parentId) { + node.parentId = nodeData.parentId; + node.extent = 'parent'; + node.expandParent = false; + node.draggable = false; + } + + return node; + }); + + const initialEdges: Edge[] = 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, + data: { ...edgeData }, + }; + }); + + return { initialNodes, initialEdges }; +} diff --git a/x-pack/packages/kbn-cloud-security-posture/graph/src/components/edge/deafult_edge.stories.tsx b/x-pack/packages/kbn-cloud-security-posture/graph/src/components/edge/deafult_edge.stories.tsx new file mode 100644 index 0000000000000..4d6c689dd643b --- /dev/null +++ b/x-pack/packages/kbn-cloud-security-posture/graph/src/components/edge/deafult_edge.stories.tsx @@ -0,0 +1,153 @@ +/* + * 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 { ThemeProvider } from '@emotion/react'; +import { + ReactFlow, + Controls, + Background, + Position, + Handle, + useNodesState, + useEdgesState, + type BuiltInNode, + type NodeProps, +} from '@xyflow/react'; +import { Story } from '@storybook/react'; +import { SvgDefsMarker } from './styles'; +import { DefaultEdge } from '.'; + +import '@xyflow/react/dist/style.css'; +import { LabelNode } from '../node'; +import type { NodeViewModel } from '../types'; + +export default { + title: 'Components/Graph Components/Default Edge', + description: 'CDR - Graph visualization', + argTypes: { + color: { + options: ['primary', 'danger', 'warning'], + control: { type: 'radio' }, + }, + }, +}; + +const nodeTypes = { + default: ((props: NodeProps) => { + const handleStyle = { + width: 0, + height: 0, + 'min-width': 0, + 'min-height': 0, + border: 'none', + }; + return ( +
+ + + {props.data.label} +
+ ); + }) as React.FC>, + label: LabelNode, +}; + +const edgeTypes = { + default: DefaultEdge, +}; + +const Template: Story = (args: NodeViewModel) => { + const initialNodes = [ + { + id: 'source', + type: 'default', + data: { label: 'source' }, + position: { x: 0, y: 0 }, + draggable: true, + }, + { + id: 'target', + type: 'default', + data: { label: 'target' }, + position: { x: 320, y: 100 }, + draggable: true, + }, + { + id: args.id, + type: 'label', + data: args, + position: { x: 160, y: 50 }, + draggable: true, + }, + ]; + + const initialEdges = [ + { + id: 'source-' + args.id, + source: 'source', + target: args.id, + data: { + id: 'source-' + args.id, + source: 'source', + sourceShape: 'rectangle', + target: args.id, + targetShape: 'label', + color: args.color, + interactive: true, + }, + type: 'default', + }, + { + id: args.id + '-target', + source: args.id, + target: 'target', + data: { + id: args.id + '-target', + source: args.id, + sourceShape: 'label', + target: 'target', + targetShape: 'rectangle', + color: args.color, + interactive: true, + }, + type: 'default', + }, + ]; + + const [nodes, _setNodes, onNodesChange] = useNodesState(initialNodes); + const [edges, _setEdges, onEdgesChange] = useEdgesState(initialEdges); + + return ( + + + + + + + + ); +}; + +export const Edge = Template.bind({}); + +Edge.args = { + id: 'siem-windows', + label: 'User login to OKTA', + color: 'primary', + icon: 'okta', + interactive: true, +}; diff --git a/x-pack/packages/kbn-cloud-security-posture/graph/src/components/edge/default_edge.tsx b/x-pack/packages/kbn-cloud-security-posture/graph/src/components/edge/default_edge.tsx new file mode 100644 index 0000000000000..898e12b5b4c01 --- /dev/null +++ b/x-pack/packages/kbn-cloud-security-posture/graph/src/components/edge/default_edge.tsx @@ -0,0 +1,62 @@ +/* + * 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 { BaseEdge, getBezierPath } from '@xyflow/react'; +import { useEuiTheme } from '@elastic/eui'; +import type { Color } from '@kbn/cloud-security-posture-common/types/graph/latest'; +import type { EdgeProps } from '../types'; +import { getMarker } from './styles'; +import { getShapeHandlePosition } from './utils'; + +export function DefaultEdge({ + id, + label, + sourceX, + sourceY, + sourcePosition, + targetX, + targetY, + targetPosition, + data, +}: EdgeProps) { + const { euiTheme } = useEuiTheme(); + const color: Color = data?.color ?? 'primary'; + + const [edgePath] = getBezierPath({ + // sourceX and targetX are adjusted to account for the shape handle position + sourceX: sourceX - getShapeHandlePosition(data?.sourceShape), + sourceY, + sourcePosition, + targetX: targetX + getShapeHandlePosition(data?.targetShape), + targetY, + targetPosition, + curvature: + 0.1 * + (data?.sourceShape === 'group' || + (data?.sourceShape === 'label' && data?.targetShape === 'group') + ? -1 // We flip direction when the edge is between parent node to child nodes (groups always contain children in our graph) + : 1), + }); + + return ( + <> + + + ); +} diff --git a/x-pack/packages/kbn-cloud-security-posture/graph/src/components/edge/index.ts b/x-pack/packages/kbn-cloud-security-posture/graph/src/components/edge/index.ts new file mode 100644 index 0000000000000..61b6b00ddd171 --- /dev/null +++ b/x-pack/packages/kbn-cloud-security-posture/graph/src/components/edge/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { DefaultEdge } from './default_edge'; 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 new file mode 100644 index 0000000000000..8f7c5e29ec3fe --- /dev/null +++ b/x-pack/packages/kbn-cloud-security-posture/graph/src/components/edge/styles.tsx @@ -0,0 +1,139 @@ +/* + * 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 styled from '@emotion/styled'; +import { rgba } from 'polished'; +import { + useEuiTheme, + useEuiBackgroundColor, + EuiText, + type EuiTextProps, + type _EuiBackgroundColor, +} from '@elastic/eui'; + +export const EdgeLabelHeight = 24; +export const EdgeLabelWidth = 100; + +export interface EdgeLabelContainerProps { + width?: number; + height?: number; + scale?: number; +} + +export const EdgeLabelContainer = styled.div` + position: absolute; + ${(props) => + props.scale && 0 < props.scale && props.scale < 1 + ? `transform: scale(${props.scale}) translateX(${(1 - props.scale) * 50}%)` + : ''}; + width: ${(props) => props.width ?? EdgeLabelWidth}px; + height: ${(props) => props.height ?? EdgeLabelHeight}px; + // Everything inside EdgeLabelRenderer has no pointer events by default + // To have an interactive element, set pointer-events: all + pointer-events: all; + text-wrap: nowrap; +`; + +export interface EdgeLabelProps extends EuiTextProps { + labelX?: number; + labelY?: number; +} + +export const EdgeLabel = styled(EuiText)` + position: absolute; + transform: ${(props) => + `translate(-50%, -50%)${ + props.labelX && props.labelY ? ` translate(${props.labelX}px,${props.labelY}px)` : '' + }`}; + background: ${(props) => useEuiBackgroundColor(props.color as _EuiBackgroundColor)}; + border: ${(props) => { + const { euiTheme } = useEuiTheme(); + return `solid ${euiTheme.colors[props.color as keyof typeof euiTheme.colors]} 1px`; + }}; + font-weight: ${(_props) => { + const { euiTheme } = useEuiTheme(); + return `${euiTheme.font.weight.semiBold}`; + }}; + font-size: ${(_props) => { + const { euiTheme } = useEuiTheme(); + return `${euiTheme.font.scale.xs * 10.5}px`; + }}; + padding: 0px 2px; + border-radius: 16px; + min-height: 100%; + min-width: 100%; +`; + +export const EdgeLabelOnHover = styled(EdgeLabel)` + opacity: 0; /* Hidden by default */ + transition: opacity 0.2s ease; /* Smooth transition */ + border: ${(props) => { + const { euiTheme } = useEuiTheme(); + return `dashed ${rgba( + euiTheme.colors[props.color as keyof typeof euiTheme.colors] as string, + 0.5 + )} 1px`; + }}; + border-radius: 20px; + width: ${(props) => (props.width ?? EdgeLabelWidth) + 10}px; + height: ${(props) => (props.height ?? EdgeLabelHeight) + 10}px; + background: transparent; + + ${EdgeLabelContainer}:hover & { + opacity: 1; /* Show on hover */ + } +`; + +const Marker = ({ id, color }: { id: string; color: string }) => { + return ( + + + + ); +}; + +export const MarkerType = { + primary: 'url(#primary)', + danger: 'url(#danger)', + warning: 'url(#warning)', +}; + +export const getMarker = (color: string) => { + const colorKey = color as keyof typeof MarkerType; + return MarkerType[colorKey] ?? MarkerType.primary; +}; + +export const SvgDefsMarker = () => { + const { euiTheme } = useEuiTheme(); + + return ( + + + + + + + + ); +}; diff --git a/x-pack/packages/kbn-cloud-security-posture/graph/src/components/edge/utils.ts b/x-pack/packages/kbn-cloud-security-posture/graph/src/components/edge/utils.ts new file mode 100644 index 0000000000000..91ad0e3297190 --- /dev/null +++ b/x-pack/packages/kbn-cloud-security-posture/graph/src/components/edge/utils.ts @@ -0,0 +1,29 @@ +/* + * 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 type { NodeShape } from '@kbn/cloud-security-posture-common/types/graph/latest'; + +export function getShapeHandlePosition(shape?: NodeShape) { + switch (shape) { + case 'hexagon': + return 14; + case 'pentagon': + return 14; + case 'ellipse': + return 13; + case 'rectangle': + return 16; + case 'diamond': + return 10; + case 'label': + return 3; + case 'group': + return 0; + default: + return 0; + } +} diff --git a/x-pack/packages/kbn-cloud-security-posture/graph/src/components/graph/layout_graph.ts b/x-pack/packages/kbn-cloud-security-posture/graph/src/components/graph/layout_graph.ts new file mode 100644 index 0000000000000..d9f637483c115 --- /dev/null +++ b/x-pack/packages/kbn-cloud-security-posture/graph/src/components/graph/layout_graph.ts @@ -0,0 +1,140 @@ +/* + * 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 Dagre from '@dagrejs/dagre'; +import type { + EdgeDataModel, + NodeDataModel, +} from '@kbn/cloud-security-posture-common/types/graph/latest'; +import type { NodeViewModel, Size } from '../types'; +import { calcLabelSize } from './utils'; + +export const layoutGraph = ( + nodes: NodeDataModel[], + edges: EdgeDataModel[] +): { nodes: NodeViewModel[] } => { + const nodesById: { [key: string]: NodeViewModel } = {}; + const graphOpts = { + compound: true, + }; + + const g = new Dagre.graphlib.Graph(graphOpts) + .setGraph({ rankdir: 'LR', align: 'UL' }) + .setDefaultEdgeLabel(() => ({})); + + edges.forEach((edge) => g.setEdge(edge.source, edge.target)); + + nodes.forEach((node) => { + let size = { width: 90, height: 90 }; + const position = { x: 0, y: 0 }; + + if (node.shape === 'label') { + size = calcLabelSize(node.label); + + // TODO: waiting for a fix: https://github.com/dagrejs/dagre/issues/238 + // if (node.parentId) { + // g.setParent(node.id, node.parentId); + // } + } else if (node.shape === 'group') { + const res = layoutGroupChildren(node, nodes); + + size = res.size; + + res.children.forEach((child) => { + nodesById[child.id] = { ...child }; + }); + } + + if (!nodesById[node.id]) { + nodesById[node.id] = { ...node, position }; + } + + g.setNode(node.id, { + ...node, + ...size, + }); + }); + + Dagre.layout(g); + + const nodesViewModel: NodeViewModel[] = nodes.map((nodeData) => { + const dagreNode = g.node(nodeData.id); + + // We are shifting the dagre node position (anchor=center center) to the top left + // so it matches the React Flow node anchor point (top left). + const x = dagreNode.x - (dagreNode.width ?? 0) / 2; + const y = dagreNode.y - (dagreNode.height ?? 0) / 2; + + // For grouped nodes, we want to keep the original position relative to the parent + if (nodeData.shape === 'label' && nodeData.parentId) { + return { + ...nodeData, + position: nodesById[nodeData.id].position, + }; + } else if (nodeData.shape === 'group') { + return { + ...nodeData, + position: { x, y }, + size: { + width: dagreNode.width, + height: dagreNode.height, + }, + }; + } + + return { + ...nodeData, + position: { x, y }, + }; + }); + + return { nodes: nodesViewModel }; +}; + +const layoutGroupChildren = ( + groupNode: NodeDataModel, + nodes: NodeDataModel[] +): { size: Size; children: NodeViewModel[] } => { + const children = nodes.filter( + (child) => child.shape === 'label' && child.parentId === groupNode.id + ); + + const STACK_VERTICAL_PADDING = 20; + const MIN_STACK_HEIGHT = 70; + const PADDING = 20; + const stackSize = children.length; + const allChildrenHeight = children.reduce( + (prevHeight, node) => prevHeight + calcLabelSize(node.label).height, + 0 + ); + const stackHeight = Math.max( + allChildrenHeight + (stackSize - 1) * STACK_VERTICAL_PADDING, + MIN_STACK_HEIGHT + ); + + const space = (stackHeight - allChildrenHeight) / (stackSize - 1); + const groupNodeWidth = children.reduce((acc, child) => { + const currLblWidth = PADDING * 2 + calcLabelSize(child.label).width; + return Math.max(acc, currLblWidth); + }, 0); + + // Layout children relative to parent + const positionedChildren: NodeViewModel[] = children.map((child, index) => { + const childSize = calcLabelSize(child.label); + const childPosition = { + x: groupNodeWidth / 2 - childSize.width / 2, + y: index * (childSize.height * 2 + space), + }; + + return { ...child, position: childPosition }; + }); + + return { + size: { width: groupNodeWidth, height: stackHeight }, + children: positionedChildren, + }; +}; diff --git a/x-pack/packages/kbn-cloud-security-posture/graph/src/components/graph/utils.ts b/x-pack/packages/kbn-cloud-security-posture/graph/src/components/graph/utils.ts new file mode 100644 index 0000000000000..76d7e9e0d7927 --- /dev/null +++ b/x-pack/packages/kbn-cloud-security-posture/graph/src/components/graph/utils.ts @@ -0,0 +1,49 @@ +/* + * 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 { EdgeLabelHeight, EdgeLabelWidth } from '../edge/styles'; +import { LABEL_BORDER_WIDTH, LABEL_PADDING_X } from '../node/styles'; + +const LABEL_FONT = `600 7.875px Inter, "system-ui", Helvetica, Arial, sans-serif`; +const LABEL_PADDING = (LABEL_PADDING_X + LABEL_BORDER_WIDTH) * 2; + +export const calcLabelSize = (label?: string) => { + const currLblWidth = Math.max(EdgeLabelWidth, LABEL_PADDING + getTextWidth(label ?? '')); + return { width: currLblWidth, height: EdgeLabelHeight }; +}; + +interface GetTextWidth { + (text: string, font?: string): number; + + // static canvas element for measuring text width + canvas?: HTMLCanvasElement; +} + +export const getTextWidth: GetTextWidth = (text: string, font: string = LABEL_FONT) => { + // re-use canvas object for better performance + const canvas: HTMLCanvasElement = + getTextWidth.canvas || (getTextWidth.canvas = document.createElement('canvas')); + const context = canvas.getContext('2d'); + if (context) { + context.font = font; + } + const metrics = context?.measureText(text); + return metrics?.width ?? 0; +}; + +function getCssStyle(element: HTMLElement, prop: string) { + return window.getComputedStyle(element, null).getPropertyValue(prop); +} + +// @ts-ignore will use it to get the font of the canvas on runtime +function getCanvasFont(el = document.body) { + const fontWeight = getCssStyle(el, 'font-weight') || 'normal'; + const fontSize = getCssStyle(el, 'font-size') || '16px'; + const fontFamily = getCssStyle(el, 'font-family') || 'Times New Roman'; + + return `${fontWeight} ${fontSize} ${fontFamily}`; +} 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 new file mode 100644 index 0000000000000..1fec1c76430eb --- /dev/null +++ b/x-pack/packages/kbn-cloud-security-posture/graph/src/components/index.ts @@ -0,0 +1,6 @@ +/* + * 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. + */ diff --git a/x-pack/packages/kbn-cloud-security-posture/graph/src/components/node/button.stories.tsx b/x-pack/packages/kbn-cloud-security-posture/graph/src/components/node/button.stories.tsx new file mode 100644 index 0000000000000..5e6e4cd37b432 --- /dev/null +++ b/x-pack/packages/kbn-cloud-security-posture/graph/src/components/node/button.stories.tsx @@ -0,0 +1,30 @@ +/* + * 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 { ThemeProvider } from '@emotion/react'; +import { Story } from '@storybook/react'; +import { NodeButton, type NodeButtonProps, NodeContainer } from './styles'; + +export default { + title: 'Components/Graph Components', + description: 'CDR - Graph visualization', + argTypes: { + onClick: { action: 'onClick' }, + }, +}; + +const Template: Story = (args) => ( + + + Hover me + + + +); + +export const Button = Template.bind({}); 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 new file mode 100644 index 0000000000000..76e7a3cd9eeeb --- /dev/null +++ b/x-pack/packages/kbn-cloud-security-posture/graph/src/components/node/diamond_node.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, { memo } from 'react'; +import { useEuiBackgroundColor, useEuiTheme } from '@elastic/eui'; +import { Handle, Position } from '@xyflow/react'; +import type { EntityNodeViewModel, NodeProps } from '../types'; +import { + NodeContainer, + NodeLabel, + NodeShapeOnHoverSvg, + NodeShapeSvg, + NodeIcon, + NodeButton, + HandleStyleOverride, +} from './styles'; + +const NODE_WIDTH = 99; +const NODE_HEIGHT = 98; + +export const DiamondNode: React.FC = memo((props: NodeProps) => { + const { id, color, icon, label, interactive, expandButtonClick } = + props.data as EntityNodeViewModel; + const { euiTheme } = useEuiTheme(); + return ( + + {interactive && ( + + + + )} + + + {icon && } + + {interactive && ( + expandButtonClick?.(e, props)} + x={`${NODE_WIDTH - NodeButton.ExpandButtonSize}px`} + y={`${(NODE_HEIGHT - NodeButton.ExpandButtonSize) / 2 - 4}px`} + /> + )} + + + {Boolean(label) ? label : id} + + ); +}); diff --git a/x-pack/packages/kbn-cloud-security-posture/graph/src/components/node/edge_group_node.tsx b/x-pack/packages/kbn-cloud-security-posture/graph/src/components/node/edge_group_node.tsx new file mode 100644 index 0000000000000..97b8928c421e1 --- /dev/null +++ b/x-pack/packages/kbn-cloud-security-posture/graph/src/components/node/edge_group_node.tsx @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo } from 'react'; +import { Handle, NodeResizeControl, Position } from '@xyflow/react'; +import { HandleStyleOverride } from './styles'; +import type { NodeProps } from '../types'; + +export const EdgeGroupNode: React.FC = memo((props: NodeProps) => { + // Handles order horizontally is: in > inside > out > outside + return ( + <> + + + + + + + + ); +}); diff --git a/x-pack/packages/kbn-cloud-security-posture/graph/src/components/node/ellipse_node.tsx b/x-pack/packages/kbn-cloud-security-posture/graph/src/components/node/ellipse_node.tsx new file mode 100644 index 0000000000000..3359905196b9d --- /dev/null +++ b/x-pack/packages/kbn-cloud-security-posture/graph/src/components/node/ellipse_node.tsx @@ -0,0 +1,89 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo } from 'react'; +import { useEuiBackgroundColor, useEuiTheme } from '@elastic/eui'; +import { Handle, Position } from '@xyflow/react'; +import { + NodeContainer, + NodeLabel, + NodeShapeOnHoverSvg, + NodeShapeSvg, + NodeIcon, + NodeButton, + HandleStyleOverride, +} from './styles'; +import type { EntityNodeViewModel, NodeProps } from '../types'; + +const NODE_WIDTH = 90; +const NODE_HEIGHT = 90; + +export const EllipseNode: React.FC = memo((props: NodeProps) => { + const { id, color, icon, label, interactive, expandButtonClick } = + props.data as EntityNodeViewModel; + const { euiTheme } = useEuiTheme(); + return ( + + {interactive && ( + + + + )} + + + {icon && } + + {interactive && ( + expandButtonClick?.(e, props)} + x={`${NODE_WIDTH - NodeButton.ExpandButtonSize / 2}px`} + y={`${(NODE_HEIGHT - NodeButton.ExpandButtonSize) / 2}px`} + /> + )} + + + {Boolean(label) ? label : id} + + ); +}); diff --git a/x-pack/packages/kbn-cloud-security-posture/graph/src/components/node/get_span_icon.ts b/x-pack/packages/kbn-cloud-security-posture/graph/src/components/node/get_span_icon.ts new file mode 100644 index 0000000000000..9aa77f03c3eb6 --- /dev/null +++ b/x-pack/packages/kbn-cloud-security-posture/graph/src/components/node/get_span_icon.ts @@ -0,0 +1,22 @@ +/* + * 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 awsIcon from '../../assets/icons/aws.svg'; +import awsEc2Icon from '../../assets/icons/aws_ec2.svg'; +import awsS3Icon from '../../assets/icons/aws_s3.svg'; +import oktaIcon from '../../assets/icons/okta.svg'; + +const icons: Record = { + aws: awsIcon, + aws_ec2: awsEc2Icon, + aws_s3: awsS3Icon, + okta: oktaIcon, +}; + +export function getSpanIcon(type?: string) { + return icons[type ?? '']; +} diff --git a/x-pack/packages/kbn-cloud-security-posture/graph/src/components/node/hexagon_node.tsx b/x-pack/packages/kbn-cloud-security-posture/graph/src/components/node/hexagon_node.tsx new file mode 100644 index 0000000000000..dee8df697c844 --- /dev/null +++ b/x-pack/packages/kbn-cloud-security-posture/graph/src/components/node/hexagon_node.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, { memo } from 'react'; +import { useEuiBackgroundColor, useEuiTheme } from '@elastic/eui'; +import { Handle, Position } from '@xyflow/react'; +import { + NodeContainer, + NodeLabel, + NodeShapeOnHoverSvg, + NodeShapeSvg, + NodeIcon, + NodeButton, + HandleStyleOverride, +} from './styles'; +import type { EntityNodeViewModel, NodeProps } from '../types'; + +const NODE_WIDTH = 87; +const NODE_HEIGHT = 96; + +export const HexagonNode: React.FC = memo((props: NodeProps) => { + const { id, color, icon, label, interactive, expandButtonClick } = + props.data as EntityNodeViewModel; + const { euiTheme } = useEuiTheme(); + return ( + + {interactive && ( + + + + )} + + + {icon && } + + {interactive && ( + expandButtonClick?.(e, props)} + x={`${NODE_WIDTH - NodeButton.ExpandButtonSize / 2 + 2}px`} + y={`${(NODE_HEIGHT - NodeButton.ExpandButtonSize) / 2 - 2}px`} + /> + )} + + + {Boolean(label) ? label : id} + + ); +}); diff --git a/x-pack/packages/kbn-cloud-security-posture/graph/src/components/node/index.ts b/x-pack/packages/kbn-cloud-security-posture/graph/src/components/node/index.ts new file mode 100644 index 0000000000000..0e384fd3fc3cf --- /dev/null +++ b/x-pack/packages/kbn-cloud-security-posture/graph/src/components/node/index.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { DiamondNode } from './diamond_node'; +export { EllipseNode } from './ellipse_node'; +export { HexagonNode } from './hexagon_node'; +export { PentagonNode } from './pentagon_node'; +export { RectangleNode } from './rectangle_node'; +export { LabelNode } from './label_node'; +export { EdgeGroupNode } from './edge_group_node'; diff --git a/x-pack/packages/kbn-cloud-security-posture/graph/src/components/node/label_node.tsx b/x-pack/packages/kbn-cloud-security-posture/graph/src/components/node/label_node.tsx new file mode 100644 index 0000000000000..2f23e5ab07513 --- /dev/null +++ b/x-pack/packages/kbn-cloud-security-posture/graph/src/components/node/label_node.tsx @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo } from 'react'; +import { Handle, Position } from '@xyflow/react'; +import { LabelNodeContainer, LabelShape, HandleStyleOverride, LabelShapeOnHover } from './styles'; +import type { LabelNodeViewModel, NodeProps } from '../types'; + +export const LabelNode: React.FC = memo((props: NodeProps) => { + const { id, color, label, interactive } = props.data as LabelNodeViewModel; + + return ( + + {interactive && } + + {Boolean(label) ? label : id} + + + + + ); +}); diff --git a/x-pack/packages/kbn-cloud-security-posture/graph/src/components/node/node.stories.tsx b/x-pack/packages/kbn-cloud-security-posture/graph/src/components/node/node.stories.tsx new file mode 100644 index 0000000000000..37f9f26ee975e --- /dev/null +++ b/x-pack/packages/kbn-cloud-security-posture/graph/src/components/node/node.stories.tsx @@ -0,0 +1,73 @@ +/* + * 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 { ThemeProvider } from '@emotion/react'; +import { pick } from 'lodash'; +import { ReactFlow, Controls, Background } from '@xyflow/react'; +import { Story } from '@storybook/react'; +import { NodeViewModel } from '../types'; +import { HexagonNode, PentagonNode, EllipseNode, RectangleNode, DiamondNode, LabelNode } from '.'; + +import '@xyflow/react/dist/style.css'; + +export default { + title: 'Components/Graph Components', + description: 'CDR - Graph visualization', + argTypes: { + color: { + options: ['primary', 'danger', 'warning'], + control: { type: 'radio' }, + }, + shape: { + options: ['ellipse', 'hexagon', 'pentagon', 'rectangle', 'diamond', 'label'], + control: { type: 'radio' }, + }, + expandButtonClick: { action: 'expandButtonClick' }, + }, +}; + +const nodeTypes = { + hexagon: HexagonNode, + pentagon: PentagonNode, + ellipse: EllipseNode, + rectangle: RectangleNode, + diamond: DiamondNode, + label: LabelNode, +}; + +const Template: Story = (args: NodeViewModel) => ( + + + + + + +); + +export const Node = Template.bind({}); + +Node.args = { + id: 'siem-windows', + label: '', + color: 'primary', + shape: 'hexagon', + icon: 'okta', + interactive: true, +}; 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 new file mode 100644 index 0000000000000..74ea8c05b5940 --- /dev/null +++ b/x-pack/packages/kbn-cloud-security-posture/graph/src/components/node/pentagon_node.tsx @@ -0,0 +1,90 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo } from 'react'; +import { useEuiBackgroundColor, useEuiTheme } from '@elastic/eui'; +import styled from '@emotion/styled'; +import { Handle, Position } from '@xyflow/react'; +import { + NodeContainer, + NodeLabel, + NodeShapeOnHoverSvg, + NodeShapeSvg, + NodeIcon, + NodeButton, + HandleStyleOverride, +} from './styles'; +import type { EntityNodeViewModel, NodeProps } from '../types'; + +const PentagonShapeOnHover = styled(NodeShapeOnHoverSvg)` + transform: translate(-50%, -51.5%); +`; + +const NODE_WIDTH = 91; +const NODE_HEIGHT = 88; + +export const PentagonNode: React.FC = memo((props: NodeProps) => { + const { id, color, icon, label, interactive, expandButtonClick } = + props.data as EntityNodeViewModel; + const { euiTheme } = useEuiTheme(); + return ( + + {interactive && ( + + + + )} + + + {icon && } + + {interactive && ( + expandButtonClick?.(e, props)} + x={`${NODE_WIDTH - NodeButton.ExpandButtonSize / 2}px`} + y={`${(NODE_HEIGHT - NodeButton.ExpandButtonSize) / 2}px`} + /> + )} + + + {Boolean(label) ? label : id} + + ); +}); diff --git a/x-pack/packages/kbn-cloud-security-posture/graph/src/components/node/rectangle_node.tsx b/x-pack/packages/kbn-cloud-security-posture/graph/src/components/node/rectangle_node.tsx new file mode 100644 index 0000000000000..22d9fbf25a4eb --- /dev/null +++ b/x-pack/packages/kbn-cloud-security-posture/graph/src/components/node/rectangle_node.tsx @@ -0,0 +1,93 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { memo } from 'react'; +import { useEuiBackgroundColor, useEuiTheme } from '@elastic/eui'; +import { Handle, Position } from '@xyflow/react'; +import { + NodeContainer, + NodeLabel, + NodeShapeOnHoverSvg, + NodeShapeSvg, + NodeIcon, + NodeButton, + HandleStyleOverride, +} from './styles'; +import type { EntityNodeViewModel, NodeProps } from '../types'; + +const NODE_WIDTH = 81; +const NODE_HEIGHT = 80; + +export const RectangleNode: React.FC = memo((props: NodeProps) => { + const { id, color, icon, label, interactive, expandButtonClick } = + props.data as EntityNodeViewModel; + const { euiTheme } = useEuiTheme(); + return ( + + {interactive && ( + + + + )} + + + {icon && } + + {interactive && ( + expandButtonClick?.(e, props)} + x={`${NODE_WIDTH - NodeButton.ExpandButtonSize / 4}px`} + y={`${(NODE_HEIGHT - NodeButton.ExpandButtonSize / 2) / 2}px`} + /> + )} + + + {Boolean(label) ? label : id} + + ); +}); diff --git a/x-pack/packages/kbn-cloud-security-posture/graph/src/components/node/styles.tsx b/x-pack/packages/kbn-cloud-security-posture/graph/src/components/node/styles.tsx new file mode 100644 index 0000000000000..f1bee0cf95e30 --- /dev/null +++ b/x-pack/packages/kbn-cloud-security-posture/graph/src/components/node/styles.tsx @@ -0,0 +1,218 @@ +/* + * 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, { useState } from 'react'; +import styled from '@emotion/styled'; +import { + type EuiIconProps, + type _EuiBackgroundColor, + EuiButtonIcon, + EuiIcon, + EuiText, + useEuiBackgroundColor, + useEuiTheme, +} from '@elastic/eui'; +import { rgba } from 'polished'; +import { getSpanIcon } from './get_span_icon'; + +export const LABEL_PADDING_X = 15; +export const LABEL_BORDER_WIDTH = 1; +export const NODE_WIDTH = 90; +export const NODE_HEIGHT = 90; + +export const LabelNodeContainer = styled.div` + text-wrap: nowrap; + min-width: 100px; + height: 24px; +`; + +export const LabelShape = styled(EuiText)` + background: ${(props) => useEuiBackgroundColor(props.color as _EuiBackgroundColor)}; + border: ${(props) => { + const { euiTheme } = useEuiTheme(); + return `solid ${ + euiTheme.colors[props.color as keyof typeof euiTheme.colors] + } ${LABEL_BORDER_WIDTH}px`; + }}; + + font-weight: ${(_props) => { + const { euiTheme } = useEuiTheme(); + return `${euiTheme.font.weight.semiBold}`; + }}; + font-size: ${(_props) => { + const { euiTheme } = useEuiTheme(); + return `${euiTheme.font.scale.xs * 10.5}px`; + }}; + + line-height: 1.5; + + padding: 5px ${LABEL_PADDING_X}px; + border-radius: 16px; + min-height: 100%; + min-width: 100%; +`; + +export const LabelShapeOnHover = styled.div` + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + + opacity: 0; /* Hidden by default */ + transition: opacity 0.2s ease; /* Smooth transition */ + border: ${(props) => { + const { euiTheme } = useEuiTheme(); + return `dashed ${rgba( + euiTheme.colors[props.color as keyof typeof euiTheme.colors] as string, + 0.5 + )} 1px`; + }}; + border-radius: 20px; + background: transparent; + width: 108%; + height: 134%; + + ${LabelNodeContainer}:hover & { + opacity: 1; /* Show on hover */ + } +`; + +export const NodeContainer = styled.div` + position: relative; + width: ${NODE_WIDTH}px; + height: ${NODE_HEIGHT}px; +`; + +export const NodeShapeSvg = styled.svg` + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + z-index: 1; +`; + +export const NodeShapeOnHoverSvg = styled(NodeShapeSvg)` + opacity: 0; /* Hidden by default */ + transition: opacity 0.2s ease; /* Smooth transition */ + + ${NodeContainer}:hover & { + opacity: 1; /* Show on hover */ + } +`; + +interface NodeIconProps { + icon: string; + color?: EuiIconProps['color']; + x: string; + y: string; +} + +export const NodeIcon = ({ icon, color, x, y }: NodeIconProps) => { + return ( + +
+ +
+
+ ); +}; + +export const NodeLabel = styled(EuiText)` + position: absolute; + top: 108%; + left: 50%; + transform: translateX(-50%); + width: 130%; + text-overflow: ellipsis; + // white-space: nowrap; + overflow: hidden; +`; + +NodeLabel.defaultProps = { + size: 'xs', + textAlign: 'center', +}; + +const ExpandButtonSize = 18; + +const RoundEuiButtonIcon = styled(EuiButtonIcon)` + border-radius: 50%; + background-color: ${(_props) => useEuiBackgroundColor('plain')}; + width: ${ExpandButtonSize}px; + height: ${ExpandButtonSize}px; + + > svg { + transform: translate(0.75px, 0.75px); + } + + :hover, + :focus, + :active { + background-color: ${(_props) => useEuiBackgroundColor('plain')}; + } +`; + +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; + + ${NodeContainer}:hover & { + opacity: 1; /* Show on hover */ + } +`; + +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', +}; + +export const GroupStyleOverride = (size?: { + width: number; + height: number; +}): React.CSSProperties => ({ + backgroundColor: 'transparent', + border: '0px solid', + boxShadow: 'none', + width: size?.width ?? 140, + height: size?.height ?? 75, +}); 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 new file mode 100644 index 0000000000000..262254c80afe3 --- /dev/null +++ b/x-pack/packages/kbn-cloud-security-posture/graph/src/components/types.ts @@ -0,0 +1,70 @@ +/* + * 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 type { + EntityNodeDataModel, + GroupNodeDataModel, + LabelNodeDataModel, + EdgeDataModel, +} from '@kbn/cloud-security-posture-common/types/graph/latest'; +import type { Node, NodeProps as xyNodeProps } from '@xyflow/react'; +import type { Edge, EdgeProps as xyEdgeProps } from '@xyflow/react'; + +export interface PositionXY { + x: number; + y: number; +} + +export interface Size { + width: number; + height: number; +} + +export interface GraphMetadata { + nodes: { [key: string]: { edgesIn: number; edgesOut: number } }; + edges: { + [key: string]: { source: string; target: string; edgesStacked: number; edges: string[] }; + }; +} + +interface BaseNodeDataViewModel { + position: PositionXY; + interactive?: boolean; +} + +export interface EntityNodeViewModel + extends Record, + EntityNodeDataModel, + BaseNodeDataViewModel { + expandButtonClick?: (e: React.MouseEvent, node: NodeProps) => void; +} + +export interface GroupNodeViewModel + extends Record, + GroupNodeDataModel, + BaseNodeDataViewModel { + size?: Size; +} + +export interface LabelNodeViewModel + extends Record, + LabelNodeDataModel, + BaseNodeDataViewModel { + expandButtonClick?: (e: React.MouseEvent, node: NodeProps) => void; +} + +export type NodeViewModel = EntityNodeViewModel | GroupNodeViewModel | LabelNodeViewModel; + +export type NodeProps = xyNodeProps>; + +export interface EdgeViewModel extends Record, EdgeDataModel { + graphMetadata?: GraphMetadata; + interactive?: boolean; + onClick?: (e: React.MouseEvent, edge: EdgeProps) => void; +} + +export type EdgeProps = xyEdgeProps>; diff --git a/x-pack/packages/kbn-cloud-security-posture/graph/tsconfig.json b/x-pack/packages/kbn-cloud-security-posture/graph/tsconfig.json new file mode 100644 index 0000000000000..55cafeb069d0f --- /dev/null +++ b/x-pack/packages/kbn-cloud-security-posture/graph/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "../../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "target/types" + }, + "include": [ + "**/*.ts", + "**/*.tsx" + ], + "exclude": [ + "target/**/*" + ], + "kbn_references": [ + "@kbn/cloud-security-posture-common", + "@kbn/utility-types" + ] +} diff --git a/x-pack/packages/kbn-cloud-security-posture/storybook/config/constants.ts b/x-pack/packages/kbn-cloud-security-posture/storybook/config/constants.ts new file mode 100644 index 0000000000000..176d9defb7ab9 --- /dev/null +++ b/x-pack/packages/kbn-cloud-security-posture/storybook/config/constants.ts @@ -0,0 +1,13 @@ +/* + * 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. + */ + +/** The title of the Storybook. */ +export const TITLE = 'Cloud Security Posture Storybook'; + +/** The remote URL of the root from which Storybook loads stories for Cloud Security Solution. */ +export const URL = + 'https://github.com/elastic/kibana/tree/main/x-pack/packages/kbn-cloud-security-posture'; diff --git a/x-pack/packages/kbn-cloud-security-posture/storybook/config/index.ts b/x-pack/packages/kbn-cloud-security-posture/storybook/config/index.ts new file mode 100755 index 0000000000000..c8d2302efe529 --- /dev/null +++ b/x-pack/packages/kbn-cloud-security-posture/storybook/config/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { TITLE, URL } from './constants'; diff --git a/x-pack/packages/kbn-cloud-security-posture/storybook/config/main.ts b/x-pack/packages/kbn-cloud-security-posture/storybook/config/main.ts new file mode 100644 index 0000000000000..186e1a2a76bed --- /dev/null +++ b/x-pack/packages/kbn-cloud-security-posture/storybook/config/main.ts @@ -0,0 +1,44 @@ +/* + * 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 { defaultConfig } from '@kbn/storybook'; +import { Configuration } from 'webpack'; + +module.exports = { + ...defaultConfig, + stories: ['../../**/*.stories.+(tsx|mdx)'], + reactOptions: { + strictMode: true, + }, + webpack: (config: Configuration) => { + config.module?.rules.push({ + test: /\.js$/, + include: /node_modules[\\\/]@dagrejs/, + use: { + loader: 'babel-loader', + options: { + presets: ['@babel/preset-env'], + plugins: ['@babel/plugin-proposal-class-properties'], + }, + }, + }); + config.module?.rules.push({ + test: /node_modules[\/\\]@?xyflow[\/\\].*.js$/, + loaders: 'babel-loader', + options: { + presets: [['@babel/preset-env', { modules: false }], '@babel/preset-react'], + plugins: [ + '@babel/plugin-proposal-optional-chaining', + '@babel/plugin-proposal-nullish-coalescing-operator', + '@babel/plugin-transform-logical-assignment-operators', + ], + }, + }); + + return config; + }, +}; diff --git a/x-pack/packages/kbn-cloud-security-posture/storybook/config/manager.ts b/x-pack/packages/kbn-cloud-security-posture/storybook/config/manager.ts new file mode 100644 index 0000000000000..5dff8fa3fcbd6 --- /dev/null +++ b/x-pack/packages/kbn-cloud-security-posture/storybook/config/manager.ts @@ -0,0 +1,22 @@ +/* + * 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 { addons } from '@storybook/addons'; +import { create } from '@storybook/theming'; +import { PANEL_ID as selectedPanel } from '@storybook/addon-actions'; + +import { TITLE as brandTitle, URL as brandUrl } from './constants'; + +addons.setConfig({ + theme: create({ + base: 'light', + brandTitle, + brandUrl, + }), + selectedPanel, + showPanel: true.valueOf, +}); diff --git a/x-pack/packages/kbn-cloud-security-posture/storybook/config/preview.ts b/x-pack/packages/kbn-cloud-security-posture/storybook/config/preview.ts new file mode 100644 index 0000000000000..fe9d78424f240 --- /dev/null +++ b/x-pack/packages/kbn-cloud-security-posture/storybook/config/preview.ts @@ -0,0 +1,10 @@ +/* + * 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 './styles.css'; + +export const parameters = {}; diff --git a/x-pack/packages/kbn-cloud-security-posture/storybook/config/styles.css b/x-pack/packages/kbn-cloud-security-posture/storybook/config/styles.css new file mode 100644 index 0000000000000..9144ae8d64dd1 --- /dev/null +++ b/x-pack/packages/kbn-cloud-security-posture/storybook/config/styles.css @@ -0,0 +1,7 @@ +html, body, #root { + width: 100%; + height: 100%; + margin: 0; + padding: 0; + box-sizing: border-box; +} diff --git a/x-pack/packages/kbn-cloud-security-posture/tsconfig.json b/x-pack/packages/kbn-cloud-security-posture/tsconfig.json index a4a5376009d9a..38799e07182d9 100644 --- a/x-pack/packages/kbn-cloud-security-posture/tsconfig.json +++ b/x-pack/packages/kbn-cloud-security-posture/tsconfig.json @@ -2,12 +2,6 @@ "extends": "../../../tsconfig.base.json", "compilerOptions": { "outDir": "target/types", - "types": [ - "jest", - "node", - "react", - "@emotion/react/types/css-prop" - ] }, "include": [ "**/*.ts", @@ -41,5 +35,6 @@ "@kbn/ui-theme", "@kbn/i18n-react", "@kbn/rison", + "@kbn/storybook", ] } diff --git a/x-pack/plugins/cloud_security_posture/common/constants.ts b/x-pack/plugins/cloud_security_posture/common/constants.ts index 474f29b859305..efc56a0da7995 100644 --- a/x-pack/plugins/cloud_security_posture/common/constants.ts +++ b/x-pack/plugins/cloud_security_posture/common/constants.ts @@ -22,6 +22,9 @@ export const BENCHMARKS_API_CURRENT_VERSION = '1'; export const FIND_CSP_BENCHMARK_RULE_ROUTE_PATH = '/internal/cloud_security_posture/rules/_find'; export const FIND_CSP_BENCHMARK_RULE_API_CURRENT_VERSION = '1'; +export const GRAPH_ROUTE_PATH = '/internal/cloud_security_posture/graph'; +export const GRAPH_API_CURRENT_VERSION = '1'; + export const CSP_BENCHMARK_RULES_BULK_ACTION_ROUTE_PATH = '/internal/cloud_security_posture/rules/_bulk_action'; export const CSP_BENCHMARK_RULES_BULK_ACTION_API_CURRENT_VERSION = '1'; diff --git a/x-pack/plugins/cloud_security_posture/server/routes/graph/route.ts b/x-pack/plugins/cloud_security_posture/server/routes/graph/route.ts new file mode 100644 index 0000000000000..9ff15c2be73e6 --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/server/routes/graph/route.ts @@ -0,0 +1,70 @@ +/* + * 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 { + graphRequestSchema, + graphResponseSchema, +} from '@kbn/cloud-security-posture-common/schema/graph/latest'; +import { transformError } from '@kbn/securitysolution-es-utils'; +import { GRAPH_ROUTE_PATH } from '../../../common/constants'; +import { CspRouter } from '../../types'; +import { getGraph as getGraphV1 } from './v1'; + +export const defineGraphRoute = (router: CspRouter) => + router.versioned + .post({ + access: 'internal', + enableQueryVersion: true, + path: GRAPH_ROUTE_PATH, + options: { + tags: ['access:cloud-security-posture-read'], + }, + }) + .addVersion( + { + version: '1', + validate: { + request: { + body: graphRequestSchema, + }, + response: { + 200: { body: graphResponseSchema }, + }, + }, + }, + async (context, request, response) => { + const { actorIds, eventIds, start, end } = request.body.query; + const cspContext = await context.csp; + const spaceId = (await cspContext.spaces?.spacesService?.getActiveSpace(request))?.id; + + try { + const { nodes, edges } = await getGraphV1( + { + logger: cspContext.logger, + esClient: cspContext.esClient, + }, + { + actorIds, + eventIds, + spaceId, + start, + end, + } + ); + + return response.ok({ body: { nodes, edges } }); + } catch (err) { + const error = transformError(err); + cspContext.logger.error(`Failed to fetch graph ${err}`); + cspContext.logger.error(err); + return response.customError({ + body: { message: error.message }, + statusCode: error.statusCode, + }); + } + } + ); diff --git a/x-pack/plugins/cloud_security_posture/server/routes/graph/types.ts b/x-pack/plugins/cloud_security_posture/server/routes/graph/types.ts new file mode 100644 index 0000000000000..ba32664da6233 --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/server/routes/graph/types.ts @@ -0,0 +1,23 @@ +/* + * 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 type { + EdgeDataModel, + NodeDataModel, +} from '@kbn/cloud-security-posture-common/types/graph/latest'; +import type { Logger, IScopedClusterClient } from '@kbn/core/server'; +import type { Writable } from '@kbn/utility-types'; + +export interface GraphContextServices { + logger: Logger; + esClient: IScopedClusterClient; +} + +export interface GraphContext { + nodes: Array>; + edges: Array>; +} diff --git a/x-pack/plugins/cloud_security_posture/server/routes/graph/v1.ts b/x-pack/plugins/cloud_security_posture/server/routes/graph/v1.ts new file mode 100644 index 0000000000000..eb372a2bfea4f --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/server/routes/graph/v1.ts @@ -0,0 +1,353 @@ +/* + * 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 { castArray } from 'lodash'; +import type { Logger, IScopedClusterClient } from '@kbn/core/server'; +import type { + EdgeDataModel, + NodeDataModel, + EntityNodeDataModel, + LabelNodeDataModel, + GroupNodeDataModel, + NodeShape, +} from '@kbn/cloud-security-posture-common/types/graph/latest'; +import type { EsqlToRecords } from '@elastic/elasticsearch/lib/helpers'; +import type { Writeable } from '@kbn/zod'; +import type { GraphContextServices, GraphContext } from './types'; + +interface GraphEdge { + badge: number; + ips: string[]; + hosts: string[]; + users: string[]; + actorIds: string[] | string; + action: string; + targetIds: string[] | string; + eventOutcome: string; + isAlert: boolean; +} + +export const getGraph = async ( + services: GraphContextServices, + query: { + actorIds: string[]; + eventIds: string[]; + spaceId?: string; + start: string | number; + end: string | number; + } +): Promise<{ + nodes: NodeDataModel[]; + edges: EdgeDataModel[]; +}> => { + const { esClient, logger } = services; + const { actorIds, eventIds, spaceId = 'default', start, end } = query; + + logger.trace( + `Fetching graph for [eventIds: ${eventIds.join(', ')}] [actorIds: ${actorIds.join( + ', ' + )}] in [spaceId: ${spaceId}]` + ); + + const results = await fetchGraph({ esClient, logger, start, end, eventIds, actorIds }); + + // Convert results into set of nodes and edges + const graphContext = parseRecords(logger, results.records); + + return { nodes: graphContext.nodes, edges: graphContext.edges }; +}; + +interface ParseContext { + nodesMap: Record; + edgeLabelsNodes: Record; + edgesMap: Record; +} + +const parseRecords = (logger: Logger, records: GraphEdge[]): GraphContext => { + const nodesMap: Record = {}; + const edgeLabelsNodes: Record = {}; + const edgesMap: Record = {}; + + logger.trace(`Parsing records [length: ${records.length}]`); + + createNodes(logger, records, { nodesMap, edgeLabelsNodes }); + createEdgesAndGroups(logger, { edgeLabelsNodes, edgesMap, nodesMap }); + + logger.trace( + `Parsed [nodes: ${Object.keys(nodesMap).length}, edges: ${Object.keys(edgesMap).length}]` + ); + + // Sort groups to be first (fixes minor layout issue) + const nodes = sortNodes(nodesMap); + + return { nodes, edges: Object.values(edgesMap) }; +}; + +const fetchGraph = async ({ + esClient, + logger, + start, + end, + actorIds, + eventIds, +}: { + esClient: IScopedClusterClient; + logger: Logger; + start: string | number; + end: string | number; + actorIds: string[]; + eventIds: string[]; +}): Promise> => { + const query = `from logs-* +| WHERE event.action IS NOT NULL AND actor.entity.id IS NOT NULL +| EVAL isAlert = ${ + eventIds.length > 0 + ? `event.id in (${eventIds.map((_id, idx) => `?al_id${idx}`).join(', ')})` + : 'false' + } +| STATS badge = COUNT(*), + ips = VALUES(related.ip), + // hosts = VALUES(related.hosts), + users = VALUES(related.user) + by actorIds = actor.entity.id, + action = event.action, + targetIds = target.entity.id, + eventOutcome = event.outcome, + isAlert +| LIMIT 1000`; + + logger.trace(`Executing query [${query}]`); + + return await esClient.asCurrentUser.helpers + .esql({ + columnar: false, + filter: { + bool: { + must: [ + { + range: { + '@timestamp': { + gte: start, + lte: end, + }, + }, + }, + { + bool: { + should: [ + { + terms: { + 'event.id': eventIds, + }, + }, + { + terms: { + 'actor.entity.id': actorIds, + }, + }, + ], + minimum_should_match: 1, + }, + }, + ], + }, + }, + query, + // @ts-ignore - types are not up to date + params: [...eventIds.map((id, idx) => ({ [`al_id${idx}`]: id }))], + }) + .toRecords(); +}; + +const createNodes = ( + logger: Logger, + records: GraphEdge[], + context: Omit +) => { + const { nodesMap, edgeLabelsNodes } = context; + + for (const record of records) { + const { ips, hosts, users, actorIds, action, targetIds, isAlert, eventOutcome } = record; + const actorIdsArray = castArray(actorIds); + const targetIdsArray = castArray(targetIds); + + logger.trace( + `Parsing record [actorIds: ${actorIdsArray.join( + ', ' + )}, action: ${action}, targetIds: ${targetIdsArray.join(', ')}]` + ); + + // Create entity nodes + [...actorIdsArray, ...targetIdsArray].forEach((id) => { + if (nodesMap[id] === undefined) { + nodesMap[id] = { + id, + label: id, + color: isAlert ? 'danger' : 'primary', + ...determineEntityNodeShape(id, ips ?? [], hosts ?? [], users ?? []), + }; + + logger.trace(`Creating entity node [${id}]`); + } + }); + + // Create label nodes + for (const actorId of actorIdsArray) { + for (const targetId of targetIdsArray) { + const edgeId = `a(${actorId})-b(${targetId})`; + + if (edgeLabelsNodes[edgeId] === undefined) { + edgeLabelsNodes[edgeId] = []; + } + + const labelNode = { + id: edgeId + `label(${action})outcome(${eventOutcome})`, + label: action, + source: actorId, + target: targetId, + color: isAlert ? 'danger' : eventOutcome === 'failed' ? 'warning' : 'primary', + shape: 'label', + } as LabelNodeDataModel; + + logger.trace(`Creating label node [${labelNode.id}]`); + + nodesMap[labelNode.id] = labelNode; + edgeLabelsNodes[edgeId].push(labelNode.id); + } + } + } +}; + +const determineEntityNodeShape = ( + actorId: string, + ips: string[], + hosts: string[], + users: string[] +): { + shape: EntityNodeDataModel['shape']; + icon: string; +} => { + // If actor is a user return ellipse + if (users.includes(actorId)) { + return { shape: 'ellipse', icon: 'user' }; + } + + // If actor is a host return hexagon + if (hosts.includes(actorId)) { + return { shape: 'hexagon', icon: 'storage' }; + } + + // If actor is an IP return diamond + if (ips.includes(actorId)) { + return { shape: 'diamond', icon: 'globe' }; + } + + return { shape: 'hexagon', icon: 'questionInCircle' }; +}; + +const sortNodes = (nodesMap: Record) => { + const groupNodes = []; + const otherNodes = []; + + for (const node of Object.values(nodesMap)) { + if (node.shape === 'group') { + groupNodes.push(node); + } else { + otherNodes.push(node); + } + } + + return [...groupNodes, ...otherNodes]; +}; + +const createEdgesAndGroups = (logger: Logger, context: ParseContext) => { + const { edgeLabelsNodes, edgesMap, nodesMap } = context; + + Object.entries(edgeLabelsNodes).forEach(([edgeId, edgeLabelsIds]) => { + // When there's more than one edge label, create a group node + if (edgeLabelsIds.length === 1) { + const edgeLabelId = edgeLabelsIds[0]; + + connectEntitiesAndLabelNode( + logger, + edgesMap, + nodesMap, + (nodesMap[edgeLabelId] as LabelNodeDataModel).source, + edgeLabelId, + (nodesMap[edgeLabelId] as LabelNodeDataModel).target + ); + } else { + const groupNode: GroupNodeDataModel = { + id: `grp(${edgeId})`, + shape: 'group', + }; + nodesMap[groupNode.id] = groupNode; + + connectEntitiesAndLabelNode( + logger, + edgesMap, + nodesMap, + (nodesMap[edgeLabelsIds[0]] as LabelNodeDataModel).source, + groupNode.id, + (nodesMap[edgeLabelsIds[0]] as LabelNodeDataModel).target + ); + + edgeLabelsIds.forEach((edgeLabelId) => { + (nodesMap[edgeLabelId] as Writeable).parentId = groupNode.id; + connectEntitiesAndLabelNode( + logger, + edgesMap, + nodesMap, + groupNode.id, + edgeLabelId, + groupNode.id + ); + }); + } + }); +}; + +const connectEntitiesAndLabelNode = ( + logger: Logger, + edgesMap: Record, + nodesMap: Record, + sourceNodeId: string, + labelNodeId: string, + targetNodeId: string +) => { + [ + connectNodes(nodesMap, sourceNodeId, labelNodeId), + connectNodes(nodesMap, labelNodeId, targetNodeId), + ].forEach((edge) => { + logger.trace(`Connecting nodes [${edge.source} -> ${edge.target}]`); + edgesMap[edge.id] = edge; + }); +}; + +const connectNodes = ( + nodesMap: Record, + sourceNodeId: string, + targetNodeId: string +) => { + const sourceNode = nodesMap[sourceNodeId]; + const targetNode = nodesMap[targetNodeId]; + const color = + sourceNode.shape !== 'group' && targetNode.shape !== 'label' + ? sourceNode.color + : targetNode.shape !== 'group' + ? targetNode.color + : 'primary'; + + return { + id: `a(${sourceNodeId})-b(${targetNodeId})`, + source: sourceNodeId, + sourceShape: nodesMap[sourceNodeId].shape as NodeShape, + target: targetNodeId, + targetShape: nodesMap[targetNodeId].shape as NodeShape, + color, + } as EdgeDataModel; +}; diff --git a/x-pack/plugins/cloud_security_posture/server/routes/setup_routes.ts b/x-pack/plugins/cloud_security_posture/server/routes/setup_routes.ts index 51d9e591b1987..af97d198b1651 100644 --- a/x-pack/plugins/cloud_security_posture/server/routes/setup_routes.ts +++ b/x-pack/plugins/cloud_security_posture/server/routes/setup_routes.ts @@ -27,6 +27,7 @@ import { defineGetDetectionEngineAlertsStatus } from './detection_engine/get_det import { defineBulkActionCspBenchmarkRulesRoute } from './benchmark_rules/bulk_action/bulk_action'; import { defineGetCspBenchmarkRulesStatesRoute } from './benchmark_rules/get_states/get_states'; import { setupCdrDataViews } from '../saved_objects/data_views'; +import { defineGraphRoute } from './graph/route'; /** * 1. Registers routes @@ -50,6 +51,7 @@ export function setupRoutes({ defineGetDetectionEngineAlertsStatus(router); defineBulkActionCspBenchmarkRulesRoute(router); defineGetCspBenchmarkRulesStatesRoute(router); + defineGraphRoute(router); core.http.registerOnPreRouting(async (request, response, toolkit) => { if (request.url.pathname.includes(CLOUD_SECURITY_INTERTAL_PREFIX_ROUTE_PATH)) { diff --git a/x-pack/plugins/cloud_security_posture/server/types.ts b/x-pack/plugins/cloud_security_posture/server/types.ts index 4524cab151dd2..df812fe534722 100644 --- a/x-pack/plugins/cloud_security_posture/server/types.ts +++ b/x-pack/plugins/cloud_security_posture/server/types.ts @@ -39,6 +39,7 @@ import { SpacesPluginStart } from '@kbn/spaces-plugin/server'; // eslint-disable-next-line @typescript-eslint/no-empty-interface export interface CspServerPluginSetup {} + // eslint-disable-next-line @typescript-eslint/no-empty-interface export interface CspServerPluginStart {} @@ -78,6 +79,8 @@ export interface CspApiRequestHandlerContext { agentService: AgentService; packagePolicyService: PackagePolicyClient; packageService: PackageService; + spaces?: SpacesPluginStart; + isPluginInitialized(): boolean; } diff --git a/x-pack/plugins/cloud_security_posture/tsconfig.json b/x-pack/plugins/cloud_security_posture/tsconfig.json index 501f9a560cdf6..456035c9112d1 100755 --- a/x-pack/plugins/cloud_security_posture/tsconfig.json +++ b/x-pack/plugins/cloud_security_posture/tsconfig.json @@ -65,7 +65,8 @@ "@kbn/spaces-plugin", "@kbn/cloud-security-posture-common", "@kbn/cloud-security-posture", - "@kbn/analytics" + "@kbn/analytics", + "@kbn/zod" ], "exclude": ["target/**/*"] } diff --git a/x-pack/test/cloud_security_posture_api/es_archives/logs_gcp_audit/data.json b/x-pack/test/cloud_security_posture_api/es_archives/logs_gcp_audit/data.json new file mode 100644 index 0000000000000..9f536d0bb6dc9 --- /dev/null +++ b/x-pack/test/cloud_security_posture_api/es_archives/logs_gcp_audit/data.json @@ -0,0 +1,499 @@ +{ + "type": "doc", + "value": { + "data_stream": "logs-gcp.audit-default", + "id": "1", + "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.CreateRole", + "agent_id_status": "missing", + "category": [ + "session", + "network", + "configuration" + ], + "id": "kabcd1234efgh5678", + "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" + } + } + } +} + +{ + "type": "doc", + "value": { + "data_stream": "logs-gcp.audit-default", + "id": "2", + "index": ".ds-logs-gcp.audit-default-2024.10.07-000001", + "source": { + "@timestamp": "2024-09-01T12:34:56.789Z", + "actor": { + "entity": { + "id": "admin2@example.com" + } + }, + "client": { + "user": { + "email": "admin2@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" + ], + "id": "failed-event", + "ingested": "2024-10-07T17:47:35Z", + "kind": "event", + "outcome": "failed", + "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": [ + "admin2@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": { + "data_stream": "logs-gcp.audit-default", + "id": "3", + "index": ".ds-logs-gcp.audit-default-2024.10.07-000001", + "source": { + "@timestamp": "2024-09-01T12:34:56.789Z", + "actor": { + "entity": { + "id": "admin3@example.com" + } + }, + "client": { + "user": { + "email": "admin3@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" + ], + "id": "grouped-event1", + "ingested": "2024-10-07T17:47:35Z", + "kind": "event", + "outcome": "failed", + "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": [ + "admin3@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": { + "data_stream": "logs-gcp.audit-default", + "id": "4", + "index": ".ds-logs-gcp.audit-default-2024.10.07-000001", + "source": { + "@timestamp": "2024-09-01T12:34:56.789Z", + "actor": { + "entity": { + "id": "admin3@example.com" + } + }, + "client": { + "user": { + "email": "admin3@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" + ], + "id": "grouped-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": [ + "admin3@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" + } + } + } +} diff --git a/x-pack/test/cloud_security_posture_api/es_archives/logs_gcp_audit/mappings.json b/x-pack/test/cloud_security_posture_api/es_archives/logs_gcp_audit/mappings.json new file mode 100644 index 0000000000000..085fc6d11d475 --- /dev/null +++ b/x-pack/test/cloud_security_posture_api/es_archives/logs_gcp_audit/mappings.json @@ -0,0 +1,628 @@ +{ + "type": "data_stream", + "value": { + "data_stream": "logs-gcp.audit-default", + "template": { + "_meta": { + "managed": true, + "managed_by": "fleet", + "package": { + "name": "gcp" + } + }, + "data_stream": { + "allow_custom_routing": false, + "hidden": false + }, + "ignore_missing_component_templates": [ + "logs-gcp.audit@custom" + ], + "index_patterns": [ + "logs-gcp.audit-*" + ], + "name": "logs-gcp.audit", + "priority": 200, + "template": { + "mappings": { + "_meta": { + "managed": true, + "managed_by": "fleet", + "package": { + "name": "gcp" + } + }, + "date_detection": false, + "dynamic_templates": [ + { + "ecs_message_match_only_text": { + "mapping": { + "type": "match_only_text" + }, + "path_match": [ + "message", + "*.message" + ], + "unmatch_mapping_type": "object" + } + }, + { + "ecs_non_indexed_keyword": { + "mapping": { + "doc_values": false, + "index": false, + "type": "keyword" + }, + "path_match": [ + "*event.original" + ] + } + }, + { + "ecs_non_indexed_long": { + "mapping": { + "doc_values": false, + "index": false, + "type": "long" + }, + "path_match": [ + "*.x509.public_key_exponent" + ] + } + }, + { + "ecs_ip": { + "mapping": { + "type": "ip" + }, + "match_mapping_type": "string", + "path_match": [ + "ip", + "*.ip", + "*_ip" + ] + } + }, + { + "ecs_wildcard": { + "mapping": { + "type": "wildcard" + }, + "path_match": [ + "*.io.text", + "*.message_id", + "*registry.data.strings", + "*url.path" + ], + "unmatch_mapping_type": "object" + } + }, + { + "ecs_path_match_wildcard_and_match_only_text": { + "mapping": { + "fields": { + "text": { + "type": "match_only_text" + } + }, + "type": "wildcard" + }, + "path_match": [ + "*.body.content", + "*url.full", + "*url.original" + ], + "unmatch_mapping_type": "object" + } + }, + { + "ecs_match_wildcard_and_match_only_text": { + "mapping": { + "fields": { + "text": { + "type": "match_only_text" + } + }, + "type": "wildcard" + }, + "match": [ + "*command_line", + "*stack_trace" + ], + "unmatch_mapping_type": "object" + } + }, + { + "ecs_path_match_keyword_and_match_only_text": { + "mapping": { + "fields": { + "text": { + "type": "match_only_text" + } + }, + "type": "keyword" + }, + "path_match": [ + "*.title", + "*.executable", + "*.name", + "*.working_directory", + "*.full_name", + "*file.path", + "*file.target_path", + "*os.full", + "*email.subject", + "*vulnerability.description", + "*user_agent.original" + ], + "unmatch_mapping_type": "object" + } + }, + { + "ecs_date": { + "mapping": { + "type": "date" + }, + "path_match": [ + "*.timestamp", + "*_timestamp", + "*.not_after", + "*.not_before", + "*.accessed", + "created", + "*.created", + "*.installed", + "*.creation_date", + "*.ctime", + "*.mtime", + "ingested", + "*.ingested", + "*.start", + "*.end", + "*.indicator.first_seen", + "*.indicator.last_seen", + "*.indicator.modified_at", + "*threat.enrichments.matched.occurred" + ], + "unmatch_mapping_type": "object" + } + }, + { + "ecs_path_match_float": { + "mapping": { + "type": "float" + }, + "path_match": [ + "*.score.*", + "*_score*" + ], + "path_unmatch": "*.version", + "unmatch_mapping_type": "object" + } + }, + { + "ecs_usage_double_scaled_float": { + "mapping": { + "scaling_factor": 1000, + "type": "scaled_float" + }, + "match_mapping_type": [ + "double", + "long", + "string" + ], + "path_match": "*.usage" + } + }, + { + "ecs_geo_point": { + "mapping": { + "type": "geo_point" + }, + "path_match": [ + "*.geo.location" + ] + } + }, + { + "ecs_flattened": { + "mapping": { + "type": "flattened" + }, + "match_mapping_type": "object", + "path_match": [ + "*structured_data", + "*exports", + "*imports" + ] + } + }, + { + "all_strings_to_keywords": { + "mapping": { + "ignore_above": 1024, + "type": "keyword" + }, + "match_mapping_type": "string" + } + } + ], + "properties": { + "@timestamp": { + "ignore_malformed": false, + "type": "date" + }, + "cloud": { + "properties": { + "image": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "data_stream": { + "properties": { + "dataset": { + "type": "constant_keyword" + }, + "namespace": { + "type": "constant_keyword" + }, + "type": { + "type": "constant_keyword" + } + } + }, + "data_stream.dataset": { + "type": "constant_keyword" + }, + "data_stream.namespace": { + "type": "constant_keyword" + }, + "data_stream.type": { + "type": "constant_keyword", + "value": "logs" + }, + "event": { + "properties": { + "agent_id_status": { + "ignore_above": 1024, + "type": "keyword" + }, + "dataset": { + "type": "constant_keyword", + "value": "gcp.audit" + }, + "ingested": { + "format": "strict_date_time_no_millis||strict_date_optional_time||epoch_millis", + "ignore_malformed": false, + "type": "date" + }, + "module": { + "type": "constant_keyword", + "value": "gcp" + } + } + }, + "gcp": { + "properties": { + "audit": { + "properties": { + "authentication_info": { + "properties": { + "authority_selector": { + "ignore_above": 1024, + "type": "keyword" + }, + "principal_email": { + "ignore_above": 1024, + "type": "keyword" + }, + "principal_subject": { + "ignore_above": 1024, + "type": "keyword" + }, + "service_account_delegation_info": { + "type": "flattened" + }, + "service_account_key_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "third_party_principal": { + "type": "flattened" + } + } + }, + "authorization_info": { + "properties": { + "granted": { + "type": "boolean" + }, + "permission": { + "ignore_above": 1024, + "type": "keyword" + }, + "resource": { + "ignore_above": 1024, + "type": "keyword" + }, + "resource_attributes": { + "properties": { + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "service": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + }, + "type": "nested" + }, + "flattened": { + "type": "flattened" + }, + "labels": { + "type": "flattened" + }, + "logentry_operation": { + "properties": { + "first": { + "type": "boolean" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "last": { + "type": "boolean" + }, + "producer": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "metadata": { + "type": "flattened" + }, + "method_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "num_response_items": { + "type": "long" + }, + "policy_violation_info": { + "properties": { + "payload": { + "type": "flattened" + }, + "resource_tags": { + "type": "flattened" + }, + "resource_type": { + "ignore_above": 1024, + "type": "keyword" + }, + "violations": { + "properties": { + "checkedValue": { + "ignore_above": 1024, + "type": "keyword" + }, + "constraint": { + "ignore_above": 1024, + "type": "keyword" + }, + "errorMessage": { + "ignore_above": 1024, + "type": "keyword" + }, + "policyType": { + "ignore_above": 1024, + "type": "keyword" + } + }, + "type": "nested" + } + } + }, + "request": { + "type": "flattened" + }, + "request_metadata": { + "properties": { + "caller_ip": { + "type": "ip" + }, + "caller_supplied_user_agent": { + "ignore_above": 1024, + "type": "keyword" + }, + "raw": { + "properties": { + "caller_ip": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "resource_location": { + "properties": { + "current_locations": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "resource_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "response": { + "type": "flattened" + }, + "service_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "status": { + "properties": { + "code": { + "type": "long" + }, + "details": { + "type": "flattened" + }, + "message": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "destination": { + "properties": { + "instance": { + "properties": { + "project_id": { + "ignore_above": 1024, + "type": "keyword" + }, + "region": { + "ignore_above": 1024, + "type": "keyword" + }, + "zone": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "vpc": { + "properties": { + "project_id": { + "ignore_above": 1024, + "type": "keyword" + }, + "subnetwork_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "vpc_name": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "source": { + "properties": { + "instance": { + "properties": { + "project_id": { + "ignore_above": 1024, + "type": "keyword" + }, + "region": { + "ignore_above": 1024, + "type": "keyword" + }, + "zone": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "vpc": { + "properties": { + "project_id": { + "ignore_above": 1024, + "type": "keyword" + }, + "subnetwork_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "vpc_name": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + } + } + }, + "host": { + "properties": { + "containerized": { + "type": "boolean" + }, + "os": { + "properties": { + "build": { + "ignore_above": 1024, + "type": "keyword" + }, + "codename": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "input": { + "properties": { + "type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "log": { + "properties": { + "offset": { + "type": "long" + } + } + } + } + }, + "settings": { + "index": { + "codec": "best_compression", + "final_pipeline": ".fleet_final_pipeline-1", + "mapping": { + "ignore_malformed": "true", + "total_fields": { + "ignore_dynamic_beyond_limit": "true", + "limit": "1000" + } + } + } + } + } + } + } +} diff --git a/x-pack/test/cloud_security_posture_api/routes/graph.ts b/x-pack/test/cloud_security_posture_api/routes/graph.ts new file mode 100644 index 0000000000000..bd2f71ef3b9b2 --- /dev/null +++ b/x-pack/test/cloud_security_posture_api/routes/graph.ts @@ -0,0 +1,257 @@ +/* + * 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 { + ELASTIC_HTTP_VERSION_HEADER, + X_ELASTIC_INTERNAL_ORIGIN_REQUEST, +} from '@kbn/core-http-common'; +import expect from '@kbn/expect'; +import type { Agent } from 'supertest'; +import { FtrProviderContext } from '../ftr_provider_context'; +import { result } from '../utils'; +import { CspSecurityCommonProvider } from './helper/user_roles_utilites'; + +// eslint-disable-next-line import/no-default-export +export default function (providerContext: FtrProviderContext) { + const { getService } = providerContext; + + const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); + const supertestWithoutAuth = getService('supertestWithoutAuth'); + const cspSecurity = CspSecurityCommonProvider(providerContext); + + const postGraph = (agent: Agent, body: any, auth?: { user: string; pass: string }) => { + const req = agent + .post('/internal/cloud_security_posture/graph') + .set(ELASTIC_HTTP_VERSION_HEADER, '1') + .set(X_ELASTIC_INTERNAL_ORIGIN_REQUEST, 'kibana') + .set('kbn-xsrf', 'xxxx'); + + if (auth) { + req.auth(auth.user, auth.pass); + } + + return req.send(body); + }; + + describe('POST /internal/cloud_security_posture/graph', () => { + describe('Authorization', () => { + it('should return 403 for user without read access', async () => { + await postGraph( + supertestWithoutAuth, + { + query: { + actorIds: [], + eventIds: [], + start: 'now-1d/d', + end: 'now/d', + }, + }, + { + user: 'role_security_no_read_user', + pass: cspSecurity.getPasswordForUser('role_security_no_read_user'), + } + ).expect(result(403)); + }); + }); + + describe('Validation', () => { + it('should return 400 when missing `actorIds` field', async () => { + await postGraph(supertest, { + query: { + eventIds: [], + start: 'now-1d/d', + end: 'now/d', + }, + }).expect(result(400)); + }); + }); + + describe('Happy flows', () => { + before(async () => { + await esArchiver.loadIfNeeded( + 'x-pack/test/cloud_security_posture_api/es_archives/logs_gcp_audit' + ); + }); + + after(async () => { + await esArchiver.unload( + 'x-pack/test/cloud_security_posture_api/es_archives/logs_gcp_audit' + ); + }); + + it('should return an empty graph', async () => { + const response = await postGraph(supertest, { + query: { + actorIds: [], + eventIds: [], + start: 'now-1d/d', + end: 'now/d', + }, + }).expect(result(200)); + + expect(response.body).to.have.property('nodes').length(0); + expect(response.body).to.have.property('edges').length(0); + }); + + it('should return a graph with nodes and edges by actor', async () => { + const response = await postGraph(supertest, { + query: { + actorIds: ['admin@example.com'], + eventIds: [], + start: '2024-09-01T00:00:00Z', + end: '2024-09-02T00:00:00Z', + }, + }).expect(result(200)); + + expect(response.body).to.have.property('nodes').length(3); + expect(response.body).to.have.property('edges').length(2); + + response.body.nodes.forEach((node: any) => { + expect(node).to.have.property('color'); + expect(node.color).equal( + 'primary', + `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( + 'primary', + `edge color mismatched [edge: ${edge.id}] [actual: ${edge.color}]` + ); + }); + }); + + it('should return a graph with nodes and edges by alert', async () => { + const response = await postGraph(supertest, { + query: { + actorIds: [], + eventIds: ['kabcd1234efgh5678'], + start: '2024-09-01T00:00:00Z', + end: '2024-09-02T00:00:00Z', + }, + }).expect(result(200)); + + expect(response.body).to.have.property('nodes').length(3); + expect(response.body).to.have.property('edges').length(2); + + response.body.nodes.forEach((node: any) => { + expect(node).to.have.property('color'); + expect(node.color).equal( + 'danger', + `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( + 'danger', + `edge color mismatched [edge: ${edge.id}] [actual: ${edge.color}]` + ); + }); + }); + + it('color of alert of failed event should be danger', async () => { + const response = await postGraph(supertest, { + query: { + actorIds: [], + eventIds: ['failed-event'], + start: '2024-09-01T00:00:00Z', + end: '2024-09-02T00:00:00Z', + }, + }).expect(result(200)); + + expect(response.body).to.have.property('nodes').length(3); + expect(response.body).to.have.property('edges').length(2); + + response.body.nodes.forEach((node: any) => { + expect(node).to.have.property('color'); + expect(node.color).equal( + 'danger', + `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( + 'danger', + `edge color mismatched [edge: ${edge.id}] [actual: ${edge.color}]` + ); + }); + }); + + it('color of event of failed event should be warning', async () => { + const response = await postGraph(supertest, { + query: { + actorIds: ['admin2@example.com'], + eventIds: [], + start: '2024-09-01T00:00:00Z', + end: '2024-09-02T00:00:00Z', + }, + }).expect(result(200)); + + expect(response.body).to.have.property('nodes').length(3); + expect(response.body).to.have.property('edges').length(2); + + response.body.nodes.forEach((node: any) => { + expect(node).to.have.property('color'); + + expect(node.color).equal( + node.shape === 'label' ? 'warning' : 'primary', + `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( + 'warning', + `edge color mismatched [edge: ${edge.id}] [actual: ${edge.color}]` + ); + }); + }); + + it('2 grouped of events, 1 failed, 1 success', async () => { + const response = await postGraph(supertest, { + query: { + actorIds: ['admin3@example.com'], + eventIds: [], + start: '2024-09-01T00:00:00Z', + end: '2024-09-02T00:00:00Z', + }, + }).expect(result(200)); + + expect(response.body).to.have.property('nodes').length(5); + expect(response.body).to.have.property('edges').length(6); + + expect(response.body.nodes[0].shape).equal('group', 'Groups should be the first nodes'); + + response.body.nodes.forEach((node: any) => { + if (node.shape !== 'group') { + expect(node).to.have.property('color'); + expect(node.color).equal( + node.shape === 'label' && node.id.includes('outcome(failed)') ? 'warning' : 'primary', + `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( + edge.id.includes('outcome(failed)') ? 'warning' : 'primary', + `edge color mismatched [edge: ${edge.id}] [actual: ${edge.color}]` + ); + }); + }); + }); + }); +} diff --git a/x-pack/test/cloud_security_posture_api/routes/index.ts b/x-pack/test/cloud_security_posture_api/routes/index.ts index bae346f880723..1d6311bf24d41 100644 --- a/x-pack/test/cloud_security_posture_api/routes/index.ts +++ b/x-pack/test/cloud_security_posture_api/routes/index.ts @@ -24,6 +24,7 @@ export default function (providerContext: FtrProviderContext) { loadTestFile(require.resolve('./csp_benchmark_rules_get_states.ts')); loadTestFile(require.resolve('./benchmarks.ts')); loadTestFile(require.resolve('./status.ts')); + loadTestFile(require.resolve('./graph.ts')); loadTestFile(require.resolve('./get_detection_engine_alerts_count_by_rule_tags')); }); } diff --git a/x-pack/test/cloud_security_posture_api/utils.ts b/x-pack/test/cloud_security_posture_api/utils.ts index 9f0805c2e85c1..e64c583af3868 100644 --- a/x-pack/test/cloud_security_posture_api/utils.ts +++ b/x-pack/test/cloud_security_posture_api/utils.ts @@ -9,6 +9,7 @@ import type { RetryService } from '@kbn/ftr-common-functional-services'; import type { Agent } from 'supertest'; import type { ToolingLog } from '@kbn/tooling-log'; import type { Client as EsClient } from '@elastic/elasticsearch'; +import type { CallbackHandler, Response } from 'superagent'; import expect from '@kbn/expect'; import { ELASTIC_HTTP_VERSION_HEADER } from '@kbn/core-http-common'; @@ -35,6 +36,19 @@ export const waitForPluginInitialized = ({ logger.debug('CSP plugin is initialized'); }); +export function result(status: number): CallbackHandler { + return (err: any, res: Response) => { + if ((res?.status || err.status) !== status) { + const e = new Error( + `Expected ${status} ,got ${res?.status || err.status} resp: ${ + res?.body ? JSON.stringify(res.body) : err.text + }` + ); + throw e; + } + }; +} + export class EsIndexDataProvider { private es: EsClient; private index: string; diff --git a/x-pack/test_serverless/api_integration/test_suites/security/cloud_security_posture/graph.ts b/x-pack/test_serverless/api_integration/test_suites/security/cloud_security_posture/graph.ts new file mode 100644 index 0000000000000..741d25291e8fa --- /dev/null +++ b/x-pack/test_serverless/api_integration/test_suites/security/cloud_security_posture/graph.ts @@ -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 expect from '@kbn/expect'; +import { + ELASTIC_HTTP_VERSION_HEADER, + X_ELASTIC_INTERNAL_ORIGIN_REQUEST, +} from '@kbn/core-http-common'; +import { result } from '@kbn/test-suites-xpack/cloud_security_posture_api/utils'; +import type { Agent } from 'supertest'; +import type { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function ({ getService }: FtrProviderContext) { + const esArchiver = getService('esArchiver'); + const roleScopedSupertest = getService('roleScopedSupertest'); + let supertestViewer: Pick; + + const postGraph = (agent: Pick, body: any) => { + const req = agent + .post('/internal/cloud_security_posture/graph') + .set(ELASTIC_HTTP_VERSION_HEADER, '1') + .set(X_ELASTIC_INTERNAL_ORIGIN_REQUEST, 'kibana') + .set('kbn-xsrf', 'xxxx'); + + return req.send(body); + }; + + describe('POST /internal/cloud_security_posture/graph', () => { + before(async () => { + await esArchiver.loadIfNeeded( + 'x-pack/test/cloud_security_posture_api/es_archives/logs_gcp_audit' + ); + supertestViewer = await roleScopedSupertest.getSupertestWithRoleScope('viewer', { + useCookieHeader: true, // to avoid generating API key and use Cookie header instead + withInternalHeaders: true, + }); + }); + + after(async () => { + await esArchiver.unload('x-pack/test/cloud_security_posture_api/es_archives/logs_gcp_audit'); + }); + + describe('Authorization', () => { + it('should return an empty graph', async () => { + const response = await postGraph(supertestViewer, { + query: { + actorIds: [], + eventIds: [], + start: 'now-1d/d', + end: 'now/d', + }, + }).expect(result(200)); + + expect(response.body).to.have.property('nodes').length(0); + expect(response.body).to.have.property('edges').length(0); + }); + + it('should return a graph with nodes and edges by actor', async () => { + const response = await postGraph(supertestViewer, { + query: { + actorIds: ['admin@example.com'], + eventIds: [], + start: '2024-09-01T00:00:00Z', + end: '2024-09-02T00:00:00Z', + }, + }).expect(result(200)); + + expect(response.body).to.have.property('nodes').length(3); + expect(response.body).to.have.property('edges').length(2); + + response.body.nodes.forEach((node: any) => { + expect(node).to.have.property('color'); + expect(node.color).equal( + 'primary', + `node color mismatched [node: ${node.id}] [actual: ${node.color}]` + ); + }); + }); + }); + }); +} diff --git a/x-pack/test_serverless/api_integration/test_suites/security/cloud_security_posture/index.ts b/x-pack/test_serverless/api_integration/test_suites/security/cloud_security_posture/index.ts index f3bc56e16a5ae..e67eadf44047c 100644 --- a/x-pack/test_serverless/api_integration/test_suites/security/cloud_security_posture/index.ts +++ b/x-pack/test_serverless/api_integration/test_suites/security/cloud_security_posture/index.ts @@ -10,14 +10,15 @@ import { FtrProviderContext } from '../../../ftr_provider_context'; export default function ({ loadTestFile }: FtrProviderContext) { describe('cloud_security_posture', function () { this.tags(['cloud_security_posture']); - loadTestFile(require.resolve('./status/status_not_deployed_not_installed')); - loadTestFile(require.resolve('./status/status_indexed')); - loadTestFile(require.resolve('./status/status_indexing')); loadTestFile(require.resolve('./benchmark/v1')); loadTestFile(require.resolve('./benchmark/v2')); loadTestFile(require.resolve('./find_csp_benchmark_rule')); - loadTestFile(require.resolve('./telemetry')); + loadTestFile(require.resolve('./graph')); loadTestFile(require.resolve('./serverless_metering/cloud_security_metering')); + loadTestFile(require.resolve('./status/status_indexed')); + loadTestFile(require.resolve('./status/status_indexing')); + loadTestFile(require.resolve('./status/status_not_deployed_not_installed')); + loadTestFile(require.resolve('./telemetry')); // TODO: migrate status_unprivileged tests from stateful, if it feasible in serverless with the new security model // loadTestFile(require.resolve('./status/status_unprivileged')); diff --git a/yarn.lock b/yarn.lock index f5f3c84be1faa..d4c80914c12b1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -346,10 +346,10 @@ resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.10.4.tgz#2f75a831269d4f677de49986dff59927533cf375" integrity sha512-O4KCvQA6lLiMU9l2eawBPMf1xPP8xPfB3iEQw150hOVTqj/rfXz0ThTb4HEzqQfs2Bmo5Ay8BzxfzVtBrr9dVg== -"@babel/helper-plugin-utils@^7.0.0", "@babel/helper-plugin-utils@^7.10.4", "@babel/helper-plugin-utils@^7.12.13", "@babel/helper-plugin-utils@^7.13.0", "@babel/helper-plugin-utils@^7.14.5", "@babel/helper-plugin-utils@^7.18.6", "@babel/helper-plugin-utils@^7.18.9", "@babel/helper-plugin-utils@^7.19.0", "@babel/helper-plugin-utils@^7.20.2", "@babel/helper-plugin-utils@^7.22.5", "@babel/helper-plugin-utils@^7.24.7", "@babel/helper-plugin-utils@^7.8.0", "@babel/helper-plugin-utils@^7.8.3": - version "7.24.7" - resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.24.7.tgz#98c84fe6fe3d0d3ae7bfc3a5e166a46844feb2a0" - integrity sha512-Rq76wjt7yz9AAc1KnlRKNAi/dMSVWgDRx43FHoJEbcYU6xOWaE2dVPwcdTukJrjxS65GITyfbvEYHvkirZ6uEg== +"@babel/helper-plugin-utils@^7.0.0", "@babel/helper-plugin-utils@^7.10.4", "@babel/helper-plugin-utils@^7.12.13", "@babel/helper-plugin-utils@^7.13.0", "@babel/helper-plugin-utils@^7.14.5", "@babel/helper-plugin-utils@^7.18.6", "@babel/helper-plugin-utils@^7.18.9", "@babel/helper-plugin-utils@^7.19.0", "@babel/helper-plugin-utils@^7.20.2", "@babel/helper-plugin-utils@^7.22.5", "@babel/helper-plugin-utils@^7.24.7", "@babel/helper-plugin-utils@^7.24.8", "@babel/helper-plugin-utils@^7.8.0", "@babel/helper-plugin-utils@^7.8.3": + version "7.24.8" + resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.24.8.tgz#94ee67e8ec0e5d44ea7baeb51e571bd26af07878" + integrity sha512-FFWx5142D8h2Mgr/iPVGH5G7w6jDn4jUSpZTyDnQO0Yn7Ks2Kuz6Pci8H6MPCoUJegd/UZQ3tAvfLCxQSnWWwg== "@babel/helper-remap-async-to-generator@^7.24.7": version "7.24.7" @@ -1012,12 +1012,12 @@ "@babel/helper-plugin-utils" "^7.24.7" "@babel/plugin-syntax-optional-catch-binding" "^7.8.3" -"@babel/plugin-transform-optional-chaining@^7.24.7": - version "7.24.7" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.24.7.tgz#b8f6848a80cf2da98a8a204429bec04756c6d454" - integrity sha512-tK+0N9yd4j+x/4hxF3F0e0fu/VdcxU18y5SevtyM/PCFlQvXbR0Zmlo2eBrKtVipGNFzpq56o8WsIIKcJFUCRQ== +"@babel/plugin-transform-optional-chaining@^7.24.7", "@babel/plugin-transform-optional-chaining@^7.24.8": + version "7.24.8" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.24.8.tgz#bb02a67b60ff0406085c13d104c99a835cdf365d" + integrity sha512-5cTOLSMs9eypEy8JUVvIKOu6NgvbJMnpG62VpIHrTmROdQ+L5mDAaI40g25k5vXti55JWNX5jCkq3HZxXBQANw== dependencies: - "@babel/helper-plugin-utils" "^7.24.7" + "@babel/helper-plugin-utils" "^7.24.8" "@babel/helper-skip-transparent-expression-wrappers" "^7.24.7" "@babel/plugin-syntax-optional-chaining" "^7.8.3" @@ -1563,6 +1563,18 @@ enabled "2.0.x" kuler "^2.0.0" +"@dagrejs/dagre@^1.1.4": + version "1.1.4" + resolved "https://registry.yarnpkg.com/@dagrejs/dagre/-/dagre-1.1.4.tgz#66f9c0e2b558308f2c268f60e2c28f22ee17e339" + integrity sha512-QUTc54Cg/wvmlEUxB+uvoPVKFazM1H18kVHBQNmK2NbrDR5ihOCR6CXLnDSZzMcSQKJtabPUWridBOlJM3WkDg== + dependencies: + "@dagrejs/graphlib" "2.2.4" + +"@dagrejs/graphlib@2.2.4": + version "2.2.4" + resolved "https://registry.yarnpkg.com/@dagrejs/graphlib/-/graphlib-2.2.4.tgz#d77bfa9ff49e2307c0c6e6b8b26b5dd3c05816c4" + integrity sha512-mepCf/e9+SKYy1d02/UkvSy6+6MoyXhVxP8lLDfA7BPE1X1d4dR0sZznmbM8/XVJ1GPM+Svnx7Xj6ZweByWUkw== + "@dependents/detective-less@^4.1.0": version "4.1.0" resolved "https://registry.yarnpkg.com/@dependents/detective-less/-/detective-less-4.1.0.tgz#4a979ee7a6a79eb33602862d6a1263e30f98002e" @@ -3631,6 +3643,10 @@ version "0.0.0" uid "" +"@kbn/cloud-security-posture-graph@link:x-pack/packages/kbn-cloud-security-posture/graph": + version "0.0.0" + uid "" + "@kbn/cloud-security-posture-plugin@link:x-pack/plugins/cloud_security_posture": version "0.0.0" uid "" @@ -10334,6 +10350,20 @@ resolved "https://registry.yarnpkg.com/@types/d3-color/-/d3-color-2.0.1.tgz#570ea7f8b853461301804efa52bd790a640a26db" integrity sha512-u7LTCL7RnaavFSmob2rIAJLNwu50i6gFwY9cHFr80BrQURYQBRkJ+Yv47nA3Fm7FeRhdWTiVTeqvSeOuMAOzBQ== +"@types/d3-drag@^3.0.7": + version "3.0.7" + resolved "https://registry.yarnpkg.com/@types/d3-drag/-/d3-drag-3.0.7.tgz#b13aba8b2442b4068c9a9e6d1d82f8bcea77fc02" + integrity sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ== + dependencies: + "@types/d3-selection" "*" + +"@types/d3-interpolate@*": + version "3.0.4" + resolved "https://registry.yarnpkg.com/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz#412b90e84870285f2ff8a846c6eb60344f12a41c" + integrity sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA== + dependencies: + "@types/d3-color" "*" + "@types/d3-interpolate@^3.0.1": version "3.0.1" resolved "https://registry.yarnpkg.com/@types/d3-interpolate/-/d3-interpolate-3.0.1.tgz#e7d17fa4a5830ad56fe22ce3b4fac8541a9572dc" @@ -10358,6 +10388,11 @@ resolved "https://registry.yarnpkg.com/@types/d3-selection/-/d3-selection-3.0.2.tgz#23e48a285b24063630bbe312cc0cfe2276de4a59" integrity sha512-d29EDd0iUBrRoKhPndhDY6U/PYxOWqgIZwKTooy2UkBfU7TNZNpRho0yLWPxlatQrFWk2mnTu71IZQ4+LRgKlQ== +"@types/d3-selection@^3.0.10": + version "3.0.10" + resolved "https://registry.yarnpkg.com/@types/d3-selection/-/d3-selection-3.0.10.tgz#98cdcf986d0986de6912b5892e7c015a95ca27fe" + integrity sha512-cuHoUgS/V3hLdjJOLTT691+G2QoqAjCVLmr4kJXR4ha56w1Zdu8UUQ5TxLRqudgNjwXeQxKMq4j+lyf9sWuslg== + "@types/d3-shape@^2.1.0": version "2.1.3" resolved "https://registry.yarnpkg.com/@types/d3-shape/-/d3-shape-2.1.3.tgz#35d397b9e687abaa0de82343b250b9897b8cacf3" @@ -10377,6 +10412,21 @@ dependencies: "@types/d3-selection" "*" +"@types/d3-transition@^3.0.8": + version "3.0.8" + resolved "https://registry.yarnpkg.com/@types/d3-transition/-/d3-transition-3.0.8.tgz#677707f5eed5b24c66a1918cde05963021351a8f" + integrity sha512-ew63aJfQ/ms7QQ4X7pk5NxQ9fZH/z+i24ZfJ6tJSfqxJMrYLiK01EAs2/Rtw/JreGUsS3pLPNV644qXFGnoZNQ== + dependencies: + "@types/d3-selection" "*" + +"@types/d3-zoom@^3.0.8": + version "3.0.8" + resolved "https://registry.yarnpkg.com/@types/d3-zoom/-/d3-zoom-3.0.8.tgz#dccb32d1c56b1e1c6e0f1180d994896f038bc40b" + integrity sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw== + dependencies: + "@types/d3-interpolate" "*" + "@types/d3-selection" "*" + "@types/d3@^3.5.43": version "3.5.43" resolved "https://registry.yarnpkg.com/@types/d3/-/d3-3.5.43.tgz#e9b4992817e0b6c5efaa7d6e5bb2cee4d73eab58" @@ -12127,6 +12177,28 @@ resolved "https://registry.yarnpkg.com/@xtuc/long/-/long-4.2.2.tgz#d291c6a4e97989b5c61d9acf396ae4fe133a718d" integrity sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ== +"@xyflow/react@^12.3.0": + version "12.3.0" + resolved "https://registry.yarnpkg.com/@xyflow/react/-/react-12.3.0.tgz#611bf68e0580ff2eab0a5cdf4b997d923a8cae85" + integrity sha512-dujEbjOn+5gMGg/wsojxtI7v2CfWm7ieRyiOHiZTPyw6p/VIdCoS3nLfSBP3TT+swoHSAXZ78iomHSKoUl4tMg== + dependencies: + "@xyflow/system" "0.0.42" + classcat "^5.0.3" + zustand "^4.4.0" + +"@xyflow/system@0.0.42": + version "0.0.42" + resolved "https://registry.yarnpkg.com/@xyflow/system/-/system-0.0.42.tgz#359b274fe072a28191ecfbc61bb7a3601de80e1e" + integrity sha512-kWYj+Y0GOct0jKYTdyRMNOLPxGNbb2TYvPg2gTmJnZ31DOOMkL5uRBLX825DR2gOACDu+i5FHLxPJUPf/eGOJw== + dependencies: + "@types/d3-drag" "^3.0.7" + "@types/d3-selection" "^3.0.10" + "@types/d3-transition" "^3.0.8" + "@types/d3-zoom" "^3.0.8" + d3-drag "^3.0.0" + d3-selection "^3.0.0" + d3-zoom "^3.0.0" + "@yarnpkg/lockfile@^1.1.0": version "1.1.0" resolved "https://registry.yarnpkg.com/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz#e77a97fbd345b76d83245edcd17d393b1b41fb31" @@ -14316,6 +14388,11 @@ class-utils@^0.3.5: lazy-cache "^2.0.2" static-extend "^0.1.1" +classcat@^5.0.3: + version "5.0.5" + resolved "https://registry.yarnpkg.com/classcat/-/classcat-5.0.5.tgz#8c209f359a93ac302404a10161b501eba9c09c77" + integrity sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w== + classnames@2.2.6: version "2.2.6" resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.2.6.tgz#43935bffdd291f326dad0a205309b38d00f650ce" @@ -15535,7 +15612,7 @@ d3-delaunay@^6.0.2: resolved "https://registry.yarnpkg.com/d3-dispatch/-/d3-dispatch-1.0.6.tgz#00d37bcee4dd8cd97729dd893a0ac29caaba5d58" integrity sha512-fVjoElzjhCEy+Hbn8KygnmMS7Or0a9sI2UzGwoB7cCtvI1XpVN9GpoYlnb3xt2YV66oXYb1fLJ8GMvP4hdU1RA== -"d3-drag@2 - 3": +"d3-drag@2 - 3", d3-drag@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/d3-drag/-/d3-drag-3.0.0.tgz#994aae9cd23c719f53b5e10e3a0a6108c69607ba" integrity sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg== @@ -15648,7 +15725,7 @@ d3-scale@^4.0.2: d3-time "2.1.1 - 3" d3-time-format "2 - 4" -d3-selection@3, d3-selection@^3.0.0: +"d3-selection@2 - 3", d3-selection@3, d3-selection@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/d3-selection/-/d3-selection-3.0.0.tgz#c25338207efa72cc5b9bd1458a1a41901f1e1b31" integrity sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ== @@ -15700,7 +15777,7 @@ d3-shape@^3.2.0: resolved "https://registry.yarnpkg.com/d3-timer/-/d3-timer-3.0.1.tgz#6284d2a2708285b1abb7e201eda4380af35e63b0" integrity sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA== -d3-transition@3, d3-transition@^3.0.1: +"d3-transition@2 - 3", d3-transition@3, d3-transition@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/d3-transition/-/d3-transition-3.0.1.tgz#6869fdde1448868077fdd5989200cb61b2a1645f" integrity sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w== @@ -15711,6 +15788,17 @@ d3-transition@3, d3-transition@^3.0.1: d3-interpolate "1 - 3" d3-timer "1 - 3" +d3-zoom@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/d3-zoom/-/d3-zoom-3.0.0.tgz#d13f4165c73217ffeaa54295cd6969b3e7aee8f3" + integrity sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw== + dependencies: + d3-dispatch "1 - 3" + d3-drag "2 - 3" + d3-interpolate "1 - 3" + d3-selection "2 - 3" + d3-transition "2 - 3" + d3@3.5.17, d3@^3.5.6: version "3.5.17" resolved "https://registry.yarnpkg.com/d3/-/d3-3.5.17.tgz#bc46748004378b21a360c9fc7cf5231790762fb8" @@ -31434,10 +31522,10 @@ use-sidecar@^1.1.2: detect-node-es "^1.1.0" tslib "^2.0.0" -use-sync-external-store@^1.0.0, use-sync-external-store@^1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz#7dbefd6ef3fe4e767a0cf5d7287aacfb5846928a" - integrity sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA== +use-sync-external-store@1.2.2, use-sync-external-store@^1.0.0, use-sync-external-store@^1.2.0: + version "1.2.2" + resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.2.2.tgz#c3b6390f3a30eba13200d2302dcdf1e7b57b2ef9" + integrity sha512-PElTlVMwpblvbNqQ82d2n6RjStvdSoNe9FG28kNfz3WiXilJm4DdNkEzRhCZuIDwY8U08WVihhGR5iRqAwfDiw== use@^2.0.0: version "2.0.2" @@ -33051,6 +33139,13 @@ zod@3.23.8, zod@^3.22.3, zod@^3.22.4, zod@^3.23.8: resolved "https://registry.yarnpkg.com/zod/-/zod-3.23.8.tgz#e37b957b5d52079769fb8097099b592f0ef4067d" integrity sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g== +zustand@^4.4.0: + version "4.5.5" + resolved "https://registry.yarnpkg.com/zustand/-/zustand-4.5.5.tgz#f8c713041543715ec81a2adda0610e1dc82d4ad1" + integrity sha512-+0PALYNJNgK6hldkgDq2vLrw5f6g/jCInz52n9RTpropGgeAf/ioFUCdtsjCqu4gNhW9D01rUQBROoRjdzyn2Q== + dependencies: + use-sync-external-store "1.2.2" + zwitch@^1.0.0: version "1.0.5" resolved "https://registry.yarnpkg.com/zwitch/-/zwitch-1.0.5.tgz#d11d7381ffed16b742f6af7b3f223d5cd9fe9920"