Render mermaid diagram per risk assessment scope

Each scope card now shows a flowchart of its nodes, processes, and
threats, with a distinct shape per type: stadium for entities,
hexagon for boundaries, rectangle for assets, cylinder for data, and
a red hexagon for threats attached via dashed edges to their process
target. The Mermaid source is built on the backend and exposed as a
new `mermaid` field on RiskAssessmentScope; the frontend just renders
it via @probo/ui's MermaidDiagram and shows a copy button + legend.

Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
This commit is contained in:
Sacha Al Himdani
2026-05-12 17:31:35 +02:00
parent b9262b5150
commit eedfdcecc8
11 changed files with 714 additions and 5 deletions

View File

@@ -27,12 +27,15 @@ import {
Select,
useDialogRef,
} from "@probo/ui";
import { useState } from "react";
import { Suspense, useState } from "react";
import { useForm } from "react-hook-form";
import { graphql, useMutation } from "react-relay";
import { graphql, useLazyLoadQuery, useMutation } from "react-relay";
import type { CreateScenarioInScopeDialogLinkRiskMutation } from "#/__generated__/core/CreateScenarioInScopeDialogLinkRiskMutation.graphql";
import type { CreateScenarioInScopeDialogLinkThreatMutation } from "#/__generated__/core/CreateScenarioInScopeDialogLinkThreatMutation.graphql";
import type { CreateScenarioInScopeDialogMutation } from "#/__generated__/core/CreateScenarioInScopeDialogMutation.graphql";
import type { CreateScenarioInScopeDialogRisksQuery } from "#/__generated__/core/CreateScenarioInScopeDialogRisksQuery.graphql";
import { useOrganizationId } from "#/hooks/useOrganizationId";
const createScenarioMutation = graphql`
mutation CreateScenarioInScopeDialogMutation(
@@ -56,11 +59,75 @@ const linkThreatMutation = graphql`
$input: LinkRiskAssessmentScenarioThreatInput!
) {
linkRiskAssessmentScenarioThreat(input: $input) {
riskAssessmentScenario { id }
riskAssessmentScenario {
id
threats(first: 10) { edges { node { id name } } }
}
}
}
`;
const linkRiskMutation = graphql`
mutation CreateScenarioInScopeDialogLinkRiskMutation(
$input: LinkRiskAssessmentScenarioRiskInput!
) {
linkRiskAssessmentScenarioRisk(input: $input) {
riskAssessmentScenario {
id
risks(first: 10) { edges { node { id name } } }
}
riskAssessmentScenarioEdge { node { id } }
}
}
`;
const risksQuery = graphql`
query CreateScenarioInScopeDialogRisksQuery($organizationId: ID!) {
node(id: $organizationId) {
... on Organization {
risks(first: 100) {
edges { node { id name } }
}
}
}
}
`;
function RiskSelector(props: {
selectedRisks: Map<string, string>;
onSelect: (id: string, name: string) => void;
}) {
const { __ } = useTranslate();
const organizationId = useOrganizationId();
const data = useLazyLoadQuery<CreateScenarioInScopeDialogRisksQuery>(
risksQuery,
{ organizationId },
{ fetchPolicy: "store-or-network" },
);
const allRisks = data.node?.risks?.edges?.map(e => e.node) ?? [];
const available = allRisks.filter(r => !props.selectedRisks.has(r.id));
if (available.length === 0) {
return <p className="text-xs text-txt-tertiary">{__("No more risks available.")}</p>;
}
return (
<Select
key={props.selectedRisks.size}
placeholder={__("Select a risk to link...")}
onValueChange={(riskId) => {
if (typeof riskId !== "string") return;
const risk = allRisks.find(r => r.id === riskId);
if (risk) props.onSelect(risk.id, risk.name);
}}
>
{available.map(r => (
<Option key={r.id} value={r.id}>{r.name}</Option>
))}
</Select>
);
}
export function CreateScenarioInScopeDialog(props: {
scopeId: string;
threats: { id: string; name: string }[];
@@ -69,8 +136,10 @@ export function CreateScenarioInScopeDialog(props: {
const { __ } = useTranslate();
const dialogRef = useDialogRef();
const [selectedThreats, setSelectedThreats] = useState<Map<string, string>>(new Map());
const [selectedRisks, setSelectedRisks] = useState<Map<string, string>>(new Map());
const [createScenario, isCreating] = useMutation<CreateScenarioInScopeDialogMutation>(createScenarioMutation);
const [linkThreat] = useMutation<CreateScenarioInScopeDialogLinkThreatMutation>(linkThreatMutation);
const [linkRisk] = useMutation<CreateScenarioInScopeDialogLinkRiskMutation>(linkRiskMutation);
const { register, handleSubmit, reset, formState } = useForm({
defaultValues: { name: "", description: "" },
});
@@ -96,8 +165,16 @@ export function CreateScenarioInScopeDialog(props: {
},
});
}
for (const riskId of selectedRisks.keys()) {
linkRisk({
variables: {
input: { riskAssessmentScenarioId: scenarioId, riskId },
},
});
}
reset();
setSelectedThreats(new Map());
setSelectedRisks(new Map());
dialogRef.current?.close();
},
});
@@ -152,6 +229,7 @@ export function CreateScenarioInScopeDialog(props: {
)}
{availableThreats.length > 0 && (
<Select
key={selectedThreats.size}
placeholder={__("Select a threat to link...")}
onValueChange={(threatId) => {
if (typeof threatId !== "string") return;
@@ -172,6 +250,44 @@ export function CreateScenarioInScopeDialog(props: {
)}
</div>
)}
<div>
<div className="text-sm font-medium mb-2">{__("Risks")}</div>
{selectedRisks.size > 0 && (
<div className="flex flex-wrap gap-1 mb-2">
{[...selectedRisks.entries()].map(([id, name]) => (
<Badge key={id}>
{name}
<button
type="button"
className="ml-1 hover:text-txt-danger"
onClick={() => {
setSelectedRisks((prev) => {
const next = new Map(prev);
next.delete(id);
return next;
});
}}
>
<IconCrossLargeX size={12} />
</button>
</Badge>
))}
</div>
)}
<Suspense fallback={<p className="text-xs text-txt-tertiary">{__("Loading risks...")}</p>}>
<RiskSelector
selectedRisks={selectedRisks}
onSelect={(id, name) => {
setSelectedRisks((prev) => {
const next = new Map(prev);
next.set(id, name);
return next;
});
}}
/>
</Suspense>
</div>
</DialogContent>
<DialogFooter><Button type="submit" disabled={isCreating}>{__("Add")}</Button></DialogFooter>
</form>

View File

@@ -40,6 +40,7 @@ import { NodeActions } from "./NodeActions";
import { ProcessActions } from "./ProcessActions";
import { ScenarioInScopeActions } from "./ScenarioInScopeActions";
import { ScopeActions } from "./ScopeActions";
import { ScopeDiagram } from "./ScopeDiagram";
import { ThreatActions } from "./ThreatActions";
export const scopeCardFragment = graphql`
@@ -82,6 +83,7 @@ export const scopeCardFragment = graphql`
}
}
}
...ScopeDiagram_scope
}
`;
@@ -164,6 +166,16 @@ export function ScopeCard(props: {
{isOpen && (
<div className="border-t border-border-low px-4 py-4 space-y-6">
<div>
<div className="mb-3">
<h3 className="text-sm font-semibold">{__("Diagram")}</h3>
<p className="text-xs text-txt-tertiary mt-1">
{__("Visualization of nodes, processes, and threats in this scope.")}
</p>
</div>
<ScopeDiagram scopeKey={scope} />
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div>
<SectionHeader

View File

@@ -0,0 +1,282 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { CheckIcon, CopyIcon } from "@phosphor-icons/react";
import { useTranslate } from "@probo/i18n";
import { Button, MermaidDiagram, useToast } from "@probo/ui";
import { useEffect, useRef, useState } from "react";
import { fetchQuery, graphql, useFragment, useRelayEnvironment } from "react-relay";
import type { ScopeDiagram_scope$key } from "#/__generated__/core/ScopeDiagram_scope.graphql";
import type { ScopeDiagramMermaidQuery } from "#/__generated__/core/ScopeDiagramMermaidQuery.graphql";
const scopeDiagramFragment = graphql`
fragment ScopeDiagram_scope on RiskAssessmentScope {
id
mermaidChart
nodes(first: 100)
@connection(key: "RiskAssessmentScope_nodes", filters: []) {
edges {
node {
id
name
nodeType
}
}
}
processes(first: 100)
@connection(key: "RiskAssessmentScope_processes", filters: []) {
edges {
node {
id
name
sourceNodeId
targetNodeId
}
}
}
threats(first: 100)
@connection(key: "RiskAssessmentScope_threats", filters: []) {
edges {
node {
id
name
processId
category
}
}
}
}
`;
const scopeDiagramMermaidQuery = graphql`
query ScopeDiagramMermaidQuery($scopeId: ID!) {
node(id: $scopeId) {
... on RiskAssessmentScope {
id
mermaidChart
}
}
}
`;
interface ScopeDiagramProps {
scopeKey: ScopeDiagram_scope$key;
}
export function ScopeDiagram({ scopeKey }: ScopeDiagramProps) {
const { __ } = useTranslate();
const environment = useRelayEnvironment();
const scope = useFragment(scopeDiagramFragment, scopeKey);
const mermaidChart = scope.mermaidChart;
const nodeSignature = scope.nodes?.edges
.map(e => `${e.node.id}|${e.node.name}|${e.node.nodeType}`)
.join(";") ?? "";
const processSignature = scope.processes?.edges
.map(e => `${e.node.id}|${e.node.name}|${e.node.sourceNodeId}|${e.node.targetNodeId}`)
.join(";") ?? "";
const threatSignature = scope.threats?.edges
.map(e => `${e.node.id}|${e.node.name}|${e.node.processId}|${e.node.category}`)
.join(";") ?? "";
const signature = `${nodeSignature}::${processSignature}::${threatSignature}`;
const previousSignature = useRef(signature);
useEffect(() => {
if (previousSignature.current === signature) {
return;
}
previousSignature.current = signature;
const subscription = fetchQuery<ScopeDiagramMermaidQuery>(
environment,
scopeDiagramMermaidQuery,
{ scopeId: scope.id },
{ fetchPolicy: "network-only" },
).subscribe({});
return () => subscription.unsubscribe();
}, [signature, environment, scope.id]);
if (!mermaidChart) {
return (
<div className="text-center text-txt-secondary text-sm py-6">
{__("Add nodes and processes to see the diagram.")}
</div>
);
}
return (
<div className="relative">
<div className="absolute right-0 top-0 z-10">
<CopyChartButton chart={mermaidChart} />
</div>
<div className="overflow-x-auto">
<MermaidDiagram chart={mermaidChart} />
<Legend />
</div>
</div>
);
}
interface CopyChartButtonProps {
chart: string;
}
function CopyChartButton({ chart }: CopyChartButtonProps) {
const { __ } = useTranslate();
const { toast } = useToast();
const [copied, setCopied] = useState(false);
const onClick = () => {
const onFailure = () => {
toast({
title: __("Error"),
description: __("Failed to copy to clipboard"),
variant: "error",
});
};
if (!navigator.clipboard?.writeText) {
onFailure();
return;
}
try {
navigator.clipboard.writeText(chart).then(
() => {
setCopied(true);
setTimeout(() => setCopied(false), 1500);
},
onFailure,
);
} catch {
onFailure();
}
};
return (
<Button
variant="secondary"
icon={copied ? CheckIcon : CopyIcon}
onClick={onClick}
aria-label={__("Copy mermaid source")}
title={__("Copy mermaid source")}
/>
);
}
type LegendShape = "stadium" | "hexagon" | "rectangle" | "cylinder";
type LegendItem = {
label: string;
shape: LegendShape;
fill: string;
stroke: string;
text: string;
};
function Legend() {
const { __ } = useTranslate();
const items: LegendItem[] = [
{ label: __("Entity"), shape: "stadium", fill: "#dbeafe", stroke: "#1d4ed8", text: "#1e3a8a" },
{ label: __("Boundary"), shape: "hexagon", fill: "#fef3c7", stroke: "#b45309", text: "#78350f" },
{ label: __("Asset"), shape: "rectangle", fill: "#e5e7eb", stroke: "#374151", text: "#111827" },
{ label: __("Data"), shape: "cylinder", fill: "#dcfce7", stroke: "#15803d", text: "#14532d" },
{ label: __("Threat"), shape: "hexagon", fill: "#fee2e2", stroke: "#b91c1c", text: "#7f1d1d" },
];
return (
<div className="flex flex-wrap items-center gap-3 mt-3">
{items.map(item => (
<LegendSwatch key={item.label} item={item} />
))}
</div>
);
}
interface LegendSwatchProps {
item: LegendItem;
}
function LegendSwatch({ item }: LegendSwatchProps) {
const w = 88;
const h = 28;
return (
<svg
width={w}
height={h}
viewBox={`0 0 ${w} ${h}`}
aria-label={item.label}
>
<LegendShapeEl shape={item.shape} w={w} h={h} fill={item.fill} stroke={item.stroke} />
<text
x={w / 2}
y={h / 2}
textAnchor="middle"
dominantBaseline="central"
fontSize={11}
fontFamily="inherit"
fill={item.text}
>
{item.label}
</text>
</svg>
);
}
interface LegendShapeElProps {
shape: LegendShape;
w: number;
h: number;
fill: string;
stroke: string;
}
function LegendShapeEl({ shape, w, h, fill, stroke }: LegendShapeElProps) {
const sw = 1.5;
const common = { fill, stroke, strokeWidth: sw };
switch (shape) {
case "stadium":
return <rect x={sw / 2} y={sw / 2} width={w - sw} height={h - sw} rx={(h - sw) / 2} {...common} />;
case "rectangle":
return <rect x={sw / 2} y={sw / 2} width={w - sw} height={h - sw} {...common} />;
case "hexagon": {
const inset = 8;
const pts = [
`${inset},${h / 2}`,
`${inset + 4},${sw}`,
`${w - inset - 4},${sw}`,
`${w - inset},${h / 2}`,
`${w - inset - 4},${h - sw}`,
`${inset + 4},${h - sw}`,
].join(" ");
return <polygon points={pts} {...common} />;
}
case "cylinder": {
const ry = 4;
return (
<g>
<path
d={`M ${sw / 2} ${ry + sw / 2} A ${(w - sw) / 2} ${ry} 0 0 1 ${w - sw / 2} ${ry + sw / 2} L ${w - sw / 2} ${h - ry - sw / 2} A ${(w - sw) / 2} ${ry} 0 0 1 ${sw / 2} ${h - ry - sw / 2} Z`}
{...common}
/>
<path
d={`M ${sw / 2} ${ry + sw / 2} A ${(w - sw) / 2} ${ry} 0 0 0 ${w - sw / 2} ${ry + sw / 2}`}
fill="none"
stroke={stroke}
strokeWidth={sw}
/>
</g>
);
}
}
}

View File

@@ -24,13 +24,19 @@ export function MermaidDiagram({ chart }: Props) {
const [svg, setSvg] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const source = (chart ?? "").trim();
useEffect(() => {
if (!source) {
return;
}
let cancelled = false;
mermaid.initialize({ startOnLoad: false, theme: "neutral" });
mermaid
.render(`mermaid-${id}`, chart.trim())
.render(`mermaid-${id}`, source)
.then((result) => {
if (!cancelled) {
setSvg(result.svg);
@@ -46,7 +52,7 @@ export function MermaidDiagram({ chart }: Props) {
return () => {
cancelled = true;
};
}, [chart, id]);
}, [source, id]);
if (error) {
return (

View File

@@ -47,6 +47,7 @@ export { PropertyRow } from "./Atoms/PropertyRow/PropertyRow";
export { Table, Tbody, Td, Th, Thead, Tr, TrButton } from "./Atoms/Table/Table";
export { TabBadge, TabItem, TabLink, Tabs } from "./Atoms/Tabs/Tabs";
export { Markdown } from "./Atoms/Markdown/Markdown";
export { MermaidDiagram } from "./Atoms/Markdown/MermaidDiagram";
export { Dropzone } from "./Atoms/Dropzone/Dropzone";
export { ControlItem } from "./Atoms/ControlItem/ControlItem";
export { InfiniteScrollTrigger } from "./Atoms/InfiniteScrollTrigger/InfiniteScrollTrigger";

View File

@@ -106,6 +106,45 @@ WHERE
return nil
}
func (ns *RiskAssessmentNodes) LoadAllByRiskAssessmentScopeID(
ctx context.Context,
conn pg.Querier,
scope Scoper,
riskAssessmentScopeID gid.GID,
) error {
q := `
SELECT
id,
organization_id,
risk_assessment_scope_id,
node_type,
name,
created_at,
updated_at
FROM
risk_assessment_nodes
WHERE
%s
AND risk_assessment_scope_id = @risk_assessment_scope_id
ORDER BY
created_at ASC, id ASC
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.NamedArgs{"risk_assessment_scope_id": riskAssessmentScopeID}
maps.Copy(args, scope.SQLArguments())
rows, err := conn.Query(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot query risk assessment nodes: %w", err)
}
results, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[RiskAssessmentNode])
if err != nil {
return fmt.Errorf("cannot collect risk assessment nodes: %w", err)
}
*ns = results
return nil
}
func (ns *RiskAssessmentNodes) CountByRiskAssessmentScopeID(
ctx context.Context,
conn pg.Querier,

View File

@@ -108,6 +108,46 @@ WHERE
return nil
}
func (ps *RiskAssessmentProcesses) LoadAllByRiskAssessmentScopeID(
ctx context.Context,
conn pg.Querier,
scope Scoper,
riskAssessmentScopeID gid.GID,
) error {
q := `
SELECT
id,
organization_id,
risk_assessment_scope_id,
source_node_id,
target_node_id,
name,
created_at,
updated_at
FROM
risk_assessment_processes
WHERE
%s
AND risk_assessment_scope_id = @risk_assessment_scope_id
ORDER BY
created_at ASC, id ASC
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.NamedArgs{"risk_assessment_scope_id": riskAssessmentScopeID}
maps.Copy(args, scope.SQLArguments())
rows, err := conn.Query(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot query risk assessment processes: %w", err)
}
results, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[RiskAssessmentProcess])
if err != nil {
return fmt.Errorf("cannot collect risk assessment processes: %w", err)
}
*ps = results
return nil
}
func (ps *RiskAssessmentProcesses) CountByRiskAssessmentScopeID(
ctx context.Context,
conn pg.Querier,

View File

@@ -108,6 +108,46 @@ WHERE
return nil
}
func (ts *RiskAssessmentThreats) LoadAllByRiskAssessmentScopeID(
ctx context.Context,
conn pg.Querier,
scope Scoper,
riskAssessmentScopeID gid.GID,
) error {
q := `
SELECT
id,
organization_id,
risk_assessment_scope_id,
process_id,
name,
category,
created_at,
updated_at
FROM
risk_assessment_threats
WHERE
%s
AND risk_assessment_scope_id = @risk_assessment_scope_id
ORDER BY
created_at ASC, id ASC
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.NamedArgs{"risk_assessment_scope_id": riskAssessmentScopeID}
maps.Copy(args, scope.SQLArguments())
rows, err := conn.Query(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot query risk threats: %w", err)
}
results, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[RiskAssessmentThreat])
if err != nil {
return fmt.Errorf("cannot collect risk threats: %w", err)
}
*ts = results
return nil
}
func (ts *RiskAssessmentThreats) CountByRiskAssessmentScopeID(
ctx context.Context,
conn pg.Querier,

View File

@@ -0,0 +1,157 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package riskmanagement
import (
"context"
"fmt"
"strings"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
)
func (s *Service) BuildScopeMermaidChart(ctx context.Context, scope coredata.Scoper, scopeID gid.GID) (string, error) {
var (
nodes coredata.RiskAssessmentNodes
processes coredata.RiskAssessmentProcesses
threats coredata.RiskAssessmentThreats
)
err := s.pg.WithConn(ctx, func(ctx context.Context, conn pg.Querier) error {
if err := nodes.LoadAllByRiskAssessmentScopeID(ctx, conn, scope, scopeID); err != nil {
return fmt.Errorf("cannot load nodes: %w", err)
}
if err := processes.LoadAllByRiskAssessmentScopeID(ctx, conn, scope, scopeID); err != nil {
return fmt.Errorf("cannot load processes: %w", err)
}
if err := threats.LoadAllByRiskAssessmentScopeID(ctx, conn, scope, scopeID); err != nil {
return fmt.Errorf("cannot load threats: %w", err)
}
return nil
})
if err != nil {
return "", err
}
return buildScopeMermaidChart(nodes, processes, threats), nil
}
func buildScopeMermaidChart(
nodes coredata.RiskAssessmentNodes,
processes coredata.RiskAssessmentProcesses,
threats coredata.RiskAssessmentThreats,
) string {
if len(nodes) == 0 {
return ""
}
nodeAlias := make(map[gid.GID]string, len(nodes))
for i, n := range nodes {
nodeAlias[n.ID] = fmt.Sprintf("n%d", i)
}
var b strings.Builder
b.WriteString("flowchart LR\n")
for _, n := range nodes {
id := nodeAlias[n.ID]
fmt.Fprintf(&b, " %s\n", mermaidNodeShape(n.NodeType, id, n.Name))
fmt.Fprintf(&b, " class %s %s\n", id, mermaidNodeClass(n.NodeType))
}
for _, p := range processes {
src, srcOK := nodeAlias[p.SourceNodeID]
dst, dstOK := nodeAlias[p.TargetNodeID]
if !srcOK || !dstOK {
continue
}
fmt.Fprintf(&b, " %s -- \"%s\" --> %s\n", src, escapeMermaidLabel(p.Name), dst)
}
processTarget := make(map[gid.GID]gid.GID, len(processes))
for _, p := range processes {
processTarget[p.ID] = p.TargetNodeID
}
for i, t := range threats {
target, ok := processTarget[t.ProcessID]
if !ok {
continue
}
targetAlias, ok := nodeAlias[target]
if !ok {
continue
}
tid := fmt.Sprintf("t%d", i)
label := escapeMermaidLabel(fmt.Sprintf("%s (%s)", t.Name, t.Category))
fmt.Fprintf(&b, " %s{{\"%s\"}}\n", tid, label)
fmt.Fprintf(&b, " class %s nodeThreat\n", tid)
fmt.Fprintf(&b, " %s -.-> %s\n", tid, targetAlias)
}
b.WriteString(" classDef nodeEntity fill:#dbeafe,stroke:#1d4ed8,color:#1e3a8a\n")
b.WriteString(" classDef nodeBoundary fill:#fef3c7,stroke:#b45309,color:#78350f\n")
b.WriteString(" classDef nodeAsset fill:#e5e7eb,stroke:#374151,color:#111827\n")
b.WriteString(" classDef nodeData fill:#dcfce7,stroke:#15803d,color:#14532d\n")
b.WriteString(" classDef nodeThreat fill:#fee2e2,stroke:#b91c1c,color:#7f1d1d\n")
return strings.TrimRight(b.String(), "\n")
}
func mermaidNodeShape(t coredata.RiskAssessmentNodeType, id, name string) string {
label := `"` + escapeMermaidLabel(name) + `"`
switch t {
case coredata.RiskAssessmentNodeTypeEntity:
return fmt.Sprintf("%s([%s])", id, label)
case coredata.RiskAssessmentNodeTypeBoundary:
return fmt.Sprintf("%s{{%s}}", id, label)
case coredata.RiskAssessmentNodeTypeData:
return fmt.Sprintf("%s[(%s)]", id, label)
case coredata.RiskAssessmentNodeTypeAsset:
fallthrough
default:
return fmt.Sprintf("%s[%s]", id, label)
}
}
func mermaidNodeClass(t coredata.RiskAssessmentNodeType) string {
switch t {
case coredata.RiskAssessmentNodeTypeEntity:
return "nodeEntity"
case coredata.RiskAssessmentNodeTypeBoundary:
return "nodeBoundary"
case coredata.RiskAssessmentNodeTypeData:
return "nodeData"
case coredata.RiskAssessmentNodeTypeAsset:
fallthrough
default:
return "nodeAsset"
}
}
var mermaidLabelReplacer = strings.NewReplacer(
"&", "&amp;",
`"`, "#quot;",
"<", "&lt;",
">", "&gt;",
"\r\n", " ",
"\n", " ",
)
func escapeMermaidLabel(s string) string {
return mermaidLabelReplacer.Replace(s)
}

View File

@@ -228,6 +228,8 @@ type RiskAssessmentScope implements Node {
orderBy: RiskAssessmentScenarioOrder
): RiskAssessmentScenarioConnection @goField(forceResolver: true)
mermaidChart: String! @goField(forceResolver: true)
createdAt: Datetime!
updatedAt: Datetime!
}

View File

@@ -854,6 +854,20 @@ func (r *riskAssessmentScopeResolver) Scenarios(ctx context.Context, obj *types.
return types.NewRiskAssessmentScenarioConnection(p, r, obj.ID), nil
}
// MermaidChart is the resolver for the mermaidChart field.
func (r *riskAssessmentScopeResolver) MermaidChart(ctx context.Context, obj *types.RiskAssessmentScope) (string, error) {
if err := r.authorize(ctx, obj.ID, probo.ActionRiskAssessmentScopeGet); err != nil {
return "", err
}
scope := coredata.NewScopeFromObjectID(obj.ID)
chart, err := r.riskManagement.BuildScopeMermaidChart(ctx, scope, obj.ID)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot build risk assessment scope mermaid chart", log.Error(err))
return "", gqlutils.Internal(ctx)
}
return chart, nil
}
// TotalCount is the resolver for the totalCount field.
func (r *riskAssessmentScopeConnectionResolver) TotalCount(ctx context.Context, obj *types.RiskAssessmentScopeConnection) (*int, error) {
if err := r.authorize(ctx, obj.ParentID, probo.ActionRiskAssessmentScopeList); err != nil {