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/__init__.py
ADDED
bhshell/__main__.py
ADDED
bhshell/cli.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Command-line entry point."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
from collections.abc import Sequence
|
|
8
|
+
|
|
9
|
+
from cmd2.string_utils import quote_if_needed
|
|
10
|
+
|
|
11
|
+
from bhshell import __version__
|
|
12
|
+
from bhshell.client.client import normalize_url
|
|
13
|
+
from bhshell.config import Config
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
17
|
+
p = argparse.ArgumentParser(prog="bhshell", description="Interactive shell for BloodHound.")
|
|
18
|
+
p.add_argument("--url", help="BH backend URL (persisted; default http://127.0.0.1:8080/)")
|
|
19
|
+
p.add_argument("-k", "--insecure", action="store_true", help="disable TLS certificate verification (persisted; 'tls on' re-enables it)")
|
|
20
|
+
p.add_argument("--no-resume", action="store_true", help="do not resume the cached session")
|
|
21
|
+
p.add_argument("-c", "--command", action="append", default=[], metavar="CMD",
|
|
22
|
+
help="run CMD and exit (repeatable)")
|
|
23
|
+
p.add_argument("-s", "--script", metavar="FILE", help="run commands from FILE and exit")
|
|
24
|
+
p.add_argument("--debug", action="store_true", help="show tracebacks for errors")
|
|
25
|
+
p.add_argument("--version", action="version", version=f"bhshell {__version__}")
|
|
26
|
+
return p
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
30
|
+
args = build_parser().parse_args(argv)
|
|
31
|
+
config = Config.load()
|
|
32
|
+
changed = False
|
|
33
|
+
if args.url:
|
|
34
|
+
try:
|
|
35
|
+
config.url = normalize_url(args.url)
|
|
36
|
+
except ValueError as exc:
|
|
37
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
38
|
+
return 2
|
|
39
|
+
changed = True
|
|
40
|
+
if args.insecure:
|
|
41
|
+
config.verify_tls = False
|
|
42
|
+
changed = True
|
|
43
|
+
if changed:
|
|
44
|
+
config.save()
|
|
45
|
+
|
|
46
|
+
from bhshell.shell.app import BHShell # imported late so '--help' stays fast
|
|
47
|
+
|
|
48
|
+
app = BHShell(config, debug=args.debug, resume=not args.no_resume)
|
|
49
|
+
if args.command or args.script:
|
|
50
|
+
commands = list(args.command)
|
|
51
|
+
if args.script:
|
|
52
|
+
commands.append(f"run_script {quote_if_needed(args.script)}")
|
|
53
|
+
app.runcmds_plus_hooks(commands)
|
|
54
|
+
return app.exit_code or (1 if app.command_failures else 0)
|
|
55
|
+
return app.cmdloop() or 0
|
|
File without changes
|
bhshell/client/auth.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Request authenticators for the BH API.
|
|
2
|
+
|
|
3
|
+
Two schemes are supported:
|
|
4
|
+
|
|
5
|
+
* `JWTAuth` - `Authorization: Bearer <session_token>` obtained from
|
|
6
|
+
`POST /api/v2/login`.
|
|
7
|
+
* `HMACAuth` - BH API tokens (Token ID + Token Key). Each request is
|
|
8
|
+
signed with a chained HMAC-SHA256 as documented by SpecterOps:
|
|
9
|
+
|
|
10
|
+
1. `k1 = HMAC(token_key, METHOD + URI)`
|
|
11
|
+
2. `k2 = HMAC(k1, RequestDate[:13])` (RFC3339 date truncated to the hour)
|
|
12
|
+
3. `signature = HMAC(k2, body)`
|
|
13
|
+
|
|
14
|
+
Headers: `Authorization: bhesignature <token_id>`, `RequestDate` and
|
|
15
|
+
`Signature` (base64).
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import base64
|
|
21
|
+
import hashlib
|
|
22
|
+
import hmac
|
|
23
|
+
from collections.abc import Callable
|
|
24
|
+
from datetime import datetime
|
|
25
|
+
from urllib.parse import urlsplit
|
|
26
|
+
|
|
27
|
+
from requests import PreparedRequest
|
|
28
|
+
from requests.auth import AuthBase
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class JWTAuth(AuthBase):
|
|
32
|
+
def __init__(self, token: str) -> None:
|
|
33
|
+
self.token = token
|
|
34
|
+
|
|
35
|
+
def __call__(self, r: PreparedRequest) -> PreparedRequest:
|
|
36
|
+
r.headers["Authorization"] = f"Bearer {self.token}"
|
|
37
|
+
return r
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class HMACAuth(AuthBase):
|
|
41
|
+
def __init__(
|
|
42
|
+
self,
|
|
43
|
+
token_id: str,
|
|
44
|
+
token_key: str,
|
|
45
|
+
*,
|
|
46
|
+
clock: Callable[[], datetime] | None = None,
|
|
47
|
+
) -> None:
|
|
48
|
+
self.token_id = token_id
|
|
49
|
+
self._key = token_key.encode()
|
|
50
|
+
self._clock = clock or (lambda: datetime.now().astimezone())
|
|
51
|
+
|
|
52
|
+
@staticmethod
|
|
53
|
+
def _request_uri(url: str) -> str:
|
|
54
|
+
parts = urlsplit(url)
|
|
55
|
+
return parts.path + (f"?{parts.query}" if parts.query else "")
|
|
56
|
+
|
|
57
|
+
def sign(self, method: str, uri: str, body: bytes | None, request_date: str) -> str:
|
|
58
|
+
digester = hmac.new(self._key, None, hashlib.sha256)
|
|
59
|
+
digester.update(f"{method.upper()}{uri}".encode())
|
|
60
|
+
digester = hmac.new(digester.digest(), None, hashlib.sha256)
|
|
61
|
+
digester.update(request_date[:13].encode())
|
|
62
|
+
digester = hmac.new(digester.digest(), None, hashlib.sha256)
|
|
63
|
+
if body:
|
|
64
|
+
digester.update(body)
|
|
65
|
+
return base64.b64encode(digester.digest()).decode()
|
|
66
|
+
|
|
67
|
+
def __call__(self, r: PreparedRequest) -> PreparedRequest:
|
|
68
|
+
request_date = self._clock().isoformat("T")
|
|
69
|
+
body = r.body.encode() if isinstance(r.body, str) else r.body
|
|
70
|
+
signature = self.sign(r.method or "GET", self._request_uri(r.url or ""), body, request_date)
|
|
71
|
+
r.headers["Authorization"] = f"bhesignature {self.token_id}"
|
|
72
|
+
r.headers["RequestDate"] = request_date
|
|
73
|
+
r.headers["Signature"] = signature
|
|
74
|
+
return r
|
bhshell/client/client.py
ADDED
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
"""BH REST API v2 transport: auth, retries, error mapping and pagination.
|
|
2
|
+
|
|
3
|
+
Returns decoded JSON as-is; bhshell.queries makes sense of it."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import base64
|
|
8
|
+
import json
|
|
9
|
+
import time
|
|
10
|
+
from collections.abc import Iterator
|
|
11
|
+
from typing import Any, Literal
|
|
12
|
+
from urllib.parse import urljoin, urlsplit
|
|
13
|
+
|
|
14
|
+
import requests
|
|
15
|
+
from requests.adapters import HTTPAdapter
|
|
16
|
+
from urllib3.util.retry import Retry
|
|
17
|
+
|
|
18
|
+
from bhshell import __version__
|
|
19
|
+
from bhshell.client.auth import HMACAuth, JWTAuth
|
|
20
|
+
from bhshell.config import DEFAULT_URL
|
|
21
|
+
from bhshell.errors import (
|
|
22
|
+
ApiError,
|
|
23
|
+
AuthError,
|
|
24
|
+
ConnectionFailed,
|
|
25
|
+
CypherDisabledError,
|
|
26
|
+
ForbiddenError,
|
|
27
|
+
LoginFailed,
|
|
28
|
+
MFARequired,
|
|
29
|
+
NotAuthenticated,
|
|
30
|
+
NotFoundError,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
AuthMode = Literal["none", "jwt", "hmac"]
|
|
34
|
+
|
|
35
|
+
_MFA_MARKERS = ("otp", "one time", "one-time", "mfa", "totp")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def normalize_url(url: str) -> str:
|
|
39
|
+
url = url.strip()
|
|
40
|
+
if not url:
|
|
41
|
+
return DEFAULT_URL
|
|
42
|
+
if "://" not in url:
|
|
43
|
+
url = f"http://{url}"
|
|
44
|
+
parts = urlsplit(url)
|
|
45
|
+
if parts.scheme not in ("http", "https") or not parts.netloc:
|
|
46
|
+
raise ValueError(f"Invalid backend URL: {url!r}")
|
|
47
|
+
return f"{parts.scheme}://{parts.netloc}{parts.path.rstrip('/')}/"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def route_missing(exc: NotFoundError) -> bool:
|
|
51
|
+
"""Whether a 404 means the endpoint does not exist (Go's router), not that nothing was found."""
|
|
52
|
+
return "page not found" in exc.message.lower()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def jwt_claims(token: str) -> dict[str, Any]:
|
|
56
|
+
"""The payload of a JWT, not verified."""
|
|
57
|
+
try:
|
|
58
|
+
payload = token.split(".")[1]
|
|
59
|
+
payload += "=" * (-len(payload) % 4)
|
|
60
|
+
return json.loads(base64.urlsafe_b64decode(payload))
|
|
61
|
+
except (IndexError, ValueError):
|
|
62
|
+
return {}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class BHClient:
|
|
66
|
+
def __init__(
|
|
67
|
+
self,
|
|
68
|
+
base_url: str = DEFAULT_URL,
|
|
69
|
+
*,
|
|
70
|
+
verify_tls: bool = True,
|
|
71
|
+
timeout: tuple[float, float] = (5.0, 60.0),
|
|
72
|
+
page_size: int = 500,
|
|
73
|
+
) -> None:
|
|
74
|
+
self.base_url = normalize_url(base_url)
|
|
75
|
+
self.verify_tls = verify_tls
|
|
76
|
+
self.timeout = timeout
|
|
77
|
+
self.page_size = page_size
|
|
78
|
+
self.auth_mode: AuthMode = "none"
|
|
79
|
+
self.username: str = ""
|
|
80
|
+
self.token_id: str = ""
|
|
81
|
+
self.jwt: str = ""
|
|
82
|
+
self._auth: requests.auth.AuthBase | None = None
|
|
83
|
+
|
|
84
|
+
self._session = requests.Session()
|
|
85
|
+
self._session.headers.update({"User-Agent": f"bhshell/{__version__}", "Accept": "application/json"})
|
|
86
|
+
retry = Retry(
|
|
87
|
+
total=3,
|
|
88
|
+
backoff_factor=0.5,
|
|
89
|
+
status_forcelist=(429, 502, 503, 504),
|
|
90
|
+
# Only idempotent methods, and never after a read timeout: a slow Cypher
|
|
91
|
+
# query (POST) would otherwise run up to four times on the server.
|
|
92
|
+
read=False,
|
|
93
|
+
allowed_methods=frozenset({"GET", "PUT", "DELETE"}),
|
|
94
|
+
respect_retry_after_header=True,
|
|
95
|
+
raise_on_status=False,
|
|
96
|
+
)
|
|
97
|
+
adapter = HTTPAdapter(max_retries=retry)
|
|
98
|
+
self._session.mount("http://", adapter)
|
|
99
|
+
self._session.mount("https://", adapter)
|
|
100
|
+
|
|
101
|
+
@property
|
|
102
|
+
def host(self) -> str:
|
|
103
|
+
return urlsplit(self.base_url).netloc
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def is_authenticated(self) -> bool:
|
|
107
|
+
return self._auth is not None
|
|
108
|
+
|
|
109
|
+
def set_url(self, url: str) -> None:
|
|
110
|
+
"""Credentials are dropped when the URL changes."""
|
|
111
|
+
new = normalize_url(url)
|
|
112
|
+
if new != self.base_url:
|
|
113
|
+
self.clear_auth()
|
|
114
|
+
self.base_url = new
|
|
115
|
+
|
|
116
|
+
def set_jwt(self, token: str, username: str = "") -> None:
|
|
117
|
+
self._auth = JWTAuth(token)
|
|
118
|
+
self.auth_mode = "jwt"
|
|
119
|
+
self.jwt = token
|
|
120
|
+
self.username = username
|
|
121
|
+
self.token_id = ""
|
|
122
|
+
|
|
123
|
+
def set_hmac(self, token_id: str, token_key: str) -> None:
|
|
124
|
+
self._auth = HMACAuth(token_id, token_key)
|
|
125
|
+
self.auth_mode = "hmac"
|
|
126
|
+
self.token_id = token_id
|
|
127
|
+
self.jwt = ""
|
|
128
|
+
self.username = ""
|
|
129
|
+
|
|
130
|
+
def auth_state(self) -> tuple[Any, ...]:
|
|
131
|
+
return (self._auth, self.auth_mode, self.username, self.token_id, self.jwt)
|
|
132
|
+
|
|
133
|
+
def restore_auth(self, state: tuple[Any, ...]) -> None:
|
|
134
|
+
self._auth, self.auth_mode, self.username, self.token_id, self.jwt = state
|
|
135
|
+
|
|
136
|
+
def clear_auth(self) -> None:
|
|
137
|
+
self._auth = None
|
|
138
|
+
self.auth_mode = "none"
|
|
139
|
+
self.username = ""
|
|
140
|
+
self.token_id = ""
|
|
141
|
+
self.jwt = ""
|
|
142
|
+
|
|
143
|
+
def token_expiry(self) -> float | None:
|
|
144
|
+
if self.auth_mode != "jwt":
|
|
145
|
+
return None
|
|
146
|
+
exp = jwt_claims(self.jwt).get("exp")
|
|
147
|
+
return float(exp) if isinstance(exp, (int, float)) else None
|
|
148
|
+
|
|
149
|
+
def token_expired(self) -> bool:
|
|
150
|
+
exp = self.token_expiry()
|
|
151
|
+
return exp is not None and exp <= time.time()
|
|
152
|
+
|
|
153
|
+
def request(
|
|
154
|
+
self,
|
|
155
|
+
method: str,
|
|
156
|
+
path: str,
|
|
157
|
+
*,
|
|
158
|
+
params: dict[str, Any] | None = None,
|
|
159
|
+
json_body: Any = None,
|
|
160
|
+
auth: bool = True,
|
|
161
|
+
) -> Any:
|
|
162
|
+
if auth and self._auth is None:
|
|
163
|
+
raise NotAuthenticated("Not authenticated")
|
|
164
|
+
url = urljoin(self.base_url, path.lstrip("/"))
|
|
165
|
+
try:
|
|
166
|
+
resp = self._session.request(
|
|
167
|
+
method,
|
|
168
|
+
url,
|
|
169
|
+
params=params,
|
|
170
|
+
json=json_body,
|
|
171
|
+
auth=self._auth if auth else None,
|
|
172
|
+
timeout=self.timeout,
|
|
173
|
+
verify=self.verify_tls,
|
|
174
|
+
)
|
|
175
|
+
except requests.exceptions.SSLError as exc:
|
|
176
|
+
raise ConnectionFailed(
|
|
177
|
+
f"TLS error talking to {self.host}: {exc}",
|
|
178
|
+
hint="Use 'tls off' for self-signed certificates.",
|
|
179
|
+
) from exc
|
|
180
|
+
except requests.exceptions.Timeout as exc:
|
|
181
|
+
raise ConnectionFailed(f"Request to {url} timed out") from exc
|
|
182
|
+
except requests.exceptions.RequestException as exc:
|
|
183
|
+
raise ConnectionFailed(f"Cannot reach BH at {self.base_url} ({exc.__class__.__name__})") from exc
|
|
184
|
+
return self._handle(resp)
|
|
185
|
+
|
|
186
|
+
@staticmethod
|
|
187
|
+
def _error_message(resp: requests.Response) -> str:
|
|
188
|
+
try:
|
|
189
|
+
body = resp.json()
|
|
190
|
+
except ValueError:
|
|
191
|
+
return resp.text.strip()[:300] or resp.reason or "unknown error"
|
|
192
|
+
if isinstance(body, dict):
|
|
193
|
+
errors = body.get("errors")
|
|
194
|
+
if isinstance(errors, list) and errors:
|
|
195
|
+
msgs = [e.get("message", "") for e in errors if isinstance(e, dict)]
|
|
196
|
+
if any(msgs):
|
|
197
|
+
return "; ".join(m for m in msgs if m)
|
|
198
|
+
for key in ("message", "error"):
|
|
199
|
+
if isinstance(body.get(key), str):
|
|
200
|
+
return body[key]
|
|
201
|
+
return str(body)[:300]
|
|
202
|
+
|
|
203
|
+
def _handle(self, resp: requests.Response) -> Any:
|
|
204
|
+
if resp.status_code < 300:
|
|
205
|
+
if not resp.content:
|
|
206
|
+
return None
|
|
207
|
+
try:
|
|
208
|
+
return resp.json()
|
|
209
|
+
except ValueError as exc:
|
|
210
|
+
raise ApiError(resp.status_code, "Response is not valid JSON") from exc
|
|
211
|
+
msg = self._error_message(resp)
|
|
212
|
+
if resp.status_code == 401:
|
|
213
|
+
raise AuthError(f"Unauthorized: {msg}")
|
|
214
|
+
if resp.status_code == 403:
|
|
215
|
+
raise ForbiddenError(f"Forbidden: {msg}")
|
|
216
|
+
if resp.status_code == 404:
|
|
217
|
+
raise NotFoundError(msg or "Not found")
|
|
218
|
+
raise ApiError(resp.status_code, msg)
|
|
219
|
+
|
|
220
|
+
def get(self, path: str, **params: Any) -> Any:
|
|
221
|
+
return self.request("GET", path, params=params or None)
|
|
222
|
+
|
|
223
|
+
def paginate(self, path: str, *, limit: int | None = None, **params: Any) -> Iterator[dict[str, Any]]:
|
|
224
|
+
skip = 0
|
|
225
|
+
yielded = 0
|
|
226
|
+
while True:
|
|
227
|
+
page = self.get(path, skip=skip, limit=self.page_size, **params) or {}
|
|
228
|
+
items = page.get("data") or []
|
|
229
|
+
if isinstance(items, dict): # e.g. asset-group-tags selectors: {"data": {"selectors": [...]}}
|
|
230
|
+
items = next((v for v in items.values() if isinstance(v, list)), [])
|
|
231
|
+
if not items:
|
|
232
|
+
return
|
|
233
|
+
for item in items:
|
|
234
|
+
yield item
|
|
235
|
+
yielded += 1
|
|
236
|
+
if limit is not None and yielded >= limit:
|
|
237
|
+
return
|
|
238
|
+
count = page.get("count")
|
|
239
|
+
skip += len(items)
|
|
240
|
+
# Trust the total when there is one: the server may cap `limit` below our page size.
|
|
241
|
+
done = (skip >= count) if isinstance(count, int) else (len(items) < self.page_size)
|
|
242
|
+
if done:
|
|
243
|
+
return
|
|
244
|
+
|
|
245
|
+
def login(self, username: str, password: str, otp: str | None = None) -> dict[str, Any]:
|
|
246
|
+
"""Switch to JWT auth. Returns the login response's `data` (user_id, auth_expired, session_token)."""
|
|
247
|
+
body: dict[str, Any] = {"login_method": "secret", "username": username, "secret": password}
|
|
248
|
+
if otp:
|
|
249
|
+
body["otp"] = otp
|
|
250
|
+
try:
|
|
251
|
+
resp = self.request("POST", "api/v2/login", json_body=body, auth=False)
|
|
252
|
+
except (AuthError, ApiError) as exc:
|
|
253
|
+
text = exc.message.lower()
|
|
254
|
+
if not otp and any(marker in text for marker in _MFA_MARKERS):
|
|
255
|
+
raise MFARequired("A one-time password is required for this account") from exc
|
|
256
|
+
if isinstance(exc, AuthError):
|
|
257
|
+
raise LoginFailed("Login failed: invalid username or password") from exc
|
|
258
|
+
raise
|
|
259
|
+
data = (resp or {}).get("data") or {}
|
|
260
|
+
token = data.get("session_token")
|
|
261
|
+
if not token:
|
|
262
|
+
raise ApiError(200, "Login response did not contain a session token")
|
|
263
|
+
self.set_jwt(token, username)
|
|
264
|
+
return data
|
|
265
|
+
|
|
266
|
+
def logout(self) -> None:
|
|
267
|
+
try:
|
|
268
|
+
if self.auth_mode == "jwt":
|
|
269
|
+
self.request("POST", "api/v2/logout")
|
|
270
|
+
except (AuthError, NotFoundError, ApiError, ConnectionFailed):
|
|
271
|
+
pass
|
|
272
|
+
finally:
|
|
273
|
+
self.clear_auth()
|
|
274
|
+
|
|
275
|
+
def self_info(self) -> dict[str, Any]:
|
|
276
|
+
# BH answers unauthenticated requests here with 200 {"data": null} instead of 401.
|
|
277
|
+
data = (self.get("api/v2/self") or {}).get("data")
|
|
278
|
+
if not data:
|
|
279
|
+
raise AuthError("Not authenticated (server returned no principal)")
|
|
280
|
+
return data
|
|
281
|
+
|
|
282
|
+
def version(self) -> dict[str, Any]:
|
|
283
|
+
return (self.get("api/version") or {}).get("data") or {}
|
|
284
|
+
|
|
285
|
+
def cypher(self, query: str, include_properties: bool = True) -> dict[str, Any]:
|
|
286
|
+
body = {"query": query, "include_properties": include_properties}
|
|
287
|
+
try:
|
|
288
|
+
resp = self.request("POST", "api/v2/graphs/cypher", json_body=body)
|
|
289
|
+
except NotFoundError as exc: # also how BH answers an empty result
|
|
290
|
+
if route_missing(exc):
|
|
291
|
+
raise CypherDisabledError("The cypher endpoint does not exist on this server") from exc
|
|
292
|
+
return {"nodes": {}, "edges": [], "literals": []}
|
|
293
|
+
except ForbiddenError as exc:
|
|
294
|
+
raise CypherDisabledError(f"Cypher queries are not permitted: {exc.message}") from exc
|
|
295
|
+
except ApiError as exc:
|
|
296
|
+
if exc.status in (405, 501):
|
|
297
|
+
raise CypherDisabledError(f"Cypher queries are disabled: {exc.detail}") from exc
|
|
298
|
+
raise
|
|
299
|
+
data = (resp or {}).get("data") or {}
|
|
300
|
+
nodes = data.get("nodes") or {}
|
|
301
|
+
edges = data.get("edges") or []
|
|
302
|
+
return {"nodes": nodes, "edges": edges, "literals": data.get("literals") or []}
|
|
303
|
+
|
|
304
|
+
def search(self, query: str, kind: str | None = None) -> list[dict[str, Any]]:
|
|
305
|
+
params: dict[str, Any] = {"q": query}
|
|
306
|
+
if kind:
|
|
307
|
+
params["type"] = kind
|
|
308
|
+
resp = self.get("api/v2/search", **params) or {}
|
|
309
|
+
data = resp.get("data") or []
|
|
310
|
+
return data if isinstance(data, list) else []
|
|
311
|
+
|
|
312
|
+
def entity(self, kind: str, objectid: str) -> dict[str, Any]:
|
|
313
|
+
resp = self.get(f"api/v2/{_collection(kind)}/{objectid}", counts="false") or {}
|
|
314
|
+
data = resp.get("data") or {}
|
|
315
|
+
return data.get("props") or data
|
|
316
|
+
|
|
317
|
+
def entity_related(
|
|
318
|
+
self, kind: str, objectid: str, relation: str, *, limit: int | None = None
|
|
319
|
+
) -> list[dict[str, Any]]:
|
|
320
|
+
path = f"api/v2/{_collection(kind)}/{objectid}/{relation}"
|
|
321
|
+
return list(self.paginate(path, limit=limit, type="list"))
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _collection(kind: str) -> str:
|
|
325
|
+
mapping = {
|
|
326
|
+
"user": "users",
|
|
327
|
+
"computer": "computers",
|
|
328
|
+
"group": "groups",
|
|
329
|
+
"domain": "domains",
|
|
330
|
+
"gpo": "gpos",
|
|
331
|
+
"ou": "ous",
|
|
332
|
+
"container": "containers",
|
|
333
|
+
}
|
|
334
|
+
try:
|
|
335
|
+
return mapping[kind.lower()]
|
|
336
|
+
except KeyError as exc:
|
|
337
|
+
raise ValueError(f"No REST collection for kind {kind!r}") from exc
|
bhshell/config.py
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""Persistent configuration and session cache.
|
|
2
|
+
|
|
3
|
+
Layout (overridable with the `BHSHELL_HOME` environment variable):
|
|
4
|
+
|
|
5
|
+
~/.bhshell/
|
|
6
|
+
config.toml # backend URL, TLS, timeouts, last username, query folders
|
|
7
|
+
session.json # cached JWT (mode 0600)
|
|
8
|
+
history # cmd2 command history
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import math
|
|
15
|
+
import os
|
|
16
|
+
import tomllib
|
|
17
|
+
from dataclasses import asdict, dataclass, fields
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
DEFAULT_URL = "http://127.0.0.1:8080/"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def config_home() -> Path:
|
|
25
|
+
home = Path(os.environ.get("BHSHELL_HOME", Path.home() / ".bhshell")).expanduser()
|
|
26
|
+
home.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
27
|
+
return home
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# Lowest accepted value of numeric settings (`display_rows` 0 means "all rows").
|
|
31
|
+
_MINIMUMS: dict[str, float] = {"connect_timeout": 1, "read_timeout": 1, "page_size": 1, "max_results": 1,
|
|
32
|
+
"display_rows": 0}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def parse_bool(value: str) -> bool:
|
|
36
|
+
lowered = value.strip().lower()
|
|
37
|
+
if lowered in ("1", "true", "yes", "on"):
|
|
38
|
+
return True
|
|
39
|
+
if lowered in ("0", "false", "no", "off"):
|
|
40
|
+
return False
|
|
41
|
+
raise ValueError(f"not a boolean: {value!r}")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _toml_value(value: Any) -> str:
|
|
45
|
+
if isinstance(value, bool):
|
|
46
|
+
return "true" if value else "false"
|
|
47
|
+
if isinstance(value, (int, float)):
|
|
48
|
+
return repr(value)
|
|
49
|
+
# JSON string escaping is a valid subset of TOML basic strings.
|
|
50
|
+
return json.dumps(str(value))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _write_private(path: Path, content: str) -> None:
|
|
54
|
+
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
55
|
+
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
56
|
+
fh.write(content)
|
|
57
|
+
os.chmod(path, 0o600)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass
|
|
61
|
+
class Config:
|
|
62
|
+
url: str = DEFAULT_URL
|
|
63
|
+
verify_tls: bool = True
|
|
64
|
+
connect_timeout: float = 5.0
|
|
65
|
+
read_timeout: float = 60.0
|
|
66
|
+
last_username: str = ""
|
|
67
|
+
page_size: int = 500
|
|
68
|
+
max_results: int = 5000
|
|
69
|
+
display_rows: int = 100
|
|
70
|
+
cache_session: bool = True
|
|
71
|
+
# Extra folders of .cyp queries for 'query', separated like PATH (':' on Unix, ';' on Windows).
|
|
72
|
+
query_dirs: str = ""
|
|
73
|
+
|
|
74
|
+
@classmethod
|
|
75
|
+
def path(cls) -> Path:
|
|
76
|
+
return config_home() / "config.toml"
|
|
77
|
+
|
|
78
|
+
@classmethod
|
|
79
|
+
def load(cls) -> Config:
|
|
80
|
+
"""Unknown keys and invalid values are ignored."""
|
|
81
|
+
path = cls.path()
|
|
82
|
+
if not path.exists():
|
|
83
|
+
return cls()
|
|
84
|
+
try:
|
|
85
|
+
data = tomllib.loads(path.read_text(encoding="utf-8"))
|
|
86
|
+
except (OSError, tomllib.TOMLDecodeError):
|
|
87
|
+
return cls()
|
|
88
|
+
known = {f.name for f in fields(cls)}
|
|
89
|
+
cfg = cls()
|
|
90
|
+
for key, value in data.items():
|
|
91
|
+
if key in known:
|
|
92
|
+
try:
|
|
93
|
+
setattr(cfg, key, cls.coerce(key, value))
|
|
94
|
+
except (TypeError, ValueError):
|
|
95
|
+
pass
|
|
96
|
+
return cfg
|
|
97
|
+
|
|
98
|
+
@classmethod
|
|
99
|
+
def coerce(cls, key: str, value: Any) -> Any:
|
|
100
|
+
"""Convert `value` to the type of setting `key`.
|
|
101
|
+
|
|
102
|
+
Raises TypeError for a value of the wrong type, ValueError for an invalid one.
|
|
103
|
+
"""
|
|
104
|
+
default = getattr(cls(), key)
|
|
105
|
+
if isinstance(default, bool):
|
|
106
|
+
return value if isinstance(value, bool) else parse_bool(str(value))
|
|
107
|
+
if isinstance(default, (int, float)):
|
|
108
|
+
if isinstance(value, bool):
|
|
109
|
+
raise TypeError(f"expected a number, got {value!r}")
|
|
110
|
+
number = type(default)(value)
|
|
111
|
+
if not math.isfinite(number):
|
|
112
|
+
raise ValueError(f"not a finite number: {value!r}")
|
|
113
|
+
minimum = _MINIMUMS.get(key)
|
|
114
|
+
if minimum is not None and number < minimum:
|
|
115
|
+
raise ValueError(f"must be at least {minimum}")
|
|
116
|
+
return number
|
|
117
|
+
if key == "url":
|
|
118
|
+
from bhshell.client.client import normalize_url # late: the client imports this module
|
|
119
|
+
|
|
120
|
+
return normalize_url(str(value))
|
|
121
|
+
return str(value)
|
|
122
|
+
|
|
123
|
+
def save(self) -> None:
|
|
124
|
+
lines = ["# bhshell configuration", *(f"{k} = {_toml_value(v)}" for k, v in asdict(self).items())]
|
|
125
|
+
_write_private(self.path(), "\n".join(lines) + "\n")
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@dataclass
|
|
129
|
+
class CachedSession:
|
|
130
|
+
url: str
|
|
131
|
+
token: str
|
|
132
|
+
username: str = ""
|
|
133
|
+
|
|
134
|
+
@staticmethod
|
|
135
|
+
def path() -> Path:
|
|
136
|
+
return config_home() / "session.json"
|
|
137
|
+
|
|
138
|
+
@classmethod
|
|
139
|
+
def load(cls) -> CachedSession | None:
|
|
140
|
+
path = cls.path()
|
|
141
|
+
try:
|
|
142
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
143
|
+
session = cls(url=data["url"], token=data["token"], username=data.get("username", ""))
|
|
144
|
+
except (OSError, ValueError, KeyError, TypeError):
|
|
145
|
+
return None
|
|
146
|
+
if not all(isinstance(v, str) for v in (session.url, session.token, session.username)):
|
|
147
|
+
return None
|
|
148
|
+
return session
|
|
149
|
+
|
|
150
|
+
def save(self) -> None:
|
|
151
|
+
_write_private(self.path(), json.dumps(asdict(self)))
|
|
152
|
+
|
|
153
|
+
@classmethod
|
|
154
|
+
def clear(cls) -> None:
|
|
155
|
+
try:
|
|
156
|
+
cls.path().unlink()
|
|
157
|
+
except FileNotFoundError:
|
|
158
|
+
pass
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def history_file() -> Path:
|
|
162
|
+
return config_home() / "history"
|