bhshell 0.1.1__tar.gz → 0.2.0__tar.gz

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 (83) hide show
  1. {bhshell-0.1.1/bhshell.egg-info → bhshell-0.2.0}/PKG-INFO +13 -8
  2. {bhshell-0.1.1 → bhshell-0.2.0}/README.md +12 -7
  3. {bhshell-0.1.1 → bhshell-0.2.0}/bhshell/__init__.py +1 -1
  4. {bhshell-0.1.1 → bhshell-0.2.0}/bhshell/client/client.py +2 -0
  5. {bhshell-0.1.1 → bhshell-0.2.0}/bhshell/config.py +17 -1
  6. {bhshell-0.1.1 → bhshell-0.2.0}/bhshell/models/entities.py +133 -4
  7. {bhshell-0.1.1 → bhshell-0.2.0}/bhshell/models/results.py +67 -9
  8. bhshell-0.2.0/bhshell/queries/adcs.py +247 -0
  9. {bhshell-0.1.1 → bhshell-0.2.0}/bhshell/queries/containers.py +100 -32
  10. {bhshell-0.1.1 → bhshell-0.2.0}/bhshell/queries/cypher.py +59 -1
  11. {bhshell-0.1.1 → bhshell-0.2.0}/bhshell/queries/domains.py +44 -5
  12. {bhshell-0.1.1 → bhshell-0.2.0}/bhshell/queries/edges.py +24 -2
  13. bhshell-0.2.0/bhshell/queries/gpos.py +275 -0
  14. {bhshell-0.1.1 → bhshell-0.2.0}/bhshell/queries/hunt.py +67 -15
  15. {bhshell-0.1.1 → bhshell-0.2.0}/bhshell/queries/marking.py +7 -1
  16. {bhshell-0.1.1 → bhshell-0.2.0}/bhshell/queries/paths.py +21 -9
  17. {bhshell-0.1.1 → bhshell-0.2.0}/bhshell/queries/search.py +8 -3
  18. {bhshell-0.1.1 → bhshell-0.2.0}/bhshell/queries/service.py +126 -27
  19. {bhshell-0.1.1 → bhshell-0.2.0}/bhshell/render/export.py +11 -1
  20. {bhshell-0.1.1 → bhshell-0.2.0}/bhshell/render/views.py +28 -7
  21. {bhshell-0.1.1 → bhshell-0.2.0}/bhshell/shell/commands/base.py +10 -1
  22. {bhshell-0.1.1 → bhshell-0.2.0}/bhshell/shell/commands/cypher.py +4 -4
  23. {bhshell-0.1.1 → bhshell-0.2.0}/bhshell/shell/commands/domains.py +13 -4
  24. {bhshell-0.1.1 → bhshell-0.2.0}/bhshell/shell/commands/entities.py +90 -20
  25. {bhshell-0.1.1 → bhshell-0.2.0}/bhshell/shell/commands/hunt.py +3 -1
  26. {bhshell-0.1.1 → bhshell-0.2.0}/bhshell/shell/commands/marking.py +3 -1
  27. {bhshell-0.1.1 → bhshell-0.2.0}/bhshell/shell/commands/search.py +1 -0
  28. {bhshell-0.1.1 → bhshell-0.2.0/bhshell.egg-info}/PKG-INFO +13 -8
  29. {bhshell-0.1.1 → bhshell-0.2.0}/bhshell.egg-info/SOURCES.txt +3 -0
  30. {bhshell-0.1.1 → bhshell-0.2.0}/docs/USAGE.md +192 -57
  31. bhshell-0.2.0/tests/test_adcs.py +260 -0
  32. {bhshell-0.1.1 → bhshell-0.2.0}/tests/test_analysis.py +49 -0
  33. {bhshell-0.1.1 → bhshell-0.2.0}/tests/test_containers.py +148 -1
  34. {bhshell-0.1.1 → bhshell-0.2.0}/tests/test_disabled_paths.py +20 -6
  35. {bhshell-0.1.1 → bhshell-0.2.0}/tests/test_hunt.py +16 -0
  36. {bhshell-0.1.1 → bhshell-0.2.0}/tests/test_search.py +12 -2
  37. {bhshell-0.1.1 → bhshell-0.2.0}/tests/test_service.py +87 -4
  38. {bhshell-0.1.1 → bhshell-0.2.0}/tests/test_shell.py +38 -1
  39. {bhshell-0.1.1 → bhshell-0.2.0}/LICENSE +0 -0
  40. {bhshell-0.1.1 → bhshell-0.2.0}/MANIFEST.in +0 -0
  41. {bhshell-0.1.1 → bhshell-0.2.0}/bhshell/__main__.py +0 -0
  42. {bhshell-0.1.1 → bhshell-0.2.0}/bhshell/cli.py +0 -0
  43. {bhshell-0.1.1 → bhshell-0.2.0}/bhshell/client/__init__.py +0 -0
  44. {bhshell-0.1.1 → bhshell-0.2.0}/bhshell/client/auth.py +0 -0
  45. {bhshell-0.1.1 → bhshell-0.2.0}/bhshell/errors.py +0 -0
  46. {bhshell-0.1.1 → bhshell-0.2.0}/bhshell/library/asrep-roastable-users.cyp +0 -0
  47. {bhshell-0.1.1 → bhshell-0.2.0}/bhshell/library/dcsync-principals.cyp +0 -0
  48. {bhshell-0.1.1 → bhshell-0.2.0}/bhshell/library/domain-admins-members.cyp +0 -0
  49. {bhshell-0.1.1 → bhshell-0.2.0}/bhshell/library/domain-trusts.cyp +0 -0
  50. {bhshell-0.1.1 → bhshell-0.2.0}/bhshell/library/gpos-linked-to-domain-controllers.cyp +0 -0
  51. {bhshell-0.1.1 → bhshell-0.2.0}/bhshell/library/kerberoastable-users.cyp +0 -0
  52. {bhshell-0.1.1 → bhshell-0.2.0}/bhshell/library/owned-objects.cyp +0 -0
  53. {bhshell-0.1.1 → bhshell-0.2.0}/bhshell/library/owned-to-tier-zero.cyp +0 -0
  54. {bhshell-0.1.1 → bhshell-0.2.0}/bhshell/library/sid-history.cyp +0 -0
  55. {bhshell-0.1.1 → bhshell-0.2.0}/bhshell/library/tier-zero-users.cyp +0 -0
  56. {bhshell-0.1.1 → bhshell-0.2.0}/bhshell/library/unconstrained-delegation.cyp +0 -0
  57. {bhshell-0.1.1 → bhshell-0.2.0}/bhshell/models/__init__.py +0 -0
  58. {bhshell-0.1.1 → bhshell-0.2.0}/bhshell/queries/__init__.py +0 -0
  59. {bhshell-0.1.1 → bhshell-0.2.0}/bhshell/queries/library.py +0 -0
  60. {bhshell-0.1.1 → bhshell-0.2.0}/bhshell/render/__init__.py +0 -0
  61. {bhshell-0.1.1 → bhshell-0.2.0}/bhshell/render/graph.py +0 -0
  62. {bhshell-0.1.1 → bhshell-0.2.0}/bhshell/render/layout.py +0 -0
  63. {bhshell-0.1.1 → bhshell-0.2.0}/bhshell/render/png.py +0 -0
  64. {bhshell-0.1.1 → bhshell-0.2.0}/bhshell/render/styles.py +0 -0
  65. {bhshell-0.1.1 → bhshell-0.2.0}/bhshell/render/svg.py +0 -0
  66. {bhshell-0.1.1 → bhshell-0.2.0}/bhshell/shell/__init__.py +0 -0
  67. {bhshell-0.1.1 → bhshell-0.2.0}/bhshell/shell/app.py +0 -0
  68. {bhshell-0.1.1 → bhshell-0.2.0}/bhshell/shell/commands/__init__.py +0 -0
  69. {bhshell-0.1.1 → bhshell-0.2.0}/bhshell/shell/commands/connection.py +0 -0
  70. {bhshell-0.1.1 → bhshell-0.2.0}/bhshell/shell/commands/library.py +0 -0
  71. {bhshell-0.1.1 → bhshell-0.2.0}/bhshell/shell/commands/paths.py +0 -0
  72. {bhshell-0.1.1 → bhshell-0.2.0}/bhshell.egg-info/dependency_links.txt +0 -0
  73. {bhshell-0.1.1 → bhshell-0.2.0}/bhshell.egg-info/entry_points.txt +0 -0
  74. {bhshell-0.1.1 → bhshell-0.2.0}/bhshell.egg-info/requires.txt +0 -0
  75. {bhshell-0.1.1 → bhshell-0.2.0}/bhshell.egg-info/top_level.txt +0 -0
  76. {bhshell-0.1.1 → bhshell-0.2.0}/pyproject.toml +0 -0
  77. {bhshell-0.1.1 → bhshell-0.2.0}/setup.cfg +0 -0
  78. {bhshell-0.1.1 → bhshell-0.2.0}/tests/__init__.py +0 -0
  79. {bhshell-0.1.1 → bhshell-0.2.0}/tests/conftest.py +0 -0
  80. {bhshell-0.1.1 → bhshell-0.2.0}/tests/test_client.py +0 -0
  81. {bhshell-0.1.1 → bhshell-0.2.0}/tests/test_graph.py +0 -0
  82. {bhshell-0.1.1 → bhshell-0.2.0}/tests/test_png.py +0 -0
  83. {bhshell-0.1.1 → bhshell-0.2.0}/tests/test_svg.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: bhshell
3
- Version: 0.1.1
3
+ Version: 0.2.0
4
4
  Summary: Interactive shell for BloodHound
5
5
  Author-email: kr0no <kr0no@ka0labs.net>
6
6
  License-Expression: MIT
@@ -62,17 +62,20 @@ $ bhshell --url http://127.0.0.1:8080
62
62
  [+] Logged in as admin on 127.0.0.1:8080
63
63
 
64
64
  [bh: shell] > hunt
65
- Severity Check Count Example Description
66
- critical dcsync 2 SVC_SQL@CORP.LOCAL Non-Tier-Zero principals with replication rights
67
- high kerberoastable 2 JDOE@CORP.LOCAL Kerberoastable users
65
+ Severity Check Count Example Description
66
+ critical dcsync 4 DCSYNCERS@PHANTOM.CORP Non-Tier-Zero principals with replication rights
67
+ critical adcs 95 ALICE@PHANTOM.CORP AD CS escalation paths (ESC*, GoldenCert)
68
+ high kerberoastable 2 ADMINISTRATOR@PHANTOM.CORP Kerberoastable users
68
69
  ...
69
- [*] Details: 'hunt CHECK' (e.g. 'hunt kerberoastable'), or 'hunt --all'.
70
+ [*] Details: 'hunt CHECK' (e.g. 'hunt dcsync'), or 'hunt --all'.
70
71
 
71
- [bh: shell] > user CORP\svc_sql --all -o svc_sql.csv
72
+ [bh: shell] > user PHANTOM\svc_kerb_del --all -o svc_kerb_del.csv
72
73
 
73
- [bh: shell] > ou corp.local -t
74
+ [bh: shell] > ou phantom.corp -t
74
75
 
75
- [bh: shell] > computer WEB01 -G
76
+ [bh: shell] > computer DF-MS-SHS -G
77
+
78
+ [bh: shell] > gpo 'ENABLE AV@phantom.corp' -c
76
79
 
77
80
  [bh: shell] > path --owned -g
78
81
  ```
@@ -85,6 +88,8 @@ The same commands run non-interactively for scripting: `bhshell -c "hunt --json"
85
88
  |---|---|
86
89
  | `user`, `computer`, `group`, `lookup` | Kerberoastable / AS-REP roastable, delegation, password age, Tier Zero and owned flags, plus group nesting, inbound/outbound ACL control, sessions and local admin rights |
87
90
  | `ou` | OUs, containers and whole domains: the OU tree with object, Tier Zero and owned counts, every object below an OU, the GPOs that apply (enforced links, blocked inheritance) and who outside Tier Zero can edit them, and who controls the OU; `user`/`computer -G` shows the GPOs applied to one object. Same-named OUs are picked by distinguished name |
91
+ | `gpo` | Where a GPO is linked, the users and computers it applies to (blocked inheritance and enforced links taken into account) and who outside Tier Zero can edit it |
92
+ | `ca`, `template` | Enterprise CAs and certificate templates: the ESC conditions a template meets (ESC2 and ESC15 included, which BloodHound has no edge for), the CA's host and settings (ESC6, ESC8), and who can enroll, manage (ESC7) or rewrite them (ESC4) |
88
93
  | `search` | Objects matching property filters (`search -t user hasspn enabled`), with dates, ages (`90d`) and wildcards |
89
94
  | `path` | Shortest paths to a target or to Tier Zero, from everything owned, or through `--via` steps; printed as hop chains, a terminal graph, or SVG/PNG for reports |
90
95
  | `hunt` | Domain-wide checks by severity: DCSync, control of Tier Zero, AD CS (ESC*), Kerberoasting, privileged sessions, LAPS/gMSA readers, passwords in attributes, takeover primitives (Shadow Credentials, RBCD), GPO control, NTLM relay, trusts, possible honeytokens...; `hunt --owned` limits them to what your owned objects can do |
@@ -27,17 +27,20 @@ $ bhshell --url http://127.0.0.1:8080
27
27
  [+] Logged in as admin on 127.0.0.1:8080
28
28
 
29
29
  [bh: shell] > hunt
30
- Severity Check Count Example Description
31
- critical dcsync 2 SVC_SQL@CORP.LOCAL Non-Tier-Zero principals with replication rights
32
- high kerberoastable 2 JDOE@CORP.LOCAL Kerberoastable users
30
+ Severity Check Count Example Description
31
+ critical dcsync 4 DCSYNCERS@PHANTOM.CORP Non-Tier-Zero principals with replication rights
32
+ critical adcs 95 ALICE@PHANTOM.CORP AD CS escalation paths (ESC*, GoldenCert)
33
+ high kerberoastable 2 ADMINISTRATOR@PHANTOM.CORP Kerberoastable users
33
34
  ...
34
- [*] Details: 'hunt CHECK' (e.g. 'hunt kerberoastable'), or 'hunt --all'.
35
+ [*] Details: 'hunt CHECK' (e.g. 'hunt dcsync'), or 'hunt --all'.
35
36
 
36
- [bh: shell] > user CORP\svc_sql --all -o svc_sql.csv
37
+ [bh: shell] > user PHANTOM\svc_kerb_del --all -o svc_kerb_del.csv
37
38
 
38
- [bh: shell] > ou corp.local -t
39
+ [bh: shell] > ou phantom.corp -t
39
40
 
40
- [bh: shell] > computer WEB01 -G
41
+ [bh: shell] > computer DF-MS-SHS -G
42
+
43
+ [bh: shell] > gpo 'ENABLE AV@phantom.corp' -c
41
44
 
42
45
  [bh: shell] > path --owned -g
43
46
  ```
@@ -50,6 +53,8 @@ The same commands run non-interactively for scripting: `bhshell -c "hunt --json"
50
53
  |---|---|
51
54
  | `user`, `computer`, `group`, `lookup` | Kerberoastable / AS-REP roastable, delegation, password age, Tier Zero and owned flags, plus group nesting, inbound/outbound ACL control, sessions and local admin rights |
52
55
  | `ou` | OUs, containers and whole domains: the OU tree with object, Tier Zero and owned counts, every object below an OU, the GPOs that apply (enforced links, blocked inheritance) and who outside Tier Zero can edit them, and who controls the OU; `user`/`computer -G` shows the GPOs applied to one object. Same-named OUs are picked by distinguished name |
56
+ | `gpo` | Where a GPO is linked, the users and computers it applies to (blocked inheritance and enforced links taken into account) and who outside Tier Zero can edit it |
57
+ | `ca`, `template` | Enterprise CAs and certificate templates: the ESC conditions a template meets (ESC2 and ESC15 included, which BloodHound has no edge for), the CA's host and settings (ESC6, ESC8), and who can enroll, manage (ESC7) or rewrite them (ESC4) |
53
58
  | `search` | Objects matching property filters (`search -t user hasspn enabled`), with dates, ages (`90d`) and wildcards |
54
59
  | `path` | Shortest paths to a target or to Tier Zero, from everything owned, or through `--via` steps; printed as hop chains, a terminal graph, or SVG/PNG for reports |
55
60
  | `hunt` | Domain-wide checks by severity: DCSync, control of Tier Zero, AD CS (ESC*), Kerberoasting, privileged sessions, LAPS/gMSA readers, passwords in attributes, takeover primitives (Shadow Credentials, RBCD), GPO control, NTLM relay, trusts, possible honeytokens...; `hunt --owned` limits them to what your owned objects can do |
@@ -1,3 +1,3 @@
1
1
  """bhshell - an interactive shell for BloodHound."""
2
2
 
3
- __version__ = "0.1.1"
3
+ __version__ = "0.2.0"
@@ -336,6 +336,8 @@ def _collection(kind: str) -> str:
336
336
  "gpo": "gpos",
337
337
  "ou": "ous",
338
338
  "container": "containers",
339
+ "enterpriseca": "enterprisecas",
340
+ "certtemplate": "certtemplates",
339
341
  }
340
342
  try:
341
343
  return mapping[kind.lower()]
@@ -57,6 +57,16 @@ def _write_private(path: Path, content: str) -> None:
57
57
  os.chmod(path, 0o600)
58
58
 
59
59
 
60
+ def _absolute_dirs(setting: str) -> str:
61
+ """`query_dirs` with relative folders made absolute, so the setting works from any directory.
62
+
63
+ `~` and absolute folders are kept as written.
64
+ """
65
+ parts = [p.strip() for p in setting.split(os.pathsep) if p.strip()]
66
+ return os.pathsep.join(p if p.startswith("~") or Path(p).is_absolute() else str(Path(p).absolute())
67
+ for p in parts)
68
+
69
+
60
70
  @dataclass
61
71
  class Config:
62
72
  url: str = DEFAULT_URL
@@ -107,7 +117,11 @@ class Config:
107
117
  if isinstance(default, (int, float)):
108
118
  if isinstance(value, bool):
109
119
  raise TypeError(f"expected a number, got {value!r}")
110
- number = type(default)(value)
120
+ try:
121
+ number = type(default)(value)
122
+ except ValueError:
123
+ raise ValueError(f"expected {'a whole number' if isinstance(default, int) else 'a number'}, "
124
+ f"got {value!r}") from None
111
125
  if not math.isfinite(number):
112
126
  raise ValueError(f"not a finite number: {value!r}")
113
127
  minimum = _MINIMUMS.get(key)
@@ -118,6 +132,8 @@ class Config:
118
132
  from bhshell.client.client import normalize_url # late: the client imports this module
119
133
 
120
134
  return normalize_url(str(value))
135
+ if key == "query_dirs":
136
+ return _absolute_dirs(str(value))
121
137
  return str(value)
122
138
 
123
139
  def save(self) -> None:
@@ -38,6 +38,30 @@ PRIMARY_KINDS = (
38
38
  TIER_ZERO_LABEL = "Tag_Tier_Zero"
39
39
  OWNED_LABEL = "Tag_Owned"
40
40
 
41
+ # Well-known broad principals, matched by objectid suffix: Everyone, Anonymous Logon, Authenticated Users,
42
+ # Domain Users, Domain Computers, Users, Pre-Windows 2000 Compatible Access.
43
+ 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")
44
+
45
+ # Extended key usages, by the OIDs BloodHound stores.
46
+ EKU_NAMES = {
47
+ "1.3.6.1.5.5.7.3.1": "Server Authentication",
48
+ "1.3.6.1.5.5.7.3.2": "Client Authentication",
49
+ "1.3.6.1.5.5.7.3.3": "Code Signing",
50
+ "1.3.6.1.5.5.7.3.4": "Secure Email",
51
+ "1.3.6.1.5.5.7.3.8": "Time Stamping",
52
+ "1.3.6.1.5.5.7.3.9": "OCSP Signing",
53
+ "1.3.6.1.5.2.3.4": "PKINIT Client Authentication",
54
+ "1.3.6.1.5.2.3.5": "KDC Authentication",
55
+ "1.3.6.1.4.1.311.10.3.4": "Encrypting File System",
56
+ "1.3.6.1.4.1.311.10.3.4.1": "File Recovery",
57
+ "1.3.6.1.4.1.311.10.3.12": "Document Signing",
58
+ "1.3.6.1.4.1.311.20.2.1": "Certificate Request Agent",
59
+ "1.3.6.1.4.1.311.20.2.2": "Smart Card Logon",
60
+ "1.3.6.1.4.1.311.21.5": "Private Key Archival",
61
+ "1.3.6.1.4.1.311.21.6": "Key Recovery Agent",
62
+ "2.5.29.37.0": "Any Purpose",
63
+ }
64
+
41
65
 
42
66
  @dataclass
43
67
  class Node:
@@ -140,16 +164,35 @@ class Edge:
140
164
  )
141
165
 
142
166
 
167
+ def literal_rows(literals: list[dict[str, Any]]) -> list[dict[str, Any]]:
168
+ """Rows of the scalar values a query returned (`RETURN n.name AS name, count(m) AS total`).
169
+
170
+ BloodHound sends them as one flat `{key, value}` list: a key already in the row starts a new one.
171
+ """
172
+ rows: list[dict[str, Any]] = []
173
+ row: dict[str, Any] = {}
174
+ for item in literals:
175
+ if item.get("key") in row:
176
+ rows.append(row)
177
+ row = {}
178
+ row[item.get("key")] = item.get("value")
179
+ if row:
180
+ rows.append(row)
181
+ return rows
182
+
183
+
143
184
  @dataclass
144
185
  class Graph:
145
186
  nodes: dict[str, Node] = field(default_factory=dict)
146
187
  edges: list[Edge] = field(default_factory=list)
147
188
  # The Cypher query this graph came from ('' when built otherwise).
148
189
  query: str = field(default="", compare=False, repr=False)
190
+ # Scalar values the query returned, as rows (see `literal_rows`).
191
+ rows: list[dict[str, Any]] = field(default_factory=list, compare=False, repr=False)
149
192
 
150
193
  @classmethod
151
194
  def from_cypher(cls, data: dict[str, Any]) -> Graph:
152
- graph = cls()
195
+ graph = cls(rows=literal_rows(data.get("literals") or []))
153
196
  for node_id, raw in (data.get("nodes") or {}).items():
154
197
  graph.nodes[str(node_id)] = Node.from_cypher(node_id, raw)
155
198
  seen: set[Edge] = set()
@@ -318,12 +361,14 @@ class ADObject:
318
361
 
319
362
  @staticmethod
320
363
  def wrap(node: Node) -> ADObject:
321
- kinds = {"User": User, "Computer": Computer, "Group": Group,
322
- "OU": DirectoryContainer, "Container": DirectoryContainer, "Domain": DirectoryContainer}
364
+ kinds = {"User": User, "Computer": Computer, "Group": Group, "GPO": GPO,
365
+ "OU": DirectoryContainer, "Container": DirectoryContainer, "Domain": DirectoryContainer,
366
+ "CertTemplate": CertTemplate, "EnterpriseCA": EnterpriseCA}
323
367
  return kinds.get(node.kind, ADObject)(node)
324
368
 
325
369
  def _common_head(self) -> list[SummaryField]:
326
- oid = "Object ID (GUID)" if self.node.kind in ("OU", "Container", "GPO") else "Object ID (SID)"
370
+ sid = self.node.kind in ("User", "Computer", "Group", "Domain")
371
+ oid = "Object ID (SID)" if sid else "Object ID (GUID)"
327
372
  fields = [
328
373
  SummaryField("Name", self.node.name),
329
374
  SummaryField("Type", self.node.kind),
@@ -407,6 +452,17 @@ class Group(ADObject):
407
452
  ]
408
453
 
409
454
 
455
+ class GPO(ADObject):
456
+ def summary(self) -> list[SummaryField]:
457
+ p = self.props
458
+ fields = self._common_head()
459
+ if p.get("gpcpath"):
460
+ fields.append(SummaryField("SYSVOL Path", p["gpcpath"]))
461
+ if p.get("whencreated"):
462
+ fields.append(SummaryField("Created", p["whencreated"], "date"))
463
+ return [*fields, *self._common_tail()]
464
+
465
+
410
466
  class DirectoryContainer(ADObject):
411
467
  """An OU, a container or a domain: the nodes of the directory tree."""
412
468
 
@@ -421,3 +477,76 @@ class DirectoryContainer(ADObject):
421
477
  if p.get("whencreated"):
422
478
  fields.append(SummaryField("Created", p["whencreated"], "date"))
423
479
  return [*fields, *self._common_tail()]
480
+
481
+
482
+ def effective_ekus(props: dict[str, Any]) -> list[str] | None:
483
+ """The EKUs a template's certificates carry (`None` when not collected); empty means any use."""
484
+ ekus = props.get("effectiveekus")
485
+ if ekus is None:
486
+ ekus = props.get("ekus")
487
+ return None if ekus is None else [str(e) for e in ekus]
488
+
489
+
490
+ def _registry_flag(props: dict[str, Any], key: str, collected: str, label: str, risk: Risk = "none") -> SummaryField:
491
+ """A CA setting read from the CA host's registry, which SharpHound cannot always reach."""
492
+ if props.get(collected) is False:
493
+ return SummaryField(label, "not collected")
494
+ value = opt_bool(props, key)
495
+ return SummaryField(label, value, "bool", _risk_if(value, risk))
496
+
497
+
498
+ class CertTemplate(ADObject):
499
+ def summary(self) -> list[SummaryField]:
500
+ p = self.props
501
+ auth = opt_bool(p, "authenticationenabled")
502
+ supplies = opt_bool(p, "enrolleesuppliessubject")
503
+ nosid = opt_bool(p, "nosecurityextension")
504
+ fields = [
505
+ *self._common_head(),
506
+ SummaryField("Display Name", p.get("displayname") or ""),
507
+ SummaryField("Template OID", p.get("oid") or ""),
508
+ SummaryField("Schema Version", p.get("schemaversion")),
509
+ ]
510
+ ekus = effective_ekus(p)
511
+ if ekus == []:
512
+ fields.append(SummaryField("Effective EKUs", "none (any purpose)"))
513
+ elif ekus:
514
+ fields.append(SummaryField("Effective EKUs", [EKU_NAMES.get(e, e) for e in ekus], "list"))
515
+ return [
516
+ *fields,
517
+ SummaryField("Authentication Enabled", auth, "bool"),
518
+ SummaryField("Enrollee Supplies Subject", supplies, "bool", _risk_if(supplies and auth)),
519
+ SummaryField("Requires Manager Approval", opt_bool(p, "requiresmanagerapproval"), "bool"),
520
+ SummaryField("Authorized Signatures Required", p.get("authorizedsignatures")),
521
+ SummaryField("No Security Extension", nosid, "bool", _risk_if(nosid and auth)),
522
+ SummaryField("Validity Period", p.get("validityperiod") or ""),
523
+ SummaryField("Renewal Period", p.get("renewalperiod") or ""),
524
+ *self._common_tail(),
525
+ ]
526
+
527
+
528
+ class EnterpriseCA(ADObject):
529
+ def summary(self) -> list[SummaryField]:
530
+ p = self.props
531
+ fields = [
532
+ *self._common_head(),
533
+ SummaryField("CA Name", p.get("caname") or ""),
534
+ SummaryField("DNS Hostname", p.get("dnshostname") or ""),
535
+ SummaryField("Certificate Thumbprint", p.get("certthumbprint") or ""),
536
+ _registry_flag(p, "isuserspecifiessanenabled", "isuserspecifiessanenabledcollected",
537
+ "User Specified SAN (ESC6)", "high"),
538
+ *([SummaryField("CA Security", "not collected: rights granted only on the CA may be missing")]
539
+ if p.get("casecuritycollected") is False else []),
540
+ _registry_flag(p, "roleseparationenabled", "roleseparationenabledcollected", "Role Separation"),
541
+ _registry_flag(p, "hasenrollmentagentrestrictions", "enrollmentagentrestrictionscollected",
542
+ "Enrollment Agent Restrictions"),
543
+ ]
544
+ endpoints = [*(p.get("httpenrollmentendpoints") or []), *(p.get("httpsenrollmentendpoints") or [])]
545
+ if endpoints:
546
+ fields.append(SummaryField("Web Enrollment Endpoints", endpoints, "list"))
547
+ if "hasvulnerableendpoint" in p:
548
+ vulnerable = opt_bool(p, "hasvulnerableendpoint")
549
+ fields.append(SummaryField("Relayable Web Enrollment (ESC8)", vulnerable, "bool", _risk_if(vulnerable)))
550
+ if p.get("whencreated"):
551
+ fields.append(SummaryField("Created", p["whencreated"], "date"))
552
+ return [*fields, *self._common_tail()]
@@ -44,6 +44,20 @@ def _with_enabled(total: int, enabled: int) -> str:
44
44
  return f"{total} ({enabled})" if total else "0"
45
45
 
46
46
 
47
+ def _shorten(names: list[str], keep: int = 3) -> list[str]:
48
+ """The first `keep` names, and how many more there are."""
49
+ shown = names[:keep]
50
+ return [*shown, f"+{len(names) - keep} more"] if len(names) > keep else shown
51
+
52
+
53
+ def _show_enabled(out: dict[str, Any]) -> dict[str, Any]:
54
+ """`users` and `computers` as "total (enabled)"."""
55
+ for key in ("users", "computers"):
56
+ total, enabled = out[key], out[f"enabled_{key}"]
57
+ out[key] = None if total is None else _with_enabled(total, enabled or 0)
58
+ return out
59
+
60
+
47
61
  @dataclass
48
62
  class ContainerRow(Row):
49
63
  """One OU or container of a `tree` view; the counts cover everything below it."""
@@ -71,11 +85,7 @@ class ContainerRow(Row):
71
85
  return {k: v for k, v in asdict(self).items() if k != "label"}
72
86
 
73
87
  def as_display(self) -> dict[str, Any]:
74
- out = asdict(self)
75
- for key in ("users", "computers"):
76
- total, enabled = out[key], out[f"enabled_{key}"]
77
- out[key] = None if total is None else _with_enabled(total, enabled or 0)
78
- return out
88
+ return _show_enabled(asdict(self))
79
89
 
80
90
 
81
91
  @dataclass
@@ -101,6 +111,8 @@ class GPOLinkRow(Row):
101
111
  gpo: str
102
112
  linked_to: str
103
113
  linked_kind: str
114
+ # Where the linked domain or OU sits (`CORP.LOCAL/SERVERS/WEB`): OU names repeat across a domain.
115
+ location: str = ""
104
116
  enforced: bool | None = None
105
117
  # `None` when unknown (REST fallback).
106
118
  applies: bool | None = True
@@ -118,10 +130,56 @@ class GPOLinkRow(Row):
118
130
  out["applies"] = None
119
131
  else:
120
132
  out["applies"] = ("yes" if self.applies else "no") + (f" ({self.reason})" if self.reason else "")
121
- shown = self.editable_by[:3]
122
- if len(self.editable_by) > len(shown):
123
- shown = [*shown, f"+{len(self.editable_by) - len(shown)} more"]
124
- out["editable_by"] = shown
133
+ out["editable_by"] = _shorten(self.editable_by)
134
+ return out
135
+
136
+
137
+ @dataclass
138
+ class GPOTargetRow(Row):
139
+ """A domain or OU a GPO is linked to (`links` view); the counts cover what the link applies to."""
140
+
141
+ linked_to: str
142
+ kind: str
143
+ location: str = ""
144
+ enforced: bool | None = None
145
+ users: int | None = None
146
+ enabled_users: int | None = None
147
+ computers: int | None = None
148
+ enabled_computers: int | None = None
149
+ tier_zero_affected: int | None = None
150
+ owned_affected: int | None = None
151
+ # OUs below the linked one that block inheritance, and so stop a link that is not enforced.
152
+ blocked_at: list[str] = field(default_factory=list)
153
+ tier_zero: bool = False
154
+ owned: bool = False
155
+ distinguishedname: str = ""
156
+ objectid: str = ""
157
+
158
+ def as_display(self) -> dict[str, Any]:
159
+ return _show_enabled(asdict(self))
160
+
161
+
162
+ @dataclass
163
+ class TemplateRow(Row):
164
+ """A certificate template published to a CA (`templates` view)."""
165
+
166
+ name: str
167
+ schema_version: int | None = None
168
+ authentication: bool | None = None
169
+ enrollee_supplies_subject: bool | None = None
170
+ manager_approval: bool | None = None
171
+ signatures: int | None = None
172
+ # ESC techniques whose template conditions it meets.
173
+ escs: list[str] = field(default_factory=list)
174
+ # Principals outside Tier Zero that can enroll in it.
175
+ enrollable_by: list[str] = field(default_factory=list)
176
+ tier_zero: bool = False
177
+ owned: bool = False
178
+ objectid: str = ""
179
+
180
+ def as_display(self) -> dict[str, Any]:
181
+ out = asdict(self)
182
+ out["enrollable_by"] = _shorten(self.enrollable_by)
125
183
  return out
126
184
 
127
185