k8s-av 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +18 -0
- package/dist/cli/docker.d.ts +29 -0
- package/dist/cli/docker.js +241 -0
- package/dist/cli/index.d.ts +16 -0
- package/dist/cli/index.js +155 -0
- package/dist/cli/scan.d.ts +16 -0
- package/dist/cli/scan.js +151 -0
- package/dist/cli/start.d.ts +20 -0
- package/dist/cli/start.js +261 -0
- package/dist/core/attack-path.d.ts +26 -0
- package/dist/core/attack-path.js +191 -0
- package/dist/core/cve-enricher.d.ts +10 -0
- package/dist/core/cve-enricher.js +175 -0
- package/dist/core/fetcher.d.ts +26 -0
- package/dist/core/fetcher.js +130 -0
- package/dist/core/schema.d.ts +290 -0
- package/dist/core/schema.js +125 -0
- package/dist/core/transformer.d.ts +19 -0
- package/dist/core/transformer.js +510 -0
- package/dist/db/loader.d.ts +16 -0
- package/dist/db/loader.js +261 -0
- package/dist/db/neo4j-client.d.ts +35 -0
- package/dist/db/neo4j-client.js +218 -0
- package/dist/db/queries.d.ts +71 -0
- package/dist/db/queries.js +290 -0
- package/dist/db/test.d.ts +19 -0
- package/dist/db/test.js +268 -0
- package/dist/db/types.d.ts +137 -0
- package/dist/db/types.js +37 -0
- package/dist/schemas/index.d.ts +70 -0
- package/dist/schemas/index.js +43 -0
- package/dist/server/routes/blast.d.ts +3 -0
- package/dist/server/routes/blast.js +44 -0
- package/dist/server/routes/critical.d.ts +3 -0
- package/dist/server/routes/critical.js +80 -0
- package/dist/server/routes/cycles.d.ts +3 -0
- package/dist/server/routes/cycles.js +41 -0
- package/dist/server/routes/graph.d.ts +3 -0
- package/dist/server/routes/graph.js +57 -0
- package/dist/server/routes/ingest.d.ts +3 -0
- package/dist/server/routes/ingest.js +82 -0
- package/dist/server/routes/paths.d.ts +3 -0
- package/dist/server/routes/paths.js +66 -0
- package/dist/server/routes/report.d.ts +3 -0
- package/dist/server/routes/report.js +47 -0
- package/dist/server/routes/simulate.d.ts +3 -0
- package/dist/server/routes/simulate.js +75 -0
- package/dist/server/routes/vulnerabilities.d.ts +3 -0
- package/dist/server/routes/vulnerabilities.js +129 -0
- package/dist/server/server.d.ts +15 -0
- package/dist/server/server.js +136 -0
- package/dist/services/ingestion.service.d.ts +25 -0
- package/dist/services/ingestion.service.js +100 -0
- package/dist/services/report/formatter.d.ts +12 -0
- package/dist/services/report/formatter.js +138 -0
- package/dist/services/report/generator.d.ts +27 -0
- package/dist/services/report/generator.js +68 -0
- package/dist/services/risk-explainer.d.ts +67 -0
- package/dist/services/risk-explainer.js +285 -0
- package/docker/docker-compose.yml +66 -0
- package/package.json +75 -0
- package/ui/index.html +12 -0
- package/ui/package-lock.json +3150 -0
- package/ui/package.json +30 -0
- package/ui/postcss.config.cjs +6 -0
- package/ui/src/App.tsx +37 -0
- package/ui/src/components/Box.tsx +33 -0
- package/ui/src/components/DetailPanel.tsx +239 -0
- package/ui/src/components/RiskBadge.tsx +38 -0
- package/ui/src/components/Sidebar.tsx +107 -0
- package/ui/src/components/graph/CustomNode.tsx +102 -0
- package/ui/src/components/graph/GraphCanvas.tsx +174 -0
- package/ui/src/index.css +48 -0
- package/ui/src/lib/api.ts +161 -0
- package/ui/src/main.tsx +10 -0
- package/ui/src/store/useAppStore.ts +168 -0
- package/ui/src/views/CriticalNodeView.tsx +150 -0
- package/ui/src/views/OverviewView.tsx +76 -0
- package/ui/src/views/PathsView.tsx +280 -0
- package/ui/src/views/ReportView.tsx +367 -0
- package/ui/src/views/VulnerabilitiesView.tsx +135 -0
- package/ui/tailwind.config.ts +14 -0
- package/ui/tsconfig.json +20 -0
- package/ui/vite.config.ts +19 -0
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { useMemo, useCallback } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
ReactFlow, Background, Controls, MiniMap,
|
|
4
|
+
useNodesState, useEdgesState,
|
|
5
|
+
type Node, type Edge,
|
|
6
|
+
BackgroundVariant, MarkerType,
|
|
7
|
+
} from '@xyflow/react';
|
|
8
|
+
import '@xyflow/react/dist/style.css';
|
|
9
|
+
import dagre from '@dagrejs/dagre';
|
|
10
|
+
|
|
11
|
+
import { CustomNode, type K8sNodeData } from './CustomNode';
|
|
12
|
+
import { useAppStore } from '../../store/useAppStore';
|
|
13
|
+
import type { GraphNode, GraphEdge } from '../../lib/api';
|
|
14
|
+
|
|
15
|
+
const NODE_W = 180;
|
|
16
|
+
const NODE_H = 54;
|
|
17
|
+
const nodeTypes = { k8s: CustomNode };
|
|
18
|
+
|
|
19
|
+
// ─── Dagre layout ─────────────────────────────────────────────────────────────
|
|
20
|
+
function applyLayout(nodes: Node[], edges: Edge[]): Node[] {
|
|
21
|
+
const g = new dagre.graphlib.Graph();
|
|
22
|
+
g.setDefaultEdgeLabel(() => ({}));
|
|
23
|
+
g.setGraph({ rankdir: 'LR', nodesep: 60, ranksep: 130 });
|
|
24
|
+
nodes.forEach((n) => g.setNode(n.id, { width: NODE_W, height: NODE_H }));
|
|
25
|
+
edges.forEach((e) => g.setEdge(e.source, e.target));
|
|
26
|
+
dagre.layout(g);
|
|
27
|
+
return nodes.map((n) => {
|
|
28
|
+
const pos = g.node(n.id);
|
|
29
|
+
return { ...n, position: { x: pos.x - NODE_W / 2, y: pos.y - NODE_H / 2 } };
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// ─── Transform backend data → React Flow ─────────────────────────────────────
|
|
34
|
+
function buildFlowGraph(
|
|
35
|
+
rawNodes: GraphNode[],
|
|
36
|
+
rawEdges: GraphEdge[],
|
|
37
|
+
selectedNodeId: string | null,
|
|
38
|
+
highlightedNodeIds: Set<string>,
|
|
39
|
+
highlightedEdgeIds: Set<string>,
|
|
40
|
+
criticalNodeId: string | null,
|
|
41
|
+
vulnMap: Map<string, number>,
|
|
42
|
+
): { nodes: Node[]; edges: Edge[] } {
|
|
43
|
+
const hasHighlight = highlightedNodeIds.size > 0;
|
|
44
|
+
|
|
45
|
+
const nodes: Node[] = rawNodes.map((n) => ({
|
|
46
|
+
id: n.id,
|
|
47
|
+
type: 'k8s',
|
|
48
|
+
position: { x: 0, y: 0 },
|
|
49
|
+
data: {
|
|
50
|
+
label: n.name || n.id,
|
|
51
|
+
nodeType: n.type,
|
|
52
|
+
riskScore: n.riskScore,
|
|
53
|
+
isEntryPoint: n.isEntryPoint,
|
|
54
|
+
isCrownJewel: n.isCrownJewel,
|
|
55
|
+
hasCve: (n.cve?.length ?? 0) > 0,
|
|
56
|
+
highlighted: highlightedNodeIds.has(n.id),
|
|
57
|
+
dimmed: hasHighlight && !highlightedNodeIds.has(n.id) && n.id !== selectedNodeId,
|
|
58
|
+
isCritical: n.id === criticalNodeId,
|
|
59
|
+
vuln: vulnMap.get(n.id) ?? null,
|
|
60
|
+
} satisfies K8sNodeData,
|
|
61
|
+
}));
|
|
62
|
+
|
|
63
|
+
const edges: Edge[] = rawEdges.map((e, i) => {
|
|
64
|
+
const edgeId = `${e.from}-${e.to}-${i}`;
|
|
65
|
+
const isHighlighted = highlightedEdgeIds.has(edgeId) || highlightedEdgeIds.has(`${e.from}-${e.to}`);
|
|
66
|
+
return {
|
|
67
|
+
id: edgeId,
|
|
68
|
+
source: e.from,
|
|
69
|
+
target: e.to,
|
|
70
|
+
label: e.type,
|
|
71
|
+
animated: isHighlighted,
|
|
72
|
+
style: {
|
|
73
|
+
stroke: isHighlighted ? '#FF6A00' : '#2a2a2a',
|
|
74
|
+
strokeWidth: isHighlighted ? 2 : 1,
|
|
75
|
+
opacity: hasHighlight && !isHighlighted ? 0.1 : 0.6,
|
|
76
|
+
strokeDasharray: isHighlighted ? '6 3' : undefined,
|
|
77
|
+
},
|
|
78
|
+
markerEnd: {
|
|
79
|
+
type: MarkerType.ArrowClosed,
|
|
80
|
+
color: isHighlighted ? '#FF6A00' : '#2a2a2a',
|
|
81
|
+
},
|
|
82
|
+
labelStyle: { fill: '#555555', fontSize: 9 },
|
|
83
|
+
labelBgStyle: { fill: '#0B0B0B' },
|
|
84
|
+
};
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
return { nodes: applyLayout(nodes, edges), edges };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ─── Component ────────────────────────────────────────────────────────────────
|
|
91
|
+
interface Props {
|
|
92
|
+
highlightedNodeIds?: Set<string>;
|
|
93
|
+
highlightedEdgeKeys?: Set<string>;
|
|
94
|
+
criticalNodeId?: string | null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function GraphCanvas({
|
|
98
|
+
highlightedNodeIds = new Set(),
|
|
99
|
+
highlightedEdgeKeys = new Set(),
|
|
100
|
+
criticalNodeId = null,
|
|
101
|
+
}: Props) {
|
|
102
|
+
const { graphNodes, graphEdges, selectedNodeId, vulnerabilities, selectNode } = useAppStore();
|
|
103
|
+
|
|
104
|
+
const vulnMap = useMemo(() => {
|
|
105
|
+
const m = new Map<string, number>();
|
|
106
|
+
vulnerabilities.forEach((v) => m.set(v.nodeId, v.riskScore));
|
|
107
|
+
return m;
|
|
108
|
+
}, [vulnerabilities]);
|
|
109
|
+
|
|
110
|
+
const { nodes: initialNodes, edges: initialEdges } = useMemo(
|
|
111
|
+
() => buildFlowGraph(graphNodes, graphEdges, selectedNodeId, highlightedNodeIds, highlightedEdgeKeys, criticalNodeId, vulnMap),
|
|
112
|
+
[graphNodes, graphEdges, selectedNodeId, highlightedNodeIds, highlightedEdgeKeys, criticalNodeId, vulnMap],
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
const [nodes, , onNodesChange] = useNodesState(initialNodes);
|
|
116
|
+
const [edges, , onEdgesChange] = useEdgesState(initialEdges);
|
|
117
|
+
|
|
118
|
+
const syncedNodes = useMemo(() => {
|
|
119
|
+
return initialNodes.map((n) => {
|
|
120
|
+
const existing = nodes.find((en) => en.id === n.id);
|
|
121
|
+
return existing ? { ...n, position: existing.position } : n;
|
|
122
|
+
});
|
|
123
|
+
}, [initialNodes]); // eslint-disable-line
|
|
124
|
+
|
|
125
|
+
const onNodeClick = useCallback((_: React.MouseEvent, node: Node) => {
|
|
126
|
+
selectNode(node.id);
|
|
127
|
+
}, [selectNode]);
|
|
128
|
+
|
|
129
|
+
const onPaneClick = useCallback(() => {
|
|
130
|
+
selectNode(null);
|
|
131
|
+
}, [selectNode]);
|
|
132
|
+
|
|
133
|
+
if (graphNodes.length === 0) {
|
|
134
|
+
return (
|
|
135
|
+
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
|
136
|
+
<div style={{ textAlign: 'center' }}>
|
|
137
|
+
<div style={{ fontSize: 13, color: '#555555' }}>No cluster data loaded.</div>
|
|
138
|
+
<div style={{ fontSize: 11, color: '#333333', marginTop: 6 }}>
|
|
139
|
+
Run: <span style={{ fontFamily: 'monospace', color: '#FF6A00' }}>npm run ingest</span>
|
|
140
|
+
</div>
|
|
141
|
+
</div>
|
|
142
|
+
</div>
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return (
|
|
147
|
+
<div style={{ flex: 1, minHeight: 0, borderRadius: 12, overflow: 'hidden' }}>
|
|
148
|
+
<ReactFlow
|
|
149
|
+
nodes={syncedNodes}
|
|
150
|
+
edges={initialEdges}
|
|
151
|
+
onNodesChange={onNodesChange}
|
|
152
|
+
onEdgesChange={onEdgesChange}
|
|
153
|
+
onNodeClick={onNodeClick}
|
|
154
|
+
onPaneClick={onPaneClick}
|
|
155
|
+
nodeTypes={nodeTypes}
|
|
156
|
+
fitView
|
|
157
|
+
fitViewOptions={{ padding: 0.12 }}
|
|
158
|
+
minZoom={0.08}
|
|
159
|
+
maxZoom={2}
|
|
160
|
+
attributionPosition={undefined}
|
|
161
|
+
>
|
|
162
|
+
<Background variant={BackgroundVariant.Dots} color="#1F1F1F" gap={28} size={1} />
|
|
163
|
+
<Controls showInteractive={false} />
|
|
164
|
+
<MiniMap
|
|
165
|
+
nodeColor={(n) => {
|
|
166
|
+
const d = n.data as K8sNodeData;
|
|
167
|
+
return d.riskScore >= 8 ? '#FF3B3B' : d.riskScore >= 5 ? '#FFA726' : '#3B82F6';
|
|
168
|
+
}}
|
|
169
|
+
maskColor="#0B0B0BCC"
|
|
170
|
+
/>
|
|
171
|
+
</ReactFlow>
|
|
172
|
+
</div>
|
|
173
|
+
);
|
|
174
|
+
}
|
package/ui/src/index.css
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
@tailwind base;
|
|
2
|
+
@tailwind components;
|
|
3
|
+
@tailwind utilities;
|
|
4
|
+
|
|
5
|
+
*, *::before, *::after { box-sizing: border-box; }
|
|
6
|
+
|
|
7
|
+
html, body, #root {
|
|
8
|
+
height: 100%;
|
|
9
|
+
margin: 0;
|
|
10
|
+
padding: 0;
|
|
11
|
+
background: #0B0B0B;
|
|
12
|
+
color: #EAEAEA;
|
|
13
|
+
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
|
14
|
+
font-size: 14px;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/* React Flow */
|
|
18
|
+
.react-flow__attribution { display: none !important; }
|
|
19
|
+
.react-flow__background { background: #0B0B0B; }
|
|
20
|
+
.react-flow__minimap { background: #121212 !important; border: 1px solid #1F1F1F; border-radius: 8px; }
|
|
21
|
+
.react-flow__controls { background: #121212 !important; border: 1px solid #1F1F1F; border-radius: 8px; }
|
|
22
|
+
.react-flow__controls-button {
|
|
23
|
+
background: #121212 !important;
|
|
24
|
+
border-bottom: 1px solid #1F1F1F !important;
|
|
25
|
+
color: #888888 !important;
|
|
26
|
+
fill: #888888 !important;
|
|
27
|
+
}
|
|
28
|
+
.react-flow__controls-button:hover {
|
|
29
|
+
background: #1F1F1F !important;
|
|
30
|
+
fill: #EAEAEA !important;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/* Scrollbar */
|
|
34
|
+
::-webkit-scrollbar { width: 4px; height: 4px; }
|
|
35
|
+
::-webkit-scrollbar-track { background: transparent; }
|
|
36
|
+
::-webkit-scrollbar-thumb { background: #1F1F1F; border-radius: 4px; }
|
|
37
|
+
::-webkit-scrollbar-thumb:hover { background: #2a2a2a; }
|
|
38
|
+
|
|
39
|
+
/* Pulse animation for skeleton loaders */
|
|
40
|
+
@keyframes pulse {
|
|
41
|
+
0%, 100% { opacity: 1; }
|
|
42
|
+
50% { opacity: 0.4; }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/* Orange glow utility */
|
|
46
|
+
.glow-orange {
|
|
47
|
+
box-shadow: 0 0 0 1px #FF6A0040, 0 0 12px #FF6A0020;
|
|
48
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// All paths are relative — Vite proxies /api/* → http://localhost:3001
|
|
2
|
+
|
|
3
|
+
async function get<T>(path: string): Promise<T> {
|
|
4
|
+
const res = await fetch(path);
|
|
5
|
+
if (!res.ok) {
|
|
6
|
+
const text = await res.text().catch(() => res.statusText);
|
|
7
|
+
throw new Error(`GET ${path} → ${res.status}: ${text}`);
|
|
8
|
+
}
|
|
9
|
+
return res.json() as Promise<T>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
async function post<T>(path: string, body: unknown): Promise<T> {
|
|
13
|
+
const res = await fetch(path, {
|
|
14
|
+
method: 'POST',
|
|
15
|
+
headers: { 'Content-Type': 'application/json' },
|
|
16
|
+
body: JSON.stringify(body),
|
|
17
|
+
});
|
|
18
|
+
if (!res.ok) {
|
|
19
|
+
const text = await res.text().catch(() => res.statusText);
|
|
20
|
+
throw new Error(`POST ${path} → ${res.status}: ${text}`);
|
|
21
|
+
}
|
|
22
|
+
return res.json() as Promise<T>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// ─── Domain types (match actual backend responses) ───────────────────────────
|
|
26
|
+
|
|
27
|
+
export interface GraphNode {
|
|
28
|
+
id: string;
|
|
29
|
+
type: string;
|
|
30
|
+
name: string;
|
|
31
|
+
namespace: string;
|
|
32
|
+
riskScore: number;
|
|
33
|
+
isEntryPoint: boolean;
|
|
34
|
+
isCrownJewel: boolean;
|
|
35
|
+
image: string | null;
|
|
36
|
+
cve: string[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface GraphEdge {
|
|
40
|
+
from: string;
|
|
41
|
+
to: string;
|
|
42
|
+
type: string;
|
|
43
|
+
weight: number;
|
|
44
|
+
verbs: string[];
|
|
45
|
+
resources: string[];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface GraphResponse {
|
|
49
|
+
nodes: GraphNode[];
|
|
50
|
+
edges: GraphEdge[];
|
|
51
|
+
metadata: {
|
|
52
|
+
totalNodes: number;
|
|
53
|
+
totalEdges: number;
|
|
54
|
+
entryPoints: number;
|
|
55
|
+
crownJewels: number;
|
|
56
|
+
retrievedAt: string;
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface Vulnerability {
|
|
61
|
+
nodeId: string;
|
|
62
|
+
type: string;
|
|
63
|
+
namespace: string;
|
|
64
|
+
riskScore: number;
|
|
65
|
+
isEntryPoint: boolean;
|
|
66
|
+
isCrownJewel: boolean;
|
|
67
|
+
cves: string[];
|
|
68
|
+
reason: string;
|
|
69
|
+
explanation: string;
|
|
70
|
+
connections: { out: number; in: number };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface VulnsResponse {
|
|
74
|
+
vulnerabilities: Vulnerability[];
|
|
75
|
+
summary: {
|
|
76
|
+
total: number;
|
|
77
|
+
critical: number;
|
|
78
|
+
high: number;
|
|
79
|
+
medium: number;
|
|
80
|
+
entryPoints: number;
|
|
81
|
+
crownJewels: number;
|
|
82
|
+
withCves: number;
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface AttackPath {
|
|
87
|
+
nodes: string[];
|
|
88
|
+
riskScore: number;
|
|
89
|
+
entryPoint: string;
|
|
90
|
+
crownJewel: string;
|
|
91
|
+
hops: number;
|
|
92
|
+
totalWeight: number;
|
|
93
|
+
description: string;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export interface PathsResponse {
|
|
97
|
+
paths: AttackPath[];
|
|
98
|
+
summary: {
|
|
99
|
+
total: number;
|
|
100
|
+
critical: number;
|
|
101
|
+
uniqueEntryPoints: number;
|
|
102
|
+
uniqueCrownJewels: number;
|
|
103
|
+
avgHops: number;
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export interface CriticalNodeResult {
|
|
108
|
+
nodeId: string;
|
|
109
|
+
name: string;
|
|
110
|
+
type: string;
|
|
111
|
+
namespace: string;
|
|
112
|
+
betweennessScore: number;
|
|
113
|
+
isEntryPoint: boolean;
|
|
114
|
+
isCrownJewel: boolean;
|
|
115
|
+
riskScore: number;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export interface CriticalResponse {
|
|
119
|
+
criticalNodes: CriticalNodeResult[];
|
|
120
|
+
pathElimination: {
|
|
121
|
+
nodeId: string;
|
|
122
|
+
totalPaths: number;
|
|
123
|
+
pathsEliminated: number;
|
|
124
|
+
pathsRemaining: number;
|
|
125
|
+
reductionPercent: number;
|
|
126
|
+
note: string;
|
|
127
|
+
} | null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export interface SimulateResponse {
|
|
131
|
+
simulation: {
|
|
132
|
+
nodeId: string;
|
|
133
|
+
maxHops: number;
|
|
134
|
+
graphMutated: boolean;
|
|
135
|
+
durationMs: number;
|
|
136
|
+
};
|
|
137
|
+
results: {
|
|
138
|
+
baselinePathCount: number;
|
|
139
|
+
filteredPathCount: number;
|
|
140
|
+
pathsEliminated: number;
|
|
141
|
+
reductionPercent: number;
|
|
142
|
+
verdict: string;
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export interface ReportResponse {
|
|
147
|
+
report: unknown;
|
|
148
|
+
formatted: string;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ─── API surface ─────────────────────────────────────────────────────────────
|
|
152
|
+
|
|
153
|
+
export const api = {
|
|
154
|
+
getGraph: () => get<GraphResponse>('/api/graph'),
|
|
155
|
+
getVulnerabilities: () => get<VulnsResponse>('/api/vulnerabilities'),
|
|
156
|
+
getPaths: () => get<PathsResponse>('/api/paths'),
|
|
157
|
+
getCriticalNode: () => get<CriticalResponse>('/api/critical-node'),
|
|
158
|
+
getReport: () => get<ReportResponse>('/api/report'),
|
|
159
|
+
simulate: (nodeId: string) =>
|
|
160
|
+
post<SimulateResponse>('/api/simulate', { nodeId }),
|
|
161
|
+
};
|
package/ui/src/main.tsx
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { create } from 'zustand';
|
|
2
|
+
import {
|
|
3
|
+
api,
|
|
4
|
+
GraphNode, GraphEdge,
|
|
5
|
+
Vulnerability, AttackPath,
|
|
6
|
+
CriticalResponse, SimulateResponse, ReportResponse,
|
|
7
|
+
} from '../lib/api';
|
|
8
|
+
|
|
9
|
+
type View = 'overview' | 'paths' | 'vulnerabilities' | 'critical' | 'report';
|
|
10
|
+
|
|
11
|
+
interface AppState {
|
|
12
|
+
// ── Data ──────────────────────────────────────────────────────────────────
|
|
13
|
+
graphNodes: GraphNode[];
|
|
14
|
+
graphEdges: GraphEdge[];
|
|
15
|
+
graphMeta: { totalNodes: number; totalEdges: number; entryPoints: number; crownJewels: number } | null;
|
|
16
|
+
vulnerabilities: Vulnerability[];
|
|
17
|
+
vulnSummary: { total: number; critical: number; high: number; medium: number } | null;
|
|
18
|
+
paths: AttackPath[];
|
|
19
|
+
pathsSummary: { total: number; critical: number } | null;
|
|
20
|
+
criticalData: CriticalResponse | null;
|
|
21
|
+
simulateResult: SimulateResponse | null;
|
|
22
|
+
reportData: ReportResponse | null;
|
|
23
|
+
|
|
24
|
+
// ── UI state ──────────────────────────────────────────────────────────────
|
|
25
|
+
activeView: View;
|
|
26
|
+
selectedNodeId: string | null;
|
|
27
|
+
selectedPathIdx: number | null;
|
|
28
|
+
loading: Record<string, boolean>;
|
|
29
|
+
errors: Record<string, string | null>;
|
|
30
|
+
|
|
31
|
+
// ── Actions ───────────────────────────────────────────────────────────────
|
|
32
|
+
setView: (v: View) => void;
|
|
33
|
+
selectNode: (id: string | null) => void;
|
|
34
|
+
selectPath: (idx: number | null) => void;
|
|
35
|
+
clearSimulate: () => void;
|
|
36
|
+
|
|
37
|
+
fetchGraph: () => Promise<void>;
|
|
38
|
+
fetchVulnerabilities: () => Promise<void>;
|
|
39
|
+
fetchPaths: () => Promise<void>;
|
|
40
|
+
fetchCritical: () => Promise<void>;
|
|
41
|
+
fetchReport: () => Promise<void>;
|
|
42
|
+
simulate: (nodeId: string) => Promise<void>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function setLoading(set: (fn: (s: AppState) => Partial<AppState>) => void, key: string, val: boolean) {
|
|
46
|
+
set((s) => ({ loading: { ...s.loading, [key]: val } }));
|
|
47
|
+
}
|
|
48
|
+
function setError(set: (fn: (s: AppState) => Partial<AppState>) => void, key: string, msg: string | null) {
|
|
49
|
+
set((s) => ({ errors: { ...s.errors, [key]: msg } }));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export const useAppStore = create<AppState>((set, get) => ({
|
|
53
|
+
// ── Initial state ─────────────────────────────────────────────────────────
|
|
54
|
+
graphNodes: [],
|
|
55
|
+
graphEdges: [],
|
|
56
|
+
graphMeta: null,
|
|
57
|
+
vulnerabilities: [],
|
|
58
|
+
vulnSummary: null,
|
|
59
|
+
paths: [],
|
|
60
|
+
pathsSummary: null,
|
|
61
|
+
criticalData: null,
|
|
62
|
+
simulateResult: null,
|
|
63
|
+
reportData: null,
|
|
64
|
+
|
|
65
|
+
activeView: 'overview',
|
|
66
|
+
selectedNodeId: null,
|
|
67
|
+
selectedPathIdx: null,
|
|
68
|
+
loading: {},
|
|
69
|
+
errors: {},
|
|
70
|
+
|
|
71
|
+
// ── UI actions ─────────────────────────────────────────────────────────────
|
|
72
|
+
setView: (v) => {
|
|
73
|
+
set({ activeView: v, selectedPathIdx: null });
|
|
74
|
+
// Lazy-fetch on first activation
|
|
75
|
+
const s = get();
|
|
76
|
+
if (v === 'paths' && s.paths.length === 0) s.fetchPaths();
|
|
77
|
+
if (v === 'vulnerabilities' && s.vulnerabilities.length === 0) s.fetchVulnerabilities();
|
|
78
|
+
if (v === 'critical' && !s.criticalData) s.fetchCritical();
|
|
79
|
+
if (v === 'report' && !s.reportData) s.fetchReport();
|
|
80
|
+
},
|
|
81
|
+
|
|
82
|
+
selectNode: (id) => set({ selectedNodeId: id, simulateResult: null }),
|
|
83
|
+
selectPath: (idx) => set({ selectedPathIdx: idx }),
|
|
84
|
+
clearSimulate: () => set({ simulateResult: null }),
|
|
85
|
+
|
|
86
|
+
// ── Fetch actions ──────────────────────────────────────────────────────────
|
|
87
|
+
fetchGraph: async () => {
|
|
88
|
+
setLoading(set, 'graph', true);
|
|
89
|
+
setError(set, 'graph', null);
|
|
90
|
+
try {
|
|
91
|
+
const data = await api.getGraph();
|
|
92
|
+
set({
|
|
93
|
+
graphNodes: data.nodes,
|
|
94
|
+
graphEdges: data.edges,
|
|
95
|
+
graphMeta: data.metadata,
|
|
96
|
+
});
|
|
97
|
+
} catch (e) {
|
|
98
|
+
setError(set, 'graph', (e as Error).message);
|
|
99
|
+
} finally {
|
|
100
|
+
setLoading(set, 'graph', false);
|
|
101
|
+
}
|
|
102
|
+
},
|
|
103
|
+
|
|
104
|
+
fetchVulnerabilities: async () => {
|
|
105
|
+
setLoading(set, 'vulns', true);
|
|
106
|
+
setError(set, 'vulns', null);
|
|
107
|
+
try {
|
|
108
|
+
const data = await api.getVulnerabilities();
|
|
109
|
+
set({ vulnerabilities: data.vulnerabilities, vulnSummary: data.summary });
|
|
110
|
+
} catch (e) {
|
|
111
|
+
setError(set, 'vulns', (e as Error).message);
|
|
112
|
+
} finally {
|
|
113
|
+
setLoading(set, 'vulns', false);
|
|
114
|
+
}
|
|
115
|
+
},
|
|
116
|
+
|
|
117
|
+
fetchPaths: async () => {
|
|
118
|
+
setLoading(set, 'paths', true);
|
|
119
|
+
setError(set, 'paths', null);
|
|
120
|
+
try {
|
|
121
|
+
const data = await api.getPaths();
|
|
122
|
+
set({ paths: data.paths, pathsSummary: data.summary });
|
|
123
|
+
} catch (e) {
|
|
124
|
+
setError(set, 'paths', (e as Error).message);
|
|
125
|
+
} finally {
|
|
126
|
+
setLoading(set, 'paths', false);
|
|
127
|
+
}
|
|
128
|
+
},
|
|
129
|
+
|
|
130
|
+
fetchCritical: async () => {
|
|
131
|
+
setLoading(set, 'critical', true);
|
|
132
|
+
setError(set, 'critical', null);
|
|
133
|
+
try {
|
|
134
|
+
const data = await api.getCriticalNode();
|
|
135
|
+
set({ criticalData: data });
|
|
136
|
+
} catch (e) {
|
|
137
|
+
setError(set, 'critical', (e as Error).message);
|
|
138
|
+
} finally {
|
|
139
|
+
setLoading(set, 'critical', false);
|
|
140
|
+
}
|
|
141
|
+
},
|
|
142
|
+
|
|
143
|
+
fetchReport: async () => {
|
|
144
|
+
setLoading(set, 'report', true);
|
|
145
|
+
setError(set, 'report', null);
|
|
146
|
+
try {
|
|
147
|
+
const data = await api.getReport();
|
|
148
|
+
set({ reportData: data });
|
|
149
|
+
} catch (e) {
|
|
150
|
+
setError(set, 'report', (e as Error).message);
|
|
151
|
+
} finally {
|
|
152
|
+
setLoading(set, 'report', false);
|
|
153
|
+
}
|
|
154
|
+
},
|
|
155
|
+
|
|
156
|
+
simulate: async (nodeId: string) => {
|
|
157
|
+
setLoading(set, 'simulate', true);
|
|
158
|
+
setError(set, 'simulate', null);
|
|
159
|
+
try {
|
|
160
|
+
const data = await api.simulate(nodeId);
|
|
161
|
+
set({ simulateResult: data });
|
|
162
|
+
} catch (e) {
|
|
163
|
+
setError(set, 'simulate', (e as Error).message);
|
|
164
|
+
} finally {
|
|
165
|
+
setLoading(set, 'simulate', false);
|
|
166
|
+
}
|
|
167
|
+
},
|
|
168
|
+
}));
|