k8s-av 1.0.2 → 1.0.4

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.
@@ -7,7 +7,7 @@ router.get('/', async (_req, res, next) => {
7
7
  try {
8
8
  const [nodesResult, edgesResult] = await Promise.all([
9
9
  (0, neo4j_client_1.runQuery)(`
10
- MATCH (n:K8sNode) AQ.Ab8RN6IDrFDTiOIGWD65asoJ0AOO5fTcFliVNoxxtZT83wueNg
10
+ MATCH (n:K8sNode)
11
11
  RETURN
12
12
  n.id AS id,
13
13
  n.type AS type,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "k8s-av",
3
- "version": "1.0.2",
3
+ "version": "1.0.4",
4
4
  "description": "Kubernetes RBAC Attack Path Visualizer — scan your cluster, detect attack paths, visualize in a local UI",
5
5
  "keywords": [
6
6
  "kubernetes",
package/ui/package.json CHANGED
@@ -14,7 +14,8 @@
14
14
  "jspdf": "^4.2.1",
15
15
  "react": "^18.3.1",
16
16
  "react-dom": "^18.3.1",
17
- "zustand": "^4.5.2"
17
+ "zustand": "^4.5.2",
18
+ "vite": "^5.2.11"
18
19
  },
19
20
  "devDependencies": {
20
21
  "@types/dagre": "^0.7.52",
package/ui/src/App.tsx CHANGED
@@ -11,7 +11,6 @@ import { useAppStore } from './store/useAppStore';
11
11
  export default function App() {
12
12
  const { activeView, selectedNodeId, fetchGraph, fetchVulnerabilities } = useAppStore();
13
13
 
14
- // Fetch graph + vulnerabilities on mount
15
14
  useEffect(() => {
16
15
  fetchGraph();
17
16
  fetchVulnerabilities();
@@ -21,7 +20,6 @@ export default function App() {
21
20
  <div style={{ display: 'flex', height: '100vh', overflow: 'hidden', background: '#0a0a0f' }}>
22
21
  <Sidebar />
23
22
 
24
- {/* Main */}
25
23
  <main style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden', position: 'relative' }}>
26
24
  {activeView === 'overview' && <OverviewView />}
27
25
  {activeView === 'paths' && <PathsView />}
@@ -30,7 +28,6 @@ export default function App() {
30
28
  {activeView === 'report' && <ReportView />}
31
29
  </main>
32
30
 
33
- {/* Detail panel slides in from right when a node is selected */}
34
31
  {selectedNodeId && <DetailPanel />}
35
32
  </div>
36
33
  );
@@ -3,7 +3,6 @@ import type { ReactNode } from 'react';
3
3
  interface BoxProps {
4
4
  children: ReactNode;
5
5
  className?: string;
6
- /** adds a 2px orange top-border accent line */
7
6
  accent?: boolean;
8
7
  }
9
8
 
@@ -15,13 +15,11 @@ export function DetailPanel() {
15
15
 
16
16
  return (
17
17
  <>
18
- {/* Backdrop */}
19
18
  <div
20
19
  style={{ position: 'fixed', inset: 0, zIndex: 40 }}
21
20
  onClick={() => selectNode(null)}
22
21
  />
23
22
 
24
- {/* Panel */}
25
23
  <aside
26
24
  style={{
27
25
  position: 'fixed',
@@ -44,7 +42,6 @@ export function DetailPanel() {
44
42
  }
45
43
  `}</style>
46
44
 
47
- {/* Header */}
48
45
  <div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', padding: '16px', borderBottom: '1px solid #1F1F1F' }}>
49
46
  <div style={{ flex: 1, minWidth: 0 }}>
50
47
  <div style={{ fontSize: 9, fontFamily: 'monospace', color: '#FF6A00', textTransform: 'uppercase', letterSpacing: '0.12em', marginBottom: 5 }}>
@@ -68,14 +65,12 @@ export function DetailPanel() {
68
65
  </div>
69
66
 
70
67
  <div style={{ flex: 1, overflowY: 'auto', padding: 16, display: 'flex', flexDirection: 'column', gap: 12 }}>
71
- {/* Tags */}
72
68
  <div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
73
69
  {node.isEntryPoint && <Tag label="Entry Point" color="blue" />}
74
70
  {node.isCrownJewel && <Tag label="Crown Jewel" color="amber" />}
75
71
  {vuln && <RiskBadge score={vuln.riskScore} />}
76
72
  </div>
77
73
 
78
- {/* Image */}
79
74
  {node.image && (
80
75
  <InfoBox>
81
76
  <Label>Image</Label>
@@ -83,7 +78,6 @@ export function DetailPanel() {
83
78
  </InfoBox>
84
79
  )}
85
80
 
86
- {/* CVEs */}
87
81
  {(node.cve?.length ?? 0) > 0 && (
88
82
  <InfoBox>
89
83
  <Label>CVEs</Label>
@@ -108,7 +102,6 @@ export function DetailPanel() {
108
102
  </InfoBox>
109
103
  )}
110
104
 
111
- {/* Risk Analysis */}
112
105
  {vuln && (
113
106
  <InfoBox>
114
107
  <Label>Risk Analysis</Label>
@@ -123,7 +116,6 @@ export function DetailPanel() {
123
116
  </InfoBox>
124
117
  )}
125
118
 
126
- {/* Simulate Removal */}
127
119
  <InfoBox>
128
120
  <Label>Simulate Removal</Label>
129
121
  <p style={{ fontSize: 11, color: '#555555', marginBottom: 10, marginTop: 0 }}>
@@ -17,7 +17,6 @@ export function Sidebar() {
17
17
  <aside
18
18
  style={{ width: 192, flexShrink: 0, display: 'flex', flexDirection: 'column', background: '#0B0B0B', borderRight: '1px solid #1F1F1F' }}
19
19
  >
20
- {/* Logo */}
21
20
  <div style={{ padding: '20px 16px 16px', borderBottom: '1px solid #1F1F1F' }}>
22
21
  <div style={{ fontSize: 14, fontFamily: 'monospace', color: '#FF6A00', letterSpacing: '0.15em', textTransform: 'uppercase', fontWeight: 600 }}>
23
22
  K8s-AV
@@ -25,12 +24,10 @@ export function Sidebar() {
25
24
  <div style={{ fontSize: 12, color: '#888888', marginTop: 3 }}>Attack Path Visualizer</div>
26
25
  </div>
27
26
 
28
- {/* Section label */}
29
27
  <div style={{ padding: '16px 16px 8px', fontSize: 11, color: '#555555', textTransform: 'uppercase', letterSpacing: '0.12em' }}>
30
28
  Navigation
31
29
  </div>
32
30
 
33
- {/* Nav */}
34
31
  <nav style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 2, padding: '0 8px' }}>
35
32
  {NAV.map(({ id, label, icon }) => {
36
33
  const active = activeView === id;
@@ -74,7 +71,6 @@ export function Sidebar() {
74
71
  })}
75
72
  </nav>
76
73
 
77
- {/* Stats footer */}
78
74
  <div style={{ padding: '12px 16px', borderTop: '1px solid #1F1F1F', display: 'flex', flexDirection: 'column', gap: 8 }}>
79
75
  <div style={{ fontSize: 11, color: '#555555', textTransform: 'uppercase', letterSpacing: '0.12em', marginBottom: 2 }}>
80
76
  Cluster Stats
@@ -62,7 +62,6 @@ export const CustomNode = memo(({ data }: NodeProps) => {
62
62
  <Handle type="target" position={Position.Left} style={{ background: borderColor, width: 5, height: 5, border: 'none' }} />
63
63
  <Handle type="source" position={Position.Right} style={{ background: borderColor, width: 5, height: 5, border: 'none' }} />
64
64
 
65
- {/* CVE dot */}
66
65
  {d.hasCve && (
67
66
  <span
68
67
  title="Has CVEs"
@@ -74,17 +73,14 @@ export const CustomNode = memo(({ data }: NodeProps) => {
74
73
  />
75
74
  )}
76
75
 
77
- {/* Type badge */}
78
76
  <div style={{ fontSize: 9, color: borderColor, textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: 3, opacity: 0.9 }}>
79
77
  {d.isEntryPoint ? '⤳ ' : ''}{d.isCrownJewel ? '★ ' : ''}{d.nodeType}
80
78
  </div>
81
79
 
82
- {/* Label */}
83
80
  <div style={{ fontSize: 11, color: '#EAEAEA', fontFamily: 'monospace', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
84
81
  {d.label}
85
82
  </div>
86
83
 
87
- {/* Risk score */}
88
84
  {d.riskScore > 0 && (
89
85
  <div style={{
90
86
  fontSize: 9,
@@ -16,7 +16,6 @@ const NODE_W = 180;
16
16
  const NODE_H = 54;
17
17
  const nodeTypes = { k8s: CustomNode };
18
18
 
19
- // ─── Dagre layout ─────────────────────────────────────────────────────────────
20
19
  function applyLayout(nodes: Node[], edges: Edge[]): Node[] {
21
20
  const g = new dagre.graphlib.Graph();
22
21
  g.setDefaultEdgeLabel(() => ({}));
@@ -30,7 +29,6 @@ function applyLayout(nodes: Node[], edges: Edge[]): Node[] {
30
29
  });
31
30
  }
32
31
 
33
- // ─── Transform backend data → React Flow ─────────────────────────────────────
34
32
  function buildFlowGraph(
35
33
  rawNodes: GraphNode[],
36
34
  rawEdges: GraphEdge[],
@@ -87,7 +85,6 @@ function buildFlowGraph(
87
85
  return { nodes: applyLayout(nodes, edges), edges };
88
86
  }
89
87
 
90
- // ─── Component ────────────────────────────────────────────────────────────────
91
88
  interface Props {
92
89
  highlightedNodeIds?: Set<string>;
93
90
  highlightedEdgeKeys?: Set<string>;
@@ -10,7 +10,6 @@ export function CriticalNodeView() {
10
10
  return (
11
11
  <div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
12
12
 
13
- {/* Info panel — full height */}
14
13
  <div style={{ flex: 1, minHeight: 0, overflowY: 'auto', padding: 14 }}>
15
14
 
16
15
  {loading['critical'] && (
@@ -30,7 +29,6 @@ export function CriticalNodeView() {
30
29
  {top && (
31
30
  <div style={{ background: '#121212', border: '1px solid #1F1F1F', borderTop: '2px solid #FF6A00', borderRadius: 12, overflow: 'hidden' }}>
32
31
 
33
- {/* Node info */}
34
32
  <div style={{ padding: '16px 20px', borderBottom: '1px solid #1F1F1F' }}>
35
33
  <div style={{ fontSize: 11, color: '#555555', textTransform: 'uppercase', letterSpacing: '0.12em', marginBottom: 10 }}>
36
34
  Critical Node
@@ -53,7 +51,6 @@ export function CriticalNodeView() {
53
51
  </div>
54
52
  </div>
55
53
 
56
- {/* Path elimination */}
57
54
  {elim && (
58
55
  <div style={{ padding: '16px 20px', borderBottom: '1px solid #1F1F1F' }}>
59
56
  <div style={{ fontSize: 11, color: '#555555', textTransform: 'uppercase', letterSpacing: '0.12em', marginBottom: 12 }}>
@@ -68,7 +65,6 @@ export function CriticalNodeView() {
68
65
  </div>
69
66
  )}
70
67
 
71
- {/* Simulate */}
72
68
  <div style={{ padding: '16px 20px' }}>
73
69
  <div style={{ fontSize: 11, color: '#555555', textTransform: 'uppercase', letterSpacing: '0.12em', marginBottom: 12 }}>
74
70
  Simulate Removal
@@ -6,7 +6,6 @@ export function OverviewView() {
6
6
 
7
7
  return (
8
8
  <div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
9
- {/* Summary cards */}
10
9
  {(graphMeta || loading['graph']) && (
11
10
  <div style={{ flexShrink: 0, display: 'flex', gap: 10, padding: '14px 16px', borderBottom: '1px solid #1F1F1F' }}>
12
11
  {loading['graph']
@@ -35,14 +34,12 @@ export function OverviewView() {
35
34
  </div>
36
35
  )}
37
36
 
38
- {/* Error */}
39
37
  {errors['graph'] && (
40
38
  <div style={{ flexShrink: 0, margin: '12px 16px 0', padding: '8px 12px', borderLeft: '2px solid #FF3B3B', background: '#FF3B3B10', color: '#FF3B3B', fontSize: 12 }}>
41
39
  {errors['graph']}
42
40
  </div>
43
41
  )}
44
42
 
45
- {/* Graph container */}
46
43
  <div style={{ flex: 1, minHeight: 0, padding: 12, display: 'flex', flexDirection: 'column' }}>
47
44
  <div style={{ flex: 1, minHeight: 0, background: '#121212', border: '1px solid #1F1F1F', borderRadius: 16, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
48
45
  <GraphCanvas />
@@ -28,7 +28,6 @@ export function PathsView() {
28
28
  return (
29
29
  <div style={{ display: 'flex', height: '100%', overflow: 'hidden' }}>
30
30
 
31
- {/* ── Path List (full width) ───────────────────────────────────── */}
32
31
  <div style={{
33
32
  flex: 1,
34
33
  display: 'flex',
@@ -36,7 +35,6 @@ export function PathsView() {
36
35
  background: '#0B0B0B',
37
36
  overflow: 'hidden',
38
37
  }}>
39
- {/* Header */}
40
38
  <div style={{ padding: '16px 20px 14px', borderBottom: '1px solid #1F1F1F', flexShrink: 0 }}>
41
39
  <div style={{ fontSize: 14, color: '#EAEAEA', fontWeight: 500, marginBottom: 6 }}>Attack Paths</div>
42
40
  {pathsSummary && (
@@ -49,7 +47,6 @@ export function PathsView() {
49
47
  )}
50
48
  </div>
51
49
 
52
- {/* List */}
53
50
  <div style={{ flex: 1, overflowY: 'auto', padding: '12px 20px', display: 'flex', flexDirection: 'column', gap: 0, maxWidth: 900, width: '100%', alignSelf: 'center', boxSizing: 'border-box' }}>
54
51
 
55
52
  {loading['paths'] && Array.from({ length: 3 }).map((_, i) => (
@@ -109,8 +106,6 @@ export function PathsView() {
109
106
  );
110
107
  }
111
108
 
112
- // ─── PathCard ──────────────────────────────────────────────────────────────────
113
-
114
109
  interface PathCardProps {
115
110
  index: number;
116
111
  nodes: string[];
@@ -122,7 +117,6 @@ interface PathCardProps {
122
117
  }
123
118
 
124
119
  function PathCard({ index, nodes, description, riskScore, riskColor, riskLabel, active }: PathCardProps) {
125
- // Chunk nodes into rows of max 3 (so arrows don't overflow)
126
120
  const COLS = 3;
127
121
  const chunks: string[][] = [];
128
122
  for (let i = 0; i < nodes.length; i += COLS) {
@@ -155,7 +149,6 @@ function PathCard({ index, nodes, description, riskScore, riskColor, riskLabel,
155
149
  }
156
150
  }}
157
151
  >
158
- {/* ── Top row: index + risk badge ── */}
159
152
  <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }}>
160
153
  <span style={{
161
154
  fontSize: 10,
@@ -182,7 +175,6 @@ function PathCard({ index, nodes, description, riskScore, riskColor, riskLabel,
182
175
  </span>
183
176
  </div>
184
177
 
185
- {/* ── Node chain grid ── */}
186
178
  <div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 16 }}>
187
179
  {chunks.map((chunk, rowIdx) => {
188
180
  const globalOffset = rowIdx * COLS;
@@ -194,7 +186,6 @@ function PathCard({ index, nodes, description, riskScore, riskColor, riskLabel,
194
186
  const isLast = globalIdx === nodes.length - 1;
195
187
  const isArrow = colIdx < chunk.length - 1 || (rowIdx < chunks.length - 1 && colIdx === chunk.length - 1);
196
188
 
197
- // determine chip style
198
189
  let chipBg = '#1e1e26';
199
190
  let chipBorder = '#2e2e3a';
200
191
  let chipColor = '#9090a0';
@@ -259,7 +250,6 @@ function PathCard({ index, nodes, description, riskScore, riskColor, riskLabel,
259
250
  })}
260
251
  </div>
261
252
 
262
- {/* ── Divider ── */}
263
253
  <div style={{
264
254
  height: 1,
265
255
  background: 'linear-gradient(90deg, #1a6070, #0a2a35 80%, transparent)',
@@ -267,7 +257,6 @@ function PathCard({ index, nodes, description, riskScore, riskColor, riskLabel,
267
257
  borderRadius: 1,
268
258
  }} />
269
259
 
270
- {/* ── Description ── */}
271
260
  <p style={{
272
261
  margin: 0,
273
262
  fontSize: 12,
@@ -13,10 +13,9 @@ export function ReportView() {
13
13
  const downloadPdf = () => {
14
14
  if (!reportData?.formatted) return;
15
15
 
16
- // ── 1. Sanitise: strip all chars the built-in Courier font can't render ──
17
16
  const sanitise = (s: string) =>
18
17
  s
19
- .replace(/[╔╗╚╝║]/g, '') // box corners / sides
18
+ .replace(/[╔╗╚╝║]/g, '')
20
19
  .replace(/═+/g, (m) => '='.repeat(m.length))
21
20
  .replace(/─+/g, (m) => '-'.repeat(m.length))
22
21
  .replace(/█/g, '#')
@@ -24,9 +23,8 @@ export function ReportView() {
24
23
  .replace(/[→]/g, '->')
25
24
  .replace(/[≥]/g, '>=')
26
25
  .replace(/[✔]/g, 'OK')
27
- .replace(/[^\x00-\x7F]/g, '?'); // catch-all for remaining non-ASCII
26
+ .replace(/[^\x00-\x7F]/g, '?');
28
27
 
29
- // ── 2. Classify each source line ────────────────────────────────────────
30
28
  type LineKind = 'blank' | 'divider' | 'title-box' | 'section' | 'path-header' | 'kv' | 'text';
31
29
 
32
30
  interface ParsedLine { kind: LineKind; raw: string; clean: string }
@@ -48,16 +46,14 @@ export function ReportView() {
48
46
  return { kind: 'text', raw, clean };
49
47
  });
50
48
 
51
- // ── 3. Build PDF ─────────────────────────────────────────────────────────
52
49
  const doc = new jsPDF({ unit: 'pt', format: 'a4' });
53
50
  const pageW = doc.internal.pageSize.getWidth();
54
51
  const pageH = doc.internal.pageSize.getHeight();
55
- const ML = 48; // margin left
56
- const MR = 48; // margin right
52
+ const ML = 48;
53
+ const MR = 48;
57
54
  const bodyW = pageW - ML - MR;
58
55
  const date = new Date().toISOString().slice(0, 10);
59
56
 
60
- // colours (RGB)
61
57
  const C = {
62
58
  bg: [11, 11, 11] as [number,number,number],
63
59
  orange: [255, 106, 0] as [number,number,number],
@@ -72,7 +68,6 @@ export function ReportView() {
72
68
  let y = 0;
73
69
  let pageNum = 1;
74
70
 
75
- // ── helpers ──────────────────────────────────────────────────────
76
71
  const fill = (c: [number,number,number]) => doc.setFillColor(...c);
77
72
  const text = (c: [number,number,number]) => doc.setTextColor(...c);
78
73
  const draw = (c: [number,number,number]) => doc.setDrawColor(...c);
@@ -81,10 +76,8 @@ export function ReportView() {
81
76
 
82
77
  const drawPageHeader = () => {
83
78
  drawBg();
84
- // orange top strip
85
79
  fill(C.orange);
86
80
  doc.rect(0, 0, pageW, 3, 'F');
87
- // left accent line
88
81
  fill(C.orange);
89
82
  doc.rect(ML - 12, 24, 2, pageH - 48, 'F');
90
83
  };
@@ -109,31 +102,26 @@ export function ReportView() {
109
102
  if (y + needed > pageH - 40) newPage();
110
103
  };
111
104
 
112
- // ── cover page ───────────────────────────────────────────────────
113
105
  drawPageHeader();
114
106
  y = 52;
115
107
 
116
- // Title
117
108
  doc.setFont('courier', 'bold');
118
109
  doc.setFontSize(22);
119
110
  text(C.white);
120
111
  doc.text('K8s Attack Path Report', ML, y);
121
112
  y += 26;
122
113
 
123
- // Subtitle row
124
114
  doc.setFont('courier', 'normal');
125
115
  doc.setFontSize(8.5);
126
116
  text(C.dim);
127
117
  doc.text(`Generated ${date} | Kubernetes RBAC Attack Path Visualizer`, ML, y);
128
118
  y += 18;
129
119
 
130
- // Full-width divider under header
131
120
  draw(C.faint);
132
121
  doc.setLineWidth(0.4);
133
122
  doc.line(ML, y, pageW - MR, y);
134
123
  y += 14;
135
124
 
136
- // ── render body lines ────────────────────────────────────────────
137
125
  for (const pl of parsed) {
138
126
  switch (pl.kind) {
139
127
 
@@ -151,13 +139,11 @@ export function ReportView() {
151
139
  }
152
140
 
153
141
  case 'title-box':
154
- // skip — we already drew a nicer cover header
155
142
  break;
156
143
 
157
144
  case 'section': {
158
145
  ensureSpace(32);
159
146
  y += 6;
160
- // orange pill background
161
147
  const label = pl.clean.trim();
162
148
  fill(C.orange);
163
149
  doc.roundedRect(ML, y - 10, bodyW, 16, 2, 2, 'F');
@@ -172,12 +158,10 @@ export function ReportView() {
172
158
  case 'path-header': {
173
159
  ensureSpace(22);
174
160
  y += 4;
175
- // strip the risk bar text, parse score
176
161
  const riskMatch = pl.raw.match(/([\d.]+)\/10/);
177
162
  const score = riskMatch ? parseFloat(riskMatch[1]) : 0;
178
163
  const riskColor = score >= 8 ? C.danger : score >= 5 ? C.orange : [76, 175, 80] as [number,number,number];
179
164
 
180
- // label (Path N / Dijkstra N / Cycle N)
181
165
  const labelMatch = pl.clean.trim().match(/^(\w+\s+\d+)/);
182
166
  const pathLabel = labelMatch ? labelMatch[1] : pl.clean.trim();
183
167
 
@@ -186,7 +170,6 @@ export function ReportView() {
186
170
  text(C.white);
187
171
  doc.text(pathLabel, ML, y);
188
172
 
189
- // score badge
190
173
  if (score > 0) {
191
174
  const badge = `${score.toFixed(1)} / 10`;
192
175
  doc.setFont('courier', 'bold');
@@ -194,7 +177,6 @@ export function ReportView() {
194
177
  text(riskColor);
195
178
  doc.text(badge, pageW - MR, y, { align: 'right' });
196
179
 
197
- // mini bar: 10 segments
198
180
  const barW = 60;
199
181
  const barH = 4;
200
182
  const barX = pageW - MR - barW - 48;
@@ -212,7 +194,6 @@ export function ReportView() {
212
194
 
213
195
  case 'kv': {
214
196
  ensureSpace(14);
215
- // split " Key : value" into key + value parts
216
197
  const colonIdx = pl.clean.indexOf(':');
217
198
  const key = colonIdx > -1 ? pl.clean.slice(0, colonIdx + 1).trim() : pl.clean.trim();
218
199
  const value = colonIdx > -1 ? pl.clean.slice(colonIdx + 1).trim() : '';
@@ -220,11 +201,9 @@ export function ReportView() {
220
201
  doc.setFont('courier', 'normal');
221
202
  doc.setFontSize(8);
222
203
 
223
- // key
224
204
  text(C.key);
225
205
  doc.text(key, ML + 8, y);
226
206
 
227
- // value (wrap if long)
228
207
  text(C.muted);
229
208
  const keyWidth = doc.getTextWidth(key + ' ');
230
209
  const valueLines = doc.splitTextToSize(value, bodyW - keyWidth - 8) as string[];
@@ -239,7 +218,6 @@ export function ReportView() {
239
218
  }
240
219
 
241
220
  default: {
242
- // generic text — wrap to page width
243
221
  ensureSpace(13);
244
222
  doc.setFont('courier', 'normal');
245
223
  doc.setFontSize(8);
@@ -262,7 +240,6 @@ export function ReportView() {
262
240
  return (
263
241
  <div style={{ display: 'flex', flexDirection: 'column', height: '100%', padding: 14, gap: 12 }}>
264
242
 
265
- {/* Header box */}
266
243
  <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexShrink: 0 }}>
267
244
  <div>
268
245
  <div style={{ fontSize: 9, color: '#555555', textTransform: 'uppercase', letterSpacing: '0.12em' }}>Attack Report</div>
@@ -275,7 +252,6 @@ export function ReportView() {
275
252
  )}
276
253
  </div>
277
254
 
278
- {/* Content box */}
279
255
  <div
280
256
  style={{
281
257
  flex: 1,
@@ -14,10 +14,8 @@ export function VulnerabilitiesView() {
14
14
  return (
15
15
  <div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
16
16
 
17
- {/* Table panel — fills full height */}
18
17
  <div style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
19
18
 
20
- {/* Summary bar */}
21
19
  {vulnSummary && (
22
20
  <div style={{ display: 'flex', gap: 20, padding: '10px 16px', borderBottom: '1px solid #1F1F1F' }}>
23
21
  <span style={{ fontSize: 13, color: '#555555' }}>
@@ -41,7 +39,6 @@ export function VulnerabilitiesView() {
41
39
  </div>
42
40
  )}
43
41
 
44
- {/* Loading */}
45
42
  {loading['vulns'] && (
46
43
  <div style={{ padding: 12, display: 'flex', flexDirection: 'column', gap: 8 }}>
47
44
  {Array.from({ length: 4 }).map((_, i) => (
@@ -50,19 +47,16 @@ export function VulnerabilitiesView() {
50
47
  </div>
51
48
  )}
52
49
 
53
- {/* Error */}
54
50
  {errors['vulns'] && (
55
51
  <div style={{ margin: 12, padding: '8px 12px', borderLeft: '2px solid #FF3B3B', background: '#FF3B3B10', color: '#FF3B3B', fontSize: 12 }}>
56
52
  {errors['vulns']}
57
53
  </div>
58
54
  )}
59
55
 
60
- {/* Empty */}
61
56
  {!loading['vulns'] && vulnerabilities.length === 0 && !errors['vulns'] && (
62
57
  <div style={{ padding: 16, textAlign: 'center', fontSize: 12, color: '#555555' }}>No vulnerabilities above threshold.</div>
63
58
  )}
64
59
 
65
- {/* Table */}
66
60
  {vulnerabilities.length > 0 && (
67
61
  <table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
68
62
  <thead>