bhshell 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- bhshell/__init__.py +3 -0
- bhshell/__main__.py +3 -0
- bhshell/cli.py +55 -0
- bhshell/client/__init__.py +0 -0
- bhshell/client/auth.py +74 -0
- bhshell/client/client.py +337 -0
- bhshell/config.py +162 -0
- bhshell/errors.py +73 -0
- bhshell/library/asrep-roastable-users.cyp +3 -0
- bhshell/library/dcsync-principals.cyp +3 -0
- bhshell/library/domain-admins-members.cyp +3 -0
- bhshell/library/domain-trusts.cyp +3 -0
- bhshell/library/gpos-linked-to-domain-controllers.cyp +3 -0
- bhshell/library/kerberoastable-users.cyp +3 -0
- bhshell/library/owned-objects.cyp +3 -0
- bhshell/library/owned-to-tier-zero.cyp +3 -0
- bhshell/library/sid-history.cyp +3 -0
- bhshell/library/tier-zero-users.cyp +3 -0
- bhshell/library/unconstrained-delegation.cyp +3 -0
- bhshell/models/__init__.py +0 -0
- bhshell/models/entities.py +402 -0
- bhshell/models/results.py +83 -0
- bhshell/queries/__init__.py +0 -0
- bhshell/queries/cypher.py +155 -0
- bhshell/queries/domains.py +136 -0
- bhshell/queries/edges.py +109 -0
- bhshell/queries/hunt.py +675 -0
- bhshell/queries/library.py +94 -0
- bhshell/queries/marking.py +121 -0
- bhshell/queries/paths.py +369 -0
- bhshell/queries/search.py +196 -0
- bhshell/queries/service.py +529 -0
- bhshell/render/__init__.py +0 -0
- bhshell/render/export.py +79 -0
- bhshell/render/graph.py +326 -0
- bhshell/render/layout.py +138 -0
- bhshell/render/png.py +44 -0
- bhshell/render/styles.py +5 -0
- bhshell/render/svg.py +495 -0
- bhshell/render/views.py +337 -0
- bhshell/shell/__init__.py +0 -0
- bhshell/shell/app.py +189 -0
- bhshell/shell/commands/__init__.py +0 -0
- bhshell/shell/commands/base.py +60 -0
- bhshell/shell/commands/connection.py +236 -0
- bhshell/shell/commands/cypher.py +97 -0
- bhshell/shell/commands/domains.py +80 -0
- bhshell/shell/commands/entities.py +88 -0
- bhshell/shell/commands/hunt.py +151 -0
- bhshell/shell/commands/library.py +56 -0
- bhshell/shell/commands/marking.py +59 -0
- bhshell/shell/commands/paths.py +166 -0
- bhshell/shell/commands/search.py +125 -0
- bhshell-0.1.0.dist-info/METADATA +134 -0
- bhshell-0.1.0.dist-info/RECORD +59 -0
- bhshell-0.1.0.dist-info/WHEEL +5 -0
- bhshell-0.1.0.dist-info/entry_points.txt +2 -0
- bhshell-0.1.0.dist-info/licenses/LICENSE +21 -0
- bhshell-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
"""Connection, authentication and configuration commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import os
|
|
7
|
+
import time
|
|
8
|
+
from dataclasses import fields
|
|
9
|
+
from getpass import getpass
|
|
10
|
+
|
|
11
|
+
from cmd2 import Cmd2ArgumentParser, RawDescriptionCmd2HelpFormatter, with_argparser
|
|
12
|
+
from rich.text import Text
|
|
13
|
+
|
|
14
|
+
from bhshell.config import CachedSession, Config
|
|
15
|
+
from bhshell.errors import (
|
|
16
|
+
ApiError,
|
|
17
|
+
AuthError,
|
|
18
|
+
BHError,
|
|
19
|
+
CypherDisabledError,
|
|
20
|
+
LoginFailed,
|
|
21
|
+
MFARequired,
|
|
22
|
+
NotFoundError,
|
|
23
|
+
)
|
|
24
|
+
from bhshell.shell.commands.base import BaseCommands
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _url_parser() -> Cmd2ArgumentParser:
|
|
28
|
+
p = Cmd2ArgumentParser(description="Show or set the BH backend URL (default http://127.0.0.1:8080/).")
|
|
29
|
+
p.add_argument("url", nargs="?", help="new backend URL, e.g. https://BH.lab:8443")
|
|
30
|
+
return p
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _tls_parser() -> Cmd2ArgumentParser:
|
|
34
|
+
p = Cmd2ArgumentParser(description="Show or toggle TLS certificate verification.")
|
|
35
|
+
p.add_argument("state", nargs="?", choices=["on", "off"], help="'off' accepts self-signed certificates")
|
|
36
|
+
return p
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _login_parser() -> Cmd2ArgumentParser:
|
|
40
|
+
p = Cmd2ArgumentParser(
|
|
41
|
+
description="Authenticate to BH.",
|
|
42
|
+
epilog=(
|
|
43
|
+
"Username/password (JWT): secrets are prompted for, or read from BH_PASSWORD.\n"
|
|
44
|
+
"API token (HMAC): use --token; the ID/key are read from BH_TOKEN_ID / BH_TOKEN_KEY\n"
|
|
45
|
+
"or prompted for. Secrets are never accepted as arguments so they stay out of history."
|
|
46
|
+
),
|
|
47
|
+
formatter_class=RawDescriptionCmd2HelpFormatter,
|
|
48
|
+
)
|
|
49
|
+
p.add_argument("-u", "--username", help="BH username (defaults to the last one used)")
|
|
50
|
+
p.add_argument("--otp", metavar="CODE", help="TOTP one-time password, if MFA is enabled")
|
|
51
|
+
p.add_argument("--token", action="store_true", help="authenticate with an API token (ID + key)")
|
|
52
|
+
p.add_argument("--token-id", metavar="ID", help="API token ID (implies --token)")
|
|
53
|
+
return p
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _config_parser() -> Cmd2ArgumentParser:
|
|
57
|
+
keys = [f.name for f in fields(Config)]
|
|
58
|
+
p = Cmd2ArgumentParser(description="Show or change persistent settings (~/.bhshell/config.toml).")
|
|
59
|
+
p.add_argument("key", nargs="?", choices=keys, help="setting name")
|
|
60
|
+
p.add_argument("value", nargs="?", help="new value")
|
|
61
|
+
return p
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class ConnectionCommands(BaseCommands):
|
|
65
|
+
DEFAULT_CATEGORY = "Connection"
|
|
66
|
+
|
|
67
|
+
@with_argparser(_url_parser)
|
|
68
|
+
def do_url(self, args: argparse.Namespace) -> None:
|
|
69
|
+
"""Show or set the BH backend URL."""
|
|
70
|
+
app = self.app
|
|
71
|
+
if not args.url:
|
|
72
|
+
app.ui.info(f"Backend URL: {app.client.base_url}")
|
|
73
|
+
return
|
|
74
|
+
previous = app.client.base_url
|
|
75
|
+
was_auth = app.client.is_authenticated
|
|
76
|
+
app.client.set_url(args.url)
|
|
77
|
+
app.config.url = app.client.base_url
|
|
78
|
+
app.config.save()
|
|
79
|
+
if app.client.base_url != previous:
|
|
80
|
+
app.entities.reset()
|
|
81
|
+
if was_auth:
|
|
82
|
+
CachedSession.clear()
|
|
83
|
+
app.ui.warn("Backend changed: local session cleared, run 'login' again.")
|
|
84
|
+
app.ui.success(f"Backend URL set to {app.client.base_url}")
|
|
85
|
+
|
|
86
|
+
@with_argparser(_tls_parser)
|
|
87
|
+
def do_tls(self, args: argparse.Namespace) -> None:
|
|
88
|
+
"""Show or toggle TLS certificate verification."""
|
|
89
|
+
app = self.app
|
|
90
|
+
if args.state:
|
|
91
|
+
app.config.verify_tls = args.state == "on"
|
|
92
|
+
app.config.save()
|
|
93
|
+
app.apply_tls_setting()
|
|
94
|
+
state = "on" if app.config.verify_tls else "off (insecure)"
|
|
95
|
+
app.ui.info(f"TLS certificate verification: {state}")
|
|
96
|
+
|
|
97
|
+
@with_argparser(_login_parser)
|
|
98
|
+
def do_login(self, args: argparse.Namespace) -> None:
|
|
99
|
+
"""Authenticate with username/password (JWT) or an API token (HMAC)."""
|
|
100
|
+
if args.token or args.token_id:
|
|
101
|
+
self._login_token(args.token_id)
|
|
102
|
+
else:
|
|
103
|
+
self._login_password(args.username, args.otp)
|
|
104
|
+
|
|
105
|
+
def _login_token(self, token_id: str | None) -> None:
|
|
106
|
+
app = self.app
|
|
107
|
+
token_id = token_id or os.environ.get("BH_TOKEN_ID") or app.read_input("Token ID: ").strip()
|
|
108
|
+
token_key = os.environ.get("BH_TOKEN_KEY") or getpass("Token key: ")
|
|
109
|
+
if not token_id or not token_key:
|
|
110
|
+
raise BHError("Token ID and key are required")
|
|
111
|
+
previous = app.client.auth_state()
|
|
112
|
+
app.client.set_hmac(token_id, token_key)
|
|
113
|
+
try:
|
|
114
|
+
me = app.client.self_info()
|
|
115
|
+
except BHError as exc:
|
|
116
|
+
app.client.restore_auth(previous) # keep the session that was active before
|
|
117
|
+
if isinstance(exc, AuthError):
|
|
118
|
+
raise LoginFailed(f"API token rejected: {exc.message}") from exc
|
|
119
|
+
raise
|
|
120
|
+
CachedSession.clear() # a token login supersedes any cached JWT
|
|
121
|
+
app.entities.reset()
|
|
122
|
+
app.ui.success(f"Authenticated with API token as {me.get('principal_name') or token_id}")
|
|
123
|
+
|
|
124
|
+
def _login_password(self, username: str | None, otp: str | None) -> None:
|
|
125
|
+
app = self.app
|
|
126
|
+
username = username or app.read_input(f"Username [{app.config.last_username}]: ").strip()
|
|
127
|
+
username = username or app.config.last_username
|
|
128
|
+
if not username:
|
|
129
|
+
raise BHError("A username is required")
|
|
130
|
+
password = os.environ.get("BH_PASSWORD") or getpass("Password: ")
|
|
131
|
+
try:
|
|
132
|
+
data = app.client.login(username, password, otp)
|
|
133
|
+
except MFARequired:
|
|
134
|
+
code = app.read_input("One-time password: ").strip()
|
|
135
|
+
data = app.client.login(username, password, code)
|
|
136
|
+
app.entities.reset()
|
|
137
|
+
app.config.last_username = username
|
|
138
|
+
app.config.save()
|
|
139
|
+
if app.config.cache_session:
|
|
140
|
+
CachedSession(url=app.client.base_url, token=app.client.jwt, username=username).save()
|
|
141
|
+
app.ui.success(f"Logged in as {username} on {app.client.host}")
|
|
142
|
+
if data.get("auth_expired"):
|
|
143
|
+
app.ui.warn("The password for this account has expired; change it in the BH UI.")
|
|
144
|
+
|
|
145
|
+
def do_logout(self, _: object) -> None:
|
|
146
|
+
"""Invalidate the current session and clear cached credentials."""
|
|
147
|
+
app = self.app
|
|
148
|
+
if not app.client.is_authenticated:
|
|
149
|
+
app.ui.info("Not logged in.")
|
|
150
|
+
return
|
|
151
|
+
app.client.logout()
|
|
152
|
+
CachedSession.clear()
|
|
153
|
+
app.entities.reset()
|
|
154
|
+
app.ui.success("Logged out.")
|
|
155
|
+
|
|
156
|
+
def do_whoami(self, _: object) -> None:
|
|
157
|
+
"""Show connection state, the authenticated principal and token validity."""
|
|
158
|
+
app = self.app
|
|
159
|
+
client = app.client
|
|
160
|
+
rows: list[tuple[str, object]] = [
|
|
161
|
+
("Backend", client.base_url),
|
|
162
|
+
("TLS verification", "on" if client.verify_tls else Text("off", style="yellow")),
|
|
163
|
+
]
|
|
164
|
+
if not client.is_authenticated:
|
|
165
|
+
rows.append(("State", Text("disconnected", style="red")))
|
|
166
|
+
app.ui.key_values("Status", rows)
|
|
167
|
+
return
|
|
168
|
+
me = client.self_info()
|
|
169
|
+
roles = ", ".join(r.get("name", "") for r in me.get("roles") or []) or "-"
|
|
170
|
+
rows += [
|
|
171
|
+
("State", Text("authenticated", style="green")),
|
|
172
|
+
("Auth mode", "JWT session" if client.auth_mode == "jwt" else f"API token ({client.token_id})"),
|
|
173
|
+
("Principal", me.get("principal_name") or client.username or "-"),
|
|
174
|
+
("Email", me.get("email_address") or "-"),
|
|
175
|
+
("Roles", roles),
|
|
176
|
+
]
|
|
177
|
+
exp = client.token_expiry()
|
|
178
|
+
if exp is not None:
|
|
179
|
+
minutes = max(int(exp - time.time()), 0) // 60
|
|
180
|
+
expires = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(exp))
|
|
181
|
+
rows.append(("Token expires", f"{expires} (in {minutes} min)"))
|
|
182
|
+
try:
|
|
183
|
+
version = client.version()
|
|
184
|
+
rows.append(("Server version", version.get("server_version") or "-"))
|
|
185
|
+
except (NotFoundError, ApiError):
|
|
186
|
+
rows.append(("Server version", "-"))
|
|
187
|
+
rows.append(("Cypher", self._cypher_state()))
|
|
188
|
+
app.ui.key_values("Status", rows)
|
|
189
|
+
|
|
190
|
+
def _cypher_state(self) -> Text:
|
|
191
|
+
entities = self.app.entities
|
|
192
|
+
if entities.cypher_available is None:
|
|
193
|
+
try:
|
|
194
|
+
entities.run_cypher("MATCH (n:Domain) RETURN n LIMIT 1")
|
|
195
|
+
except CypherDisabledError:
|
|
196
|
+
pass
|
|
197
|
+
except ApiError as exc:
|
|
198
|
+
return Text(f"error ({exc.detail})", style="yellow")
|
|
199
|
+
if entities.cypher_available:
|
|
200
|
+
return Text("available", style="green")
|
|
201
|
+
return Text("unavailable (using REST fallback)", style="yellow")
|
|
202
|
+
|
|
203
|
+
def do_status(self, args: object) -> None:
|
|
204
|
+
"""Alias of 'whoami'."""
|
|
205
|
+
self.do_whoami(args)
|
|
206
|
+
|
|
207
|
+
@with_argparser(_config_parser)
|
|
208
|
+
def do_config(self, args: argparse.Namespace) -> None:
|
|
209
|
+
"""Show or change persistent settings."""
|
|
210
|
+
app = self.app
|
|
211
|
+
cfg = app.config
|
|
212
|
+
if args.key and args.value is not None:
|
|
213
|
+
if args.key == "url":
|
|
214
|
+
self.do_url(args.value)
|
|
215
|
+
return
|
|
216
|
+
try:
|
|
217
|
+
value = Config.coerce(args.key, args.value)
|
|
218
|
+
except (TypeError, ValueError) as exc:
|
|
219
|
+
raise BHError(f"Invalid value for {args.key}: {exc}") from exc
|
|
220
|
+
setattr(cfg, args.key, value)
|
|
221
|
+
cfg.save()
|
|
222
|
+
self._apply(cfg)
|
|
223
|
+
app.ui.success(f"{args.key} = {value!r}")
|
|
224
|
+
return
|
|
225
|
+
keys = [args.key] if args.key else [f.name for f in fields(Config)]
|
|
226
|
+
app.ui.key_values(f"Config ({Config.path()})", [(k, repr(getattr(cfg, k))) for k in keys])
|
|
227
|
+
|
|
228
|
+
def _apply(self, cfg: Config) -> None:
|
|
229
|
+
app = self.app
|
|
230
|
+
app.client.timeout = (cfg.connect_timeout, cfg.read_timeout)
|
|
231
|
+
app.client.page_size = cfg.page_size
|
|
232
|
+
app.entities.max_results = cfg.max_results
|
|
233
|
+
app.apply_tls_setting()
|
|
234
|
+
if not cfg.cache_session:
|
|
235
|
+
CachedSession.clear()
|
|
236
|
+
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Raw Cypher command."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from cmd2 import Statement
|
|
10
|
+
|
|
11
|
+
from bhshell.errors import BHError
|
|
12
|
+
from bhshell.shell.commands.base import BaseCommands, output_path
|
|
13
|
+
|
|
14
|
+
CYPHER_HELP = """\
|
|
15
|
+
Usage: cypher [--json] [-o FILE] [-f QUERY_FILE] [QUERY]
|
|
16
|
+
|
|
17
|
+
Run a raw Cypher query against BH (POST /api/v2/graphs/cypher) and render
|
|
18
|
+
the returned nodes and edges. The query is taken verbatim from the command
|
|
19
|
+
line, so '|', '>' and quotes need no escaping. The query must return nodes or
|
|
20
|
+
paths, e.g.:
|
|
21
|
+
|
|
22
|
+
cypher MATCH (n:User) WHERE n.hasspn = true RETURN n LIMIT 20
|
|
23
|
+
cypher MATCH p=(u:User)-[:MemberOf*1..]->(g:Group) WHERE g.name STARTS WITH 'DOMAIN ADMINS@' RETURN p
|
|
24
|
+
cypher -f queries/kerberoastable.cyp -o kerberoastable.csv
|
|
25
|
+
|
|
26
|
+
Options:
|
|
27
|
+
--json print the normalised result as JSON
|
|
28
|
+
-o FILE export to FILE (.json or .csv; CSV contains the nodes)
|
|
29
|
+
-f FILE read the query from FILE
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class CypherArgs:
|
|
35
|
+
query: str
|
|
36
|
+
json: bool = False
|
|
37
|
+
output: str | None = None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def parse_cypher_args(text: str) -> CypherArgs:
|
|
41
|
+
"""Parse leading options from a raw `cypher` argument string."""
|
|
42
|
+
rest = text.strip()
|
|
43
|
+
args = CypherArgs(query="")
|
|
44
|
+
query_file: str | None = None
|
|
45
|
+
while rest.startswith("-"):
|
|
46
|
+
flag, _, remainder = rest.partition(" ")
|
|
47
|
+
remainder = remainder.lstrip()
|
|
48
|
+
if flag == "--json":
|
|
49
|
+
args.json = True
|
|
50
|
+
elif flag in ("-o", "--output", "-f", "--file"):
|
|
51
|
+
value, _, remainder = remainder.partition(" ")
|
|
52
|
+
if not value:
|
|
53
|
+
raise BHError(f"Option {flag} requires a value")
|
|
54
|
+
if flag in ("-o", "--output"):
|
|
55
|
+
try:
|
|
56
|
+
args.output = output_path(value)
|
|
57
|
+
except argparse.ArgumentTypeError as exc:
|
|
58
|
+
raise BHError(str(exc)) from exc
|
|
59
|
+
else:
|
|
60
|
+
query_file = value
|
|
61
|
+
else:
|
|
62
|
+
break
|
|
63
|
+
rest = remainder.lstrip()
|
|
64
|
+
if query_file:
|
|
65
|
+
try:
|
|
66
|
+
rest = Path(query_file).expanduser().read_text(encoding="utf-8")
|
|
67
|
+
except OSError as exc:
|
|
68
|
+
raise BHError(f"Cannot read {query_file}: {exc.strerror}") from exc
|
|
69
|
+
rest = rest.strip()
|
|
70
|
+
if len(rest) > 1 and rest[0] in "\"'" and rest[-1] == rest[0]:
|
|
71
|
+
rest = rest[1:-1].strip()
|
|
72
|
+
args.query = rest
|
|
73
|
+
return args
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class CypherCommands(BaseCommands):
|
|
77
|
+
DEFAULT_CATEGORY = "Cypher"
|
|
78
|
+
|
|
79
|
+
def do_cypher(self, statement: Statement) -> None:
|
|
80
|
+
"""Run a raw Cypher query (see 'help cypher')."""
|
|
81
|
+
raw = statement.raw.strip()
|
|
82
|
+
text = raw.split(None, 1)[1] if " " in raw or "\t" in raw else ""
|
|
83
|
+
if text.strip() in ("-h", "--help") or not text.strip():
|
|
84
|
+
self.app.poutput(CYPHER_HELP)
|
|
85
|
+
return
|
|
86
|
+
args = parse_cypher_args(text)
|
|
87
|
+
if not args.query:
|
|
88
|
+
raise BHError("No query given")
|
|
89
|
+
app = self.app
|
|
90
|
+
app.require_auth()
|
|
91
|
+
graph = app.entities.run_cypher(args.query)
|
|
92
|
+
for node in graph.nodes.values():
|
|
93
|
+
app.remember(node)
|
|
94
|
+
self.show_graph(graph, as_json=args.json, output=args.output)
|
|
95
|
+
|
|
96
|
+
def help_cypher(self) -> None:
|
|
97
|
+
self.app.poutput(CYPHER_HELP)
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Domain overview and trust commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
|
|
7
|
+
from cmd2 import Cmd2ArgumentParser, with_argparser
|
|
8
|
+
|
|
9
|
+
from bhshell.models.results import Column
|
|
10
|
+
from bhshell.queries.domains import DomainService
|
|
11
|
+
from bhshell.render import export
|
|
12
|
+
from bhshell.shell.commands.base import BaseCommands, add_output_args
|
|
13
|
+
|
|
14
|
+
DOMAIN_COLS = [
|
|
15
|
+
Column("Domain", "name"), Column("SID", "sid"), Column("Level", "functional_level"),
|
|
16
|
+
Column("Users (enabled)", "users_fmt"), Column("Computers (enabled)", "computers_fmt"),
|
|
17
|
+
Column("Groups", "groups"), Column("Tier Zero", "tier_zero"), Column("Last collected", "last_collected"),
|
|
18
|
+
]
|
|
19
|
+
TRUST_COLS = [
|
|
20
|
+
Column("Trusting (source)", "trusting"), Column("Trusted (target)", "trusted"), Column("Kind", "kind"),
|
|
21
|
+
Column("Type", "trust_type"), Column("Transitive", "transitive", "bool"),
|
|
22
|
+
Column("SID filtering", "sid_filtering_fmt"), Column("Abuse edges", "abuse"), Column("Issues", "issues"),
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _output_parser(description: str) -> Cmd2ArgumentParser:
|
|
27
|
+
p = Cmd2ArgumentParser(description=description)
|
|
28
|
+
add_output_args(p)
|
|
29
|
+
return p
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _pair(total: object, enabled: object) -> str:
|
|
33
|
+
if total is None:
|
|
34
|
+
return "-"
|
|
35
|
+
return f"{total} ({enabled if enabled is not None else '-'})"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class DomainCommands(BaseCommands):
|
|
39
|
+
DEFAULT_CATEGORY = "Analysis"
|
|
40
|
+
|
|
41
|
+
@with_argparser(lambda: _output_parser("Per-domain overview (object counts, functional level, collection)."))
|
|
42
|
+
def do_domains(self, args: argparse.Namespace) -> None:
|
|
43
|
+
"""Per-domain overview."""
|
|
44
|
+
app = self.app
|
|
45
|
+
app.require_auth()
|
|
46
|
+
infos = DomainService(app.entities).domains()
|
|
47
|
+
rows = [d.as_dict() for d in infos]
|
|
48
|
+
if args.json:
|
|
49
|
+
app.poutput(export.to_json(rows))
|
|
50
|
+
else:
|
|
51
|
+
display = [{**r, "users_fmt": _pair(r["users"], r["enabled_users"]),
|
|
52
|
+
"computers_fmt": _pair(r["computers"], r["enabled_computers"])} for r in rows]
|
|
53
|
+
app.ui.table("Domains", DOMAIN_COLS, display)
|
|
54
|
+
if args.output:
|
|
55
|
+
fields = list(rows[0]) if rows else ["name"]
|
|
56
|
+
self.export(args.output, rows, (fields, rows))
|
|
57
|
+
|
|
58
|
+
@with_argparser(lambda: _output_parser(
|
|
59
|
+
"Domain trusts. 'A trusts B' means principals of B can access resources in A."))
|
|
60
|
+
def do_trusts(self, args: argparse.Namespace) -> None:
|
|
61
|
+
"""Domain trust map with SID-filtering status."""
|
|
62
|
+
app = self.app
|
|
63
|
+
app.require_auth()
|
|
64
|
+
trusts = DomainService(app.entities).trusts()
|
|
65
|
+
rows = [t.as_dict() for t in trusts]
|
|
66
|
+
if args.json:
|
|
67
|
+
app.poutput(export.to_json(rows))
|
|
68
|
+
else:
|
|
69
|
+
display = []
|
|
70
|
+
for t, r in zip(trusts, rows, strict=True):
|
|
71
|
+
if t.same_forest:
|
|
72
|
+
sid = "n/a (same forest)"
|
|
73
|
+
else:
|
|
74
|
+
sid = {True: "enforced", False: "DISABLED", None: "unknown (default: enforced)"}[t.sid_filtering]
|
|
75
|
+
display.append({**r, "sid_filtering_fmt": sid})
|
|
76
|
+
app.ui.table("Trusts", TRUST_COLS, display,
|
|
77
|
+
notes=["Access flows from the trusted (target) domain into the trusting (source) domain."])
|
|
78
|
+
if args.output:
|
|
79
|
+
fields = list(rows[0]) if rows else ["trusting"]
|
|
80
|
+
self.export(args.output, rows, (fields, rows))
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Entity lookup commands: user, computer, group, lookup."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
|
|
7
|
+
from cmd2 import Cmd2ArgumentParser, RawDescriptionCmd2HelpFormatter, with_argparser
|
|
8
|
+
|
|
9
|
+
from bhshell.queries.service import VIEWS
|
|
10
|
+
from bhshell.render import export
|
|
11
|
+
from bhshell.shell.commands.base import BaseCommands, add_output_args
|
|
12
|
+
|
|
13
|
+
_NAME_HELP = "samaccountname, NAME@DOMAIN, DOMAIN\\NAME, FQDN (computers) or SID"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _entity_parser(kind: str | None) -> Cmd2ArgumentParser:
|
|
17
|
+
what = kind.lower() if kind else "object (user, computer or group; type auto-detected)"
|
|
18
|
+
p = Cmd2ArgumentParser(
|
|
19
|
+
description=f"Show a triage summary for a {what}, and optional relationship views.",
|
|
20
|
+
epilog="Without view flags the summary is shown. With view flags only those views are shown;\n"
|
|
21
|
+
"--all shows the summary plus every applicable view.",
|
|
22
|
+
formatter_class=RawDescriptionCmd2HelpFormatter,
|
|
23
|
+
)
|
|
24
|
+
p.add_argument("name", help=_NAME_HELP, choices_provider=BaseCommands.recent_names)
|
|
25
|
+
p.add_argument("-d", "--domain", help="restrict the lookup to a domain (FQDN or NetBIOS)")
|
|
26
|
+
|
|
27
|
+
views = p.add_argument_group("relationship views")
|
|
28
|
+
views.add_argument("-g", "--groups", action="store_true", help="group memberships (direct + nested)")
|
|
29
|
+
if kind in (None, "Group"):
|
|
30
|
+
views.add_argument("-m", "--members", action="store_true", help="group members (direct + nested)")
|
|
31
|
+
views.add_argument("-O", "--outbound", action="store_true", help="dangerous rights held over other objects")
|
|
32
|
+
views.add_argument("-I", "--inbound", action="store_true", help="principals with dangerous rights over it")
|
|
33
|
+
views.add_argument("-s", "--sessions", action="store_true", help="HasSession relationships")
|
|
34
|
+
views.add_argument("-p", "--local-admin", "--privs", dest="privs", action="store_true",
|
|
35
|
+
help="AdminTo / CanRDP / ExecuteDCOM / CanPSRemote / SQLAdmin")
|
|
36
|
+
views.add_argument("-a", "--all", action="store_true", help="summary + every applicable view")
|
|
37
|
+
|
|
38
|
+
out = p.add_argument_group("output")
|
|
39
|
+
out.add_argument("-n", "--max-rows", type=int, metavar="N",
|
|
40
|
+
help="max table rows per view (default: config display_rows; 0 = all)")
|
|
41
|
+
add_output_args(out)
|
|
42
|
+
return p
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class EntityCommands(BaseCommands):
|
|
46
|
+
DEFAULT_CATEGORY = "Entities"
|
|
47
|
+
|
|
48
|
+
def _run(self, args: argparse.Namespace, kind: str | None) -> None:
|
|
49
|
+
app = self.app
|
|
50
|
+
app.require_auth()
|
|
51
|
+
node = app.entities.resolve(args.name, kind, args.domain)
|
|
52
|
+
app.remember(node)
|
|
53
|
+
|
|
54
|
+
if args.all:
|
|
55
|
+
selected = app.entities.applicable_views(node)
|
|
56
|
+
else:
|
|
57
|
+
requested = [key for key in VIEWS if getattr(args, key, False)]
|
|
58
|
+
selected = [key for key in requested if node.kind in VIEWS[key]]
|
|
59
|
+
for key in set(requested) - set(selected):
|
|
60
|
+
app.ui.warn(f"View '{key}' does not apply to a {node.kind}; skipped.")
|
|
61
|
+
report = app.entities.report(node, selected, show_summary=args.all or not selected)
|
|
62
|
+
|
|
63
|
+
if args.json:
|
|
64
|
+
app.poutput(export.to_json(report.as_dict()))
|
|
65
|
+
else:
|
|
66
|
+
app.ui.report(report, self.max_rows(args))
|
|
67
|
+
if args.output:
|
|
68
|
+
self.export(args.output, report.as_dict(), export.report_rows(report))
|
|
69
|
+
|
|
70
|
+
@with_argparser(lambda: _entity_parser("User"))
|
|
71
|
+
def do_user(self, args: argparse.Namespace) -> None:
|
|
72
|
+
"""Query a user by samaccountname."""
|
|
73
|
+
self._run(args, "User")
|
|
74
|
+
|
|
75
|
+
@with_argparser(lambda: _entity_parser("Computer"))
|
|
76
|
+
def do_computer(self, args: argparse.Namespace) -> None:
|
|
77
|
+
"""Query a computer by name / samaccountname / FQDN."""
|
|
78
|
+
self._run(args, "Computer")
|
|
79
|
+
|
|
80
|
+
@with_argparser(lambda: _entity_parser("Group"))
|
|
81
|
+
def do_group(self, args: argparse.Namespace) -> None:
|
|
82
|
+
"""Query a group by name."""
|
|
83
|
+
self._run(args, "Group")
|
|
84
|
+
|
|
85
|
+
@with_argparser(lambda: _entity_parser(None))
|
|
86
|
+
def do_lookup(self, args: argparse.Namespace) -> None:
|
|
87
|
+
"""Query any user, computer or group (type auto-detected)."""
|
|
88
|
+
self._run(args, None)
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Domain-wide weakness checks."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
from collections.abc import Sequence
|
|
7
|
+
|
|
8
|
+
from cmd2 import (
|
|
9
|
+
Choices,
|
|
10
|
+
Cmd2ArgumentParser,
|
|
11
|
+
RawDescriptionCmd2HelpFormatter,
|
|
12
|
+
with_argparser,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
from bhshell.errors import BHError
|
|
16
|
+
from bhshell.models.results import Column
|
|
17
|
+
from bhshell.queries.hunt import (
|
|
18
|
+
CHECKS,
|
|
19
|
+
CHECKS_BY_ID,
|
|
20
|
+
FROM_OWNED,
|
|
21
|
+
SCOPED_CHECKS,
|
|
22
|
+
Check,
|
|
23
|
+
Finding,
|
|
24
|
+
HuntOptions,
|
|
25
|
+
HuntService,
|
|
26
|
+
)
|
|
27
|
+
from bhshell.queries.service import PATH_KINDS, truncation_note
|
|
28
|
+
from bhshell.render import export
|
|
29
|
+
from bhshell.shell.commands.base import BaseCommands, add_output_args
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _check_id(value: str) -> str:
|
|
33
|
+
# A type check rather than `choices=`: before Python 3.12, argparse validates the empty default
|
|
34
|
+
# of an `nargs="*"` positional against `choices` and rejects a bare `hunt`.
|
|
35
|
+
if value not in CHECKS_BY_ID:
|
|
36
|
+
raise argparse.ArgumentTypeError(f"invalid choice: '{value}' (choose from {', '.join(CHECKS_BY_ID)})")
|
|
37
|
+
return value
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _hunt_parser() -> Cmd2ArgumentParser:
|
|
41
|
+
p = Cmd2ArgumentParser(
|
|
42
|
+
description="Run domain-wide weakness checks.",
|
|
43
|
+
epilog="hunt summary of every check\n"
|
|
44
|
+
"hunt CHECK ... details of the given checks\n"
|
|
45
|
+
"hunt --all details of every check\n"
|
|
46
|
+
"hunt --list describe the checks\n"
|
|
47
|
+
"hunt --owned what the owned objects (and their groups) can do\n"
|
|
48
|
+
"hunt --from NAME the same, from one object\n\n"
|
|
49
|
+
"Checks: " + ", ".join(c.id for c in CHECKS),
|
|
50
|
+
formatter_class=RawDescriptionCmd2HelpFormatter,
|
|
51
|
+
)
|
|
52
|
+
p.add_argument("checks", nargs="*", type=_check_id, metavar="CHECK", help="check id(s)",
|
|
53
|
+
choices_provider=lambda _self: Choices.from_values(CHECKS_BY_ID, is_sorted=True))
|
|
54
|
+
p.add_argument("--all", action="store_true", help="show details of every check")
|
|
55
|
+
p.add_argument("--list", action="store_true", help="list available checks")
|
|
56
|
+
scope = p.add_mutually_exclusive_group()
|
|
57
|
+
scope.add_argument("--owned", action="store_true",
|
|
58
|
+
help="only rights held by owned objects and their groups (checks marked 'scoped')")
|
|
59
|
+
scope.add_argument("--from", dest="source", metavar="NAME",
|
|
60
|
+
help="only rights held by NAME and its groups (checks marked 'scoped')",
|
|
61
|
+
choices_provider=BaseCommands.recent_names)
|
|
62
|
+
p.add_argument("-d", "--domain", help="restrict to a domain (FQDN or NetBIOS)")
|
|
63
|
+
p.add_argument("--days", type=int, default=HuntOptions.days,
|
|
64
|
+
help="password age threshold for stale-passwords (days)")
|
|
65
|
+
p.add_argument("-n", "--max-rows", type=int, metavar="N",
|
|
66
|
+
help="max table rows per check (default: config display_rows; 0 = all)")
|
|
67
|
+
add_output_args(p)
|
|
68
|
+
return p
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _csv_rows(findings: list[Finding]) -> tuple[list[str], list[dict]]:
|
|
72
|
+
fields: list[str] = ["check", "severity"]
|
|
73
|
+
rows: list[dict] = []
|
|
74
|
+
for f in findings:
|
|
75
|
+
for row in f.rows:
|
|
76
|
+
for key in row:
|
|
77
|
+
if key not in fields:
|
|
78
|
+
fields.append(key)
|
|
79
|
+
rows.append({"check": f.check.id, "severity": f.check.severity, **row})
|
|
80
|
+
return fields, rows
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class HuntCommands(BaseCommands):
|
|
84
|
+
DEFAULT_CATEGORY = "Analysis"
|
|
85
|
+
|
|
86
|
+
@with_argparser(_hunt_parser)
|
|
87
|
+
def do_hunt(self, args: argparse.Namespace) -> None:
|
|
88
|
+
"""Run domain-wide weakness checks (Kerberoasting, delegation, DCSync, AD CS, trusts, ...)."""
|
|
89
|
+
app = self.app
|
|
90
|
+
if args.list:
|
|
91
|
+
rows = [{"severity": c.severity, "id": c.id, "scoped": c.scoped, "description": c.description}
|
|
92
|
+
for c in CHECKS]
|
|
93
|
+
app.ui.table("Hunt checks", [Column("Severity", "severity", "severity"), Column("Check", "id"),
|
|
94
|
+
Column("Scoped", "scoped", "bool"), Column("Description", "description")],
|
|
95
|
+
rows)
|
|
96
|
+
return
|
|
97
|
+
app.require_auth()
|
|
98
|
+
detail = args.all or bool(args.checks)
|
|
99
|
+
service = HuntService(app.entities)
|
|
100
|
+
opts = HuntOptions(domain=args.domain or "", days=args.days, limit=app.config.max_results,
|
|
101
|
+
enrich=detail or args.json or bool(args.output))
|
|
102
|
+
checks = [CHECKS_BY_ID[i] for i in dict.fromkeys(args.checks)] if args.checks else CHECKS
|
|
103
|
+
if args.owned or args.source:
|
|
104
|
+
checks = self._scoped_checks(checks, bool(args.checks))
|
|
105
|
+
opts.principals = self._scope(service, args)
|
|
106
|
+
findings = [service.run(check, opts) for check in checks]
|
|
107
|
+
|
|
108
|
+
if args.json:
|
|
109
|
+
app.poutput(export.to_json([f.as_dict() for f in findings]))
|
|
110
|
+
elif not detail:
|
|
111
|
+
app.ui.hunt_summary(findings)
|
|
112
|
+
flag = " --owned" if args.owned else f" --from {args.source}" if args.source else ""
|
|
113
|
+
example = "tier-zero-control" if flag else "kerberoastable"
|
|
114
|
+
app.ui.info(f"Details: 'hunt CHECK{flag}' (e.g. 'hunt {example}{flag}'), or 'hunt --all{flag}'.")
|
|
115
|
+
else:
|
|
116
|
+
max_rows = self.max_rows(args)
|
|
117
|
+
for f in findings:
|
|
118
|
+
notes = [f.check.description]
|
|
119
|
+
if f.truncated:
|
|
120
|
+
notes.append(truncation_note(opts.limit, [f.total], "results"))
|
|
121
|
+
if f.error:
|
|
122
|
+
notes.append(f"error: {f.error}")
|
|
123
|
+
notes += f.notes
|
|
124
|
+
columns = f.check.columns
|
|
125
|
+
if opts.scoped:
|
|
126
|
+
columns = (*(f.check.scoped_columns or columns), FROM_OWNED)
|
|
127
|
+
app.ui.table(f"[{f.check.severity}] {f.check.title}", columns, f.rows,
|
|
128
|
+
max_rows=max_rows, notes=notes)
|
|
129
|
+
if args.output:
|
|
130
|
+
self.export(args.output, [f.as_dict() for f in findings], _csv_rows(findings))
|
|
131
|
+
|
|
132
|
+
@staticmethod
|
|
133
|
+
def _scoped_checks(checks: Sequence[Check], explicit: bool) -> list[Check]:
|
|
134
|
+
"""The checks that run with --owned / --from: all of them when named explicitly, or an error."""
|
|
135
|
+
if not explicit:
|
|
136
|
+
return list(SCOPED_CHECKS)
|
|
137
|
+
unscoped = [c.id for c in checks if not c.scoped]
|
|
138
|
+
if unscoped:
|
|
139
|
+
raise BHError(f"{', '.join(unscoped)}: not available with --owned / --from",
|
|
140
|
+
hint="'hunt --list' shows which checks are scoped.")
|
|
141
|
+
return list(checks)
|
|
142
|
+
|
|
143
|
+
def _scope(self, service: HuntService, args: argparse.Namespace) -> dict[str, list[str]]:
|
|
144
|
+
if args.owned:
|
|
145
|
+
scope = service.owned_scope()
|
|
146
|
+
if not scope:
|
|
147
|
+
raise BHError("Nothing is marked owned", hint="Mark objects with 'owned add NAME'.")
|
|
148
|
+
return scope
|
|
149
|
+
node = self.app.entities.resolve(args.source, domain=args.domain, kinds=PATH_KINDS)
|
|
150
|
+
self.app.remember(node)
|
|
151
|
+
return service.principal_scope(node)
|