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
@@ -0,0 +1,326 @@
1
+ """Attack paths drawn as a graph with box-drawing characters, top to bottom.
2
+
3
+ Every layer of `Layout` becomes a row of boxes. Below each
4
+ row, every link gets its own horizontal track: it leaves its source from the bottom
5
+ centre, runs along the track and drops into the top centre of its target. Line pieces are
6
+ recorded as directions per cell and turned into the right junction characters at the end,
7
+ so shared stubs merge into trees (fan-out) and arrows into the same box join (fan-in).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import itertools
13
+ from collections import defaultdict
14
+ from dataclasses import dataclass
15
+
16
+ from rich.text import Text
17
+
18
+ from bhshell.models.entities import Node
19
+ from bhshell.queries.edges import SEVERITIES, severity, severity_rank
20
+ from bhshell.queries.paths import AttackPath, PathResult
21
+ from bhshell.render.layout import Layout, Link, split_name
22
+ from bhshell.render.styles import KIND_STYLE, SEVERITY_STYLE
23
+
24
+ MIN_GAP = 4 # columns between boxes
25
+
26
+ UP, DOWN, LEFT, RIGHT = 1, 2, 4, 8
27
+ JUNCTION = {
28
+ UP: "│", DOWN: "│", UP | DOWN: "│", LEFT: "─", RIGHT: "─", LEFT | RIGHT: "─",
29
+ DOWN | RIGHT: "╭", DOWN | LEFT: "╮", UP | RIGHT: "╰", UP | LEFT: "╯",
30
+ UP | DOWN | RIGHT: "├", UP | DOWN | LEFT: "┤", LEFT | RIGHT | DOWN: "┬", LEFT | RIGHT | UP: "┴",
31
+ UP | DOWN | LEFT | RIGHT: "┼",
32
+ }
33
+ ROUND = "╭╮╰╯─│"
34
+ DOUBLE = "╔╗╚╝═║"
35
+
36
+
37
+ class _Canvas:
38
+ def __init__(self, width: int, height: int) -> None:
39
+ self.width, self.height = width, height
40
+ self.chars = [[" "] * width for _ in range(height)]
41
+ self.styles = [[""] * width for _ in range(height)]
42
+ self.dirs = [[0] * width for _ in range(height)]
43
+
44
+ def put(self, x: int, y: int, text: str, style: str = "") -> None:
45
+ for i, char in enumerate(text):
46
+ self.chars[y][x + i] = char
47
+ self.styles[y][x + i] = style
48
+ self.dirs[y][x + i] = 0
49
+
50
+ def vline(self, x: int, y0: int, y1: int, style: str) -> None:
51
+ for y in range(y0, y1 + 1):
52
+ self.dirs[y][x] |= (UP if y > y0 else 0) | (DOWN if y < y1 else 0)
53
+ self.styles[y][x] = style
54
+
55
+ def hline(self, y: int, x0: int, x1: int, style: str) -> None:
56
+ a, b = sorted((x0, x1))
57
+ for x in range(a, b + 1):
58
+ self.dirs[y][x] |= (LEFT if x > a else 0) | (RIGHT if x < b else 0)
59
+ self.styles[y][x] = style
60
+
61
+ def free(self, x: int, y: int, length: int) -> bool:
62
+ """True if `length` cells from `(x, y)` are inside the canvas and hold nothing."""
63
+ if x < 0 or x + length > self.width:
64
+ return False
65
+ return all(self.chars[y][i] == " " and not self.dirs[y][i] for i in range(x, x + length))
66
+
67
+ def crossable(self, x: int, y: int, length: int) -> bool:
68
+ """True if the cells only hold horizontal line pieces (a label may be written over them)."""
69
+ if x < 0 or x + length > self.width:
70
+ return False
71
+ return all(self.chars[y][i] == " " and not self.dirs[y][i] & (UP | DOWN) for i in range(x, x + length))
72
+
73
+ def lines(self) -> list[Text]:
74
+ out = []
75
+ for chars, styles, dirs in zip(self.chars, self.styles, self.dirs, strict=True):
76
+ text = Text()
77
+ for char, style, d in zip(chars, styles, dirs, strict=True):
78
+ text.append(JUNCTION.get(d, char) if d else char, style=style)
79
+ text.rstrip()
80
+ out.append(text)
81
+ return out
82
+
83
+
84
+ @dataclass
85
+ class _Segment:
86
+ """One link (or one stretch of a long link) between two adjacent layers."""
87
+
88
+ link: Link
89
+ x_from: int
90
+ x_to: int
91
+ into_box: bool # ends on a real node (arrow) rather than a lane point
92
+ label: str # '' on the continuation stretches of a long link
93
+ style: str
94
+ track: int = 0
95
+
96
+
97
+ def draw(result: PathResult, paths: list[AttackPath], max_width: int) -> tuple[list[Text], list[str]]:
98
+ """Graph lines for `paths` and extra notes (links that loop back, labels moved to footnotes).
99
+
100
+ Wider spacing leaves room to write labels next to their lines, so gaps from generous down to
101
+ the minimum are tried and the drawing with the fewest footnotes that fits `max_width` wins
102
+ (the narrowest one if none fits).
103
+ """
104
+ layout = Layout(paths)
105
+ widest_label = max(len(", ".join(link.kinds)) for link in layout.links.values())
106
+ best: tuple[int, int, list[Text], list[str]] | None = None
107
+ for gap in range(widest_label + 4, MIN_GAP - 1, -2):
108
+ lines, notes = _draw(result, layout, max(gap, MIN_GAP))
109
+ width = max(line.cell_len for line in lines)
110
+ footnotes = sum(note.startswith("[") for note in notes)
111
+ if width <= max_width and (best is None or footnotes < best[0]):
112
+ best = (footnotes, width, lines, notes)
113
+ if best is not None and best[0] == 0:
114
+ break
115
+ if best is None:
116
+ return _draw(result, layout, MIN_GAP)
117
+ return best[2], best[3]
118
+
119
+
120
+ def _draw(result: PathResult, layout: Layout, gap: int) -> tuple[list[Text], list[str]]:
121
+ waypoints = set(result.waypoints)
122
+ domains = {tail for _, tail in (split_name(n) for n in layout.nodes.values())}
123
+ short = len(domains) == 1 # one domain: leave it out of every box
124
+
125
+ boxes = {key: _box_text(node, short, node.objectid in waypoints) for key, node in layout.nodes.items()}
126
+ width_of = {key: (boxes[key].cell_len + 4 if key in boxes else 1) for key in layout.layer}
127
+
128
+ left = _place(layout, width_of, gap)
129
+ port = {key: left[key] + width_of[key] // 2 for key in layout.layer}
130
+ total_width = max(left[key] + width_of[key] for key in left)
131
+
132
+ # Segments per band (band i lies between layer i and layer i + 1), each on its own track.
133
+ bands: list[list[_Segment]] = [[] for _ in layout.columns]
134
+ for link in sorted(layout.links.values(), key=lambda l: -_order(l.kinds)):
135
+ if link.back:
136
+ continue
137
+ for i, (a, b) in enumerate(itertools.pairwise(link.lane)):
138
+ bands[layout.layer[a]].append(_Segment(
139
+ link, port[a], port[b], b in layout.nodes, ", ".join(link.kinds) if i == 0 else "",
140
+ SEVERITY_STYLE.get(_worst(link.kinds), "")))
141
+ for band in bands:
142
+ # Tracks: links heading left take the upper tracks, links heading right the lower ones,
143
+ # each group ordered so their horizontals nest instead of crossing.
144
+ band.sort(key=lambda s: (s.x_to >= s.x_from, -s.x_from if s.x_to < s.x_from else s.x_from,
145
+ s.x_to if s.x_to < s.x_from else -s.x_to))
146
+ for track, segment in enumerate(band):
147
+ segment.track = track
148
+
149
+ # Vertical placement: 3 rows per layer, then one row per track and one for the arrows.
150
+ top: list[int] = []
151
+ y = 0
152
+ for band in bands:
153
+ top.append(y)
154
+ y += 3 + (len(band) + 1 if band else 0)
155
+ canvas = _Canvas(total_width + 40, y) # spare room on the right for labels
156
+
157
+ for key, node in layout.nodes.items():
158
+ _draw_box(canvas, left[key], top[layout.layer[key]], boxes[key], node, node.objectid in waypoints,
159
+ has_out=any(l.source == key and not l.back for l in layout.links.values()))
160
+ for link in layout.links.values():
161
+ for key in link.lane[1:-1]:
162
+ canvas.vline(port[key], top[layout.layer[key]], top[layout.layer[key]] + 2,
163
+ SEVERITY_STYLE.get(_worst(link.kinds), ""))
164
+ for i, band in enumerate(bands):
165
+ for s in band:
166
+ track_y = top[i] + 3 + s.track
167
+ arrow_y = top[i + 1] - 1
168
+ canvas.vline(s.x_from, top[i] + 3, track_y, s.style)
169
+ canvas.dirs[top[i] + 3][s.x_from] |= UP # joins the ┬ of the source box (or a lane line)
170
+ canvas.hline(track_y, s.x_from, s.x_to, s.style)
171
+ canvas.vline(s.x_to, track_y, arrow_y, s.style)
172
+ if s.into_box:
173
+ canvas.put(s.x_to, arrow_y, "▼", s.style)
174
+ else:
175
+ canvas.dirs[arrow_y][s.x_to] |= DOWN
176
+
177
+ notes = _place_labels(canvas, bands, top)
178
+ for link in layout.links.values():
179
+ if link.back:
180
+ a, b = layout.nodes[link.source], layout.nodes[link.target]
181
+ notes.append(f"↺ {a.name} ─{', '.join(link.kinds)}→ {b.name} (back to an earlier step)")
182
+ lines = canvas.lines()
183
+ width = max(line.cell_len for line in lines)
184
+ return [line[:width] for line in lines], notes
185
+
186
+
187
+ def _place(layout: Layout, width_of: dict[str, int], gap: int) -> dict[str, int]:
188
+ """Left column of every box: each one under the mean port of its parents (and parents over their
189
+ children), keeping the layer order and at least `gap` columns between boxes."""
190
+ parents: dict[str, list[str]] = defaultdict(list)
191
+ children: dict[str, list[str]] = defaultdict(list)
192
+ for link in layout.links.values():
193
+ if not link.back:
194
+ for a, b in itertools.pairwise(link.lane):
195
+ children[a].append(b)
196
+ parents[b].append(a)
197
+ left: dict[str, int] = {}
198
+ for col in layout.columns:
199
+ x = 0
200
+ for key in col:
201
+ left[key] = x
202
+ x += width_of[key] + gap
203
+
204
+ def align(col: list[str], neighbours: dict[str, list[str]]) -> None:
205
+ wanted = {}
206
+ for key in col:
207
+ ports = [left[n] + width_of[n] // 2 for n in neighbours[key]]
208
+ wanted[key] = round(sum(ports) / len(ports)) - width_of[key] // 2 if ports else left[key]
209
+ # Boxes that would overlap form a block, centred on where its members want to be.
210
+ blocks: list[tuple[list[str], list[int], int]] = [] # (keys, offsets in the block, start)
211
+ for key in col:
212
+ blocks.append(([key], [0], wanted[key]))
213
+ while len(blocks) > 1:
214
+ (keys_a, offs_a, start_a), (keys_b, offs_b, start_b) = blocks[-2:]
215
+ base = offs_a[-1] + width_of[keys_a[-1]] + gap
216
+ if start_a + base <= start_b:
217
+ break
218
+ keys, offsets = keys_a + keys_b, offs_a + [base + o for o in offs_b]
219
+ start = round(sum(wanted[k] - o for k, o in zip(keys, offsets, strict=True)) / len(keys))
220
+ blocks[-2:] = [(keys, offsets, start)]
221
+ for keys, offsets, start in blocks:
222
+ for key, offset in zip(keys, offsets, strict=True):
223
+ left[key] = start + offset
224
+
225
+ for _ in range(2):
226
+ for col in layout.columns[1:]:
227
+ align(col, parents)
228
+ for col in reversed(layout.columns[:-1]):
229
+ align(col, children)
230
+ for col in layout.columns[1:]:
231
+ align(col, parents)
232
+ shift = min(left.values())
233
+ return {key: x - shift for key, x in left.items()}
234
+
235
+
236
+ def _place_labels(canvas: _Canvas, bands: list[list[_Segment]], top: list[int]) -> list[str]:
237
+ """Write each link's right names next to its line; whatever does not fit becomes a footnote."""
238
+ notes: list[str] = []
239
+ for i, band in enumerate(bands):
240
+ for s in sorted((s for s in band if s.label), key=lambda s: _order(s.link.kinds)):
241
+ track_y = top[i] + 3 + s.track
242
+ text = f" {s.label} "
243
+ a, b = sorted((s.x_from, s.x_to))
244
+ candidates = []
245
+ if b - a - 1 >= len(text): # on the horizontal stretch, centred
246
+ candidates.append(((a + b + 1 - len(text)) // 2, track_y, canvas.crossable))
247
+ # Otherwise beside the drop into the target, then beside the stub leaving the source.
248
+ for y in range(track_y, top[i + 1] - 1):
249
+ candidates += [(s.x_to + 1, y, canvas.free), (s.x_to - len(text), y, canvas.free)]
250
+ for y in range(track_y, top[i] + 2, -1):
251
+ candidates += [(s.x_from + 1, y, canvas.free), (s.x_from - len(text), y, canvas.free)]
252
+ for x, y, fits in candidates:
253
+ if fits(x, y, len(text)):
254
+ canvas.put(x, y, text, f"bold {s.style}".strip())
255
+ break
256
+ else:
257
+ marker = f"[{len(notes) + 1}]"
258
+ for x in (s.x_to + 1, s.x_to - len(marker)):
259
+ if canvas.free(x, track_y, len(marker)):
260
+ canvas.put(x, track_y, marker, f"bold {s.style}".strip())
261
+ break
262
+ notes.append(f"{marker} {s.label}")
263
+ return notes
264
+
265
+
266
+ def _box_text(node: Node, short: bool, waypoint: bool) -> Text:
267
+ head, tail = split_name(node)
268
+ name = head if short or not tail else node.name
269
+ text = Text.assemble(("● ", KIND_STYLE.get(node.kind, "white")), (name, "bold"))
270
+ if node.tier_zero:
271
+ text.append(" ").append("T0", style="bold white on red")
272
+ if node.owned:
273
+ text.append(" ").append("OWNED", style="bold white on blue")
274
+ if waypoint:
275
+ text.append(" ").append("via", style="bold")
276
+ return text
277
+
278
+
279
+ def _draw_box(canvas: _Canvas, x: int, y: int, text: Text, node: Node, waypoint: bool, has_out: bool) -> None:
280
+ tl, tr, bl, br, h, v = DOUBLE if waypoint else ROUND
281
+ border = "bold red" if node.tier_zero else "bold blue" if node.owned else "bright_black"
282
+ inner = text.cell_len + 2
283
+ canvas.put(x, y, tl + h * inner + tr, border)
284
+ canvas.put(x, y + 1, v, border)
285
+ for offset, span in enumerate(_cells(text)):
286
+ canvas.put(x + 2 + offset, y + 1, span[0], span[1])
287
+ canvas.put(x + 2 + text.cell_len, y + 1, " " + v, border)
288
+ canvas.put(x + 1, y + 1, " ")
289
+ bottom = bl + h * inner + br
290
+ if has_out:
291
+ mid = (inner + 2) // 2
292
+ bottom = bottom[:mid] + "┬" + bottom[mid + 1:]
293
+ canvas.put(x, y + 2, bottom, border)
294
+
295
+
296
+ def _cells(text: Text) -> list[tuple[str, str]]:
297
+ """`(char, style)` for every character of `text`."""
298
+ styles = [""] * len(text.plain)
299
+ for span in text.spans:
300
+ for i in range(span.start, span.end):
301
+ styles[i] = str(span.style)
302
+ return list(zip(text.plain, styles, strict=True))
303
+
304
+
305
+ def _order(kinds: list[str]) -> int:
306
+ return min(map(severity_rank, kinds))
307
+
308
+
309
+ def _worst(kinds: list[str]) -> str:
310
+ return SEVERITIES[_order(kinds)]
311
+
312
+
313
+ def legend(paths: list[AttackPath], waypoints: bool) -> Text:
314
+ layout_kinds = {n.kind for p in paths for h in p.hops for n in (h.source, h.target)}
315
+ text = Text()
316
+ for kind, style in KIND_STYLE.items():
317
+ if kind in layout_kinds:
318
+ text.append("● ", style=style).append(f"{kind} ", style="dim")
319
+ present = {severity(h.edge) for p in paths for h in p.hops}
320
+ for level, style in SEVERITY_STYLE.items():
321
+ if level in present:
322
+ text.append("── ", style=style).append(f"{level} ", style="dim")
323
+ if waypoints:
324
+ text.append("╔═╗", style="bright_black").append(" waypoint (--via)", style="dim")
325
+ text.rstrip()
326
+ return text
@@ -0,0 +1,138 @@
1
+ """Layered layout shared by the path graph renderers.
2
+
3
+ Paths are merged into one graph where every object appears once. Objects are placed in
4
+ layers by hop distance (longest path from a start), a link skipping layers is routed
5
+ through a virtual point in each layer it crosses, and each layer is ordered with
6
+ barycenter sweeps to limit crossings. Links that close a cycle (a route revisiting an
7
+ object) are flagged `back` and left out of the layering.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import itertools
13
+ from dataclasses import dataclass, field
14
+
15
+ from bhshell.models.entities import Node
16
+ from bhshell.queries.paths import AttackPath
17
+
18
+
19
+ def split_name(node: Node) -> tuple[str, str]:
20
+ """`JDOE@CORP.LOCAL` -> (`JDOE`, `CORP.LOCAL`); `WS01.CORP.LOCAL` -> (`WS01`, `CORP.LOCAL`)."""
21
+ name = node.name
22
+ if "@" in name:
23
+ head, _, tail = name.partition("@")
24
+ return head, tail
25
+ if node.kind == "Computer" and "." in name:
26
+ head, _, tail = name.partition(".")
27
+ return head, tail
28
+ return name, ""
29
+
30
+
31
+ @dataclass
32
+ class Link:
33
+ source: str
34
+ target: str
35
+ kinds: list[str] = field(default_factory=list)
36
+ back: bool = False
37
+ # Keys from source to target, with a virtual point in every column the link crosses.
38
+ lane: list[str] = field(default_factory=list)
39
+
40
+
41
+ class Layout:
42
+ """Layered (Sugiyama-style) layout: columns by hop distance, long links routed through virtual points."""
43
+
44
+ def __init__(self, paths: list[AttackPath]) -> None:
45
+ self.nodes: dict[str, Node] = {}
46
+ self.links: dict[tuple[str, str], Link] = {}
47
+ for path in paths:
48
+ for hop in path.hops:
49
+ a, b = self._add(hop.source), self._add(hop.target)
50
+ link = self.links.setdefault((a, b), Link(a, b))
51
+ if hop.edge not in link.kinds:
52
+ link.kinds.append(hop.edge)
53
+ self.starts = list(dict.fromkeys(self._key(p.start) for p in paths))
54
+ self._mark_back_links()
55
+ self.layer = self._layers()
56
+ self.virtual: set[str] = set()
57
+ self._add_lanes()
58
+ self.columns = self._order()
59
+
60
+ @staticmethod
61
+ def _key(node: Node) -> str:
62
+ return node.objectid or node.name
63
+
64
+ def _add(self, node: Node) -> str:
65
+ key = self._key(node)
66
+ self.nodes.setdefault(key, node)
67
+ return key
68
+
69
+ def _successors(self, key: str) -> list[Link]:
70
+ return [link for link in self.links.values() if link.source == key and not link.back]
71
+
72
+ def _mark_back_links(self) -> None:
73
+ """Flag links closing a cycle (possible when a route revisits an object) so layering stays acyclic."""
74
+ state: dict[str, int] = {} # 1 = on the DFS stack, 2 = done
75
+ for start in [*self.starts, *self.nodes]:
76
+ if start in state:
77
+ continue
78
+ stack = [(start, iter(self._successors(start)))]
79
+ state[start] = 1
80
+ while stack:
81
+ node, links = stack[-1]
82
+ link = next(links, None)
83
+ if link is None:
84
+ state[node] = 2
85
+ stack.pop()
86
+ elif state.get(link.target) == 1:
87
+ link.back = True
88
+ elif link.target not in state:
89
+ state[link.target] = 1
90
+ stack.append((link.target, iter(self._successors(link.target))))
91
+
92
+ def _layers(self) -> dict[str, int]:
93
+ """Longest-path layering: an object sits one column right of its furthest predecessor."""
94
+ layer = dict.fromkeys(self.nodes, 0)
95
+ for _ in range(len(self.nodes)):
96
+ changed = False
97
+ for link in self.links.values():
98
+ if not link.back and layer[link.target] < layer[link.source] + 1:
99
+ layer[link.target] = layer[link.source] + 1
100
+ changed = True
101
+ if not changed:
102
+ break
103
+ return layer
104
+
105
+ def _add_lanes(self) -> None:
106
+ for i, link in enumerate(self.links.values()):
107
+ if link.back:
108
+ link.lane = [link.source, link.target]
109
+ continue
110
+ points = [f"~{i}.{col}" for col in range(self.layer[link.source] + 1, self.layer[link.target])]
111
+ for point, col in zip(points, range(self.layer[link.source] + 1, self.layer[link.target]), strict=True):
112
+ self.layer[point] = col
113
+ self.virtual.add(point)
114
+ link.lane = [link.source, *points, link.target]
115
+
116
+ def _order(self) -> list[list[str]]:
117
+ """Columns of keys, ordered with a few barycenter sweeps to limit crossings."""
118
+ columns: list[list[str]] = [[] for _ in range(max(self.layer.values(), default=0) + 1)]
119
+ for key in self.layer: # insertion order follows the (sorted) paths
120
+ columns[self.layer[key]].append(key)
121
+ steps = [pair for link in self.links.values() if not link.back for pair in itertools.pairwise(link.lane)]
122
+ for _ in range(6):
123
+ for i in range(1, len(columns)):
124
+ self._sort_by_neighbours(columns[i], columns[i - 1], [(b, a) for a, b in steps])
125
+ for i in range(len(columns) - 2, -1, -1):
126
+ self._sort_by_neighbours(columns[i], columns[i + 1], steps)
127
+ return columns
128
+
129
+ @staticmethod
130
+ def _sort_by_neighbours(column: list[str], fixed: list[str], pairs: list[tuple[str, str]]) -> None:
131
+ position = {key: i for i, key in enumerate(fixed)}
132
+ current = {key: i for i, key in enumerate(column)}
133
+
134
+ def barycenter(key: str) -> float:
135
+ seen = [position[other] for node, other in pairs if node == key and other in position]
136
+ return sum(seen) / len(seen) if seen else current[key]
137
+
138
+ column.sort(key=barycenter)
bhshell/render/png.py ADDED
@@ -0,0 +1,44 @@
1
+ """PNG export of the SVG diagrams, rasterised with resvg.
2
+
3
+ The image is rendered at `scale` times the SVG size and tagged with `96 * scale` dpi, so Word
4
+ inserts it at the same physical size as the SVG while keeping it sharp when printed.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import struct
10
+ import zlib
11
+
12
+ import resvg_py
13
+
14
+ _SIGNATURE = b"\x89PNG\r\n\x1a\n"
15
+ _INCH_PER_METRE = 39.3701
16
+
17
+
18
+ def from_svg(svg: str, scale: float = 2.0) -> bytes:
19
+ """PNG bytes for an SVG document, on a white background."""
20
+ png = bytes(resvg_py.svg_to_bytes(svg_string=svg, zoom=scale, background="#ffffff"))
21
+ return with_dpi(png, 96 * scale)
22
+
23
+
24
+ def _chunk(kind: bytes, data: bytes) -> bytes:
25
+ return struct.pack(">I", len(data)) + kind + data + struct.pack(">I", zlib.crc32(kind + data))
26
+
27
+
28
+ def with_dpi(png: bytes, dpi: float) -> bytes:
29
+ """`png` with its physical resolution (`pHYs` chunk) set to `dpi`, replacing any existing one."""
30
+ if not png.startswith(_SIGNATURE):
31
+ raise ValueError("not a PNG image")
32
+ ppm = round(dpi * _INCH_PER_METRE)
33
+ phys = _chunk(b"pHYs", struct.pack(">IIB", ppm, ppm, 1))
34
+ out, pos = [_SIGNATURE], len(_SIGNATURE)
35
+ while pos < len(png):
36
+ (length,) = struct.unpack(">I", png[pos:pos + 4])
37
+ kind = png[pos + 4:pos + 8]
38
+ end = pos + 12 + length
39
+ if kind != b"pHYs":
40
+ out.append(png[pos:end])
41
+ if kind == b"IHDR": # pHYs must come before the image data; right after the header is safe
42
+ out.append(phys)
43
+ pos = end
44
+ return b"".join(out)
@@ -0,0 +1,5 @@
1
+ """Terminal colours shared by the table and graph renderers."""
2
+
3
+ KIND_STYLE = {"User": "green", "Computer": "blue", "Group": "magenta", "Domain": "bold yellow",
4
+ "GPO": "bright_magenta", "OU": "bright_cyan", "Container": "bright_red"}
5
+ SEVERITY_STYLE = {"critical": "bold red", "high": "dark_orange", "medium": "yellow", "low": "cyan"}