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.
@@ -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