souleyez 2.28.0__py3-none-any.whl → 2.39.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.

Potentially problematic release.


This version of souleyez might be problematic. Click here for more details.

@@ -0,0 +1,614 @@
1
+ """
2
+ Google SecOps (Chronicle) SIEM Client.
3
+
4
+ Implements the SIEMClient interface for Google SecOps (formerly Chronicle SIEM).
5
+ Uses Chronicle REST APIs for querying detections, events, and rules.
6
+ """
7
+
8
+ import base64
9
+ import json
10
+ import time
11
+ from datetime import datetime, timedelta
12
+ from typing import Dict, List, Optional, Any
13
+
14
+ import requests
15
+
16
+ from souleyez.integrations.siem.base import (
17
+ SIEMClient,
18
+ SIEMAlert,
19
+ SIEMRule,
20
+ SIEMConnectionStatus,
21
+ )
22
+
23
+
24
+ class GoogleSecOpsSIEMClient(SIEMClient):
25
+ """Google SecOps (Chronicle) implementation of the SIEMClient interface.
26
+
27
+ Uses Chronicle APIs:
28
+ - Auth: OAuth 2.0 with service account JWT
29
+ - Search: POST /v1alpha/events:udmSearch
30
+ - Detections: GET /v1alpha/detections
31
+ - Rules: GET /v1alpha/rules
32
+ """
33
+
34
+ # Chronicle API regions
35
+ REGIONS = {
36
+ 'us': 'https://backstory.googleapis.com',
37
+ 'europe': 'https://europe-backstory.googleapis.com',
38
+ 'asia-southeast1': 'https://asia-southeast1-backstory.googleapis.com',
39
+ }
40
+
41
+ def __init__(
42
+ self,
43
+ credentials_json: str,
44
+ customer_id: str,
45
+ region: str = 'us',
46
+ project_id: Optional[str] = None,
47
+ verify_ssl: bool = True,
48
+ ):
49
+ """Initialize Google SecOps client.
50
+
51
+ Args:
52
+ credentials_json: Service account JSON key (as string)
53
+ customer_id: Chronicle customer ID
54
+ region: Chronicle region ('us', 'europe', 'asia-southeast1')
55
+ project_id: Google Cloud project ID (optional, extracted from creds if not provided)
56
+ verify_ssl: Verify SSL certificates
57
+ """
58
+ self.customer_id = customer_id
59
+ self.region = region.lower()
60
+ self.verify_ssl = verify_ssl
61
+ self._access_token: Optional[str] = None
62
+ self._token_expiry: Optional[datetime] = None
63
+
64
+ # Parse service account credentials
65
+ try:
66
+ if isinstance(credentials_json, str):
67
+ self._credentials = json.loads(credentials_json)
68
+ else:
69
+ self._credentials = credentials_json
70
+ except json.JSONDecodeError as e:
71
+ raise ValueError(f"Invalid service account JSON: {e}")
72
+
73
+ self.project_id = project_id or self._credentials.get('project_id', '')
74
+
75
+ # Set API base URL
76
+ self.api_base = self.REGIONS.get(self.region, self.REGIONS['us'])
77
+
78
+ @classmethod
79
+ def from_config(cls, config: Dict[str, Any]) -> 'GoogleSecOpsSIEMClient':
80
+ """Create client from configuration dictionary.
81
+
82
+ Args:
83
+ config: Dict with credentials_json, customer_id, region, etc.
84
+
85
+ Returns:
86
+ GoogleSecOpsSIEMClient instance
87
+ """
88
+ return cls(
89
+ credentials_json=config.get('credentials_json', '{}'),
90
+ customer_id=config.get('customer_id', ''),
91
+ region=config.get('region', 'us'),
92
+ project_id=config.get('project_id'),
93
+ verify_ssl=config.get('verify_ssl', True),
94
+ )
95
+
96
+ @property
97
+ def siem_type(self) -> str:
98
+ """Return the SIEM type identifier."""
99
+ return 'google_secops'
100
+
101
+ def _create_jwt(self) -> str:
102
+ """Create a signed JWT for service account authentication.
103
+
104
+ Returns:
105
+ Signed JWT string
106
+ """
107
+ from cryptography.hazmat.primitives import hashes, serialization
108
+ from cryptography.hazmat.primitives.asymmetric import padding
109
+ from cryptography.hazmat.backends import default_backend
110
+
111
+ now = int(time.time())
112
+ expiry = now + 3600 # 1 hour
113
+
114
+ # JWT header
115
+ header = {
116
+ 'alg': 'RS256',
117
+ 'typ': 'JWT',
118
+ 'kid': self._credentials.get('private_key_id', '')
119
+ }
120
+
121
+ # JWT claims
122
+ claims = {
123
+ 'iss': self._credentials.get('client_email', ''),
124
+ 'sub': self._credentials.get('client_email', ''),
125
+ 'aud': 'https://oauth2.googleapis.com/token',
126
+ 'iat': now,
127
+ 'exp': expiry,
128
+ 'scope': 'https://www.googleapis.com/auth/chronicle-backstory'
129
+ }
130
+
131
+ # Encode header and claims
132
+ def b64_encode(data: dict) -> str:
133
+ return base64.urlsafe_b64encode(
134
+ json.dumps(data, separators=(',', ':')).encode()
135
+ ).rstrip(b'=').decode()
136
+
137
+ header_b64 = b64_encode(header)
138
+ claims_b64 = b64_encode(claims)
139
+ message = f"{header_b64}.{claims_b64}".encode()
140
+
141
+ # Sign with private key
142
+ private_key_pem = self._credentials.get('private_key', '')
143
+ private_key = serialization.load_pem_private_key(
144
+ private_key_pem.encode(),
145
+ password=None,
146
+ backend=default_backend()
147
+ )
148
+
149
+ signature = private_key.sign(
150
+ message,
151
+ padding.PKCS1v15(),
152
+ hashes.SHA256()
153
+ )
154
+ signature_b64 = base64.urlsafe_b64encode(signature).rstrip(b'=').decode()
155
+
156
+ return f"{header_b64}.{claims_b64}.{signature_b64}"
157
+
158
+ def _get_access_token(self) -> str:
159
+ """Get Google OAuth access token using service account.
160
+
161
+ Returns:
162
+ Access token string
163
+ """
164
+ # Check cached token
165
+ if self._access_token and self._token_expiry:
166
+ if datetime.now() < self._token_expiry:
167
+ return self._access_token
168
+
169
+ # Create signed JWT
170
+ jwt = self._create_jwt()
171
+
172
+ # Exchange JWT for access token
173
+ response = requests.post(
174
+ 'https://oauth2.googleapis.com/token',
175
+ data={
176
+ 'grant_type': 'urn:ietf:params:oauth:grant-type:jwt-bearer',
177
+ 'assertion': jwt,
178
+ },
179
+ timeout=30,
180
+ verify=self.verify_ssl
181
+ )
182
+ response.raise_for_status()
183
+
184
+ token_data = response.json()
185
+ self._access_token = token_data['access_token']
186
+ expires_in = token_data.get('expires_in', 3600)
187
+ self._token_expiry = datetime.now() + timedelta(seconds=expires_in - 60)
188
+
189
+ return self._access_token
190
+
191
+ def _request(
192
+ self,
193
+ method: str,
194
+ endpoint: str,
195
+ params: Optional[Dict] = None,
196
+ json_data: Optional[Dict] = None,
197
+ ) -> requests.Response:
198
+ """Make authenticated API request.
199
+
200
+ Args:
201
+ method: HTTP method
202
+ endpoint: API endpoint (relative to api_base)
203
+ params: Query parameters
204
+ json_data: JSON request body
205
+
206
+ Returns:
207
+ Response object
208
+ """
209
+ token = self._get_access_token()
210
+ url = f"{self.api_base}{endpoint}"
211
+
212
+ headers = {
213
+ 'Authorization': f'Bearer {token}',
214
+ 'Content-Type': 'application/json',
215
+ }
216
+
217
+ response = requests.request(
218
+ method=method,
219
+ url=url,
220
+ headers=headers,
221
+ params=params,
222
+ json=json_data,
223
+ verify=self.verify_ssl,
224
+ timeout=60
225
+ )
226
+ return response
227
+
228
+ def test_connection(self) -> SIEMConnectionStatus:
229
+ """Test connection to Google SecOps.
230
+
231
+ Returns:
232
+ SIEMConnectionStatus with connection details
233
+ """
234
+ try:
235
+ # Try to get access token first (validates credentials)
236
+ self._get_access_token()
237
+
238
+ # Query for a small time window to verify API access
239
+ response = self._request(
240
+ 'GET',
241
+ '/v1alpha/detect/rules',
242
+ params={'page_size': 1}
243
+ )
244
+
245
+ if response.status_code == 200:
246
+ return SIEMConnectionStatus(
247
+ connected=True,
248
+ version='Chronicle API v1alpha',
249
+ siem_type='google_secops',
250
+ details={
251
+ 'region': self.region,
252
+ 'customer_id': self.customer_id,
253
+ 'project_id': self.project_id,
254
+ }
255
+ )
256
+ elif response.status_code == 403:
257
+ return SIEMConnectionStatus(
258
+ connected=False,
259
+ error='Permission denied. Check service account permissions.',
260
+ siem_type='google_secops'
261
+ )
262
+ else:
263
+ return SIEMConnectionStatus(
264
+ connected=False,
265
+ error=f'API error: {response.status_code} - {response.text[:200]}',
266
+ siem_type='google_secops'
267
+ )
268
+
269
+ except requests.exceptions.ConnectionError as e:
270
+ return SIEMConnectionStatus(
271
+ connected=False,
272
+ error=f'Connection failed: {str(e)}',
273
+ siem_type='google_secops'
274
+ )
275
+ except ValueError as e:
276
+ return SIEMConnectionStatus(
277
+ connected=False,
278
+ error=f'Configuration error: {str(e)}',
279
+ siem_type='google_secops'
280
+ )
281
+ except Exception as e:
282
+ return SIEMConnectionStatus(
283
+ connected=False,
284
+ error=str(e),
285
+ siem_type='google_secops'
286
+ )
287
+
288
+ def get_alerts(
289
+ self,
290
+ start_time: datetime,
291
+ end_time: datetime,
292
+ source_ip: Optional[str] = None,
293
+ dest_ip: Optional[str] = None,
294
+ rule_ids: Optional[List[str]] = None,
295
+ search_text: Optional[str] = None,
296
+ limit: int = 100
297
+ ) -> List[SIEMAlert]:
298
+ """Query detections/alerts from Google SecOps.
299
+
300
+ Args:
301
+ start_time: Start of time range
302
+ end_time: End of time range
303
+ source_ip: Filter by source IP
304
+ dest_ip: Filter by destination IP
305
+ rule_ids: Filter by rule IDs
306
+ search_text: Free text search
307
+ limit: Maximum number of results
308
+
309
+ Returns:
310
+ List of normalized SIEMAlert objects
311
+ """
312
+ # Format times for Chronicle API (RFC 3339)
313
+ start_str = start_time.strftime('%Y-%m-%dT%H:%M:%SZ')
314
+ end_str = end_time.strftime('%Y-%m-%dT%H:%M:%SZ')
315
+
316
+ # Query detections endpoint
317
+ params = {
318
+ 'start_time': start_str,
319
+ 'end_time': end_str,
320
+ 'page_size': min(limit, 1000),
321
+ }
322
+
323
+ response = self._request(
324
+ 'GET',
325
+ '/v1alpha/detect/detections',
326
+ params=params
327
+ )
328
+
329
+ if response.status_code != 200:
330
+ return []
331
+
332
+ data = response.json()
333
+ detections = data.get('detections', [])
334
+
335
+ # Filter and normalize results
336
+ alerts = []
337
+ for detection in detections:
338
+ alert = self._normalize_alert(detection)
339
+
340
+ # Apply filters
341
+ if source_ip and alert.source_ip != source_ip:
342
+ continue
343
+ if dest_ip and alert.dest_ip != dest_ip:
344
+ continue
345
+ if rule_ids and alert.rule_id not in rule_ids:
346
+ continue
347
+ if search_text:
348
+ search_lower = search_text.lower()
349
+ if (search_lower not in alert.rule_name.lower() and
350
+ search_lower not in alert.description.lower()):
351
+ continue
352
+
353
+ alerts.append(alert)
354
+
355
+ if len(alerts) >= limit:
356
+ break
357
+
358
+ return alerts
359
+
360
+ def _normalize_alert(self, detection: Dict[str, Any]) -> SIEMAlert:
361
+ """Convert Chronicle detection to normalized SIEMAlert.
362
+
363
+ Args:
364
+ detection: Raw detection from Chronicle API
365
+
366
+ Returns:
367
+ Normalized SIEMAlert
368
+ """
369
+ # Parse timestamp
370
+ timestamp_str = detection.get('detectionTime', '')
371
+ try:
372
+ timestamp = datetime.fromisoformat(timestamp_str.replace('Z', '+00:00'))
373
+ except (ValueError, AttributeError):
374
+ timestamp = datetime.now()
375
+
376
+ # Extract rule info
377
+ rule_info = detection.get('detection', [{}])[0] if detection.get('detection') else {}
378
+ rule_id = rule_info.get('ruleId', detection.get('ruleId', ''))
379
+ rule_name = rule_info.get('ruleName', detection.get('ruleName', rule_id))
380
+
381
+ # Map severity
382
+ severity_raw = detection.get('severity', rule_info.get('severity', 'INFORMATIONAL'))
383
+ severity = self._map_severity(severity_raw)
384
+
385
+ # Extract IPs from UDM events
386
+ source_ip = None
387
+ dest_ip = None
388
+ events = detection.get('collectionElements', [])
389
+ for element in events:
390
+ references = element.get('references', [])
391
+ for ref in references:
392
+ event = ref.get('event', {})
393
+ principal = event.get('principal', {})
394
+ target = event.get('target', {})
395
+
396
+ if not source_ip and principal.get('ip'):
397
+ ips = principal.get('ip', [])
398
+ source_ip = ips[0] if ips else None
399
+
400
+ if not dest_ip and target.get('ip'):
401
+ ips = target.get('ip', [])
402
+ dest_ip = ips[0] if ips else None
403
+
404
+ # Extract description
405
+ description = detection.get('description', rule_info.get('ruleText', ''))
406
+ if not description:
407
+ description = f"Chronicle detection: {rule_name}"
408
+
409
+ return SIEMAlert(
410
+ id=detection.get('id', str(hash(str(detection)))[:12]),
411
+ timestamp=timestamp,
412
+ rule_id=str(rule_id),
413
+ rule_name=str(rule_name),
414
+ severity=severity,
415
+ source_ip=source_ip,
416
+ dest_ip=dest_ip,
417
+ description=str(description)[:200],
418
+ raw_data=detection,
419
+ mitre_tactics=[],
420
+ mitre_techniques=[],
421
+ )
422
+
423
+ def _map_severity(self, severity: str) -> str:
424
+ """Map Chronicle severity to normalized severity."""
425
+ severity_upper = str(severity).upper()
426
+ severity_map = {
427
+ 'CRITICAL': 'critical',
428
+ 'HIGH': 'high',
429
+ 'MEDIUM': 'medium',
430
+ 'LOW': 'low',
431
+ 'INFORMATIONAL': 'info',
432
+ 'INFO': 'info',
433
+ }
434
+ return severity_map.get(severity_upper, 'info')
435
+
436
+ def get_rules(
437
+ self,
438
+ rule_ids: Optional[List[str]] = None,
439
+ enabled_only: bool = True
440
+ ) -> List[SIEMRule]:
441
+ """Get YARA-L detection rules from Google SecOps.
442
+
443
+ Args:
444
+ rule_ids: Optional list of specific rule IDs
445
+ enabled_only: Only return enabled rules
446
+
447
+ Returns:
448
+ List of normalized SIEMRule objects
449
+ """
450
+ response = self._request(
451
+ 'GET',
452
+ '/v1alpha/detect/rules',
453
+ params={'page_size': 1000}
454
+ )
455
+
456
+ if response.status_code != 200:
457
+ return []
458
+
459
+ data = response.json()
460
+ raw_rules = data.get('rules', [])
461
+
462
+ rules = []
463
+ for raw_rule in raw_rules:
464
+ rule_id = raw_rule.get('ruleId', '')
465
+
466
+ # Filter by rule_ids if provided
467
+ if rule_ids and rule_id not in rule_ids:
468
+ continue
469
+
470
+ # Check if enabled
471
+ is_enabled = raw_rule.get('liveRuleEnabled', True)
472
+ if enabled_only and not is_enabled:
473
+ continue
474
+
475
+ rule = SIEMRule(
476
+ id=rule_id,
477
+ name=raw_rule.get('ruleName', rule_id),
478
+ description=raw_rule.get('metadata', {}).get('description', ''),
479
+ severity=self._map_severity(raw_rule.get('metadata', {}).get('severity', '')),
480
+ enabled=is_enabled,
481
+ mitre_tactics=raw_rule.get('metadata', {}).get('mitreTactics', []),
482
+ mitre_techniques=raw_rule.get('metadata', {}).get('mitreTechniques', []),
483
+ raw_data=raw_rule,
484
+ )
485
+ rules.append(rule)
486
+
487
+ return rules
488
+
489
+ def get_recommended_rules(self, attack_type: str) -> List[Dict[str, Any]]:
490
+ """Get recommended rules for detecting an attack type.
491
+
492
+ Args:
493
+ attack_type: Tool/attack name (e.g., 'nmap', 'hydra')
494
+
495
+ Returns:
496
+ List of rule recommendations
497
+ """
498
+ # Chronicle/Google SecOps rule recommendations
499
+ recommendations_map = {
500
+ 'nmap': [
501
+ {
502
+ 'rule_id': 'network_port_scan',
503
+ 'rule_name': 'Network Port Scan Detection',
504
+ 'yaral': '''
505
+ rule network_port_scan {
506
+ meta:
507
+ description = "Detects potential port scanning activity"
508
+ severity = "MEDIUM"
509
+ events:
510
+ $e.metadata.event_type = "NETWORK_CONNECTION"
511
+ $e.principal.ip = $src_ip
512
+ match:
513
+ $src_ip over 5m
514
+ condition:
515
+ #e > 100
516
+ }''',
517
+ },
518
+ ],
519
+ 'hydra': [
520
+ {
521
+ 'rule_id': 'brute_force_auth',
522
+ 'rule_name': 'Brute Force Authentication',
523
+ 'yaral': '''
524
+ rule brute_force_authentication {
525
+ meta:
526
+ description = "Detects brute force login attempts"
527
+ severity = "HIGH"
528
+ events:
529
+ $e.metadata.event_type = "USER_LOGIN"
530
+ $e.security_result.action = "BLOCK"
531
+ $e.principal.ip = $src_ip
532
+ match:
533
+ $src_ip over 5m
534
+ condition:
535
+ #e > 10
536
+ }''',
537
+ },
538
+ ],
539
+ 'sqlmap': [
540
+ {
541
+ 'rule_id': 'sql_injection',
542
+ 'rule_name': 'SQL Injection Attempt',
543
+ 'yaral': '''
544
+ rule sql_injection_attempt {
545
+ meta:
546
+ description = "Detects SQL injection patterns in requests"
547
+ severity = "HIGH"
548
+ events:
549
+ $e.metadata.event_type = "NETWORK_HTTP"
550
+ re.regex($e.target.url, `(?i)(union|select|insert|update|delete|drop).*`)
551
+ condition:
552
+ $e
553
+ }''',
554
+ },
555
+ ],
556
+ }
557
+
558
+ attack_lower = attack_type.lower()
559
+ recommendations = recommendations_map.get(attack_lower, [])
560
+
561
+ return [
562
+ {
563
+ 'rule_id': r['rule_id'],
564
+ 'rule_name': r['rule_name'],
565
+ 'description': f"YARA-L rule for detecting {attack_type}",
566
+ 'severity': 'high',
567
+ 'enabled': False, # These are recommendations, not deployed
568
+ 'siem_type': 'google_secops',
569
+ 'yaral_rule': r.get('yaral', ''),
570
+ }
571
+ for r in recommendations
572
+ ]
573
+
574
+ def search_udm_events(
575
+ self,
576
+ query: str,
577
+ start_time: datetime,
578
+ end_time: datetime,
579
+ limit: int = 100
580
+ ) -> List[Dict[str, Any]]:
581
+ """Search UDM events with a custom query.
582
+
583
+ This is a Chronicle-specific method for advanced queries.
584
+
585
+ Args:
586
+ query: UDM search query
587
+ start_time: Start of time range
588
+ end_time: End of time range
589
+ limit: Maximum results
590
+
591
+ Returns:
592
+ List of UDM events
593
+ """
594
+ start_str = start_time.strftime('%Y-%m-%dT%H:%M:%SZ')
595
+ end_str = end_time.strftime('%Y-%m-%dT%H:%M:%SZ')
596
+
597
+ response = self._request(
598
+ 'POST',
599
+ '/v1alpha/events:udmSearch',
600
+ json_data={
601
+ 'query': query,
602
+ 'time_range': {
603
+ 'start_time': start_str,
604
+ 'end_time': end_str,
605
+ },
606
+ 'limit': limit,
607
+ }
608
+ )
609
+
610
+ if response.status_code != 200:
611
+ return []
612
+
613
+ data = response.json()
614
+ return data.get('events', {}).get('events', [])