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,56 @@
1
+ """Saved query library command."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+
7
+ from cmd2 import Choices, Cmd2ArgumentParser, with_argparser
8
+
9
+ from bhshell.errors import NotFoundError
10
+ from bhshell.models.results import Column
11
+ from bhshell.queries.library import QueryLibrary, query_dirs, user_dir
12
+ from bhshell.shell.commands.base import BaseCommands, add_output_args
13
+
14
+
15
+ def _query_parser() -> Cmd2ArgumentParser:
16
+ p = Cmd2ArgumentParser(description=f"Saved Cypher queries: bundled, {user_dir()}/*.cyp, the folders in "
17
+ "'config query_dirs', and queries saved on the server.")
18
+ sub = p.add_subparsers(dest="action", required=True, metavar="{list,show,run}")
19
+ sub.add_parser("list", help="list available queries")
20
+ show = sub.add_parser("show", help="print a query")
21
+ show.add_argument("name", choices_provider=LibraryCommands.query_names)
22
+ run = sub.add_parser("run", help="run a query")
23
+ run.add_argument("name", choices_provider=LibraryCommands.query_names)
24
+ add_output_args(run)
25
+ return p
26
+
27
+
28
+ class LibraryCommands(BaseCommands):
29
+ DEFAULT_CATEGORY = "Analysis"
30
+
31
+ def _library(self) -> QueryLibrary:
32
+ return QueryLibrary(self.app.client, extra_dirs=query_dirs(self.app.config.query_dirs))
33
+
34
+ def query_names(self) -> Choices:
35
+ """Tab-completion provider (local queries only, to stay fast)."""
36
+ return Choices.from_values(self._library().load(include_server=False))
37
+
38
+ @with_argparser(_query_parser)
39
+ def do_query(self, args: argparse.Namespace) -> None:
40
+ """List, show or run saved Cypher queries."""
41
+ app = self.app
42
+ library = self._library()
43
+ queries = library.load()
44
+ if args.action == "list":
45
+ rows = [{"name": q.name, "source": q.source, "description": q.description} for q in queries.values()]
46
+ app.ui.table("Saved queries", [Column("Name", "name"), Column("Source", "source"),
47
+ Column("Description", "description")], rows, notes=library.notes)
48
+ return
49
+ query = queries.get(args.name)
50
+ if query is None:
51
+ raise NotFoundError(f"No saved query named '{args.name}'", hint="Run 'query list'.")
52
+ if args.action == "show":
53
+ app.poutput(query.query)
54
+ return
55
+ app.require_auth()
56
+ self.show_graph(app.entities.run_cypher(query.query), as_json=args.json, output=args.output)
@@ -0,0 +1,59 @@
1
+ """Owned / high-value (Tier Zero) marking commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+
7
+ from cmd2 import Cmd2ArgumentParser, with_argparser
8
+
9
+ from bhshell.queries.marking import Tag
10
+ from bhshell.render import export
11
+ from bhshell.shell.commands.base import BaseCommands, add_output_args
12
+
13
+ _LABEL = {"owned": "Owned", "tier0": "Tier Zero / High Value"}
14
+
15
+
16
+ def _marking_parser(tag: Tag) -> Cmd2ArgumentParser:
17
+ label = _LABEL[tag]
18
+ p = Cmd2ArgumentParser(description=f"List or change {label} objects.")
19
+ sub = p.add_subparsers(dest="action", required=True, metavar="{list,add,remove}")
20
+ ls = sub.add_parser("list", help=f"list {label} objects")
21
+ add_output_args(ls)
22
+ for action in ("add", "remove"):
23
+ a = sub.add_parser(action, help=f"{action} an object {'to' if action == 'add' else 'from'} {label}")
24
+ a.add_argument("name", help="samaccountname, NAME@DOMAIN, DOMAIN\\NAME or SID",
25
+ choices_provider=BaseCommands.recent_names)
26
+ a.add_argument("-t", "--type", choices=["user", "computer", "group"], help="object type (default: any)")
27
+ a.add_argument("-d", "--domain", help="restrict the lookup to a domain")
28
+ return p
29
+
30
+
31
+ class MarkingCommands(BaseCommands):
32
+ DEFAULT_CATEGORY = "Marking"
33
+
34
+ def _run(self, args: argparse.Namespace, tag: Tag) -> None:
35
+ app = self.app
36
+ app.require_auth()
37
+ if args.action == "list":
38
+ nodes = app.marking.list(tag)
39
+ if args.json:
40
+ app.poutput(export.to_json([n.as_dict() for n in nodes]))
41
+ else:
42
+ app.ui.nodes(nodes, _LABEL[tag])
43
+ if args.output:
44
+ self.export(args.output, [n.as_dict() for n in nodes], export.node_rows(nodes))
45
+ return
46
+ kind = args.type.capitalize() if args.type else None
47
+ node = app.entities.resolve(args.name, kind, args.domain)
48
+ app.remember(node)
49
+ app.ui.success(app.marking.mark(node, tag, add=args.action == "add"))
50
+
51
+ @with_argparser(lambda: _marking_parser("owned"))
52
+ def do_owned(self, args: argparse.Namespace) -> None:
53
+ """List, add or remove owned objects."""
54
+ self._run(args, "owned")
55
+
56
+ @with_argparser(lambda: _marking_parser("tier0"))
57
+ def do_hvt(self, args: argparse.Namespace) -> None:
58
+ """List, add or remove Tier Zero / high value targets."""
59
+ self._run(args, "tier0")
@@ -0,0 +1,166 @@
1
+ """Attack-path command."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import itertools
7
+ from dataclasses import replace
8
+ from pathlib import Path
9
+
10
+ from cmd2 import Choices, Cmd2ArgumentParser, RawDescriptionCmd2HelpFormatter, with_argparser
11
+
12
+ from bhshell.errors import BHError
13
+ from bhshell.models.entities import Node
14
+ from bhshell.queries.edges import EDGE_GROUPS
15
+ from bhshell.queries.paths import PathResult, PathService
16
+ from bhshell.queries.service import PATH_KINDS
17
+ from bhshell.render import export, png, svg
18
+ from bhshell.render.views import first_n
19
+ from bhshell.shell.commands.base import BaseCommands, add_output_args
20
+
21
+
22
+ def _path_parser() -> Cmd2ArgumentParser:
23
+ p = Cmd2ArgumentParser(
24
+ description="Find attack paths.",
25
+ epilog="path SRC DST shortest path between two objects\n"
26
+ "path SRC shortest paths from SRC to any Tier Zero object\n"
27
+ "path SRC [DST] --via X go through X on the way (repeat --via for more steps, in order)\n"
28
+ "path --owned [DST] shortest paths from every owned object to Tier Zero (or to DST)",
29
+ formatter_class=RawDescriptionCmd2HelpFormatter,
30
+ )
31
+ p.add_argument("src", nargs="?", help="source object (user, computer, group, domain, OU, GPO)",
32
+ choices_provider=BaseCommands.recent_names)
33
+ p.add_argument("dst", nargs="?", help="destination object (default: Tier Zero)",
34
+ choices_provider=BaseCommands.recent_names)
35
+ p.add_argument("--via", action="append", default=[], metavar="NAME", choices_provider=BaseCommands.recent_names,
36
+ help="intermediate step (repeatable, visited in the order given)")
37
+ p.add_argument("--owned", action="store_true", help="start from every owned object")
38
+ p.add_argument("--exclude", action="append", default=[], metavar="EDGE", type=_edge_group,
39
+ choices_provider=lambda _self: Choices.from_values(sorted(EDGE_GROUPS)),
40
+ help="skip these edges/groups: " + ", ".join(sorted(EDGE_GROUPS)) + " (repeatable)")
41
+ p.add_argument("-d", "--domain", help="restrict name resolution to a domain")
42
+ p.add_argument("--include-disabled", action="store_true",
43
+ help="allow paths through disabled objects that cannot be re-enabled")
44
+ p.add_argument("-g", "--graph", action="store_true",
45
+ help="draw the paths as a graph (asks which ones when there are several)")
46
+ p.add_argument("-p", "--pick", type=_selection, metavar="N",
47
+ help="only these paths, numbered as in the list: 2, 1,3 or 2-4")
48
+ p.add_argument("-n", "--max-paths", type=int, default=20, metavar="N", help="paths shown (default 20, 0 = all)")
49
+ add_output_args(p, formats=(".json", ".csv", ".svg", ".png"))
50
+ p.add_argument("--svg-layout", choices=("auto", "horizontal", "vertical", "wrapped"), default="auto",
51
+ help="SVG/PNG layout (default auto: whichever prints largest on a portrait page; "
52
+ "wrapped: rows of hops, like lines of text)")
53
+ p.add_argument("--png-scale", type=_scale, default=2.0, metavar="N",
54
+ help="PNG pixels per SVG pixel (default 2: sharp in print, same size in Word)")
55
+ return p
56
+
57
+
58
+ def _edge_group(text: str) -> frozenset[str]:
59
+ try:
60
+ return EDGE_GROUPS[text.lower()]
61
+ except KeyError:
62
+ raise argparse.ArgumentTypeError(
63
+ f"unknown edge group {text!r} (choose from {', '.join(sorted(EDGE_GROUPS))})") from None
64
+
65
+
66
+ def _scale(text: str) -> float:
67
+ try:
68
+ value = float(text)
69
+ except ValueError:
70
+ raise argparse.ArgumentTypeError(f"not a number: {text!r}") from None
71
+ if not 1 <= value <= 8:
72
+ raise argparse.ArgumentTypeError("must be between 1 and 8")
73
+ return value
74
+
75
+
76
+ def _selection(text: str) -> list[int]:
77
+ """Parse `2`, `1,3` or `2-4` into path numbers."""
78
+ numbers: list[int] = []
79
+ try:
80
+ for part in text.replace(" ", "").split(","):
81
+ first, _, last = part.partition("-")
82
+ numbers += range(int(first), int(last or first) + 1)
83
+ except ValueError:
84
+ raise argparse.ArgumentTypeError(f"not a path selection: {text!r} (use e.g. 2, 1,3 or 2-4)") from None
85
+ if not numbers or min(numbers) < 1:
86
+ raise argparse.ArgumentTypeError(f"path numbers start at 1: {text!r}")
87
+ return list(dict.fromkeys(numbers))
88
+
89
+
90
+ def _pick(result: PathResult, numbers: list[int]) -> PathResult:
91
+ if max(numbers) > len(result.paths):
92
+ raise BHError(f"There are only {len(result.paths)} path(s); cannot pick {max(numbers)}")
93
+ picked = [result.paths[n - 1] for n in numbers]
94
+ note = f"Showing path {', '.join(map(str, numbers))} of {len(result.paths)}."
95
+ return replace(result, paths=picked, notes=[*result.notes, note], numbers=numbers)
96
+
97
+
98
+ class PathCommands(BaseCommands):
99
+ DEFAULT_CATEGORY = "Analysis"
100
+
101
+ @with_argparser(_path_parser)
102
+ def do_path(self, args: argparse.Namespace) -> None:
103
+ """Find attack paths between objects or towards Tier Zero."""
104
+ app = self.app
105
+ app.require_auth()
106
+ exclude = frozenset().union(*args.exclude) if args.exclude else frozenset()
107
+ service = PathService(app.entities, include_disabled=args.include_disabled, exclude=exclude)
108
+ if args.owned:
109
+ if args.src or args.via:
110
+ raise BHError("--owned takes no SRC or --via (an optional DST is allowed)")
111
+ if args.dst:
112
+ result = service.owned_to(self._resolve(args.dst, args.domain))
113
+ else:
114
+ result = service.owned_to_tier_zero()
115
+ elif not args.src:
116
+ raise BHError("Give a SRC object or --owned")
117
+ else:
118
+ src = self._resolve(args.src, args.domain)
119
+ waypoints = [self._resolve(name, args.domain) for name in args.via]
120
+ dst = self._resolve(args.dst, args.domain) if args.dst else None
121
+ steps = [src, *waypoints] + ([dst] if dst else [])
122
+ for a, b in itertools.pairwise(steps):
123
+ if a.objectid == b.objectid:
124
+ raise BHError(f"{a.name} appears twice in a row in the route")
125
+ if waypoints:
126
+ result = service.route(src, waypoints, dst)
127
+ elif dst:
128
+ result = service.shortest(src, dst)
129
+ else:
130
+ result = service.to_tier_zero(src)
131
+ if args.pick:
132
+ result = _pick(result, args.pick)
133
+ elif args.graph and not args.json and len(result.paths) > 1 and app.can_ask():
134
+ app.ui.paths(result, args.max_paths)
135
+ shown = len(first_n(result.paths, args.max_paths))
136
+ answer = app.read_input(f"Path(s) to draw [1-{shown}, e.g. 2, 1,3 or 2-4; Enter = all]: ").strip()
137
+ if answer:
138
+ try:
139
+ numbers = _selection(answer)
140
+ except argparse.ArgumentTypeError as exc:
141
+ raise BHError(str(exc)) from exc
142
+ result = _pick(result, numbers)
143
+ if args.json:
144
+ app.poutput(export.to_json(result.as_dict()))
145
+ elif args.graph:
146
+ app.ui.path_graph(result, args.max_paths)
147
+ else:
148
+ app.ui.paths(result, args.max_paths)
149
+ if args.output and args.output.lower().endswith((".svg", ".png")):
150
+ shown = first_n(result.paths, args.max_paths)
151
+ out = Path(args.output).expanduser()
152
+ document = svg.render(result, shown, args.svg_layout)
153
+ if out.suffix.lower() == ".png":
154
+ out.write_bytes(png.from_svg(document, args.png_scale))
155
+ else:
156
+ out.write_text(document, encoding="utf-8")
157
+ app.ui.success(f"Diagram of {len(shown)} path(s) written to {out}")
158
+ elif args.output:
159
+ rows = result.rows()
160
+ fields = ["path", "hop", "source", "edge", "target", "target_kind", "tier_zero"]
161
+ self.export(args.output, result.as_dict(), (fields, rows))
162
+
163
+ def _resolve(self, name: str, domain: str | None) -> Node:
164
+ node = self.app.entities.resolve(name, domain=domain, kinds=PATH_KINDS)
165
+ self.app.remember(node)
166
+ return node
@@ -0,0 +1,125 @@
1
+ """Attribute search command."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import shlex
7
+
8
+ from cmd2 import (
9
+ Choices,
10
+ Cmd2ArgparseError,
11
+ Cmd2ArgumentParser,
12
+ RawDescriptionCmd2HelpFormatter,
13
+ with_argparser,
14
+ )
15
+
16
+ from bhshell.errors import BHError
17
+ from bhshell.models.entities import PRIMARY_KINDS
18
+ from bhshell.queries.search import (
19
+ COMMON_PROPS,
20
+ SearchOptions,
21
+ SearchService,
22
+ parse_filter,
23
+ )
24
+ from bhshell.render import export
25
+ from bhshell.shell.commands.base import BaseCommands, add_output_args
26
+
27
+ _KINDS = {k.lower(): k for k in PRIMARY_KINDS}
28
+
29
+
30
+ def _kind(value: str) -> str:
31
+ try:
32
+ return _KINDS[value.lower()]
33
+ except KeyError:
34
+ raise argparse.ArgumentTypeError(f"unknown type '{value}' (one of: {', '.join(PRIMARY_KINDS)})") from None
35
+
36
+
37
+ def _filter_names() -> list[str]:
38
+ return [*COMMON_PROPS, *(f"!{p}" for p in COMMON_PROPS)]
39
+
40
+
41
+ def _search_parser() -> Cmd2ArgumentParser:
42
+ p = Cmd2ArgumentParser(
43
+ description="List objects whose properties match every FILTER.",
44
+ epilog="Filters (ANDed):\n"
45
+ " ATTR true hasspn\n"
46
+ " !ATTR false or unset !enabled\n"
47
+ " ATTR=VALUE equals samaccountname=svc_sql\n"
48
+ " ATTR!=VALUE differs\n"
49
+ " ATTR~TEXT contains description~pass\n"
50
+ " ATTR!~TEXT doesn't contain\n"
51
+ " ATTR<VALUE > <= >= compare pwdlastset<2020-01-01 lastlogontimestamp<90d\n\n"
52
+ "Text matching ignores case; '*' at either end of a value is a wildcard (name=svc_*).\n"
53
+ "VALUE can be true/false, null (unset), a number, a date (YYYY-MM-DD) or an age (90d =\n"
54
+ "90 days ago). Quote filters containing spaces: 'description~service account'.\n\n"
55
+ "Examples:\n"
56
+ " search -t user hasspn enabled\n"
57
+ " search -t computer operatingsystem~2012 -d CORP\n"
58
+ " search -t user pwdneverexpires 'pwdlastset<5000d' -c lastlogon -o old.csv",
59
+ formatter_class=RawDescriptionCmd2HelpFormatter,
60
+ )
61
+ p.add_argument("filters", nargs="*", metavar="FILTER", help="property filter (see below)",
62
+ choices_provider=lambda _self: Choices.from_values(_filter_names()))
63
+ p.add_argument("-t", "--type", type=_kind, metavar="TYPE",
64
+ choices_provider=lambda _self: Choices.from_values(PRIMARY_KINDS),
65
+ help="object type (User, Computer, Group, OU, GPO, ...; default: any)")
66
+ p.add_argument("-d", "--domain", help="restrict to a domain (FQDN or NetBIOS)")
67
+ p.add_argument("-c", "--columns", action="append", default=[], metavar="ATTR[,ATTR...]",
68
+ help="extra properties to show (filtered properties are always shown)")
69
+ p.add_argument("-l", "--limit", type=int, metavar="N", help="max results fetched (default: config max_results)")
70
+ p.add_argument("-n", "--max-rows", type=int, metavar="N",
71
+ help="max table rows shown (default: config display_rows; 0 = all)")
72
+ add_output_args(p)
73
+ return p
74
+
75
+
76
+ def _uncut(args: argparse.Namespace) -> argparse.Namespace:
77
+ """Re-read the arguments when cmd2 cut them at '>', '|' or ';'.
78
+
79
+ Redirection is disabled, but cmd2 still ends the argument list at those characters, which would
80
+ silently drop the rest of `lastlogon>=90d enabled`.
81
+ """
82
+ stmt = args.cmd2_statement
83
+ if not (stmt.redirector or stmt.terminator):
84
+ return args
85
+ try:
86
+ return _search_parser().parse_args(shlex.split(stmt.raw.split(None, 1)[1]))
87
+ except ValueError as exc:
88
+ raise BHError(f"Cannot parse arguments: {exc}") from exc
89
+ except SystemExit as exc: # usage error: argparse already printed it
90
+ raise Cmd2ArgparseError from exc
91
+
92
+
93
+ class SearchCommands(BaseCommands):
94
+ DEFAULT_CATEGORY = "Entities"
95
+
96
+ @with_argparser(_search_parser)
97
+ def do_search(self, args: argparse.Namespace) -> None:
98
+ """List objects matching property filters (e.g. 'search -t user hasspn enabled')."""
99
+ app = self.app
100
+ args = _uncut(args)
101
+ if not (args.filters or args.type or args.domain):
102
+ raise BHError("Give at least one FILTER, -t TYPE or -d DOMAIN (see 'help search')")
103
+ if args.limit is not None and args.limit < 1:
104
+ raise BHError("--limit must be at least 1")
105
+ opts = SearchOptions(filters=[parse_filter(f) for f in args.filters], kind=args.type,
106
+ domain=args.domain or "", limit=args.limit or app.config.max_results)
107
+ columns = [c.strip() for spec in args.columns for c in spec.split(",") if c.strip()]
108
+ app.require_auth()
109
+ result = SearchService(app.entities).search(opts, columns)
110
+ for node in result.nodes:
111
+ app.remember(node)
112
+
113
+ if args.json:
114
+ app.poutput(export.to_json(result.as_dict()))
115
+ else:
116
+ notes = [f"Filters: {' AND '.join(args.filters)}"] if args.filters else []
117
+ if result.truncated:
118
+ total = f" of {result.total:,}" if result.total is not None else ""
119
+ notes.append(f"Only the first {opts.limit:,}{total} results were fetched (raise --limit).")
120
+ app.ui.table(f"Search: {opts.kind or 'any type'}", result.columns, result.rows(),
121
+ max_rows=self.max_rows(args), notes=notes)
122
+ if args.output:
123
+ fields = [c.attr for c in result.columns]
124
+ fields.insert(3, "objectid")
125
+ self.export(args.output, result.as_dict(), (fields, result.rows()))
@@ -0,0 +1,134 @@
1
+ Metadata-Version: 2.4
2
+ Name: bhshell
3
+ Version: 0.1.0
4
+ Summary: Interactive shell for BloodHound
5
+ Author-email: kr0no <kr0no@ka0labs.net>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/kr0no/bloodhound-shell
8
+ Project-URL: Issues, https://github.com/kr0no/bloodhound-shell/issues
9
+ Project-URL: Author, https://kr0no.me
10
+ Keywords: bloodhound,bloodhound-ce,active-directory,attack-paths,cypher,pentest,red-team,security,cli,shell
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Information Technology
14
+ Classifier: Intended Audience :: System Administrators
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3 :: Only
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Programming Language :: Python :: 3.14
22
+ Classifier: Topic :: Security
23
+ Classifier: Topic :: System :: Systems Administration
24
+ Requires-Python: >=3.11
25
+ Description-Content-Type: text/markdown
26
+ License-File: LICENSE
27
+ Requires-Dist: cmd2<5,>=4.3
28
+ Requires-Dist: rich>=13.0
29
+ Requires-Dist: requests>=2.31
30
+ Requires-Dist: resvg-py>=0.5
31
+ Provides-Extra: dev
32
+ Requires-Dist: pytest>=8.0; extra == "dev"
33
+ Requires-Dist: responses>=0.25; extra == "dev"
34
+ Dynamic: license-file
35
+
36
+ # bhshell
37
+
38
+ [![PyPI version](https://img.shields.io/pypi/v/bhshell.svg)](https://pypi.org/project/bhshell/)
39
+ [![Python versions](https://img.shields.io/pypi/pyversions/bhshell.svg)](https://pypi.org/project/bhshell/)
40
+ [![CI](https://github.com/kr0no/bloodhound-shell/actions/workflows/ci.yml/badge.svg)](https://github.com/kr0no/bloodhound-shell/actions/workflows/ci.yml)
41
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/kr0no/bloodhound-shell/blob/main/LICENSE)
42
+
43
+ An interactive command-line shell for BloodHound. Look up Active Directory objects,
44
+ hunt for weaknesses and find attack paths from the terminal, without opening the web UI.
45
+
46
+ ## Installation
47
+
48
+ Requires Python 3.11+ and a BloodHound CE v9 server (tested on v9.7.1).
49
+
50
+ ```bash
51
+ pipx install bhshell # recommended
52
+ # or
53
+ pip install bhshell
54
+ ```
55
+
56
+ ## Quick Start
57
+
58
+ ```console
59
+ $ bhshell --url http://127.0.0.1:8080
60
+
61
+ [bh: shell] > login -u admin
62
+ [+] Logged in as admin on 127.0.0.1:8080
63
+
64
+ [bh: shell] > hunt
65
+ Severity Check Count Example Description
66
+ critical dcsync 2 SVC_SQL@CORP.LOCAL Non-Tier-Zero principals with replication rights
67
+ high kerberoastable 2 JDOE@CORP.LOCAL Kerberoastable users
68
+ ...
69
+ [*] Details: 'hunt CHECK' (e.g. 'hunt kerberoastable'), or 'hunt --all'.
70
+
71
+ [bh: shell] > user CORP\svc_sql --all -o svc_sql.csv
72
+
73
+ [bh: shell] > path --owned -g
74
+ ```
75
+
76
+ The same commands run non-interactively for scripting: `bhshell -c "hunt --json" > hunt.json`.
77
+
78
+ ## What it does
79
+
80
+ | Command | Shows |
81
+ |---|---|
82
+ | `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 |
83
+ | `search` | Objects matching property filters (`search -t user hasspn enabled`), with dates, ages (`90d`) and wildcards |
84
+ | `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
+ | `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 |
86
+ | `cypher`, `query` | Raw Cypher, and a library of saved queries: bundled, your own `.cyp` files (`~/.bhshell/queries/` or any folder set with `config query_dirs`) and those saved on the server |
87
+ | `owned`, `hvt` | Mark or unmark owned and Tier Zero objects |
88
+ | `domains`, `trusts` | Per-domain counts, and the trust map with SID filtering status |
89
+
90
+ To add your own query, save it as a `.cyp` file in `~/.bhshell/queries/` and run it by name:
91
+
92
+ ```console
93
+ $ cat ~/.bhshell/queries/stale-admins.cyp
94
+ // description: Enabled adminCount users who have not logged on for a year
95
+ MATCH (u:User) WHERE u.admincount = true AND u.enabled = true
96
+ AND u.lastlogontimestamp < (datetime().epochseconds - 31536000) RETURN u
97
+
98
+ [bh: shell] > query run stale-admins -o stale-admins.csv
99
+ ```
100
+
101
+ See [Query library](https://github.com/kr0no/bloodhound-shell/blob/main/docs/USAGE.md#query-library) for the format and for
102
+ keeping queries in other folders (a team repository, one folder per engagement).
103
+
104
+ Most commands take `--json` and `-o FILE` (JSON or CSV). Login works with a password (plus MFA) or
105
+ an API token; secrets are prompted for or read from the environment, never passed as arguments.
106
+
107
+ ## Documentation
108
+
109
+ The full usage guide (every command, view flag, search filter, path option, hunt check and
110
+ configuration setting) is in [docs/USAGE.md](https://github.com/kr0no/bloodhound-shell/blob/main/docs/USAGE.md).
111
+ Inside the shell, run `help` or `help <command>`.
112
+
113
+ ## Contributing
114
+
115
+ Bug reports and pull requests are welcome at
116
+ [github.com/kr0no/bloodhound-shell/issues](https://github.com/kr0no/bloodhound-shell/issues).
117
+
118
+ ```bash
119
+ git clone https://github.com/kr0no/bloodhound-shell.git
120
+ cd bloodhound-shell
121
+ python3 -m venv .venv && . .venv/bin/activate
122
+ pip install -e '.[dev]'
123
+ python -m pytest
124
+ ```
125
+
126
+ The tests mock the BloodHound API with `responses`, so no BloodHound server is needed.
127
+
128
+ ## License
129
+
130
+ Distributed under the MIT License. See
131
+ [LICENSE](https://github.com/kr0no/bloodhound-shell/blob/main/LICENSE) for details.
132
+
133
+ BloodHound is a trademark of SpecterOps. bhshell is an independent project and is not affiliated
134
+ with or endorsed by SpecterOps.
@@ -0,0 +1,59 @@
1
+ bhshell/__init__.py,sha256=n8SMTm85bIm5dpDJikW_zJjQZPgMT4FcZ-GmvKO48tA,76
2
+ bhshell/__main__.py,sha256=zu6PVRedmvjjW2Sw8F-W3cMXcD6G5_trj8p4GdVkPRI,55
3
+ bhshell/cli.py,sha256=Hjy5c0bc7ntBteP2q_SK5FnOm1-vjqDx_86P00YOAMA,2123
4
+ bhshell/config.py,sha256=qXOeWgTerpgZAK0spewO57qJn3fqkeOPF3XySp7ROO4,5121
5
+ bhshell/errors.py,sha256=ZAYQp51ERF9VN47obi43OGVrizLiHXRerFZFwwYrDvg,1847
6
+ bhshell/client/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
+ bhshell/client/auth.py,sha256=w2o-u7CYCfgJF1uKOv6l6fJK4rwhS4m1pzC-_jz_q-g,2503
8
+ bhshell/client/client.py,sha256=s5R5lgwl25AdHE8-9zbmXikzWQriOdlCq8QL7TIYc8w,12543
9
+ bhshell/library/asrep-roastable-users.cyp,sha256=Iee5Ll4mMBTx_5OEx3eeNAlEWOghziQo-g5Y7fiSff0,172
10
+ bhshell/library/dcsync-principals.cyp,sha256=HsNxJ71svTZTdwvgtYMPyqDEfxkQxWbjNwnEE8GcW-M,173
11
+ bhshell/library/domain-admins-members.cyp,sha256=zz10dAxSza6ZNQLSz06OEkVRoYIU_V3KLu6AtGsPtbw,189
12
+ bhshell/library/domain-trusts.cyp,sha256=BUE17dPNPnEE4-uoVEzeq60j-F_Sz1s9MnJdalDVHKY,140
13
+ bhshell/library/gpos-linked-to-domain-controllers.cyp,sha256=vFhfVW06oOV2Z2K4jm40JHMKKlEgBy3hoTskUHMr4fs,216
14
+ bhshell/library/kerberoastable-users.cyp,sha256=pi66OnFXQCR_JXjbJV_cZUvbXQk4ZHl0SiOIRd86rr0,156
15
+ bhshell/library/owned-objects.cyp,sha256=y13goTwj7NCNHP9pBZup_Dc8zvrNEiywNEwTWYJkczg,92
16
+ bhshell/library/owned-to-tier-zero.cyp,sha256=nEHSLzU1WRVKifbP1LkL82uJM00fy-Px8I1lL5iJ_OY,227
17
+ bhshell/library/sid-history.cyp,sha256=rZCgg6Lz-thCU_p7On1kJFanM91uc7A59IXCr81MaSo,147
18
+ bhshell/library/tier-zero-users.cyp,sha256=-ISf4QzQ4v-RtHyr2A66lTOCksvKgGhDWTnYpuoJihM,133
19
+ bhshell/library/unconstrained-delegation.cyp,sha256=6BFxFRHoJ57pHqDcjsLwdzWfUQnCcUPNekIndXPuI28,199
20
+ bhshell/models/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
21
+ bhshell/models/entities.py,sha256=VqlC-wPJ_mTdTKwRS1WEr11RBNkTYKc7aaJzWxTPRUA,15155
22
+ bhshell/models/results.py,sha256=ea9HiAKVuyMI7JVQzjDmoEtLDUIs4HfmpFf5dIh3kv0,2159
23
+ bhshell/queries/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
24
+ bhshell/queries/cypher.py,sha256=97G_ynpiAtYb_Kf3IAsV7hcnf1KXOIMso-A7lQMb7Ow,5420
25
+ bhshell/queries/domains.py,sha256=Ob6FGCPIAVfFiuQtytD0xN_ipPjuVvjIHrsrTO7p8AU,5572
26
+ bhshell/queries/edges.py,sha256=5Iexg7VS_RYvsYXMb7quznbGed4FjQGMY9o4pyLp9v4,4687
27
+ bhshell/queries/hunt.py,sha256=xkPi499zV6HzaTQotUvMrGILyQCEzsK7lbFO54zp9mc,35125
28
+ bhshell/queries/library.py,sha256=4zfvI3CxG90_HeDSiR36gASLAfpy3bSEE516GLTo--Q,3732
29
+ bhshell/queries/marking.py,sha256=iL17u8sVl5q_-y21D0vafkq89pWqw9cObjw_5jkOOnc,5849
30
+ bhshell/queries/paths.py,sha256=dI83FisHXN9Zh0UdyzmormNML9W3ocoLvp7rwMHpki0,18762
31
+ bhshell/queries/search.py,sha256=BKgINanmdsp-62fdrgeRoP9VF-na7W69Cg2GJAso5Ww,8609
32
+ bhshell/queries/service.py,sha256=tdSTxv1CUKVmbvggYgXqb6fp0nfoIOnyq8O5Ow--qCE,24560
33
+ bhshell/render/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
34
+ bhshell/render/export.py,sha256=y7jtGMF9hVEGXMMRr059e14R_uRFGQttYQHCeFJPzeo,2911
35
+ bhshell/render/graph.py,sha256=Hk950xYCqWhgoJSLhic9vkLT3ILKmSQvtqxJB4MOHes,14728
36
+ bhshell/render/layout.py,sha256=UV-FF421SxnnTvhc755utRgZozJ_JHhN4De__eRoT2A,5763
37
+ bhshell/render/png.py,sha256=iJnkKzY_03LGhlL072rzC_amfMJcQyOuNxx2uJfLD-8,1544
38
+ bhshell/render/styles.py,sha256=cdxIDXjx9PvqlhyJgMaobLrdhU-_l5PmbKxsmsR3Plk,348
39
+ bhshell/render/svg.py,sha256=7KiXEQcb3tdfkXUdRzyHF40jJeFVwyO36k6JM31Mris,24560
40
+ bhshell/render/views.py,sha256=DkrcKlKwr1W-PUAuOMnNB-hw1AGK8rc0yNvvhyMI-Kw,15254
41
+ bhshell/shell/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
42
+ bhshell/shell/app.py,sha256=q6j5LVLsaGIE3V18pDkkx30OO_Ar6W41jkskInhtkRI,7734
43
+ bhshell/shell/commands/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
44
+ bhshell/shell/commands/base.py,sha256=kDe-betW_HlyTjDeO1E7BS5J3efI7zKSKy4LcuvErks,2339
45
+ bhshell/shell/commands/connection.py,sha256=2ScEPftqtBAG7bKFJ2Dl7KdvOx6PB6THYVIoHeguhhk,9802
46
+ bhshell/shell/commands/cypher.py,sha256=qub9u7jgP8nXz_JMmpijU1o0JPEGTWRsSj6yaiZ_G18,3266
47
+ bhshell/shell/commands/domains.py,sha256=HvCRbJc8XihzqIgiBQFTI73jcS6qjEK8okUM2Lc-xy0,3335
48
+ bhshell/shell/commands/entities.py,sha256=l6FBMYJky31NI0DYYE3zSLUeqDtXWprE6qsSN_R8iOY,4088
49
+ bhshell/shell/commands/hunt.py,sha256=mbW9OTYOCyM4UI9Y9UfBJJJ3Gjgeraup7je0xotKwJI,6891
50
+ bhshell/shell/commands/library.py,sha256=SR9UwFc0FRENLTpkoMXYAhoPEWGuSvQmoZlKrUBrNOY,2455
51
+ bhshell/shell/commands/marking.py,sha256=re0an3vMhLrq89_Sj0CFwKlvx7f2pb-2Ox40YfV18Ks,2414
52
+ bhshell/shell/commands/paths.py,sha256=oqdaR1VxQWAzBaWlrmtxHehpbE2UECgtpcQF3GBGahg,8211
53
+ bhshell/shell/commands/search.py,sha256=Mmaf6ekSOKszxw3hUeLI1v5IC89O3GMJqWqmCXGYGoM,5680
54
+ bhshell-0.1.0.dist-info/licenses/LICENSE,sha256=C-WjLV0cwkkudk4FX4LGQqaiRtlt442o8gRVlCsdANA,1062
55
+ bhshell-0.1.0.dist-info/METADATA,sha256=Q5b4SSVftYbVI2k_WIyo-LR30JVzIov7pOr367lPeH4,5942
56
+ bhshell-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
57
+ bhshell-0.1.0.dist-info/entry_points.txt,sha256=E9K8CHFYC7Kbbt-QESeY5S2LsWXxbLXthz0REEFK9nY,45
58
+ bhshell-0.1.0.dist-info/top_level.txt,sha256=Ajj2nWqEd-LNnHF6KFuzwwmXq76LZuRFdzRaTqb9rg0,8
59
+ bhshell-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ bhshell = bhshell.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 kr0no
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ bhshell