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
bhshell/render/views.py
ADDED
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
"""Rich renderers for entities, relationship views and graphs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import itertools
|
|
6
|
+
from collections import Counter
|
|
7
|
+
from collections.abc import Iterable
|
|
8
|
+
from datetime import UTC, datetime
|
|
9
|
+
from typing import TYPE_CHECKING, Any, TypeVar
|
|
10
|
+
|
|
11
|
+
from rich import box
|
|
12
|
+
from rich.console import Console
|
|
13
|
+
from rich.console import Group as RichGroup
|
|
14
|
+
from rich.markup import escape
|
|
15
|
+
from rich.panel import Panel
|
|
16
|
+
from rich.table import Table
|
|
17
|
+
from rich.text import Text
|
|
18
|
+
|
|
19
|
+
from bhshell.errors import AmbiguousMatchError, BHError
|
|
20
|
+
from bhshell.models.entities import Graph, Node, SummaryField, epoch_to_datetime
|
|
21
|
+
from bhshell.models.results import Column, EntityReport, ViewResult
|
|
22
|
+
from bhshell.queries.edges import severity, severity_rank
|
|
23
|
+
from bhshell.render import graph
|
|
24
|
+
from bhshell.render.styles import KIND_STYLE, SEVERITY_STYLE
|
|
25
|
+
|
|
26
|
+
if TYPE_CHECKING:
|
|
27
|
+
from bhshell.queries.hunt import Finding
|
|
28
|
+
from bhshell.queries.paths import PathResult
|
|
29
|
+
|
|
30
|
+
T = TypeVar("T")
|
|
31
|
+
|
|
32
|
+
_NARROW_FORMATS = ("t0", "owned", "bool", "risk", "depth")
|
|
33
|
+
RISK_STYLE = {"high": "bold red", "medium": "yellow", "info": "cyan", "none": ""}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def first_n(items: list[T], limit: int) -> list[T]:
|
|
37
|
+
"""The first `limit` items; all of them when `limit` is 0."""
|
|
38
|
+
return items[:limit] if limit > 0 else items
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def fmt_date(value: Any) -> Text:
|
|
42
|
+
dt = epoch_to_datetime(value)
|
|
43
|
+
if dt is None:
|
|
44
|
+
return Text("never", style="dim")
|
|
45
|
+
days = (datetime.now(UTC) - dt).days
|
|
46
|
+
return Text(f"{dt:%Y-%m-%d %H:%M} UTC ({days}d ago)")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def fmt_bool(value: bool | None, risk: str = "none") -> Text:
|
|
50
|
+
if value is None:
|
|
51
|
+
return Text("-", style="dim")
|
|
52
|
+
if value:
|
|
53
|
+
return Text("yes", style=RISK_STYLE.get(risk) or "green")
|
|
54
|
+
return Text("no", style="dim")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def fmt_field(f: SummaryField) -> Text:
|
|
58
|
+
if f.type == "bool":
|
|
59
|
+
return fmt_bool(f.value, f.risk)
|
|
60
|
+
if f.type == "date":
|
|
61
|
+
return fmt_date(f.value)
|
|
62
|
+
if f.type == "list":
|
|
63
|
+
items = f.value or []
|
|
64
|
+
return Text("\n".join(map(str, items)) if items else "-", style="" if items else "dim")
|
|
65
|
+
text = str(f.value) if f.value not in (None, "") else "-"
|
|
66
|
+
return Text(text, style=RISK_STYLE.get(f.risk, "") if text != "-" else "dim")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def fmt_kind(kind: str) -> Text:
|
|
70
|
+
return Text(kind, style=KIND_STYLE.get(kind, ""))
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _marker(value: bool, symbol: str, style: str) -> Text:
|
|
74
|
+
return Text(symbol, style=style) if value else Text("")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _with_tags(text: Text, node: Node) -> Text:
|
|
78
|
+
if node.tier_zero:
|
|
79
|
+
text.append(" [T0]", style="bold red")
|
|
80
|
+
if node.owned:
|
|
81
|
+
text.append(" [owned]", style="bold cyan")
|
|
82
|
+
if node.properties.get("enabled") is False:
|
|
83
|
+
text.append(" [disabled]", style="dim")
|
|
84
|
+
return text
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _path_summary(result: PathResult) -> Text:
|
|
88
|
+
fewest = min((p.actions for p in result.paths), default=0)
|
|
89
|
+
return Text(f"{len(result.paths)} path(s), shortest {result.shortest} hop(s), "
|
|
90
|
+
f"fewest {fewest} action(s), "
|
|
91
|
+
f"{len(result.first_hops())} distinct first hop(s)", style="dim")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _titled(title: str, backend: str) -> str:
|
|
95
|
+
return f"{title} (REST)" if backend == "rest" else title
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _more(shown: int, total: int, what: str, flag: str) -> Text:
|
|
99
|
+
return Text(f"showing {shown} of {total} {what}; use {flag} 0 to show all or -o FILE to export", style="yellow")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class Renderer:
|
|
103
|
+
"""Turn bhshell models into rich renderables on a console.
|
|
104
|
+
|
|
105
|
+
Results go to `console` (stdout); errors and status messages go to
|
|
106
|
+
`status` (stderr) so that `bhshell -c "... --json" > out.json` stays clean.
|
|
107
|
+
Object names come from AD and may contain `[...]`: they are always wrapped
|
|
108
|
+
in `Text` so they are never parsed as rich markup.
|
|
109
|
+
"""
|
|
110
|
+
|
|
111
|
+
def __init__(self, console: Console | None = None, status: Console | None = None) -> None:
|
|
112
|
+
self.console = console or Console(highlight=False)
|
|
113
|
+
self.status = status or Console(stderr=True, highlight=False)
|
|
114
|
+
|
|
115
|
+
def summary(self, report: EntityReport) -> None:
|
|
116
|
+
entity = report.entity
|
|
117
|
+
table = Table(box=None, show_header=False, pad_edge=False, padding=(0, 2))
|
|
118
|
+
table.add_column(style="bold")
|
|
119
|
+
table.add_column(overflow="fold")
|
|
120
|
+
for f in entity.summary():
|
|
121
|
+
if f.type == "list" and not f.value:
|
|
122
|
+
continue
|
|
123
|
+
table.add_row(f.label, fmt_field(f))
|
|
124
|
+
node = report.node
|
|
125
|
+
self._panel(_with_tags(Text.assemble(fmt_kind(node.kind), " ", (node.name, "bold")), node), [table])
|
|
126
|
+
|
|
127
|
+
def header(self, node: Node) -> None:
|
|
128
|
+
"""One-line identification used when only views were requested."""
|
|
129
|
+
line = Text.assemble(fmt_kind(node.kind), " ", (node.name, "bold"), " ", (node.objectid, "dim"))
|
|
130
|
+
self.console.print(_with_tags(line, node))
|
|
131
|
+
|
|
132
|
+
def _panel(self, title: str | Text, parts: list[Any], notes: Iterable[str] = ()) -> None:
|
|
133
|
+
parts = [*parts, *(Text(f"note: {n}", style="dim italic") for n in notes)]
|
|
134
|
+
self.console.print(Panel(RichGroup(*parts), title=escape(title) if isinstance(title, str) else title,
|
|
135
|
+
title_align="left", border_style="bright_black"))
|
|
136
|
+
|
|
137
|
+
def report(self, report: EntityReport, max_rows: int = 0) -> None:
|
|
138
|
+
if report.show_summary:
|
|
139
|
+
self.summary(report)
|
|
140
|
+
else:
|
|
141
|
+
self.header(report.node)
|
|
142
|
+
for view in report.views:
|
|
143
|
+
self.view(view, max_rows)
|
|
144
|
+
|
|
145
|
+
@staticmethod
|
|
146
|
+
def _breakdown(view: ViewResult) -> Text | None:
|
|
147
|
+
"""Per-edge counts, shown when a view has many rows of several kinds."""
|
|
148
|
+
if len(view.rows) < 10 or not any(c.attr == "edge" for c in view.columns):
|
|
149
|
+
return None
|
|
150
|
+
counts = Counter(r.edge for r in view.rows)
|
|
151
|
+
text = Text("by right: ", style="dim")
|
|
152
|
+
for i, (edge, n) in enumerate(sorted(counts.items(), key=lambda kv: (severity_rank(kv[0]), -kv[1]))):
|
|
153
|
+
if i:
|
|
154
|
+
text.append(", ", style="dim")
|
|
155
|
+
text.append(edge, style=SEVERITY_STYLE[severity(edge)])
|
|
156
|
+
text.append(f" x{n}", style="dim")
|
|
157
|
+
t0 = sum(1 for r in view.rows if r.tier_zero)
|
|
158
|
+
owned = sum(1 for r in view.rows if r.owned)
|
|
159
|
+
if t0 or owned:
|
|
160
|
+
text.append(f" | T0: {t0} owned: {owned}", style="dim")
|
|
161
|
+
return text
|
|
162
|
+
|
|
163
|
+
def view(self, view: ViewResult, max_rows: int = 0) -> None:
|
|
164
|
+
breakdown = self._breakdown(view)
|
|
165
|
+
self.table(_titled(view.title, view.backend), view.columns, [r.as_dict() for r in view.rows],
|
|
166
|
+
max_rows=max_rows, notes=view.notes, lead=[] if breakdown is None else [breakdown])
|
|
167
|
+
|
|
168
|
+
@staticmethod
|
|
169
|
+
def fmt_value(value: Any, fmt: str) -> Text:
|
|
170
|
+
"""Format one cell according to a `Column` `fmt` hint."""
|
|
171
|
+
if fmt == "date":
|
|
172
|
+
return fmt_date(value)
|
|
173
|
+
if fmt == "bool":
|
|
174
|
+
return fmt_bool(None if value is None else bool(value))
|
|
175
|
+
if fmt == "risk":
|
|
176
|
+
return fmt_bool(None if value is None else bool(value), "high")
|
|
177
|
+
if fmt == "edge":
|
|
178
|
+
return Text(str(value), style=SEVERITY_STYLE[severity(str(value))])
|
|
179
|
+
if fmt == "kind":
|
|
180
|
+
return fmt_kind(str(value))
|
|
181
|
+
if fmt == "t0":
|
|
182
|
+
return _marker(bool(value), "T0", "bold red")
|
|
183
|
+
if fmt == "owned":
|
|
184
|
+
return _marker(bool(value), "owned", "bold cyan")
|
|
185
|
+
if fmt == "severity":
|
|
186
|
+
return Text(str(value), style=SEVERITY_STYLE.get(str(value), ""))
|
|
187
|
+
if fmt == "depth":
|
|
188
|
+
return Text("-" if value is None else str(value), style="" if value else "dim")
|
|
189
|
+
if fmt == "via":
|
|
190
|
+
return Text(value or "-", style="dim" if value in ("direct", "") else "")
|
|
191
|
+
if value is None or value == "":
|
|
192
|
+
return Text("-", style="dim")
|
|
193
|
+
if isinstance(value, (list, tuple)):
|
|
194
|
+
return Text(", ".join(map(str, value)))
|
|
195
|
+
return Text(str(value))
|
|
196
|
+
|
|
197
|
+
def table(self, title: str, columns: Iterable[Column], rows: list[dict[str, Any]], *,
|
|
198
|
+
max_rows: int = 0, notes: Iterable[str] = (), lead: Iterable[Any] = ()) -> None:
|
|
199
|
+
"""`lead` goes above the table, inside the panel."""
|
|
200
|
+
columns = list(columns)
|
|
201
|
+
parts: list[Any] = list(lead)
|
|
202
|
+
if not rows:
|
|
203
|
+
parts.append(Text("No results.", style="dim"))
|
|
204
|
+
else:
|
|
205
|
+
shown = first_n(rows, max_rows)
|
|
206
|
+
table = Table(box=box.SIMPLE_HEAD, header_style="bold", pad_edge=False)
|
|
207
|
+
for col in columns:
|
|
208
|
+
table.add_column(col.header, overflow="fold", no_wrap=col.fmt in _NARROW_FORMATS)
|
|
209
|
+
for row in shown:
|
|
210
|
+
table.add_row(*(self.fmt_value(row.get(c.attr), c.fmt) for c in columns))
|
|
211
|
+
parts.append(table)
|
|
212
|
+
if len(shown) < len(rows):
|
|
213
|
+
parts.append(_more(len(shown), len(rows), "rows", "--max-rows"))
|
|
214
|
+
self._panel(f"{title} [{len(rows)}]", parts, notes)
|
|
215
|
+
|
|
216
|
+
def paths(self, result: PathResult, max_paths: int = 0) -> None:
|
|
217
|
+
parts: list[Any] = []
|
|
218
|
+
if result.paths:
|
|
219
|
+
parts.append(_path_summary(result))
|
|
220
|
+
shown = first_n(result.paths, max_paths)
|
|
221
|
+
for i, path in zip(result.numbers or itertools.count(1), shown):
|
|
222
|
+
line = Text(f"{i:>3}. ", style="dim")
|
|
223
|
+
line.append_text(self._path_node(path.start))
|
|
224
|
+
for hop in path.hops:
|
|
225
|
+
line.append(" ─", style="bright_black")
|
|
226
|
+
line.append(hop.edge, style=SEVERITY_STYLE[severity(hop.edge)])
|
|
227
|
+
line.append("→ ", style="bright_black")
|
|
228
|
+
line.append_text(self._path_node(hop.target))
|
|
229
|
+
parts.append(line)
|
|
230
|
+
if len(shown) < len(result.paths):
|
|
231
|
+
parts.append(_more(len(shown), len(result.paths), "paths", "-n"))
|
|
232
|
+
else:
|
|
233
|
+
parts.append(Text("No path found.", style="dim"))
|
|
234
|
+
self._panel(_titled(result.title, result.backend), parts, result.notes)
|
|
235
|
+
|
|
236
|
+
def path_graph(self, result: PathResult, max_paths: int = 0) -> None:
|
|
237
|
+
"""Render attack paths as one graph, falling back to the list when it is wider than the terminal."""
|
|
238
|
+
shown = first_n(result.paths, max_paths)
|
|
239
|
+
if not shown:
|
|
240
|
+
self.paths(result, max_paths)
|
|
241
|
+
return
|
|
242
|
+
lines, notes = graph.draw(result, shown, max_width=self.console.width - 4)
|
|
243
|
+
width = max(line.cell_len for line in lines)
|
|
244
|
+
if width + 4 > self.console.width:
|
|
245
|
+
self.warn(f"The graph is {width} columns wide and the terminal {self.console.width}; showing the "
|
|
246
|
+
"list instead (fewer paths with -n, or widen the terminal).")
|
|
247
|
+
self.paths(result, max_paths)
|
|
248
|
+
return
|
|
249
|
+
parts: list[Any] = [_path_summary(result), Text(), *lines, Text(), graph.legend(shown, bool(result.waypoints))]
|
|
250
|
+
if len(shown) < len(result.paths):
|
|
251
|
+
parts.append(_more(len(shown), len(result.paths), "paths", "-n"))
|
|
252
|
+
self._panel(_titled(result.title, result.backend), parts, [*notes, *result.notes])
|
|
253
|
+
|
|
254
|
+
@staticmethod
|
|
255
|
+
def _path_node(node: Node) -> Text:
|
|
256
|
+
return _with_tags(Text(node.name, style=KIND_STYLE.get(node.kind, "")), node)
|
|
257
|
+
|
|
258
|
+
def hunt_summary(self, findings: list[Finding]) -> None:
|
|
259
|
+
table = Table(title="Hunt summary", title_justify="left", box=box.SIMPLE_HEAD, header_style="bold")
|
|
260
|
+
for header in ("Severity", "Check", "Count", "Example", "Description"):
|
|
261
|
+
table.add_column(header, overflow="fold")
|
|
262
|
+
for f in findings:
|
|
263
|
+
if f.error:
|
|
264
|
+
count = Text("error", style="yellow")
|
|
265
|
+
elif f.total is not None:
|
|
266
|
+
count = Text(f"~{f.total}", style="bold")
|
|
267
|
+
else:
|
|
268
|
+
count = Text(f"{'≥' if f.truncated else ''}{len(f.rows)}",
|
|
269
|
+
style="bold" if f.rows else "dim")
|
|
270
|
+
example = ""
|
|
271
|
+
if f.rows:
|
|
272
|
+
first = f.rows[0]
|
|
273
|
+
example = str(first.get("name") or first.get("principal") or first.get("trusting") or "")
|
|
274
|
+
severity = Text(f.check.severity, style=SEVERITY_STYLE.get(f.check.severity, "blue"))
|
|
275
|
+
table.add_row(severity, f.check.id, count,
|
|
276
|
+
Text(example), Text(f.error or f.check.title, style="yellow" if f.error else ""))
|
|
277
|
+
self.console.print(table)
|
|
278
|
+
|
|
279
|
+
def nodes(self, nodes: Iterable[Node], title: str) -> None:
|
|
280
|
+
self.console.print(self._nodes_table(nodes, title))
|
|
281
|
+
|
|
282
|
+
@staticmethod
|
|
283
|
+
def _nodes_table(nodes: Iterable[Node], title: str) -> Table:
|
|
284
|
+
nodes = list(nodes)
|
|
285
|
+
table = Table(title=Text(f"{title} [{len(nodes)}]"), title_justify="left", box=box.SIMPLE_HEAD,
|
|
286
|
+
header_style="bold")
|
|
287
|
+
for header in ("Type", "Name", "Domain", "Object ID", "Enabled", "T0", "Owned"):
|
|
288
|
+
table.add_column(header, overflow="fold")
|
|
289
|
+
for n in nodes:
|
|
290
|
+
enabled = n.properties.get("enabled")
|
|
291
|
+
table.add_row(
|
|
292
|
+
fmt_kind(n.kind), Text(n.name), Text(n.domain), Text(n.objectid, style="dim"),
|
|
293
|
+
fmt_bool(None if enabled is None else bool(enabled)),
|
|
294
|
+
_marker(n.tier_zero, "T0", "bold red"), _marker(n.owned, "owned", "bold cyan"),
|
|
295
|
+
)
|
|
296
|
+
return table
|
|
297
|
+
|
|
298
|
+
def graph(self, graph: Graph) -> None:
|
|
299
|
+
if not graph.nodes and not graph.edges:
|
|
300
|
+
self.console.print(Text("No results.", style="dim"))
|
|
301
|
+
return
|
|
302
|
+
self.nodes(graph.nodes.values(), "Nodes")
|
|
303
|
+
if graph.edges:
|
|
304
|
+
table = Table(title=f"Edges [{len(graph.edges)}]", title_justify="left", box=box.SIMPLE_HEAD,
|
|
305
|
+
header_style="bold")
|
|
306
|
+
for header in ("Source", "Edge", "Target"):
|
|
307
|
+
table.add_column(header, overflow="fold")
|
|
308
|
+
for e in graph.edges:
|
|
309
|
+
src, tgt = graph.nodes.get(e.source), graph.nodes.get(e.target)
|
|
310
|
+
table.add_row(Text(src.name if src else e.source),
|
|
311
|
+
Text(e.kind, style=SEVERITY_STYLE[severity(e.kind)]),
|
|
312
|
+
Text(tgt.name if tgt else e.target))
|
|
313
|
+
self.console.print(table)
|
|
314
|
+
|
|
315
|
+
def key_values(self, title: str, rows: list[tuple[str, Any]]) -> None:
|
|
316
|
+
table = Table(box=None, show_header=False, padding=(0, 2))
|
|
317
|
+
table.add_column(style="bold")
|
|
318
|
+
table.add_column(overflow="fold")
|
|
319
|
+
for key, value in rows:
|
|
320
|
+
table.add_row(key, value if isinstance(value, Text) else Text(str(value)))
|
|
321
|
+
self.console.print(Panel(table, title=escape(title), title_align="left", border_style="bright_black"))
|
|
322
|
+
|
|
323
|
+
def error(self, err: BHError) -> None:
|
|
324
|
+
self.status.print(Text.assemble(("error: ", "bold red"), err.message))
|
|
325
|
+
if isinstance(err, AmbiguousMatchError):
|
|
326
|
+
self.status.print(self._nodes_table(err.candidates, "Candidates"))
|
|
327
|
+
if err.hint:
|
|
328
|
+
self.status.print(Text(f"hint: {err.hint}", style="dim"))
|
|
329
|
+
|
|
330
|
+
def success(self, message: str) -> None:
|
|
331
|
+
self.status.print(Text.assemble(("[+] ", "bold green"), message))
|
|
332
|
+
|
|
333
|
+
def info(self, message: str) -> None:
|
|
334
|
+
self.status.print(Text.assemble(("[*] ", "bold blue"), message))
|
|
335
|
+
|
|
336
|
+
def warn(self, message: str) -> None:
|
|
337
|
+
self.status.print(Text.assemble(("[!] ", "bold yellow"), message))
|
|
File without changes
|
bhshell/shell/app.py
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""The interactive bhshell REPL (cmd2 application)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
import traceback
|
|
7
|
+
from collections import deque
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import cmd2
|
|
11
|
+
import urllib3
|
|
12
|
+
from cmd2 import Cmd2ArgparseError, Statement
|
|
13
|
+
from cmd2.string_utils import stylize
|
|
14
|
+
from urllib3.exceptions import InsecureRequestWarning
|
|
15
|
+
|
|
16
|
+
from bhshell import __version__
|
|
17
|
+
from bhshell.client.client import BHClient
|
|
18
|
+
from bhshell.config import CachedSession, Config, history_file
|
|
19
|
+
from bhshell.errors import (
|
|
20
|
+
AuthError,
|
|
21
|
+
BHError,
|
|
22
|
+
ConnectionFailed,
|
|
23
|
+
LoginFailed,
|
|
24
|
+
MFARequired,
|
|
25
|
+
NotAuthenticated,
|
|
26
|
+
)
|
|
27
|
+
from bhshell.models.entities import Node
|
|
28
|
+
from bhshell.queries.marking import MarkingService
|
|
29
|
+
from bhshell.queries.service import EntityService
|
|
30
|
+
from bhshell.render.views import Renderer
|
|
31
|
+
from bhshell.shell.commands.connection import ConnectionCommands
|
|
32
|
+
from bhshell.shell.commands.cypher import CypherCommands
|
|
33
|
+
from bhshell.shell.commands.domains import DomainCommands
|
|
34
|
+
from bhshell.shell.commands.entities import EntityCommands
|
|
35
|
+
from bhshell.shell.commands.hunt import HuntCommands
|
|
36
|
+
from bhshell.shell.commands.library import LibraryCommands
|
|
37
|
+
from bhshell.shell.commands.marking import MarkingCommands
|
|
38
|
+
from bhshell.shell.commands.paths import PathCommands
|
|
39
|
+
from bhshell.shell.commands.search import SearchCommands
|
|
40
|
+
|
|
41
|
+
BANNER = rf"""
|
|
42
|
+
_ _ _ _ _
|
|
43
|
+
| |__ | |__ ___| |__ ___| | |
|
|
44
|
+
| '_ \| '_ \/ __| '_ \ / _ \ | | BloodHound interactive shell v{__version__}
|
|
45
|
+
| |_) | | | \__ \ | | | __/ | | Type 'help' for commands, 'help <command>' for details.
|
|
46
|
+
|_.__/|_| |_|___/_| |_|\___|_|_|
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
# Built-in cmd2 commands that are not useful here.
|
|
50
|
+
_HIDDEN_BUILTINS = ("edit", "run_pyscript", "shortcuts", "macro", "_relative_run_script")
|
|
51
|
+
|
|
52
|
+
# Help category of cmd2's built-in commands (help, history, quit, ...).
|
|
53
|
+
cmd2.Cmd.DEFAULT_CATEGORY = "Shell"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class BHShell(cmd2.Cmd):
|
|
57
|
+
def __init__(self, config: Config, *, debug: bool = False, resume: bool = True) -> None:
|
|
58
|
+
self.config = config
|
|
59
|
+
self.client = BHClient(
|
|
60
|
+
config.url,
|
|
61
|
+
verify_tls=config.verify_tls,
|
|
62
|
+
timeout=(config.connect_timeout, config.read_timeout),
|
|
63
|
+
page_size=config.page_size,
|
|
64
|
+
)
|
|
65
|
+
self.entities = EntityService(self.client, max_results=config.max_results)
|
|
66
|
+
self.marking = MarkingService(self.client, self.entities)
|
|
67
|
+
self.ui = Renderer()
|
|
68
|
+
self.recent_names: deque[str] = deque(maxlen=200)
|
|
69
|
+
# Commands that failed (errors, usage errors, unknown commands); drives the exit code of -c/-s runs.
|
|
70
|
+
self.command_failures = 0
|
|
71
|
+
# True while the REPL itself is reading commands (never for -c / -s runs).
|
|
72
|
+
self.interactive = False
|
|
73
|
+
|
|
74
|
+
super().__init__(
|
|
75
|
+
persistent_history_file=str(history_file()),
|
|
76
|
+
allow_cli_args=False,
|
|
77
|
+
# Disabled on purpose: Cypher uses '|' and '>' which cmd2 would
|
|
78
|
+
# otherwise interpret as shell pipes/redirection. Use '-o FILE'.
|
|
79
|
+
allow_redirection=False,
|
|
80
|
+
auto_load_commands=False,
|
|
81
|
+
command_sets=[ConnectionCommands(), EntityCommands(), SearchCommands(), CypherCommands(), MarkingCommands(),
|
|
82
|
+
PathCommands(), HuntCommands(), DomainCommands(), LibraryCommands()],
|
|
83
|
+
)
|
|
84
|
+
self.debug = debug
|
|
85
|
+
self.intro = BANNER
|
|
86
|
+
for name in _HIDDEN_BUILTINS:
|
|
87
|
+
if name not in self.hidden_commands:
|
|
88
|
+
self.hidden_commands.append(name)
|
|
89
|
+
self.register_precmd_hook(self._before_command)
|
|
90
|
+
self.register_postcmd_hook(self._after_command)
|
|
91
|
+
self.apply_tls_setting()
|
|
92
|
+
if resume:
|
|
93
|
+
self._resume_session()
|
|
94
|
+
self.update_prompt()
|
|
95
|
+
|
|
96
|
+
def preloop(self) -> None:
|
|
97
|
+
self.interactive = True
|
|
98
|
+
|
|
99
|
+
def can_ask(self) -> bool:
|
|
100
|
+
"""Whether a command may stop and prompt the user for a choice."""
|
|
101
|
+
return self.interactive and not self.in_script() and sys.stdin.isatty()
|
|
102
|
+
|
|
103
|
+
def apply_tls_setting(self) -> None:
|
|
104
|
+
self.client.verify_tls = self.config.verify_tls
|
|
105
|
+
if not self.config.verify_tls:
|
|
106
|
+
urllib3.disable_warnings(InsecureRequestWarning)
|
|
107
|
+
|
|
108
|
+
def require_auth(self) -> None:
|
|
109
|
+
if not self.client.is_authenticated:
|
|
110
|
+
raise NotAuthenticated("Not authenticated")
|
|
111
|
+
|
|
112
|
+
def remember(self, node: Node) -> None:
|
|
113
|
+
"""Record a resolved name for tab completion."""
|
|
114
|
+
name = node.samaccountname or node.name
|
|
115
|
+
if name and name not in self.recent_names:
|
|
116
|
+
self.recent_names.appendleft(name)
|
|
117
|
+
|
|
118
|
+
def on_auth_lost(self) -> None:
|
|
119
|
+
self.client.clear_auth()
|
|
120
|
+
CachedSession.clear()
|
|
121
|
+
self.entities.reset()
|
|
122
|
+
self.update_prompt()
|
|
123
|
+
|
|
124
|
+
def update_prompt(self) -> None:
|
|
125
|
+
"""`[bh: shell] >`; "shell" is green when authenticated, red when not ('whoami' has details)."""
|
|
126
|
+
colour = "green" if self.client.is_authenticated else "red"
|
|
127
|
+
self.prompt = f"[{stylize('bh', 'bold')}: {stylize('shell', colour)}] > "
|
|
128
|
+
|
|
129
|
+
def _resume_session(self) -> None:
|
|
130
|
+
if not self.config.cache_session:
|
|
131
|
+
return
|
|
132
|
+
cached = CachedSession.load()
|
|
133
|
+
if cached is None or cached.url != self.client.base_url:
|
|
134
|
+
return
|
|
135
|
+
self.client.set_jwt(cached.token, cached.username)
|
|
136
|
+
if self.client.token_expired():
|
|
137
|
+
self.on_auth_lost()
|
|
138
|
+
self.ui.info("Cached session has expired; run 'login'.")
|
|
139
|
+
return
|
|
140
|
+
try:
|
|
141
|
+
self.client.self_info()
|
|
142
|
+
except AuthError:
|
|
143
|
+
self.on_auth_lost()
|
|
144
|
+
self.ui.info("Cached session is no longer valid; run 'login'.")
|
|
145
|
+
except ConnectionFailed:
|
|
146
|
+
self.ui.warn(f"Backend {self.client.base_url} unreachable; keeping cached session for {cached.username}.")
|
|
147
|
+
else:
|
|
148
|
+
self.ui.success(f"Resumed session for {cached.username or 'user'} on {self.client.host}")
|
|
149
|
+
|
|
150
|
+
def _before_command(self, data: cmd2.plugin.PrecommandData) -> cmd2.plugin.PrecommandData:
|
|
151
|
+
self.entities.reset_cache()
|
|
152
|
+
if self.client.is_authenticated and self.client.token_expired():
|
|
153
|
+
self.ui.warn("Session token has expired.")
|
|
154
|
+
self.on_auth_lost()
|
|
155
|
+
return data
|
|
156
|
+
|
|
157
|
+
def _after_command(self, data: cmd2.plugin.PostcommandData) -> cmd2.plugin.PostcommandData:
|
|
158
|
+
self.update_prompt()
|
|
159
|
+
return data
|
|
160
|
+
|
|
161
|
+
def onecmd(self, statement: Statement | str, *, add_to_history: bool = True) -> bool:
|
|
162
|
+
try:
|
|
163
|
+
return super().onecmd(statement, add_to_history=add_to_history)
|
|
164
|
+
except Cmd2ArgparseError: # usage error: argparse already printed it
|
|
165
|
+
self.command_failures += 1
|
|
166
|
+
raise
|
|
167
|
+
|
|
168
|
+
def perror(self, *objects: Any, **kwargs: Any) -> None:
|
|
169
|
+
self.command_failures += 1
|
|
170
|
+
super().perror(*objects, **kwargs)
|
|
171
|
+
|
|
172
|
+
def pexcept(self, msg: BaseException, **kwargs: Any) -> None:
|
|
173
|
+
"""Render bhshell errors concisely; full tracebacks only in debug mode."""
|
|
174
|
+
if isinstance(msg, BHError):
|
|
175
|
+
self.command_failures += 1
|
|
176
|
+
# A rejected login leaves the active session alone; other 401s mean it is gone.
|
|
177
|
+
session_lost = isinstance(msg, AuthError) and not isinstance(
|
|
178
|
+
msg, (NotAuthenticated, MFARequired, LoginFailed))
|
|
179
|
+
if session_lost and self.client.is_authenticated:
|
|
180
|
+
self.on_auth_lost()
|
|
181
|
+
self.ui.error(msg)
|
|
182
|
+
elif isinstance(msg, (ValueError, OSError)):
|
|
183
|
+
self.command_failures += 1
|
|
184
|
+
self.ui.error(BHError(str(msg)))
|
|
185
|
+
else:
|
|
186
|
+
super().pexcept(msg, **kwargs)
|
|
187
|
+
return
|
|
188
|
+
if self.debug:
|
|
189
|
+
self.ui.status.print("".join(traceback.format_exception(msg)), style="dim", markup=False)
|
|
File without changes
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Shared helpers for bhshell command sets."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
from typing import TYPE_CHECKING, Any, cast
|
|
7
|
+
|
|
8
|
+
from cmd2 import Choices, CommandSet
|
|
9
|
+
|
|
10
|
+
from bhshell.render import export
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from bhshell.models.entities import Graph
|
|
14
|
+
from bhshell.shell.app import BHShell
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class BaseCommands(CommandSet):
|
|
18
|
+
@property
|
|
19
|
+
def app(self) -> BHShell:
|
|
20
|
+
return cast("BHShell", self._cmd)
|
|
21
|
+
|
|
22
|
+
def recent_names(self) -> Choices:
|
|
23
|
+
"""Tab-completion provider: names resolved earlier in this session."""
|
|
24
|
+
return Choices.from_values(self.app.recent_names)
|
|
25
|
+
|
|
26
|
+
def max_rows(self, args: argparse.Namespace) -> int:
|
|
27
|
+
return self.app.config.display_rows if args.max_rows is None else args.max_rows
|
|
28
|
+
|
|
29
|
+
def export(self, path: str, data: Any, csv_table: tuple[list[str], list[dict[str, Any]]]) -> None:
|
|
30
|
+
self.app.ui.success(f"Exported to {export.write(path, data, csv_table)}")
|
|
31
|
+
|
|
32
|
+
def show_graph(self, graph: Graph, *, as_json: bool, output: str | None) -> None:
|
|
33
|
+
"""Print a raw Cypher result (table or JSON), exporting it when `output` is set."""
|
|
34
|
+
if as_json:
|
|
35
|
+
self.app.poutput(export.to_json(export.graph_dict(graph)))
|
|
36
|
+
else:
|
|
37
|
+
self.app.ui.graph(graph)
|
|
38
|
+
if output:
|
|
39
|
+
self.export(output, export.graph_dict(graph), export.node_rows(graph.nodes.values()))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def output_path(value: str, formats: tuple[str, ...] = (".json", ".csv")) -> str:
|
|
43
|
+
"""argparse type for `-o/--output`: the file extension must be one of `formats`."""
|
|
44
|
+
if not value.lower().endswith(formats):
|
|
45
|
+
raise argparse.ArgumentTypeError(f"output file must end in {_one_of(formats)}")
|
|
46
|
+
return value
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def add_output_args(parser: Any, formats: tuple[str, ...] = (".json", ".csv")) -> None:
|
|
50
|
+
"""`--json` and `-o FILE` on a parser or argument group."""
|
|
51
|
+
parser.add_argument("--json", action="store_true", help="print raw JSON")
|
|
52
|
+
parser.add_argument("-o", "--output", type=lambda value: output_path(value, formats), metavar="FILE",
|
|
53
|
+
help=f"export to FILE ({_one_of(formats)})")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _one_of(formats: tuple[str, ...]) -> str:
|
|
57
|
+
"""`.json`, `.json or .csv`, `.json, .csv or .svg`, ..."""
|
|
58
|
+
if len(formats) == 1:
|
|
59
|
+
return formats[0]
|
|
60
|
+
return f"{', '.join(formats[:-1])} or {formats[-1]}"
|