binalyze-air-sdk 1.0.1__py3-none-any.whl → 1.0.3__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 (142) hide show
  1. binalyze_air/__init__.py +77 -77
  2. binalyze_air/apis/__init__.py +67 -27
  3. binalyze_air/apis/acquisitions.py +107 -0
  4. binalyze_air/apis/api_tokens.py +49 -0
  5. binalyze_air/apis/assets.py +161 -0
  6. binalyze_air/apis/audit_logs.py +26 -0
  7. binalyze_air/apis/{authentication.py → auth.py} +29 -27
  8. binalyze_air/apis/auto_asset_tags.py +79 -75
  9. binalyze_air/apis/backup.py +177 -0
  10. binalyze_air/apis/baseline.py +46 -0
  11. binalyze_air/apis/cases.py +225 -0
  12. binalyze_air/apis/cloud_forensics.py +116 -0
  13. binalyze_air/apis/event_subscription.py +96 -96
  14. binalyze_air/apis/evidence.py +249 -53
  15. binalyze_air/apis/interact.py +153 -36
  16. binalyze_air/apis/investigation_hub.py +234 -0
  17. binalyze_air/apis/license.py +104 -0
  18. binalyze_air/apis/logger.py +83 -0
  19. binalyze_air/apis/multipart_upload.py +201 -0
  20. binalyze_air/apis/notifications.py +115 -0
  21. binalyze_air/apis/organizations.py +267 -0
  22. binalyze_air/apis/params.py +44 -39
  23. binalyze_air/apis/policies.py +186 -0
  24. binalyze_air/apis/preset_filters.py +79 -0
  25. binalyze_air/apis/recent_activities.py +71 -0
  26. binalyze_air/apis/relay_server.py +104 -0
  27. binalyze_air/apis/settings.py +395 -27
  28. binalyze_air/apis/tasks.py +80 -0
  29. binalyze_air/apis/triage.py +197 -0
  30. binalyze_air/apis/user_management.py +183 -74
  31. binalyze_air/apis/webhook_executions.py +50 -0
  32. binalyze_air/apis/webhooks.py +322 -230
  33. binalyze_air/base.py +207 -133
  34. binalyze_air/client.py +217 -1337
  35. binalyze_air/commands/__init__.py +175 -145
  36. binalyze_air/commands/acquisitions.py +661 -387
  37. binalyze_air/commands/api_tokens.py +55 -0
  38. binalyze_air/commands/assets.py +324 -362
  39. binalyze_air/commands/{authentication.py → auth.py} +36 -36
  40. binalyze_air/commands/auto_asset_tags.py +230 -230
  41. binalyze_air/commands/backup.py +47 -0
  42. binalyze_air/commands/baseline.py +32 -396
  43. binalyze_air/commands/cases.py +609 -602
  44. binalyze_air/commands/cloud_forensics.py +88 -0
  45. binalyze_air/commands/event_subscription.py +101 -101
  46. binalyze_air/commands/evidences.py +918 -988
  47. binalyze_air/commands/interact.py +172 -58
  48. binalyze_air/commands/investigation_hub.py +315 -0
  49. binalyze_air/commands/license.py +183 -0
  50. binalyze_air/commands/logger.py +126 -0
  51. binalyze_air/commands/multipart_upload.py +363 -0
  52. binalyze_air/commands/notifications.py +45 -0
  53. binalyze_air/commands/organizations.py +200 -221
  54. binalyze_air/commands/policies.py +175 -203
  55. binalyze_air/commands/preset_filters.py +55 -0
  56. binalyze_air/commands/recent_activities.py +32 -0
  57. binalyze_air/commands/relay_server.py +144 -0
  58. binalyze_air/commands/settings.py +431 -29
  59. binalyze_air/commands/tasks.py +95 -56
  60. binalyze_air/commands/triage.py +224 -360
  61. binalyze_air/commands/user_management.py +351 -126
  62. binalyze_air/commands/webhook_executions.py +77 -0
  63. binalyze_air/config.py +244 -244
  64. binalyze_air/exceptions.py +49 -49
  65. binalyze_air/http_client.py +426 -305
  66. binalyze_air/models/__init__.py +287 -285
  67. binalyze_air/models/acquisitions.py +365 -250
  68. binalyze_air/models/api_tokens.py +73 -0
  69. binalyze_air/models/assets.py +438 -438
  70. binalyze_air/models/audit.py +247 -272
  71. binalyze_air/models/audit_logs.py +14 -0
  72. binalyze_air/models/{authentication.py → auth.py} +69 -69
  73. binalyze_air/models/auto_asset_tags.py +227 -116
  74. binalyze_air/models/backup.py +138 -0
  75. binalyze_air/models/baseline.py +231 -231
  76. binalyze_air/models/cases.py +275 -275
  77. binalyze_air/models/cloud_forensics.py +145 -0
  78. binalyze_air/models/event_subscription.py +170 -171
  79. binalyze_air/models/evidence.py +65 -65
  80. binalyze_air/models/evidences.py +367 -348
  81. binalyze_air/models/interact.py +266 -135
  82. binalyze_air/models/investigation_hub.py +265 -0
  83. binalyze_air/models/license.py +150 -0
  84. binalyze_air/models/logger.py +83 -0
  85. binalyze_air/models/multipart_upload.py +352 -0
  86. binalyze_air/models/notifications.py +138 -0
  87. binalyze_air/models/organizations.py +293 -293
  88. binalyze_air/models/params.py +153 -127
  89. binalyze_air/models/policies.py +260 -249
  90. binalyze_air/models/preset_filters.py +79 -0
  91. binalyze_air/models/recent_activities.py +70 -0
  92. binalyze_air/models/relay_server.py +121 -0
  93. binalyze_air/models/settings.py +538 -84
  94. binalyze_air/models/tasks.py +215 -149
  95. binalyze_air/models/triage.py +141 -142
  96. binalyze_air/models/user_management.py +200 -97
  97. binalyze_air/models/webhook_executions.py +33 -0
  98. binalyze_air/queries/__init__.py +121 -133
  99. binalyze_air/queries/acquisitions.py +155 -155
  100. binalyze_air/queries/api_tokens.py +46 -0
  101. binalyze_air/queries/assets.py +186 -105
  102. binalyze_air/queries/audit.py +400 -416
  103. binalyze_air/queries/{authentication.py → auth.py} +55 -55
  104. binalyze_air/queries/auto_asset_tags.py +59 -59
  105. binalyze_air/queries/backup.py +66 -0
  106. binalyze_air/queries/baseline.py +21 -185
  107. binalyze_air/queries/cases.py +292 -292
  108. binalyze_air/queries/cloud_forensics.py +137 -0
  109. binalyze_air/queries/event_subscription.py +54 -54
  110. binalyze_air/queries/evidence.py +139 -139
  111. binalyze_air/queries/evidences.py +279 -279
  112. binalyze_air/queries/interact.py +140 -28
  113. binalyze_air/queries/investigation_hub.py +329 -0
  114. binalyze_air/queries/license.py +85 -0
  115. binalyze_air/queries/logger.py +58 -0
  116. binalyze_air/queries/multipart_upload.py +180 -0
  117. binalyze_air/queries/notifications.py +71 -0
  118. binalyze_air/queries/organizations.py +222 -222
  119. binalyze_air/queries/params.py +154 -115
  120. binalyze_air/queries/policies.py +149 -149
  121. binalyze_air/queries/preset_filters.py +60 -0
  122. binalyze_air/queries/recent_activities.py +44 -0
  123. binalyze_air/queries/relay_server.py +42 -0
  124. binalyze_air/queries/settings.py +533 -20
  125. binalyze_air/queries/tasks.py +125 -81
  126. binalyze_air/queries/triage.py +230 -230
  127. binalyze_air/queries/user_management.py +193 -83
  128. binalyze_air/queries/webhook_executions.py +39 -0
  129. binalyze_air_sdk-1.0.3.dist-info/METADATA +752 -0
  130. binalyze_air_sdk-1.0.3.dist-info/RECORD +132 -0
  131. {binalyze_air_sdk-1.0.1.dist-info → binalyze_air_sdk-1.0.3.dist-info}/WHEEL +1 -1
  132. binalyze_air/apis/endpoints.py +0 -22
  133. binalyze_air/apis/evidences.py +0 -216
  134. binalyze_air/apis/users.py +0 -68
  135. binalyze_air/commands/users.py +0 -101
  136. binalyze_air/models/endpoints.py +0 -76
  137. binalyze_air/models/users.py +0 -82
  138. binalyze_air/queries/endpoints.py +0 -25
  139. binalyze_air/queries/users.py +0 -69
  140. binalyze_air_sdk-1.0.1.dist-info/METADATA +0 -635
  141. binalyze_air_sdk-1.0.1.dist-info/RECORD +0 -82
  142. {binalyze_air_sdk-1.0.1.dist-info → binalyze_air_sdk-1.0.3.dist-info}/top_level.txt +0 -0
@@ -1,306 +1,427 @@
1
- """
2
- HTTP client for Binalyze AIR API communications.
3
- """
4
-
5
- import time
6
- import requests
7
- import urllib3
8
- from typing import Any, Dict, Optional, Union
9
- from urllib.parse import urljoin
10
-
11
- from .config import AIRConfig
12
- from .exceptions import (
13
- AIRAPIError,
14
- AuthenticationError,
15
- AuthorizationError,
16
- NotFoundError,
17
- ValidationError,
18
- RateLimitError,
19
- ServerError,
20
- NetworkError,
21
- )
22
-
23
-
24
- class HTTPClient:
25
- """HTTP client for AIR API communications."""
26
-
27
- def __init__(self, config: AIRConfig):
28
- """Initialize the HTTP client with configuration."""
29
- self.config = config
30
- self.session = requests.Session()
31
- self.session.headers.update({
32
- "Content-Type": "application/json",
33
- "Authorization": f"Bearer {config.api_token}",
34
- "User-Agent": "binalyze-air-sdk/1.0.0",
35
- })
36
- self.session.verify = config.verify_ssl
37
-
38
- # Disable SSL warnings when SSL verification is disabled
39
- if not config.verify_ssl:
40
- urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
41
-
42
- def _build_url(self, endpoint: str) -> str:
43
- """Build full URL from endpoint."""
44
- # Remove leading slash if present
45
- endpoint = endpoint.lstrip("/")
46
- # Build full URL with API prefix
47
- return f"{self.config.host}/{self.config.api_prefix}/{endpoint}"
48
-
49
- def _handle_response(self, response: requests.Response) -> Dict[str, Any]:
50
- """Handle HTTP response and raise appropriate exceptions."""
51
- try:
52
- data = response.json()
53
- except ValueError:
54
- # If response is not JSON, create a basic structure
55
- data = {
56
- "success": False,
57
- "result": None,
58
- "statusCode": response.status_code,
59
- "errors": [response.text or "Unknown error"]
60
- }
61
-
62
- # Handle specific known API bugs with better error messages
63
- if response.status_code == 500:
64
- error_message = data.get("errors", [""])[0] if data.get("errors") else ""
65
-
66
- # API-001: Policies endpoint parameter validation bug
67
- if "GET: /api/public/policies route has internal server error" in error_message:
68
- raise ValidationError(
69
- "Missing required 'organizationIds' filter parameter. "
70
- "Please provide organization IDs to filter policies. "
71
- "(Note: This is a known API server bug that returns 500 instead of 400)",
72
- status_code=400, # What it should be
73
- response_data=data
74
- )
75
-
76
- # API-002: Auto asset tags update endpoint bug
77
- if "auto-asset-tag" in response.url and response.request.method == "PUT":
78
- raise ServerError(
79
- "Auto asset tag update is currently unavailable due to a server bug. "
80
- "Workaround: Delete the existing tag and create a new one with updated values. "
81
- "(Note: This is a known API server issue)",
82
- status_code=response.status_code,
83
- response_data=data
84
- )
85
-
86
- # Check for specific error status codes
87
- if response.status_code == 401:
88
- raise AuthenticationError(
89
- "Authentication failed. Check your API token.",
90
- status_code=response.status_code,
91
- response_data=data
92
- )
93
- elif response.status_code == 403:
94
- raise AuthorizationError(
95
- "Authorization failed. Insufficient permissions.",
96
- status_code=response.status_code,
97
- response_data=data
98
- )
99
- elif response.status_code == 404:
100
- # Use specific API error message instead of generic message
101
- api_errors = data.get("errors", ["Resource not found"])
102
- error_message = "; ".join(api_errors) if api_errors else "Resource not found"
103
- raise NotFoundError(
104
- error_message,
105
- status_code=response.status_code,
106
- response_data=data
107
- )
108
- elif response.status_code == 422:
109
- errors = data.get("errors", ["Validation failed"])
110
- raise ValidationError(
111
- f"Validation error: {'; '.join(errors)}",
112
- status_code=response.status_code,
113
- response_data=data
114
- )
115
- elif response.status_code == 429:
116
- raise RateLimitError(
117
- "Rate limit exceeded. Please try again later.",
118
- status_code=response.status_code,
119
- response_data=data
120
- )
121
- elif response.status_code >= 500:
122
- raise ServerError(
123
- f"Server error: {response.status_code}",
124
- status_code=response.status_code,
125
- response_data=data
126
- )
127
- elif not response.ok:
128
- errors = data.get("errors", [f"HTTP {response.status_code}"])
129
- raise AIRAPIError(
130
- f"API error: {'; '.join(errors)}",
131
- status_code=response.status_code,
132
- response_data=data
133
- )
134
-
135
- return data
136
-
137
- def _handle_binary_response(self, response: requests.Response) -> requests.Response:
138
- """Handle binary file response without JSON parsing."""
139
- # Check for specific error status codes
140
- if response.status_code == 401:
141
- raise AuthenticationError(
142
- "Authentication failed. Check your API token.",
143
- status_code=response.status_code
144
- )
145
- elif response.status_code == 403:
146
- raise AuthorizationError(
147
- "Authorization failed. Insufficient permissions.",
148
- status_code=response.status_code
149
- )
150
- elif response.status_code == 404:
151
- raise NotFoundError(
152
- "Resource not found.",
153
- status_code=response.status_code
154
- )
155
- elif response.status_code == 422:
156
- raise ValidationError(
157
- "Validation error",
158
- status_code=response.status_code
159
- )
160
- elif response.status_code == 429:
161
- raise RateLimitError(
162
- "Rate limit exceeded. Please try again later.",
163
- status_code=response.status_code
164
- )
165
- elif response.status_code >= 500:
166
- raise ServerError(
167
- f"Server error: {response.status_code}",
168
- status_code=response.status_code
169
- )
170
- elif not response.ok:
171
- raise AIRAPIError(
172
- f"API error: HTTP {response.status_code}",
173
- status_code=response.status_code
174
- )
175
-
176
- return response
177
-
178
- def _make_request(
179
- self,
180
- method: str,
181
- endpoint: str,
182
- params: Optional[Dict[str, Any]] = None,
183
- data: Optional[Dict[str, Any]] = None,
184
- json_data: Optional[Dict[str, Any]] = None
185
- ) -> Dict[str, Any]:
186
- """Make HTTP request with retry logic."""
187
- url = self._build_url(endpoint)
188
- last_exception = None
189
-
190
- for attempt in range(self.config.retry_attempts):
191
- try:
192
- response = self.session.request(
193
- method=method,
194
- url=url,
195
- params=params,
196
- data=data,
197
- json=json_data,
198
- timeout=self.config.timeout
199
- )
200
- return self._handle_response(response)
201
-
202
- except (requests.ConnectionError, requests.Timeout) as e:
203
- last_exception = NetworkError(f"Network error: {str(e)}")
204
- if attempt < self.config.retry_attempts - 1:
205
- time.sleep(self.config.retry_delay * (2 ** attempt)) # Exponential backoff
206
- continue
207
- raise last_exception
208
-
209
- except (RateLimitError, ServerError) as e:
210
- last_exception = e
211
- if attempt < self.config.retry_attempts - 1:
212
- time.sleep(self.config.retry_delay * (2 ** attempt))
213
- continue
214
- raise
215
-
216
- except (AuthenticationError, AuthorizationError, NotFoundError, ValidationError) as e:
217
- # Don't retry these errors
218
- raise
219
-
220
- # If we get here, all retries failed
221
- if last_exception:
222
- raise last_exception
223
-
224
- raise AIRAPIError("All retry attempts failed")
225
-
226
- def get(self, endpoint: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
227
- """Make GET request."""
228
- return self._make_request("GET", endpoint, params=params)
229
-
230
- def post(
231
- self,
232
- endpoint: str,
233
- data: Optional[Dict[str, Any]] = None,
234
- json_data: Optional[Dict[str, Any]] = None,
235
- params: Optional[Dict[str, Any]] = None
236
- ) -> Dict[str, Any]:
237
- """Make POST request."""
238
- return self._make_request("POST", endpoint, params=params, data=data, json_data=json_data)
239
-
240
- def put(
241
- self,
242
- endpoint: str,
243
- data: Optional[Dict[str, Any]] = None,
244
- json_data: Optional[Dict[str, Any]] = None,
245
- params: Optional[Dict[str, Any]] = None
246
- ) -> Dict[str, Any]:
247
- """Make PUT request."""
248
- return self._make_request("PUT", endpoint, params=params, data=data, json_data=json_data)
249
-
250
- def patch(
251
- self,
252
- endpoint: str,
253
- data: Optional[Dict[str, Any]] = None,
254
- json_data: Optional[Dict[str, Any]] = None,
255
- params: Optional[Dict[str, Any]] = None
256
- ) -> Dict[str, Any]:
257
- """Make PATCH request."""
258
- return self._make_request("PATCH", endpoint, params=params, data=data, json_data=json_data)
259
-
260
- def delete(
261
- self,
262
- endpoint: str,
263
- params: Optional[Dict[str, Any]] = None,
264
- json_data: Optional[Dict[str, Any]] = None
265
- ) -> Dict[str, Any]:
266
- """Make DELETE request."""
267
- return self._make_request("DELETE", endpoint, params=params, json_data=json_data)
268
-
269
- def get_binary(self, endpoint: str, params: Optional[Dict[str, Any]] = None) -> requests.Response:
270
- """Make GET request for binary file downloads."""
271
- url = self._build_url(endpoint)
272
- last_exception = None
273
-
274
- for attempt in range(self.config.retry_attempts):
275
- try:
276
- response = self.session.request(
277
- method="GET",
278
- url=url,
279
- params=params,
280
- timeout=self.config.timeout
281
- )
282
- return self._handle_binary_response(response)
283
-
284
- except (requests.ConnectionError, requests.Timeout) as e:
285
- last_exception = NetworkError(f"Network error: {str(e)}")
286
- if attempt < self.config.retry_attempts - 1:
287
- time.sleep(self.config.retry_delay * (2 ** attempt)) # Exponential backoff
288
- continue
289
- raise last_exception
290
-
291
- except (RateLimitError, ServerError) as e:
292
- last_exception = e
293
- if attempt < self.config.retry_attempts - 1:
294
- time.sleep(self.config.retry_delay * (2 ** attempt))
295
- continue
296
- raise
297
-
298
- except (AuthenticationError, AuthorizationError, NotFoundError, ValidationError) as e:
299
- # Don't retry these errors
300
- raise
301
-
302
- # If we get here, all retries failed
303
- if last_exception:
304
- raise last_exception
305
-
1
+ """
2
+ HTTP client for Binalyze AIR API communications.
3
+ """
4
+
5
+ import time
6
+ import requests
7
+ import urllib3
8
+ from typing import Any, Dict, Optional, Union
9
+ from urllib.parse import urljoin
10
+
11
+ from .config import AIRConfig
12
+ from .exceptions import (
13
+ AIRAPIError,
14
+ AuthenticationError,
15
+ AuthorizationError,
16
+ NotFoundError,
17
+ ValidationError,
18
+ RateLimitError,
19
+ ServerError,
20
+ NetworkError,
21
+ )
22
+
23
+
24
+ class HTTPClient:
25
+ """HTTP client for AIR API communications."""
26
+
27
+ def __init__(self, config: AIRConfig):
28
+ """Initialize the HTTP client with configuration."""
29
+ self.config = config
30
+ self.session = requests.Session()
31
+ self.session.headers.update({
32
+ "Content-Type": "application/json",
33
+ "Authorization": f"Bearer {config.api_token}",
34
+ "User-Agent": "binalyze-air-sdk/1.0.0",
35
+ })
36
+ self.session.verify = config.verify_ssl
37
+
38
+ # Disable SSL warnings when SSL verification is disabled
39
+ if not config.verify_ssl:
40
+ urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
41
+
42
+ def _build_url(self, endpoint: str) -> str:
43
+ """Build full URL from endpoint."""
44
+ # Remove leading slash if present
45
+ endpoint = endpoint.lstrip("/")
46
+ # Build full URL with API prefix
47
+ return f"{self.config.host}/{self.config.api_prefix}/{endpoint}"
48
+
49
+ def _handle_response(self, response: requests.Response) -> Dict[str, Any]:
50
+ """Handle HTTP response and raise appropriate exceptions."""
51
+ try:
52
+ data = response.json()
53
+ except ValueError:
54
+ # If response is not JSON, create a basic structure
55
+ data = {
56
+ "success": False,
57
+ "result": None,
58
+ "statusCode": response.status_code,
59
+ "errors": [response.text or "Unknown error"]
60
+ }
61
+
62
+ # Treat HTTP 204 No Content as a successful response with empty result
63
+ if response.status_code == 204:
64
+ return {
65
+ "success": True,
66
+ "result": None,
67
+ "statusCode": response.status_code,
68
+ "errors": []
69
+ }
70
+
71
+ # Handle specific known API bugs with better error messages
72
+ if response.status_code == 500:
73
+ error_message = data.get("errors", [""])[0] if data.get("errors") else ""
74
+
75
+ # API-001: Policies endpoint parameter validation bug
76
+ if "GET: /api/public/policies route has internal server error" in error_message:
77
+ raise ValidationError(
78
+ "Missing required 'organizationIds' filter parameter. "
79
+ "Please provide organization IDs to filter policies. "
80
+ "(Note: This is a known API server bug that returns 500 instead of 400)",
81
+ status_code=400, # What it should be
82
+ response_data=data
83
+ )
84
+
85
+ # API-002: Auto asset tags update endpoint bug
86
+ if "auto-asset-tag" in response.url and response.request.method == "PUT":
87
+ raise ServerError(
88
+ "Auto asset tag update is currently unavailable due to a server bug. "
89
+ "Workaround: Delete the existing tag and create a new one with updated values. "
90
+ "(Note: This is a known API server issue)",
91
+ status_code=response.status_code,
92
+ response_data=data
93
+ )
94
+
95
+ # Generic 500 error with detailed message
96
+ errors = data.get("errors", [f"Server error: {response.status_code}"])
97
+ error_text = '; '.join(str(e) for e in errors)
98
+ raise ServerError(
99
+ f"Server error: {error_text}",
100
+ status_code=response.status_code,
101
+ response_data=data
102
+ )
103
+
104
+ elif response.status_code == 400:
105
+ # Show detailed validation errors instead of generic "HTTP 400"
106
+ errors = data.get("errors", [f"Bad request: {response.status_code}"])
107
+ error_text = '; '.join(str(e) for e in errors)
108
+ raise ValidationError(
109
+ f"Validation error: {error_text}",
110
+ status_code=response.status_code,
111
+ response_data=data
112
+ )
113
+
114
+ elif response.status_code == 422:
115
+ errors = data.get("errors", ["Validation failed"])
116
+ # Handle complex error objects (like OSQuery validation errors)
117
+ if errors and isinstance(errors[0], dict):
118
+ error_messages = []
119
+ for error in errors:
120
+ if isinstance(error, dict):
121
+ if 'message' in error:
122
+ error_messages.append(error['message'])
123
+ elif 'errors' in error and isinstance(error['errors'], list):
124
+ for nested_error in error['errors']:
125
+ if isinstance(nested_error, dict) and 'message' in nested_error:
126
+ error_messages.append(nested_error['message'])
127
+ else:
128
+ error_messages.append(str(nested_error))
129
+ else:
130
+ error_messages.append(str(error))
131
+ else:
132
+ error_messages.append(str(error))
133
+ error_text = '; '.join(error_messages) if error_messages else "Validation failed"
134
+ else:
135
+ error_text = '; '.join(str(e) for e in errors)
136
+
137
+ raise ValidationError(
138
+ f"Validation error: {error_text}",
139
+ status_code=response.status_code,
140
+ response_data=data
141
+ )
142
+ elif response.status_code == 429:
143
+ raise RateLimitError(
144
+ "Rate limit exceeded. Please try again later.",
145
+ status_code=response.status_code,
146
+ response_data=data
147
+ )
148
+ elif response.status_code >= 500:
149
+ raise ServerError(
150
+ f"Server error: {response.status_code}",
151
+ status_code=response.status_code,
152
+ response_data=data
153
+ )
154
+ elif not response.ok:
155
+ errors = data.get("errors", [f"HTTP {response.status_code}"])
156
+ # Handle complex error objects (like OSQuery validation errors)
157
+ if errors and isinstance(errors[0], dict):
158
+ error_messages = []
159
+ for error in errors:
160
+ if isinstance(error, dict):
161
+ # Extract meaningful error information from complex objects
162
+ if 'message' in error:
163
+ error_messages.append(error['message'])
164
+ elif 'errors' in error and isinstance(error['errors'], list):
165
+ # Handle nested error structures (like OSQuery validation)
166
+ for nested_error in error['errors']:
167
+ if isinstance(nested_error, dict) and 'message' in nested_error:
168
+ error_messages.append(nested_error['message'])
169
+ else:
170
+ error_messages.append(str(nested_error))
171
+ else:
172
+ error_messages.append(str(error))
173
+ else:
174
+ error_messages.append(str(error))
175
+ error_text = '; '.join(error_messages) if error_messages else f"HTTP {response.status_code}"
176
+ else:
177
+ # Handle simple string errors with detailed error messages
178
+ error_text = '; '.join(str(e) for e in errors)
179
+
180
+ raise AIRAPIError(
181
+ f"API error: {error_text}",
182
+ status_code=response.status_code,
183
+ response_data=data
184
+ )
185
+
186
+ return data
187
+
188
+ def _handle_binary_response(self, response: requests.Response) -> requests.Response:
189
+ """Handle binary file response without JSON parsing."""
190
+ # Check for specific error status codes
191
+ if response.status_code == 401:
192
+ raise AuthenticationError(
193
+ "Authentication failed. Check your API token.",
194
+ status_code=response.status_code
195
+ )
196
+ elif response.status_code == 403:
197
+ raise AuthorizationError(
198
+ "Authorization failed. Insufficient permissions.",
199
+ status_code=response.status_code
200
+ )
201
+ elif response.status_code == 404:
202
+ raise NotFoundError(
203
+ "Resource not found.",
204
+ status_code=response.status_code
205
+ )
206
+ elif response.status_code == 422:
207
+ raise ValidationError(
208
+ "Validation error",
209
+ status_code=response.status_code
210
+ )
211
+ elif response.status_code == 429:
212
+ raise RateLimitError(
213
+ "Rate limit exceeded. Please try again later.",
214
+ status_code=response.status_code
215
+ )
216
+ elif response.status_code >= 500:
217
+ raise ServerError(
218
+ f"Server error: {response.status_code}",
219
+ status_code=response.status_code
220
+ )
221
+ elif not response.ok:
222
+ raise AIRAPIError(
223
+ f"API error: HTTP {response.status_code}",
224
+ status_code=response.status_code
225
+ )
226
+
227
+ return response
228
+
229
+ def _make_request(
230
+ self,
231
+ method: str,
232
+ endpoint: str,
233
+ params: Optional[Dict[str, Any]] = None,
234
+ data: Optional[Dict[str, Any]] = None,
235
+ json_data: Optional[Dict[str, Any]] = None
236
+ ) -> Dict[str, Any]:
237
+ """Make HTTP request with retry logic."""
238
+ url = self._build_url(endpoint)
239
+ last_exception = None
240
+
241
+ for attempt in range(self.config.retry_attempts):
242
+ try:
243
+ response = self.session.request(
244
+ method=method,
245
+ url=url,
246
+ params=params,
247
+ data=data,
248
+ json=json_data,
249
+ timeout=self.config.timeout
250
+ )
251
+ return self._handle_response(response)
252
+
253
+ except (requests.ConnectionError, requests.Timeout) as e:
254
+ last_exception = NetworkError(f"Network error: {str(e)}")
255
+ if attempt < self.config.retry_attempts - 1:
256
+ time.sleep(self.config.retry_delay * (2 ** attempt)) # Exponential backoff
257
+ continue
258
+ raise last_exception
259
+
260
+ except (RateLimitError, ServerError) as e:
261
+ last_exception = e
262
+ if attempt < self.config.retry_attempts - 1:
263
+ time.sleep(self.config.retry_delay * (2 ** attempt))
264
+ continue
265
+ raise
266
+
267
+ except (AuthenticationError, AuthorizationError, NotFoundError, ValidationError) as e:
268
+ # Don't retry these errors
269
+ raise
270
+
271
+ # If we get here, all retries failed
272
+ if last_exception:
273
+ raise last_exception
274
+
275
+ raise AIRAPIError("All retry attempts failed")
276
+
277
+ def get(self, endpoint: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
278
+ """Make GET request."""
279
+ return self._make_request("GET", endpoint, params=params)
280
+
281
+ def post(
282
+ self,
283
+ endpoint: str,
284
+ data: Optional[Dict[str, Any]] = None,
285
+ json_data: Optional[Dict[str, Any]] = None,
286
+ params: Optional[Dict[str, Any]] = None
287
+ ) -> Dict[str, Any]:
288
+ """Make POST request."""
289
+ return self._make_request("POST", endpoint, params=params, data=data, json_data=json_data)
290
+
291
+ def put(
292
+ self,
293
+ endpoint: str,
294
+ data: Optional[Dict[str, Any]] = None,
295
+ json_data: Optional[Dict[str, Any]] = None,
296
+ params: Optional[Dict[str, Any]] = None
297
+ ) -> Dict[str, Any]:
298
+ """Make PUT request."""
299
+ return self._make_request("PUT", endpoint, params=params, data=data, json_data=json_data)
300
+
301
+ def patch(
302
+ self,
303
+ endpoint: str,
304
+ data: Optional[Dict[str, Any]] = None,
305
+ json_data: Optional[Dict[str, Any]] = None,
306
+ params: Optional[Dict[str, Any]] = None
307
+ ) -> Dict[str, Any]:
308
+ """Make PATCH request."""
309
+ return self._make_request("PATCH", endpoint, params=params, data=data, json_data=json_data)
310
+
311
+ def delete(
312
+ self,
313
+ endpoint: str,
314
+ params: Optional[Dict[str, Any]] = None,
315
+ json_data: Optional[Dict[str, Any]] = None
316
+ ) -> Dict[str, Any]:
317
+ """Make DELETE request."""
318
+ return self._make_request("DELETE", endpoint, params=params, json_data=json_data)
319
+
320
+ def get_binary(self, endpoint: str, params: Optional[Dict[str, Any]] = None) -> requests.Response:
321
+ """Make GET request for binary file downloads."""
322
+ url = self._build_url(endpoint)
323
+ last_exception = None
324
+
325
+ for attempt in range(self.config.retry_attempts):
326
+ try:
327
+ response = self.session.request(
328
+ method="GET",
329
+ url=url,
330
+ params=params,
331
+ timeout=self.config.timeout
332
+ )
333
+ return self._handle_binary_response(response)
334
+
335
+ except (requests.ConnectionError, requests.Timeout) as e:
336
+ last_exception = NetworkError(f"Network error: {str(e)}")
337
+ if attempt < self.config.retry_attempts - 1:
338
+ time.sleep(self.config.retry_delay * (2 ** attempt)) # Exponential backoff
339
+ continue
340
+ raise last_exception
341
+
342
+ except (RateLimitError, ServerError) as e:
343
+ last_exception = e
344
+ if attempt < self.config.retry_attempts - 1:
345
+ time.sleep(self.config.retry_delay * (2 ** attempt))
346
+ continue
347
+ raise
348
+
349
+ except (AuthenticationError, AuthorizationError, NotFoundError, ValidationError) as e:
350
+ # Don't retry these errors
351
+ raise
352
+
353
+ # If we get here, all retries failed
354
+ if last_exception:
355
+ raise last_exception
356
+
357
+ raise AIRAPIError("All retry attempts failed")
358
+
359
+ def upload_multipart(
360
+ self,
361
+ endpoint: str,
362
+ files: Optional[Dict[str, Any]] = None,
363
+ data: Optional[Dict[str, Any]] = None,
364
+ params: Optional[Dict[str, Any]] = None,
365
+ method: str = "POST"
366
+ ) -> Dict[str, Any]:
367
+ """Make multipart file upload request.
368
+
369
+ Args:
370
+ endpoint: API endpoint
371
+ files: Dictionary with file data for upload
372
+ data: Form data fields
373
+ params: Query parameters
374
+ method: HTTP method (POST or PUT)
375
+
376
+ Returns:
377
+ Parsed JSON response
378
+ """
379
+ url = self._build_url(endpoint)
380
+ last_exception = None
381
+
382
+ for attempt in range(self.config.retry_attempts):
383
+ try:
384
+ # Temporarily remove Content-Type from session headers
385
+ # to let requests library set the appropriate multipart/form-data header
386
+ original_content_type = self.session.headers.pop('Content-Type', None)
387
+
388
+ try:
389
+ response = self.session.request(
390
+ method=method,
391
+ url=url,
392
+ params=params,
393
+ data=data,
394
+ files=files,
395
+ timeout=self.config.timeout
396
+ )
397
+ result = self._handle_response(response)
398
+ finally:
399
+ # Restore original Content-Type header
400
+ if original_content_type:
401
+ self.session.headers['Content-Type'] = original_content_type
402
+
403
+ return result
404
+
405
+ except (requests.ConnectionError, requests.Timeout) as e:
406
+ last_exception = NetworkError(f"Network error: {str(e)}")
407
+ if attempt < self.config.retry_attempts - 1:
408
+ time.sleep(self.config.retry_delay * (2 ** attempt)) # Exponential backoff
409
+ continue
410
+ raise last_exception
411
+
412
+ except (RateLimitError, ServerError) as e:
413
+ last_exception = e
414
+ if attempt < self.config.retry_attempts - 1:
415
+ time.sleep(self.config.retry_delay * (2 ** attempt))
416
+ continue
417
+ raise
418
+
419
+ except (AuthenticationError, AuthorizationError, NotFoundError, ValidationError) as e:
420
+ # Don't retry these errors
421
+ raise
422
+
423
+ # If we get here, all retries failed
424
+ if last_exception:
425
+ raise last_exception
426
+
306
427
  raise AIRAPIError("All retry attempts failed")