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/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)
|
|
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
|