secops 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.
- secops/__init__.py +22 -0
- secops/auth.py +79 -0
- secops/chronicle/__init__.py +19 -0
- secops/chronicle/client.py +941 -0
- secops/chronicle/models.py +166 -0
- secops/client.py +60 -0
- secops/exceptions.py +27 -0
- secops-0.1.0.dist-info/METADATA +608 -0
- secops-0.1.0.dist-info/RECORD +11 -0
- secops-0.1.0.dist-info/WHEEL +4 -0
- secops-0.1.0.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,941 @@
|
|
|
1
|
+
# Copyright 2025 Google LLC
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
#
|
|
15
|
+
"""Chronicle API client implementation."""
|
|
16
|
+
from typing import Optional, Dict, Any, List
|
|
17
|
+
from datetime import datetime
|
|
18
|
+
import json
|
|
19
|
+
import re
|
|
20
|
+
import time
|
|
21
|
+
from google.auth.transport import requests as google_auth_requests
|
|
22
|
+
from secops.auth import SecOpsAuth
|
|
23
|
+
from secops.exceptions import APIError
|
|
24
|
+
from secops.chronicle.models import Entity, EntityMetadata, EntityMetrics, TimeInterval, TimelineBucket, Timeline, WidgetMetadata, EntitySummary, AlertCount, CaseList
|
|
25
|
+
from enum import Enum
|
|
26
|
+
|
|
27
|
+
class ValueType(Enum):
|
|
28
|
+
"""Chronicle API value types."""
|
|
29
|
+
ASSET_IP_ADDRESS = "ASSET_IP_ADDRESS"
|
|
30
|
+
MAC = "MAC"
|
|
31
|
+
HOSTNAME = "HOSTNAME"
|
|
32
|
+
DOMAIN_NAME = "DOMAIN_NAME"
|
|
33
|
+
HASH_MD5 = "HASH_MD5"
|
|
34
|
+
HASH_SHA256 = "HASH_SHA256"
|
|
35
|
+
HASH_SHA1 = "HASH_SHA1"
|
|
36
|
+
EMAIL = "EMAIL"
|
|
37
|
+
USERNAME = "USERNAME"
|
|
38
|
+
|
|
39
|
+
def _detect_value_type(value: str) -> tuple[Optional[str], Optional[str]]:
|
|
40
|
+
"""Detect the type of value and return appropriate field path or value type.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
value: The value to analyze
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
Tuple of (field_path, value_type)
|
|
47
|
+
"""
|
|
48
|
+
# IPv4 pattern with validation for numbers 0-255
|
|
49
|
+
ipv4_pattern = r'^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$'
|
|
50
|
+
if re.match(ipv4_pattern, value):
|
|
51
|
+
return ("principal.ip", None) # Use field_path for IPs
|
|
52
|
+
|
|
53
|
+
# MD5 pattern (32 hex chars)
|
|
54
|
+
md5_pattern = r'^[a-fA-F0-9]{32}$'
|
|
55
|
+
if re.match(md5_pattern, value):
|
|
56
|
+
return ("target.file.md5", None) # Use field_path for file hashes
|
|
57
|
+
|
|
58
|
+
# SHA1 pattern (40 hex chars)
|
|
59
|
+
sha1_pattern = r'^[a-fA-F0-9]{40}$'
|
|
60
|
+
if re.match(sha1_pattern, value):
|
|
61
|
+
return ("target.file.sha1", None)
|
|
62
|
+
|
|
63
|
+
# SHA256 pattern (64 hex chars)
|
|
64
|
+
sha256_pattern = r'^[a-fA-F0-9]{64}$'
|
|
65
|
+
if re.match(sha256_pattern, value):
|
|
66
|
+
return ("target.file.sha256", None)
|
|
67
|
+
|
|
68
|
+
# Domain pattern
|
|
69
|
+
domain_pattern = r'^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z]{2,})+$'
|
|
70
|
+
if re.match(domain_pattern, value):
|
|
71
|
+
return (None, ValueType.DOMAIN_NAME.value)
|
|
72
|
+
|
|
73
|
+
# Email pattern
|
|
74
|
+
email_pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
|
|
75
|
+
if re.match(email_pattern, value):
|
|
76
|
+
return (None, ValueType.EMAIL.value)
|
|
77
|
+
|
|
78
|
+
# MAC address pattern
|
|
79
|
+
mac_pattern = r'^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$'
|
|
80
|
+
if re.match(mac_pattern, value):
|
|
81
|
+
return (None, ValueType.MAC.value)
|
|
82
|
+
|
|
83
|
+
# Default to hostname if it looks like one
|
|
84
|
+
hostname_pattern = r'^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?$'
|
|
85
|
+
if re.match(hostname_pattern, value):
|
|
86
|
+
return (None, ValueType.HOSTNAME.value)
|
|
87
|
+
|
|
88
|
+
return (None, None)
|
|
89
|
+
|
|
90
|
+
class ChronicleClient:
|
|
91
|
+
"""Client for interacting with Chronicle API."""
|
|
92
|
+
|
|
93
|
+
def __init__(
|
|
94
|
+
self,
|
|
95
|
+
customer_id: str,
|
|
96
|
+
project_id: str,
|
|
97
|
+
region: str = "us",
|
|
98
|
+
auth: Optional[SecOpsAuth] = None
|
|
99
|
+
):
|
|
100
|
+
"""Initialize Chronicle client.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
customer_id: Chronicle customer ID
|
|
104
|
+
project_id: GCP project ID
|
|
105
|
+
region: Chronicle API region (default: "us")
|
|
106
|
+
auth: Optional SecOpsAuth instance
|
|
107
|
+
"""
|
|
108
|
+
self.customer_id = customer_id
|
|
109
|
+
self.project_id = project_id
|
|
110
|
+
self.region = region
|
|
111
|
+
self.auth = auth or SecOpsAuth()
|
|
112
|
+
|
|
113
|
+
self.instance_id = f"projects/{project_id}/locations/{region}/instances/{customer_id}"
|
|
114
|
+
self.base_url = f"https://{region}-chronicle.googleapis.com/v1alpha"
|
|
115
|
+
self._session = None
|
|
116
|
+
|
|
117
|
+
@property
|
|
118
|
+
def session(self) -> google_auth_requests.AuthorizedSession:
|
|
119
|
+
"""Get or create authorized session."""
|
|
120
|
+
if self._session is None:
|
|
121
|
+
self._session = google_auth_requests.AuthorizedSession(self.auth.credentials)
|
|
122
|
+
return self._session
|
|
123
|
+
|
|
124
|
+
def fetch_udm_search_csv(
|
|
125
|
+
self,
|
|
126
|
+
query: str,
|
|
127
|
+
start_time: datetime,
|
|
128
|
+
end_time: datetime,
|
|
129
|
+
fields: list[str],
|
|
130
|
+
case_insensitive: bool = True
|
|
131
|
+
) -> str:
|
|
132
|
+
"""Fetch UDM search results in CSV format.
|
|
133
|
+
|
|
134
|
+
Args:
|
|
135
|
+
query: Chronicle search query
|
|
136
|
+
start_time: Search start time
|
|
137
|
+
end_time: Search end time
|
|
138
|
+
fields: List of fields to include in results
|
|
139
|
+
case_insensitive: Whether to perform case-insensitive search
|
|
140
|
+
|
|
141
|
+
Returns:
|
|
142
|
+
CSV formatted string of results
|
|
143
|
+
|
|
144
|
+
Raises:
|
|
145
|
+
APIError: If the API request fails
|
|
146
|
+
"""
|
|
147
|
+
url = f"{self.base_url}/{self.instance_id}/legacy:legacyFetchUdmSearchCsv"
|
|
148
|
+
|
|
149
|
+
search_query = {
|
|
150
|
+
"baselineQuery": query,
|
|
151
|
+
"baselineTimeRange": {
|
|
152
|
+
"startTime": start_time.strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
|
|
153
|
+
"endTime": end_time.strftime("%Y-%m-%dT%H:%M:%S.%fZ")
|
|
154
|
+
},
|
|
155
|
+
"fields": {
|
|
156
|
+
"fields": fields
|
|
157
|
+
},
|
|
158
|
+
"caseInsensitive": case_insensitive
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
response = self.session.post(
|
|
162
|
+
url,
|
|
163
|
+
json=search_query,
|
|
164
|
+
headers={"Accept": "*/*"}
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
if response.status_code != 200:
|
|
168
|
+
raise APIError(f"Chronicle API request failed: {response.text}")
|
|
169
|
+
|
|
170
|
+
return response.text
|
|
171
|
+
|
|
172
|
+
def validate_query(self, query: str) -> Dict[str, Any]:
|
|
173
|
+
"""Validate a UDM search query.
|
|
174
|
+
|
|
175
|
+
Args:
|
|
176
|
+
query: The query to validate
|
|
177
|
+
|
|
178
|
+
Returns:
|
|
179
|
+
Dict containing validation results
|
|
180
|
+
|
|
181
|
+
Raises:
|
|
182
|
+
APIError: If validation fails
|
|
183
|
+
"""
|
|
184
|
+
url = f"{self.base_url}/{self.instance_id}:validateQuery"
|
|
185
|
+
|
|
186
|
+
# Replace special characters with Unicode escapes
|
|
187
|
+
encoded_query = query.replace('!', '\u0021')
|
|
188
|
+
|
|
189
|
+
params = {
|
|
190
|
+
"rawQuery": encoded_query,
|
|
191
|
+
"dialect": "DIALECT_UDM_SEARCH",
|
|
192
|
+
"allowUnreplacedPlaceholders": "false"
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
response = self.session.get(url, params=params)
|
|
196
|
+
|
|
197
|
+
if response.status_code != 200:
|
|
198
|
+
raise APIError(f"Query validation failed: {response.text}")
|
|
199
|
+
|
|
200
|
+
return response.json()
|
|
201
|
+
|
|
202
|
+
def get_stats(
|
|
203
|
+
self,
|
|
204
|
+
query: str,
|
|
205
|
+
start_time: datetime,
|
|
206
|
+
end_time: datetime,
|
|
207
|
+
max_values: int = 60,
|
|
208
|
+
max_events: int = 10000,
|
|
209
|
+
case_insensitive: bool = True,
|
|
210
|
+
max_attempts: int = 30
|
|
211
|
+
) -> Dict[str, Any]:
|
|
212
|
+
"""Perform a UDM stats search query."""
|
|
213
|
+
url = f"{self.base_url}/{self.instance_id}/legacy:legacyFetchUdmSearchView"
|
|
214
|
+
|
|
215
|
+
payload = {
|
|
216
|
+
"baselineQuery": query,
|
|
217
|
+
"baselineTimeRange": {
|
|
218
|
+
"startTime": start_time.strftime("%Y-%m-%dT%H:%M:%S.000Z"),
|
|
219
|
+
"endTime": end_time.strftime("%Y-%m-%dT%H:%M:%S.000Z")
|
|
220
|
+
},
|
|
221
|
+
"caseInsensitive": case_insensitive,
|
|
222
|
+
"returnOperationIdOnly": True,
|
|
223
|
+
"eventList": {
|
|
224
|
+
"maxReturnedEvents": max_events
|
|
225
|
+
},
|
|
226
|
+
"fieldAggregations": {
|
|
227
|
+
"maxValuesPerField": max_values
|
|
228
|
+
},
|
|
229
|
+
"generateAiOverview": True
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
# Start the search operation
|
|
233
|
+
response = self.session.post(url, json=payload)
|
|
234
|
+
if response.status_code != 200:
|
|
235
|
+
raise APIError(
|
|
236
|
+
f"Error initiating search: Status {response.status_code}, "
|
|
237
|
+
f"Response: {response.text}"
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
operation = response.json()
|
|
241
|
+
|
|
242
|
+
# Extract operation ID from response
|
|
243
|
+
try:
|
|
244
|
+
if isinstance(operation, list):
|
|
245
|
+
operation_id = operation[0].get("operation")
|
|
246
|
+
else:
|
|
247
|
+
operation_id = operation.get("operation") or operation.get("name")
|
|
248
|
+
except Exception as e:
|
|
249
|
+
raise APIError(
|
|
250
|
+
f"Error extracting operation ID. Response: {operation}, Error: {str(e)}"
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
if not operation_id:
|
|
254
|
+
raise APIError(f"No operation ID found in response: {operation}")
|
|
255
|
+
|
|
256
|
+
# Poll for results using the full operation ID path
|
|
257
|
+
results_url = f"{self.base_url}/{operation_id}:streamSearch"
|
|
258
|
+
attempt = 0
|
|
259
|
+
|
|
260
|
+
while attempt < max_attempts:
|
|
261
|
+
results_response = self.session.get(results_url)
|
|
262
|
+
if results_response.status_code != 200:
|
|
263
|
+
raise APIError(f"Error fetching results: {results_response.text}")
|
|
264
|
+
|
|
265
|
+
results = results_response.json()
|
|
266
|
+
|
|
267
|
+
if isinstance(results, list):
|
|
268
|
+
results = results[0]
|
|
269
|
+
|
|
270
|
+
# Check both possible paths for completion status
|
|
271
|
+
done = (
|
|
272
|
+
results.get("done") or # Check top level
|
|
273
|
+
results.get("operation", {}).get("done") or # Check under operation
|
|
274
|
+
results.get("response", {}).get("complete") # Check under response
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
if done:
|
|
278
|
+
# Check both possible paths for stats
|
|
279
|
+
stats = (
|
|
280
|
+
results.get("response", {}).get("stats") or # Check under response
|
|
281
|
+
results.get("operation", {}).get("response", {}).get("stats") # Check under operation.response
|
|
282
|
+
)
|
|
283
|
+
if stats:
|
|
284
|
+
return self._process_stats_results({"response": {"stats": stats}})
|
|
285
|
+
else:
|
|
286
|
+
raise APIError("No stats found in completed response")
|
|
287
|
+
|
|
288
|
+
attempt += 1
|
|
289
|
+
time.sleep(1)
|
|
290
|
+
|
|
291
|
+
raise APIError(f"Search timed out after {max_attempts} attempts")
|
|
292
|
+
|
|
293
|
+
def _process_stats_results(self, results: Dict[str, Any]) -> Dict[str, Any]:
|
|
294
|
+
"""Process and format stats search results.
|
|
295
|
+
|
|
296
|
+
Args:
|
|
297
|
+
results: Raw API response
|
|
298
|
+
|
|
299
|
+
Returns:
|
|
300
|
+
Processed results with formatted rows
|
|
301
|
+
"""
|
|
302
|
+
try:
|
|
303
|
+
stats = results.get("response", {}).get("stats", {})
|
|
304
|
+
if not stats:
|
|
305
|
+
return {"rows": [], "columns": []}
|
|
306
|
+
|
|
307
|
+
# Extract column information
|
|
308
|
+
columns = []
|
|
309
|
+
for col in stats.get("results", []):
|
|
310
|
+
if "column" in col:
|
|
311
|
+
columns.append(col["column"])
|
|
312
|
+
|
|
313
|
+
# Process rows
|
|
314
|
+
rows = []
|
|
315
|
+
if stats.get("results"):
|
|
316
|
+
first_col = stats["results"][0]
|
|
317
|
+
num_rows = len(first_col.get("values", []))
|
|
318
|
+
|
|
319
|
+
for i in range(num_rows):
|
|
320
|
+
row = {}
|
|
321
|
+
for col in stats["results"]:
|
|
322
|
+
col_name = col["column"]
|
|
323
|
+
value = col["values"][i]["value"]
|
|
324
|
+
|
|
325
|
+
# Handle different value types
|
|
326
|
+
if "stringVal" in value:
|
|
327
|
+
row[col_name] = value["stringVal"]
|
|
328
|
+
elif "int64Val" in value:
|
|
329
|
+
row[col_name] = int(value["int64Val"])
|
|
330
|
+
else:
|
|
331
|
+
row[col_name] = None
|
|
332
|
+
|
|
333
|
+
rows.append(row)
|
|
334
|
+
|
|
335
|
+
return {
|
|
336
|
+
"columns": columns,
|
|
337
|
+
"rows": rows,
|
|
338
|
+
"total_rows": len(rows)
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
except Exception as e:
|
|
342
|
+
raise APIError(f"Error processing stats results: {str(e)}")
|
|
343
|
+
|
|
344
|
+
def search_udm(
|
|
345
|
+
self,
|
|
346
|
+
query: str,
|
|
347
|
+
start_time: datetime,
|
|
348
|
+
end_time: datetime,
|
|
349
|
+
max_events: int = 10000,
|
|
350
|
+
case_insensitive: bool = True,
|
|
351
|
+
max_attempts: int = 30
|
|
352
|
+
) -> Dict[str, Any]:
|
|
353
|
+
"""Perform a UDM search query.
|
|
354
|
+
|
|
355
|
+
Args:
|
|
356
|
+
query: The UDM search query
|
|
357
|
+
start_time: Search start time
|
|
358
|
+
end_time: Search end time
|
|
359
|
+
max_events: Maximum events to return
|
|
360
|
+
case_insensitive: Whether to perform case-insensitive search
|
|
361
|
+
max_attempts: Maximum number of polling attempts (default: 30)
|
|
362
|
+
|
|
363
|
+
Returns:
|
|
364
|
+
Dict containing the search results with events
|
|
365
|
+
"""
|
|
366
|
+
url = f"{self.base_url}/{self.instance_id}/legacy:legacyFetchUdmSearchView"
|
|
367
|
+
|
|
368
|
+
payload = {
|
|
369
|
+
"baselineQuery": query,
|
|
370
|
+
"baselineTimeRange": {
|
|
371
|
+
"startTime": start_time.strftime("%Y-%m-%dT%H:%M:%S.000Z"),
|
|
372
|
+
"endTime": end_time.strftime("%Y-%m-%dT%H:%M:%S.000Z")
|
|
373
|
+
},
|
|
374
|
+
"caseInsensitive": case_insensitive,
|
|
375
|
+
"returnOperationIdOnly": True,
|
|
376
|
+
"eventList": {
|
|
377
|
+
"maxReturnedEvents": max_events
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
# Start the search operation
|
|
382
|
+
response = self.session.post(url, json=payload)
|
|
383
|
+
if response.status_code != 200:
|
|
384
|
+
raise APIError(
|
|
385
|
+
f"Error initiating search: Status {response.status_code}, "
|
|
386
|
+
f"Response: {response.text}"
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
operation = response.json()
|
|
390
|
+
|
|
391
|
+
# Extract operation ID from response
|
|
392
|
+
try:
|
|
393
|
+
if isinstance(operation, list):
|
|
394
|
+
operation_id = operation[0].get("operation")
|
|
395
|
+
else:
|
|
396
|
+
operation_id = operation.get("operation") or operation.get("name")
|
|
397
|
+
except Exception as e:
|
|
398
|
+
raise APIError(
|
|
399
|
+
f"Error extracting operation ID. Response: {operation}, Error: {str(e)}"
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
if not operation_id:
|
|
403
|
+
raise APIError(f"No operation ID found in response: {operation}")
|
|
404
|
+
|
|
405
|
+
# Poll for results using the full operation ID path
|
|
406
|
+
results_url = f"{self.base_url}/{operation_id}:streamSearch"
|
|
407
|
+
attempt = 0
|
|
408
|
+
|
|
409
|
+
while attempt < max_attempts:
|
|
410
|
+
results_response = self.session.get(results_url)
|
|
411
|
+
if results_response.status_code != 200:
|
|
412
|
+
raise APIError(f"Error fetching results: {results_response.text}")
|
|
413
|
+
|
|
414
|
+
results = results_response.json()
|
|
415
|
+
|
|
416
|
+
if isinstance(results, list):
|
|
417
|
+
results = results[0]
|
|
418
|
+
|
|
419
|
+
# Check both possible paths for completion status
|
|
420
|
+
done = (
|
|
421
|
+
results.get("done") or # Check top level
|
|
422
|
+
results.get("operation", {}).get("done") or # Check under operation
|
|
423
|
+
results.get("response", {}).get("complete") # Check under response
|
|
424
|
+
)
|
|
425
|
+
|
|
426
|
+
if done:
|
|
427
|
+
events = (
|
|
428
|
+
results.get("response", {}).get("events", {}).get("events", []) or
|
|
429
|
+
results.get("operation", {}).get("response", {}).get("events", {}).get("events", [])
|
|
430
|
+
)
|
|
431
|
+
return {"events": events, "total_events": len(events)}
|
|
432
|
+
|
|
433
|
+
attempt += 1
|
|
434
|
+
time.sleep(1)
|
|
435
|
+
|
|
436
|
+
raise APIError(f"Search timed out after {max_attempts} attempts")
|
|
437
|
+
|
|
438
|
+
def summarize_entity(
|
|
439
|
+
self,
|
|
440
|
+
start_time: datetime,
|
|
441
|
+
end_time: datetime,
|
|
442
|
+
value: str,
|
|
443
|
+
field_path: Optional[str] = None,
|
|
444
|
+
value_type: Optional[str] = None,
|
|
445
|
+
entity_id: Optional[str] = None,
|
|
446
|
+
entity_namespace: Optional[str] = None,
|
|
447
|
+
return_alerts: bool = True,
|
|
448
|
+
return_prevalence: bool = False,
|
|
449
|
+
include_all_udm_types: bool = True,
|
|
450
|
+
page_size: int = 1000,
|
|
451
|
+
page_token: Optional[str] = None
|
|
452
|
+
) -> EntitySummary:
|
|
453
|
+
"""Get summary information about an entity.
|
|
454
|
+
|
|
455
|
+
Args:
|
|
456
|
+
start_time: Start time for the summary
|
|
457
|
+
end_time: End time for the summary
|
|
458
|
+
value: Value to search for (IP, domain, file hash, etc)
|
|
459
|
+
field_path: Optional override for UDM field path
|
|
460
|
+
value_type: Optional override for value type
|
|
461
|
+
entity_id: Entity ID to look up
|
|
462
|
+
entity_namespace: Entity namespace
|
|
463
|
+
return_alerts: Whether to include alerts
|
|
464
|
+
return_prevalence: Whether to include prevalence data
|
|
465
|
+
include_all_udm_types: Whether to include all UDM event types
|
|
466
|
+
page_size: Maximum number of results per page
|
|
467
|
+
page_token: Token for pagination
|
|
468
|
+
|
|
469
|
+
Returns:
|
|
470
|
+
EntitySummary object containing the results
|
|
471
|
+
|
|
472
|
+
Raises:
|
|
473
|
+
APIError: If the API request fails
|
|
474
|
+
"""
|
|
475
|
+
url = f"{self.base_url}/{self.instance_id}:summarizeEntity"
|
|
476
|
+
|
|
477
|
+
params = {
|
|
478
|
+
"timeRange.startTime": start_time.strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
|
|
479
|
+
"timeRange.endTime": end_time.strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
|
|
480
|
+
"returnAlerts": return_alerts,
|
|
481
|
+
"returnPrevalence": return_prevalence,
|
|
482
|
+
"includeAllUdmEventTypesForFirstLastSeen": include_all_udm_types,
|
|
483
|
+
"pageSize": page_size
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
# Add optional parameters
|
|
487
|
+
if page_token:
|
|
488
|
+
params["pageToken"] = page_token
|
|
489
|
+
|
|
490
|
+
if entity_id:
|
|
491
|
+
params["entityId"] = entity_id
|
|
492
|
+
else:
|
|
493
|
+
# Auto-detect type if not explicitly provided
|
|
494
|
+
detected_field_path, detected_value_type = _detect_value_type(value)
|
|
495
|
+
|
|
496
|
+
# Use explicit values if provided, otherwise use detected values
|
|
497
|
+
final_field_path = field_path or detected_field_path
|
|
498
|
+
final_value_type = value_type or detected_value_type
|
|
499
|
+
|
|
500
|
+
if final_field_path:
|
|
501
|
+
params["fieldAndValue.fieldPath"] = final_field_path
|
|
502
|
+
params["fieldAndValue.value"] = value
|
|
503
|
+
elif final_value_type:
|
|
504
|
+
params["fieldAndValue.value"] = value
|
|
505
|
+
params["fieldAndValue.valueType"] = final_value_type
|
|
506
|
+
else:
|
|
507
|
+
raise ValueError(
|
|
508
|
+
f"Could not determine type for value: {value}. "
|
|
509
|
+
"Please specify field_path or value_type explicitly."
|
|
510
|
+
)
|
|
511
|
+
|
|
512
|
+
if entity_namespace:
|
|
513
|
+
params["fieldAndValue.entityNamespace"] = entity_namespace
|
|
514
|
+
|
|
515
|
+
response = self.session.get(url, params=params)
|
|
516
|
+
|
|
517
|
+
if response.status_code != 200:
|
|
518
|
+
raise APIError(f"Error getting entity summary: {response.text}")
|
|
519
|
+
|
|
520
|
+
try:
|
|
521
|
+
data = response.json()
|
|
522
|
+
|
|
523
|
+
# Parse entities
|
|
524
|
+
entities = []
|
|
525
|
+
for entity_data in data.get("entities", []):
|
|
526
|
+
metadata = entity_data.get("metadata", {})
|
|
527
|
+
interval = metadata.get("interval", {})
|
|
528
|
+
|
|
529
|
+
entity = Entity(
|
|
530
|
+
name=entity_data.get("name", ""),
|
|
531
|
+
metadata=EntityMetadata(
|
|
532
|
+
entity_type=metadata.get("entityType", ""),
|
|
533
|
+
interval=TimeInterval(
|
|
534
|
+
start_time=datetime.fromisoformat(interval.get("startTime").replace('Z', '+00:00')),
|
|
535
|
+
end_time=datetime.fromisoformat(interval.get("endTime").replace('Z', '+00:00'))
|
|
536
|
+
)
|
|
537
|
+
),
|
|
538
|
+
metric=EntityMetrics(
|
|
539
|
+
first_seen=datetime.fromisoformat(entity_data.get("metric", {}).get("firstSeen").replace('Z', '+00:00')),
|
|
540
|
+
last_seen=datetime.fromisoformat(entity_data.get("metric", {}).get("lastSeen").replace('Z', '+00:00'))
|
|
541
|
+
),
|
|
542
|
+
entity=entity_data.get("entity", {})
|
|
543
|
+
)
|
|
544
|
+
entities.append(entity)
|
|
545
|
+
|
|
546
|
+
# Parse alert counts
|
|
547
|
+
alert_counts = []
|
|
548
|
+
for alert_data in data.get("alertCounts", []):
|
|
549
|
+
alert_counts.append(AlertCount(
|
|
550
|
+
rule=alert_data.get("rule", ""),
|
|
551
|
+
count=int(alert_data.get("count", 0))
|
|
552
|
+
))
|
|
553
|
+
|
|
554
|
+
# Parse timeline
|
|
555
|
+
timeline_data = data.get("timeline", {})
|
|
556
|
+
timeline = Timeline(
|
|
557
|
+
buckets=[TimelineBucket(**bucket) for bucket in timeline_data.get("buckets", [])],
|
|
558
|
+
bucket_size=timeline_data.get("bucketSize", "")
|
|
559
|
+
) if timeline_data else None
|
|
560
|
+
|
|
561
|
+
# Parse widget metadata
|
|
562
|
+
widget_data = data.get("widgetMetadata")
|
|
563
|
+
widget_metadata = WidgetMetadata(
|
|
564
|
+
uri=widget_data.get("uri", ""),
|
|
565
|
+
detections=widget_data.get("detections", 0),
|
|
566
|
+
total=widget_data.get("total", 0)
|
|
567
|
+
) if widget_data else None
|
|
568
|
+
|
|
569
|
+
return EntitySummary(
|
|
570
|
+
entities=entities,
|
|
571
|
+
alert_counts=alert_counts,
|
|
572
|
+
timeline=timeline,
|
|
573
|
+
widget_metadata=widget_metadata,
|
|
574
|
+
has_more_alerts=data.get("hasMoreAlerts", False),
|
|
575
|
+
next_page_token=data.get("nextPageToken")
|
|
576
|
+
)
|
|
577
|
+
|
|
578
|
+
except Exception as e:
|
|
579
|
+
raise APIError(f"Error parsing entity summary response: {str(e)}")
|
|
580
|
+
|
|
581
|
+
def summarize_entities_from_query(
|
|
582
|
+
self,
|
|
583
|
+
query: str,
|
|
584
|
+
start_time: datetime,
|
|
585
|
+
end_time: datetime,
|
|
586
|
+
) -> List[EntitySummary]:
|
|
587
|
+
"""Get entity summaries from a UDM query.
|
|
588
|
+
|
|
589
|
+
Args:
|
|
590
|
+
query: UDM query to find entities
|
|
591
|
+
start_time: Start time for the summary
|
|
592
|
+
end_time: End time for the summary
|
|
593
|
+
|
|
594
|
+
Returns:
|
|
595
|
+
List of EntitySummary objects containing the results
|
|
596
|
+
|
|
597
|
+
Raises:
|
|
598
|
+
APIError: If the API request fails
|
|
599
|
+
"""
|
|
600
|
+
url = f"{self.base_url}/{self.instance_id}:summarizeEntitiesFromQuery"
|
|
601
|
+
|
|
602
|
+
params = {
|
|
603
|
+
"query": query,
|
|
604
|
+
"timeRange.startTime": start_time.strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
|
|
605
|
+
"timeRange.endTime": end_time.strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
response = self.session.get(url, params=params)
|
|
609
|
+
|
|
610
|
+
if response.status_code != 200:
|
|
611
|
+
raise APIError(f"Error getting entity summaries: {response.text}")
|
|
612
|
+
|
|
613
|
+
try:
|
|
614
|
+
data = response.json()
|
|
615
|
+
summaries = []
|
|
616
|
+
|
|
617
|
+
for summary_data in data.get("entitySummaries", []):
|
|
618
|
+
entities = []
|
|
619
|
+
for entity_data in summary_data.get("entity", []):
|
|
620
|
+
metadata = entity_data.get("metadata", {})
|
|
621
|
+
interval = metadata.get("interval", {})
|
|
622
|
+
|
|
623
|
+
entity = Entity(
|
|
624
|
+
name=entity_data.get("name", ""),
|
|
625
|
+
metadata=EntityMetadata(
|
|
626
|
+
entity_type=metadata.get("entityType", ""),
|
|
627
|
+
interval=TimeInterval(
|
|
628
|
+
start_time=datetime.fromisoformat(interval.get("startTime").replace('Z', '+00:00')),
|
|
629
|
+
end_time=datetime.fromisoformat(interval.get("endTime").replace('Z', '+00:00'))
|
|
630
|
+
)
|
|
631
|
+
),
|
|
632
|
+
metric=EntityMetrics(
|
|
633
|
+
first_seen=datetime.fromisoformat(entity_data.get("metric", {}).get("firstSeen").replace('Z', '+00:00')),
|
|
634
|
+
last_seen=datetime.fromisoformat(entity_data.get("metric", {}).get("lastSeen").replace('Z', '+00:00'))
|
|
635
|
+
),
|
|
636
|
+
entity=entity_data.get("entity", {})
|
|
637
|
+
)
|
|
638
|
+
entities.append(entity)
|
|
639
|
+
|
|
640
|
+
summary = EntitySummary(entities=entities)
|
|
641
|
+
summaries.append(summary)
|
|
642
|
+
|
|
643
|
+
return summaries
|
|
644
|
+
|
|
645
|
+
except Exception as e:
|
|
646
|
+
raise APIError(f"Error parsing entity summaries response: {str(e)}")
|
|
647
|
+
|
|
648
|
+
def list_iocs(
|
|
649
|
+
self,
|
|
650
|
+
start_time: datetime,
|
|
651
|
+
end_time: datetime,
|
|
652
|
+
max_matches: int = 1000,
|
|
653
|
+
add_mandiant_attributes: bool = True,
|
|
654
|
+
prioritized_only: bool = False,
|
|
655
|
+
) -> dict:
|
|
656
|
+
"""List IoC matches against ingested events."""
|
|
657
|
+
url = f"{self.base_url}/{self.instance_id}/legacy:legacySearchEnterpriseWideIoCs"
|
|
658
|
+
|
|
659
|
+
params = {
|
|
660
|
+
"timestampRange.startTime": start_time.strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
|
|
661
|
+
"timestampRange.endTime": end_time.strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
|
|
662
|
+
"maxMatchesToReturn": max_matches,
|
|
663
|
+
"addMandiantAttributes": add_mandiant_attributes,
|
|
664
|
+
"fetchPrioritizedIocsOnly": prioritized_only,
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
response = self.session.get(url, params=params)
|
|
668
|
+
|
|
669
|
+
if response.status_code != 200:
|
|
670
|
+
raise APIError(f"Failed to list IoCs: {response.text}")
|
|
671
|
+
|
|
672
|
+
try:
|
|
673
|
+
data = response.json()
|
|
674
|
+
|
|
675
|
+
# Process each IoC match to ensure consistent field names
|
|
676
|
+
if "matches" in data:
|
|
677
|
+
for match in data["matches"]:
|
|
678
|
+
# Convert timestamps if present
|
|
679
|
+
for ts_field in ["iocIngestTimestamp", "firstSeenTimestamp", "lastSeenTimestamp"]:
|
|
680
|
+
if ts_field in match:
|
|
681
|
+
match[ts_field] = match[ts_field].rstrip("Z")
|
|
682
|
+
|
|
683
|
+
# Ensure consistent field names
|
|
684
|
+
if "filterProperties" in match and "stringProperties" in match["filterProperties"]:
|
|
685
|
+
props = match["filterProperties"]["stringProperties"]
|
|
686
|
+
match["properties"] = {
|
|
687
|
+
k: [v["rawValue"] for v in values["values"]]
|
|
688
|
+
for k, values in props.items()
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
# Process associations
|
|
692
|
+
if "associationIdentifier" in match:
|
|
693
|
+
# Remove duplicate associations (some have same name but different regionCode)
|
|
694
|
+
seen = set()
|
|
695
|
+
unique_associations = []
|
|
696
|
+
for assoc in match["associationIdentifier"]:
|
|
697
|
+
key = (assoc["name"], assoc["associationType"])
|
|
698
|
+
if key not in seen:
|
|
699
|
+
seen.add(key)
|
|
700
|
+
unique_associations.append(assoc)
|
|
701
|
+
match["associationIdentifier"] = unique_associations
|
|
702
|
+
|
|
703
|
+
return data
|
|
704
|
+
|
|
705
|
+
except Exception as e:
|
|
706
|
+
raise APIError(f"Failed to process IoCs response: {str(e)}")
|
|
707
|
+
|
|
708
|
+
def get_cases(self, case_ids: list[str]) -> CaseList:
|
|
709
|
+
"""Get details for specified cases.
|
|
710
|
+
|
|
711
|
+
Args:
|
|
712
|
+
case_ids (list[str]): List of case IDs to retrieve
|
|
713
|
+
|
|
714
|
+
Returns:
|
|
715
|
+
CaseList: Collection of cases with helper methods for filtering and lookup
|
|
716
|
+
|
|
717
|
+
Raises:
|
|
718
|
+
APIError: If the API request fails
|
|
719
|
+
ValueError: If more than 1000 case IDs are requested
|
|
720
|
+
"""
|
|
721
|
+
if len(case_ids) > 1000:
|
|
722
|
+
raise ValueError("Maximum of 1000 cases can be retrieved in a batch")
|
|
723
|
+
|
|
724
|
+
url = f"{self.base_url}/{self.instance_id}/legacy:legacyBatchGetCases"
|
|
725
|
+
|
|
726
|
+
params = {
|
|
727
|
+
"names": case_ids
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
response = self.session.get(url, params=params)
|
|
731
|
+
|
|
732
|
+
if response.status_code != 200:
|
|
733
|
+
raise APIError(f"Failed to get cases: {response.text}")
|
|
734
|
+
|
|
735
|
+
return CaseList.from_dict(response.json())
|
|
736
|
+
|
|
737
|
+
def get_alerts(
|
|
738
|
+
self,
|
|
739
|
+
start_time: datetime,
|
|
740
|
+
end_time: datetime,
|
|
741
|
+
snapshot_query: str = "feedback_summary.status != \"CLOSED\"",
|
|
742
|
+
baseline_query: str = None,
|
|
743
|
+
max_alerts: int = 1000,
|
|
744
|
+
enable_cache: bool = True,
|
|
745
|
+
max_attempts: int = 30,
|
|
746
|
+
poll_interval: float = 1.0
|
|
747
|
+
) -> dict:
|
|
748
|
+
"""Get alerts within a time range.
|
|
749
|
+
|
|
750
|
+
Args:
|
|
751
|
+
start_time: Start time for alerts search
|
|
752
|
+
end_time: End time for alerts search
|
|
753
|
+
snapshot_query: Query to filter alerts (default: non-closed alerts)
|
|
754
|
+
baseline_query: Optional baseline query
|
|
755
|
+
max_alerts: Maximum number of alerts to return
|
|
756
|
+
enable_cache: Whether to use caching for the request
|
|
757
|
+
max_attempts: Maximum number of polling attempts
|
|
758
|
+
poll_interval: Polling interval in seconds
|
|
759
|
+
|
|
760
|
+
Returns:
|
|
761
|
+
Dict containing the alerts response
|
|
762
|
+
|
|
763
|
+
Raises:
|
|
764
|
+
APIError: If the API request fails or response parsing fails
|
|
765
|
+
"""
|
|
766
|
+
url = f"{self.base_url}/{self.instance_id}/legacy:legacyFetchAlertsView"
|
|
767
|
+
|
|
768
|
+
params = {
|
|
769
|
+
"timeRange.startTime": start_time.isoformat(),
|
|
770
|
+
"timeRange.endTime": end_time.isoformat(),
|
|
771
|
+
"snapshotQuery": snapshot_query,
|
|
772
|
+
"alertListOptions.maxReturnedAlerts": max_alerts,
|
|
773
|
+
"enableCache": "ALERTS_FEATURE_PREFERENCE_ENABLED" if enable_cache else "ALERTS_FEATURE_PREFERENCE_DISABLED",
|
|
774
|
+
"fieldAggregationOptions.maxValuesPerField": 60
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
if baseline_query:
|
|
778
|
+
params["baselineQuery"] = baseline_query
|
|
779
|
+
|
|
780
|
+
headers = {
|
|
781
|
+
'Accept': '*/*',
|
|
782
|
+
'Accept-Language': 'en-US,en;q=0.9',
|
|
783
|
+
'Cache-Control': 'no-cache',
|
|
784
|
+
'Pragma': 'no-cache'
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
# Create an accumulator for all updates
|
|
788
|
+
final_response = {
|
|
789
|
+
'progress': 0,
|
|
790
|
+
'alerts': {'alerts': []},
|
|
791
|
+
'complete': False
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
# Poll until complete or max attempts
|
|
795
|
+
attempt = 0
|
|
796
|
+
while attempt < max_attempts:
|
|
797
|
+
attempt += 1
|
|
798
|
+
|
|
799
|
+
# Make the request
|
|
800
|
+
response = self.session.get(url, params=params, headers=headers, stream=True)
|
|
801
|
+
|
|
802
|
+
if response.status_code != 200:
|
|
803
|
+
raise APIError(f"Failed to get alerts: {response.text}")
|
|
804
|
+
|
|
805
|
+
# Process this response
|
|
806
|
+
updates = self._process_alerts_response(response)
|
|
807
|
+
|
|
808
|
+
# Merge the updates into our accumulator
|
|
809
|
+
self._merge_alert_updates(final_response, updates)
|
|
810
|
+
|
|
811
|
+
# Check if we're done
|
|
812
|
+
if final_response.get('complete'):
|
|
813
|
+
break
|
|
814
|
+
|
|
815
|
+
# If not complete and we have more attempts, wait and try again
|
|
816
|
+
if attempt < max_attempts:
|
|
817
|
+
time.sleep(poll_interval)
|
|
818
|
+
|
|
819
|
+
return final_response
|
|
820
|
+
|
|
821
|
+
def _process_alerts_response(self, response) -> list:
|
|
822
|
+
"""Process streaming response from alerts API.
|
|
823
|
+
|
|
824
|
+
Args:
|
|
825
|
+
response: HTTP response with streaming data
|
|
826
|
+
|
|
827
|
+
Returns:
|
|
828
|
+
List of parsed JSON updates
|
|
829
|
+
|
|
830
|
+
Raises:
|
|
831
|
+
APIError: If parsing fails and no valid updates were found
|
|
832
|
+
"""
|
|
833
|
+
# The API can return either individual JSON objects per line
|
|
834
|
+
# or an array of objects, or a mix of both
|
|
835
|
+
line_buffer = []
|
|
836
|
+
updates = []
|
|
837
|
+
|
|
838
|
+
for line in response.iter_lines():
|
|
839
|
+
if not line:
|
|
840
|
+
continue
|
|
841
|
+
|
|
842
|
+
line_str = line.decode('utf-8').strip()
|
|
843
|
+
if not line_str:
|
|
844
|
+
continue
|
|
845
|
+
|
|
846
|
+
line_buffer.append(line_str)
|
|
847
|
+
|
|
848
|
+
# Try to parse what we have so far
|
|
849
|
+
current_buffer = ''.join(line_buffer)
|
|
850
|
+
|
|
851
|
+
# Handle case where response starts with an array bracket
|
|
852
|
+
if current_buffer.startswith('[') and not current_buffer.endswith(']'):
|
|
853
|
+
# Not a complete array yet, continue collecting
|
|
854
|
+
continue
|
|
855
|
+
|
|
856
|
+
# Fix any JSON formatting issues
|
|
857
|
+
current_buffer = self._fix_json_formatting(current_buffer)
|
|
858
|
+
|
|
859
|
+
try:
|
|
860
|
+
# Try parsing it as a complete object or array
|
|
861
|
+
parsed_data = json.loads(current_buffer)
|
|
862
|
+
|
|
863
|
+
# Handle the case where parsed_data is an array of updates
|
|
864
|
+
if isinstance(parsed_data, list):
|
|
865
|
+
updates.extend(parsed_data)
|
|
866
|
+
else:
|
|
867
|
+
updates.append(parsed_data)
|
|
868
|
+
|
|
869
|
+
# Clear the buffer after successful parsing
|
|
870
|
+
line_buffer = []
|
|
871
|
+
except json.JSONDecodeError:
|
|
872
|
+
# If it fails, it might be incomplete. Continue to the next line
|
|
873
|
+
pass
|
|
874
|
+
|
|
875
|
+
# If we have leftover data, try once more with aggressive fix-ups
|
|
876
|
+
if line_buffer:
|
|
877
|
+
try:
|
|
878
|
+
current_buffer = ''.join(line_buffer)
|
|
879
|
+
# Make sure array has closing bracket if it started with one
|
|
880
|
+
if current_buffer.startswith('[') and not current_buffer.endswith(']'):
|
|
881
|
+
current_buffer += ']'
|
|
882
|
+
# Fix potential trailing commas
|
|
883
|
+
current_buffer = self._fix_json_formatting(current_buffer)
|
|
884
|
+
|
|
885
|
+
parsed_data = json.loads(current_buffer)
|
|
886
|
+
if isinstance(parsed_data, list):
|
|
887
|
+
updates.extend(parsed_data)
|
|
888
|
+
else:
|
|
889
|
+
updates.append(parsed_data)
|
|
890
|
+
except json.JSONDecodeError as e:
|
|
891
|
+
# If we can't parse the remaining data, log but don't fail if we have some updates
|
|
892
|
+
if not updates:
|
|
893
|
+
raise APIError(f"Failed to parse alerts response: {str(e)} - Data: {current_buffer}")
|
|
894
|
+
|
|
895
|
+
if not updates:
|
|
896
|
+
raise APIError("No valid data received from alerts API")
|
|
897
|
+
|
|
898
|
+
return updates
|
|
899
|
+
|
|
900
|
+
def _merge_alert_updates(self, target: dict, updates: list) -> None:
|
|
901
|
+
"""Merge alerts updates into the target dictionary.
|
|
902
|
+
|
|
903
|
+
Args:
|
|
904
|
+
target: Target dictionary to update
|
|
905
|
+
updates: List of updates to merge in
|
|
906
|
+
|
|
907
|
+
This method modifies the target dictionary in place.
|
|
908
|
+
"""
|
|
909
|
+
for update in updates:
|
|
910
|
+
# Merge all fields from this update
|
|
911
|
+
for key, value in update.items():
|
|
912
|
+
# Special handling for alerts to accumulate them
|
|
913
|
+
if key == 'alerts' and isinstance(value, dict) and 'alerts' in value:
|
|
914
|
+
if 'alerts' not in target:
|
|
915
|
+
target['alerts'] = {'alerts': []}
|
|
916
|
+
if not isinstance(target['alerts'], dict):
|
|
917
|
+
target['alerts'] = {'alerts': []}
|
|
918
|
+
if 'alerts' not in target['alerts']:
|
|
919
|
+
target['alerts']['alerts'] = []
|
|
920
|
+
target['alerts']['alerts'].extend(value['alerts'])
|
|
921
|
+
# Take the latest value for progress or overwrite other fields
|
|
922
|
+
elif key == 'progress':
|
|
923
|
+
if value > target.get('progress', 0):
|
|
924
|
+
target['progress'] = value
|
|
925
|
+
# For all other fields, take the latest value
|
|
926
|
+
else:
|
|
927
|
+
target[key] = value
|
|
928
|
+
|
|
929
|
+
def _fix_json_formatting(self, json_str: str) -> str:
|
|
930
|
+
"""Fix common JSON formatting issues.
|
|
931
|
+
|
|
932
|
+
Args:
|
|
933
|
+
json_str: JSON string to fix
|
|
934
|
+
|
|
935
|
+
Returns:
|
|
936
|
+
Fixed JSON string
|
|
937
|
+
"""
|
|
938
|
+
# Replace trailing commas in arrays and objects which cause JSON parsing to fail
|
|
939
|
+
# This regex finds commas followed by a closing bracket
|
|
940
|
+
json_str = re.sub(r',\s*([\]}])', r'\1', json_str)
|
|
941
|
+
return json_str
|