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,675 @@
1
+ """Domain-wide weakness checks (`hunt`).
2
+
3
+ Each `Check` is declarative: a Cypher builder, the columns to display
4
+ and a function turning the returned graph into rows.
5
+
6
+ Checks marked `scoped` also run from a set of principals (`hunt --owned`,
7
+ `hunt --from NAME`): the owned objects or the named one, plus every group
8
+ they belong to. Their query builders restrict the principal side with
9
+ `HuntOptions.scope`, which is empty outside that mode.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import re
15
+ import time
16
+ from collections.abc import Callable, Iterable
17
+ from dataclasses import dataclass, field
18
+ from typing import Any, Literal
19
+
20
+ from bhshell.errors import ApiError, CypherDisabledError, UnsupportedFeature
21
+ from bhshell.models.entities import OWNED_LABEL, TIER_ZERO_LABEL, Edge, Graph, Node, last_logon
22
+ from bhshell.models.results import Column
23
+ from bhshell.queries import cypher as Q
24
+ from bhshell.queries.domains import TRUSTS_QUERY, trusts_from_graph
25
+ from bhshell.queries.edges import (
26
+ ACL_EDGES,
27
+ ADCS_EDGES,
28
+ COERCION_EDGES,
29
+ CONTROL_EDGES,
30
+ DELEGATION_EDGES,
31
+ ENABLE_EDGES,
32
+ EXEC_EDGES,
33
+ HAS_SESSION,
34
+ MEMBER_OF,
35
+ severity_rank,
36
+ )
37
+ from bhshell.queries.paths import PathService
38
+ from bhshell.queries.service import EntityService
39
+
40
+ Row = dict[str, Any]
41
+ HuntSeverity = Literal["critical", "high", "medium", "low", "info"]
42
+
43
+ # Well-known broad principals, matched by objectid suffix: Everyone, Anonymous Logon, Authenticated Users,
44
+ # Domain Users, Domain Computers, Users, Pre-Windows 2000 Compatible Access.
45
+ BROAD_SIDS = ("-S-1-1-0", "-S-1-5-7", "-S-1-5-11", "-513", "-515", "-S-1-5-32-545", "-S-1-5-32-554")
46
+ LEGACY_OS = ("2000", "2003", "2008", " XP", "VISTA", "WINDOWS 7", "2012", "WINDOWS 8")
47
+ # Windows 10 left support in October 2025; the LTSC/LTSB editions are still supported.
48
+ WINDOWS_10 = "WINDOWS 10"
49
+ LTSC = ("LTSC", "LTSB")
50
+ PROTECTED_USERS_RID = "-525"
51
+ DCSYNC_EDGES = ("DCSync", "GetChanges", "GetChangesAll")
52
+ ESC8_EDGE = "CoerceAndRelayNTLMToADCS"
53
+ ADCS_ABUSE_EDGES = frozenset(k for k in ADCS_EDGES if k.startswith("ADCSESC")) | {"GoldenCert", ESC8_EDGE}
54
+ BROAD_RIGHT_EDGES = (CONTROL_EDGES | EXEC_EDGES) - {"Enroll"}
55
+ CRED_READ_EDGES = frozenset({"ReadLAPSPassword", "ReadGMSAPassword", "SyncLAPSPassword", "DumpSMSAPassword"})
56
+ # Control of a Tier Zero object, other than the DCSync, SID history and AD CS edges reported by their own checks.
57
+ T0_CONTROL_EDGES = ACL_EDGES | DELEGATION_EDGES | CRED_READ_EDGES | EXEC_EDGES
58
+ TAKEOVER = {
59
+ "AddKeyCredentialLink": "Shadow Credentials",
60
+ "WriteSPN": "Targeted Kerberoasting",
61
+ "AddAllowedToAct": "RBCD",
62
+ "WriteAccountRestrictions": "RBCD",
63
+ }
64
+ TAKEOVER_EDGES = frozenset(TAKEOVER)
65
+ GPO_CONTROL_EDGES = frozenset({"GenericAll", "GenericWrite", "WriteDacl", "WriteOwner", "Owns", "WriteGPLink",
66
+ "OwnsLimitedRights", "WriteOwnerLimitedRights"})
67
+ CA_CONTROL_EDGES = frozenset({"ManageCA", "ManageCertificates"})
68
+ RELAY_EDGES = COERCION_EDGES - {ESC8_EDGE}
69
+ FOREIGN_EDGES = ACL_EDGES | EXEC_EDGES | {MEMBER_OF}
70
+ # Attributes that may hold a cleartext (or reversibly encoded) password.
71
+ PASSWORD_ATTRS = ("userpassword", "unixpassword", "unicodepassword", "sfupassword")
72
+ PASSWORD_WORDS = ("pass", "pwd", "pw:", "pw=", "mdp", "contrase", "clave", "kennwort", "senha", "wachtwoord",
73
+ "losenord", "salasana", "parola", "haslo")
74
+ # "(?<!by)": "bypass" is not a password.
75
+ _PASSWORD_RE = re.compile("|".join(("(?<!by)" if w == "pass" else "") + re.escape(w) for w in PASSWORD_WORDS),
76
+ re.IGNORECASE)
77
+ # Default descriptions of built-in AD / Exchange groups that mention passwords (found in every domain).
78
+ DEFAULT_DESCRIPTIONS = (
79
+ "members in this group can have their passwords replicated to all read-only domain controllers",
80
+ "members in this group cannot have their passwords replicated to any read-only domain controllers",
81
+ "this group contains all the exchange hosted organization mailboxes groups",
82
+ )
83
+ # Users scanned for a path to Tier Zero (kerberoastable, asreproast): one allShortestPaths query.
84
+ PATH_ENRICH_MAX = 500
85
+
86
+
87
+ @dataclass
88
+ class HuntOptions:
89
+ domain: str = ""
90
+ days: int = 1825
91
+ limit: int = 5000
92
+ now: float = field(default_factory=time.time)
93
+ # Scoped mode: objectid of each principal checked -> the owned (or --from) objects holding its rights.
94
+ principals: dict[str, list[str]] | None = None
95
+ # Run the extra queries some checks use to enrich their rows (skipped for the summary).
96
+ enrich: bool = True
97
+
98
+ @property
99
+ def scoped(self) -> bool:
100
+ return self.principals is not None
101
+
102
+ def dom(self, alias: str) -> str:
103
+ """A Cypher condition restricting `alias` to the selected domain ('' = all)."""
104
+ return Q.domain_condition(alias, self.domain)
105
+
106
+ def scope(self, alias: str) -> str:
107
+ """A Cypher condition restricting `alias` to the scoped principals ('' outside scoped mode)."""
108
+ if self.principals is None:
109
+ return ""
110
+ return f"{alias}.objectid IN [{', '.join(Q.lit(i) for i in sorted(self.principals))}]"
111
+
112
+ def matches(self, domain: str) -> bool:
113
+ """Whether `domain` (an FQDN) is in the selected domain, matched the same way as `dom`."""
114
+ if not self.domain:
115
+ return True
116
+ d, name = self.domain.upper(), domain.upper()
117
+ return name == d or ("." not in d and name.startswith(d + "."))
118
+
119
+
120
+ # Extra queries run after a check, filling in its rows; returns notes for the table.
121
+ Enricher = Callable[[EntityService, list["Row"], HuntOptions], list[str]]
122
+
123
+
124
+ @dataclass(frozen=True)
125
+ class Check:
126
+ id: str
127
+ title: str
128
+ severity: HuntSeverity
129
+ description: str
130
+ query: Callable[[HuntOptions], str]
131
+ columns: tuple[Column, ...]
132
+ rows: Callable[[Graph, HuntOptions], list[Row]]
133
+ fallback: Callable[[HuntOptions], str] | None = None
134
+ # Runs with --owned / --from (the query restricts its principals with `HuntOptions.scope`).
135
+ scoped: bool = False
136
+ # Columns shown instead of `columns` in scoped mode.
137
+ scoped_columns: tuple[Column, ...] | None = None
138
+ # Extra queries filling in the rows: only for details (not the summary).
139
+ enrich: Enricher | None = None
140
+ # Extra queries filtering the rows: always run.
141
+ refine: Enricher | None = None
142
+
143
+
144
+ @dataclass
145
+ class Finding:
146
+ check: Check
147
+ rows: list[Row] = field(default_factory=list)
148
+ truncated: bool = False
149
+ error: str = ""
150
+ # Server-side total when the row list was truncated (`None` if the server cannot count it).
151
+ total: int | None = None
152
+ notes: list[str] = field(default_factory=list)
153
+
154
+ def as_dict(self) -> dict[str, Any]:
155
+ return {"check": self.check.id, "title": self.check.title, "severity": self.check.severity,
156
+ "count": len(self.rows), "total": self.total, "truncated": self.truncated,
157
+ "error": self.error, "notes": self.notes, "rows": self.rows}
158
+
159
+
160
+ def node_row(n: Node, **extra: Any) -> Row:
161
+ return {"name": n.name, "kind": n.kind, "domain": n.domain, "objectid": n.objectid,
162
+ "enabled": n.properties.get("enabled"), "tier_zero": n.tier_zero, "owned": n.owned, **extra}
163
+
164
+
165
+ def edge_row(g: Graph, e: Edge, **extra: Any) -> Row:
166
+ src, tgt = g.nodes[e.source], g.nodes[e.target]
167
+ return {"principal": src.name, "principal_kind": src.kind, "edge": e.kind, "target": tgt.name,
168
+ "target_kind": tgt.kind, "domain": src.domain, "objectid": src.objectid,
169
+ "tier_zero": tgt.tier_zero, "owned": src.owned, **extra}
170
+
171
+
172
+ def enctypes(props: dict[str, Any]) -> str:
173
+ """Kerberos encryption types a user's tickets can use: RC4 hashes crack far faster than AES."""
174
+ raw = props.get("supportedencryptiontypes")
175
+ values = [str(v).upper() for v in (raw if isinstance(raw, list) else [raw] if raw else [])]
176
+ if not values or any("NOT DEFINED" in v for v in values):
177
+ return "default (RC4)"
178
+ if any("RC4" in v for v in values):
179
+ return "RC4"
180
+ if any("DES" in v for v in values):
181
+ return "DES"
182
+ return "AES only"
183
+
184
+
185
+ def _user_rows(g: Graph, _: HuntOptions) -> list[Row]:
186
+ rows = [node_row(n, pwdlastset=n.properties.get("pwdlastset"), lastlogon=last_logon(n.properties),
187
+ admincount=bool(n.properties.get("admincount")),
188
+ spns=len(n.properties.get("serviceprincipalnames") or []),
189
+ enctypes=enctypes(n.properties))
190
+ for n in g.nodes.values()
191
+ if n.kind == "User" and n.samaccountname.lower() != "krbtgt"]
192
+ # Oldest passwords first; unset (0 / missing) passwords last.
193
+ rows.sort(key=lambda r: (not r["tier_zero"], not r["admincount"], (r["pwdlastset"] or 0) <= 0,
194
+ r["pwdlastset"] or 0))
195
+ return rows
196
+
197
+
198
+ def _computer_rows(g: Graph, _: HuntOptions) -> list[Row]:
199
+ rows = [node_row(n, os=n.properties.get("operatingsystem") or "", lastlogon=last_logon(n.properties))
200
+ for n in g.nodes.values()]
201
+ rows.sort(key=lambda r: (not r["tier_zero"], r["os"], r["name"]))
202
+ return rows
203
+
204
+
205
+ NAME = Column("Name", "name")
206
+ DOMAIN = Column("Domain", "domain")
207
+ KIND = Column("Type", "kind", "kind")
208
+ T0 = Column("T0", "tier_zero", "t0")
209
+ OWNED = Column("Owned", "owned", "owned")
210
+ PWD = Column("Pwd last set", "pwdlastset", "date")
211
+ LOGON = Column("Last logon", "lastlogon", "date")
212
+ ADMINCOUNT = Column("AdminCount", "admincount", "bool")
213
+ OS = Column("Operating system", "os")
214
+ PRINCIPAL = Column("Principal", "principal")
215
+ PRINCIPAL_KIND = Column("Type", "principal_kind", "kind")
216
+ EDGE = Column("Right", "edge", "edge")
217
+ TARGET = Column("Target", "target")
218
+ TARGET_KIND = Column("Target type", "target_kind", "kind")
219
+ PATH_T0 = Column("Path to T0", "path_to_t0", "risk")
220
+ ENCTYPES = Column("Enc types", "enctypes")
221
+ FROM_OWNED = Column("From", "from_owned")
222
+
223
+
224
+ def _unconstrained_rows(g: Graph, _: HuntOptions) -> list[Row]:
225
+ rows = [node_row(n, os=n.properties.get("operatingsystem") or "") for n in g.nodes.values()]
226
+ return sorted(rows, key=lambda r: (r["kind"] != "User", r["name"]))
227
+
228
+
229
+ def _constrained(o: HuntOptions) -> str:
230
+ return (f"MATCH p=(n:Base)-[:AllowedToDelegate]->(t:Base){Q.where('n.enabled = true', o.scope('n'), o.dom('n'))} "
231
+ f"RETURN p LIMIT {o.limit}")
232
+
233
+
234
+ def _constrained_rows(g: Graph, _: HuntOptions) -> list[Row]:
235
+ rows = [edge_row(g, e, protocol_transition=bool(g.nodes[e.source].properties.get("trustedtoauth")))
236
+ for e in g.edges if e.kind == "AllowedToDelegate"]
237
+ return sorted(rows, key=lambda r: (not r["tier_zero"], not r["protocol_transition"], r["principal"]))
238
+
239
+
240
+ def _dcsync(o: HuntOptions) -> str:
241
+ return (f"MATCH p=(n:Base)-[:{Q.edge_types(DCSYNC_EDGES)}]->(d:Domain){Q.where(o.scope('n'), o.dom('d'))} "
242
+ f"RETURN p LIMIT {o.limit}")
243
+
244
+
245
+ def _dcsync_rows(g: Graph, _: HuntOptions) -> list[Row]:
246
+ by_principal: dict[tuple[str, str], set[str]] = {}
247
+ for e in g.edges:
248
+ if e.kind in DCSYNC_EDGES and not g.nodes[e.source].tier_zero:
249
+ by_principal.setdefault((e.source, e.target), set()).add(e.kind)
250
+ rows = []
251
+ for (src, dst), rights in by_principal.items():
252
+ full = "DCSync" in rights or {"GetChanges", "GetChangesAll"} <= rights
253
+ rows.append(node_row(g.nodes[src], rights=", ".join(sorted(rights)), target=g.nodes[dst].name,
254
+ full_dcsync=full))
255
+ return sorted(rows, key=lambda r: (not r["full_dcsync"], r["name"]))
256
+
257
+
258
+ def _broad(o: HuntOptions, edges: frozenset[str] | None = BROAD_RIGHT_EDGES) -> str:
259
+ sids = " OR ".join(f"g.objectid ENDS WITH {Q.lit(s)}" for s in BROAD_SIDS)
260
+ rel = f"[r:{Q.edge_types(edges)}]" if edges else "[r]"
261
+ return (f"MATCH p=(g:Base)-{rel}->(t:Base){Q.where(f'({sids})', o.scope('g'), o.dom('g'))} "
262
+ f"RETURN p LIMIT {o.limit}")
263
+
264
+
265
+ def _broad_rows(g: Graph, _: HuntOptions) -> list[Row]:
266
+ rows = [edge_row(g, e) for e in g.edges
267
+ if e.kind in BROAD_RIGHT_EDGES and g.nodes[e.source].objectid.upper().endswith(BROAD_SIDS)]
268
+ return sorted(rows, key=lambda r: (not r["tier_zero"], r["edge"], r["target"]))
269
+
270
+
271
+ def _node_check(label: str, *conditions: str) -> Callable[[HuntOptions], str]:
272
+ def build(o: HuntOptions) -> str:
273
+ return f"MATCH (n:{label}){Q.where(*conditions, o.scope('n'), o.dom('n'))} RETURN n LIMIT {o.limit}"
274
+ return build
275
+
276
+
277
+ def _edge_check(kinds: Iterable[str] | None, *conditions: str, target: str = "Base",
278
+ dom: str = "n") -> Callable[[HuntOptions], str]:
279
+ """`(n)-[kinds]->(t:target)` paths; `kinds=None` matches any relationship (a fallback for unknown kinds)."""
280
+ rel = f"[:{Q.edge_types(kinds)}]" if kinds is not None else "[]"
281
+
282
+ def build(o: HuntOptions) -> str:
283
+ return (f"MATCH p=(n:Base)-{rel}->(t:{target}){Q.where(*conditions, o.scope('n'), o.dom(dom))} "
284
+ f"RETURN p LIMIT {o.limit}")
285
+ return build
286
+
287
+
288
+ def _edge_rows(kinds: Iterable[str]) -> Callable[[Graph, HuntOptions], list[Row]]:
289
+ kinds = frozenset(kinds)
290
+
291
+ def rows(g: Graph, _: HuntOptions) -> list[Row]:
292
+ out = [edge_row(g, e) for e in g.edges if e.kind in kinds]
293
+ return sorted(out, key=lambda r: (not r["tier_zero"], severity_rank(r["edge"]), r["edge"], r["principal"]))
294
+ return rows
295
+
296
+
297
+ def _stale(o: HuntOptions) -> str:
298
+ cutoff = int(o.now - o.days * 86400)
299
+ where = Q.where("n.enabled = true", "n.pwdneverexpires = true", "n.pwdlastset > 0",
300
+ f"n.pwdlastset < {cutoff}", o.dom("n"))
301
+ return f"MATCH (n:User){where} RETURN n LIMIT {o.limit}"
302
+
303
+
304
+ def _trusts(_: HuntOptions) -> str:
305
+ return TRUSTS_QUERY
306
+
307
+
308
+ def _trust_rows(g: Graph, o: HuntOptions) -> list[Row]:
309
+ rows = []
310
+ for t in trusts_from_graph(g):
311
+ if not t.issues:
312
+ continue
313
+ if not (o.matches(t.trusting) or o.matches(t.trusted)):
314
+ continue
315
+ rows.append({"trusting": t.trusting, "trusted": t.trusted, "kind": t.kind,
316
+ "trust_type": t.trust_type, "issues": "; ".join(t.issues)})
317
+ return rows
318
+
319
+
320
+ _NOT_T0 = f"NOT n:{TIER_ZERO_LABEL}"
321
+ _LEGACY_OS_CONDITION = "(" + " OR ".join(
322
+ [f"n.operatingsystem CONTAINS {Q.lit(v)}" for v in LEGACY_OS]
323
+ + [f"(n.operatingsystem CONTAINS {Q.lit(WINDOWS_10)} AND "
324
+ + " AND ".join(f"NOT n.operatingsystem CONTAINS {Q.lit(v)}" for v in LTSC) + ")"]) + ")"
325
+ _PRIVILEGED_USER = f"(u:{TIER_ZERO_LABEL} OR u.admincount = true)"
326
+
327
+
328
+ # Path to Tier Zero (kerberoastable, asreproast)
329
+
330
+ def _path_to_t0(entities: EntityService, rows: list[Row], _: HuntOptions) -> list[str]:
331
+ """Mark the users with a path to Tier Zero, and list them first: their hashes are the ones to crack."""
332
+ candidates = [r["objectid"] for r in rows if not r["tier_zero"]]
333
+ if len(candidates) > PATH_ENRICH_MAX:
334
+ return [f"Path to Tier Zero not checked: more than {PATH_ENRICH_MAX} users."]
335
+ try:
336
+ reach, notes = PathService(entities).reaching_tier_zero(candidates)
337
+ except (ApiError, UnsupportedFeature) as exc:
338
+ return [f"Path to Tier Zero not checked: {exc}"]
339
+ for r in rows:
340
+ r["path_to_t0"] = r["tier_zero"] or r["objectid"] in reach
341
+ rows.sort(key=lambda r: (not r["path_to_t0"], r["enctypes"] == "AES only")) # stable: keeps the age order
342
+ return notes
343
+
344
+
345
+ # Credential exposure
346
+
347
+ def _t0_sessions(o: HuntOptions) -> str:
348
+ conds = (_PRIVILEGED_USER, f"NOT c:{TIER_ZERO_LABEL}", o.dom("c"))
349
+ if o.scoped:
350
+ return (f"MATCH p=(n:Base)-[:{Q.edge_types(EXEC_EDGES)}]->(c:Computer)-[:{HAS_SESSION}]->(u:User)"
351
+ f"{Q.where(o.scope('n'), *conds)} RETURN p LIMIT {o.limit}")
352
+ return f"MATCH p=(c:Computer)-[:{HAS_SESSION}]->(u:User){Q.where(*conds)} RETURN p LIMIT {o.limit}"
353
+
354
+
355
+ def _t0_session_rows(g: Graph, o: HuntOptions) -> list[Row]:
356
+ sessions: dict[str, list[Node]] = {}
357
+ for e in g.edges:
358
+ if e.kind == HAS_SESSION:
359
+ sessions.setdefault(e.source, []).append(g.nodes[e.target])
360
+
361
+ def row(c: Node, u: Node, **extra: Any) -> Row:
362
+ return {"name": c.name, "os": c.properties.get("operatingsystem") or "", "user": u.name,
363
+ "tier_zero": u.tier_zero, "admincount": bool(u.properties.get("admincount")),
364
+ "domain": c.domain, "objectid": c.objectid, **extra}
365
+
366
+ if o.scoped:
367
+ rows = [row(g.nodes[e.target], u, access=e.kind, principal=g.nodes[e.source].name,
368
+ objectid=g.nodes[e.source].objectid)
369
+ for e in g.edges if e.kind in EXEC_EDGES for u in sessions.get(e.target, [])]
370
+ else:
371
+ rows = [row(g.nodes[c], u) for c, users in sessions.items() for u in users]
372
+ return sorted(rows, key=lambda r: (not r["tier_zero"], r["name"], r["user"]))
373
+
374
+
375
+ def _password_attrs(o: HuntOptions, *, lower: bool = True) -> str:
376
+ if lower:
377
+ words = [f"toLower(n.description) CONTAINS {Q.lit(w)}" for w in PASSWORD_WORDS]
378
+ else: # servers without toLower(): the usual spellings
379
+ variants = dict.fromkeys(v for w in PASSWORD_WORDS for v in (w, w.capitalize(), w.upper()))
380
+ words = [f"n.description CONTAINS {Q.lit(v)}" for v in variants]
381
+ attrs = [f"n.{a} IS NOT NULL" for a in PASSWORD_ATTRS]
382
+ return f"MATCH (n:Base){Q.where('(' + ' OR '.join(attrs + words) + ')', o.dom('n'))} RETURN n LIMIT {o.limit}"
383
+
384
+
385
+ def _password_rows(g: Graph, _: HuntOptions) -> list[Row]:
386
+ rows = []
387
+ for n in g.nodes.values():
388
+ for attr in PASSWORD_ATTRS:
389
+ value = n.properties.get(attr)
390
+ if value not in (None, "", []):
391
+ rows.append(node_row(n, attribute=attr, value=str(value)))
392
+ description = str(n.properties.get("description") or "")
393
+ if _PASSWORD_RE.search(description) and not description.lower().startswith(DEFAULT_DESCRIPTIONS):
394
+ rows.append(node_row(n, attribute="description", value=description))
395
+ return sorted(rows, key=lambda r: (r["attribute"] == "description", not r["tier_zero"], r["name"]))
396
+
397
+
398
+ # NTLM relay
399
+
400
+ _NTLM_ISSUES = (
401
+ ("smbsigning", False, "SMB signing not required"),
402
+ ("webclientrunning", True, "WebClient running (HTTP coercion)"),
403
+ ("ldapsigning", False, "LDAP signing not required"),
404
+ ("ldapsepa", False, "LDAPS channel binding not required"),
405
+ )
406
+
407
+
408
+ def _ntlm(o: HuntOptions) -> str:
409
+ issues = " OR ".join(f"n.{prop} = {str(bad).lower()}" for prop, bad, _ in _NTLM_ISSUES)
410
+ return f"MATCH (n:Computer){Q.where('n.enabled = true', f'({issues})', o.dom('n'))} RETURN n LIMIT {o.limit}"
411
+
412
+
413
+ def _ntlm_rows(g: Graph, _: HuntOptions) -> list[Row]:
414
+ rows = []
415
+ for n in g.nodes.values():
416
+ issues = [text for prop, bad, text in _NTLM_ISSUES if n.properties.get(prop) is bad]
417
+ rows.append(node_row(n, os=n.properties.get("operatingsystem") or "", issues="; ".join(issues)))
418
+ return sorted(rows, key=lambda r: (not r["tier_zero"], r["name"]))
419
+
420
+
421
+ # Object takeover
422
+
423
+ def _takeover_rows(g: Graph, _: HuntOptions) -> list[Row]:
424
+ rows = [edge_row(g, e, technique=TAKEOVER[e.kind],
425
+ admincount=bool(g.nodes[e.target].properties.get("admincount")))
426
+ for e in g.edges if e.kind in TAKEOVER]
427
+ return sorted(rows, key=lambda r: (not r["admincount"], r["technique"], r["target"], r["principal"]))
428
+
429
+
430
+ def _reenable_rows(g: Graph, _: HuntOptions) -> list[Row]:
431
+ rows = [edge_row(g, e, admincount=bool(g.nodes[e.target].properties.get("admincount")))
432
+ for e in g.edges if e.kind in ENABLE_EDGES]
433
+ return sorted(rows, key=lambda r: (not r["tier_zero"], r["target"], severity_rank(r["edge"])))
434
+
435
+
436
+ def _foreign_rows(g: Graph, _: HuntOptions) -> list[Row]:
437
+ rows = [edge_row(g, e, target_domain=g.nodes[e.target].domain)
438
+ for e in g.edges if e.kind in FOREIGN_EDGES and g.nodes[e.source].domain != g.nodes[e.target].domain]
439
+ return sorted(rows, key=lambda r: (not r["tier_zero"], r["edge"] == MEMBER_OF, r["principal"]))
440
+
441
+
442
+ # OPSEC
443
+
444
+ def _decoys(o: HuntOptions) -> str:
445
+ never = ("(n.lastlogon IS NULL OR n.lastlogon <= 0)",
446
+ "(n.lastlogontimestamp IS NULL OR n.lastlogontimestamp <= 0)")
447
+ lure = "(n.hasspn = true OR n.dontreqpreauth = true OR n.admincount = true)"
448
+ where = Q.where("n.enabled = true", lure, *never, "n.samaccountname <> 'krbtgt'", o.dom("n"))
449
+ return f"MATCH (n:User){where} RETURN n LIMIT {o.limit}"
450
+
451
+
452
+ def _decoy_rows(g: Graph, o: HuntOptions) -> list[Row]:
453
+ rows = _user_rows(g, o)
454
+ for r, n in zip(rows, (g.find(r["objectid"]) for r in rows), strict=True):
455
+ props = n.properties if n else {}
456
+ lures = [label for prop, label in (("hasspn", "SPN"), ("dontreqpreauth", "no preauth"),
457
+ ("admincount", "adminCount")) if props.get(prop)]
458
+ r.update(lures=", ".join(lures), created=props.get("whencreated"))
459
+ return rows
460
+
461
+
462
+ def _drop_protected_users(entities: EntityService, rows: list[Row], o: HuntOptions) -> list[str]:
463
+ """Drop members of Protected Users: their tickets cannot be delegated either."""
464
+ query = (f"MATCH p=(m:User)-[:{MEMBER_OF}*1..]->(g:Group) "
465
+ f"WHERE g.objectid ENDS WITH {Q.lit(PROTECTED_USERS_RID)} RETURN p")
466
+ try:
467
+ members = entities.run_cypher(query)
468
+ except ApiError as exc:
469
+ return [f"Protected Users members not excluded: {exc}"]
470
+ protected = {n.objectid for n in members.nodes.values() if n.kind == "User"}
471
+ kept = [r for r in rows if r["objectid"] not in protected]
472
+ dropped = len(rows) - len(kept)
473
+ rows[:] = kept
474
+ return [f"{dropped} Protected Users member(s) left out."] if dropped else []
475
+
476
+
477
+ CHECKS: tuple[Check, ...] = (
478
+ Check("dcsync", "Non-Tier-Zero principals with replication rights", "critical",
479
+ "DCSync / GetChanges(All) on a domain held by principals outside Tier Zero.",
480
+ _dcsync, (NAME, KIND, Column("Rights", "rights"), Column("Domain", "target"),
481
+ Column("Full DCSync", "full_dcsync", "risk"), OWNED), _dcsync_rows, scoped=True),
482
+ Check("tier-zero-control", "Control of Tier Zero from outside it", "critical",
483
+ "Non-Tier-Zero principals with ACL, delegation, credential-read or execution rights on Tier Zero "
484
+ "objects (DCSync, SID history and AD CS have their own checks).",
485
+ _edge_check(T0_CONTROL_EDGES, _NOT_T0, target=TIER_ZERO_LABEL, dom="t"),
486
+ (PRINCIPAL, PRINCIPAL_KIND, EDGE, TARGET, TARGET_KIND, OWNED), _edge_rows(T0_CONTROL_EDGES),
487
+ fallback=_edge_check(None, _NOT_T0, target=TIER_ZERO_LABEL, dom="t"), scoped=True),
488
+ Check("broad-rights", "Dangerous rights held by broad groups", "critical",
489
+ "Everyone / Anonymous / Authenticated Users / Domain Users / Domain Computers / Users / "
490
+ "Pre-Windows 2000 Compatible Access holding control or execution rights.",
491
+ _broad, (PRINCIPAL, EDGE, TARGET, TARGET_KIND, T0), _broad_rows,
492
+ fallback=lambda o: _broad(o, None), scoped=True),
493
+ Check("adcs", "AD CS escalation paths (ESC*, GoldenCert)", "critical",
494
+ "Principals with a certificate-services escalation edge, including NTLM relay to web enrollment (ESC8).",
495
+ _edge_check(ADCS_ABUSE_EDGES), (PRINCIPAL, PRINCIPAL_KIND, EDGE, TARGET, T0),
496
+ _edge_rows(ADCS_ABUSE_EDGES), scoped=True),
497
+ Check("adcs-ca-control", "Enterprise CA managed from outside Tier Zero (ESC7)", "high",
498
+ "ManageCA / ManageCertificates on an Enterprise CA held by principals outside Tier Zero.",
499
+ _edge_check(CA_CONTROL_EDGES, _NOT_T0, target="EnterpriseCA", dom="t"),
500
+ (PRINCIPAL, PRINCIPAL_KIND, EDGE, TARGET, OWNED), _edge_rows(CA_CONTROL_EDGES), scoped=True),
501
+ Check("kerberoastable", "Kerberoastable users", "high",
502
+ "Enabled users with an SPN (excluding krbtgt); those with a path to Tier Zero first, then RC4, "
503
+ "then the oldest passwords.",
504
+ _node_check("User", "n.hasspn = true", "n.enabled = true"),
505
+ (NAME, DOMAIN, PWD, ADMINCOUNT, Column("SPNs", "spns"), ENCTYPES, PATH_T0, T0, OWNED), _user_rows,
506
+ enrich=_path_to_t0),
507
+ Check("asreproast", "AS-REP roastable users", "high",
508
+ "Enabled users without Kerberos pre-authentication; those with a path to Tier Zero first.",
509
+ _node_check("User", "n.dontreqpreauth = true", "n.enabled = true"),
510
+ (NAME, DOMAIN, PWD, ADMINCOUNT, ENCTYPES, PATH_T0, T0, OWNED), _user_rows, enrich=_path_to_t0),
511
+ Check("t0-sessions", "Privileged sessions on non-Tier-Zero computers", "high",
512
+ "Tier Zero or adminCount users logged on to computers outside Tier Zero (credential dumping "
513
+ "targets). Scoped: those computers where the principals can execute code.",
514
+ _t0_sessions, (NAME, OS, Column("Session user", "user"), T0, ADMINCOUNT), _t0_session_rows,
515
+ scoped=True, scoped_columns=(NAME, OS, Column("Session user", "user"), T0, ADMINCOUNT,
516
+ Column("Access", "access", "edge"), PRINCIPAL)),
517
+ Check("cred-readers", "Readable LAPS / gMSA / sMSA passwords", "high",
518
+ "Principals outside Tier Zero that can read LAPS, gMSA or sMSA passwords.",
519
+ _edge_check(CRED_READ_EDGES, _NOT_T0), (PRINCIPAL, PRINCIPAL_KIND, EDGE, TARGET, TARGET_KIND, T0),
520
+ _edge_rows(CRED_READ_EDGES), scoped=True),
521
+ Check("passwords-in-attributes", "Passwords in AD attributes", "high",
522
+ "Objects with userPassword / unixUserPassword / unicodePwd / msSFU30Password set, or a description "
523
+ "mentioning a password.",
524
+ _password_attrs, (NAME, KIND, DOMAIN, Column("Attribute", "attribute"), Column("Value", "value"), T0,
525
+ OWNED), _password_rows, fallback=lambda o: _password_attrs(o, lower=False)),
526
+ Check("object-takeover", "Account and computer takeover primitives", "high",
527
+ "Shadow Credentials (AddKeyCredentialLink), targeted Kerberoasting (WriteSPN) and RBCD "
528
+ "(AddAllowedToAct, WriteAccountRestrictions) between non-Tier-Zero objects.",
529
+ _edge_check(TAKEOVER_EDGES, _NOT_T0, f"NOT t:{TIER_ZERO_LABEL}", "t.enabled = true"),
530
+ (PRINCIPAL, PRINCIPAL_KIND, EDGE, Column("Technique", "technique"), TARGET, TARGET_KIND,
531
+ Column("Target adminCount", "admincount", "bool")), _takeover_rows, scoped=True),
532
+ Check("gpo-control", "GPO and OU link control", "high",
533
+ "Principals outside Tier Zero that can edit a GPO or link GPOs to an OU (code execution on every "
534
+ "computer below it).",
535
+ _edge_check(GPO_CONTROL_EDGES, _NOT_T0, "(t:GPO OR t:OU)"),
536
+ (PRINCIPAL, PRINCIPAL_KIND, EDGE, TARGET, TARGET_KIND, T0), _edge_rows(GPO_CONTROL_EDGES),
537
+ fallback=_edge_check(None, _NOT_T0, "(t:GPO OR t:OU)"), scoped=True),
538
+ Check("relay", "NTLM coercion and relay", "high",
539
+ "CoerceAndRelayNTLMTo SMB / LDAP / LDAPS edges (relay to AD CS is in 'adcs').",
540
+ _edge_check(RELAY_EDGES, dom="t"), (PRINCIPAL, EDGE, TARGET, TARGET_KIND, T0), _edge_rows(RELAY_EDGES)),
541
+ Check("foreign-access", "Cross-domain memberships and rights", "high",
542
+ "Non-Tier-Zero principals with membership, control or execution rights in another domain.",
543
+ _edge_check(FOREIGN_EDGES, _NOT_T0, "n.domain <> t.domain", dom="t"),
544
+ (PRINCIPAL, PRINCIPAL_KIND, DOMAIN, EDGE, TARGET, Column("Target domain", "target_domain"), T0),
545
+ _foreign_rows, scoped=True),
546
+ Check("unconstrained", "Unconstrained delegation outside Tier Zero", "high",
547
+ "Enabled users/computers trusted for unconstrained delegation (DCs excluded).",
548
+ _node_check("Base", "n.unconstraineddelegation = true", "n.enabled = true", _NOT_T0),
549
+ (NAME, KIND, DOMAIN, OS, OWNED), _unconstrained_rows, scoped=True),
550
+ Check("sidhistory", "SID history", "high",
551
+ "Principals carrying the SID of another object (HasSIDHistory).",
552
+ _edge_check(("HasSIDHistory",)), (PRINCIPAL, PRINCIPAL_KIND, TARGET, TARGET_KIND, T0),
553
+ _edge_rows(("HasSIDHistory",)), scoped=True),
554
+ Check("trust-sid-filtering", "Trusts with SID filtering disabled or abusable", "high",
555
+ "Same-forest trusts (no SID filtering by default), cross-forest trusts with SID filtering "
556
+ "explicitly disabled, or trusts with SpoofSIDHistory / AbuseTGTDelegation edges.",
557
+ _trusts, (Column("Trusting", "trusting"), Column("Trusted", "trusted"), Column("Kind", "kind"),
558
+ Column("Type", "trust_type"), Column("Issues", "issues")), _trust_rows),
559
+ Check("constrained", "Constrained delegation", "medium",
560
+ "AllowedToDelegate edges; protocol transition (trustedtoauth) needs no victim interaction.",
561
+ _constrained, (PRINCIPAL, PRINCIPAL_KIND, TARGET, Column("Protocol transition", "protocol_transition",
562
+ "risk"), T0), _constrained_rows, scoped=True),
563
+ Check("reenable", "Disabled privileged accounts that can be re-enabled", "medium",
564
+ "Disabled Tier Zero or adminCount users on which principals outside Tier Zero hold a right to "
565
+ "re-enable them.",
566
+ _edge_check(ENABLE_EDGES, _NOT_T0, "t.enabled = false", f"(t:{TIER_ZERO_LABEL} OR t.admincount = true)",
567
+ target="User", dom="t"),
568
+ (PRINCIPAL, PRINCIPAL_KIND, EDGE, TARGET, T0, Column("Target adminCount", "admincount", "bool")),
569
+ _reenable_rows, scoped=True),
570
+ Check("impersonable-admins", "Tier Zero users open to delegation", "medium",
571
+ "Enabled Tier Zero users not marked sensitive and not in Protected Users: constrained delegation, "
572
+ "RBCD and S4U can impersonate them.",
573
+ _node_check("User", f"n:{TIER_ZERO_LABEL}", "n.enabled = true",
574
+ "(n.sensitive IS NULL OR n.sensitive = false)"),
575
+ (NAME, DOMAIN, PWD, LOGON, ADMINCOUNT), _user_rows, refine=_drop_protected_users),
576
+ Check("ntlm-hardening", "Missing NTLM relay protections", "medium",
577
+ "Enabled computers without SMB signing, running WebClient, or DCs not requiring LDAP signing or "
578
+ "LDAPS channel binding.",
579
+ _ntlm, (NAME, DOMAIN, OS, Column("Issues", "issues"), T0), _ntlm_rows),
580
+ Check("stale-passwords", "Old passwords that never expire", "medium",
581
+ "Enabled users with pwdneverexpires and a password older than --days (default 5 years).",
582
+ _stale, (NAME, DOMAIN, PWD, LOGON, ADMINCOUNT, T0), _user_rows),
583
+ Check("pwd-not-required", "Password not required", "medium",
584
+ "Enabled users with PASSWD_NOTREQD (may have an empty password).",
585
+ _node_check("User", "n.passwordnotreqd = true", "n.enabled = true"),
586
+ (NAME, DOMAIN, PWD, LOGON, T0), _user_rows),
587
+ Check("legacy-os", "End-of-life operating systems", "medium",
588
+ "Enabled computers running Windows 2000/2003/2008/2012, XP, Vista, 7, 8 or 10 (except LTSC).",
589
+ _node_check("Computer", "n.enabled = true", _LEGACY_OS_CONDITION),
590
+ (NAME, DOMAIN, OS, LOGON, T0), _computer_rows),
591
+ Check("admincount-orphans", "AdminCount users outside Tier Zero", "low",
592
+ "adminCount=1 users no longer in a protected group (stale ACLs, AdminSDHolder leftovers).",
593
+ _node_check("User", "n.admincount = true", _NOT_T0),
594
+ (NAME, DOMAIN, PWD, LOGON), _user_rows),
595
+ Check("no-laps", "Computers without LAPS", "low",
596
+ "Enabled non-Tier-Zero computers without LAPS (shared local admin passwords likely).",
597
+ _node_check("Computer", "n.enabled = true", "n.haslaps = false", _NOT_T0),
598
+ (NAME, DOMAIN, OS, LOGON), _computer_rows),
599
+ Check("decoys", "Possible honeytokens", "info",
600
+ "Enabled users that look attractive (SPN, no pre-authentication or adminCount) but have never "
601
+ "logged on: check before roasting or using them.",
602
+ _decoys, (NAME, DOMAIN, Column("Lures", "lures"), Column("Created", "created", "date"), PWD, T0),
603
+ _decoy_rows),
604
+ )
605
+ CHECKS_BY_ID: dict[str, Check] = {c.id: c for c in CHECKS}
606
+ SCOPED_CHECKS: tuple[Check, ...] = tuple(c for c in CHECKS if c.scoped)
607
+
608
+
609
+ class HuntService:
610
+ def __init__(self, entities: EntityService) -> None:
611
+ self.entities = entities
612
+
613
+ def run(self, check: Check, opts: HuntOptions) -> Finding:
614
+ if opts.scoped and not check.scoped:
615
+ raise UnsupportedFeature(f"'{check.id}' does not run with --owned / --from")
616
+ try:
617
+ graph = self._query(check, opts)
618
+ except CypherDisabledError as exc:
619
+ raise UnsupportedFeature("'hunt' needs the Cypher endpoint") from exc
620
+ except ApiError as exc:
621
+ return Finding(check, error=exc.message)
622
+ truncated, total = self.entities.truncation(graph)
623
+ finding = Finding(check, rows=check.rows(graph, opts), truncated=truncated, total=total)
624
+ if check.refine and finding.rows:
625
+ finding.notes += check.refine(self.entities, finding.rows, opts)
626
+ if check.enrich and opts.enrich and finding.rows:
627
+ finding.notes += check.enrich(self.entities, finding.rows, opts)
628
+ if opts.principals is not None:
629
+ for row in finding.rows:
630
+ row["from_owned"] = ", ".join(opts.principals.get(row.get("objectid", ""), []))
631
+ return finding
632
+
633
+ def _query(self, check: Check, opts: HuntOptions) -> Graph:
634
+ try:
635
+ return self.entities.run_cypher(check.query(opts))
636
+ except ApiError:
637
+ if check.fallback is None:
638
+ raise
639
+ return self.entities.run_cypher(check.fallback(opts))
640
+
641
+ def owned_scope(self) -> dict[str, list[str]]:
642
+ """Owned objects and the groups they belong to, each mapped to the owned objects behind it."""
643
+ owned = self._cypher(f"MATCH (n:{OWNED_LABEL}) RETURN n")
644
+ memberships = self._cypher(f"MATCH p=(n:{OWNED_LABEL})-[:{MEMBER_OF}*1..]->(g:Group) RETURN p")
645
+ return _scope(owned.nodes.values(), memberships)
646
+
647
+ def principal_scope(self, node: Node) -> dict[str, list[str]]:
648
+ """`node` and the groups it belongs to, each mapped to `node`."""
649
+ return _scope([node], self._cypher(Q.member_of(node.objectid)))
650
+
651
+ def _cypher(self, query: str) -> Graph:
652
+ try:
653
+ return self.entities.run_cypher(query)
654
+ except CypherDisabledError as exc:
655
+ raise UnsupportedFeature("'hunt' needs the Cypher endpoint") from exc
656
+
657
+
658
+ def _scope(starts: Iterable[Node], memberships: Graph) -> dict[str, list[str]]:
659
+ groups: dict[str, list[str]] = {}
660
+ for e in memberships.edges:
661
+ if e.kind == MEMBER_OF:
662
+ groups.setdefault(e.source, []).append(e.target)
663
+ by_objectid = {n.objectid: nid for nid, n in memberships.nodes.items()}
664
+ scope: dict[str, list[str]] = {}
665
+ for start in starts:
666
+ scope.setdefault(start.objectid, []).append(start.name)
667
+ seen: set[str] = set()
668
+ todo = [by_objectid[start.objectid]] if start.objectid in by_objectid else []
669
+ while todo:
670
+ for group in groups.get(todo.pop(), []):
671
+ if group not in seen:
672
+ seen.add(group)
673
+ todo.append(group)
674
+ scope.setdefault(memberships.nodes[group].objectid, []).append(start.name)
675
+ return scope