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/graph.py
ADDED
|
@@ -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
|
bhshell/render/layout.py
ADDED
|
@@ -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)
|
bhshell/render/styles.py
ADDED
|
@@ -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"}
|