regscale-cli 6.20.9.1__py3-none-any.whl → 6.21.0.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.

Files changed (56) hide show
  1. regscale/_version.py +1 -1
  2. regscale/core/app/application.py +12 -5
  3. regscale/core/app/internal/set_permissions.py +58 -27
  4. regscale/integrations/commercial/defender.py +9 -0
  5. regscale/integrations/commercial/nessus/scanner.py +2 -0
  6. regscale/integrations/commercial/sonarcloud.py +35 -36
  7. regscale/integrations/commercial/synqly/ticketing.py +51 -0
  8. regscale/integrations/commercial/wizv2/async_client.py +325 -0
  9. regscale/integrations/commercial/wizv2/constants.py +756 -0
  10. regscale/integrations/commercial/wizv2/scanner.py +1301 -89
  11. regscale/integrations/commercial/wizv2/utils.py +280 -36
  12. regscale/integrations/commercial/wizv2/variables.py +2 -10
  13. regscale/integrations/integration_override.py +15 -6
  14. regscale/integrations/scanner_integration.py +221 -37
  15. regscale/integrations/variables.py +1 -0
  16. regscale/models/integration_models/amazon_models/inspector_scan.py +32 -57
  17. regscale/models/integration_models/aqua.py +92 -78
  18. regscale/models/integration_models/cisa_kev_data.json +47 -4
  19. regscale/models/integration_models/defenderimport.py +64 -59
  20. regscale/models/integration_models/ecr_models/ecr.py +100 -147
  21. regscale/models/integration_models/flat_file_importer/__init__.py +52 -38
  22. regscale/models/integration_models/ibm.py +29 -47
  23. regscale/models/integration_models/nexpose.py +156 -68
  24. regscale/models/integration_models/prisma.py +46 -66
  25. regscale/models/integration_models/qualys.py +99 -93
  26. regscale/models/integration_models/snyk.py +229 -158
  27. regscale/models/integration_models/synqly_models/capabilities.json +1 -1
  28. regscale/models/integration_models/veracode.py +15 -20
  29. regscale/models/integration_models/xray.py +276 -82
  30. regscale/models/regscale_models/__init__.py +13 -0
  31. regscale/models/regscale_models/classification.py +23 -0
  32. regscale/models/regscale_models/control_implementation.py +14 -12
  33. regscale/models/regscale_models/cryptography.py +56 -0
  34. regscale/models/regscale_models/deviation.py +4 -4
  35. regscale/models/regscale_models/group.py +3 -2
  36. regscale/models/regscale_models/interconnection.py +1 -1
  37. regscale/models/regscale_models/issue.py +140 -41
  38. regscale/models/regscale_models/milestone.py +40 -0
  39. regscale/models/regscale_models/property.py +0 -1
  40. regscale/models/regscale_models/rbac.py +22 -0
  41. regscale/models/regscale_models/regscale_model.py +29 -18
  42. regscale/models/regscale_models/team.py +55 -0
  43. {regscale_cli-6.20.9.1.dist-info → regscale_cli-6.21.0.0.dist-info}/METADATA +1 -1
  44. {regscale_cli-6.20.9.1.dist-info → regscale_cli-6.21.0.0.dist-info}/RECORD +56 -49
  45. tests/fixtures/test_fixture.py +58 -2
  46. tests/regscale/core/test_app.py +5 -3
  47. tests/regscale/integrations/test_integration_mapping.py +522 -40
  48. tests/regscale/integrations/test_issue_due_date.py +1 -1
  49. tests/regscale/integrations/test_property_and_milestone_creation.py +684 -0
  50. tests/regscale/integrations/test_update_finding_dates.py +336 -0
  51. tests/regscale/models/test_asset.py +406 -50
  52. tests/regscale/models/test_report.py +105 -29
  53. {regscale_cli-6.20.9.1.dist-info → regscale_cli-6.21.0.0.dist-info}/LICENSE +0 -0
  54. {regscale_cli-6.20.9.1.dist-info → regscale_cli-6.21.0.0.dist-info}/WHEEL +0 -0
  55. {regscale_cli-6.20.9.1.dist-info → regscale_cli-6.21.0.0.dist-info}/entry_points.txt +0 -0
  56. {regscale_cli-6.20.9.1.dist-info → regscale_cli-6.21.0.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 the GraphQL API
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 = kwargs.get("wiz_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
- raise ValueError("Wiz project ID is required")
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
- logger.info("Fetching Wiz findings...")
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
- for wiz_vulnerability_type in self.get_query_types(project_id=project_id):
113
- logger.info("Fetching Wiz findings for %s...", wiz_vulnerability_type["type"])
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
- yield from self.parse_findings(nodes, wiz_vulnerability_type["type"])
125
- logger.info("Finished fetching Wiz findings.")
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
- for node in nodes:
139
- if finding := self.parse_finding(node, vulnerability_type):
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
- asset_id = node.get(self.asset_lookup, {}).get("id")
186
- if not asset_id:
187
- return None
188
-
189
- first_seen = node.get("firstDetectedAt") or node.get("firstSeenAt") or get_current_datetime()
190
- first_seen = format_to_regscale_iso(first_seen)
191
- severity = self.get_issue_severity(node.get("severity", "Low"))
192
- due_date = regscale_models.Issue.get_due_date(severity, self.app.config, "wiz", first_seen)
193
-
194
- status = self.map_status_to_issue_status(node.get("status", "Open"))
195
- name: str = node.get("name", "")
196
- cve = (
197
- name
198
- if name and (name.startswith("CVE") or name.startswith("GHSA")) and not node.get("cve")
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
- @staticmethod
237
- def map_status_to_issue_status(status: str) -> IssueStatus:
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
- Maps the Wiz status to issue status
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
- :returns: Issue status
242
- :rtype: str
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
- return IssueStatus.Open
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
- logger.info("Fetched %d Wiz assets.", len(nodes))
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
- for node in nodes:
273
- if asset := self.parse_asset(node):
274
- yield asset
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") or start_port
417
- protocol = wiz_entity_properties.get("protocols", wiz_entity_properties.get("protocol"))
418
- if protocol in ["other", None]:
419
- protocol = get_base_protocol_from_port(start_port)
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
- @staticmethod
468
- def create_name_version_dict(package_list: List[str]) -> List[Dict[str, str]]:
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 dictionary of package names and their versions from a list of strings in the format "name (version)".
1461
+ Creates a list of dictionaries with package names and versions from formatted strings.
471
1462
 
472
- :param List[str] package_list: A list of strings containing package names and versions.
473
- :return Dict[str, str]: A dictionary with package names as keys and versions as values.
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
- software_inventory = []
476
- for package in package_list:
477
- match = re.match(r"(.+?) \((.+?)\)", package)
478
- if match:
479
- name, version = match.groups()
480
- software_inventory.append({"name": name, "version": version})
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
- raise ValueError("Wiz token is not set. Please authenticate first.")
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