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.
Files changed (59) hide show
  1. bhshell/__init__.py +3 -0
  2. bhshell/__main__.py +3 -0
  3. bhshell/cli.py +55 -0
  4. bhshell/client/__init__.py +0 -0
  5. bhshell/client/auth.py +74 -0
  6. bhshell/client/client.py +337 -0
  7. bhshell/config.py +162 -0
  8. bhshell/errors.py +73 -0
  9. bhshell/library/asrep-roastable-users.cyp +3 -0
  10. bhshell/library/dcsync-principals.cyp +3 -0
  11. bhshell/library/domain-admins-members.cyp +3 -0
  12. bhshell/library/domain-trusts.cyp +3 -0
  13. bhshell/library/gpos-linked-to-domain-controllers.cyp +3 -0
  14. bhshell/library/kerberoastable-users.cyp +3 -0
  15. bhshell/library/owned-objects.cyp +3 -0
  16. bhshell/library/owned-to-tier-zero.cyp +3 -0
  17. bhshell/library/sid-history.cyp +3 -0
  18. bhshell/library/tier-zero-users.cyp +3 -0
  19. bhshell/library/unconstrained-delegation.cyp +3 -0
  20. bhshell/models/__init__.py +0 -0
  21. bhshell/models/entities.py +402 -0
  22. bhshell/models/results.py +83 -0
  23. bhshell/queries/__init__.py +0 -0
  24. bhshell/queries/cypher.py +155 -0
  25. bhshell/queries/domains.py +136 -0
  26. bhshell/queries/edges.py +109 -0
  27. bhshell/queries/hunt.py +675 -0
  28. bhshell/queries/library.py +94 -0
  29. bhshell/queries/marking.py +121 -0
  30. bhshell/queries/paths.py +369 -0
  31. bhshell/queries/search.py +196 -0
  32. bhshell/queries/service.py +529 -0
  33. bhshell/render/__init__.py +0 -0
  34. bhshell/render/export.py +79 -0
  35. bhshell/render/graph.py +326 -0
  36. bhshell/render/layout.py +138 -0
  37. bhshell/render/png.py +44 -0
  38. bhshell/render/styles.py +5 -0
  39. bhshell/render/svg.py +495 -0
  40. bhshell/render/views.py +337 -0
  41. bhshell/shell/__init__.py +0 -0
  42. bhshell/shell/app.py +189 -0
  43. bhshell/shell/commands/__init__.py +0 -0
  44. bhshell/shell/commands/base.py +60 -0
  45. bhshell/shell/commands/connection.py +236 -0
  46. bhshell/shell/commands/cypher.py +97 -0
  47. bhshell/shell/commands/domains.py +80 -0
  48. bhshell/shell/commands/entities.py +88 -0
  49. bhshell/shell/commands/hunt.py +151 -0
  50. bhshell/shell/commands/library.py +56 -0
  51. bhshell/shell/commands/marking.py +59 -0
  52. bhshell/shell/commands/paths.py +166 -0
  53. bhshell/shell/commands/search.py +125 -0
  54. bhshell-0.1.0.dist-info/METADATA +134 -0
  55. bhshell-0.1.0.dist-info/RECORD +59 -0
  56. bhshell-0.1.0.dist-info/WHEEL +5 -0
  57. bhshell-0.1.0.dist-info/entry_points.txt +2 -0
  58. bhshell-0.1.0.dist-info/licenses/LICENSE +21 -0
  59. 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)