bhshell 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. bhshell/__init__.py +3 -0
  2. bhshell/__main__.py +3 -0
  3. bhshell/cli.py +55 -0
  4. bhshell/client/__init__.py +0 -0
  5. bhshell/client/auth.py +74 -0
  6. bhshell/client/client.py +337 -0
  7. bhshell/config.py +162 -0
  8. bhshell/errors.py +73 -0
  9. bhshell/library/asrep-roastable-users.cyp +3 -0
  10. bhshell/library/dcsync-principals.cyp +3 -0
  11. bhshell/library/domain-admins-members.cyp +3 -0
  12. bhshell/library/domain-trusts.cyp +3 -0
  13. bhshell/library/gpos-linked-to-domain-controllers.cyp +3 -0
  14. bhshell/library/kerberoastable-users.cyp +3 -0
  15. bhshell/library/owned-objects.cyp +3 -0
  16. bhshell/library/owned-to-tier-zero.cyp +3 -0
  17. bhshell/library/sid-history.cyp +3 -0
  18. bhshell/library/tier-zero-users.cyp +3 -0
  19. bhshell/library/unconstrained-delegation.cyp +3 -0
  20. bhshell/models/__init__.py +0 -0
  21. bhshell/models/entities.py +402 -0
  22. bhshell/models/results.py +83 -0
  23. bhshell/queries/__init__.py +0 -0
  24. bhshell/queries/cypher.py +155 -0
  25. bhshell/queries/domains.py +136 -0
  26. bhshell/queries/edges.py +109 -0
  27. bhshell/queries/hunt.py +675 -0
  28. bhshell/queries/library.py +94 -0
  29. bhshell/queries/marking.py +121 -0
  30. bhshell/queries/paths.py +369 -0
  31. bhshell/queries/search.py +196 -0
  32. bhshell/queries/service.py +529 -0
  33. bhshell/render/__init__.py +0 -0
  34. bhshell/render/export.py +79 -0
  35. bhshell/render/graph.py +326 -0
  36. bhshell/render/layout.py +138 -0
  37. bhshell/render/png.py +44 -0
  38. bhshell/render/styles.py +5 -0
  39. bhshell/render/svg.py +495 -0
  40. bhshell/render/views.py +337 -0
  41. bhshell/shell/__init__.py +0 -0
  42. bhshell/shell/app.py +189 -0
  43. bhshell/shell/commands/__init__.py +0 -0
  44. bhshell/shell/commands/base.py +60 -0
  45. bhshell/shell/commands/connection.py +236 -0
  46. bhshell/shell/commands/cypher.py +97 -0
  47. bhshell/shell/commands/domains.py +80 -0
  48. bhshell/shell/commands/entities.py +88 -0
  49. bhshell/shell/commands/hunt.py +151 -0
  50. bhshell/shell/commands/library.py +56 -0
  51. bhshell/shell/commands/marking.py +59 -0
  52. bhshell/shell/commands/paths.py +166 -0
  53. bhshell/shell/commands/search.py +125 -0
  54. bhshell-0.1.0.dist-info/METADATA +134 -0
  55. bhshell-0.1.0.dist-info/RECORD +59 -0
  56. bhshell-0.1.0.dist-info/WHEEL +5 -0
  57. bhshell-0.1.0.dist-info/entry_points.txt +2 -0
  58. bhshell-0.1.0.dist-info/licenses/LICENSE +21 -0
  59. bhshell-0.1.0.dist-info/top_level.txt +1 -0
bhshell/errors.py ADDED
@@ -0,0 +1,73 @@
1
+ """Everything the client and query layer raise derives from BHError, so the shell
2
+ can print it as a one-line error with an optional hint."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from collections.abc import Sequence
7
+ from typing import TYPE_CHECKING
8
+
9
+ if TYPE_CHECKING:
10
+ from bhshell.models.entities import Node
11
+
12
+
13
+ class BHError(Exception):
14
+ hint: str = ""
15
+
16
+ def __init__(self, message: str, *, hint: str = "") -> None:
17
+ super().__init__(message)
18
+ self.message = message
19
+ if hint:
20
+ self.hint = hint
21
+
22
+
23
+ class ConnectionFailed(BHError):
24
+ hint = "Check the backend with 'url' and 'tls', then retry."
25
+
26
+
27
+ class AuthError(BHError):
28
+ hint = "Run 'login' to authenticate again."
29
+
30
+
31
+ class NotAuthenticated(AuthError):
32
+ hint = "Run 'login' first."
33
+
34
+
35
+ class MFARequired(AuthError):
36
+ hint = "Re-run 'login' and provide the one-time password."
37
+
38
+
39
+ class LoginFailed(AuthError):
40
+ # Unlike a plain 401, a rejected login does not drop the session already in use.
41
+ hint = "Check the credentials and retry; the previous session (if any) is still active."
42
+
43
+
44
+ class ForbiddenError(BHError):
45
+ pass
46
+
47
+
48
+ class NotFoundError(BHError):
49
+ pass
50
+
51
+
52
+ class ApiError(BHError):
53
+ def __init__(self, status: int, message: str, *, hint: str = "") -> None:
54
+ super().__init__(f"HTTP {status}: {message}", hint=hint)
55
+ self.status = status
56
+ self.detail = message
57
+
58
+
59
+ class CypherDisabledError(BHError):
60
+ pass
61
+
62
+
63
+ class UnsupportedFeature(BHError):
64
+ pass
65
+
66
+
67
+ class AmbiguousMatchError(BHError):
68
+ hint = "Qualify the name: 'jdoe@corp.local', 'CORP\\jdoe', '--domain CORP.LOCAL' or use the SID."
69
+
70
+ def __init__(self, query: str, candidates: Sequence[Node]) -> None:
71
+ super().__init__(f"'{query}' matches {len(candidates)} objects")
72
+ self.query = query
73
+ self.candidates = list(candidates)
@@ -0,0 +1,3 @@
1
+ // name: asrep-roastable-users
2
+ // description: Enabled users without Kerberos pre-authentication
3
+ MATCH (u:User) WHERE u.dontreqpreauth = true AND u.enabled = true RETURN u
@@ -0,0 +1,3 @@
1
+ // name: dcsync-principals
2
+ // description: Principals with DCSync or replication rights on a domain
3
+ MATCH p=(n:Base)-[:DCSync|GetChanges|GetChangesAll]->(d:Domain) RETURN p
@@ -0,0 +1,3 @@
1
+ // name: domain-admins-members
2
+ // description: Direct and nested members of every Domain Admins group
3
+ MATCH p=(m:Base)-[:MemberOf*1..]->(g:Group) WHERE g.objectid ENDS WITH '-512' RETURN p
@@ -0,0 +1,3 @@
1
+ // name: domain-trusts
2
+ // description: All edges between domains (trusts and trust abuse edges)
3
+ MATCH p=(a:Domain)-[r]->(b:Domain) RETURN p
@@ -0,0 +1,3 @@
1
+ // name: gpos-linked-to-domain-controllers
2
+ // description: GPOs linked to the Domain Controllers OU (control = DC compromise)
3
+ MATCH p=(g:GPO)-[:GPLink]->(o:OU) WHERE o.name STARTS WITH 'DOMAIN CONTROLLERS@' RETURN p
@@ -0,0 +1,3 @@
1
+ // name: kerberoastable-users
2
+ // description: Enabled users with an SPN (Kerberoastable)
3
+ MATCH (u:User) WHERE u.hasspn = true AND u.enabled = true RETURN u
@@ -0,0 +1,3 @@
1
+ // name: owned-objects
2
+ // description: Everything marked owned
3
+ MATCH (n:Tag_Owned) RETURN n
@@ -0,0 +1,3 @@
1
+ // name: owned-to-tier-zero
2
+ // description: Shortest paths from owned objects to Tier Zero (raw graph; see also 'path --owned')
3
+ MATCH p=allShortestPaths((s:Tag_Owned)-[*1..]->(t:Tag_Tier_Zero)) WHERE s <> t RETURN p LIMIT 1000
@@ -0,0 +1,3 @@
1
+ // name: sid-history
2
+ // description: Principals carrying another object's SID (HasSIDHistory)
3
+ MATCH p=(n:Base)-[:HasSIDHistory]->(t:Base) RETURN p
@@ -0,0 +1,3 @@
1
+ // name: tier-zero-users
2
+ // description: Enabled users tagged Tier Zero
3
+ MATCH (u:User:Tag_Tier_Zero) WHERE u.enabled = true RETURN u
@@ -0,0 +1,3 @@
1
+ // name: unconstrained-delegation
2
+ // description: Enabled computers and users trusted for unconstrained delegation
3
+ MATCH (n:Base) WHERE n.unconstraineddelegation = true AND n.enabled = true RETURN n
File without changes
@@ -0,0 +1,402 @@
1
+ """Graph and Active Directory entity models.
2
+
3
+ The BH Cypher endpoint returns graphs shaped like::
4
+
5
+ {"nodes": {"<id>": {"label", "kind", "kinds", "objectId", "isTierZero",
6
+ "isOwnedObject", "properties": {...}}},
7
+ "edges": [{"source", "target", "kind", "properties"}]}
8
+
9
+ These are normalised into `Node`, `Edge` and `Graph`.
10
+ Typed AD views (`User`, `Computer`, `Group`) expose the
11
+ triage summary for each object type.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from collections import deque
17
+ from collections.abc import Iterable, Iterator
18
+ from dataclasses import dataclass, field
19
+ from datetime import UTC, datetime
20
+ from typing import Any, Literal
21
+
22
+ PRIMARY_KINDS = (
23
+ "User",
24
+ "Computer",
25
+ "Group",
26
+ "Domain",
27
+ "GPO",
28
+ "OU",
29
+ "Container",
30
+ "AIACA",
31
+ "RootCA",
32
+ "EnterpriseCA",
33
+ "NTAuthStore",
34
+ "CertTemplate",
35
+ "IssuancePolicy",
36
+ )
37
+
38
+ TIER_ZERO_LABEL = "Tag_Tier_Zero"
39
+ OWNED_LABEL = "Tag_Owned"
40
+
41
+
42
+ @dataclass
43
+ class Node:
44
+ id: str
45
+ objectid: str
46
+ kind: str
47
+ name: str
48
+ kinds: list[str] = field(default_factory=list)
49
+ properties: dict[str, Any] = field(default_factory=dict)
50
+ tier_zero: bool = False
51
+ owned: bool = False
52
+
53
+ @classmethod
54
+ def from_cypher(cls, node_id: str, raw: dict[str, Any]) -> Node:
55
+ props = raw.get("properties") or {}
56
+ kinds = list(raw.get("kinds") or [])
57
+ primary = raw.get("kind") or ""
58
+ for candidate in PRIMARY_KINDS:
59
+ if candidate in kinds or candidate == primary:
60
+ primary = candidate
61
+ break
62
+ return cls._build(
63
+ node_id=str(node_id),
64
+ objectid=raw.get("objectId") or props.get("objectid") or "",
65
+ kind=primary or "Unknown",
66
+ name=props.get("name") or raw.get("label") or "",
67
+ kinds=kinds,
68
+ props=props,
69
+ tier_zero=bool(raw.get("isTierZero")),
70
+ owned=bool(raw.get("isOwnedObject")),
71
+ )
72
+
73
+ @classmethod
74
+ def from_rest(cls, kind: str, raw: dict[str, Any]) -> Node:
75
+ """Build a node from a REST entity's `props` or from an item of a related-objects list.
76
+
77
+ Entity props spell it `objectid`; list items use `objectID` and put the kind in `label`.
78
+ """
79
+ objectid = raw.get("objectid") or raw.get("objectID") or ""
80
+ item_kind = raw.get("kind") or raw.get("label") or kind
81
+ return cls._build(
82
+ node_id=objectid,
83
+ objectid=objectid,
84
+ kind=str(item_kind),
85
+ name=raw.get("name") or objectid,
86
+ kinds=[str(k) for k in raw.get("kinds") or [item_kind]],
87
+ props=raw,
88
+ tier_zero=bool(raw.get("isTierZero")),
89
+ owned=bool(raw.get("isOwnedObject")),
90
+ )
91
+
92
+ @classmethod
93
+ def _build(cls, *, node_id: str, objectid: str, kind: str, name: str, kinds: list[str],
94
+ props: dict[str, Any], tier_zero: bool, owned: bool) -> Node:
95
+ return cls(
96
+ id=node_id,
97
+ objectid=objectid,
98
+ kind=kind,
99
+ name=name,
100
+ kinds=kinds,
101
+ properties=props,
102
+ tier_zero=tier_zero or TIER_ZERO_LABEL in kinds,
103
+ owned=owned or OWNED_LABEL in kinds,
104
+ )
105
+
106
+ @property
107
+ def domain(self) -> str:
108
+ return str(self.properties.get("domain") or "")
109
+
110
+ @property
111
+ def samaccountname(self) -> str:
112
+ return str(self.properties.get("samaccountname") or "")
113
+
114
+ def as_dict(self) -> dict[str, Any]:
115
+ return {
116
+ "name": self.name,
117
+ "kind": self.kind,
118
+ "objectid": self.objectid,
119
+ "domain": self.domain,
120
+ "tier_zero": self.tier_zero,
121
+ "owned": self.owned,
122
+ "properties": self.properties,
123
+ }
124
+
125
+
126
+ @dataclass(frozen=True)
127
+ class Edge:
128
+ source: str
129
+ target: str
130
+ kind: str
131
+ properties: dict[str, Any] = field(default_factory=dict, compare=False, hash=False)
132
+
133
+ @classmethod
134
+ def from_cypher(cls, raw: dict[str, Any]) -> Edge:
135
+ return cls(
136
+ source=str(raw.get("source")),
137
+ target=str(raw.get("target")),
138
+ kind=str(raw.get("kind") or ""),
139
+ properties=raw.get("properties") or {},
140
+ )
141
+
142
+
143
+ @dataclass
144
+ class Graph:
145
+ nodes: dict[str, Node] = field(default_factory=dict)
146
+ edges: list[Edge] = field(default_factory=list)
147
+ # The Cypher query this graph came from ('' when built otherwise).
148
+ query: str = field(default="", compare=False, repr=False)
149
+
150
+ @classmethod
151
+ def from_cypher(cls, data: dict[str, Any]) -> Graph:
152
+ graph = cls()
153
+ for node_id, raw in (data.get("nodes") or {}).items():
154
+ graph.nodes[str(node_id)] = Node.from_cypher(node_id, raw)
155
+ seen: set[Edge] = set()
156
+ for raw in data.get("edges") or []:
157
+ edge = Edge.from_cypher(raw)
158
+ if edge not in seen:
159
+ seen.add(edge)
160
+ graph.edges.append(edge)
161
+ return graph
162
+
163
+ def merge(self, other: Graph) -> Graph:
164
+ self.nodes.update(other.nodes)
165
+ seen = set(self.edges)
166
+ self.edges.extend(e for e in other.edges if e not in seen)
167
+ return self
168
+
169
+ @classmethod
170
+ def union(cls, graphs: Iterable[Graph]) -> Graph:
171
+ merged = cls()
172
+ for g in graphs:
173
+ merged.merge(g)
174
+ return merged
175
+
176
+ def find(self, objectid: str) -> Node | None:
177
+ return next((n for n in self.nodes.values() if n.objectid == objectid), None)
178
+
179
+ def edges_from(self, sources: Iterable[str], kinds: Iterable[str] | None = None) -> Iterator[Edge]:
180
+ srcs = set(sources)
181
+ wanted = set(kinds) if kinds is not None else None
182
+ return (e for e in self.edges if e.source in srcs and (wanted is None or e.kind in wanted))
183
+
184
+ def edges_to(self, targets: Iterable[str], kinds: Iterable[str] | None = None) -> Iterator[Edge]:
185
+ tgts = set(targets)
186
+ wanted = set(kinds) if kinds is not None else None
187
+ return (e for e in self.edges if e.target in tgts and (wanted is None or e.kind in wanted))
188
+
189
+ def paths(self, sources: Iterable[str], targets: Iterable[str], *, max_paths: int = 1000) -> list[list[Edge]]:
190
+ """Enumerate shortest chains from each source to the nearest-reachable targets.
191
+
192
+ Path APIs return the *union* of all paths as one subgraph; this splits
193
+ it back into individual chains. A chain ends at the first target it
194
+ reaches, and only edges that advance the BFS distance from the source
195
+ are followed, so every chain is a shortest path to its target.
196
+ """
197
+ adjacency: dict[str, list[Edge]] = {}
198
+ for e in self.edges:
199
+ adjacency.setdefault(e.source, []).append(e)
200
+ tgts = set(targets)
201
+ found: list[list[Edge]] = []
202
+
203
+ for src in sources:
204
+ dist = {src: 0}
205
+ queue: deque[str] = deque([src])
206
+ while queue:
207
+ current = queue.popleft()
208
+ if current in tgts and current != src:
209
+ continue # never expand beyond a target
210
+ for e in adjacency.get(current, []):
211
+ if e.target not in dist:
212
+ dist[e.target] = dist[current] + 1
213
+ queue.append(e.target)
214
+
215
+ # Iterative DFS along distance-increasing edges: (node, chain so far).
216
+ stack: list[tuple[str, list[Edge]]] = [(src, [])]
217
+ while stack and len(found) < max_paths:
218
+ node, chain = stack.pop()
219
+ if chain and node in tgts:
220
+ found.append(chain)
221
+ continue
222
+ for e in reversed(adjacency.get(node, [])):
223
+ if dist.get(e.target) == dist[node] + 1:
224
+ stack.append((e.target, [*chain, e]))
225
+ return found
226
+
227
+ def count_paths(self, cap: int) -> int:
228
+ """Count simple paths (one edge or more) in the graph, stopping at `cap`.
229
+
230
+ Every row of a `RETURN p` query is a simple path of its result graph,
231
+ so this is an upper bound on the number of rows the server returned.
232
+ """
233
+ adjacency: dict[str, list[str]] = {}
234
+ for e in self.edges:
235
+ adjacency.setdefault(e.source, []).append(e.target)
236
+ total = 0
237
+ for start, targets in adjacency.items():
238
+ on_path = {start}
239
+ stack: list[tuple[str, Iterator[str]]] = [(start, iter(targets))]
240
+ while stack:
241
+ node, successors = stack[-1]
242
+ nxt = next(successors, None)
243
+ if nxt is None:
244
+ stack.pop()
245
+ on_path.discard(node)
246
+ continue
247
+ if nxt in on_path:
248
+ continue
249
+ total += 1
250
+ if total >= cap:
251
+ return total
252
+ if nxt in adjacency:
253
+ on_path.add(nxt)
254
+ stack.append((nxt, iter(adjacency[nxt])))
255
+ return total
256
+
257
+ def bfs(self, start: str, kind: str, *, reverse: bool = False) -> dict[str, tuple[int, str]]:
258
+ """`{node_id: (depth, parent_id)}` of every node reachable along `kind` edges, `start` excluded."""
259
+ adjacency: dict[str, list[str]] = {}
260
+ for e in self.edges:
261
+ if e.kind != kind:
262
+ continue
263
+ a, b = (e.target, e.source) if reverse else (e.source, e.target)
264
+ adjacency.setdefault(a, []).append(b)
265
+ found: dict[str, tuple[int, str]] = {}
266
+ queue: deque[tuple[str, int]] = deque([(start, 0)])
267
+ while queue:
268
+ current, depth = queue.popleft()
269
+ for nxt in adjacency.get(current, []):
270
+ if nxt != start and nxt not in found:
271
+ found[nxt] = (depth + 1, current)
272
+ queue.append((nxt, depth + 1))
273
+ return found
274
+
275
+
276
+ Risk = Literal["high", "medium", "info", "none"]
277
+ FieldType = Literal["text", "bool", "date", "list"]
278
+
279
+
280
+ @dataclass(frozen=True)
281
+ class SummaryField:
282
+ label: str
283
+ value: Any
284
+ type: FieldType = "text"
285
+ risk: Risk = "none"
286
+
287
+
288
+ def epoch_to_datetime(value: Any) -> datetime | None:
289
+ """`None` for never/unset (BloodHound stores 0 or -1)."""
290
+ try:
291
+ ts = float(value)
292
+ except (TypeError, ValueError):
293
+ return None
294
+ if ts <= 0:
295
+ return None
296
+ return datetime.fromtimestamp(ts, tz=UTC)
297
+
298
+
299
+ def opt_bool(props: dict[str, Any], key: str) -> bool | None:
300
+ value = props.get(key)
301
+ return None if value is None else bool(value)
302
+
303
+
304
+ def last_logon(props: dict[str, Any]) -> int | float | None:
305
+ """Most recent of `lastlogon` and the replicated `lastlogontimestamp`."""
306
+ values = [v for v in (props.get("lastlogon"), props.get("lastlogontimestamp")) if isinstance(v, (int, float))]
307
+ return max(values) if values else None
308
+
309
+
310
+ def _risk_if(value: bool | None, risk: Risk = "high") -> Risk:
311
+ return risk if value else "none"
312
+
313
+
314
+ class ADObject:
315
+ def __init__(self, node: Node) -> None:
316
+ self.node = node
317
+ self.props = node.properties
318
+
319
+ @staticmethod
320
+ def wrap(node: Node) -> ADObject:
321
+ cls = {"User": User, "Computer": Computer, "Group": Group}.get(node.kind, ADObject)
322
+ return cls(node)
323
+
324
+ def _common_head(self) -> list[SummaryField]:
325
+ return [
326
+ SummaryField("Name", self.node.name),
327
+ SummaryField("Type", self.node.kind),
328
+ SummaryField("Object ID (SID)", self.node.objectid),
329
+ SummaryField("Domain", self.node.domain),
330
+ ]
331
+
332
+ def _common_tail(self) -> list[SummaryField]:
333
+ return [
334
+ SummaryField("Description", self.props.get("description") or ""),
335
+ SummaryField("Tier Zero / High Value", self.node.tier_zero, "bool",
336
+ _risk_if(self.node.tier_zero, "medium")),
337
+ SummaryField("Owned", self.node.owned, "bool", _risk_if(self.node.owned, "info")),
338
+ ]
339
+
340
+ def summary(self) -> list[SummaryField]:
341
+ return [*self._common_head(), *self._common_tail()]
342
+
343
+
344
+ class User(ADObject):
345
+ def summary(self) -> list[SummaryField]:
346
+ p = self.props
347
+ enabled = opt_bool(p, "enabled")
348
+ admincount = opt_bool(p, "admincount")
349
+ hasspn = opt_bool(p, "hasspn")
350
+ asrep = opt_bool(p, "dontreqpreauth")
351
+ unconstrained = opt_bool(p, "unconstraineddelegation")
352
+ t2a = opt_bool(p, "trustedtoauth")
353
+ sensitive = opt_bool(p, "sensitive")
354
+ return [
355
+ *self._common_head(),
356
+ SummaryField("Enabled", enabled, "bool"),
357
+ SummaryField("AdminCount", admincount, "bool", _risk_if(admincount, "medium")),
358
+ SummaryField("Kerberoastable (hasspn)", hasspn, "bool", _risk_if(hasspn and enabled is not False)),
359
+ SummaryField("SPNs", p.get("serviceprincipalnames") or [], "list"),
360
+ SummaryField("AS-REP Roastable (dontreqpreauth)", asrep, "bool", _risk_if(asrep and enabled is not False)),
361
+ SummaryField("Unconstrained Delegation", unconstrained, "bool", _risk_if(unconstrained)),
362
+ SummaryField("Constrained Delegation (trustedtoauth)", t2a, "bool", _risk_if(t2a)),
363
+ SummaryField("Allowed To Delegate To", p.get("allowedtodelegate") or [], "list"),
364
+ SummaryField("Password Never Expires", opt_bool(p, "pwdneverexpires"), "bool",
365
+ _risk_if(opt_bool(p, "pwdneverexpires"), "medium")),
366
+ SummaryField("Password Not Required", opt_bool(p, "passwordnotreqd"), "bool",
367
+ _risk_if(opt_bool(p, "passwordnotreqd"))),
368
+ SummaryField("Password Last Set", p.get("pwdlastset"), "date"),
369
+ SummaryField("Last Logon", last_logon(p), "date"),
370
+ SummaryField("Sensitive (cannot be delegated)", sensitive, "bool"),
371
+ *self._common_tail(),
372
+ ]
373
+
374
+
375
+ class Computer(ADObject):
376
+ def summary(self) -> list[SummaryField]:
377
+ p = self.props
378
+ unconstrained = opt_bool(p, "unconstraineddelegation")
379
+ t2a = opt_bool(p, "trustedtoauth")
380
+ haslaps = opt_bool(p, "haslaps")
381
+ return [
382
+ *self._common_head(),
383
+ SummaryField("Enabled", opt_bool(p, "enabled"), "bool"),
384
+ SummaryField("Operating System", p.get("operatingsystem") or ""),
385
+ SummaryField("Unconstrained Delegation", unconstrained, "bool",
386
+ _risk_if(unconstrained and not self.node.tier_zero)),
387
+ SummaryField("Constrained Delegation (trustedtoauth)", t2a, "bool", _risk_if(t2a)),
388
+ SummaryField("Allowed To Delegate To", p.get("allowedtodelegate") or [], "list"),
389
+ SummaryField("LAPS Enabled", haslaps, "bool", "medium" if haslaps is False else "none"),
390
+ SummaryField("Last Logon", last_logon(p), "date"),
391
+ *self._common_tail(),
392
+ ]
393
+
394
+
395
+ class Group(ADObject):
396
+ def summary(self) -> list[SummaryField]:
397
+ admincount = opt_bool(self.props, "admincount")
398
+ return [
399
+ *self._common_head(),
400
+ SummaryField("AdminCount", admincount, "bool", _risk_if(admincount, "medium")),
401
+ *self._common_tail(),
402
+ ]
@@ -0,0 +1,83 @@
1
+ """Result containers returned by the query layer and consumed by renderers/exporters."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import asdict, dataclass, field
6
+ from typing import Any, Literal
7
+
8
+ from bhshell.models.entities import ADObject, Node
9
+
10
+ Backend = Literal["cypher", "rest"]
11
+
12
+
13
+ @dataclass
14
+ class RelationshipRow:
15
+ """One relationship in a view.
16
+
17
+ Not every view fills every field; `Column` definitions on the
18
+ owning `ViewResult` select which ones are displayed.
19
+ """
20
+
21
+ principal: str = ""
22
+ principal_kind: str = ""
23
+ edge: str = ""
24
+ target: str = ""
25
+ target_kind: str = ""
26
+ via: str = ""
27
+ depth: int | None = None
28
+ tier_zero: bool = False
29
+ owned: bool = False
30
+ objectid: str = ""
31
+
32
+ def as_dict(self) -> dict[str, Any]:
33
+ return asdict(self)
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class Column:
38
+ """A table column: `attr` is a row key; `fmt` picks the cell formatting (see `Renderer.fmt_value`)."""
39
+
40
+ header: str
41
+ attr: str
42
+ fmt: str = ""
43
+
44
+
45
+ @dataclass
46
+ class ViewResult:
47
+ """The outcome of one relationship view (groups, outbound, ...)."""
48
+
49
+ key: str
50
+ title: str
51
+ columns: list[Column]
52
+ rows: list[RelationshipRow] = field(default_factory=list)
53
+ backend: Backend = "cypher"
54
+ notes: list[str] = field(default_factory=list)
55
+
56
+ def as_dict(self) -> dict[str, Any]:
57
+ return {
58
+ "view": self.key,
59
+ "title": self.title,
60
+ "backend": self.backend,
61
+ "notes": self.notes,
62
+ "rows": [r.as_dict() for r in self.rows],
63
+ }
64
+
65
+
66
+ @dataclass
67
+ class EntityReport:
68
+ """A summary and/or a set of relationship views for one entity."""
69
+
70
+ node: Node
71
+ show_summary: bool = True
72
+ views: list[ViewResult] = field(default_factory=list)
73
+
74
+ @property
75
+ def entity(self) -> ADObject:
76
+ return ADObject.wrap(self.node)
77
+
78
+ def as_dict(self) -> dict[str, Any]:
79
+ out: dict[str, Any] = {"entity": self.node.as_dict()}
80
+ if self.show_summary:
81
+ out["summary"] = {f.label: f.value for f in self.entity.summary()}
82
+ out["views"] = {v.key: v.as_dict() for v in self.views}
83
+ return out
File without changes