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,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]}"