regscale-cli 6.20.9.1__py3-none-any.whl → 6.20.10.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 regscale-cli might be problematic. Click here for more details.
- regscale/_version.py +1 -1
- regscale/integrations/commercial/defender.py +9 -0
- regscale/integrations/commercial/wizv2/async_client.py +325 -0
- regscale/integrations/commercial/wizv2/constants.py +756 -0
- regscale/integrations/commercial/wizv2/scanner.py +1301 -89
- regscale/integrations/commercial/wizv2/utils.py +280 -36
- regscale/integrations/commercial/wizv2/variables.py +2 -10
- regscale/integrations/scanner_integration.py +58 -2
- regscale/integrations/variables.py +1 -0
- regscale/models/integration_models/synqly_models/capabilities.json +1 -1
- regscale/models/regscale_models/__init__.py +13 -0
- regscale/models/regscale_models/classification.py +23 -0
- regscale/models/regscale_models/cryptography.py +56 -0
- regscale/models/regscale_models/deviation.py +4 -4
- regscale/models/regscale_models/group.py +3 -2
- regscale/models/regscale_models/interconnection.py +1 -1
- regscale/models/regscale_models/issue.py +140 -41
- regscale/models/regscale_models/milestone.py +40 -0
- regscale/models/regscale_models/property.py +0 -1
- regscale/models/regscale_models/regscale_model.py +29 -18
- regscale/models/regscale_models/team.py +55 -0
- {regscale_cli-6.20.9.1.dist-info → regscale_cli-6.20.10.0.dist-info}/METADATA +1 -1
- {regscale_cli-6.20.9.1.dist-info → regscale_cli-6.20.10.0.dist-info}/RECORD +29 -23
- tests/regscale/integrations/test_property_and_milestone_creation.py +684 -0
- tests/regscale/models/test_report.py +105 -29
- {regscale_cli-6.20.9.1.dist-info → regscale_cli-6.20.10.0.dist-info}/LICENSE +0 -0
- {regscale_cli-6.20.9.1.dist-info → regscale_cli-6.20.10.0.dist-info}/WHEEL +0 -0
- {regscale_cli-6.20.9.1.dist-info → regscale_cli-6.20.10.0.dist-info}/entry_points.txt +0 -0
- {regscale_cli-6.20.9.1.dist-info → regscale_cli-6.20.10.0.dist-info}/top_level.txt +0 -0
|
@@ -10,9 +10,14 @@ from typing import Any, Dict, Iterator, List, Optional, Union, Tuple
|
|
|
10
10
|
from regscale.core.app.utils.app_utils import check_file_path, get_current_datetime
|
|
11
11
|
from regscale.core.utils import get_base_protocol_from_port
|
|
12
12
|
from regscale.core.utils.date import format_to_regscale_iso
|
|
13
|
+
from regscale.integrations.commercial.wizv2.async_client import run_async_queries
|
|
13
14
|
from regscale.integrations.commercial.wizv2.constants import (
|
|
15
|
+
END_OF_LIFE_FILE_PATH,
|
|
16
|
+
EXTERNAL_ATTACK_SURFACE_FILE_PATH,
|
|
14
17
|
INVENTORY_FILE_PATH,
|
|
15
18
|
INVENTORY_QUERY,
|
|
19
|
+
NETWORK_EXPOSURE_FILE_PATH,
|
|
20
|
+
SECRET_FINDINGS_FILE_PATH,
|
|
16
21
|
WizVulnerabilityType,
|
|
17
22
|
get_wiz_vulnerability_queries,
|
|
18
23
|
)
|
|
@@ -40,6 +45,8 @@ from regscale.integrations.commercial.wizv2.wiz_auth import wiz_authenticate
|
|
|
40
45
|
from regscale.integrations.scanner_integration import IntegrationAsset, IntegrationFinding, ScannerIntegration
|
|
41
46
|
from regscale.integrations.variables import ScannerVariables
|
|
42
47
|
from regscale.models import IssueStatus, regscale_models
|
|
48
|
+
from regscale.models.regscale_models.compliance_settings import ComplianceSettings
|
|
49
|
+
from regscale.core.app.utils.app_utils import error_and_exit
|
|
43
50
|
|
|
44
51
|
logger = logging.getLogger("regscale")
|
|
45
52
|
|
|
@@ -58,6 +65,7 @@ class WizVulnerabilityIntegration(ScannerIntegration):
|
|
|
58
65
|
}
|
|
59
66
|
asset_lookup = "vulnerableAsset"
|
|
60
67
|
wiz_token = None
|
|
68
|
+
_compliance_settings = None
|
|
61
69
|
|
|
62
70
|
@staticmethod
|
|
63
71
|
def get_variables() -> Dict[str, Any]:
|
|
@@ -96,21 +104,376 @@ class WizVulnerabilityIntegration(ScannerIntegration):
|
|
|
96
104
|
|
|
97
105
|
def fetch_findings(self, *args, **kwargs) -> Iterator[IntegrationFinding]:
|
|
98
106
|
"""
|
|
99
|
-
Fetches Wiz findings using
|
|
107
|
+
Fetches Wiz findings using async GraphQL queries for improved performance
|
|
108
|
+
|
|
109
|
+
This method automatically uses async concurrent queries by default for better performance,
|
|
110
|
+
with fallback to synchronous queries if async fails.
|
|
100
111
|
|
|
101
112
|
:yield: IntegrationFinding objects
|
|
102
113
|
:rtype: Iterator[IntegrationFinding]
|
|
103
114
|
"""
|
|
115
|
+
# Check if async processing should be disabled (for debugging or compatibility)
|
|
116
|
+
use_async = kwargs.get("use_async", True) # Default to async
|
|
117
|
+
|
|
118
|
+
if use_async:
|
|
119
|
+
try:
|
|
120
|
+
# Use async concurrent queries for better performance
|
|
121
|
+
yield from self.fetch_findings_async(*args, **kwargs)
|
|
122
|
+
except Exception as e:
|
|
123
|
+
logger.warning(f"Async query failed, falling back to sync: {str(e)}")
|
|
124
|
+
# Fallback to synchronous method
|
|
125
|
+
yield from self.fetch_findings_sync(**kwargs)
|
|
126
|
+
else:
|
|
127
|
+
# Use synchronous method if explicitly requested
|
|
128
|
+
yield from self.fetch_findings_sync(**kwargs)
|
|
129
|
+
|
|
130
|
+
@staticmethod
|
|
131
|
+
def _validate_project_id(project_id: Optional[str]) -> str:
|
|
132
|
+
"""
|
|
133
|
+
Validate and format the Wiz project ID.
|
|
104
134
|
|
|
105
|
-
project_id
|
|
135
|
+
:param Optional[str] project_id: Project ID to validate
|
|
136
|
+
:return: Validated project ID
|
|
137
|
+
:rtype: str
|
|
138
|
+
"""
|
|
106
139
|
if not project_id:
|
|
107
|
-
|
|
140
|
+
error_and_exit("Wiz project ID is required")
|
|
141
|
+
|
|
142
|
+
# Clean and validate project ID format
|
|
143
|
+
project_id = project_id.strip()
|
|
144
|
+
if len(project_id) != 36:
|
|
145
|
+
error_and_exit(
|
|
146
|
+
f"Invalid Wiz project ID format. Expected 36 characters (UUID), "
|
|
147
|
+
f"got {len(project_id)} characters: '{project_id}'"
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
# UUID format validation
|
|
151
|
+
uuid_pattern = r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
|
|
152
|
+
if not re.match(uuid_pattern, project_id, re.IGNORECASE):
|
|
153
|
+
error_and_exit(f"Invalid Wiz project ID format. Must be a valid UUID: '{project_id}'")
|
|
154
|
+
|
|
155
|
+
return project_id
|
|
156
|
+
|
|
157
|
+
def _setup_authentication_headers(self) -> Dict[str, str]:
|
|
158
|
+
"""
|
|
159
|
+
Setup authentication headers for API requests.
|
|
160
|
+
|
|
161
|
+
:return: Headers dictionary with authentication
|
|
162
|
+
:rtype: Dict[str, str]
|
|
163
|
+
"""
|
|
164
|
+
if not self.wiz_token:
|
|
165
|
+
self.authenticate()
|
|
166
|
+
|
|
167
|
+
# Debug authentication
|
|
168
|
+
logger.debug(f"Wiz token exists: {bool(self.wiz_token)}")
|
|
169
|
+
logger.debug(f"Wiz token length: {len(self.wiz_token) if self.wiz_token else 0}")
|
|
170
|
+
if self.wiz_token:
|
|
171
|
+
logger.debug(f"Wiz token starts with: {self.wiz_token[:20]}...")
|
|
172
|
+
|
|
173
|
+
headers = {"Authorization": f"Bearer {self.wiz_token}", "Content-Type": "application/json"}
|
|
174
|
+
logger.debug(f"Headers for async request: {headers}")
|
|
175
|
+
return headers
|
|
176
|
+
|
|
177
|
+
def _execute_concurrent_queries(
|
|
178
|
+
self, query_configs: List[Dict[str, Any]], headers: Dict[str, str]
|
|
179
|
+
) -> List[Tuple[str, List[Dict[str, Any]], Optional[Exception]]]:
|
|
180
|
+
"""
|
|
181
|
+
Execute GraphQL queries concurrently or load cached data.
|
|
182
|
+
|
|
183
|
+
:param List[Dict[str, Any]] query_configs: Query configurations
|
|
184
|
+
:param Dict[str, str] headers: Request headers
|
|
185
|
+
:return: Query results
|
|
186
|
+
:rtype: List[Tuple[str, List[Dict[str, Any]], Optional[Exception]]]
|
|
187
|
+
"""
|
|
188
|
+
should_fetch_fresh = self._should_fetch_fresh_data(query_configs)
|
|
189
|
+
|
|
190
|
+
if should_fetch_fresh:
|
|
191
|
+
logger.info(f"Starting {len(query_configs)} concurrent queries to Wiz API...")
|
|
192
|
+
return run_async_queries(
|
|
193
|
+
endpoint=WizVariables.wizUrl,
|
|
194
|
+
headers=headers,
|
|
195
|
+
query_configs=query_configs,
|
|
196
|
+
progress_tracker=self.finding_progress,
|
|
197
|
+
max_concurrent=5,
|
|
198
|
+
)
|
|
199
|
+
else:
|
|
200
|
+
return self._load_cached_data_with_progress(query_configs)
|
|
201
|
+
|
|
202
|
+
def _process_query_results(
|
|
203
|
+
self,
|
|
204
|
+
results: List[Tuple[str, List[Dict[str, Any]], Optional[Exception]]],
|
|
205
|
+
query_configs: List[Dict[str, Any]],
|
|
206
|
+
project_id: str,
|
|
207
|
+
should_fetch_fresh: bool,
|
|
208
|
+
) -> Iterator[IntegrationFinding]:
|
|
209
|
+
"""
|
|
210
|
+
Process query results and yield findings.
|
|
211
|
+
|
|
212
|
+
:param results: Query results from concurrent execution
|
|
213
|
+
:param query_configs: Original query configurations
|
|
214
|
+
:param project_id: Project ID for filtering
|
|
215
|
+
:param should_fetch_fresh: Whether fresh data was fetched
|
|
216
|
+
:yield: IntegrationFinding objects
|
|
217
|
+
:rtype: Iterator[IntegrationFinding]
|
|
218
|
+
"""
|
|
219
|
+
parse_task = self.finding_progress.add_task("[magenta]Processing fetched findings...", total=len(results))
|
|
220
|
+
|
|
221
|
+
for query_type_str, nodes, error in results:
|
|
222
|
+
if error:
|
|
223
|
+
logger.error(f"Error fetching {query_type_str}: {error}")
|
|
224
|
+
self.finding_progress.advance(parse_task, 1)
|
|
225
|
+
continue
|
|
226
|
+
|
|
227
|
+
# Find corresponding vulnerability type and config
|
|
228
|
+
vulnerability_type, config = self._find_vulnerability_config(query_type_str, query_configs)
|
|
229
|
+
if not vulnerability_type or not config:
|
|
230
|
+
logger.warning(f"Could not find vulnerability type for {query_type_str}")
|
|
231
|
+
self.finding_progress.advance(parse_task, 1)
|
|
232
|
+
continue
|
|
233
|
+
|
|
234
|
+
# Save fetched data to cache if fresh data was fetched
|
|
235
|
+
if should_fetch_fresh and nodes:
|
|
236
|
+
self._save_data_to_cache(nodes, config.get("file_path"))
|
|
237
|
+
|
|
238
|
+
# Apply project filtering for certain vulnerability types
|
|
239
|
+
nodes = self._apply_project_filtering(nodes, vulnerability_type, project_id, query_type_str)
|
|
240
|
+
|
|
241
|
+
logger.info(f"Processing {len(nodes)} {query_type_str} findings...")
|
|
242
|
+
yield from self.parse_findings(nodes, vulnerability_type)
|
|
243
|
+
self.finding_progress.advance(parse_task, 1)
|
|
244
|
+
|
|
245
|
+
self.finding_progress.update(
|
|
246
|
+
parse_task, description=f"[green]✓ Processed all findings ({self.num_findings_to_process} total)"
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
def _find_vulnerability_config(
|
|
250
|
+
self, query_type_str: str, query_configs: List[Dict[str, Any]]
|
|
251
|
+
) -> Tuple[Optional[WizVulnerabilityType], Optional[Dict[str, Any]]]:
|
|
252
|
+
"""
|
|
253
|
+
Find vulnerability type and config for a query type string.
|
|
254
|
+
|
|
255
|
+
:param str query_type_str: Query type string to find
|
|
256
|
+
:param List[Dict[str, Any]] query_configs: List of query configurations
|
|
257
|
+
:return: Tuple of vulnerability type and config, or (None, None) if not found
|
|
258
|
+
:rtype: Tuple[Optional[WizVulnerabilityType], Optional[Dict[str, Any]]]
|
|
259
|
+
"""
|
|
260
|
+
for query_config in query_configs:
|
|
261
|
+
if query_config["type"].value == query_type_str:
|
|
262
|
+
return query_config["type"], query_config
|
|
263
|
+
return None, None
|
|
264
|
+
|
|
265
|
+
def _apply_project_filtering(
|
|
266
|
+
self,
|
|
267
|
+
nodes: List[Dict[str, Any]],
|
|
268
|
+
vulnerability_type: WizVulnerabilityType,
|
|
269
|
+
project_id: str,
|
|
270
|
+
query_type_str: str,
|
|
271
|
+
) -> List[Dict[str, Any]]:
|
|
272
|
+
"""
|
|
273
|
+
Apply project filtering for vulnerability types that need it.
|
|
274
|
+
|
|
275
|
+
:param List[Dict[str, Any]] nodes: Nodes to filter
|
|
276
|
+
:param WizVulnerabilityType vulnerability_type: Vulnerability type
|
|
277
|
+
:param str project_id: Project ID to filter by
|
|
278
|
+
:param str query_type_str: Query type string for logging
|
|
279
|
+
:return: Filtered nodes
|
|
280
|
+
:rtype: List[Dict[str, Any]]
|
|
281
|
+
"""
|
|
282
|
+
filter_required_types = {
|
|
283
|
+
WizVulnerabilityType.EXCESSIVE_ACCESS_FINDING,
|
|
284
|
+
WizVulnerabilityType.NETWORK_EXPOSURE_FINDING,
|
|
285
|
+
WizVulnerabilityType.EXTERNAL_ATTACH_SURFACE,
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
if vulnerability_type in filter_required_types:
|
|
289
|
+
original_count = len(nodes)
|
|
290
|
+
nodes = self._filter_findings_by_project(nodes, project_id)
|
|
291
|
+
if len(nodes) != original_count:
|
|
292
|
+
logger.info(f"Filtered {query_type_str}: {len(nodes)}/{original_count} match project")
|
|
293
|
+
|
|
294
|
+
return nodes
|
|
295
|
+
|
|
296
|
+
def fetch_findings_async(self, *args, **kwargs) -> Iterator[IntegrationFinding]:
|
|
297
|
+
"""
|
|
298
|
+
Fetches Wiz findings using async GraphQL queries for improved performance
|
|
299
|
+
|
|
300
|
+
This method runs multiple GraphQL queries concurrently, significantly reducing
|
|
301
|
+
the total time needed to fetch all finding types from Wiz.
|
|
302
|
+
|
|
303
|
+
:yield: IntegrationFinding objects
|
|
304
|
+
:rtype: Iterator[IntegrationFinding]
|
|
305
|
+
"""
|
|
306
|
+
try:
|
|
307
|
+
# Step 1: Validate project ID
|
|
308
|
+
project_id = self._validate_project_id(kwargs.get("wiz_project_id"))
|
|
309
|
+
|
|
310
|
+
# Step 2: Initialize progress tracking
|
|
311
|
+
logger.info("Fetching Wiz findings using async concurrent queries...")
|
|
312
|
+
self.num_findings_to_process = 0
|
|
313
|
+
query_configs = self.get_query_types(project_id=project_id)
|
|
314
|
+
|
|
315
|
+
main_task = self.finding_progress.add_task(
|
|
316
|
+
"[cyan]Running concurrent GraphQL queries...", total=len(query_configs)
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
# Step 3: Setup authentication
|
|
320
|
+
headers = self._setup_authentication_headers()
|
|
321
|
+
|
|
322
|
+
# Step 4: Execute queries
|
|
323
|
+
results = self._execute_concurrent_queries(query_configs, headers)
|
|
324
|
+
should_fetch_fresh = self._should_fetch_fresh_data(query_configs)
|
|
325
|
+
|
|
326
|
+
# Step 5: Update main progress
|
|
327
|
+
self.finding_progress.update(
|
|
328
|
+
main_task,
|
|
329
|
+
description="[green]✓ Completed all concurrent queries",
|
|
330
|
+
completed=len(query_configs),
|
|
331
|
+
total=len(query_configs),
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
# Step 6: Process results
|
|
335
|
+
yield from self._process_query_results(results, query_configs, project_id, should_fetch_fresh)
|
|
336
|
+
|
|
337
|
+
# Step 7: Complete main task
|
|
338
|
+
self.finding_progress.advance(main_task, len(query_configs))
|
|
339
|
+
|
|
340
|
+
except Exception as e:
|
|
341
|
+
logger.error(f"Error in async findings fetch: {str(e)}", exc_info=True)
|
|
342
|
+
if "main_task" in locals():
|
|
343
|
+
self.finding_progress.update(
|
|
344
|
+
main_task, description=f"[red]✗ Error in concurrent queries: {str(e)[:50]}..."
|
|
345
|
+
)
|
|
346
|
+
# Fallback to synchronous method
|
|
347
|
+
logger.info("Falling back to synchronous query method...")
|
|
348
|
+
yield from self.fetch_findings_sync(**kwargs)
|
|
349
|
+
|
|
350
|
+
logger.info(
|
|
351
|
+
"Finished async fetching Wiz findings. Total findings to process: %d", self.num_findings_to_process or 0
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
def _should_fetch_fresh_data(self, query_configs: List[Dict[str, Any]]) -> bool:
|
|
355
|
+
"""
|
|
356
|
+
Check if we should fetch fresh data or use cached data.
|
|
357
|
+
|
|
358
|
+
:param List[Dict[str, Any]] query_configs: Query configurations
|
|
359
|
+
:return: True if fresh data should be fetched
|
|
360
|
+
:rtype: bool
|
|
361
|
+
"""
|
|
362
|
+
import datetime
|
|
363
|
+
import os
|
|
364
|
+
|
|
365
|
+
fetch_interval = datetime.timedelta(hours=WizVariables.wizFullPullLimitHours or 8)
|
|
366
|
+
current_time = datetime.datetime.now()
|
|
367
|
+
|
|
368
|
+
# Check if any file is missing or older than the fetch interval
|
|
369
|
+
for config in query_configs:
|
|
370
|
+
file_path = config.get("file_path")
|
|
371
|
+
if not file_path or not os.path.exists(file_path):
|
|
372
|
+
return True
|
|
373
|
+
|
|
374
|
+
file_mod_time = datetime.datetime.fromtimestamp(os.path.getmtime(file_path))
|
|
375
|
+
if current_time - file_mod_time >= fetch_interval:
|
|
376
|
+
return True
|
|
377
|
+
|
|
378
|
+
return False
|
|
379
|
+
|
|
380
|
+
def _load_cached_data_with_progress(
|
|
381
|
+
self, query_configs: List[Dict[str, Any]]
|
|
382
|
+
) -> List[Tuple[str, List[Dict[str, Any]], Optional[Exception]]]:
|
|
383
|
+
"""
|
|
384
|
+
Load cached data with progress tracking.
|
|
385
|
+
|
|
386
|
+
:param List[Dict[str, Any]] query_configs: Query configurations
|
|
387
|
+
:return: Results in the same format as async queries
|
|
388
|
+
:rtype: List[Tuple[str, List[Dict[str, Any]], Optional[Exception]]]
|
|
389
|
+
"""
|
|
390
|
+
results = []
|
|
391
|
+
cache_task = self.finding_progress.add_task("[green]Loading cached Wiz data...", total=len(query_configs))
|
|
392
|
+
|
|
393
|
+
for config in query_configs:
|
|
394
|
+
query_type = config["type"].value
|
|
395
|
+
file_path = config.get("file_path")
|
|
396
|
+
|
|
397
|
+
try:
|
|
398
|
+
if file_path and os.path.exists(file_path):
|
|
399
|
+
with open(file_path, "r", encoding="utf-8") as file:
|
|
400
|
+
nodes = json.load(file)
|
|
401
|
+
|
|
402
|
+
logger.info(f"Loaded {len(nodes)} cached {query_type} findings from {file_path}")
|
|
403
|
+
results.append((query_type, nodes, None))
|
|
404
|
+
else:
|
|
405
|
+
logger.warning(f"No cached data found for {query_type}")
|
|
406
|
+
results.append((query_type, [], None))
|
|
407
|
+
|
|
408
|
+
except Exception as e:
|
|
409
|
+
logger.error(f"Error loading cached data for {query_type}: {e}")
|
|
410
|
+
results.append((query_type, [], e))
|
|
411
|
+
|
|
412
|
+
self.finding_progress.advance(cache_task, 1)
|
|
413
|
+
|
|
414
|
+
self.finding_progress.update(
|
|
415
|
+
cache_task, description=f"[green]✓ Loaded cached data for {len(query_configs)} query types"
|
|
416
|
+
)
|
|
417
|
+
|
|
418
|
+
return results
|
|
419
|
+
|
|
420
|
+
def _save_data_to_cache(self, nodes: List[Dict[str, Any]], file_path: Optional[str]) -> None:
|
|
421
|
+
"""
|
|
422
|
+
Save fetched data to cache file.
|
|
108
423
|
|
|
109
|
-
|
|
424
|
+
:param List[Dict[str, Any]] nodes: Data to save
|
|
425
|
+
:param Optional[str] file_path: File path to save to
|
|
426
|
+
:rtype: None
|
|
427
|
+
"""
|
|
428
|
+
if not file_path:
|
|
429
|
+
return
|
|
430
|
+
|
|
431
|
+
try:
|
|
432
|
+
from regscale.core.app.utils.app_utils import check_file_path
|
|
433
|
+
|
|
434
|
+
# Ensure directory exists
|
|
435
|
+
check_file_path(os.path.dirname(file_path))
|
|
436
|
+
|
|
437
|
+
# Save data to file
|
|
438
|
+
with open(file_path, "w", encoding="utf-8") as file:
|
|
439
|
+
json.dump(nodes, file)
|
|
440
|
+
|
|
441
|
+
logger.debug(f"Saved {len(nodes)} nodes to cache file: {file_path}")
|
|
442
|
+
|
|
443
|
+
except Exception as e:
|
|
444
|
+
logger.warning(f"Failed to save data to cache file {file_path}: {e}")
|
|
445
|
+
|
|
446
|
+
def fetch_findings_sync(self, **kwargs) -> Iterator[IntegrationFinding]:
|
|
447
|
+
"""
|
|
448
|
+
Original synchronous method for fetching findings (renamed for fallback)
|
|
449
|
+
:param List[Dict[str, Any]] kwargs: Query configurations
|
|
450
|
+
:return: Results in the same format as async queries
|
|
451
|
+
:rtype: Iterator[IntegrationFinding]
|
|
452
|
+
"""
|
|
453
|
+
# Use shared validation logic
|
|
454
|
+
project_id = self._validate_project_id(kwargs.get("wiz_project_id"))
|
|
455
|
+
|
|
456
|
+
logger.info("Fetching Wiz findings using synchronous queries...")
|
|
110
457
|
self.num_findings_to_process = 0
|
|
458
|
+
query_types = self.get_query_types(project_id=project_id)
|
|
459
|
+
|
|
460
|
+
# Create detailed progress tracking for each query type
|
|
461
|
+
main_task = self.finding_progress.add_task(
|
|
462
|
+
"[cyan]Fetching Wiz findings across all query types...", total=len(query_types)
|
|
463
|
+
)
|
|
464
|
+
|
|
465
|
+
for i, wiz_vulnerability_type in enumerate(query_types, 1):
|
|
466
|
+
vulnerability_name = self._get_friendly_vulnerability_name(wiz_vulnerability_type["type"])
|
|
111
467
|
|
|
112
|
-
|
|
113
|
-
|
|
468
|
+
# Update main progress with current query type
|
|
469
|
+
self.finding_progress.update(
|
|
470
|
+
main_task, description=f"[cyan]Step {i}/{len(query_types)}: Fetching {vulnerability_name}..."
|
|
471
|
+
)
|
|
472
|
+
|
|
473
|
+
# Create subtask for this specific query type
|
|
474
|
+
query_task = self.finding_progress.add_task(
|
|
475
|
+
f"[yellow]Querying Wiz API for {vulnerability_name}...", total=None # Indeterminate while fetching
|
|
476
|
+
)
|
|
114
477
|
|
|
115
478
|
# Use the variables from the query type configuration
|
|
116
479
|
variables = wiz_vulnerability_type.get("variables", self.get_variables())
|
|
@@ -121,8 +484,176 @@ class WizVulnerabilityIntegration(ScannerIntegration):
|
|
|
121
484
|
topic_key=wiz_vulnerability_type["topic_key"],
|
|
122
485
|
file_path=wiz_vulnerability_type["file_path"],
|
|
123
486
|
)
|
|
124
|
-
|
|
125
|
-
|
|
487
|
+
|
|
488
|
+
# Update query task to show data fetched
|
|
489
|
+
self.finding_progress.update(
|
|
490
|
+
query_task, description=f"[green]✓ Fetched {len(nodes)} {vulnerability_name} from Wiz API"
|
|
491
|
+
)
|
|
492
|
+
|
|
493
|
+
# Filter findings by project for queries that don't support API-level project filtering
|
|
494
|
+
if wiz_vulnerability_type["type"] in [
|
|
495
|
+
WizVulnerabilityType.EXCESSIVE_ACCESS_FINDING,
|
|
496
|
+
WizVulnerabilityType.NETWORK_EXPOSURE_FINDING,
|
|
497
|
+
WizVulnerabilityType.EXTERNAL_ATTACH_SURFACE,
|
|
498
|
+
]:
|
|
499
|
+
filter_task = self.finding_progress.add_task(
|
|
500
|
+
f"[blue]Filtering {vulnerability_name} by project...", total=len(nodes)
|
|
501
|
+
)
|
|
502
|
+
nodes = self._filter_findings_by_project_with_progress(nodes, project_id, filter_task)
|
|
503
|
+
self.finding_progress.update(
|
|
504
|
+
filter_task, description=f"[green]✓ Filtered to {len(nodes)} {vulnerability_name} for project"
|
|
505
|
+
)
|
|
506
|
+
|
|
507
|
+
# Create parsing task
|
|
508
|
+
if nodes:
|
|
509
|
+
parse_task = self.finding_progress.add_task(
|
|
510
|
+
f"[magenta]Parsing {len(nodes)} {vulnerability_name}...", total=len(nodes)
|
|
511
|
+
)
|
|
512
|
+
|
|
513
|
+
yield from self.parse_findings_with_progress(nodes, wiz_vulnerability_type["type"], parse_task)
|
|
514
|
+
|
|
515
|
+
self.finding_progress.update(
|
|
516
|
+
parse_task, description=f"[green]✓ Parsed {len(nodes)} {vulnerability_name} successfully"
|
|
517
|
+
)
|
|
518
|
+
|
|
519
|
+
# Mark query complete and advance main progress
|
|
520
|
+
self.finding_progress.update(query_task, completed=1, total=1)
|
|
521
|
+
self.finding_progress.advance(main_task, 1)
|
|
522
|
+
|
|
523
|
+
# Update main task completion
|
|
524
|
+
self.finding_progress.update(
|
|
525
|
+
main_task,
|
|
526
|
+
description=f"[green]✓ Completed fetching all Wiz findings ({self.num_findings_to_process or 0} total)",
|
|
527
|
+
)
|
|
528
|
+
|
|
529
|
+
logger.info(
|
|
530
|
+
"Finished synchronous fetching Wiz findings. Total findings to process: %d",
|
|
531
|
+
self.num_findings_to_process or 0,
|
|
532
|
+
)
|
|
533
|
+
|
|
534
|
+
def _get_friendly_vulnerability_name(self, vulnerability_type: WizVulnerabilityType) -> str:
|
|
535
|
+
"""
|
|
536
|
+
Convert vulnerability type enum to user-friendly name for progress display.
|
|
537
|
+
|
|
538
|
+
:param WizVulnerabilityType vulnerability_type: The vulnerability type enum
|
|
539
|
+
:return: User-friendly name
|
|
540
|
+
:rtype: str
|
|
541
|
+
"""
|
|
542
|
+
friendly_names = {
|
|
543
|
+
WizVulnerabilityType.VULNERABILITY: "Vulnerabilities",
|
|
544
|
+
WizVulnerabilityType.CONFIGURATION: "Configuration Findings",
|
|
545
|
+
WizVulnerabilityType.HOST_FINDING: "Host Findings",
|
|
546
|
+
WizVulnerabilityType.DATA_FINDING: "Data Findings",
|
|
547
|
+
WizVulnerabilityType.SECRET_FINDING: "Secret Findings",
|
|
548
|
+
WizVulnerabilityType.NETWORK_EXPOSURE_FINDING: "Network Exposures",
|
|
549
|
+
WizVulnerabilityType.END_OF_LIFE_FINDING: "End-of-Life Findings",
|
|
550
|
+
WizVulnerabilityType.EXTERNAL_ATTACH_SURFACE: "External Attack Surface",
|
|
551
|
+
WizVulnerabilityType.EXCESSIVE_ACCESS_FINDING: "Excessive Access Findings",
|
|
552
|
+
WizVulnerabilityType.ISSUE: "Issues",
|
|
553
|
+
}
|
|
554
|
+
return friendly_names.get(vulnerability_type, vulnerability_type.value.replace("_", " ").title())
|
|
555
|
+
|
|
556
|
+
def parse_findings_with_progress(
|
|
557
|
+
self, nodes: List[Dict[str, Any]], vulnerability_type: WizVulnerabilityType, task_id: Optional[Any] = None
|
|
558
|
+
) -> Iterator[IntegrationFinding]:
|
|
559
|
+
"""
|
|
560
|
+
Parse findings with progress tracking.
|
|
561
|
+
|
|
562
|
+
:param List[Dict[str, Any]] nodes: List of Wiz finding nodes
|
|
563
|
+
:param WizVulnerabilityType vulnerability_type: The type of vulnerability
|
|
564
|
+
:param Optional[Any] task_id: Progress task ID for tracking
|
|
565
|
+
:yield: IntegrationFinding objects
|
|
566
|
+
:rtype: Iterator[IntegrationFinding]
|
|
567
|
+
"""
|
|
568
|
+
findings_count = 0
|
|
569
|
+
total_nodes = len(nodes)
|
|
570
|
+
|
|
571
|
+
for i, node in enumerate(nodes, 1):
|
|
572
|
+
if finding := self.parse_finding(node, vulnerability_type):
|
|
573
|
+
findings_count += 1
|
|
574
|
+
yield finding
|
|
575
|
+
|
|
576
|
+
# Update progress if task_id provided
|
|
577
|
+
if task_id is not None:
|
|
578
|
+
self.finding_progress.advance(task_id, 1)
|
|
579
|
+
|
|
580
|
+
# Log parsing results for this type
|
|
581
|
+
if findings_count != total_nodes:
|
|
582
|
+
logger.info(
|
|
583
|
+
"Parsed %d/%d %s findings successfully (%d failed/skipped)",
|
|
584
|
+
findings_count,
|
|
585
|
+
total_nodes,
|
|
586
|
+
vulnerability_type.value,
|
|
587
|
+
total_nodes - findings_count,
|
|
588
|
+
)
|
|
589
|
+
|
|
590
|
+
# Update the total count once at the end to prevent flashing
|
|
591
|
+
self.num_findings_to_process = (self.num_findings_to_process or 0) + findings_count
|
|
592
|
+
|
|
593
|
+
def _filter_findings_by_project_with_progress(
|
|
594
|
+
self, nodes: List[Dict[str, Any]], project_id: str, task_id: Optional[Any] = None
|
|
595
|
+
) -> List[Dict[str, Any]]:
|
|
596
|
+
"""
|
|
597
|
+
Filter findings by project ID with progress tracking.
|
|
598
|
+
|
|
599
|
+
:param List[Dict[str, Any]] nodes: List of finding nodes
|
|
600
|
+
:param str project_id: Project ID to filter by
|
|
601
|
+
:param Optional[Any] task_id: Progress task ID for tracking
|
|
602
|
+
:return: Filtered list of nodes
|
|
603
|
+
:rtype: List[Dict[str, Any]]
|
|
604
|
+
"""
|
|
605
|
+
filtered_nodes = []
|
|
606
|
+
original_count = len(nodes)
|
|
607
|
+
|
|
608
|
+
for i, node in enumerate(nodes, 1):
|
|
609
|
+
# Check if any of the node's projects match the target project ID
|
|
610
|
+
projects = node.get("projects", [])
|
|
611
|
+
if any(project.get("id") == project_id for project in projects):
|
|
612
|
+
filtered_nodes.append(node)
|
|
613
|
+
|
|
614
|
+
# Update progress if task_id provided
|
|
615
|
+
if task_id is not None:
|
|
616
|
+
self.finding_progress.advance(task_id, 1)
|
|
617
|
+
|
|
618
|
+
filtered_count = len(filtered_nodes)
|
|
619
|
+
if filtered_count != original_count:
|
|
620
|
+
logger.info(
|
|
621
|
+
"Filtered findings by project: %d/%d findings match project %s",
|
|
622
|
+
filtered_count,
|
|
623
|
+
original_count,
|
|
624
|
+
project_id,
|
|
625
|
+
)
|
|
626
|
+
|
|
627
|
+
return filtered_nodes
|
|
628
|
+
|
|
629
|
+
def _filter_findings_by_project(self, nodes: List[Dict[str, Any]], project_id: str) -> List[Dict[str, Any]]:
|
|
630
|
+
"""
|
|
631
|
+
Filter findings by project ID for queries that don't support API-level project filtering.
|
|
632
|
+
|
|
633
|
+
:param List[Dict[str, Any]] nodes: List of finding nodes
|
|
634
|
+
:param str project_id: Project ID to filter by
|
|
635
|
+
:return: Filtered list of nodes
|
|
636
|
+
:rtype: List[Dict[str, Any]]
|
|
637
|
+
"""
|
|
638
|
+
filtered_nodes = []
|
|
639
|
+
original_count = len(nodes)
|
|
640
|
+
|
|
641
|
+
for node in nodes:
|
|
642
|
+
# Check if any of the node's projects match the target project ID
|
|
643
|
+
projects = node.get("projects", [])
|
|
644
|
+
if any(project.get("id") == project_id for project in projects):
|
|
645
|
+
filtered_nodes.append(node)
|
|
646
|
+
|
|
647
|
+
filtered_count = len(filtered_nodes)
|
|
648
|
+
if filtered_count != original_count:
|
|
649
|
+
logger.info(
|
|
650
|
+
"Filtered findings by project: %d/%d findings match project %s",
|
|
651
|
+
filtered_count,
|
|
652
|
+
original_count,
|
|
653
|
+
project_id,
|
|
654
|
+
)
|
|
655
|
+
|
|
656
|
+
return filtered_nodes
|
|
126
657
|
|
|
127
658
|
def parse_findings(
|
|
128
659
|
self, nodes: List[Dict[str, Any]], vulnerability_type: WizVulnerabilityType
|
|
@@ -130,15 +661,15 @@ class WizVulnerabilityIntegration(ScannerIntegration):
|
|
|
130
661
|
"""
|
|
131
662
|
Parses a list of Wiz finding nodes into IntegrationFinding objects.
|
|
132
663
|
|
|
664
|
+
This is a compatibility wrapper that calls the progress-aware version.
|
|
665
|
+
|
|
133
666
|
:param List[Dict[str, Any]] nodes: List of Wiz finding nodes
|
|
134
667
|
:param WizVulnerabilityType vulnerability_type: The type of vulnerability
|
|
135
668
|
:yield: IntegrationFinding objects
|
|
136
669
|
:rtype: Iterator[IntegrationFinding]
|
|
137
670
|
"""
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
self.num_findings_to_process = (self.num_findings_to_process or 0) + 1
|
|
141
|
-
yield finding
|
|
671
|
+
# Delegate to the progress-aware version without progress tracking
|
|
672
|
+
yield from self.parse_findings_with_progress(nodes, vulnerability_type, task_id=None)
|
|
142
673
|
|
|
143
674
|
@classmethod
|
|
144
675
|
def get_issue_severity(cls, severity: str) -> regscale_models.IssueSeverity:
|
|
@@ -170,6 +701,41 @@ class WizVulnerabilityIntegration(ScannerIntegration):
|
|
|
170
701
|
result = "\n".join(formatted_comments)
|
|
171
702
|
return result
|
|
172
703
|
|
|
704
|
+
def get_asset_id_from_node(self, node: Dict[str, Any], vulnerability_type: WizVulnerabilityType) -> Optional[str]:
|
|
705
|
+
"""
|
|
706
|
+
Get the asset ID from a node based on the vulnerability type.
|
|
707
|
+
|
|
708
|
+
:param Dict[str, Any] node: The Wiz finding node
|
|
709
|
+
:param WizVulnerabilityType vulnerability_type: The type of vulnerability
|
|
710
|
+
:return: The asset ID or None if not found
|
|
711
|
+
:rtype: Optional[str]
|
|
712
|
+
"""
|
|
713
|
+
# Define asset lookup patterns for different vulnerability types
|
|
714
|
+
asset_lookup_patterns = {
|
|
715
|
+
# WizVulnerabilityType.VULNERABILITY: "vulnerableAsset",
|
|
716
|
+
# WizVulnerabilityType.CONFIGURATION: "resource",
|
|
717
|
+
# WizVulnerabilityType.HOST_FINDING: "resource",
|
|
718
|
+
# WizVulnerabilityType.DATA_FINDING: "resource",
|
|
719
|
+
WizVulnerabilityType.SECRET_FINDING: "resource",
|
|
720
|
+
WizVulnerabilityType.NETWORK_EXPOSURE_FINDING: "exposedEntity",
|
|
721
|
+
WizVulnerabilityType.END_OF_LIFE_FINDING: "vulnerableAsset",
|
|
722
|
+
WizVulnerabilityType.EXTERNAL_ATTACH_SURFACE: "exposedEntity",
|
|
723
|
+
WizVulnerabilityType.EXCESSIVE_ACCESS_FINDING: "scope",
|
|
724
|
+
WizVulnerabilityType.ISSUE: "entitySnapshot",
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
asset_lookup_key = asset_lookup_patterns.get(vulnerability_type, "vulnerableAsset")
|
|
728
|
+
|
|
729
|
+
if asset_lookup_key == "scope":
|
|
730
|
+
# Handle special case for excessive access findings where ID is nested
|
|
731
|
+
scope = node.get("scope", {})
|
|
732
|
+
graph_entity = scope.get("graphEntity", {})
|
|
733
|
+
return graph_entity.get("id")
|
|
734
|
+
|
|
735
|
+
# Standard case - direct id access
|
|
736
|
+
asset_container = node.get(asset_lookup_key, {})
|
|
737
|
+
return asset_container.get("id")
|
|
738
|
+
|
|
173
739
|
def parse_finding(
|
|
174
740
|
self, node: Dict[str, Any], vulnerability_type: WizVulnerabilityType
|
|
175
741
|
) -> Optional[IntegrationFinding]:
|
|
@@ -182,96 +748,510 @@ class WizVulnerabilityIntegration(ScannerIntegration):
|
|
|
182
748
|
:rtype: Optional[IntegrationFinding]
|
|
183
749
|
"""
|
|
184
750
|
try:
|
|
185
|
-
|
|
186
|
-
if
|
|
187
|
-
return
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
else node.get("cve", name)
|
|
200
|
-
)
|
|
201
|
-
|
|
202
|
-
comments_dict = node.get("commentThread", {})
|
|
203
|
-
formatted_comments = self.process_comments(comments_dict)
|
|
204
|
-
|
|
205
|
-
return IntegrationFinding(
|
|
206
|
-
control_labels=[],
|
|
207
|
-
category="Wiz Vulnerability",
|
|
208
|
-
title=node.get("name", "Unknown vulnerability"),
|
|
209
|
-
description=node.get("description", ""),
|
|
210
|
-
severity=severity,
|
|
211
|
-
status=status,
|
|
212
|
-
asset_identifier=asset_id,
|
|
213
|
-
external_id=f"{node.get('sourceRule', {'id': cve}).get('id')}",
|
|
214
|
-
first_seen=first_seen,
|
|
215
|
-
date_created=first_seen,
|
|
216
|
-
last_seen=format_to_regscale_iso(
|
|
217
|
-
node.get("lastDetectedAt") or node.get("analyzedAt") or get_current_datetime()
|
|
218
|
-
),
|
|
219
|
-
remediation=node.get("description", ""),
|
|
220
|
-
cvss_score=node.get("score"),
|
|
221
|
-
cve=cve,
|
|
222
|
-
plugin_name=cve,
|
|
223
|
-
cvss_v3_base_score=node.get("score"),
|
|
224
|
-
source_rule_id=node.get("sourceRule", {}).get("id"),
|
|
225
|
-
vulnerability_type=vulnerability_type.value,
|
|
226
|
-
due_date=due_date,
|
|
227
|
-
date_last_updated=format_to_regscale_iso(get_current_datetime()),
|
|
228
|
-
identification="Vulnerability Assessment",
|
|
229
|
-
comments=formatted_comments,
|
|
230
|
-
poam_comments=formatted_comments,
|
|
231
|
-
)
|
|
751
|
+
# Route to specific parsing method based on vulnerability type
|
|
752
|
+
if vulnerability_type == WizVulnerabilityType.SECRET_FINDING:
|
|
753
|
+
return self._parse_secret_finding(node)
|
|
754
|
+
elif vulnerability_type == WizVulnerabilityType.NETWORK_EXPOSURE_FINDING:
|
|
755
|
+
return self._parse_network_exposure_finding(node)
|
|
756
|
+
elif vulnerability_type == WizVulnerabilityType.EXTERNAL_ATTACH_SURFACE:
|
|
757
|
+
return self._parse_external_attack_surface_finding(node)
|
|
758
|
+
elif vulnerability_type == WizVulnerabilityType.EXCESSIVE_ACCESS_FINDING:
|
|
759
|
+
return self._parse_excessive_access_finding(node)
|
|
760
|
+
elif vulnerability_type == WizVulnerabilityType.END_OF_LIFE_FINDING:
|
|
761
|
+
return self._parse_end_of_life_finding(node)
|
|
762
|
+
else:
|
|
763
|
+
# Fallback to generic parsing for any other types
|
|
764
|
+
return self._parse_generic_finding(node, vulnerability_type)
|
|
232
765
|
except (KeyError, TypeError, ValueError) as e:
|
|
233
766
|
logger.error("Error parsing Wiz finding: %s", str(e), exc_info=True)
|
|
234
767
|
return None
|
|
235
768
|
|
|
236
|
-
|
|
237
|
-
|
|
769
|
+
def _parse_secret_finding(self, node: Dict[str, Any]) -> Optional[IntegrationFinding]:
|
|
770
|
+
"""
|
|
771
|
+
Parse secret finding from Wiz.
|
|
772
|
+
|
|
773
|
+
:param Dict[str, Any] node: The Wiz finding node to parse
|
|
774
|
+
:return: The parsed IntegrationFinding or None if parsing fails
|
|
775
|
+
:rtype: Optional[IntegrationFinding]
|
|
776
|
+
"""
|
|
777
|
+
asset_id = node.get("resource", {}).get("id")
|
|
778
|
+
if not asset_id:
|
|
779
|
+
return None
|
|
780
|
+
|
|
781
|
+
first_seen = node.get("firstSeenAt") or get_current_datetime()
|
|
782
|
+
first_seen = format_to_regscale_iso(first_seen)
|
|
783
|
+
severity = self.get_issue_severity(node.get("severity", "Low"))
|
|
784
|
+
due_date = regscale_models.Issue.get_due_date(severity, self.app.config, "wiz", first_seen)
|
|
785
|
+
|
|
786
|
+
# Create meaningful title for secret findings
|
|
787
|
+
secret_type = node.get("type", "Unknown Secret")
|
|
788
|
+
resource_name = node.get("resource", {}).get("name", "Unknown Resource")
|
|
789
|
+
title = f"Secret Detected: {secret_type} in {resource_name}"
|
|
790
|
+
|
|
791
|
+
# Build description with secret details
|
|
792
|
+
description_parts = [
|
|
793
|
+
f"Secret type: {secret_type}",
|
|
794
|
+
f"Confidence: {node.get('confidence', 'Unknown')}",
|
|
795
|
+
f"Encrypted: {node.get('isEncrypted', False)}",
|
|
796
|
+
f"Managed: {node.get('isManaged', False)}",
|
|
797
|
+
]
|
|
798
|
+
|
|
799
|
+
if rule := node.get("rule", {}):
|
|
800
|
+
description_parts.append(f"Detection rule: {rule.get('name', 'Unknown')}")
|
|
801
|
+
|
|
802
|
+
description = "\n".join(description_parts)
|
|
803
|
+
|
|
804
|
+
return IntegrationFinding(
|
|
805
|
+
control_labels=[],
|
|
806
|
+
category="Wiz Secret Detection",
|
|
807
|
+
title=title,
|
|
808
|
+
description=description,
|
|
809
|
+
severity=severity,
|
|
810
|
+
status=self.map_status_to_issue_status(node.get("status", "Open")),
|
|
811
|
+
asset_identifier=asset_id,
|
|
812
|
+
external_id=node.get("id"),
|
|
813
|
+
first_seen=first_seen,
|
|
814
|
+
date_created=first_seen,
|
|
815
|
+
last_seen=format_to_regscale_iso(node.get("lastSeenAt") or get_current_datetime()),
|
|
816
|
+
remediation=f"Remove or properly secure the {secret_type} secret found in {resource_name}",
|
|
817
|
+
plugin_name=f"Wiz Secret Detection - {secret_type}",
|
|
818
|
+
vulnerability_type=WizVulnerabilityType.SECRET_FINDING.value,
|
|
819
|
+
due_date=due_date,
|
|
820
|
+
date_last_updated=format_to_regscale_iso(get_current_datetime()),
|
|
821
|
+
identification="Secret Scanning",
|
|
822
|
+
)
|
|
823
|
+
|
|
824
|
+
def _parse_network_exposure_finding(self, node: Dict[str, Any]) -> Optional[IntegrationFinding]:
|
|
825
|
+
"""Parse network exposure finding from Wiz.
|
|
826
|
+
|
|
827
|
+
:param Dict[str, Any] node: The Wiz finding node to parse
|
|
828
|
+
:return: The parsed IntegrationFinding or None if parsing fails
|
|
829
|
+
:rtype: Optional[IntegrationFinding]
|
|
830
|
+
"""
|
|
831
|
+
asset_id = node.get("exposedEntity", {}).get("id")
|
|
832
|
+
if not asset_id:
|
|
833
|
+
return None
|
|
834
|
+
|
|
835
|
+
first_seen = node.get("firstSeenAt") or get_current_datetime()
|
|
836
|
+
first_seen = format_to_regscale_iso(first_seen)
|
|
837
|
+
|
|
838
|
+
# Network exposures typically don't have explicit severity, assume Medium
|
|
839
|
+
severity = regscale_models.IssueSeverity.Moderate
|
|
840
|
+
due_date = regscale_models.Issue.get_due_date(severity, self.app.config, "wiz", first_seen)
|
|
841
|
+
|
|
842
|
+
# Create meaningful title for network exposure
|
|
843
|
+
exposed_entity = node.get("exposedEntity", {})
|
|
844
|
+
entity_name = exposed_entity.get("name", "Unknown Entity")
|
|
845
|
+
port_range = node.get("portRange", "Unknown Port")
|
|
846
|
+
title = f"Network Exposure: {entity_name} on {port_range}"
|
|
847
|
+
|
|
848
|
+
# Build description with network details
|
|
849
|
+
description_parts = [
|
|
850
|
+
f"Exposed entity: {entity_name} ({exposed_entity.get('type', 'Unknown Type')})",
|
|
851
|
+
f"Port range: {port_range}",
|
|
852
|
+
f"Source IP range: {node.get('sourceIpRange', 'Unknown')}",
|
|
853
|
+
f"Destination IP range: {node.get('destinationIpRange', 'Unknown')}",
|
|
854
|
+
]
|
|
855
|
+
|
|
856
|
+
if protocols := node.get("appProtocols"):
|
|
857
|
+
description_parts.append(f"Application protocols: {', '.join(protocols)}")
|
|
858
|
+
if net_protocols := node.get("networkProtocols"):
|
|
859
|
+
description_parts.append(f"Network protocols: {', '.join(net_protocols)}")
|
|
860
|
+
|
|
861
|
+
description = "\n".join(description_parts)
|
|
862
|
+
|
|
863
|
+
return IntegrationFinding(
|
|
864
|
+
control_labels=[],
|
|
865
|
+
category="Wiz Network Exposure",
|
|
866
|
+
title=title,
|
|
867
|
+
description=description,
|
|
868
|
+
severity=severity,
|
|
869
|
+
status=IssueStatus.Open,
|
|
870
|
+
asset_identifier=asset_id,
|
|
871
|
+
external_id=node.get("id"),
|
|
872
|
+
first_seen=first_seen,
|
|
873
|
+
date_created=first_seen,
|
|
874
|
+
last_seen=first_seen, # Network exposures may not have lastSeen
|
|
875
|
+
remediation=f"Review and restrict network access to {entity_name} on {port_range}",
|
|
876
|
+
plugin_name=f"Wiz Network Exposure - {port_range}",
|
|
877
|
+
vulnerability_type=WizVulnerabilityType.NETWORK_EXPOSURE_FINDING.value,
|
|
878
|
+
due_date=due_date,
|
|
879
|
+
date_last_updated=format_to_regscale_iso(get_current_datetime()),
|
|
880
|
+
identification="Network Security Assessment",
|
|
881
|
+
)
|
|
882
|
+
|
|
883
|
+
def _parse_external_attack_surface_finding(self, node: Dict[str, Any]) -> Optional[IntegrationFinding]:
|
|
884
|
+
"""Parse external attack surface finding from Wiz.
|
|
885
|
+
|
|
886
|
+
:param Dict[str, Any] node: The Wiz finding node to parse
|
|
887
|
+
:return: The parsed IntegrationFinding or None if parsing fails
|
|
888
|
+
:rtype: Optional[IntegrationFinding]
|
|
889
|
+
"""
|
|
890
|
+
asset_id = node.get("exposedEntity", {}).get("id")
|
|
891
|
+
if not asset_id:
|
|
892
|
+
return None
|
|
893
|
+
|
|
894
|
+
first_seen = node.get("firstSeenAt") or get_current_datetime()
|
|
895
|
+
first_seen = format_to_regscale_iso(first_seen)
|
|
896
|
+
|
|
897
|
+
# External attack surface findings are typically high severity
|
|
898
|
+
severity = regscale_models.IssueSeverity.High
|
|
899
|
+
due_date = regscale_models.Issue.get_due_date(severity, self.app.config, "wiz", first_seen)
|
|
900
|
+
|
|
901
|
+
# Create meaningful title for external attack surface
|
|
902
|
+
exposed_entity = node.get("exposedEntity", {})
|
|
903
|
+
entity_name = exposed_entity.get("name", "Unknown Entity")
|
|
904
|
+
port_range = node.get("portRange", "Unknown Port")
|
|
905
|
+
title = f"External Attack Surface: {entity_name} exposed on {port_range}"
|
|
906
|
+
|
|
907
|
+
# Build description with attack surface details
|
|
908
|
+
description_parts = [
|
|
909
|
+
f"Externally exposed entity: {entity_name} ({exposed_entity.get('type', 'Unknown Type')})",
|
|
910
|
+
f"Exposed port range: {port_range}",
|
|
911
|
+
f"Source IP range: {node.get('sourceIpRange', 'Public Internet')}",
|
|
912
|
+
]
|
|
913
|
+
|
|
914
|
+
if protocols := node.get("appProtocols"):
|
|
915
|
+
description_parts.append(f"Application protocols: {', '.join(protocols)}")
|
|
916
|
+
if endpoints := node.get("applicationEndpoints"):
|
|
917
|
+
endpoint_names = [ep.get("name", "Unknown") for ep in endpoints[:3]] # Limit to first 3
|
|
918
|
+
description_parts.append(f"Application endpoints: {', '.join(endpoint_names)}")
|
|
919
|
+
|
|
920
|
+
description = "\n".join(description_parts)
|
|
921
|
+
|
|
922
|
+
return IntegrationFinding(
|
|
923
|
+
control_labels=[],
|
|
924
|
+
category="Wiz External Attack Surface",
|
|
925
|
+
title=title,
|
|
926
|
+
description=description,
|
|
927
|
+
severity=severity,
|
|
928
|
+
status=IssueStatus.Open,
|
|
929
|
+
asset_identifier=asset_id,
|
|
930
|
+
external_id=node.get("id"),
|
|
931
|
+
first_seen=first_seen,
|
|
932
|
+
date_created=first_seen,
|
|
933
|
+
last_seen=first_seen,
|
|
934
|
+
remediation=f"Review external exposure of {entity_name} and implement proper access controls",
|
|
935
|
+
plugin_name=f"Wiz External Attack Surface - {port_range}",
|
|
936
|
+
vulnerability_type=WizVulnerabilityType.EXTERNAL_ATTACH_SURFACE.value,
|
|
937
|
+
due_date=due_date,
|
|
938
|
+
date_last_updated=format_to_regscale_iso(get_current_datetime()),
|
|
939
|
+
identification="External Attack Surface Assessment",
|
|
940
|
+
)
|
|
941
|
+
|
|
942
|
+
def _parse_excessive_access_finding(self, node: Dict[str, Any]) -> Optional[IntegrationFinding]:
|
|
943
|
+
"""Parse excessive access finding from Wiz.
|
|
944
|
+
|
|
945
|
+
:param Dict[str, Any] node: The Wiz finding node to parse
|
|
946
|
+
:return: The parsed IntegrationFinding or None if parsing fails
|
|
947
|
+
:rtype: Optional[IntegrationFinding]
|
|
948
|
+
"""
|
|
949
|
+
scope = node.get("scope", {})
|
|
950
|
+
asset_id = scope.get("graphEntity", {}).get("id")
|
|
951
|
+
if not asset_id:
|
|
952
|
+
return None
|
|
953
|
+
|
|
954
|
+
first_seen = get_current_datetime() # Excessive access findings may not have firstSeen
|
|
955
|
+
first_seen = format_to_regscale_iso(first_seen)
|
|
956
|
+
severity = self.get_issue_severity(node.get("severity", "Medium"))
|
|
957
|
+
due_date = regscale_models.Issue.get_due_date(severity, self.app.config, "wiz", first_seen)
|
|
958
|
+
|
|
959
|
+
# Use the finding name directly as it's descriptive
|
|
960
|
+
title = node.get("name", "Excessive Access Detected")
|
|
961
|
+
description = node.get("description", "")
|
|
962
|
+
|
|
963
|
+
# Add remediation details
|
|
964
|
+
remediation_parts = [node.get("description", "")]
|
|
965
|
+
if remediation_instructions := node.get("remediationInstructions"):
|
|
966
|
+
remediation_parts.append(f"Remediation: {remediation_instructions}")
|
|
967
|
+
if policy_name := node.get("builtInPolicyRemediationName"):
|
|
968
|
+
remediation_parts.append(f"Built-in policy: {policy_name}")
|
|
969
|
+
|
|
970
|
+
remediation = "\n".join(filter(None, remediation_parts))
|
|
971
|
+
|
|
972
|
+
return IntegrationFinding(
|
|
973
|
+
control_labels=[],
|
|
974
|
+
category="Wiz Excessive Access",
|
|
975
|
+
title=title,
|
|
976
|
+
description=description,
|
|
977
|
+
severity=severity,
|
|
978
|
+
status=self.map_status_to_issue_status(node.get("status", "Open")),
|
|
979
|
+
asset_identifier=asset_id,
|
|
980
|
+
external_id=node.get("id"),
|
|
981
|
+
first_seen=first_seen,
|
|
982
|
+
date_created=first_seen,
|
|
983
|
+
last_seen=first_seen,
|
|
984
|
+
remediation=remediation,
|
|
985
|
+
plugin_name=f"Wiz Excessive Access - {node.get('remediationType', 'Unknown')}",
|
|
986
|
+
vulnerability_type=WizVulnerabilityType.EXCESSIVE_ACCESS_FINDING.value,
|
|
987
|
+
due_date=due_date,
|
|
988
|
+
date_last_updated=format_to_regscale_iso(get_current_datetime()),
|
|
989
|
+
identification="Access Control Assessment",
|
|
990
|
+
)
|
|
991
|
+
|
|
992
|
+
def _parse_end_of_life_finding(self, node: Dict[str, Any]) -> Optional[IntegrationFinding]:
|
|
993
|
+
"""Parse end of life finding from Wiz."""
|
|
994
|
+
asset_id = node.get("vulnerableAsset", {}).get("id")
|
|
995
|
+
if not asset_id:
|
|
996
|
+
return None
|
|
997
|
+
|
|
998
|
+
first_seen = node.get("firstDetectedAt") or get_current_datetime()
|
|
999
|
+
first_seen = format_to_regscale_iso(first_seen)
|
|
1000
|
+
severity = self.get_issue_severity(node.get("severity", "High"))
|
|
1001
|
+
due_date = regscale_models.Issue.get_due_date(severity, self.app.config, "wiz", first_seen)
|
|
1002
|
+
|
|
1003
|
+
# Create meaningful title for end-of-life findings
|
|
1004
|
+
name = node.get("name", "Unknown Technology")
|
|
1005
|
+
title = f"End of Life: {name}"
|
|
1006
|
+
|
|
1007
|
+
# Build description with EOL details
|
|
1008
|
+
description_parts = [node.get("description", "")]
|
|
1009
|
+
if eol_date := node.get("technologyEndOfLifeAt"):
|
|
1010
|
+
description_parts.append(f"End of life date: {eol_date}")
|
|
1011
|
+
if recommended_version := node.get("recommendedVersion"):
|
|
1012
|
+
description_parts.append(f"Recommended version: {recommended_version}")
|
|
1013
|
+
|
|
1014
|
+
description = "\n".join(filter(None, description_parts))
|
|
1015
|
+
|
|
1016
|
+
return IntegrationFinding(
|
|
1017
|
+
control_labels=[],
|
|
1018
|
+
category="Wiz End of Life",
|
|
1019
|
+
title=title,
|
|
1020
|
+
description=description,
|
|
1021
|
+
severity=severity,
|
|
1022
|
+
status=self.map_status_to_issue_status(node.get("status", "Open")),
|
|
1023
|
+
asset_identifier=asset_id,
|
|
1024
|
+
external_id=node.get("id"),
|
|
1025
|
+
first_seen=first_seen,
|
|
1026
|
+
date_created=first_seen,
|
|
1027
|
+
last_seen=format_to_regscale_iso(node.get("lastDetectedAt") or get_current_datetime()),
|
|
1028
|
+
remediation=f"Upgrade {name} to a supported version",
|
|
1029
|
+
plugin_name=f"Wiz End of Life - {name}",
|
|
1030
|
+
vulnerability_type=WizVulnerabilityType.END_OF_LIFE_FINDING.value,
|
|
1031
|
+
due_date=due_date,
|
|
1032
|
+
date_last_updated=format_to_regscale_iso(get_current_datetime()),
|
|
1033
|
+
identification="Technology Lifecycle Assessment",
|
|
1034
|
+
)
|
|
1035
|
+
|
|
1036
|
+
def _parse_generic_finding(
|
|
1037
|
+
self, node: Dict[str, Any], vulnerability_type: WizVulnerabilityType
|
|
1038
|
+
) -> Optional[IntegrationFinding]:
|
|
1039
|
+
"""Generic parsing method for fallback cases."""
|
|
1040
|
+
asset_id = self.get_asset_id_from_node(node, vulnerability_type)
|
|
1041
|
+
if not asset_id:
|
|
1042
|
+
return None
|
|
1043
|
+
|
|
1044
|
+
first_seen = node.get("firstDetectedAt") or node.get("firstSeenAt") or get_current_datetime()
|
|
1045
|
+
first_seen = format_to_regscale_iso(first_seen)
|
|
1046
|
+
severity = self.get_issue_severity(node.get("severity", "Low"))
|
|
1047
|
+
due_date = regscale_models.Issue.get_due_date(severity, self.app.config, "wiz", first_seen)
|
|
1048
|
+
|
|
1049
|
+
status = self.map_status_to_issue_status(node.get("status", "Open"))
|
|
1050
|
+
name: str = node.get("name", "")
|
|
1051
|
+
cve = (
|
|
1052
|
+
name
|
|
1053
|
+
if name and (name.startswith("CVE") or name.startswith("GHSA")) and not node.get("cve")
|
|
1054
|
+
else node.get("cve", name)
|
|
1055
|
+
)
|
|
1056
|
+
|
|
1057
|
+
comments_dict = node.get("commentThread", {})
|
|
1058
|
+
formatted_comments = self.process_comments(comments_dict)
|
|
1059
|
+
|
|
1060
|
+
return IntegrationFinding(
|
|
1061
|
+
control_labels=[],
|
|
1062
|
+
category="Wiz Vulnerability",
|
|
1063
|
+
title=node.get("name", "Unknown vulnerability"),
|
|
1064
|
+
description=node.get("description", ""),
|
|
1065
|
+
severity=severity,
|
|
1066
|
+
status=status,
|
|
1067
|
+
asset_identifier=asset_id,
|
|
1068
|
+
external_id=f"{node.get('sourceRule', {'id': cve}).get('id')}",
|
|
1069
|
+
first_seen=first_seen,
|
|
1070
|
+
date_created=first_seen,
|
|
1071
|
+
last_seen=format_to_regscale_iso(
|
|
1072
|
+
node.get("lastDetectedAt") or node.get("analyzedAt") or get_current_datetime()
|
|
1073
|
+
),
|
|
1074
|
+
remediation=node.get("description", ""),
|
|
1075
|
+
cvss_score=node.get("score"),
|
|
1076
|
+
cve=cve,
|
|
1077
|
+
plugin_name=cve,
|
|
1078
|
+
cvss_v3_base_score=node.get("score"),
|
|
1079
|
+
source_rule_id=node.get("sourceRule", {}).get("id"),
|
|
1080
|
+
vulnerability_type=vulnerability_type.value,
|
|
1081
|
+
due_date=due_date,
|
|
1082
|
+
date_last_updated=format_to_regscale_iso(get_current_datetime()),
|
|
1083
|
+
identification="Vulnerability Assessment",
|
|
1084
|
+
comments=formatted_comments,
|
|
1085
|
+
poam_comments=formatted_comments,
|
|
1086
|
+
)
|
|
1087
|
+
|
|
1088
|
+
def get_compliance_settings(self):
|
|
1089
|
+
"""
|
|
1090
|
+
Get compliance settings for status mapping
|
|
1091
|
+
|
|
1092
|
+
:return: Compliance settings instance
|
|
1093
|
+
:rtype: Optional[ComplianceSettings]
|
|
238
1094
|
"""
|
|
239
|
-
|
|
1095
|
+
if self._compliance_settings is None:
|
|
1096
|
+
try:
|
|
1097
|
+
settings = ComplianceSettings.get_by_current_tenant()
|
|
1098
|
+
self._compliance_settings = next(
|
|
1099
|
+
(comp for comp in settings if comp.title == "Wiz Compliance Setting"), None
|
|
1100
|
+
)
|
|
1101
|
+
if not self._compliance_settings:
|
|
1102
|
+
logger.debug("No Wiz Compliance Setting found, using default status mapping")
|
|
1103
|
+
else:
|
|
1104
|
+
logger.debug("Using Wiz Compliance Setting for status mapping")
|
|
1105
|
+
except Exception as e:
|
|
1106
|
+
logger.debug(f"Error getting Compliance Setting: {e}")
|
|
1107
|
+
self._compliance_settings = None
|
|
1108
|
+
return self._compliance_settings
|
|
1109
|
+
|
|
1110
|
+
def map_status_to_issue_status(self, status: str) -> IssueStatus:
|
|
1111
|
+
"""
|
|
1112
|
+
Maps the Wiz status to issue status using compliance settings if available
|
|
1113
|
+
|
|
240
1114
|
:param str status: Status of the vulnerability
|
|
241
|
-
:
|
|
242
|
-
:rtype:
|
|
1115
|
+
:return: Issue status
|
|
1116
|
+
:rtype: IssueStatus
|
|
1117
|
+
"""
|
|
1118
|
+
compliance_settings = self.get_compliance_settings()
|
|
1119
|
+
|
|
1120
|
+
if compliance_settings:
|
|
1121
|
+
mapped_status = self._get_status_from_compliance_settings(status, compliance_settings)
|
|
1122
|
+
if mapped_status:
|
|
1123
|
+
return mapped_status
|
|
1124
|
+
|
|
1125
|
+
# Fallback to default mapping
|
|
1126
|
+
return self._get_default_issue_status_mapping(status)
|
|
1127
|
+
|
|
1128
|
+
def _get_status_from_compliance_settings(self, status: str, compliance_settings) -> Optional[IssueStatus]:
|
|
1129
|
+
"""
|
|
1130
|
+
Get issue status from compliance settings
|
|
1131
|
+
|
|
1132
|
+
:param str status: Wiz status
|
|
1133
|
+
:param compliance_settings: Compliance settings object
|
|
1134
|
+
:return: Issue status or None if not found
|
|
1135
|
+
:rtype: Optional[IssueStatus]
|
|
1136
|
+
"""
|
|
1137
|
+
try:
|
|
1138
|
+
status_labels = compliance_settings.get_field_labels("status")
|
|
1139
|
+
status_lower = status.lower()
|
|
1140
|
+
|
|
1141
|
+
for label in status_labels:
|
|
1142
|
+
mapped_status = self._match_wiz_status_to_label(status_lower, label)
|
|
1143
|
+
if mapped_status:
|
|
1144
|
+
return mapped_status
|
|
1145
|
+
|
|
1146
|
+
logger.debug(f"No matching compliance setting found for status: {status}")
|
|
1147
|
+
return None
|
|
1148
|
+
|
|
1149
|
+
except Exception as e:
|
|
1150
|
+
logger.debug(f"Error using compliance settings for status mapping: {e}")
|
|
1151
|
+
return None
|
|
1152
|
+
|
|
1153
|
+
def _match_wiz_status_to_label(self, status_lower: str, label: str) -> Optional[IssueStatus]:
|
|
1154
|
+
"""
|
|
1155
|
+
Match a Wiz status to a compliance label and return appropriate IssueStatus
|
|
1156
|
+
|
|
1157
|
+
:param str status_lower: Lowercase Wiz status
|
|
1158
|
+
:param str label: Compliance setting label to check
|
|
1159
|
+
:return: Matched IssueStatus or None
|
|
1160
|
+
:rtype: Optional[IssueStatus]
|
|
1161
|
+
"""
|
|
1162
|
+
label_lower = label.lower()
|
|
1163
|
+
|
|
1164
|
+
# Check for open status mappings
|
|
1165
|
+
if status_lower == "open" and label_lower in ["open", "active", "new"]:
|
|
1166
|
+
return IssueStatus.Open
|
|
1167
|
+
|
|
1168
|
+
# Check for closed status mappings
|
|
1169
|
+
if status_lower in ["resolved", "rejected"] and label_lower in ["closed", "resolved", "rejected", "completed"]:
|
|
1170
|
+
return IssueStatus.Closed
|
|
1171
|
+
|
|
1172
|
+
return None
|
|
1173
|
+
|
|
1174
|
+
def _get_default_issue_status_mapping(self, status: str) -> IssueStatus:
|
|
1175
|
+
"""
|
|
1176
|
+
Get default issue status mapping for a Wiz status
|
|
1177
|
+
|
|
1178
|
+
:param str status: Wiz status
|
|
1179
|
+
:return: Default issue status
|
|
1180
|
+
:rtype: IssueStatus
|
|
243
1181
|
"""
|
|
244
1182
|
status_lower = status.lower()
|
|
1183
|
+
|
|
245
1184
|
if status_lower == "open":
|
|
246
1185
|
return IssueStatus.Open
|
|
247
1186
|
elif status_lower in ["resolved", "rejected"]:
|
|
248
1187
|
return IssueStatus.Closed
|
|
249
|
-
|
|
1188
|
+
else:
|
|
1189
|
+
return IssueStatus.Open
|
|
250
1190
|
|
|
251
1191
|
def fetch_assets(self, *args, **kwargs) -> Iterator[IntegrationAsset]:
|
|
252
1192
|
"""
|
|
253
|
-
Fetches Wiz assets using the GraphQL API
|
|
1193
|
+
Fetches Wiz assets using the GraphQL API with detailed progress tracking
|
|
254
1194
|
|
|
255
1195
|
:yields: Iterator[IntegrationAsset]
|
|
256
1196
|
"""
|
|
1197
|
+
# Create main task for asset fetching process
|
|
1198
|
+
main_task = self.asset_progress.add_task("[cyan]Fetching Wiz assets...", total=3) # Auth, Query, Parse steps
|
|
1199
|
+
|
|
1200
|
+
# Step 1: Authentication
|
|
1201
|
+
auth_task = self.asset_progress.add_task("[yellow]Authenticating to Wiz API...", total=None)
|
|
1202
|
+
|
|
257
1203
|
self.authenticate(kwargs.get("client_id"), kwargs.get("client_secret"))
|
|
1204
|
+
|
|
1205
|
+
self.asset_progress.update(
|
|
1206
|
+
auth_task, description="[green]✓ Successfully authenticated to Wiz API", completed=1, total=1
|
|
1207
|
+
)
|
|
1208
|
+
self.asset_progress.advance(main_task, 1)
|
|
1209
|
+
|
|
1210
|
+
# Step 2: Query preparation and execution
|
|
258
1211
|
wiz_project_id: str = kwargs.get("wiz_project_id", "")
|
|
259
|
-
logger.info("Fetching Wiz assets...")
|
|
260
1212
|
filter_by_override: Dict[str, Any] = kwargs.get("filter_by_override") or WizVariables.wizInventoryFilterBy or {}
|
|
261
1213
|
filter_by = self.get_filter_by(filter_by_override, wiz_project_id)
|
|
262
1214
|
|
|
263
1215
|
variables = self.get_variables()
|
|
264
1216
|
variables["filterBy"].update(filter_by)
|
|
265
1217
|
|
|
1218
|
+
query_task = self.asset_progress.add_task(
|
|
1219
|
+
f"[yellow]Querying Wiz inventory for project {wiz_project_id[:8]}...", total=None
|
|
1220
|
+
)
|
|
1221
|
+
|
|
266
1222
|
nodes = self.fetch_wiz_data_if_needed(
|
|
267
1223
|
query=INVENTORY_QUERY, variables=variables, topic_key="cloudResources", file_path=INVENTORY_FILE_PATH
|
|
268
1224
|
)
|
|
269
|
-
|
|
1225
|
+
|
|
1226
|
+
self.asset_progress.update(
|
|
1227
|
+
query_task, description=f"[green]✓ Fetched {len(nodes)} assets from Wiz inventory", completed=1, total=1
|
|
1228
|
+
)
|
|
1229
|
+
self.asset_progress.advance(main_task, 1)
|
|
1230
|
+
|
|
270
1231
|
self.num_assets_to_process = len(nodes)
|
|
271
1232
|
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
1233
|
+
# Step 3: Parse assets with progress tracking
|
|
1234
|
+
if nodes:
|
|
1235
|
+
parse_task = self.asset_progress.add_task(f"[magenta]Parsing {len(nodes)} Wiz assets...", total=len(nodes))
|
|
1236
|
+
|
|
1237
|
+
parsed_count = 0
|
|
1238
|
+
for i, node in enumerate(nodes, 1):
|
|
1239
|
+
if asset := self.parse_asset(node):
|
|
1240
|
+
parsed_count += 1
|
|
1241
|
+
yield asset
|
|
1242
|
+
|
|
1243
|
+
self.asset_progress.advance(parse_task, 1)
|
|
1244
|
+
|
|
1245
|
+
self.asset_progress.update(
|
|
1246
|
+
parse_task, description=f"[green]✓ Successfully parsed {parsed_count}/{len(nodes)} assets"
|
|
1247
|
+
)
|
|
1248
|
+
|
|
1249
|
+
self.asset_progress.advance(main_task, 1)
|
|
1250
|
+
self.asset_progress.update(
|
|
1251
|
+
main_task, description=f"[green]✓ Completed Wiz asset fetching ({self.num_assets_to_process} assets)"
|
|
1252
|
+
)
|
|
1253
|
+
|
|
1254
|
+
logger.info("Fetched %d Wiz assets.", len(nodes))
|
|
275
1255
|
|
|
276
1256
|
@staticmethod
|
|
277
1257
|
def get_filter_by(filter_by_override: Union[str, Dict[str, Any]], wiz_project_id: str) -> Dict[str, Any]:
|
|
@@ -412,11 +1392,19 @@ class WizVulnerabilityIntegration(ScannerIntegration):
|
|
|
412
1392
|
:rtype: List[Dict[str, Union[int, str]]]
|
|
413
1393
|
"""
|
|
414
1394
|
start_port = wiz_entity_properties.get("portStart")
|
|
415
|
-
if start_port:
|
|
416
|
-
end_port = wiz_entity_properties.get("portEnd"
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
1395
|
+
if start_port is not None and isinstance(start_port, (int, str)):
|
|
1396
|
+
end_port = wiz_entity_properties.get("portEnd", start_port)
|
|
1397
|
+
if not isinstance(end_port, (int, str)):
|
|
1398
|
+
end_port = start_port
|
|
1399
|
+
|
|
1400
|
+
protocol = wiz_entity_properties.get("protocols") or wiz_entity_properties.get("protocol")
|
|
1401
|
+
if not protocol or protocol == "other":
|
|
1402
|
+
protocol = get_base_protocol_from_port(start_port) or "tcp"
|
|
1403
|
+
|
|
1404
|
+
# Ensure protocol is a string
|
|
1405
|
+
if not isinstance(protocol, str):
|
|
1406
|
+
protocol = "tcp"
|
|
1407
|
+
|
|
420
1408
|
return [{"start_port": start_port, "end_port": end_port, "protocol": protocol}]
|
|
421
1409
|
return []
|
|
422
1410
|
|
|
@@ -464,21 +1452,24 @@ class WizVulnerabilityIntegration(ScannerIntegration):
|
|
|
464
1452
|
return software_name_dict.get("software_name") or wiz_entity_properties.get("nativeType")
|
|
465
1453
|
return None
|
|
466
1454
|
|
|
467
|
-
|
|
468
|
-
|
|
1455
|
+
# Pre-compiled regex for better performance (ReDoS-safe pattern)
|
|
1456
|
+
_PACKAGE_PATTERN = re.compile(r"([^()]+) \(([^()]+)\)")
|
|
1457
|
+
|
|
1458
|
+
@classmethod
|
|
1459
|
+
def create_name_version_dict(cls, package_list: List[str]) -> List[Dict[str, str]]:
|
|
469
1460
|
"""
|
|
470
|
-
Creates a
|
|
1461
|
+
Creates a list of dictionaries with package names and versions from formatted strings.
|
|
471
1462
|
|
|
472
|
-
:param List[str] package_list:
|
|
473
|
-
:return
|
|
1463
|
+
:param List[str] package_list: List of strings in format "name (version)"
|
|
1464
|
+
:return: List of dictionaries with name and version keys
|
|
1465
|
+
:rtype: List[Dict[str, str]]
|
|
474
1466
|
"""
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
match
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
return software_inventory
|
|
1467
|
+
# Use list comprehension with pre-compiled regex for better performance
|
|
1468
|
+
return [
|
|
1469
|
+
{"name": match.group(1).strip(), "version": match.group(2).strip()}
|
|
1470
|
+
for package in package_list
|
|
1471
|
+
if (match := cls._PACKAGE_PATTERN.match(package))
|
|
1472
|
+
]
|
|
482
1473
|
|
|
483
1474
|
@staticmethod
|
|
484
1475
|
def map_wiz_status(wiz_status: Optional[str]) -> regscale_models.AssetStatus:
|
|
@@ -514,7 +1505,7 @@ class WizVulnerabilityIntegration(ScannerIntegration):
|
|
|
514
1505
|
self.authenticate(WizVariables.wizClientId, WizVariables.wizClientSecret)
|
|
515
1506
|
|
|
516
1507
|
if not self.wiz_token:
|
|
517
|
-
|
|
1508
|
+
error_and_exit("Wiz token is not set. Please authenticate first.")
|
|
518
1509
|
|
|
519
1510
|
nodes = fetch_wiz_data(
|
|
520
1511
|
query=query,
|
|
@@ -527,3 +1518,224 @@ class WizVulnerabilityIntegration(ScannerIntegration):
|
|
|
527
1518
|
json.dump(nodes, file)
|
|
528
1519
|
|
|
529
1520
|
return nodes
|
|
1521
|
+
|
|
1522
|
+
def get_asset_by_identifier(self, identifier: str) -> Optional[regscale_models.Asset]:
|
|
1523
|
+
"""
|
|
1524
|
+
Enhanced asset lookup with diagnostic logging for Wiz integration
|
|
1525
|
+
|
|
1526
|
+
:param str identifier: The identifier of the asset
|
|
1527
|
+
:return: The asset
|
|
1528
|
+
:rtype: Optional[regscale_models.Asset]
|
|
1529
|
+
"""
|
|
1530
|
+
# First try to get asset using parent method
|
|
1531
|
+
asset = self.asset_map_by_identifier.get(identifier)
|
|
1532
|
+
|
|
1533
|
+
# If asset not found and we haven't already alerted for this asset
|
|
1534
|
+
if not asset and identifier not in self.alerted_assets:
|
|
1535
|
+
self.alerted_assets.add(identifier)
|
|
1536
|
+
|
|
1537
|
+
# Add debug logging to confirm this method is being called
|
|
1538
|
+
logger.debug("WizVulnerabilityIntegration.get_asset_by_identifier called for %s", identifier)
|
|
1539
|
+
|
|
1540
|
+
# Try to provide more diagnostic information
|
|
1541
|
+
self._log_missing_asset_diagnostics(identifier)
|
|
1542
|
+
|
|
1543
|
+
# Still log the original error for consistency
|
|
1544
|
+
self.log_error("1. Asset not found for identifier %s", identifier)
|
|
1545
|
+
|
|
1546
|
+
return asset
|
|
1547
|
+
|
|
1548
|
+
def _log_missing_asset_diagnostics(self, identifier: str) -> None:
|
|
1549
|
+
"""
|
|
1550
|
+
Log diagnostic information about missing assets to help identify patterns
|
|
1551
|
+
|
|
1552
|
+
:param str identifier: The missing asset identifier
|
|
1553
|
+
:rtype: None
|
|
1554
|
+
"""
|
|
1555
|
+
logger.info("🔍 Analyzing missing asset: %s", identifier)
|
|
1556
|
+
|
|
1557
|
+
# Define inventory files to search (constant moved up for clarity)
|
|
1558
|
+
inventory_files = (
|
|
1559
|
+
INVENTORY_FILE_PATH,
|
|
1560
|
+
SECRET_FINDINGS_FILE_PATH,
|
|
1561
|
+
NETWORK_EXPOSURE_FILE_PATH,
|
|
1562
|
+
END_OF_LIFE_FILE_PATH,
|
|
1563
|
+
EXTERNAL_ATTACK_SURFACE_FILE_PATH,
|
|
1564
|
+
)
|
|
1565
|
+
|
|
1566
|
+
# Search for asset information across files
|
|
1567
|
+
asset_info, source_file = self._search_asset_in_files(identifier, inventory_files)
|
|
1568
|
+
|
|
1569
|
+
# Log results based on what was found
|
|
1570
|
+
if asset_info:
|
|
1571
|
+
self._log_found_asset_details(identifier, asset_info, source_file)
|
|
1572
|
+
else:
|
|
1573
|
+
self._log_asset_not_found(identifier)
|
|
1574
|
+
|
|
1575
|
+
def _search_asset_in_files(self, identifier: str, file_paths: tuple) -> tuple[Optional[Dict], Optional[str]]:
|
|
1576
|
+
"""
|
|
1577
|
+
Search for asset information across multiple JSON files
|
|
1578
|
+
|
|
1579
|
+
:param str identifier: Asset identifier to search for
|
|
1580
|
+
:param tuple file_paths: Tuple of file paths to search
|
|
1581
|
+
:return: Tuple of (asset_info, source_file) or (None, None)
|
|
1582
|
+
:rtype: tuple[Optional[Dict], Optional[str]]
|
|
1583
|
+
"""
|
|
1584
|
+
for file_path in file_paths:
|
|
1585
|
+
if not os.path.exists(file_path):
|
|
1586
|
+
continue
|
|
1587
|
+
|
|
1588
|
+
try:
|
|
1589
|
+
asset_info = self._search_single_file(identifier, file_path)
|
|
1590
|
+
if asset_info:
|
|
1591
|
+
return asset_info, file_path
|
|
1592
|
+
except (json.JSONDecodeError, IOError) as e:
|
|
1593
|
+
logger.debug("Error reading %s: %s", file_path, e)
|
|
1594
|
+
continue
|
|
1595
|
+
|
|
1596
|
+
return None, None
|
|
1597
|
+
|
|
1598
|
+
def _search_single_file(self, identifier: str, file_path: str) -> Optional[Dict]:
|
|
1599
|
+
"""
|
|
1600
|
+
Search for asset in a single JSON file
|
|
1601
|
+
|
|
1602
|
+
:param str identifier: Asset identifier to search for
|
|
1603
|
+
:param str file_path: Path to JSON file
|
|
1604
|
+
:return: Asset data if found, None otherwise
|
|
1605
|
+
:rtype: Optional[Dict]
|
|
1606
|
+
"""
|
|
1607
|
+
logger.debug("Searching for asset %s in %s", identifier, file_path)
|
|
1608
|
+
|
|
1609
|
+
with open(file_path, "r", encoding="utf-8") as f:
|
|
1610
|
+
data = json.load(f)
|
|
1611
|
+
|
|
1612
|
+
if not isinstance(data, list):
|
|
1613
|
+
return None
|
|
1614
|
+
|
|
1615
|
+
# Use generator expression for memory efficiency
|
|
1616
|
+
return next((item for item in data if self._find_asset_in_node(item, identifier)), None)
|
|
1617
|
+
|
|
1618
|
+
def _log_found_asset_details(self, identifier: str, asset_info: Dict, source_file: str) -> None:
|
|
1619
|
+
"""
|
|
1620
|
+
Log details for found asset with actionable recommendations
|
|
1621
|
+
|
|
1622
|
+
:param str identifier: Asset identifier
|
|
1623
|
+
:param Dict asset_info: Asset information from file
|
|
1624
|
+
:param str source_file: Source file where asset was found
|
|
1625
|
+
:rtype: None
|
|
1626
|
+
"""
|
|
1627
|
+
asset_type = self._extract_asset_type_from_node(asset_info)
|
|
1628
|
+
asset_name = self._extract_asset_name_from_node(asset_info)
|
|
1629
|
+
|
|
1630
|
+
logger.warning(
|
|
1631
|
+
"🚨 MISSING ASSET FOUND: ID=%s, Type=%s, Name='%s', Source=%s\n"
|
|
1632
|
+
" 💡 SOLUTION: Add '%s' to RECOMMENDED_WIZ_INVENTORY_TYPES in constants.py\n"
|
|
1633
|
+
" 📍 Then re-run: regscale wiz inventory -id <plan_id> -p <project_id>",
|
|
1634
|
+
identifier,
|
|
1635
|
+
asset_type,
|
|
1636
|
+
asset_name,
|
|
1637
|
+
source_file,
|
|
1638
|
+
asset_type,
|
|
1639
|
+
)
|
|
1640
|
+
|
|
1641
|
+
def _log_asset_not_found(self, identifier: str) -> None:
|
|
1642
|
+
"""
|
|
1643
|
+
Log message when asset is not found in any cached files
|
|
1644
|
+
|
|
1645
|
+
:param str identifier: Asset identifier
|
|
1646
|
+
:rtype: None
|
|
1647
|
+
"""
|
|
1648
|
+
logger.warning(
|
|
1649
|
+
"❓ MISSING ASSET ANALYSIS: ID=%s\n"
|
|
1650
|
+
" Asset not found in any cached data files.\n"
|
|
1651
|
+
" This may indicate:\n"
|
|
1652
|
+
" - Asset from different Wiz project\n"
|
|
1653
|
+
" - Asset type not included in current queries\n"
|
|
1654
|
+
" - Asset deleted from Wiz but finding still exists",
|
|
1655
|
+
identifier,
|
|
1656
|
+
)
|
|
1657
|
+
|
|
1658
|
+
# Class-level constants for better performance (avoid recreating lists)
|
|
1659
|
+
_ASSET_ID_PATHS = (
|
|
1660
|
+
("id",),
|
|
1661
|
+
("resource", "id"),
|
|
1662
|
+
("exposedEntity", "id"),
|
|
1663
|
+
("vulnerableAsset", "id"),
|
|
1664
|
+
("scope", "graphEntity", "id"),
|
|
1665
|
+
("entitySnapshot", "id"),
|
|
1666
|
+
)
|
|
1667
|
+
|
|
1668
|
+
_ASSET_TYPE_PATHS = (
|
|
1669
|
+
("type",),
|
|
1670
|
+
("resource", "type"),
|
|
1671
|
+
("exposedEntity", "type"),
|
|
1672
|
+
("vulnerableAsset", "type"),
|
|
1673
|
+
("scope", "graphEntity", "type"),
|
|
1674
|
+
("entitySnapshot", "type"),
|
|
1675
|
+
)
|
|
1676
|
+
|
|
1677
|
+
_ASSET_NAME_PATHS = (
|
|
1678
|
+
("name",),
|
|
1679
|
+
("resource", "name"),
|
|
1680
|
+
("exposedEntity", "name"),
|
|
1681
|
+
("vulnerableAsset", "name"),
|
|
1682
|
+
("scope", "graphEntity", "name"),
|
|
1683
|
+
("entitySnapshot", "name"),
|
|
1684
|
+
)
|
|
1685
|
+
|
|
1686
|
+
def _find_asset_in_node(self, node: Dict[str, Any], identifier: str) -> bool:
|
|
1687
|
+
"""
|
|
1688
|
+
Check if a node contains the specified asset identifier.
|
|
1689
|
+
|
|
1690
|
+
:param Dict[str, Any] node: Node to search in
|
|
1691
|
+
:param str identifier: Asset identifier to find
|
|
1692
|
+
:return: True if identifier found, False otherwise
|
|
1693
|
+
:rtype: bool
|
|
1694
|
+
"""
|
|
1695
|
+
return any(self._get_nested_value(node, path) == identifier for path in self._ASSET_ID_PATHS)
|
|
1696
|
+
|
|
1697
|
+
def _extract_asset_type_from_node(self, node: Dict[str, Any]) -> str:
|
|
1698
|
+
"""
|
|
1699
|
+
Extract asset type from a node for diagnostic purposes.
|
|
1700
|
+
|
|
1701
|
+
:param Dict[str, Any] node: Node to extract type from
|
|
1702
|
+
:return: Asset type or "Unknown Type"
|
|
1703
|
+
:rtype: str
|
|
1704
|
+
"""
|
|
1705
|
+
for path in self._ASSET_TYPE_PATHS:
|
|
1706
|
+
value = self._get_nested_value(node, path)
|
|
1707
|
+
if isinstance(value, str):
|
|
1708
|
+
return value
|
|
1709
|
+
return "Unknown Type"
|
|
1710
|
+
|
|
1711
|
+
def _extract_asset_name_from_node(self, node: Dict[str, Any]) -> str:
|
|
1712
|
+
"""
|
|
1713
|
+
Extract asset name from a node for diagnostic purposes.
|
|
1714
|
+
|
|
1715
|
+
:param Dict[str, Any] node: Node to extract name from
|
|
1716
|
+
:return: Asset name or "Unknown Name"
|
|
1717
|
+
:rtype: str
|
|
1718
|
+
"""
|
|
1719
|
+
for path in self._ASSET_NAME_PATHS:
|
|
1720
|
+
value = self._get_nested_value(node, path)
|
|
1721
|
+
if isinstance(value, str):
|
|
1722
|
+
return value
|
|
1723
|
+
return "Unknown Name"
|
|
1724
|
+
|
|
1725
|
+
def _get_nested_value(self, data: Dict[str, Any], path: tuple) -> Any:
|
|
1726
|
+
"""
|
|
1727
|
+
Get a nested value from a dictionary using a path tuple.
|
|
1728
|
+
|
|
1729
|
+
:param Dict[str, Any] data: Dictionary to traverse
|
|
1730
|
+
:param tuple path: Tuple of keys representing the path
|
|
1731
|
+
:return: Value at path or None if not found
|
|
1732
|
+
:rtype: Any
|
|
1733
|
+
"""
|
|
1734
|
+
current = data
|
|
1735
|
+
for key in path:
|
|
1736
|
+
if not isinstance(current, dict):
|
|
1737
|
+
return None
|
|
1738
|
+
current = current.get(key)
|
|
1739
|
+
if current is None:
|
|
1740
|
+
return None
|
|
1741
|
+
return current
|