bhshell 0.1.0__py3-none-any.whl

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.
Files changed (59) hide show
  1. bhshell/__init__.py +3 -0
  2. bhshell/__main__.py +3 -0
  3. bhshell/cli.py +55 -0
  4. bhshell/client/__init__.py +0 -0
  5. bhshell/client/auth.py +74 -0
  6. bhshell/client/client.py +337 -0
  7. bhshell/config.py +162 -0
  8. bhshell/errors.py +73 -0
  9. bhshell/library/asrep-roastable-users.cyp +3 -0
  10. bhshell/library/dcsync-principals.cyp +3 -0
  11. bhshell/library/domain-admins-members.cyp +3 -0
  12. bhshell/library/domain-trusts.cyp +3 -0
  13. bhshell/library/gpos-linked-to-domain-controllers.cyp +3 -0
  14. bhshell/library/kerberoastable-users.cyp +3 -0
  15. bhshell/library/owned-objects.cyp +3 -0
  16. bhshell/library/owned-to-tier-zero.cyp +3 -0
  17. bhshell/library/sid-history.cyp +3 -0
  18. bhshell/library/tier-zero-users.cyp +3 -0
  19. bhshell/library/unconstrained-delegation.cyp +3 -0
  20. bhshell/models/__init__.py +0 -0
  21. bhshell/models/entities.py +402 -0
  22. bhshell/models/results.py +83 -0
  23. bhshell/queries/__init__.py +0 -0
  24. bhshell/queries/cypher.py +155 -0
  25. bhshell/queries/domains.py +136 -0
  26. bhshell/queries/edges.py +109 -0
  27. bhshell/queries/hunt.py +675 -0
  28. bhshell/queries/library.py +94 -0
  29. bhshell/queries/marking.py +121 -0
  30. bhshell/queries/paths.py +369 -0
  31. bhshell/queries/search.py +196 -0
  32. bhshell/queries/service.py +529 -0
  33. bhshell/render/__init__.py +0 -0
  34. bhshell/render/export.py +79 -0
  35. bhshell/render/graph.py +326 -0
  36. bhshell/render/layout.py +138 -0
  37. bhshell/render/png.py +44 -0
  38. bhshell/render/styles.py +5 -0
  39. bhshell/render/svg.py +495 -0
  40. bhshell/render/views.py +337 -0
  41. bhshell/shell/__init__.py +0 -0
  42. bhshell/shell/app.py +189 -0
  43. bhshell/shell/commands/__init__.py +0 -0
  44. bhshell/shell/commands/base.py +60 -0
  45. bhshell/shell/commands/connection.py +236 -0
  46. bhshell/shell/commands/cypher.py +97 -0
  47. bhshell/shell/commands/domains.py +80 -0
  48. bhshell/shell/commands/entities.py +88 -0
  49. bhshell/shell/commands/hunt.py +151 -0
  50. bhshell/shell/commands/library.py +56 -0
  51. bhshell/shell/commands/marking.py +59 -0
  52. bhshell/shell/commands/paths.py +166 -0
  53. bhshell/shell/commands/search.py +125 -0
  54. bhshell-0.1.0.dist-info/METADATA +134 -0
  55. bhshell-0.1.0.dist-info/RECORD +59 -0
  56. bhshell-0.1.0.dist-info/WHEEL +5 -0
  57. bhshell-0.1.0.dist-info/entry_points.txt +2 -0
  58. bhshell-0.1.0.dist-info/licenses/LICENSE +21 -0
  59. bhshell-0.1.0.dist-info/top_level.txt +1 -0
bhshell/render/svg.py ADDED
@@ -0,0 +1,495 @@
1
+ """Attack paths as an SVG diagram, for reports.
2
+
3
+ The paths are laid out by `bhshell.render.layout`, left to right or top to bottom, whichever
4
+ prints larger on a portrait Word page. Only plain shapes with inline attributes are
5
+ used (no CSS, markers or filters) so the file renders the same in browsers and in Word.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import itertools
11
+ from collections import Counter
12
+ from typing import Literal, NamedTuple
13
+ from xml.sax.saxutils import escape, quoteattr
14
+
15
+ from bhshell.models.entities import Node
16
+ from bhshell.queries.edges import SEVERITIES, severity
17
+ from bhshell.queries.paths import AttackPath, PathResult
18
+ from bhshell.render.layout import Layout, Link, split_name
19
+
20
+ FONT = "Segoe UI, Helvetica Neue, Arial, sans-serif"
21
+ INK = "#1f2933"
22
+ MUTED = "#6b7785"
23
+ KIND_COLOR = {
24
+ "User": "#2e9e5b", "Computer": "#d9534f", "Group": "#c79a00", "Domain": "#12a08a",
25
+ "GPO": "#7b68d9", "OU": "#e08a00", "Container": "#c9704f",
26
+ }
27
+ SEVERITY_COLOR = {"critical": "#c62828", "high": "#e65100", "medium": "#a07800"}
28
+ T0_COLOR = "#c62828"
29
+ OWNED_COLOR = "#1565c0"
30
+
31
+ R = 24 # node radius
32
+ NODE_SLOT = 104 # vertical room for a node and its caption
33
+ LANE_SLOT = 40 # vertical room for a long link passing through a column
34
+ MARGIN = 36
35
+ HEADER = 16 # space above the top nodes (their badges have their own room)
36
+ LABEL_FS = 13 # edge label font size
37
+ MIN_PER_ROW = 3 # a wrapped row holds at least this many nodes, so it still reads as a path
38
+ LEGEND_MIN = 420 # the legend wraps at the diagram width, but not narrower than this
39
+ # Word's text area on a portrait Letter/A4 page with normal margins (6.5 x 9 in), in px at 96 dpi.
40
+ PAGE = (624, 864)
41
+
42
+ Orientation = Literal["auto", "horizontal", "vertical", "wrapped"]
43
+
44
+
45
+ class Pill(NamedTuple):
46
+ """An edge label. Labels sharing a `gap` (between two columns or rows) are kept from overlapping."""
47
+
48
+ gap: int # -1 for back links
49
+ x: float
50
+ y: float
51
+ kinds: list[str]
52
+
53
+
54
+ class Drawing(NamedTuple):
55
+ pos: dict[str, tuple[float, float]]
56
+ edges: list[str]
57
+ pills: list[Pill]
58
+ right: float
59
+ bottom: float
60
+
61
+
62
+ def _text_width(text: str, size: float, bold: bool = False) -> float:
63
+ """Rough rendered width; good enough to size columns and label pills."""
64
+ return len(text) * size * (0.64 if bold else 0.55)
65
+
66
+
67
+ def page_fit(width: float, height: float, page: tuple[float, float] = PAGE) -> float:
68
+ """Scale at which a `width` x `height` picture fits the page (1 = native size or smaller)."""
69
+ return min(1.0, page[0] / width, page[1] / height)
70
+
71
+
72
+ def render(result: PathResult, paths: list[AttackPath] | None = None, orientation: Orientation = "auto") -> str:
73
+ """SVG document for `paths` (default: every path of `result`).
74
+
75
+ `auto` tries horizontal, vertical and every wrapped row length, and keeps whichever prints
76
+ largest on a portrait page; near ties go to the simpler one. `wrapped` tries only wrapped rows.
77
+ """
78
+ paths = result.paths if paths is None else paths
79
+ if not paths:
80
+ return _document(240, 80, [_text(MARGIN, 46, "No path found.", 14, MUTED)])
81
+ if orientation in ("horizontal", "vertical"):
82
+ return _document(*_diagram(result, paths, vertical=orientation == "vertical"))
83
+ layers = len(Layout(paths).columns)
84
+ candidates = [] if orientation == "wrapped" else [_diagram(result, paths, vertical=False),
85
+ _diagram(result, paths, vertical=True)]
86
+ candidates += [_diagram(result, paths, vertical=False, per_row=n) for n in range(layers - 1, MIN_PER_ROW - 1, -1)]
87
+ if not candidates: # too short to wrap
88
+ return _document(*_diagram(result, paths, vertical=False))
89
+ best = candidates[0]
90
+ for candidate in candidates[1:]:
91
+ if page_fit(*candidate[:2]) > page_fit(*best[:2]) + 0.03:
92
+ best = candidate
93
+ return _document(*best)
94
+
95
+
96
+ def _diagram(result: PathResult, paths: list[AttackPath], *, vertical: bool,
97
+ per_row: int | None = None) -> tuple[float, float, list[str]]:
98
+ """Width, height and elements of the whole picture: the paths and the legend."""
99
+ layout = Layout(paths)
100
+ labels = {key: split_name(node) for key, node in layout.nodes.items()}
101
+ domains = {tail for _, tail in labels.values()}
102
+ single_domain = domains.pop() if len(domains) == 1 else ""
103
+ if single_domain: # the report around the picture names the domain; keep the captions short
104
+ labels = {key: (head, "") for key, (head, _) in labels.items()}
105
+
106
+ pos, edges, pills, right, bottom = (_vertical(layout, labels) if vertical
107
+ else _horizontal(layout, labels, per_row))
108
+ spread = _spread(pills, horizontal=vertical)
109
+ right = max([right, *(x + _pill_width(kinds) / 2 for x, _, kinds in spread)])
110
+
111
+ legend_width_cap = max(right - MARGIN, LEGEND_MIN) if vertical else float("inf")
112
+ legend, legend_width, legend_height = _legend(MARGIN, bottom + 16, layout, bool(result.waypoints),
113
+ legend_width_cap)
114
+ width = max(right + MARGIN, legend_width + 2 * MARGIN)
115
+ height = bottom + 16 + legend_height + 16
116
+ waypoints = set(result.waypoints)
117
+ nodes = [_node(layout.nodes[key], pos[key], labels[key], layout.nodes[key].objectid in waypoints)
118
+ for key in layout.nodes]
119
+ parts = edges + nodes + [svg for pill in spread for svg in _pill(*pill)] + legend
120
+ return width, height, parts
121
+
122
+
123
+ def _caption_width(label: tuple[str, str]) -> float:
124
+ return max(_text_width(label[0], 13, True), _text_width(label[1], 11))
125
+
126
+
127
+ def _caption_depth(label: tuple[str, str]) -> float:
128
+ """How far a node and its caption reach below the node's centre."""
129
+ return R + (40 if label[1] else 26)
130
+
131
+
132
+ def _color(link: Link) -> str:
133
+ return SEVERITY_COLOR.get(min((severity(k) for k in link.kinds), key=SEVERITIES.index), MUTED)
134
+
135
+
136
+ def _horizontal(layout: Layout, labels: dict[str, tuple[str, str]], per_row: int | None = None) -> Drawing:
137
+ """Columns left to right, by hop distance; `per_row` columns per row, wrapping like lines of text."""
138
+ columns = layout.columns
139
+ per_row = per_row or len(columns)
140
+ rows = [list(range(i, min(i + per_row, len(columns)))) for i in range(0, len(columns), per_row)]
141
+ row_of = {col: r for r, cols in enumerate(rows) for col in cols}
142
+ # Column widths fit the node captions; gaps fit the edge labels leaving that column.
143
+ col_width = [max(2 * R + 16, *(_caption_width(labels[k]) for k in col if k in labels)) for col in columns]
144
+ gaps = [0.0] * len(columns)
145
+ forward = [link for link in layout.links.values() if not link.back]
146
+ for link in forward:
147
+ col = layout.layer[link.source]
148
+ gaps[col] = max(gaps[col], _pill_width(link.kinds) + 56)
149
+ x_center, right = [0.0] * len(columns), float(MARGIN)
150
+ for cols in rows:
151
+ x = MARGIN
152
+ for j, col in enumerate(cols):
153
+ x_center[col] = x + col_width[col] / 2
154
+ x += col_width[col] + (gaps[col] if j < len(cols) - 1 else 0)
155
+ right = max(right, x)
156
+
157
+ # A link into a later row leaves through the channel under its own row; one skipping rows also
158
+ # runs down the left gutter to the channel above its target. Its lane points are not drawn.
159
+ def row(key: str) -> int:
160
+ return row_of[layout.layer[key]]
161
+
162
+ wraps: list[list[tuple[Link, str]]] = [[] for _ in rows]
163
+ gutter: dict[int, float] = {}
164
+ unused: set[str] = set()
165
+ for link in forward:
166
+ rs, rt = row(link.source), row(link.target)
167
+ if rs != rt:
168
+ wraps[rs].append((link, "out"))
169
+ if rt - rs > 1:
170
+ wraps[rt - 1].append((link, "in"))
171
+ gutter[id(link)] = MARGIN / 2 - 6 * len(gutter)
172
+ unused.update(link.lane[1:-1])
173
+
174
+ # Stack each column (nodes need more room than lanes), centre the columns of a row on each other,
175
+ # and leave room under a row for the wrapping links, one level each so their labels never overlap.
176
+ slot = {key: LANE_SLOT if key in layout.virtual else NODE_SLOT for key in layout.layer}
177
+ heights = [sum(slot[k] for k in col if k not in unused) for col in columns]
178
+ back_room = 40 if any(link.back for link in layout.links.values()) else 0
179
+ pos: dict[str, tuple[float, float]] = {}
180
+ run_y: dict[tuple[int, str], float] = {}
181
+ top = float(HEADER)
182
+ for r, cols in enumerate(rows):
183
+ body = max(max(heights[c] for c in cols), NODE_SLOT)
184
+ for col in cols:
185
+ y = top + (body - heights[col]) / 2
186
+ for key in columns[col]:
187
+ if key in unused:
188
+ continue
189
+ pos[key] = (x_center[col], y + (R + 12 if key in labels else slot[key] / 2))
190
+ y += slot[key]
191
+ top += body + back_room
192
+ if wraps[r]:
193
+ top += 10
194
+ for link, end in wraps[r]:
195
+ h = _pill_height(link.kinds)
196
+ run_y[(id(link), end)] = top + h / 2
197
+ top += h + 8
198
+ top += 18
199
+
200
+ edges: list[str] = []
201
+ pills: list[Pill] = []
202
+ for link in layout.links.values():
203
+ color = _color(link)
204
+ (x1, y1), (x2, y2) = pos[link.source], pos[link.target]
205
+ if link.back: # loop back under the nodes
206
+ low = max(y1, y2) + R + 46
207
+ d = f"M{x1:.1f},{y1 + R:.1f} C{x1:.1f},{low:.1f} {x2:.1f},{low:.1f} {x2:.1f},{y2 + R + 9:.1f}"
208
+ pills.append(Pill(-1, (x1 + x2) / 2, low - 8, link.kinds))
209
+ edges += [_stroke(d, color), _arrow(x2, y2 + R, "up", color)]
210
+ continue
211
+ if (id(link), "out") in run_y:
212
+ # Into a later row: down to the channel under this row, across, and down into the target.
213
+ # A link skipping rows goes along the left gutter to the channel above the target's row.
214
+ ry = run_y[(id(link), "out")]
215
+ gx = gutter.get(id(link))
216
+ d = f"M{x1:.1f},{y1 + _caption_depth(labels[link.source]):.1f}" + _corner(x1, ry, x2 if gx is None else gx)
217
+ if gx is not None:
218
+ ry2 = run_y[(id(link), "in")]
219
+ d += f" L{gx:.1f},{ry2 - 12:.1f} Q{gx:.1f},{ry2:.1f} {gx + 12:.1f},{ry2:.1f}"
220
+ d += _corner(gx, ry2, x2, entering=True)
221
+ # Each wrapping link has its own channel, so its label gets a gap of its own.
222
+ pills.append(Pill(10_000 + len(pills), (x1 + (MARGIN if gx is not None else x2)) / 2, ry, link.kinds))
223
+ d += f" L{x2:.1f},{y2 - R - 9:.1f}"
224
+ edges += [_stroke(d, color), _arrow(x2, y2 - R, "down", color)]
225
+ continue
226
+ # Horizontal entry/exit points for each column on the lane, joined by S-curves across the gaps.
227
+ stops: list[tuple[float, float, float]] = [] # (x in, x out, y)
228
+ for key in link.lane:
229
+ kx, ky = pos[key]
230
+ half = col_width[layout.layer[key]] / 2 if key in layout.virtual else R
231
+ stops.append((kx - half, kx + half, ky))
232
+ d = f"M{stops[0][1]:.1f},{stops[0][2]:.1f}"
233
+ for i, ((_, out_x, ya), (in_x, next_out, yb)) in enumerate(itertools.pairwise(stops)):
234
+ end = in_x - 9 if i == len(stops) - 2 else in_x
235
+ dx = (end - out_x) / 2
236
+ d += f" C{out_x + dx:.1f},{ya:.1f} {end - dx:.1f},{yb:.1f} {end:.1f},{yb:.1f}"
237
+ if i < len(stops) - 2:
238
+ d += f" L{next_out:.1f},{yb:.1f}"
239
+ # Label in the middle of the first gap, where the S-curve crosses halfway.
240
+ col = layout.layer[link.source]
241
+ pills.append(Pill(col, x_center[col] + col_width[col] / 2 + gaps[col] / 2,
242
+ (stops[0][2] + stops[1][2]) / 2, link.kinds))
243
+ edges += [_stroke(d, color), _arrow(x2 - R, y2, "right", color)]
244
+ return Drawing(pos, edges, pills, right, top)
245
+
246
+
247
+ def _corner(x: float, y: float, to_x: float, *, entering: bool = False) -> str:
248
+ """Path from heading down at `x` (or, `entering`, heading sideways at `y`) along `y` to `to_x`,
249
+ turning down there. Corners are rounded."""
250
+ turn = -12 if to_x < x else 12
251
+ head = "" if entering else f" L{x:.1f},{y - 12:.1f} Q{x:.1f},{y:.1f} {x + turn:.1f},{y:.1f}"
252
+ return head + f" L{to_x - turn:.1f},{y:.1f} Q{to_x:.1f},{y:.1f} {to_x:.1f},{y + 12:.1f}"
253
+
254
+
255
+ def _vertical(layout: Layout, labels: dict[str, tuple[str, str]]) -> Drawing:
256
+ """Rows top to bottom, by hop distance; captions under the nodes, edges leave below the captions."""
257
+ rows = layout.columns
258
+ # Labels sit next to a node at a fan's wide end, so a node's slot fits its caption and its edge labels.
259
+ label_width: dict[str, float] = {}
260
+ for link in layout.links.values():
261
+ if not link.back:
262
+ for key in (link.source, link.target):
263
+ label_width[key] = max(label_width.get(key, 0.0), _pill_width(link.kinds))
264
+ slot = {key: LANE_SLOT if key in layout.virtual else
265
+ max(2 * R + 16, _caption_width(labels[key]), label_width.get(key, 0.0)) + 20 for key in layout.layer}
266
+ has_nodes = [any(k in labels for k in row) for row in rows]
267
+ above = [R + 12 if real else LANE_SLOT / 2 for real in has_nodes]
268
+ below = [max((_caption_depth(labels[k]) for k in row if k in labels), default=LANE_SLOT / 2) for row in rows]
269
+ gaps = [56.0] * len(rows)
270
+ for link in layout.links.values():
271
+ if not link.back:
272
+ row = layout.layer[link.source]
273
+ gaps[row] = max(gaps[row], _pill_height(link.kinds) + 44)
274
+ y_center, y = [], HEADER
275
+ for i in range(len(rows)):
276
+ y_center.append(y + above[i])
277
+ y += above[i] + below[i] + (gaps[i] if i < len(rows) - 1 else 0)
278
+
279
+ widths = [sum(slot[k] for k in row) for row in rows]
280
+ body = max(*widths, LEGEND_MIN) # centred over the legend
281
+ pos: dict[str, tuple[float, float]] = {}
282
+ for i, row in enumerate(rows):
283
+ x = MARGIN + (body - widths[i]) / 2
284
+ for key in row:
285
+ pos[key] = (x + slot[key] / 2, y_center[i])
286
+ x += slot[key]
287
+
288
+ forward = [link for link in layout.links.values() if not link.back]
289
+ fan_out = Counter(link.source for link in forward)
290
+ fan_in = Counter(link.target for link in forward)
291
+ right = MARGIN + body
292
+ edges: list[str] = []
293
+ pills: list[Pill] = []
294
+ for link in layout.links.values():
295
+ color = _color(link)
296
+ (x1, y1), (x2, y2) = pos[link.source], pos[link.target]
297
+ if link.back: # loop back on the right of the nodes
298
+ side = max(x1, x2) + max(slot[link.source], slot[link.target]) / 2 + 30
299
+ d = f"M{x1 + R:.1f},{y1:.1f} C{side:.1f},{y1:.1f} {side:.1f},{y2:.1f} {x2 + R + 9:.1f},{y2:.1f}"
300
+ pills.append(Pill(-1, side - 8, (y1 + y2) / 2, link.kinds))
301
+ right = max(right, side + _pill_width(link.kinds) / 2)
302
+ edges += [_stroke(d, color), _arrow(x2 + R, y2, "left", color)]
303
+ continue
304
+ stops: list[tuple[float, float, float]] = [] # (y in, y out, x)
305
+ for key in link.lane:
306
+ kx, ky = pos[key]
307
+ row = layout.layer[key]
308
+ if key in layout.virtual:
309
+ stops.append((ky - above[row], ky + below[row], kx))
310
+ else:
311
+ stops.append((ky - R, ky + _caption_depth(labels[key]), kx))
312
+ d = f"M{stops[0][2]:.1f},{stops[0][1]:.1f}"
313
+ for i, ((_, out_y, xa), (in_y, next_out, xb)) in enumerate(itertools.pairwise(stops)):
314
+ end = in_y - 9 if i == len(stops) - 2 else in_y
315
+ dy = (end - out_y) / 2
316
+ d += f" C{xa:.1f},{out_y + dy:.1f} {xb:.1f},{end - dy:.1f} {xb:.1f},{end:.1f}"
317
+ if i < len(stops) - 2:
318
+ d += f" L{xb:.1f},{next_out:.1f}"
319
+ # Label on the first curve, near its end with more neighbours: where a fan's edges are still apart.
320
+ (_, out_y, xa), (in_y, _, xb) = stops[0], stops[1]
321
+ room = (_pill_height(link.kinds) / 2 + 6) / (in_y - out_y)
322
+ if fan_in[link.target] > fan_out[link.source]:
323
+ f = max(0.16, room)
324
+ elif fan_out[link.source] > fan_in[link.target]:
325
+ f = 1 - max(0.16, room)
326
+ else:
327
+ f = 0.5
328
+ # A point of the S-curve: x and y follow the same Bezier weights, so (f, f) stays on it.
329
+ pills.append(Pill(layout.layer[link.source], xa + (xb - xa) * f, out_y + (in_y - out_y) * f, link.kinds))
330
+ edges += [_stroke(d, color), _arrow(x2, y2 - R, "down", color)]
331
+ return Drawing(pos, edges, pills, right, y)
332
+
333
+
334
+ def _stroke(d: str, color: str) -> str:
335
+ return f'<path d="{d}" fill="none" stroke="{color}" stroke-width="2"/>'
336
+
337
+
338
+ def _pill_width(kinds: list[str]) -> float:
339
+ return max(_text_width(k, LABEL_FS, True) for k in kinds) + 14
340
+
341
+
342
+ def _pill_height(kinds: list[str]) -> float:
343
+ return (LABEL_FS + 5) * len(kinds) + 6
344
+
345
+
346
+ def _spread(pills: list[Pill], *, horizontal: bool = False) -> list[tuple[float, float, list[str]]]:
347
+ """Push apart labels that would overlap within the same gap (sideways when the gap is horizontal)."""
348
+ out = []
349
+ for gap in sorted({p.gap for p in pills}):
350
+ if horizontal and gap >= 0:
351
+ row = sorted((p for p in pills if p.gap == gap), key=lambda p: p.x)
352
+ edge = float("-inf")
353
+ for _, x, y, kinds in row:
354
+ half = _pill_width(kinds) / 2
355
+ x = max(x, edge + half + 6)
356
+ edge = x + half
357
+ out.append((x, y, kinds))
358
+ continue
359
+ column = sorted((p for p in pills if p.gap == gap), key=lambda p: p.y)
360
+ bottom = float("-inf")
361
+ for _, x, y, kinds in column:
362
+ half = _pill_height(kinds) / 2
363
+ y = max(y, bottom + half + 4)
364
+ bottom = y + half
365
+ out.append((x, y, kinds))
366
+ return out
367
+
368
+
369
+ def _arrow(x: float, y: float, direction: str, color: str) -> str:
370
+ dx, dy = {"right": (-11, 0), "left": (11, 0), "down": (0, -11), "up": (0, 11)}[direction]
371
+ if dx:
372
+ points = f"{x:.1f},{y:.1f} {x + dx:.1f},{y - 6:.1f} {x + dx:.1f},{y + 6:.1f}"
373
+ else:
374
+ points = f"{x:.1f},{y:.1f} {x - 6:.1f},{y + dy:.1f} {x + 6:.1f},{y + dy:.1f}"
375
+ return f'<polygon points="{points}" fill="{color}"/>'
376
+
377
+
378
+ def _pill(x: float, y: float, kinds: list[str]) -> list[str]:
379
+ line_h = LABEL_FS + 5
380
+ w = _pill_width(kinds)
381
+ h = _pill_height(kinds)
382
+ top = y - h / 2
383
+ rect = (f'<rect x="{x - w / 2:.1f}" y="{top:.1f}" width="{w:.1f}" height="{h:.1f}" rx="{min(h / 2, 11):.1f}" '
384
+ 'fill="#ffffff" stroke="#d5dbe1"/>')
385
+ out = [rect]
386
+ for i, kind in enumerate(kinds):
387
+ color = SEVERITY_COLOR.get(severity(kind), MUTED)
388
+ out.append(_text(x, top + 3 + line_h * (i + 1) - 5, kind, LABEL_FS, color, anchor="middle", bold=True))
389
+ return out
390
+
391
+
392
+ def _node(node: Node, xy: tuple[float, float], label: tuple[str, str], waypoint: bool) -> str:
393
+ x, y = xy
394
+ color = KIND_COLOR.get(node.kind, "#78909c")
395
+ out = []
396
+ if waypoint:
397
+ out.append(f'<circle cx="{x:.1f}" cy="{y:.1f}" r="{R + 8}" fill="none" stroke="{INK}" '
398
+ 'stroke-width="1.5" stroke-dasharray="4 3"/>')
399
+ ring = f' stroke="{T0_COLOR}" stroke-width="4"' if node.tier_zero else ' stroke="#ffffff" stroke-width="2"'
400
+ out.append(f'<circle cx="{x:.1f}" cy="{y:.1f}" r="{R}" fill="{color}"{ring}/>')
401
+ out.append(_icon(node.kind, x, y))
402
+ out.append(_text(x, y + R + 20, label[0], 13, INK, anchor="middle", bold=True))
403
+ if label[1]:
404
+ out.append(_text(x, y + R + 35, label[1], 11, MUTED, anchor="middle"))
405
+ badges = [("T0", T0_COLOR)] if node.tier_zero else []
406
+ if node.owned:
407
+ badges.append(("OWNED", OWNED_COLOR))
408
+ if node.properties.get("enabled") is False:
409
+ badges.append(("DISABLED", MUTED))
410
+ bx = x + R * 0.55
411
+ for text, badge_color in badges:
412
+ w = _text_width(text, 10, True) + 10
413
+ out.append(f'<rect x="{bx:.1f}" y="{y - R - 8:.1f}" width="{w:.1f}" height="16" rx="8" fill="{badge_color}"/>')
414
+ out.append(_text(bx + w / 2, y - R + 4, text, 10, "#ffffff", anchor="middle", bold=True))
415
+ bx += w + 3
416
+ if waypoint:
417
+ out.append(_text(x - R - 4, y - R - 2, "via", 10, INK, anchor="end", bold=True))
418
+ return "\n".join(out)
419
+
420
+
421
+ def _icon(kind: str, x: float, y: float) -> str:
422
+ """A small white glyph for the object kind, drawn with basic shapes."""
423
+ w = 'fill="#ffffff"'
424
+ if kind == "User":
425
+ return (f'<circle cx="{x:.1f}" cy="{y - 5:.1f}" r="6" {w}/>'
426
+ f'<path d="M{x - 11:.1f},{y + 12:.1f} Q{x:.1f},{y - 4:.1f} {x + 11:.1f},{y + 12:.1f} Z" {w}/>')
427
+ if kind == "Group":
428
+ out = []
429
+ for dx, r in ((-6, 5), (7, 5)):
430
+ out.append(f'<circle cx="{x + dx:.1f}" cy="{y - 5:.1f}" r="{r}" {w}/>')
431
+ out.append(f'<path d="M{x + dx - 9:.1f},{y + 11:.1f} Q{x + dx:.1f},{y - 3:.1f} {x + dx + 9:.1f},'
432
+ f'{y + 11:.1f} Z" {w}/>')
433
+ return "".join(out)
434
+ if kind == "Computer":
435
+ return (f'<rect x="{x - 12:.1f}" y="{y - 10:.1f}" width="24" height="15" rx="2" {w}/>'
436
+ f'<rect x="{x - 3:.1f}" y="{y + 5:.1f}" width="6" height="4" {w}/>'
437
+ f'<rect x="{x - 8:.1f}" y="{y + 9:.1f}" width="16" height="3" rx="1" {w}/>')
438
+ if kind == "Domain":
439
+ stroke = 'fill="none" stroke="#ffffff" stroke-width="2"'
440
+ return (f'<circle cx="{x:.1f}" cy="{y:.1f}" r="12" {stroke}/>'
441
+ f'<ellipse cx="{x:.1f}" cy="{y:.1f}" rx="5" ry="12" {stroke}/>'
442
+ f'<line x1="{x - 12:.1f}" y1="{y:.1f}" x2="{x + 12:.1f}" y2="{y:.1f}" stroke="#ffffff" '
443
+ 'stroke-width="2"/>')
444
+ if kind == "OU" or kind == "Container":
445
+ return (f'<path d="M{x - 12:.1f},{y - 8:.1f} h8 l3,3 h13 v15 h-24 Z" {w}/>')
446
+ if kind == "GPO":
447
+ return (f'<path d="M{x - 9:.1f},{y - 12:.1f} h12 l6,6 v18 h-18 Z" {w}/>'
448
+ f'<line x1="{x - 5:.1f}" y1="{y + 1:.1f}" x2="{x + 5:.1f}" y2="{y + 1:.1f}" '
449
+ f'stroke="{KIND_COLOR["GPO"]}" stroke-width="2"/>')
450
+ return _text(x, y + 5, kind[:2].upper(), 13, "#ffffff", anchor="middle", bold=True)
451
+
452
+
453
+ def _legend(x: float, y: float, layout: Layout, waypoints: bool,
454
+ max_width: float = float("inf")) -> tuple[list[str], float, float]:
455
+ """Legend entries for what the diagram shows, wrapped at `max_width`; returns (elements, width, height)."""
456
+ items: list[tuple[str, str]] = [] # (marker drawn in a 18px box at `mx`, caption)
457
+ for kind, color in KIND_COLOR.items():
458
+ if any(n.kind == kind for n in layout.nodes.values()):
459
+ items.append((f'<circle cx="{{mx}}" cy="{{cy}}" r="6" fill="{color}"/>', kind))
460
+ present = {severity(k) for link in layout.links.values() for k in link.kinds}
461
+ for level, color in SEVERITY_COLOR.items():
462
+ if level in present:
463
+ line = (f'<line x1="{{mx_l}}" y1="{{cy}}" x2="{{mx_r}}" y2="{{cy}}" stroke="{color}" '
464
+ 'stroke-width="2.5"/>')
465
+ items.append((line, f"{level} edge"))
466
+ if any(n.tier_zero for n in layout.nodes.values()):
467
+ ring = f'<circle cx="{{mx}}" cy="{{cy}}" r="6" fill="#ffffff" stroke="{T0_COLOR}" stroke-width="2.5"/>'
468
+ items.append((ring, "Tier Zero"))
469
+ if waypoints:
470
+ dashed = f'<circle cx="{{mx}}" cy="{{cy}}" r="6" fill="none" stroke="{INK}" stroke-dasharray="3 2"/>'
471
+ items.append((dashed, "waypoint (--via)"))
472
+ out, start, widest, lines = [], x, 0.0, 1
473
+ for marker, caption in items:
474
+ step = 24 + _text_width(caption, 11) + 20
475
+ if x > start and x + step - 20 - start > max_width:
476
+ x, y, lines = start, y + 20, lines + 1
477
+ out.append(marker.format(mx=f"{x + 9:.1f}", mx_l=f"{x:.1f}", mx_r=f"{x + 18:.1f}", cy=f"{y - 4:.1f}"))
478
+ out.append(_text(x + 24, y, caption, 11, MUTED))
479
+ x += step
480
+ widest = max(widest, x - 20 - start)
481
+ return out, widest, 20.0 * lines
482
+
483
+
484
+ def _text(x: float, y: float, text: str, size: float, color: str, *, anchor: str = "start",
485
+ bold: bool = False) -> str:
486
+ weight = ' font-weight="bold"' if bold else ""
487
+ return (f'<text x="{x:.1f}" y="{y:.1f}" font-family={quoteattr(FONT)} font-size="{size}" fill="{color}" '
488
+ f'text-anchor="{anchor}"{weight}>{escape(text)}</text>')
489
+
490
+
491
+ def _document(width: float, height: float, parts: list[str]) -> str:
492
+ return (f'<?xml version="1.0" encoding="UTF-8"?>\n'
493
+ f'<svg xmlns="http://www.w3.org/2000/svg" width="{width:.0f}" height="{height:.0f}" '
494
+ f'viewBox="0 0 {width:.0f} {height:.0f}">\n'
495
+ f'<rect width="100%" height="100%" fill="#ffffff"/>\n' + "\n".join(parts) + "\n</svg>\n")