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.
- souleyez/__init__.py +2 -1
- souleyez/core/msf_auto_mapper.py +3 -2
- souleyez/core/tool_chaining.py +77 -11
- souleyez/docs/README.md +1 -1
- souleyez/integrations/siem/__init__.py +2 -0
- souleyez/integrations/siem/factory.py +26 -5
- souleyez/integrations/siem/googlesecops.py +614 -0
- souleyez/integrations/wazuh/config.py +143 -20
- souleyez/main.py +7 -40
- souleyez/storage/database.py +59 -20
- souleyez/storage/migrations/_027_multi_siem_persistence.py +119 -0
- souleyez/storage/migrations/__init__.py +6 -0
- souleyez/storage/schema.sql +44 -4
- souleyez/ui/interactive.py +575 -237
- souleyez-2.39.0.dist-info/METADATA +265 -0
- {souleyez-2.28.0.dist-info → souleyez-2.39.0.dist-info}/RECORD +20 -18
- souleyez-2.28.0.dist-info/METADATA +0 -319
- {souleyez-2.28.0.dist-info → souleyez-2.39.0.dist-info}/WHEEL +0 -0
- {souleyez-2.28.0.dist-info → souleyez-2.39.0.dist-info}/entry_points.txt +0 -0
- {souleyez-2.28.0.dist-info → souleyez-2.39.0.dist-info}/licenses/LICENSE +0 -0
- {souleyez-2.28.0.dist-info → souleyez-2.39.0.dist-info}/top_level.txt +0 -0
|
@@ -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', [])
|