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
|
@@ -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
|
bhshell/render/export.py
ADDED
|
@@ -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
|