bhshell 0.1.0__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 (87) hide show
  1. {bhshell-0.1.0/bhshell.egg-info → bhshell-0.2.0}/PKG-INFO +18 -8
  2. {bhshell-0.1.0 → bhshell-0.2.0}/README.md +17 -7
  3. {bhshell-0.1.0 → bhshell-0.2.0}/bhshell/__init__.py +1 -1
  4. {bhshell-0.1.0 → bhshell-0.2.0}/bhshell/client/client.py +8 -0
  5. {bhshell-0.1.0 → bhshell-0.2.0}/bhshell/config.py +17 -1
  6. {bhshell-0.1.0 → bhshell-0.2.0}/bhshell/errors.py +2 -2
  7. {bhshell-0.1.0 → bhshell-0.2.0}/bhshell/models/entities.py +155 -5
  8. bhshell-0.2.0/bhshell/models/results.py +238 -0
  9. bhshell-0.2.0/bhshell/queries/adcs.py +247 -0
  10. bhshell-0.2.0/bhshell/queries/containers.py +505 -0
  11. bhshell-0.2.0/bhshell/queries/cypher.py +290 -0
  12. {bhshell-0.1.0 → bhshell-0.2.0}/bhshell/queries/domains.py +44 -5
  13. {bhshell-0.1.0 → bhshell-0.2.0}/bhshell/queries/edges.py +34 -4
  14. bhshell-0.2.0/bhshell/queries/gpos.py +275 -0
  15. {bhshell-0.1.0 → bhshell-0.2.0}/bhshell/queries/hunt.py +69 -17
  16. {bhshell-0.1.0 → bhshell-0.2.0}/bhshell/queries/marking.py +7 -1
  17. {bhshell-0.1.0 → bhshell-0.2.0}/bhshell/queries/paths.py +21 -9
  18. {bhshell-0.1.0 → bhshell-0.2.0}/bhshell/queries/search.py +8 -3
  19. {bhshell-0.1.0 → bhshell-0.2.0}/bhshell/queries/service.py +170 -18
  20. {bhshell-0.1.0 → bhshell-0.2.0}/bhshell/render/export.py +16 -3
  21. {bhshell-0.1.0 → bhshell-0.2.0}/bhshell/render/views.py +37 -12
  22. {bhshell-0.1.0 → bhshell-0.2.0}/bhshell/shell/commands/base.py +10 -1
  23. {bhshell-0.1.0 → bhshell-0.2.0}/bhshell/shell/commands/cypher.py +4 -4
  24. {bhshell-0.1.0 → bhshell-0.2.0}/bhshell/shell/commands/domains.py +13 -4
  25. bhshell-0.2.0/bhshell/shell/commands/entities.py +201 -0
  26. {bhshell-0.1.0 → bhshell-0.2.0}/bhshell/shell/commands/hunt.py +3 -1
  27. {bhshell-0.1.0 → bhshell-0.2.0}/bhshell/shell/commands/marking.py +3 -1
  28. {bhshell-0.1.0 → bhshell-0.2.0}/bhshell/shell/commands/paths.py +4 -3
  29. {bhshell-0.1.0 → bhshell-0.2.0}/bhshell/shell/commands/search.py +1 -0
  30. {bhshell-0.1.0 → bhshell-0.2.0/bhshell.egg-info}/PKG-INFO +18 -8
  31. {bhshell-0.1.0 → bhshell-0.2.0}/bhshell.egg-info/SOURCES.txt +5 -0
  32. bhshell-0.2.0/docs/USAGE.md +557 -0
  33. bhshell-0.2.0/tests/test_adcs.py +260 -0
  34. {bhshell-0.1.0 → bhshell-0.2.0}/tests/test_analysis.py +49 -0
  35. bhshell-0.2.0/tests/test_containers.py +454 -0
  36. {bhshell-0.1.0 → bhshell-0.2.0}/tests/test_disabled_paths.py +20 -6
  37. {bhshell-0.1.0 → bhshell-0.2.0}/tests/test_hunt.py +16 -0
  38. {bhshell-0.1.0 → bhshell-0.2.0}/tests/test_search.py +12 -2
  39. {bhshell-0.1.0 → bhshell-0.2.0}/tests/test_service.py +87 -4
  40. {bhshell-0.1.0 → bhshell-0.2.0}/tests/test_shell.py +47 -1
  41. bhshell-0.1.0/bhshell/models/results.py +0 -83
  42. bhshell-0.1.0/bhshell/queries/cypher.py +0 -155
  43. bhshell-0.1.0/bhshell/shell/commands/entities.py +0 -88
  44. bhshell-0.1.0/docs/USAGE.md +0 -317
  45. {bhshell-0.1.0 → bhshell-0.2.0}/LICENSE +0 -0
  46. {bhshell-0.1.0 → bhshell-0.2.0}/MANIFEST.in +0 -0
  47. {bhshell-0.1.0 → bhshell-0.2.0}/bhshell/__main__.py +0 -0
  48. {bhshell-0.1.0 → bhshell-0.2.0}/bhshell/cli.py +0 -0
  49. {bhshell-0.1.0 → bhshell-0.2.0}/bhshell/client/__init__.py +0 -0
  50. {bhshell-0.1.0 → bhshell-0.2.0}/bhshell/client/auth.py +0 -0
  51. {bhshell-0.1.0 → bhshell-0.2.0}/bhshell/library/asrep-roastable-users.cyp +0 -0
  52. {bhshell-0.1.0 → bhshell-0.2.0}/bhshell/library/dcsync-principals.cyp +0 -0
  53. {bhshell-0.1.0 → bhshell-0.2.0}/bhshell/library/domain-admins-members.cyp +0 -0
  54. {bhshell-0.1.0 → bhshell-0.2.0}/bhshell/library/domain-trusts.cyp +0 -0
  55. {bhshell-0.1.0 → bhshell-0.2.0}/bhshell/library/gpos-linked-to-domain-controllers.cyp +0 -0
  56. {bhshell-0.1.0 → bhshell-0.2.0}/bhshell/library/kerberoastable-users.cyp +0 -0
  57. {bhshell-0.1.0 → bhshell-0.2.0}/bhshell/library/owned-objects.cyp +0 -0
  58. {bhshell-0.1.0 → bhshell-0.2.0}/bhshell/library/owned-to-tier-zero.cyp +0 -0
  59. {bhshell-0.1.0 → bhshell-0.2.0}/bhshell/library/sid-history.cyp +0 -0
  60. {bhshell-0.1.0 → bhshell-0.2.0}/bhshell/library/tier-zero-users.cyp +0 -0
  61. {bhshell-0.1.0 → bhshell-0.2.0}/bhshell/library/unconstrained-delegation.cyp +0 -0
  62. {bhshell-0.1.0 → bhshell-0.2.0}/bhshell/models/__init__.py +0 -0
  63. {bhshell-0.1.0 → bhshell-0.2.0}/bhshell/queries/__init__.py +0 -0
  64. {bhshell-0.1.0 → bhshell-0.2.0}/bhshell/queries/library.py +0 -0
  65. {bhshell-0.1.0 → bhshell-0.2.0}/bhshell/render/__init__.py +0 -0
  66. {bhshell-0.1.0 → bhshell-0.2.0}/bhshell/render/graph.py +0 -0
  67. {bhshell-0.1.0 → bhshell-0.2.0}/bhshell/render/layout.py +0 -0
  68. {bhshell-0.1.0 → bhshell-0.2.0}/bhshell/render/png.py +0 -0
  69. {bhshell-0.1.0 → bhshell-0.2.0}/bhshell/render/styles.py +0 -0
  70. {bhshell-0.1.0 → bhshell-0.2.0}/bhshell/render/svg.py +0 -0
  71. {bhshell-0.1.0 → bhshell-0.2.0}/bhshell/shell/__init__.py +0 -0
  72. {bhshell-0.1.0 → bhshell-0.2.0}/bhshell/shell/app.py +0 -0
  73. {bhshell-0.1.0 → bhshell-0.2.0}/bhshell/shell/commands/__init__.py +0 -0
  74. {bhshell-0.1.0 → bhshell-0.2.0}/bhshell/shell/commands/connection.py +0 -0
  75. {bhshell-0.1.0 → bhshell-0.2.0}/bhshell/shell/commands/library.py +0 -0
  76. {bhshell-0.1.0 → bhshell-0.2.0}/bhshell.egg-info/dependency_links.txt +0 -0
  77. {bhshell-0.1.0 → bhshell-0.2.0}/bhshell.egg-info/entry_points.txt +0 -0
  78. {bhshell-0.1.0 → bhshell-0.2.0}/bhshell.egg-info/requires.txt +0 -0
  79. {bhshell-0.1.0 → bhshell-0.2.0}/bhshell.egg-info/top_level.txt +0 -0
  80. {bhshell-0.1.0 → bhshell-0.2.0}/pyproject.toml +0 -0
  81. {bhshell-0.1.0 → bhshell-0.2.0}/setup.cfg +0 -0
  82. {bhshell-0.1.0 → bhshell-0.2.0}/tests/__init__.py +0 -0
  83. {bhshell-0.1.0 → bhshell-0.2.0}/tests/conftest.py +0 -0
  84. {bhshell-0.1.0 → bhshell-0.2.0}/tests/test_client.py +0 -0
  85. {bhshell-0.1.0 → bhshell-0.2.0}/tests/test_graph.py +0 -0
  86. {bhshell-0.1.0 → bhshell-0.2.0}/tests/test_png.py +0 -0
  87. {bhshell-0.1.0 → 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.0
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
@@ -35,8 +35,8 @@ Dynamic: license-file
35
35
 
36
36
  # bhshell
37
37
 
38
- [![PyPI version](https://img.shields.io/pypi/v/bhshell.svg)](https://pypi.org/project/bhshell/)
39
- [![Python versions](https://img.shields.io/pypi/pyversions/bhshell.svg)](https://pypi.org/project/bhshell/)
38
+ ![PyPI Status](https://img.shields.io/pypi/status/bhshell)
39
+ [![PyPI version](https://img.shields.io/pypi/v/bhshell)](https://pypi.org/project/bhshell/)
40
40
  [![CI](https://github.com/kr0no/bloodhound-shell/actions/workflows/ci.yml/badge.svg)](https://github.com/kr0no/bloodhound-shell/actions/workflows/ci.yml)
41
41
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/kr0no/bloodhound-shell/blob/main/LICENSE)
42
42
 
@@ -62,13 +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
73
+
74
+ [bh: shell] > ou phantom.corp -t
75
+
76
+ [bh: shell] > computer DF-MS-SHS -G
77
+
78
+ [bh: shell] > gpo 'ENABLE AV@phantom.corp' -c
72
79
 
73
80
  [bh: shell] > path --owned -g
74
81
  ```
@@ -80,6 +87,9 @@ The same commands run non-interactively for scripting: `bhshell -c "hunt --json"
80
87
  | Command | Shows |
81
88
  |---|---|
82
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 |
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) |
83
93
  | `search` | Objects matching property filters (`search -t user hasspn enabled`), with dates, ages (`90d`) and wildcards |
84
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 |
85
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 |
@@ -1,7 +1,7 @@
1
1
  # bhshell
2
2
 
3
- [![PyPI version](https://img.shields.io/pypi/v/bhshell.svg)](https://pypi.org/project/bhshell/)
4
- [![Python versions](https://img.shields.io/pypi/pyversions/bhshell.svg)](https://pypi.org/project/bhshell/)
3
+ ![PyPI Status](https://img.shields.io/pypi/status/bhshell)
4
+ [![PyPI version](https://img.shields.io/pypi/v/bhshell)](https://pypi.org/project/bhshell/)
5
5
  [![CI](https://github.com/kr0no/bloodhound-shell/actions/workflows/ci.yml/badge.svg)](https://github.com/kr0no/bloodhound-shell/actions/workflows/ci.yml)
6
6
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/kr0no/bloodhound-shell/blob/main/LICENSE)
7
7
 
@@ -27,13 +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
38
+
39
+ [bh: shell] > ou phantom.corp -t
40
+
41
+ [bh: shell] > computer DF-MS-SHS -G
42
+
43
+ [bh: shell] > gpo 'ENABLE AV@phantom.corp' -c
37
44
 
38
45
  [bh: shell] > path --owned -g
39
46
  ```
@@ -45,6 +52,9 @@ The same commands run non-interactively for scripting: `bhshell -c "hunt --json"
45
52
  | Command | Shows |
46
53
  |---|---|
47
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 |
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) |
48
58
  | `search` | Objects matching property filters (`search -t user hasspn enabled`), with dates, ages (`90d`) and wildcards |
49
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 |
50
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.0"
3
+ __version__ = "0.2.0"
@@ -314,6 +314,12 @@ class BHClient:
314
314
  data = resp.get("data") or {}
315
315
  return data.get("props") or data
316
316
 
317
+ def entity_counts(self, kind: str, objectid: str) -> dict[str, int]:
318
+ """Related-object counts the entity endpoint reports (`users`, `computers`, `gpos`, ...)."""
319
+ resp = self.get(f"api/v2/{_collection(kind)}/{objectid}", counts="true") or {}
320
+ data = resp.get("data") or {}
321
+ return {k: v for k, v in data.items() if isinstance(v, int) and not isinstance(v, bool)}
322
+
317
323
  def entity_related(
318
324
  self, kind: str, objectid: str, relation: str, *, limit: int | None = None
319
325
  ) -> list[dict[str, Any]]:
@@ -330,6 +336,8 @@ def _collection(kind: str) -> str:
330
336
  "gpo": "gpos",
331
337
  "ou": "ous",
332
338
  "container": "containers",
339
+ "enterpriseca": "enterprisecas",
340
+ "certtemplate": "certtemplates",
333
341
  }
334
342
  try:
335
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:
@@ -67,7 +67,7 @@ class UnsupportedFeature(BHError):
67
67
  class AmbiguousMatchError(BHError):
68
68
  hint = "Qualify the name: 'jdoe@corp.local', 'CORP\\jdoe', '--domain CORP.LOCAL' or use the SID."
69
69
 
70
- def __init__(self, query: str, candidates: Sequence[Node]) -> None:
71
- super().__init__(f"'{query}' matches {len(candidates)} objects")
70
+ def __init__(self, query: str, candidates: Sequence[Node], *, hint: str = "") -> None:
71
+ super().__init__(f"'{query}' matches {len(candidates)} objects", hint=hint)
72
72
  self.query = query
73
73
  self.candidates = list(candidates)
@@ -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,16 +361,23 @@ class ADObject:
318
361
 
319
362
  @staticmethod
320
363
  def wrap(node: Node) -> ADObject:
321
- cls = {"User": User, "Computer": Computer, "Group": Group}.get(node.kind, ADObject)
322
- return cls(node)
364
+ kinds = {"User": User, "Computer": Computer, "Group": Group, "GPO": GPO,
365
+ "OU": DirectoryContainer, "Container": DirectoryContainer, "Domain": DirectoryContainer,
366
+ "CertTemplate": CertTemplate, "EnterpriseCA": EnterpriseCA}
367
+ return kinds.get(node.kind, ADObject)(node)
323
368
 
324
369
  def _common_head(self) -> list[SummaryField]:
325
- return [
370
+ sid = self.node.kind in ("User", "Computer", "Group", "Domain")
371
+ oid = "Object ID (SID)" if sid else "Object ID (GUID)"
372
+ fields = [
326
373
  SummaryField("Name", self.node.name),
327
374
  SummaryField("Type", self.node.kind),
328
- SummaryField("Object ID (SID)", self.node.objectid),
375
+ SummaryField(oid, self.node.objectid),
329
376
  SummaryField("Domain", self.node.domain),
330
377
  ]
378
+ if self.props.get("distinguishedname"):
379
+ fields.append(SummaryField("Distinguished Name", self.props["distinguishedname"]))
380
+ return fields
331
381
 
332
382
  def _common_tail(self) -> list[SummaryField]:
333
383
  return [
@@ -400,3 +450,103 @@ class Group(ADObject):
400
450
  SummaryField("AdminCount", admincount, "bool", _risk_if(admincount, "medium")),
401
451
  *self._common_tail(),
402
452
  ]
453
+
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
+
466
+ class DirectoryContainer(ADObject):
467
+ """An OU, a container or a domain: the nodes of the directory tree."""
468
+
469
+ def summary(self) -> list[SummaryField]:
470
+ p = self.props
471
+ fields = self._common_head()
472
+ if self.node.kind == "Domain":
473
+ fields.append(SummaryField("Functional Level", p.get("functionallevel") or ""))
474
+ if self.node.kind == "OU":
475
+ blocks = opt_bool(p, "blocksinheritance")
476
+ fields.append(SummaryField("Blocks GPO Inheritance", blocks, "bool", _risk_if(blocks, "info")))
477
+ if p.get("whencreated"):
478
+ fields.append(SummaryField("Created", p["whencreated"], "date"))
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()]
@@ -0,0 +1,238 @@
1
+ """Result containers returned by the query layer and consumed by renderers/exporters."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import asdict, dataclass, field
6
+ from typing import Any, Literal
7
+
8
+ from bhshell.models.entities import ADObject, Node, SummaryField
9
+
10
+ Backend = Literal["cypher", "rest"]
11
+
12
+
13
+ class Row:
14
+ """A view row (a dataclass): `as_dict` is what is exported, `as_display` what the table shows."""
15
+
16
+ def as_dict(self) -> dict[str, Any]:
17
+ return asdict(self)
18
+
19
+ def as_display(self) -> dict[str, Any]:
20
+ return self.as_dict()
21
+
22
+
23
+ @dataclass
24
+ class RelationshipRow(Row):
25
+ """One relationship in a view.
26
+
27
+ Not every view fills every field; `Column` definitions on the
28
+ owning `ViewResult` select which ones are displayed.
29
+ """
30
+
31
+ principal: str = ""
32
+ principal_kind: str = ""
33
+ edge: str = ""
34
+ target: str = ""
35
+ target_kind: str = ""
36
+ via: str = ""
37
+ depth: int | None = None
38
+ tier_zero: bool = False
39
+ owned: bool = False
40
+ objectid: str = ""
41
+
42
+
43
+ def _with_enabled(total: int, enabled: int) -> str:
44
+ return f"{total} ({enabled})" if total else "0"
45
+
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
+
61
+ @dataclass
62
+ class ContainerRow(Row):
63
+ """One OU or container of a `tree` view; the counts cover everything below it."""
64
+
65
+ name: str
66
+ kind: str
67
+ depth: int
68
+ # The name indented under its parent, for display.
69
+ label: str = ""
70
+ users: int | None = None
71
+ enabled_users: int | None = None
72
+ computers: int | None = None
73
+ enabled_computers: int | None = None
74
+ groups: int | None = None
75
+ tier_zero_below: int | None = None
76
+ owned_below: int | None = None
77
+ gpo_links: int = 0
78
+ blocks_inheritance: bool | None = None
79
+ tier_zero: bool = False
80
+ owned: bool = False
81
+ distinguishedname: str = ""
82
+ objectid: str = ""
83
+
84
+ def as_dict(self) -> dict[str, Any]:
85
+ return {k: v for k, v in asdict(self).items() if k != "label"}
86
+
87
+ def as_display(self) -> dict[str, Any]:
88
+ return _show_enabled(asdict(self))
89
+
90
+
91
+ @dataclass
92
+ class ContainedRow(Row):
93
+ """An object below an OU or container (`contents` view)."""
94
+
95
+ name: str
96
+ kind: str
97
+ enabled: bool | None = None
98
+ # Containers between the explored one and the object ("direct" when it is a direct child).
99
+ location: str = ""
100
+ depth: int = 1
101
+ tier_zero: bool = False
102
+ owned: bool = False
103
+ distinguishedname: str = ""
104
+ objectid: str = ""
105
+
106
+
107
+ @dataclass
108
+ class GPOLinkRow(Row):
109
+ """A GPO linked to the object or to a container above it (`gpos` view)."""
110
+
111
+ gpo: str
112
+ linked_to: str
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 = ""
116
+ enforced: bool | None = None
117
+ # `None` when unknown (REST fallback).
118
+ applies: bool | None = True
119
+ # Why it does not apply ("inheritance blocked at X"), why the link adds nothing ("duplicate: ..."), or ''.
120
+ reason: str = ""
121
+ # Principals outside Tier Zero that can edit the GPO.
122
+ editable_by: list[str] = field(default_factory=list)
123
+ tier_zero: bool = False
124
+ owned: bool = False
125
+ objectid: str = ""
126
+
127
+ def as_display(self) -> dict[str, Any]:
128
+ out = asdict(self)
129
+ if self.applies is None:
130
+ out["applies"] = None
131
+ else:
132
+ out["applies"] = ("yes" if self.applies else "no") + (f" ({self.reason})" if self.reason else "")
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)
183
+ return out
184
+
185
+
186
+ @dataclass(frozen=True)
187
+ class Column:
188
+ """A table column: `attr` is a row key; `fmt` picks the cell formatting (see `Renderer.fmt_value`)."""
189
+
190
+ header: str
191
+ attr: str
192
+ fmt: str = ""
193
+
194
+
195
+ @dataclass
196
+ class ViewResult:
197
+ """The outcome of one relationship view (groups, outbound, ...)."""
198
+
199
+ key: str
200
+ title: str
201
+ columns: list[Column]
202
+ rows: list[Any] = field(default_factory=list) # `Row`s; `RelationshipRow`s for relationship views
203
+ backend: Backend = "cypher"
204
+ notes: list[str] = field(default_factory=list)
205
+
206
+ def as_dict(self) -> dict[str, Any]:
207
+ return {
208
+ "view": self.key,
209
+ "title": self.title,
210
+ "backend": self.backend,
211
+ "notes": self.notes,
212
+ "rows": [r.as_dict() for r in self.rows],
213
+ }
214
+
215
+
216
+ @dataclass
217
+ class EntityReport:
218
+ """A summary and/or a set of relationship views for one entity."""
219
+
220
+ node: Node
221
+ show_summary: bool = True
222
+ views: list[ViewResult] = field(default_factory=list)
223
+ # Summary fields computed by queries (OU contents, linked GPOs, ...), shown after the node's own.
224
+ extra: list[SummaryField] = field(default_factory=list)
225
+
226
+ @property
227
+ def entity(self) -> ADObject:
228
+ return ADObject.wrap(self.node)
229
+
230
+ def summary(self) -> list[SummaryField]:
231
+ return [*self.entity.summary(), *self.extra]
232
+
233
+ def as_dict(self) -> dict[str, Any]:
234
+ out: dict[str, Any] = {"entity": self.node.as_dict()}
235
+ if self.show_summary:
236
+ out["summary"] = {f.label: f.value for f in self.summary()}
237
+ out["views"] = {v.key: v.as_dict() for v in self.views}
238
+ return out