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.
- bhshell/__init__.py +3 -0
- bhshell/__main__.py +3 -0
- bhshell/cli.py +55 -0
- bhshell/client/__init__.py +0 -0
- bhshell/client/auth.py +74 -0
- bhshell/client/client.py +337 -0
- bhshell/config.py +162 -0
- bhshell/errors.py +73 -0
- bhshell/library/asrep-roastable-users.cyp +3 -0
- bhshell/library/dcsync-principals.cyp +3 -0
- bhshell/library/domain-admins-members.cyp +3 -0
- bhshell/library/domain-trusts.cyp +3 -0
- bhshell/library/gpos-linked-to-domain-controllers.cyp +3 -0
- bhshell/library/kerberoastable-users.cyp +3 -0
- bhshell/library/owned-objects.cyp +3 -0
- bhshell/library/owned-to-tier-zero.cyp +3 -0
- bhshell/library/sid-history.cyp +3 -0
- bhshell/library/tier-zero-users.cyp +3 -0
- bhshell/library/unconstrained-delegation.cyp +3 -0
- bhshell/models/__init__.py +0 -0
- bhshell/models/entities.py +402 -0
- bhshell/models/results.py +83 -0
- bhshell/queries/__init__.py +0 -0
- bhshell/queries/cypher.py +155 -0
- bhshell/queries/domains.py +136 -0
- bhshell/queries/edges.py +109 -0
- bhshell/queries/hunt.py +675 -0
- bhshell/queries/library.py +94 -0
- bhshell/queries/marking.py +121 -0
- bhshell/queries/paths.py +369 -0
- bhshell/queries/search.py +196 -0
- bhshell/queries/service.py +529 -0
- bhshell/render/__init__.py +0 -0
- bhshell/render/export.py +79 -0
- bhshell/render/graph.py +326 -0
- bhshell/render/layout.py +138 -0
- bhshell/render/png.py +44 -0
- bhshell/render/styles.py +5 -0
- bhshell/render/svg.py +495 -0
- bhshell/render/views.py +337 -0
- bhshell/shell/__init__.py +0 -0
- bhshell/shell/app.py +189 -0
- bhshell/shell/commands/__init__.py +0 -0
- bhshell/shell/commands/base.py +60 -0
- bhshell/shell/commands/connection.py +236 -0
- bhshell/shell/commands/cypher.py +97 -0
- bhshell/shell/commands/domains.py +80 -0
- bhshell/shell/commands/entities.py +88 -0
- bhshell/shell/commands/hunt.py +151 -0
- bhshell/shell/commands/library.py +56 -0
- bhshell/shell/commands/marking.py +59 -0
- bhshell/shell/commands/paths.py +166 -0
- bhshell/shell/commands/search.py +125 -0
- bhshell-0.1.0.dist-info/METADATA +134 -0
- bhshell-0.1.0.dist-info/RECORD +59 -0
- bhshell-0.1.0.dist-info/WHEEL +5 -0
- bhshell-0.1.0.dist-info/entry_points.txt +2 -0
- bhshell-0.1.0.dist-info/licenses/LICENSE +21 -0
- 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")
|