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,529 @@
1
+ """Entity resolution and relationship views.
2
+
3
+ Everything goes through Cypher first and falls back to the REST entity endpoints when
4
+ Cypher is disabled or a query is rejected.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import re
10
+ from collections.abc import Callable, Iterable
11
+ from dataclasses import dataclass
12
+ from typing import TypeVar
13
+
14
+ from bhshell.client.client import BHClient
15
+ from bhshell.errors import (
16
+ AmbiguousMatchError,
17
+ ApiError,
18
+ CypherDisabledError,
19
+ NotFoundError,
20
+ )
21
+ from bhshell.models.entities import Graph, Node
22
+ from bhshell.models.results import Column, EntityReport, RelationshipRow, ViewResult
23
+ from bhshell.queries import cypher as Q
24
+ from bhshell.queries.edges import (
25
+ CONTROL_EDGES,
26
+ EXEC_EDGES,
27
+ HAS_SESSION,
28
+ MEMBER_OF,
29
+ SEVERITIES,
30
+ severity,
31
+ severity_rank,
32
+ )
33
+
34
+ T = TypeVar("T")
35
+
36
+ RAISE_LIMIT = "raise it with 'config max_results N'"
37
+
38
+ ENTITY_KINDS = ("User", "Computer", "Group")
39
+ # Kinds accepted as attack-path endpoints.
40
+ PATH_KINDS = (*ENTITY_KINDS, "Domain", "OU", "GPO", "Container")
41
+ _SID_RE = re.compile(r"(^|-)S-1-\d+(-\d+)+$", re.IGNORECASE)
42
+ _GUID_RE = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", re.IGNORECASE)
43
+
44
+ # View keys in display order, and which entity kinds support them.
45
+ VIEWS: dict[str, tuple[str, ...]] = {
46
+ "groups": ENTITY_KINDS,
47
+ "members": ("Group",),
48
+ "outbound": ENTITY_KINDS,
49
+ "inbound": ENTITY_KINDS,
50
+ "sessions": ENTITY_KINDS,
51
+ "privs": ENTITY_KINDS,
52
+ }
53
+
54
+ FLAGS = [Column("T0", "tier_zero", "t0"), Column("Owned", "owned", "owned")]
55
+ DEPTH, VIA = Column("Depth", "depth", "depth"), Column("Via", "via", "via")
56
+ GROUP_COLS = [Column("Group", "target"), DEPTH, VIA, *FLAGS]
57
+ MEMBER_COLS = [Column("Member", "principal"), Column("Type", "principal_kind", "kind"), DEPTH, VIA, *FLAGS]
58
+ OUTBOUND_COLS = [Column("Right", "edge", "edge"), Column("Target", "target"), Column("Type", "target_kind", "kind"),
59
+ VIA, *FLAGS]
60
+ INBOUND_COLS = [Column("Principal", "principal"), Column("Type", "principal_kind", "kind"),
61
+ Column("Right", "edge", "edge"), VIA, *FLAGS]
62
+ SESSION_COLS = {
63
+ "User": [Column("Computer", "target"), *FLAGS],
64
+ "Computer": [Column("User", "principal"), *FLAGS],
65
+ "Group": [Column("User", "principal"), Column("Computer", "target"), *FLAGS],
66
+ }
67
+
68
+ # (relation path, edge label, direction) - "out": items are targets, "in": items are principals.
69
+ RestRoute = tuple[str, str, str]
70
+ _EXEC_HELD: list[RestRoute] = [
71
+ ("admin-rights", "AdminTo", "out"), ("rdp-rights", "CanRDP", "out"),
72
+ ("dcom-rights", "ExecuteDCOM", "out"), ("ps-remote-rights", "CanPSRemote", "out"),
73
+ ]
74
+ REST_ROUTES: dict[tuple[str, str], list[RestRoute]] = {
75
+ ("User", "groups"): [("memberships", MEMBER_OF, "out")],
76
+ ("Computer", "groups"): [("group-membership", MEMBER_OF, "out")],
77
+ ("Group", "groups"): [("memberships", MEMBER_OF, "out")],
78
+ ("Group", "members"): [("members", MEMBER_OF, "in")],
79
+ ("User", "outbound"): [("controllables", "Controls", "out"),
80
+ ("constrained-delegation-rights", "AllowedToDelegate", "out")],
81
+ ("Computer", "outbound"): [("controllables", "Controls", "out"),
82
+ ("constrained-delegation-rights", "AllowedToDelegate", "out")],
83
+ ("Group", "outbound"): [("controllables", "Controls", "out")],
84
+ ("User", "inbound"): [("controllers", "Controls", "in")],
85
+ ("Computer", "inbound"): [("controllers", "Controls", "in"), ("constrained-users", "AllowedToDelegate", "in")],
86
+ ("Group", "inbound"): [("controllers", "Controls", "in")],
87
+ ("User", "sessions"): [("sessions", HAS_SESSION, "out")],
88
+ ("Computer", "sessions"): [("sessions", HAS_SESSION, "in")],
89
+ ("Group", "sessions"): [("sessions", HAS_SESSION, "out")],
90
+ ("User", "privs_held"): [*_EXEC_HELD, ("sql-admin-rights", "SQLAdmin", "out")],
91
+ ("Computer", "privs_held"): _EXEC_HELD,
92
+ ("Group", "privs_held"): _EXEC_HELD,
93
+ ("Computer", "privs_inbound"): [
94
+ ("admin-users", "AdminTo", "in"), ("rdp-users", "CanRDP", "in"), ("dcom-users", "ExecuteDCOM", "in"),
95
+ ("ps-remote-users", "CanPSRemote", "in"), ("sql-admins", "SQLAdmin", "in"),
96
+ ],
97
+ }
98
+
99
+
100
+ @dataclass(frozen=True)
101
+ class Identifier:
102
+ raw: str
103
+ sam: str = ""
104
+ domain: str = ""
105
+ fqdn: str = ""
106
+ objectid: str = ""
107
+
108
+
109
+ def parse_identifier(raw: str, *, domain: str | None = None, kind: str | None = None) -> Identifier:
110
+ """Parse `jdoe`, `jdoe@corp.local`, `CORP\\jdoe`, `WS01.corp.local` or a SID/GUID."""
111
+ text = raw.strip()
112
+ if not text:
113
+ raise ValueError("Empty object name")
114
+ if _SID_RE.search(text) or _GUID_RE.match(text):
115
+ return Identifier(raw=text, objectid=text.upper())
116
+ sam, dom, fqdn = text, "", ""
117
+ if "\\" in text:
118
+ dom, sam = text.split("\\", 1)
119
+ elif "@" in text:
120
+ sam, dom = text.rsplit("@", 1)
121
+ fqdn = f"{sam}@{dom}"
122
+ elif "." in text and kind == "Computer":
123
+ sam, dom = text.split(".", 1)
124
+ fqdn = text
125
+ elif "." in text and kind is None:
126
+ # Could be a dotted username ("j.doe") or a computer FQDN: try both.
127
+ fqdn = text
128
+ if domain:
129
+ dom = domain
130
+ return Identifier(raw=text, sam=sam, domain=dom.upper(), fqdn=fqdn.upper())
131
+
132
+
133
+ def _node_domain(node: Node) -> str:
134
+ if node.domain:
135
+ return node.domain.upper()
136
+ name = node.name.upper()
137
+ if "@" in name:
138
+ return name.rsplit("@", 1)[1]
139
+ return name.split(".", 1)[1] if "." in name else ""
140
+
141
+
142
+ def _matches(node: Node, ident: Identifier, kinds: tuple[str, ...]) -> bool:
143
+ if node.kind not in kinds:
144
+ return False
145
+ if ident.objectid:
146
+ return node.objectid.upper() == ident.objectid
147
+ sam = ident.sam.upper()
148
+ name = node.name.upper()
149
+ node_sam = node.samaccountname.upper()
150
+ if node.kind == "Computer":
151
+ host = sam.rstrip("$")
152
+ ok = name == ident.fqdn or name.split(".", 1)[0] == host or node_sam == f"{host}$"
153
+ elif node.kind not in ENTITY_KINDS: # Domain, OU, GPO, Container: match the full name
154
+ ok = name in (ident.fqdn, sam) or name.rsplit("@", 1)[0] == sam
155
+ else:
156
+ ok = name.rsplit("@", 1)[0] == sam or node_sam == sam
157
+ if not ok:
158
+ return False
159
+ if ident.domain:
160
+ dom = _node_domain(node)
161
+ return dom == ident.domain or dom.startswith(ident.domain + ".")
162
+ return True
163
+
164
+
165
+ def truncation_note(limit: int, totals: Iterable[int | None], what: str = "paths") -> str:
166
+ """Note for results cut off at `limit`; `totals` are the server-side counts (`None` = unknown)."""
167
+ totals = list(totals)
168
+ if not totals or None in totals:
169
+ return f"Results may be truncated at {limit} {what} ({RAISE_LIMIT})."
170
+ counts = ", ".join(f"{t:,}" for t in totals)
171
+ return f"Results truncated at {limit} {what} (server total: {counts}); {RAISE_LIMIT}."
172
+
173
+
174
+ class EntityService:
175
+ def __init__(self, client: BHClient, *, max_results: int = 5000) -> None:
176
+ self.client = client
177
+ self.max_results = max_results
178
+ # `None` = not probed yet, `True`/`False` once known.
179
+ self.cypher_available: bool | None = None
180
+ self._cache: dict[str, Graph] = {}
181
+
182
+ def reset_cache(self) -> None:
183
+ self._cache.clear()
184
+
185
+ def reset(self) -> None:
186
+ """Forget everything learned about the backend (after URL/auth change)."""
187
+ self.reset_cache()
188
+ self.cypher_available = None
189
+
190
+ def run_cypher(self, query: str) -> Graph:
191
+ """Results are cached until the next shell command."""
192
+ if query in self._cache:
193
+ return self._cache[query]
194
+ if self.cypher_available is False:
195
+ raise CypherDisabledError("Cypher is unavailable on this server")
196
+ try:
197
+ data = self.client.cypher(query)
198
+ except CypherDisabledError:
199
+ self.cypher_available = False
200
+ raise
201
+ self.cypher_available = True
202
+ graph = Graph.from_cypher(data)
203
+ graph.query = query
204
+ self._cache[query] = graph
205
+ return graph
206
+
207
+ def count(self, query: str) -> int | None:
208
+ """Run a `RETURN count(...)` query; `None` if scalars are unsupported or Cypher is off."""
209
+ if self.cypher_available is False:
210
+ return None
211
+ try:
212
+ literals = self.client.cypher(query).get("literals") or []
213
+ return int(literals[0]["value"])
214
+ except (ApiError, CypherDisabledError, IndexError, KeyError, TypeError, ValueError):
215
+ return None
216
+
217
+ def truncation(self, graph: Graph) -> tuple[bool, int | None]:
218
+ """Was `graph`'s LIMITed query cut off? Returns `(truncated, server_total)`.
219
+
220
+ Rows cannot be counted from a result graph, but a cheap upper bound can
221
+ (nodes for `RETURN n`, simple paths for `RETURN p`). Only when that
222
+ bound reaches the LIMIT is an exact `count()` query sent. The total is
223
+ `None` when the server cannot count (truncation is then assumed).
224
+ """
225
+ limit = Q.limit_of(graph.query)
226
+ if limit is None:
227
+ return False, None
228
+ upper = graph.count_paths(limit) if graph.edges else len(graph.nodes)
229
+ if upper < limit:
230
+ return False, None
231
+ count_query = Q.to_count(graph.query)
232
+ total = self.count(count_query) if count_query else None
233
+ if total is None:
234
+ return True, None
235
+ return total > limit, total
236
+
237
+ def _with_fallback(self, via_cypher: Callable[[], T], via_rest: Callable[[str], T]) -> T:
238
+ """`via_rest` gets the reason the Cypher query failed ('' when Cypher is off)."""
239
+ if self.cypher_available is not False:
240
+ try:
241
+ return via_cypher()
242
+ except CypherDisabledError:
243
+ pass
244
+ except ApiError as exc:
245
+ return via_rest(exc.message)
246
+ return via_rest("")
247
+
248
+ def resolve(self, name: str, kind: str | None = None, domain: str | None = None,
249
+ *, kinds: tuple[str, ...] | None = None) -> Node:
250
+ """The one node named `name`. `kinds` limits the accepted kinds when `kind` is not given.
251
+
252
+ Raises NotFoundError, or AmbiguousMatchError when the name exists in several domains.
253
+ """
254
+ ident = parse_identifier(name, domain=domain, kind=kind)
255
+ kinds = (kind,) if kind else (kinds or ENTITY_KINDS)
256
+ candidates = self._with_fallback(
257
+ lambda: self._resolve_cypher(ident, kind),
258
+ lambda _: self._resolve_rest(ident, kinds),
259
+ )
260
+ unique: dict[str, Node] = {}
261
+ for node in candidates:
262
+ if _matches(node, ident, kinds):
263
+ unique.setdefault(node.objectid, node)
264
+ if not unique:
265
+ what = (kind or "object").lower()
266
+ scope = f" in domain {ident.domain}" if ident.domain else ""
267
+ raise NotFoundError(f"No {what} named '{name}'{scope}")
268
+ if len(unique) > 1:
269
+ raise AmbiguousMatchError(name, sorted(unique.values(), key=lambda n: (n.domain, n.name)))
270
+ node = next(iter(unique.values()))
271
+ if self.cypher_available is False:
272
+ node = self._hydrate_rest(node)
273
+ return node
274
+
275
+ def _resolve_cypher(self, ident: Identifier, kind: str | None) -> list[Node]:
276
+ if ident.objectid:
277
+ query = Q.by_objectid(ident.objectid)
278
+ else:
279
+ query = Q.by_name(Q.name_conditions(ident.sam, ident.fqdn or None, kind), kind, limit=100)
280
+ return list(self.run_cypher(query).nodes.values())
281
+
282
+ def _resolve_rest(self, ident: Identifier, kinds: tuple[str, ...]) -> list[Node]:
283
+ term = ident.objectid or ident.sam.rstrip("$")
284
+ found: list[Node] = []
285
+ for k in kinds:
286
+ for item in self.client.search(term, k):
287
+ found.append(Node.from_rest(k, {**item, "kind": item.get("type") or k}))
288
+ return found
289
+
290
+ def _hydrate_rest(self, node: Node) -> Node:
291
+ try:
292
+ props = self.client.entity(node.kind, node.objectid)
293
+ except (NotFoundError, ValueError):
294
+ return node
295
+ merged = Node.from_rest(node.kind, {**node.properties, **props, "kind": node.kind})
296
+ merged.tier_zero = merged.tier_zero or node.tier_zero
297
+ merged.owned = merged.owned or node.owned
298
+ return merged
299
+
300
+ def applicable_views(self, node: Node) -> list[str]:
301
+ return [key for key, kinds in VIEWS.items() if node.kind in kinds]
302
+
303
+ def report(self, node: Node, views: list[str], *, show_summary: bool) -> EntityReport:
304
+ report = EntityReport(node=node, show_summary=show_summary)
305
+ for key in views:
306
+ if node.kind in VIEWS.get(key, ()):
307
+ report.views.extend(self.view(node, key))
308
+ return report
309
+
310
+ def view(self, node: Node, key: str) -> list[ViewResult]:
311
+ return getattr(self, key)(node) # every VIEWS key is a method
312
+
313
+ def groups(self, node: Node) -> list[ViewResult]:
314
+ title = "Group memberships (direct + nested)"
315
+
316
+ def via_cypher() -> list[ViewResult]:
317
+ g = self.run_cypher(Q.member_of(node.objectid))
318
+ rows: list[RelationshipRow] = []
319
+ start = g.find(node.objectid)
320
+ if start:
321
+ for gid, (depth, parent) in g.bfs(start.id, MEMBER_OF).items():
322
+ grp = g.nodes[gid]
323
+ rows.append(RelationshipRow(
324
+ principal=node.name, edge=MEMBER_OF, target=grp.name, target_kind=grp.kind,
325
+ via="direct" if depth == 1 else g.nodes[parent].name, depth=depth,
326
+ tier_zero=grp.tier_zero, owned=grp.owned, objectid=grp.objectid,
327
+ ))
328
+ rows.sort(key=lambda r: (r.depth or 0, r.target))
329
+ return [ViewResult("groups", title, GROUP_COLS, rows)]
330
+
331
+ return self._with_fallback(via_cypher, lambda why: self._rest_view(node, "groups", title, GROUP_COLS, why))
332
+
333
+ def members(self, node: Node) -> list[ViewResult]:
334
+ title = "Group members (direct + nested)"
335
+
336
+ def via_cypher() -> list[ViewResult]:
337
+ g = self.run_cypher(Q.members(node.objectid, self.max_results))
338
+ rows: list[RelationshipRow] = []
339
+ start = g.find(node.objectid)
340
+ if start:
341
+ for mid, (depth, parent) in g.bfs(start.id, MEMBER_OF, reverse=True).items():
342
+ m = g.nodes[mid]
343
+ rows.append(RelationshipRow(
344
+ principal=m.name, principal_kind=m.kind, edge=MEMBER_OF, target=node.name,
345
+ via="direct" if depth == 1 else g.nodes[parent].name, depth=depth,
346
+ tier_zero=m.tier_zero, owned=m.owned, objectid=m.objectid,
347
+ ))
348
+ rows.sort(key=lambda r: (r.depth or 0, r.principal_kind != "User", r.principal))
349
+ return [self._capped(ViewResult("members", title, MEMBER_COLS, rows), g)]
350
+
351
+ return self._with_fallback(via_cypher, lambda why: self._rest_view(node, "members", title, MEMBER_COLS, why))
352
+
353
+ def outbound(self, node: Node) -> list[ViewResult]:
354
+ title = "Outbound object control (rights held over other objects)"
355
+ return self._with_fallback(
356
+ lambda: [self._effective_view(node, "outbound", title, CONTROL_EDGES)],
357
+ lambda why: self._rest_view(node, "outbound", title, OUTBOUND_COLS, why),
358
+ )
359
+
360
+ def inbound(self, node: Node) -> list[ViewResult]:
361
+ title = "Inbound object control (principals with rights over this object)"
362
+
363
+ def via_cypher() -> list[ViewResult]:
364
+ oid, limit = node.objectid, self.max_results
365
+ graphs = self._tiered(
366
+ lambda ks: Q.incoming(oid, ks, limit) if ks else Q.incoming_any(oid, limit), CONTROL_EDGES)
367
+ g = Graph.union(graphs)
368
+ rows: list[RelationshipRow] = []
369
+ target = g.find(node.objectid)
370
+ if target:
371
+ for e in g.edges_to([target.id], CONTROL_EDGES):
372
+ src = g.nodes[e.source]
373
+ rows.append(RelationshipRow(
374
+ principal=src.name, principal_kind=src.kind, edge=e.kind, target=node.name,
375
+ target_kind=node.kind, via="direct", tier_zero=src.tier_zero, owned=src.owned,
376
+ objectid=src.objectid,
377
+ ))
378
+ rows.sort(key=lambda r: (severity_rank(r.edge), not r.tier_zero, r.edge, r.principal))
379
+ return [self._capped(ViewResult("inbound", title, INBOUND_COLS, rows), *graphs)]
380
+
381
+ return self._with_fallback(via_cypher, lambda why: self._rest_view(node, "inbound", title, INBOUND_COLS, why))
382
+
383
+ def sessions(self, node: Node) -> list[ViewResult]:
384
+ titles = {
385
+ "User": "Sessions (computers where this user is logged on)",
386
+ "Computer": "Sessions (users logged on to this computer)",
387
+ "Group": "Sessions of group members (direct + nested)",
388
+ }
389
+ title, cols = titles[node.kind], SESSION_COLS[node.kind]
390
+
391
+ def via_cypher() -> list[ViewResult]:
392
+ query = {
393
+ "User": Q.sessions_of_user,
394
+ "Computer": Q.sessions_on_computer,
395
+ "Group": Q.sessions_of_group_members,
396
+ }[node.kind](node.objectid, self.max_results)
397
+ g = self.run_cypher(query)
398
+ rows: list[RelationshipRow] = []
399
+ for e in g.edges:
400
+ if e.kind != HAS_SESSION:
401
+ continue
402
+ comp, user = g.nodes[e.source], g.nodes[e.target]
403
+ flag_node = user if node.kind == "Computer" else comp
404
+ rows.append(RelationshipRow(
405
+ principal=user.name, principal_kind=user.kind, edge=HAS_SESSION,
406
+ target=comp.name, target_kind=comp.kind,
407
+ tier_zero=flag_node.tier_zero, owned=flag_node.owned, objectid=flag_node.objectid,
408
+ ))
409
+ rows.sort(key=lambda r: (not r.tier_zero, r.principal, r.target))
410
+ return [self._capped(ViewResult("sessions", title, cols, rows), g)]
411
+
412
+ return self._with_fallback(via_cypher, lambda why: self._rest_view(node, "sessions", title, cols, why))
413
+
414
+ def privs(self, node: Node) -> list[ViewResult]:
415
+ held_title = "Execution privileges held (AdminTo, CanRDP, ExecuteDCOM, CanPSRemote, SQLAdmin)"
416
+ results = self._with_fallback(
417
+ lambda: [self._effective_view(node, "privs_held", held_title, EXEC_EDGES)],
418
+ lambda why: self._rest_view(node, "privs_held", held_title, OUTBOUND_COLS, why),
419
+ )
420
+ if node.kind == "Computer":
421
+ in_title = "Principals with execution privileges on this computer (direct + via groups)"
422
+ results += self._with_fallback(
423
+ lambda: [self._exec_inbound(node, in_title)],
424
+ lambda why: self._rest_view(node, "privs_inbound", in_title, INBOUND_COLS, why),
425
+ )
426
+ return results
427
+
428
+ def _capped(self, view: ViewResult, *graphs: Graph) -> ViewResult:
429
+ """Flag views whose underlying (LIMITed) queries hit `max_results`."""
430
+ limit = self.max_results
431
+ cut = [total for truncated, total in map(self.truncation, graphs) if truncated]
432
+ if view.backend == "rest" and len(view.rows) >= limit:
433
+ cut.append(None)
434
+ if cut:
435
+ view.notes.append(truncation_note(limit, cut, "paths per query"))
436
+ return view
437
+
438
+ def _tiered(self, build: Callable[[frozenset[str] | None], str], kinds: frozenset[str]) -> list[Graph]:
439
+ """Run one query per severity tier of `kinds`.
440
+
441
+ Each query has its own LIMIT, so a flood of low-impact edges can never
442
+ crowd critical ones out of the result. If the server rejects a kind it does
443
+ not know, a single unfiltered query (`build(None)`) is used instead.
444
+ """
445
+ tiers: dict[str, set[str]] = {}
446
+ for kind in kinds:
447
+ tiers.setdefault(severity(kind), set()).add(kind)
448
+ try:
449
+ return [self.run_cypher(build(frozenset(tiers[t]))) for t in SEVERITIES if t in tiers]
450
+ except ApiError:
451
+ return [self.run_cypher(build(None))]
452
+
453
+ def _effective_view(self, node: Node, key: str, title: str, kinds: frozenset[str]) -> ViewResult:
454
+ """Edges of `kinds` held by the node directly or through nested groups."""
455
+ oid, limit = node.objectid, self.max_results
456
+ graphs = [
457
+ *self._tiered(lambda ks: Q.outgoing_direct(oid, limit, ks), kinds),
458
+ *self._tiered(lambda ks: Q.outgoing_inherited(oid, limit, ks), kinds),
459
+ ]
460
+ view = ViewResult(key, title, OUTBOUND_COLS)
461
+ g = Graph.union(graphs)
462
+ start = g.find(node.objectid)
463
+ if not start:
464
+ return self._capped(view, *graphs)
465
+ groups = g.bfs(start.id, MEMBER_OF)
466
+ rows: list[RelationshipRow] = []
467
+ for e in g.edges_from({start.id, *groups}, kinds):
468
+ src, tgt = g.nodes[e.source], g.nodes[e.target]
469
+ is_direct = e.source == start.id
470
+ rows.append(RelationshipRow(
471
+ principal=src.name, principal_kind=src.kind, edge=e.kind, target=tgt.name,
472
+ target_kind=tgt.kind, via="direct" if is_direct else src.name,
473
+ depth=0 if is_direct else groups[e.source][0],
474
+ tier_zero=tgt.tier_zero, owned=tgt.owned, objectid=tgt.objectid,
475
+ ))
476
+ rows.sort(key=lambda r: (r.depth or 0, severity_rank(r.edge), not r.tier_zero, r.edge, r.target))
477
+ view.rows = rows
478
+ return self._capped(view, *graphs)
479
+
480
+ def _exec_inbound(self, node: Node, title: str) -> ViewResult:
481
+ oid, limit = node.objectid, self.max_results
482
+ graphs = [
483
+ *self._tiered(lambda ks: Q.incoming(oid, ks or EXEC_EDGES, limit), EXEC_EDGES),
484
+ *self._tiered(lambda ks: Q.exec_rights_inbound_unrolled(oid, limit, ks or EXEC_EDGES), EXEC_EDGES),
485
+ ]
486
+ g = Graph.union(graphs)
487
+ target = g.find(node.objectid)
488
+ rows: list[RelationshipRow] = []
489
+ if target:
490
+ for e in g.edges_to([target.id], EXEC_EDGES):
491
+ src = g.nodes[e.source]
492
+ rows.append(RelationshipRow(
493
+ principal=src.name, principal_kind=src.kind, edge=e.kind, target=node.name, via="direct",
494
+ depth=0, tier_zero=src.tier_zero, owned=src.owned, objectid=src.objectid,
495
+ ))
496
+ if src.kind == "Group":
497
+ for mid, (depth, _parent) in g.bfs(src.id, MEMBER_OF, reverse=True).items():
498
+ m = g.nodes[mid]
499
+ rows.append(RelationshipRow(
500
+ principal=m.name, principal_kind=m.kind, edge=e.kind, target=node.name,
501
+ via=src.name, depth=depth, tier_zero=m.tier_zero, owned=m.owned, objectid=m.objectid,
502
+ ))
503
+ rows.sort(key=lambda r: (r.depth or 0, severity_rank(r.edge), not r.tier_zero, r.edge, r.principal))
504
+ return self._capped(ViewResult("privs_inbound", title, INBOUND_COLS, rows), *graphs)
505
+
506
+ def _rest_view(self, node: Node, key: str, title: str, columns: list[Column], why: str) -> list[ViewResult]:
507
+ view = ViewResult(key, title, columns, backend="rest")
508
+ if why:
509
+ view.notes.append(f"Cypher query failed ({why}); used REST fallback.")
510
+ for relation, label, direction in REST_ROUTES.get((node.kind, key), []):
511
+ try:
512
+ items = self.client.entity_related(node.kind, node.objectid, relation, limit=self.max_results)
513
+ except NotFoundError:
514
+ view.notes.append(f"Endpoint '{relation}' is not available on this server.")
515
+ continue
516
+ for item in items:
517
+ other = Node.from_rest("", item)
518
+ row = RelationshipRow(edge=label, tier_zero=other.tier_zero, owned=other.owned,
519
+ objectid=other.objectid)
520
+ if direction == "out":
521
+ row.principal, row.principal_kind = node.name, node.kind
522
+ row.target, row.target_kind = other.name, other.kind
523
+ else:
524
+ row.principal, row.principal_kind = other.name, other.kind
525
+ row.target, row.target_kind = node.name, node.kind
526
+ view.rows.append(row)
527
+ if key in ("outbound", "inbound"):
528
+ view.notes.append("REST fallback: exact right names are not exposed; enable Cypher for full detail.")
529
+ return [self._capped(view)]
File without changes
@@ -0,0 +1,79 @@
1
+ """JSON / CSV export of query results."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import csv
6
+ import json
7
+ from collections.abc import Iterable
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from bhshell.models.entities import Graph, Node
12
+ from bhshell.models.results import EntityReport, RelationshipRow
13
+
14
+ ROW_FIELDS = list(RelationshipRow.__dataclass_fields__)
15
+ NODE_FIELDS = ["kind", "name", "domain", "objectid", "tier_zero", "owned"]
16
+
17
+
18
+ def to_json(data: Any) -> str:
19
+ return json.dumps(data, indent=2, default=str)
20
+
21
+
22
+ def report_rows(report: EntityReport) -> tuple[list[str], list[dict[str, Any]]]:
23
+ """Flatten a report into CSV rows (summary as key/value, views as relationships)."""
24
+ if not report.views:
25
+ rows = [{"field": f.label, "value": _flat(f.value)} for f in report.entity.summary()]
26
+ return ["field", "value"], rows
27
+ rows = [{"view": v.key, **r.as_dict()} for v in report.views for r in v.rows]
28
+ return ["view", *ROW_FIELDS], rows
29
+
30
+
31
+ def node_rows(nodes: Iterable[Node]) -> tuple[list[str], list[dict[str, Any]]]:
32
+ return NODE_FIELDS, [{k: v for k, v in n.as_dict().items() if k in NODE_FIELDS} for n in nodes]
33
+
34
+
35
+ def graph_dict(graph: Graph) -> dict[str, Any]:
36
+ return {
37
+ "nodes": [n.as_dict() for n in graph.nodes.values()],
38
+ "edges": [
39
+ {"source": graph.nodes[e.source].name if e.source in graph.nodes else e.source,
40
+ "edge": e.kind,
41
+ "target": graph.nodes[e.target].name if e.target in graph.nodes else e.target}
42
+ for e in graph.edges
43
+ ],
44
+ }
45
+
46
+
47
+ def _flat(value: Any) -> Any:
48
+ if isinstance(value, (list, tuple)):
49
+ return ";".join(map(str, value))
50
+ return value
51
+
52
+
53
+ # Leading characters that make spreadsheet applications evaluate a cell as a formula.
54
+ _FORMULA_PREFIXES = ("=", "+", "-", "@", "\t", "\r")
55
+
56
+
57
+ def _csv_cell(value: Any) -> Any:
58
+ """Flatten a value and neutralise formulas: AD data (descriptions, GPO names) is attacker-controlled."""
59
+ value = _flat(value)
60
+ if isinstance(value, str) and value.startswith(_FORMULA_PREFIXES):
61
+ return "'" + value
62
+ return value
63
+
64
+
65
+ def write(path: str, data: Any, csv_table: tuple[list[str], list[dict[str, Any]]]) -> Path:
66
+ """Write `data` as JSON or `csv_table` as CSV, depending on the file extension."""
67
+ out = Path(path).expanduser()
68
+ out.parent.mkdir(parents=True, exist_ok=True)
69
+ if out.suffix.lower() == ".csv":
70
+ fields, rows = csv_table
71
+ with out.open("w", newline="", encoding="utf-8") as fh:
72
+ writer = csv.DictWriter(fh, fieldnames=fields, extrasaction="ignore")
73
+ writer.writeheader()
74
+ writer.writerows({k: _csv_cell(v) for k, v in row.items()} for row in rows)
75
+ elif out.suffix.lower() == ".json":
76
+ out.write_text(to_json(data) + "\n", encoding="utf-8")
77
+ else:
78
+ raise ValueError("Output file must end in .json or .csv")
79
+ return out