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.
- {bhshell-0.1.1/bhshell.egg-info → bhshell-0.2.0}/PKG-INFO +13 -8
- {bhshell-0.1.1 → bhshell-0.2.0}/README.md +12 -7
- {bhshell-0.1.1 → bhshell-0.2.0}/bhshell/__init__.py +1 -1
- {bhshell-0.1.1 → bhshell-0.2.0}/bhshell/client/client.py +2 -0
- {bhshell-0.1.1 → bhshell-0.2.0}/bhshell/config.py +17 -1
- {bhshell-0.1.1 → bhshell-0.2.0}/bhshell/models/entities.py +133 -4
- {bhshell-0.1.1 → bhshell-0.2.0}/bhshell/models/results.py +67 -9
- bhshell-0.2.0/bhshell/queries/adcs.py +247 -0
- {bhshell-0.1.1 → bhshell-0.2.0}/bhshell/queries/containers.py +100 -32
- {bhshell-0.1.1 → bhshell-0.2.0}/bhshell/queries/cypher.py +59 -1
- {bhshell-0.1.1 → bhshell-0.2.0}/bhshell/queries/domains.py +44 -5
- {bhshell-0.1.1 → bhshell-0.2.0}/bhshell/queries/edges.py +24 -2
- bhshell-0.2.0/bhshell/queries/gpos.py +275 -0
- {bhshell-0.1.1 → bhshell-0.2.0}/bhshell/queries/hunt.py +67 -15
- {bhshell-0.1.1 → bhshell-0.2.0}/bhshell/queries/marking.py +7 -1
- {bhshell-0.1.1 → bhshell-0.2.0}/bhshell/queries/paths.py +21 -9
- {bhshell-0.1.1 → bhshell-0.2.0}/bhshell/queries/search.py +8 -3
- {bhshell-0.1.1 → bhshell-0.2.0}/bhshell/queries/service.py +126 -27
- {bhshell-0.1.1 → bhshell-0.2.0}/bhshell/render/export.py +11 -1
- {bhshell-0.1.1 → bhshell-0.2.0}/bhshell/render/views.py +28 -7
- {bhshell-0.1.1 → bhshell-0.2.0}/bhshell/shell/commands/base.py +10 -1
- {bhshell-0.1.1 → bhshell-0.2.0}/bhshell/shell/commands/cypher.py +4 -4
- {bhshell-0.1.1 → bhshell-0.2.0}/bhshell/shell/commands/domains.py +13 -4
- {bhshell-0.1.1 → bhshell-0.2.0}/bhshell/shell/commands/entities.py +90 -20
- {bhshell-0.1.1 → bhshell-0.2.0}/bhshell/shell/commands/hunt.py +3 -1
- {bhshell-0.1.1 → bhshell-0.2.0}/bhshell/shell/commands/marking.py +3 -1
- {bhshell-0.1.1 → bhshell-0.2.0}/bhshell/shell/commands/search.py +1 -0
- {bhshell-0.1.1 → bhshell-0.2.0/bhshell.egg-info}/PKG-INFO +13 -8
- {bhshell-0.1.1 → bhshell-0.2.0}/bhshell.egg-info/SOURCES.txt +3 -0
- {bhshell-0.1.1 → bhshell-0.2.0}/docs/USAGE.md +192 -57
- bhshell-0.2.0/tests/test_adcs.py +260 -0
- {bhshell-0.1.1 → bhshell-0.2.0}/tests/test_analysis.py +49 -0
- {bhshell-0.1.1 → bhshell-0.2.0}/tests/test_containers.py +148 -1
- {bhshell-0.1.1 → bhshell-0.2.0}/tests/test_disabled_paths.py +20 -6
- {bhshell-0.1.1 → bhshell-0.2.0}/tests/test_hunt.py +16 -0
- {bhshell-0.1.1 → bhshell-0.2.0}/tests/test_search.py +12 -2
- {bhshell-0.1.1 → bhshell-0.2.0}/tests/test_service.py +87 -4
- {bhshell-0.1.1 → bhshell-0.2.0}/tests/test_shell.py +38 -1
- {bhshell-0.1.1 → bhshell-0.2.0}/LICENSE +0 -0
- {bhshell-0.1.1 → bhshell-0.2.0}/MANIFEST.in +0 -0
- {bhshell-0.1.1 → bhshell-0.2.0}/bhshell/__main__.py +0 -0
- {bhshell-0.1.1 → bhshell-0.2.0}/bhshell/cli.py +0 -0
- {bhshell-0.1.1 → bhshell-0.2.0}/bhshell/client/__init__.py +0 -0
- {bhshell-0.1.1 → bhshell-0.2.0}/bhshell/client/auth.py +0 -0
- {bhshell-0.1.1 → bhshell-0.2.0}/bhshell/errors.py +0 -0
- {bhshell-0.1.1 → bhshell-0.2.0}/bhshell/library/asrep-roastable-users.cyp +0 -0
- {bhshell-0.1.1 → bhshell-0.2.0}/bhshell/library/dcsync-principals.cyp +0 -0
- {bhshell-0.1.1 → bhshell-0.2.0}/bhshell/library/domain-admins-members.cyp +0 -0
- {bhshell-0.1.1 → bhshell-0.2.0}/bhshell/library/domain-trusts.cyp +0 -0
- {bhshell-0.1.1 → bhshell-0.2.0}/bhshell/library/gpos-linked-to-domain-controllers.cyp +0 -0
- {bhshell-0.1.1 → bhshell-0.2.0}/bhshell/library/kerberoastable-users.cyp +0 -0
- {bhshell-0.1.1 → bhshell-0.2.0}/bhshell/library/owned-objects.cyp +0 -0
- {bhshell-0.1.1 → bhshell-0.2.0}/bhshell/library/owned-to-tier-zero.cyp +0 -0
- {bhshell-0.1.1 → bhshell-0.2.0}/bhshell/library/sid-history.cyp +0 -0
- {bhshell-0.1.1 → bhshell-0.2.0}/bhshell/library/tier-zero-users.cyp +0 -0
- {bhshell-0.1.1 → bhshell-0.2.0}/bhshell/library/unconstrained-delegation.cyp +0 -0
- {bhshell-0.1.1 → bhshell-0.2.0}/bhshell/models/__init__.py +0 -0
- {bhshell-0.1.1 → bhshell-0.2.0}/bhshell/queries/__init__.py +0 -0
- {bhshell-0.1.1 → bhshell-0.2.0}/bhshell/queries/library.py +0 -0
- {bhshell-0.1.1 → bhshell-0.2.0}/bhshell/render/__init__.py +0 -0
- {bhshell-0.1.1 → bhshell-0.2.0}/bhshell/render/graph.py +0 -0
- {bhshell-0.1.1 → bhshell-0.2.0}/bhshell/render/layout.py +0 -0
- {bhshell-0.1.1 → bhshell-0.2.0}/bhshell/render/png.py +0 -0
- {bhshell-0.1.1 → bhshell-0.2.0}/bhshell/render/styles.py +0 -0
- {bhshell-0.1.1 → bhshell-0.2.0}/bhshell/render/svg.py +0 -0
- {bhshell-0.1.1 → bhshell-0.2.0}/bhshell/shell/__init__.py +0 -0
- {bhshell-0.1.1 → bhshell-0.2.0}/bhshell/shell/app.py +0 -0
- {bhshell-0.1.1 → bhshell-0.2.0}/bhshell/shell/commands/__init__.py +0 -0
- {bhshell-0.1.1 → bhshell-0.2.0}/bhshell/shell/commands/connection.py +0 -0
- {bhshell-0.1.1 → bhshell-0.2.0}/bhshell/shell/commands/library.py +0 -0
- {bhshell-0.1.1 → bhshell-0.2.0}/bhshell/shell/commands/paths.py +0 -0
- {bhshell-0.1.1 → bhshell-0.2.0}/bhshell.egg-info/dependency_links.txt +0 -0
- {bhshell-0.1.1 → bhshell-0.2.0}/bhshell.egg-info/entry_points.txt +0 -0
- {bhshell-0.1.1 → bhshell-0.2.0}/bhshell.egg-info/requires.txt +0 -0
- {bhshell-0.1.1 → bhshell-0.2.0}/bhshell.egg-info/top_level.txt +0 -0
- {bhshell-0.1.1 → bhshell-0.2.0}/pyproject.toml +0 -0
- {bhshell-0.1.1 → bhshell-0.2.0}/setup.cfg +0 -0
- {bhshell-0.1.1 → bhshell-0.2.0}/tests/__init__.py +0 -0
- {bhshell-0.1.1 → bhshell-0.2.0}/tests/conftest.py +0 -0
- {bhshell-0.1.1 → bhshell-0.2.0}/tests/test_client.py +0 -0
- {bhshell-0.1.1 → bhshell-0.2.0}/tests/test_graph.py +0 -0
- {bhshell-0.1.1 → bhshell-0.2.0}/tests/test_png.py +0 -0
- {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.
|
|
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
|
|
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
|
|
72
73
|
|
|
73
|
-
[bh: shell] > ou corp
|
|
74
|
+
[bh: shell] > ou phantom.corp -t
|
|
74
75
|
|
|
75
|
-
[bh: shell] > computer
|
|
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
|
|
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
|
|
37
38
|
|
|
38
|
-
[bh: shell] > ou corp
|
|
39
|
+
[bh: shell] > ou phantom.corp -t
|
|
39
40
|
|
|
40
|
-
[bh: shell] > computer
|
|
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 |
|
|
@@ -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:
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
|