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.
- {bhshell-0.1.0/bhshell.egg-info → bhshell-0.2.0}/PKG-INFO +18 -8
- {bhshell-0.1.0 → bhshell-0.2.0}/README.md +17 -7
- {bhshell-0.1.0 → bhshell-0.2.0}/bhshell/__init__.py +1 -1
- {bhshell-0.1.0 → bhshell-0.2.0}/bhshell/client/client.py +8 -0
- {bhshell-0.1.0 → bhshell-0.2.0}/bhshell/config.py +17 -1
- {bhshell-0.1.0 → bhshell-0.2.0}/bhshell/errors.py +2 -2
- {bhshell-0.1.0 → bhshell-0.2.0}/bhshell/models/entities.py +155 -5
- bhshell-0.2.0/bhshell/models/results.py +238 -0
- bhshell-0.2.0/bhshell/queries/adcs.py +247 -0
- bhshell-0.2.0/bhshell/queries/containers.py +505 -0
- bhshell-0.2.0/bhshell/queries/cypher.py +290 -0
- {bhshell-0.1.0 → bhshell-0.2.0}/bhshell/queries/domains.py +44 -5
- {bhshell-0.1.0 → bhshell-0.2.0}/bhshell/queries/edges.py +34 -4
- bhshell-0.2.0/bhshell/queries/gpos.py +275 -0
- {bhshell-0.1.0 → bhshell-0.2.0}/bhshell/queries/hunt.py +69 -17
- {bhshell-0.1.0 → bhshell-0.2.0}/bhshell/queries/marking.py +7 -1
- {bhshell-0.1.0 → bhshell-0.2.0}/bhshell/queries/paths.py +21 -9
- {bhshell-0.1.0 → bhshell-0.2.0}/bhshell/queries/search.py +8 -3
- {bhshell-0.1.0 → bhshell-0.2.0}/bhshell/queries/service.py +170 -18
- {bhshell-0.1.0 → bhshell-0.2.0}/bhshell/render/export.py +16 -3
- {bhshell-0.1.0 → bhshell-0.2.0}/bhshell/render/views.py +37 -12
- {bhshell-0.1.0 → bhshell-0.2.0}/bhshell/shell/commands/base.py +10 -1
- {bhshell-0.1.0 → bhshell-0.2.0}/bhshell/shell/commands/cypher.py +4 -4
- {bhshell-0.1.0 → bhshell-0.2.0}/bhshell/shell/commands/domains.py +13 -4
- bhshell-0.2.0/bhshell/shell/commands/entities.py +201 -0
- {bhshell-0.1.0 → bhshell-0.2.0}/bhshell/shell/commands/hunt.py +3 -1
- {bhshell-0.1.0 → bhshell-0.2.0}/bhshell/shell/commands/marking.py +3 -1
- {bhshell-0.1.0 → bhshell-0.2.0}/bhshell/shell/commands/paths.py +4 -3
- {bhshell-0.1.0 → bhshell-0.2.0}/bhshell/shell/commands/search.py +1 -0
- {bhshell-0.1.0 → bhshell-0.2.0/bhshell.egg-info}/PKG-INFO +18 -8
- {bhshell-0.1.0 → bhshell-0.2.0}/bhshell.egg-info/SOURCES.txt +5 -0
- bhshell-0.2.0/docs/USAGE.md +557 -0
- bhshell-0.2.0/tests/test_adcs.py +260 -0
- {bhshell-0.1.0 → bhshell-0.2.0}/tests/test_analysis.py +49 -0
- bhshell-0.2.0/tests/test_containers.py +454 -0
- {bhshell-0.1.0 → bhshell-0.2.0}/tests/test_disabled_paths.py +20 -6
- {bhshell-0.1.0 → bhshell-0.2.0}/tests/test_hunt.py +16 -0
- {bhshell-0.1.0 → bhshell-0.2.0}/tests/test_search.py +12 -2
- {bhshell-0.1.0 → bhshell-0.2.0}/tests/test_service.py +87 -4
- {bhshell-0.1.0 → bhshell-0.2.0}/tests/test_shell.py +47 -1
- bhshell-0.1.0/bhshell/models/results.py +0 -83
- bhshell-0.1.0/bhshell/queries/cypher.py +0 -155
- bhshell-0.1.0/bhshell/shell/commands/entities.py +0 -88
- bhshell-0.1.0/docs/USAGE.md +0 -317
- {bhshell-0.1.0 → bhshell-0.2.0}/LICENSE +0 -0
- {bhshell-0.1.0 → bhshell-0.2.0}/MANIFEST.in +0 -0
- {bhshell-0.1.0 → bhshell-0.2.0}/bhshell/__main__.py +0 -0
- {bhshell-0.1.0 → bhshell-0.2.0}/bhshell/cli.py +0 -0
- {bhshell-0.1.0 → bhshell-0.2.0}/bhshell/client/__init__.py +0 -0
- {bhshell-0.1.0 → bhshell-0.2.0}/bhshell/client/auth.py +0 -0
- {bhshell-0.1.0 → bhshell-0.2.0}/bhshell/library/asrep-roastable-users.cyp +0 -0
- {bhshell-0.1.0 → bhshell-0.2.0}/bhshell/library/dcsync-principals.cyp +0 -0
- {bhshell-0.1.0 → bhshell-0.2.0}/bhshell/library/domain-admins-members.cyp +0 -0
- {bhshell-0.1.0 → bhshell-0.2.0}/bhshell/library/domain-trusts.cyp +0 -0
- {bhshell-0.1.0 → bhshell-0.2.0}/bhshell/library/gpos-linked-to-domain-controllers.cyp +0 -0
- {bhshell-0.1.0 → bhshell-0.2.0}/bhshell/library/kerberoastable-users.cyp +0 -0
- {bhshell-0.1.0 → bhshell-0.2.0}/bhshell/library/owned-objects.cyp +0 -0
- {bhshell-0.1.0 → bhshell-0.2.0}/bhshell/library/owned-to-tier-zero.cyp +0 -0
- {bhshell-0.1.0 → bhshell-0.2.0}/bhshell/library/sid-history.cyp +0 -0
- {bhshell-0.1.0 → bhshell-0.2.0}/bhshell/library/tier-zero-users.cyp +0 -0
- {bhshell-0.1.0 → bhshell-0.2.0}/bhshell/library/unconstrained-delegation.cyp +0 -0
- {bhshell-0.1.0 → bhshell-0.2.0}/bhshell/models/__init__.py +0 -0
- {bhshell-0.1.0 → bhshell-0.2.0}/bhshell/queries/__init__.py +0 -0
- {bhshell-0.1.0 → bhshell-0.2.0}/bhshell/queries/library.py +0 -0
- {bhshell-0.1.0 → bhshell-0.2.0}/bhshell/render/__init__.py +0 -0
- {bhshell-0.1.0 → bhshell-0.2.0}/bhshell/render/graph.py +0 -0
- {bhshell-0.1.0 → bhshell-0.2.0}/bhshell/render/layout.py +0 -0
- {bhshell-0.1.0 → bhshell-0.2.0}/bhshell/render/png.py +0 -0
- {bhshell-0.1.0 → bhshell-0.2.0}/bhshell/render/styles.py +0 -0
- {bhshell-0.1.0 → bhshell-0.2.0}/bhshell/render/svg.py +0 -0
- {bhshell-0.1.0 → bhshell-0.2.0}/bhshell/shell/__init__.py +0 -0
- {bhshell-0.1.0 → bhshell-0.2.0}/bhshell/shell/app.py +0 -0
- {bhshell-0.1.0 → bhshell-0.2.0}/bhshell/shell/commands/__init__.py +0 -0
- {bhshell-0.1.0 → bhshell-0.2.0}/bhshell/shell/commands/connection.py +0 -0
- {bhshell-0.1.0 → bhshell-0.2.0}/bhshell/shell/commands/library.py +0 -0
- {bhshell-0.1.0 → bhshell-0.2.0}/bhshell.egg-info/dependency_links.txt +0 -0
- {bhshell-0.1.0 → bhshell-0.2.0}/bhshell.egg-info/entry_points.txt +0 -0
- {bhshell-0.1.0 → bhshell-0.2.0}/bhshell.egg-info/requires.txt +0 -0
- {bhshell-0.1.0 → bhshell-0.2.0}/bhshell.egg-info/top_level.txt +0 -0
- {bhshell-0.1.0 → bhshell-0.2.0}/pyproject.toml +0 -0
- {bhshell-0.1.0 → bhshell-0.2.0}/setup.cfg +0 -0
- {bhshell-0.1.0 → bhshell-0.2.0}/tests/__init__.py +0 -0
- {bhshell-0.1.0 → bhshell-0.2.0}/tests/conftest.py +0 -0
- {bhshell-0.1.0 → bhshell-0.2.0}/tests/test_client.py +0 -0
- {bhshell-0.1.0 → bhshell-0.2.0}/tests/test_graph.py +0 -0
- {bhshell-0.1.0 → bhshell-0.2.0}/tests/test_png.py +0 -0
- {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.
|
|
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
|
-
|
|
39
|
-
[
|
|
39
|
+
[](https://pypi.org/project/bhshell/)
|
|
40
40
|
[](https://github.com/kr0no/bloodhound-shell/actions/workflows/ci.yml)
|
|
41
41
|
[](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
|
|
66
|
-
critical dcsync
|
|
67
|
-
|
|
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
|
|
70
|
+
[*] Details: 'hunt CHECK' (e.g. 'hunt dcsync'), or 'hunt --all'.
|
|
70
71
|
|
|
71
|
-
[bh: shell] > user
|
|
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
|
-
|
|
4
|
-
[
|
|
4
|
+
[](https://pypi.org/project/bhshell/)
|
|
5
5
|
[](https://github.com/kr0no/bloodhound-shell/actions/workflows/ci.yml)
|
|
6
6
|
[](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
|
|
31
|
-
critical dcsync
|
|
32
|
-
|
|
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
|
|
35
|
+
[*] Details: 'hunt CHECK' (e.g. 'hunt dcsync'), or 'hunt --all'.
|
|
35
36
|
|
|
36
|
-
[bh: shell] > user
|
|
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 |
|
|
@@ -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
|
-
|
|
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
|
-
|
|
322
|
-
|
|
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
|
-
|
|
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(
|
|
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
|