bt-cli 0.4.13__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 (121) hide show
  1. bt_cli/__init__.py +3 -0
  2. bt_cli/cli.py +830 -0
  3. bt_cli/commands/__init__.py +1 -0
  4. bt_cli/commands/configure.py +415 -0
  5. bt_cli/commands/learn.py +229 -0
  6. bt_cli/commands/quick.py +784 -0
  7. bt_cli/core/__init__.py +1 -0
  8. bt_cli/core/auth.py +213 -0
  9. bt_cli/core/client.py +313 -0
  10. bt_cli/core/config.py +393 -0
  11. bt_cli/core/config_file.py +420 -0
  12. bt_cli/core/csv_utils.py +91 -0
  13. bt_cli/core/errors.py +247 -0
  14. bt_cli/core/output.py +205 -0
  15. bt_cli/core/prompts.py +87 -0
  16. bt_cli/core/rest_debug.py +221 -0
  17. bt_cli/data/CLAUDE.md +94 -0
  18. bt_cli/data/__init__.py +0 -0
  19. bt_cli/data/skills/bt/SKILL.md +108 -0
  20. bt_cli/data/skills/entitle/SKILL.md +170 -0
  21. bt_cli/data/skills/epmw/SKILL.md +144 -0
  22. bt_cli/data/skills/pra/SKILL.md +150 -0
  23. bt_cli/data/skills/pws/SKILL.md +198 -0
  24. bt_cli/entitle/__init__.py +1 -0
  25. bt_cli/entitle/client/__init__.py +5 -0
  26. bt_cli/entitle/client/base.py +443 -0
  27. bt_cli/entitle/commands/__init__.py +24 -0
  28. bt_cli/entitle/commands/accounts.py +53 -0
  29. bt_cli/entitle/commands/applications.py +39 -0
  30. bt_cli/entitle/commands/auth.py +68 -0
  31. bt_cli/entitle/commands/bundles.py +218 -0
  32. bt_cli/entitle/commands/integrations.py +60 -0
  33. bt_cli/entitle/commands/permissions.py +70 -0
  34. bt_cli/entitle/commands/policies.py +97 -0
  35. bt_cli/entitle/commands/resources.py +131 -0
  36. bt_cli/entitle/commands/roles.py +74 -0
  37. bt_cli/entitle/commands/users.py +123 -0
  38. bt_cli/entitle/commands/workflows.py +187 -0
  39. bt_cli/entitle/models/__init__.py +31 -0
  40. bt_cli/entitle/models/bundle.py +28 -0
  41. bt_cli/entitle/models/common.py +37 -0
  42. bt_cli/entitle/models/integration.py +30 -0
  43. bt_cli/entitle/models/permission.py +27 -0
  44. bt_cli/entitle/models/policy.py +25 -0
  45. bt_cli/entitle/models/resource.py +29 -0
  46. bt_cli/entitle/models/role.py +28 -0
  47. bt_cli/entitle/models/user.py +24 -0
  48. bt_cli/entitle/models/workflow.py +55 -0
  49. bt_cli/epmw/__init__.py +1 -0
  50. bt_cli/epmw/client/__init__.py +5 -0
  51. bt_cli/epmw/client/base.py +848 -0
  52. bt_cli/epmw/commands/__init__.py +33 -0
  53. bt_cli/epmw/commands/audits.py +250 -0
  54. bt_cli/epmw/commands/auth.py +55 -0
  55. bt_cli/epmw/commands/computers.py +140 -0
  56. bt_cli/epmw/commands/events.py +233 -0
  57. bt_cli/epmw/commands/groups.py +215 -0
  58. bt_cli/epmw/commands/policies.py +673 -0
  59. bt_cli/epmw/commands/quick.py +348 -0
  60. bt_cli/epmw/commands/requests.py +224 -0
  61. bt_cli/epmw/commands/roles.py +78 -0
  62. bt_cli/epmw/commands/tasks.py +38 -0
  63. bt_cli/epmw/commands/users.py +219 -0
  64. bt_cli/epmw/models/__init__.py +1 -0
  65. bt_cli/pra/__init__.py +1 -0
  66. bt_cli/pra/client/__init__.py +5 -0
  67. bt_cli/pra/client/base.py +618 -0
  68. bt_cli/pra/commands/__init__.py +30 -0
  69. bt_cli/pra/commands/auth.py +55 -0
  70. bt_cli/pra/commands/import_export.py +442 -0
  71. bt_cli/pra/commands/jump_clients.py +139 -0
  72. bt_cli/pra/commands/jump_groups.py +146 -0
  73. bt_cli/pra/commands/jump_items.py +638 -0
  74. bt_cli/pra/commands/jumpoints.py +95 -0
  75. bt_cli/pra/commands/policies.py +197 -0
  76. bt_cli/pra/commands/quick.py +470 -0
  77. bt_cli/pra/commands/teams.py +81 -0
  78. bt_cli/pra/commands/users.py +87 -0
  79. bt_cli/pra/commands/vault.py +564 -0
  80. bt_cli/pra/models/__init__.py +27 -0
  81. bt_cli/pra/models/common.py +12 -0
  82. bt_cli/pra/models/jump_client.py +25 -0
  83. bt_cli/pra/models/jump_group.py +15 -0
  84. bt_cli/pra/models/jump_item.py +72 -0
  85. bt_cli/pra/models/jumpoint.py +19 -0
  86. bt_cli/pra/models/team.py +14 -0
  87. bt_cli/pra/models/user.py +17 -0
  88. bt_cli/pra/models/vault.py +45 -0
  89. bt_cli/pws/__init__.py +1 -0
  90. bt_cli/pws/client/__init__.py +5 -0
  91. bt_cli/pws/client/base.py +356 -0
  92. bt_cli/pws/client/beyondinsight.py +869 -0
  93. bt_cli/pws/client/passwordsafe.py +1786 -0
  94. bt_cli/pws/commands/__init__.py +33 -0
  95. bt_cli/pws/commands/accounts.py +372 -0
  96. bt_cli/pws/commands/assets.py +311 -0
  97. bt_cli/pws/commands/auth.py +166 -0
  98. bt_cli/pws/commands/clouds.py +221 -0
  99. bt_cli/pws/commands/config.py +344 -0
  100. bt_cli/pws/commands/credentials.py +347 -0
  101. bt_cli/pws/commands/databases.py +306 -0
  102. bt_cli/pws/commands/directories.py +199 -0
  103. bt_cli/pws/commands/functional.py +298 -0
  104. bt_cli/pws/commands/import_export.py +452 -0
  105. bt_cli/pws/commands/platforms.py +118 -0
  106. bt_cli/pws/commands/quick.py +1646 -0
  107. bt_cli/pws/commands/search.py +256 -0
  108. bt_cli/pws/commands/secrets.py +1343 -0
  109. bt_cli/pws/commands/systems.py +389 -0
  110. bt_cli/pws/commands/users.py +415 -0
  111. bt_cli/pws/commands/workgroups.py +166 -0
  112. bt_cli/pws/config.py +18 -0
  113. bt_cli/pws/models/__init__.py +19 -0
  114. bt_cli/pws/models/account.py +186 -0
  115. bt_cli/pws/models/asset.py +102 -0
  116. bt_cli/pws/models/common.py +132 -0
  117. bt_cli/pws/models/system.py +121 -0
  118. bt_cli-0.4.13.dist-info/METADATA +417 -0
  119. bt_cli-0.4.13.dist-info/RECORD +121 -0
  120. bt_cli-0.4.13.dist-info/WHEEL +4 -0
  121. bt_cli-0.4.13.dist-info/entry_points.txt +2 -0
@@ -0,0 +1 @@
1
+ """Core shared utilities for BeyondTrust CLI."""
bt_cli/core/auth.py ADDED
@@ -0,0 +1,213 @@
1
+ """Authentication strategies for BeyondTrust products."""
2
+
3
+ import logging
4
+ from abc import ABC, abstractmethod
5
+ from typing import Optional
6
+
7
+ import httpx
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+
12
+ class AuthStrategy(ABC):
13
+ """Base class for authentication strategies.
14
+
15
+ Each product may use different authentication mechanisms.
16
+ This abstraction allows the base client to work with any auth method.
17
+ """
18
+
19
+ @abstractmethod
20
+ def get_headers(self) -> dict[str, str]:
21
+ """Get authentication headers for requests.
22
+
23
+ Returns:
24
+ Dictionary of headers to include in requests
25
+ """
26
+ pass
27
+
28
+ def authenticate(self, client: httpx.Client) -> dict:
29
+ """Perform any initial authentication if needed.
30
+
31
+ Some auth methods (like OAuth) require an initial token exchange.
32
+ Others (like API key) don't need this step.
33
+
34
+ Args:
35
+ client: HTTP client to use for auth requests
36
+
37
+ Returns:
38
+ Authentication response data (may be empty dict)
39
+ """
40
+ return {}
41
+
42
+ def sign_out(self, client: httpx.Client) -> None:
43
+ """Perform any cleanup/sign-out if needed.
44
+
45
+ Args:
46
+ client: HTTP client to use for sign-out
47
+ """
48
+ pass
49
+
50
+
51
+ class BearerTokenAuth(AuthStrategy):
52
+ """Simple Bearer token authentication.
53
+
54
+ Used by: Entitle
55
+
56
+ The API key is passed directly as a Bearer token.
57
+ No session management required.
58
+ """
59
+
60
+ def __init__(self, api_key: str):
61
+ """Initialize with API key.
62
+
63
+ Args:
64
+ api_key: API key to use as Bearer token
65
+ """
66
+ self.api_key = api_key
67
+
68
+ def __repr__(self) -> str:
69
+ """Safe repr that doesn't expose credentials."""
70
+ return f"BearerTokenAuth(api_key='***')"
71
+
72
+ def get_headers(self) -> dict[str, str]:
73
+ """Get Bearer auth header."""
74
+ return {"Authorization": f"Bearer {self.api_key}"}
75
+
76
+
77
+ class PSAuthKeyAuth(AuthStrategy):
78
+ """PS-Auth header authentication for Password Safe.
79
+
80
+ Used by: Password Safe (API key method)
81
+
82
+ Uses custom PS-Auth header format with optional impersonation.
83
+ Requires session establishment via SignAppIn.
84
+ """
85
+
86
+ def __init__(self, api_key: str, run_as: Optional[str] = None):
87
+ """Initialize with API key and optional impersonation.
88
+
89
+ Args:
90
+ api_key: API key for authentication
91
+ run_as: Optional username for impersonation
92
+ """
93
+ self.api_key = api_key
94
+ self.run_as = run_as
95
+ self._session_active = False
96
+
97
+ def __repr__(self) -> str:
98
+ """Safe repr that doesn't expose credentials."""
99
+ run_as_str = f", run_as='{self.run_as}'" if self.run_as else ""
100
+ return f"PSAuthKeyAuth(api_key='***'{run_as_str})"
101
+
102
+ def get_headers(self) -> dict[str, str]:
103
+ """Get PS-Auth header with optional runas."""
104
+ auth_value = f"PS-Auth key={self.api_key};"
105
+ if self.run_as:
106
+ auth_value += f" runas={self.run_as};"
107
+ return {"Authorization": auth_value}
108
+
109
+ def authenticate(self, client: httpx.Client) -> dict:
110
+ """Establish session via SignAppIn.
111
+
112
+ Args:
113
+ client: HTTP client configured with base URL
114
+
115
+ Returns:
116
+ Session info from SignAppIn response
117
+ """
118
+ headers = self.get_headers()
119
+ headers["Content-Type"] = "application/json"
120
+
121
+ response = client.post("/Auth/SignAppIn", headers=headers)
122
+ response.raise_for_status()
123
+ self._session_active = True
124
+ return response.json()
125
+
126
+ def sign_out(self, client: httpx.Client) -> None:
127
+ """Sign out and end session."""
128
+ if self._session_active:
129
+ try:
130
+ headers = self.get_headers()
131
+ headers["Content-Type"] = "application/json"
132
+ client.post("/Auth/Signout", headers=headers)
133
+ except Exception as e:
134
+ logger.debug(f"Sign out failed (best effort): {e}")
135
+ finally:
136
+ self._session_active = False
137
+
138
+
139
+ class OAuthClientCredentials(AuthStrategy):
140
+ """OAuth client credentials flow for Password Safe.
141
+
142
+ Used by: Password Safe (OAuth method)
143
+
144
+ Exchanges client_id/client_secret for access token,
145
+ then uses Bearer token for subsequent requests.
146
+ """
147
+
148
+ def __init__(self, client_id: str, client_secret: str):
149
+ """Initialize with OAuth credentials.
150
+
151
+ Args:
152
+ client_id: OAuth client ID
153
+ client_secret: OAuth client secret
154
+ """
155
+ self.client_id = client_id
156
+ self.client_secret = client_secret
157
+ self._access_token: Optional[str] = None
158
+ self._session_active = False
159
+
160
+ def __repr__(self) -> str:
161
+ """Safe repr that doesn't expose credentials."""
162
+ return f"OAuthClientCredentials(client_id='{self.client_id}', client_secret='***')"
163
+
164
+ def get_headers(self) -> dict[str, str]:
165
+ """Get Bearer auth header with access token."""
166
+ if self._access_token:
167
+ return {"Authorization": f"Bearer {self._access_token}"}
168
+ return {}
169
+
170
+ def authenticate(self, client: httpx.Client) -> dict:
171
+ """Exchange credentials for token and establish session.
172
+
173
+ Args:
174
+ client: HTTP client configured with base URL
175
+
176
+ Returns:
177
+ Session info from SignAppIn response
178
+ """
179
+ # Step 1: Get access token
180
+ token_response = client.post(
181
+ "/Auth/connect/token",
182
+ data={
183
+ "grant_type": "client_credentials",
184
+ "client_id": self.client_id,
185
+ "client_secret": self.client_secret,
186
+ },
187
+ headers={"Content-Type": "application/x-www-form-urlencoded"},
188
+ )
189
+ token_response.raise_for_status()
190
+ token_data = token_response.json()
191
+ self._access_token = token_data.get("access_token")
192
+
193
+ # Step 2: Establish session
194
+ headers = self.get_headers()
195
+ headers["Content-Type"] = "application/json"
196
+
197
+ session_response = client.post("/Auth/SignAppIn", headers=headers)
198
+ session_response.raise_for_status()
199
+ self._session_active = True
200
+ return session_response.json()
201
+
202
+ def sign_out(self, client: httpx.Client) -> None:
203
+ """Sign out and end session."""
204
+ if self._session_active:
205
+ try:
206
+ headers = self.get_headers()
207
+ headers["Content-Type"] = "application/json"
208
+ client.post("/Auth/Signout", headers=headers)
209
+ except Exception as e:
210
+ logger.debug(f"Sign out failed (best effort): {e}")
211
+ finally:
212
+ self._session_active = False
213
+ self._access_token = None
bt_cli/core/client.py ADDED
@@ -0,0 +1,313 @@
1
+ """Base HTTP client for BeyondTrust products."""
2
+
3
+ import logging
4
+ import os
5
+ import sys
6
+ from typing import Any, Optional
7
+
8
+ import httpx
9
+
10
+ from .auth import AuthStrategy
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+ # Track if SSL warning has been shown (only show once per process)
15
+ _ssl_warning_shown = False
16
+
17
+
18
+ def _warn_ssl_disabled() -> None:
19
+ """Print warning to stderr when SSL verification is disabled.
20
+
21
+ Security: SSL verification protects against man-in-the-middle attacks.
22
+ Disabling it allows attackers to intercept credentials and data.
23
+
24
+ Only shows detailed warning once per process, but logs every time.
25
+ Suppression requires explicit BT_SSL_INSECURE_ALLOW=1 (not just any value).
26
+ """
27
+ global _ssl_warning_shown
28
+
29
+ # Always log for audit trail
30
+ logger.warning("SSL certificate verification is DISABLED - connection is insecure")
31
+
32
+ # Check for explicit opt-in to suppress visual warning
33
+ # Requires explicit value "1" to prevent accidental suppression
34
+ if os.environ.get("BT_SSL_INSECURE_ALLOW") == "1":
35
+ if not _ssl_warning_shown:
36
+ _ssl_warning_shown = True
37
+ print(
38
+ "\033[93m[SSL WARNING SUPPRESSED - BT_SSL_INSECURE_ALLOW=1]\033[0m",
39
+ file=sys.stderr,
40
+ )
41
+ return
42
+
43
+ if _ssl_warning_shown:
44
+ return
45
+
46
+ _ssl_warning_shown = True
47
+ print(
48
+ "\n\033[91m" + "=" * 70 + "\n"
49
+ "SECURITY WARNING: SSL certificate verification is DISABLED\n"
50
+ "=" * 70 + "\033[0m\n"
51
+ "\033[93m"
52
+ "This exposes your credentials to man-in-the-middle attacks.\n"
53
+ "Only use for testing with self-signed certificates.\n"
54
+ "\n"
55
+ "To suppress this warning (NOT RECOMMENDED):\n"
56
+ " export BT_SSL_INSECURE_ALLOW=1\n"
57
+ "\033[0m",
58
+ file=sys.stderr,
59
+ )
60
+
61
+
62
+ class BaseClient:
63
+ """Base HTTP client that all product clients inherit from.
64
+
65
+ Provides common functionality:
66
+ - Context manager for connection lifecycle
67
+ - Request methods (GET, POST, PUT, DELETE)
68
+ - Response handling
69
+ - Auth integration
70
+
71
+ Product clients extend this with:
72
+ - Product-specific auth configuration
73
+ - Pagination handling (varies by API)
74
+ - Convenience methods for API endpoints
75
+ """
76
+
77
+ def __init__(
78
+ self,
79
+ base_url: str,
80
+ auth: AuthStrategy,
81
+ timeout: float = 30.0,
82
+ verify_ssl: bool = True,
83
+ ):
84
+ """Initialize base client.
85
+
86
+ Args:
87
+ base_url: API base URL
88
+ auth: Authentication strategy to use
89
+ timeout: Request timeout in seconds
90
+ verify_ssl: Whether to verify SSL certificates
91
+ """
92
+ self.base_url = base_url.rstrip("/")
93
+ self.auth = auth
94
+ self.timeout = timeout
95
+ self.verify_ssl = verify_ssl
96
+ self._client: Optional[httpx.Client] = None
97
+
98
+ def _ensure_client(self) -> httpx.Client:
99
+ """Ensure HTTP client is initialized.
100
+
101
+ Returns:
102
+ The httpx client
103
+
104
+ Raises:
105
+ RuntimeError: If client not initialized (not in context manager)
106
+ """
107
+ if self._client is None:
108
+ raise RuntimeError(
109
+ "Client not initialized. Use as context manager: "
110
+ "'with get_client() as client:'"
111
+ )
112
+ return self._client
113
+
114
+ def __enter__(self) -> "BaseClient":
115
+ """Enter context manager - create HTTP client."""
116
+ if not self.verify_ssl:
117
+ _warn_ssl_disabled()
118
+
119
+ self._client = httpx.Client(
120
+ base_url=self.base_url,
121
+ timeout=self.timeout,
122
+ verify=self.verify_ssl,
123
+ )
124
+ return self
125
+
126
+ def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
127
+ """Exit context manager - close HTTP client."""
128
+ if self._client:
129
+ self._client.close()
130
+ self._client = None
131
+
132
+ def _get_headers(self) -> dict[str, str]:
133
+ """Build request headers including auth.
134
+
135
+ Returns:
136
+ Headers dictionary with content type and auth
137
+ """
138
+ headers = {
139
+ "Content-Type": "application/json",
140
+ "Accept": "application/json",
141
+ }
142
+ headers.update(self.auth.get_headers())
143
+ return headers
144
+
145
+ def _request(
146
+ self,
147
+ method: str,
148
+ path: str,
149
+ params: Optional[dict[str, Any]] = None,
150
+ json: Optional[dict[str, Any]] = None,
151
+ data: Optional[dict[str, Any]] = None,
152
+ headers: Optional[dict[str, str]] = None,
153
+ files: Optional[dict[str, Any]] = None,
154
+ ) -> Any:
155
+ """Make an HTTP request.
156
+
157
+ Args:
158
+ method: HTTP method (GET, POST, PUT, DELETE)
159
+ path: API endpoint path
160
+ params: Query parameters
161
+ json: JSON body (for POST/PUT)
162
+ data: Form data (for POST)
163
+ headers: Additional headers
164
+ files: File upload data
165
+
166
+ Returns:
167
+ Parsed JSON response, or empty dict for 204/empty responses
168
+
169
+ Raises:
170
+ httpx.HTTPStatusError: If request fails
171
+ """
172
+ client = self._ensure_client()
173
+
174
+ # Build headers
175
+ request_headers = self._get_headers()
176
+ if headers:
177
+ request_headers.update(headers)
178
+
179
+ # Handle file uploads - don't send Content-Type, let httpx set it
180
+ if files:
181
+ request_headers.pop("Content-Type", None)
182
+
183
+ # Filter out None params
184
+ if params:
185
+ params = {k: v for k, v in params.items() if v is not None}
186
+
187
+ response = client.request(
188
+ method=method,
189
+ url=path,
190
+ params=params,
191
+ json=json,
192
+ data=data,
193
+ headers=request_headers,
194
+ files=files,
195
+ )
196
+ response.raise_for_status()
197
+
198
+ # Handle empty responses
199
+ if response.status_code == 204 or not response.content:
200
+ return {}
201
+
202
+ # Try to parse JSON
203
+ try:
204
+ return response.json()
205
+ except Exception:
206
+ return {"content": response.text}
207
+
208
+ def get(
209
+ self,
210
+ path: str,
211
+ params: Optional[dict[str, Any]] = None,
212
+ ) -> Any:
213
+ """Make a GET request.
214
+
215
+ Args:
216
+ path: API endpoint path
217
+ params: Query parameters
218
+
219
+ Returns:
220
+ Parsed JSON response
221
+ """
222
+ return self._request("GET", path, params=params)
223
+
224
+ def post(
225
+ self,
226
+ path: str,
227
+ json: Optional[dict[str, Any]] = None,
228
+ data: Optional[dict[str, Any]] = None,
229
+ headers: Optional[dict[str, str]] = None,
230
+ files: Optional[dict[str, Any]] = None,
231
+ ) -> Any:
232
+ """Make a POST request.
233
+
234
+ Args:
235
+ path: API endpoint path
236
+ json: JSON body
237
+ data: Form data
238
+ headers: Additional headers
239
+ files: File upload data
240
+
241
+ Returns:
242
+ Parsed JSON response
243
+ """
244
+ return self._request(
245
+ "POST", path, json=json, data=data, headers=headers, files=files
246
+ )
247
+
248
+ def put(
249
+ self,
250
+ path: str,
251
+ json: Optional[dict[str, Any]] = None,
252
+ data: Optional[dict[str, Any]] = None,
253
+ headers: Optional[dict[str, str]] = None,
254
+ files: Optional[dict[str, Any]] = None,
255
+ ) -> Any:
256
+ """Make a PUT request.
257
+
258
+ Args:
259
+ path: API endpoint path
260
+ json: JSON body
261
+ data: Form data
262
+ headers: Additional headers
263
+ files: File upload data
264
+
265
+ Returns:
266
+ Parsed JSON response
267
+ """
268
+ return self._request(
269
+ "PUT", path, json=json, data=data, headers=headers, files=files
270
+ )
271
+
272
+ def delete(
273
+ self,
274
+ path: str,
275
+ params: Optional[dict[str, Any]] = None,
276
+ ) -> Any:
277
+ """Make a DELETE request.
278
+
279
+ Args:
280
+ path: API endpoint path
281
+ params: Query parameters
282
+
283
+ Returns:
284
+ Parsed JSON response
285
+ """
286
+ return self._request("DELETE", path, params=params)
287
+
288
+ def get_raw(
289
+ self,
290
+ path: str,
291
+ params: Optional[dict[str, Any]] = None,
292
+ ) -> bytes:
293
+ """Make a GET request and return raw bytes.
294
+
295
+ Useful for file downloads.
296
+
297
+ Args:
298
+ path: API endpoint path
299
+ params: Query parameters
300
+
301
+ Returns:
302
+ Raw response bytes
303
+ """
304
+ client = self._ensure_client()
305
+ headers = self._get_headers()
306
+ headers.pop("Content-Type", None) # Not needed for downloads
307
+
308
+ if params:
309
+ params = {k: v for k, v in params.items() if v is not None}
310
+
311
+ response = client.get(path, params=params, headers=headers)
312
+ response.raise_for_status()
313
+ return response.content