regscale-cli 6.23.0.0__py3-none-any.whl → 6.24.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 (44) hide show
  1. regscale/_version.py +1 -1
  2. regscale/core/app/application.py +2 -0
  3. regscale/integrations/commercial/__init__.py +1 -0
  4. regscale/integrations/commercial/sarif/sarif_converter.py +1 -1
  5. regscale/integrations/commercial/wizv2/click.py +109 -2
  6. regscale/integrations/commercial/wizv2/compliance_report.py +1485 -0
  7. regscale/integrations/commercial/wizv2/constants.py +72 -2
  8. regscale/integrations/commercial/wizv2/data_fetcher.py +61 -0
  9. regscale/integrations/commercial/wizv2/file_cleanup.py +104 -0
  10. regscale/integrations/commercial/wizv2/issue.py +775 -27
  11. regscale/integrations/commercial/wizv2/policy_compliance.py +599 -181
  12. regscale/integrations/commercial/wizv2/reports.py +243 -0
  13. regscale/integrations/commercial/wizv2/scanner.py +668 -245
  14. regscale/integrations/compliance_integration.py +304 -51
  15. regscale/integrations/due_date_handler.py +210 -0
  16. regscale/integrations/public/cci_importer.py +444 -0
  17. regscale/integrations/scanner_integration.py +718 -153
  18. regscale/models/integration_models/CCI_List.xml +1 -0
  19. regscale/models/integration_models/cisa_kev_data.json +61 -3
  20. regscale/models/integration_models/synqly_models/capabilities.json +1 -1
  21. regscale/models/integration_models/synqly_models/connectors/vulnerabilities.py +3 -3
  22. regscale/models/regscale_models/form_field_value.py +1 -1
  23. regscale/models/regscale_models/milestone.py +1 -0
  24. regscale/models/regscale_models/regscale_model.py +225 -60
  25. regscale/models/regscale_models/security_plan.py +3 -2
  26. regscale/regscale.py +7 -0
  27. {regscale_cli-6.23.0.0.dist-info → regscale_cli-6.24.0.0.dist-info}/METADATA +9 -9
  28. {regscale_cli-6.23.0.0.dist-info → regscale_cli-6.24.0.0.dist-info}/RECORD +44 -27
  29. tests/fixtures/test_fixture.py +13 -8
  30. tests/regscale/integrations/public/__init__.py +0 -0
  31. tests/regscale/integrations/public/test_alienvault.py +220 -0
  32. tests/regscale/integrations/public/test_cci.py +458 -0
  33. tests/regscale/integrations/public/test_cisa.py +1021 -0
  34. tests/regscale/integrations/public/test_emass.py +518 -0
  35. tests/regscale/integrations/public/test_fedramp.py +851 -0
  36. tests/regscale/integrations/public/test_fedramp_cis_crm.py +3661 -0
  37. tests/regscale/integrations/public/test_file_uploads.py +506 -0
  38. tests/regscale/integrations/public/test_oscal.py +453 -0
  39. tests/regscale/models/test_form_field_value_integration.py +304 -0
  40. tests/regscale/models/test_module_integration.py +582 -0
  41. {regscale_cli-6.23.0.0.dist-info → regscale_cli-6.24.0.0.dist-info}/LICENSE +0 -0
  42. {regscale_cli-6.23.0.0.dist-info → regscale_cli-6.24.0.0.dist-info}/WHEEL +0 -0
  43. {regscale_cli-6.23.0.0.dist-info → regscale_cli-6.24.0.0.dist-info}/entry_points.txt +0 -0
  44. {regscale_cli-6.23.0.0.dist-info → regscale_cli-6.24.0.0.dist-info}/top_level.txt +0 -0
@@ -37,7 +37,6 @@ from regscale.integrations.scanner_integration import (
37
37
  ScannerIntegrationType,
38
38
  IntegrationAsset,
39
39
  IntegrationFinding,
40
- issue_due_date,
41
40
  )
42
41
  from regscale.integrations.variables import ScannerVariables
43
42
  from regscale.models import regscale_models
@@ -66,12 +65,18 @@ SAFE_CONTROL_ID_RE = re.compile( # NOSONAR
66
65
  class WizComplianceItem(ComplianceItem):
67
66
  """Wiz implementation of ComplianceItem."""
68
67
 
69
- def __init__(self, raw_data: Dict[str, Any], integration: Optional["WizPolicyComplianceIntegration"] = None):
68
+ def __init__(
69
+ self,
70
+ raw_data: Dict[str, Any],
71
+ integration: Optional["WizPolicyComplianceIntegration"] = None,
72
+ specific_control_id: Optional[str] = None,
73
+ ):
70
74
  """
71
75
  Initialize WizComplianceItem from raw GraphQL response.
72
76
 
73
77
  :param Dict[str, Any] raw_data: Raw policy assessment data from Wiz
74
78
  :param Optional['WizPolicyComplianceIntegration'] integration: Integration instance for framework mapping
79
+ :param Optional[str] specific_control_id: Specific control ID to use (for multi-control policies)
75
80
  """
76
81
  self.id = raw_data.get("id", "")
77
82
  self.result = raw_data.get("result", "")
@@ -79,6 +84,7 @@ class WizComplianceItem(ComplianceItem):
79
84
  self.resource = raw_data.get("resource", {})
80
85
  self.output = raw_data.get("output", {})
81
86
  self._integration = integration
87
+ self._specific_control_id = specific_control_id
82
88
 
83
89
  def _get_filtered_subcategories(self) -> List[Dict[str, Any]]:
84
90
  """
@@ -110,15 +116,24 @@ class WizComplianceItem(ComplianceItem):
110
116
  """Human-readable name of the resource."""
111
117
  return self.resource.get("name", "")
112
118
 
119
+ @property
120
+ def provider_unique_id(self) -> str:
121
+ """Provider unique ID (e.g., ARN for AWS resources) for meaningful asset identification."""
122
+ return self.resource.get("providerUniqueId", "")
123
+
113
124
  @property
114
125
  def control_id(self) -> str:
115
126
  """Control identifier (e.g., AC-3, SI-2)."""
127
+ # If a specific control ID was provided (for multi-control policies), use it
128
+ if self._specific_control_id:
129
+ return self._specific_control_id
130
+
116
131
  if not self.policy:
117
132
  return ""
118
133
 
119
134
  subcategories = self._get_filtered_subcategories()
120
135
  if subcategories:
121
- return subcategories[0].get("externalId", "")
136
+ return subcategories[0].get("externalId", "").strip()
122
137
  return ""
123
138
 
124
139
  @property
@@ -285,6 +300,10 @@ class WizPolicyComplianceIntegration(ComplianceIntegration):
285
300
  self._map_framework_id_to_name(framework_id),
286
301
  )
287
302
 
303
+ # Configure strict control failure threshold for Wiz project-scoped assessments
304
+ # Since Wiz filters to project resources, use 0% failure tolerance
305
+ self.control_failure_threshold = 0.0
306
+
288
307
  def fetch_compliance_data(self) -> List[Any]:
289
308
  """
290
309
  Fetch compliance data from Wiz GraphQL API and filter to framework-specific
@@ -324,7 +343,10 @@ class WizPolicyComplianceIntegration(ComplianceIntegration):
324
343
 
325
344
  def _filter_assessments_to_existing_assets(self, assessments: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
326
345
  """
327
- Filter assessments to only include items with existing assets and control IDs.
346
+ Filter assessments to include items with control IDs and existing assets.
347
+
348
+ For compliance reporting, PASS controls are always included even without assets
349
+ to ensure complete compliance documentation.
328
350
 
329
351
  :param assessments: List of raw assessments from Wiz
330
352
  :return: Filtered list of assessments
@@ -343,26 +365,187 @@ class WizPolicyComplianceIntegration(ComplianceIntegration):
343
365
  skipped_no_control += 1
344
366
  continue
345
367
 
346
- # Skip if asset doesn't exist in RegScale (use cached lookup)
368
+ # For PASS controls, allow through even without existing assets for compliance documentation
369
+ is_pass = temp_item.compliance_result in self.PASS_STATUSES
370
+
371
+ # Skip if asset doesn't exist in RegScale UNLESS it's a PASS control
347
372
  if temp_item.resource_id not in assets_exist:
348
- skipped_no_asset += 1
349
- continue
373
+ if not is_pass:
374
+ skipped_no_asset += 1
375
+ continue
376
+ # PASS control without asset - allow through for compliance documentation
350
377
 
351
378
  filtered_assessments.append(assessment)
352
379
  logger.debug(f"Skipped {skipped_no_control} assessments with no control ID for framework.")
353
- logger.debug(f"Skipped {skipped_no_asset} assessments with no existing asset in RegScale.")
380
+ logger.debug(
381
+ f"Skipped {skipped_no_asset} assessments with no existing asset in RegScale (PASS controls allowed)."
382
+ )
354
383
  return filtered_assessments
355
384
 
356
385
  def create_compliance_item(self, raw_data: Any) -> ComplianceItem:
357
386
  """
358
387
  Create a ComplianceItem from raw compliance data.
359
388
 
389
+ Note: This creates a single item for the first control ID only.
390
+ Use create_all_compliance_items() to get all control mappings.
391
+
360
392
  :param Any raw_data: Raw compliance data from Wiz
361
393
  :return: ComplianceItem instance
362
394
  :rtype: ComplianceItem
363
395
  """
364
396
  return WizComplianceItem(raw_data, self)
365
397
 
398
+ def create_all_compliance_items(self, raw_data: Any) -> List[ComplianceItem]:
399
+ """
400
+ Create all ComplianceItems from raw compliance data.
401
+
402
+ This handles Wiz policies that map to multiple controls by creating
403
+ a separate ComplianceItem for each control ID.
404
+
405
+ :param Any raw_data: Raw compliance data from Wiz
406
+ :return: List of ComplianceItem instances (one per control)
407
+ :rtype: List[ComplianceItem]
408
+ """
409
+ # First get all control IDs this policy maps to
410
+ temp_item = WizComplianceItem(raw_data, self)
411
+ all_control_ids = self._get_all_control_ids_for_compliance_item(temp_item)
412
+
413
+ if not all_control_ids:
414
+ # No control IDs found, return single item with default behavior
415
+ return [temp_item]
416
+
417
+ # Create one compliance item per control ID
418
+ compliance_items = []
419
+ for control_id in all_control_ids:
420
+ compliance_items.append(WizComplianceItem(raw_data, self, specific_control_id=control_id))
421
+
422
+ return compliance_items
423
+
424
+ def process_compliance_data(self) -> None:
425
+ """
426
+ Override base class to handle multi-control Wiz policies.
427
+
428
+ Creates separate compliance items for each control ID that a policy maps to.
429
+ """
430
+ logger.info("Processing compliance data with multi-control support...")
431
+
432
+ # Reset state to avoid double counting on repeated calls
433
+ self._reset_compliance_state()
434
+
435
+ # Build allowed control IDs from plan/catalog controls to restrict scope
436
+ allowed_controls_normalized = self._build_allowed_controls_set()
437
+
438
+ # Fetch and process raw compliance data
439
+ raw_compliance_data = self.fetch_compliance_data()
440
+ total_policies_processed, total_compliance_items_created = self._process_raw_compliance_data(
441
+ raw_compliance_data, allowed_controls_normalized
442
+ )
443
+
444
+ # Perform control-level categorization based on aggregated results
445
+ self._categorize_controls_by_aggregation()
446
+
447
+ self._log_processing_summary(total_policies_processed, total_compliance_items_created)
448
+
449
+ def _reset_compliance_state(self) -> None:
450
+ """Reset state to avoid double counting on repeated calls."""
451
+ self.all_compliance_items = []
452
+ self.failed_compliance_items = []
453
+ self.passing_controls = {}
454
+ self.failing_controls = {}
455
+ self.asset_compliance_map.clear()
456
+
457
+ def _build_allowed_controls_set(self) -> set[str]:
458
+ """Build allowed control IDs from plan/catalog controls to restrict scope."""
459
+ allowed_controls_normalized: set[str] = set()
460
+ try:
461
+ controls = self._get_controls()
462
+ for ctl in controls:
463
+ cid = (ctl.get("controlId") or "").strip()
464
+ if not cid:
465
+ continue
466
+ base, sub = self._normalize_control_id(cid)
467
+ normalized = f"{base}({sub})" if sub else base
468
+ allowed_controls_normalized.add(normalized)
469
+ except Exception:
470
+ # If controls cannot be loaded, proceed without additional filtering
471
+ allowed_controls_normalized = set()
472
+ return allowed_controls_normalized
473
+
474
+ def _process_raw_compliance_data(
475
+ self, raw_compliance_data: List[Any], allowed_controls_normalized: set[str]
476
+ ) -> tuple[int, int]:
477
+ """Process raw compliance data and return counts."""
478
+ total_policies_processed = 0
479
+ total_compliance_items_created = 0
480
+
481
+ for raw_item in raw_compliance_data:
482
+ try:
483
+ total_policies_processed += 1
484
+ compliance_items_for_policy = self.create_all_compliance_items(raw_item)
485
+
486
+ items_created_for_policy = self._process_compliance_items_for_policy(
487
+ compliance_items_for_policy, allowed_controls_normalized
488
+ )
489
+ total_compliance_items_created += items_created_for_policy
490
+
491
+ except Exception as e:
492
+ logger.error(f"Error processing compliance item: {e}")
493
+ continue
494
+
495
+ return total_policies_processed, total_compliance_items_created
496
+
497
+ def _process_compliance_items_for_policy(
498
+ self, compliance_items_for_policy: List[Any], allowed_controls_normalized: set[str]
499
+ ) -> int:
500
+ """Process compliance items for a single policy and return count of items created."""
501
+ items_created = 0
502
+
503
+ for compliance_item in compliance_items_for_policy:
504
+ if not self._is_valid_compliance_item(compliance_item):
505
+ continue
506
+
507
+ if not self._is_control_in_allowed_set(compliance_item, allowed_controls_normalized):
508
+ continue
509
+
510
+ self._add_compliance_item_to_collections(compliance_item)
511
+ items_created += 1
512
+
513
+ return items_created
514
+
515
+ def _is_valid_compliance_item(self, compliance_item: Any) -> bool:
516
+ """Check if compliance item has required control_id and resource_id."""
517
+ return getattr(compliance_item, "control_id", "") and getattr(compliance_item, "resource_id", "")
518
+
519
+ def _is_control_in_allowed_set(self, compliance_item: Any, allowed_controls_normalized: set[str]) -> bool:
520
+ """Check if compliance item's control is in allowed set."""
521
+ if not allowed_controls_normalized:
522
+ return True
523
+
524
+ base, sub = self._normalize_control_id(getattr(compliance_item, "control_id", ""))
525
+ norm_item = f"{base}({sub})" if sub else base
526
+ return norm_item in allowed_controls_normalized
527
+
528
+ def _add_compliance_item_to_collections(self, compliance_item: Any) -> None:
529
+ """Add compliance item to appropriate collections."""
530
+ self.all_compliance_items.append(compliance_item)
531
+ self.asset_compliance_map[compliance_item.resource_id].append(compliance_item)
532
+
533
+ if compliance_item.compliance_result in self.FAIL_STATUSES:
534
+ self.failed_compliance_items.append(compliance_item)
535
+
536
+ def _log_processing_summary(self, total_policies_processed: int, total_compliance_items_created: int) -> None:
537
+ """Log processing summary information."""
538
+ logger.info(
539
+ f"Processed {total_policies_processed} Wiz policies into {total_compliance_items_created} compliance items"
540
+ )
541
+ logger.debug(
542
+ f"Compliance breakdown: {len(self.all_compliance_items) - len(self.failed_compliance_items)} passing items, "
543
+ f"{len(self.failed_compliance_items)} failing items"
544
+ )
545
+ logger.info(
546
+ f"Control categorization: {len(self.passing_controls)} passing controls, {len(self.failing_controls)} failing controls"
547
+ )
548
+
366
549
  def _map_resource_type_to_asset_type(self, compliance_item: ComplianceItem) -> str:
367
550
  """
368
551
  Map Wiz resource type to RegScale asset type.
@@ -442,7 +625,7 @@ class WizPolicyComplianceIntegration(ComplianceIntegration):
442
625
  seen = set()
443
626
 
444
627
  for subcat in subcategories:
445
- external_id = subcat.get("externalId", "")
628
+ external_id = subcat.get("externalId", "").strip()
446
629
  if external_id and external_id not in seen:
447
630
  seen.add(external_id)
448
631
  unique_control_ids.append(external_id)
@@ -496,7 +679,6 @@ class WizPolicyComplianceIntegration(ComplianceIntegration):
496
679
  :rtype: Iterator[IntegrationFinding]
497
680
  """
498
681
  for control_id, resources in control_to_resources.items():
499
-
500
682
  # Use the first compliance item as the base for this control's finding
501
683
  base_compliance_item = next(iter(resources.values()))
502
684
 
@@ -826,6 +1008,7 @@ class WizPolicyComplianceIntegration(ComplianceIntegration):
826
1008
  last_seen=self.scan_date,
827
1009
  scan_date=self.scan_date,
828
1010
  asset_identifier=self._get_regscale_asset_identifier(compliance_item),
1011
+ issue_asset_identifier_value=self._get_provider_unique_id_for_asset_identifier(compliance_item),
829
1012
  vulnerability_type="Policy Compliance Violation",
830
1013
  rule_id=compliance_item.control_id,
831
1014
  baseline=compliance_item.framework,
@@ -1024,36 +1207,33 @@ class WizPolicyComplianceIntegration(ComplianceIntegration):
1024
1207
  """
1025
1208
  logger.info("Fetching policy assessments from Wiz...")
1026
1209
 
1027
- # Authenticate if not already done
1028
1210
  if not self.access_token:
1029
1211
  self.authenticate_wiz()
1030
1212
 
1031
- headers = self._build_wiz_headers()
1032
- session = self._prepare_wiz_requests_session()
1033
-
1034
- # Try cache first unless forced refresh
1035
1213
  cached_nodes = self._load_assessments_from_cache()
1036
1214
  if cached_nodes is not None:
1037
1215
  logger.info("Using cached Wiz policy assessments")
1038
1216
  return cached_nodes
1039
1217
 
1040
- # Only include variables supported by the query (avoid validation errors)
1041
- page_size = 100
1042
- base_variables = {"first": page_size}
1218
+ # Try async approach first
1219
+ async_results = self._try_async_assessment_fetch()
1220
+ if async_results is not None:
1221
+ self._write_assessments_cache(async_results)
1222
+ return async_results
1043
1223
 
1044
- # Try multiple filter key variants to avoid schema differences across tenants
1045
- filter_variants = [
1046
- {"project": [self.wiz_project_id]},
1047
- {"projectId": [self.wiz_project_id]},
1048
- {"projects": [self.wiz_project_id]},
1049
- {}, # Empty filterBy
1050
- None, # Omit filterBy entirely
1051
- ]
1224
+ # Fall back to requests-based method
1225
+ filtered_nodes = self._fetch_assessments_with_requests()
1226
+ self._write_assessments_cache(filtered_nodes)
1227
+ return filtered_nodes
1052
1228
 
1053
- # First, try async client (unit tests patch this path)
1229
+ def _try_async_assessment_fetch(self) -> Optional[List[Dict[str, Any]]]:
1230
+ """Try to fetch assessments using async client."""
1054
1231
  try:
1055
1232
  from regscale.integrations.commercial.wizv2.utils import compliance_job_progress
1056
1233
 
1234
+ page_size = 100
1235
+ headers = self._build_wiz_headers()
1236
+
1057
1237
  with compliance_job_progress:
1058
1238
  task = compliance_job_progress.add_task(
1059
1239
  f"[#f68d1f]Fetching Wiz policy assessments (async, page size: {page_size})...",
@@ -1074,24 +1254,36 @@ class WizPolicyComplianceIntegration(ComplianceIntegration):
1074
1254
  max_concurrent=1,
1075
1255
  )
1076
1256
  compliance_job_progress.update(task, completed=1, advance=1)
1257
+
1077
1258
  if results and len(results) == 1 and not results[0][2]:
1078
1259
  nodes = results[0][1] or []
1079
- filtered = self._filter_nodes_to_framework(nodes)
1080
- self._write_assessments_cache(filtered)
1081
- return filtered
1260
+ return self._filter_nodes_to_framework(nodes)
1082
1261
  except Exception:
1083
- # Fall back to requests-based method below
1084
1262
  pass
1263
+ return None
1085
1264
 
1086
- filtered_nodes = self._fetch_assessments_with_variants(
1265
+ def _fetch_assessments_with_requests(self) -> List[Dict[str, Any]]:
1266
+ """Fetch assessments using requests-based method with filter variants."""
1267
+ headers = self._build_wiz_headers()
1268
+ session = self._prepare_wiz_requests_session()
1269
+ page_size = 100
1270
+ base_variables = {"first": page_size}
1271
+
1272
+ filter_variants = [
1273
+ {"project": [self.wiz_project_id]},
1274
+ {"projectId": [self.wiz_project_id]},
1275
+ {"projects": [self.wiz_project_id]},
1276
+ {}, # Empty filterBy
1277
+ None, # Omit filterBy entirely
1278
+ ]
1279
+
1280
+ return self._fetch_assessments_with_variants(
1087
1281
  session=session,
1088
1282
  headers=headers,
1089
1283
  base_variables=base_variables,
1090
1284
  page_size=page_size,
1091
1285
  filter_variants=filter_variants,
1092
1286
  )
1093
- self._write_assessments_cache(filtered_nodes)
1094
- return filtered_nodes
1095
1287
 
1096
1288
  def _build_wiz_headers(self) -> Dict[str, str]:
1097
1289
  """
@@ -1127,7 +1319,7 @@ class WizPolicyComplianceIntegration(ComplianceIntegration):
1127
1319
  )
1128
1320
  adapter = HTTPAdapter(max_retries=retry)
1129
1321
  session.mount("https://", adapter)
1130
- session.mount("http://", adapter)
1322
+ session.mount("http://", adapter) # NO SONAR #NOSONAR
1131
1323
  return session
1132
1324
 
1133
1325
  def _fetch_assessments_with_variants(
@@ -1377,19 +1569,70 @@ class WizPolicyComplianceIntegration(ComplianceIntegration):
1377
1569
  :return: Path to the written JSON file
1378
1570
  :rtype: str
1379
1571
  """
1380
- # Create artifacts/wiz directory if it doesn't exist
1572
+ # Setup file paths
1573
+ artifacts_dir, timestamp, file_path, file_path_jsonl = self._setup_output_files()
1574
+
1575
+ # Build compliance summary data
1576
+ catalog_controls = self._get_catalog_controls()
1577
+ control_sets = self._build_control_sets(catalog_controls)
1578
+
1579
+ # Prepare export data structure
1580
+ export_data = self._build_export_data(timestamp, catalog_controls, control_sets)
1581
+
1582
+ # Convert compliance items to serializable format
1583
+ self._add_policy_assessments_to_export(export_data)
1584
+
1585
+ # Write files and cleanup
1586
+ return self._write_output_files(file_path, file_path_jsonl, export_data, artifacts_dir)
1587
+
1588
+ def _setup_output_files(self) -> tuple[str, str, str, str]:
1589
+ """Setup output directory and file paths."""
1381
1590
  artifacts_dir = os.path.join("artifacts", "wiz")
1382
1591
  os.makedirs(artifacts_dir, exist_ok=True)
1383
1592
 
1384
- # Generate timestamped filename
1385
1593
  timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
1386
1594
  filename_json = f"policy_compliance_report_{timestamp}.json"
1387
1595
  filename_jsonl = f"policy_compliance_report_{timestamp}.jsonl"
1388
1596
  file_path = os.path.join(artifacts_dir, filename_json)
1389
1597
  file_path_jsonl = os.path.join(artifacts_dir, filename_jsonl)
1390
1598
 
1391
- # Prepare data for JSON export
1392
- export_data = {
1599
+ return artifacts_dir, timestamp, file_path, file_path_jsonl
1600
+
1601
+ def _get_catalog_controls(self) -> set[str]:
1602
+ """Get catalog controls from the plan/catalog."""
1603
+ catalog_controls = set()
1604
+ try:
1605
+ controls = self._get_controls()
1606
+ for ctl in controls:
1607
+ cid = (ctl.get("controlId") or "").strip()
1608
+ if cid:
1609
+ catalog_controls.add(cid)
1610
+ except Exception:
1611
+ catalog_controls = set()
1612
+ return catalog_controls
1613
+
1614
+ def _build_control_sets(self, catalog_controls: set[str]) -> Dict[str, set]:
1615
+ """Build control sets for summary calculations."""
1616
+ assessed_controls = {item.control_id for item in self.all_compliance_items if item.control_id}
1617
+ passing_control_ids = {key.upper() for key in self.passing_controls.keys()}
1618
+ failing_control_ids = {key.upper() for key in self.failing_controls.keys()}
1619
+
1620
+ return {
1621
+ "assessed": assessed_controls,
1622
+ "passing": passing_control_ids,
1623
+ "failing": failing_control_ids,
1624
+ "catalog": catalog_controls,
1625
+ }
1626
+
1627
+ def _build_export_data(
1628
+ self, timestamp: str, catalog_controls: set[str], control_sets: Dict[str, set]
1629
+ ) -> Dict[str, Any]:
1630
+ """Build the main export data structure."""
1631
+ assessed_controls = control_sets["assessed"]
1632
+ passing_control_ids = control_sets["passing"]
1633
+ failing_control_ids = control_sets["failing"]
1634
+
1635
+ return {
1393
1636
  "metadata": {
1394
1637
  "timestamp": timestamp,
1395
1638
  "wiz_project_id": self.wiz_project_id,
@@ -1398,62 +1641,107 @@ class WizPolicyComplianceIntegration(ComplianceIntegration):
1398
1641
  "total_assessments": len(self.all_compliance_items),
1399
1642
  "pass_count": len(self.all_compliance_items) - len(self.failed_compliance_items),
1400
1643
  "fail_count": len(self.failed_compliance_items),
1401
- "unique_controls": len({item.control_id for item in self.all_compliance_items if item.control_id}),
1644
+ "unique_controls": len(assessed_controls),
1645
+ "catalog_summary": self._build_catalog_summary(
1646
+ catalog_controls, assessed_controls, passing_control_ids, failing_control_ids
1647
+ ),
1402
1648
  },
1403
1649
  "framework_mapping": self.framework_mapping,
1650
+ "control_summary": {
1651
+ "passing_controls": list(passing_control_ids),
1652
+ "failing_controls": list(failing_control_ids),
1653
+ "catalog_controls_no_wiz_data": list(catalog_controls - assessed_controls - passing_control_ids),
1654
+ "wiz_controls_outside_catalog": list(assessed_controls - catalog_controls),
1655
+ },
1404
1656
  "policy_assessments": [],
1405
1657
  }
1406
1658
 
1407
- # Convert compliance items to serializable format
1659
+ def _build_catalog_summary(
1660
+ self,
1661
+ catalog_controls: set[str],
1662
+ assessed_controls: set[str],
1663
+ passing_control_ids: set[str],
1664
+ failing_control_ids: set[str],
1665
+ ) -> Dict[str, int]:
1666
+ """Build catalog summary statistics."""
1667
+ return {
1668
+ "total_catalog_controls": len(catalog_controls),
1669
+ "catalog_controls_with_wiz_data": len(catalog_controls.intersection(assessed_controls)),
1670
+ "catalog_controls_passing": len(catalog_controls.intersection(passing_control_ids)),
1671
+ "catalog_controls_failing": len(catalog_controls.intersection(failing_control_ids)),
1672
+ "catalog_controls_no_data": len(catalog_controls - assessed_controls - passing_control_ids),
1673
+ "wiz_controls_outside_catalog": len(assessed_controls - catalog_controls),
1674
+ }
1675
+
1676
+ def _add_policy_assessments_to_export(self, export_data: Dict[str, Any]) -> None:
1677
+ """Add policy assessments to export data."""
1408
1678
  for compliance_item in self.all_compliance_items:
1409
1679
  if isinstance(compliance_item, WizComplianceItem):
1410
- # Filter policy subcategories to only the selected framework to avoid noise
1411
- filtered_policy = dict(compliance_item.policy) if compliance_item.policy else {}
1412
- if filtered_policy:
1413
- subcats = filtered_policy.get("securitySubCategories", [])
1414
- if subcats:
1415
- target_framework_id = self.framework_id
1416
- filtered_subcats = [
1417
- sc
1418
- for sc in subcats
1419
- if sc.get("category", {}).get("framework", {}).get("id") == target_framework_id
1420
- ]
1421
- if filtered_subcats:
1422
- filtered_policy["securitySubCategories"] = filtered_subcats
1423
- else:
1424
- # If filter removes all, keep original to retain context
1425
- pass
1426
- assessment_data = {
1427
- "id": compliance_item.id,
1428
- "result": compliance_item.result,
1429
- "control_id": compliance_item.control_id,
1430
- "framework_name": compliance_item.framework,
1431
- "framework_id": compliance_item.framework_id,
1432
- "policy": filtered_policy or compliance_item.policy,
1433
- "resource": compliance_item.resource,
1434
- "output": compliance_item.output,
1435
- }
1680
+ assessment_data = self._build_assessment_data(compliance_item)
1436
1681
  export_data["policy_assessments"].append(assessment_data)
1437
1682
 
1438
- # Write to JSON and JSONL files
1683
+ def _build_assessment_data(self, compliance_item: WizComplianceItem) -> Dict[str, Any]:
1684
+ """Build assessment data for a single compliance item."""
1685
+ filtered_policy = self._filter_policy_subcategories(compliance_item)
1686
+
1687
+ return {
1688
+ "id": compliance_item.id,
1689
+ "result": compliance_item.result,
1690
+ "control_id": compliance_item.control_id,
1691
+ "framework_name": compliance_item.framework,
1692
+ "framework_id": compliance_item.framework_id,
1693
+ "policy": filtered_policy or compliance_item.policy,
1694
+ "resource": compliance_item.resource,
1695
+ "output": compliance_item.output,
1696
+ }
1697
+
1698
+ def _filter_policy_subcategories(self, compliance_item: WizComplianceItem) -> Dict[str, Any]:
1699
+ """Filter policy subcategories to only the selected framework."""
1700
+ filtered_policy = dict(compliance_item.policy) if compliance_item.policy else {}
1701
+ if not filtered_policy:
1702
+ return filtered_policy
1703
+
1704
+ subcats = filtered_policy.get("securitySubCategories", [])
1705
+ if not subcats:
1706
+ return filtered_policy
1707
+
1708
+ target_framework_id = self.framework_id
1709
+ filtered_subcats = [
1710
+ sc for sc in subcats if sc.get("category", {}).get("framework", {}).get("id") == target_framework_id
1711
+ ]
1712
+
1713
+ if filtered_subcats:
1714
+ filtered_policy["securitySubCategories"] = filtered_subcats
1715
+
1716
+ return filtered_policy
1717
+
1718
+ def _write_output_files(
1719
+ self, file_path: str, file_path_jsonl: str, export_data: Dict[str, Any], artifacts_dir: str
1720
+ ) -> str:
1721
+ """Write output files and perform cleanup."""
1439
1722
  try:
1440
- with open(file_path, "w", encoding="utf-8") as f:
1441
- json.dump(export_data, f, indent=2, ensure_ascii=False)
1442
-
1443
- logger.info(f"Policy compliance data written to: {file_path}")
1444
- # JSONL: aggregated by control_id (optional)
1445
- if getattr(self, "write_jsonl_output", False):
1446
- control_agg = self._build_control_aggregation()
1447
- with open(file_path_jsonl, "w", encoding="utf-8") as jf:
1448
- for control_id, ctrl in control_agg.items():
1449
- jf.write(json.dumps(ctrl, ensure_ascii=False) + "\n")
1450
- logger.info(f"Policy compliance JSONL written to: {file_path_jsonl}")
1723
+ self._write_json_file(file_path, export_data)
1724
+ self._write_jsonl_file_if_enabled(file_path_jsonl)
1451
1725
  self._cleanup_artifacts(artifacts_dir, keep=CACHE_CLEANUP_KEEP_COUNT)
1452
1726
  return file_path
1453
-
1454
1727
  except Exception as e:
1455
1728
  error_and_exit(f"Failed to write policy data to JSON: {str(e)}")
1456
1729
 
1730
+ def _write_json_file(self, file_path: str, export_data: Dict[str, Any]) -> None:
1731
+ """Write JSON export data to file."""
1732
+ with open(file_path, "w", encoding="utf-8") as f:
1733
+ json.dump(export_data, f, indent=2, ensure_ascii=False)
1734
+ logger.info(f"Policy compliance data written to: {file_path}")
1735
+
1736
+ def _write_jsonl_file_if_enabled(self, file_path_jsonl: str) -> None:
1737
+ """Write JSONL file if output is enabled."""
1738
+ if getattr(self, "write_jsonl_output", False):
1739
+ control_agg = self._build_control_aggregation()
1740
+ with open(file_path_jsonl, "w", encoding="utf-8") as jf:
1741
+ for control_id, ctrl in control_agg.items():
1742
+ jf.write(json.dumps(ctrl, ensure_ascii=False) + "\n")
1743
+ logger.info(f"Policy compliance JSONL written to: {file_path_jsonl}")
1744
+
1457
1745
  def _build_control_aggregation(self) -> Dict[str, Dict[str, Any]]:
1458
1746
  """
1459
1747
  Build an aggregated view per control_id for JSONL export.
@@ -1975,7 +2263,7 @@ class WizPolicyComplianceIntegration(ComplianceIntegration):
1975
2263
  # Update basic fields (similar to parent class logic)
1976
2264
  existing_issue.title = title
1977
2265
  existing_issue.description = finding.description
1978
- existing_issue.severity = finding.severity
2266
+ existing_issue.severityLevel = finding.severity
1979
2267
  existing_issue.status = finding.status
1980
2268
  existing_issue.dateLastUpdated = self.scan_date
1981
2269
 
@@ -2013,24 +2301,44 @@ class WizPolicyComplianceIntegration(ComplianceIntegration):
2013
2301
 
2014
2302
  CRITICAL FIX: Check if the finding has assessment parent overrides and apply them.
2015
2303
  """
2016
- # Get consolidated asset identifier
2017
2304
  asset_identifier = self.get_consolidated_asset_identifier(finding, existing_issue)
2018
-
2019
- # Prepare issue data
2020
- issue_title = self.get_issue_title(finding) or title
2021
- description = finding.description or ""
2022
- remediation_description = finding.recommendation_for_mitigation or finding.remediation or ""
2023
- is_poam = self.is_poam(finding)
2305
+ issue_data = self._prepare_issue_data(finding, title)
2024
2306
 
2025
2307
  if existing_issue:
2026
2308
  logger.debug(
2027
2309
  "Updating existing issue %s with assetIdentifier %s", existing_issue.id, finding.asset_identifier
2028
2310
  )
2029
2311
 
2030
- # If we have an existing issue, update its fields instead of creating a new one
2031
2312
  issue = existing_issue or regscale_models.Issue()
2313
+ parent_info = self._get_parent_info(finding)
2032
2314
 
2033
- # CRITICAL FIX: Check for parent overrides from the finding
2315
+ self._set_basic_issue_properties(issue, finding, issue_status, issue_data, parent_info, asset_identifier)
2316
+ self._set_compliance_properties(issue, finding)
2317
+ self._set_additional_properties(issue, finding, issue_data)
2318
+
2319
+ if finding.cve:
2320
+ issue = self.lookup_kev_and_update_issue(cve=finding.cve, issue=issue, cisa_kevs=self._kev_data)
2321
+
2322
+ issue = self._save_or_create_issue_record(issue, finding, existing_issue, issue_data["is_poam"])
2323
+
2324
+ if issue and issue.id:
2325
+ self._handle_post_creation_tasks(issue, finding, existing_issue)
2326
+ else:
2327
+ logger.debug("Skipping milestone creation - issue has no ID")
2328
+
2329
+ return issue
2330
+
2331
+ def _prepare_issue_data(self, finding: IntegrationFinding, title: str) -> Dict[str, Any]:
2332
+ """Prepare basic issue data from finding."""
2333
+ return {
2334
+ "issue_title": self.get_issue_title(finding) or title,
2335
+ "description": finding.description or "",
2336
+ "remediation_description": finding.recommendation_for_mitigation or finding.remediation or "",
2337
+ "is_poam": self.is_poam(finding),
2338
+ }
2339
+
2340
+ def _get_parent_info(self, finding: IntegrationFinding) -> Dict[str, Any]:
2341
+ """Get parent information for the issue."""
2034
2342
  if hasattr(finding, "_override_parent_id") and hasattr(finding, "_override_parent_module"):
2035
2343
  parent_id = finding._override_parent_id
2036
2344
  parent_module = finding._override_parent_module
@@ -2039,11 +2347,22 @@ class WizPolicyComplianceIntegration(ComplianceIntegration):
2039
2347
  parent_id = self.plan_id
2040
2348
  parent_module = self.parent_module
2041
2349
 
2042
- # Update all fields (copying from ScannerIntegration but with override parent)
2043
- issue.parentId = parent_id
2044
- issue.parentModule = parent_module
2350
+ return {"parent_id": parent_id, "parent_module": parent_module}
2351
+
2352
+ def _set_basic_issue_properties(
2353
+ self,
2354
+ issue: regscale_models.Issue,
2355
+ finding: IntegrationFinding,
2356
+ issue_status,
2357
+ issue_data: Dict[str, Any],
2358
+ parent_info: Dict[str, Any],
2359
+ asset_identifier: str,
2360
+ ) -> None:
2361
+ """Set basic properties on the issue."""
2362
+ issue.parentId = parent_info["parent_id"]
2363
+ issue.parentModule = parent_info["parent_module"]
2045
2364
  issue.vulnerabilityId = finding.vulnerability_id
2046
- issue.title = issue_title
2365
+ issue.title = issue_data["issue_title"]
2047
2366
  issue.dateCreated = finding.date_created
2048
2367
  issue.status = issue_status
2049
2368
  issue.dateCompleted = (
@@ -2056,51 +2375,40 @@ class WizPolicyComplianceIntegration(ComplianceIntegration):
2056
2375
  issue.securityPlanId = self.plan_id if not self.is_component else None
2057
2376
  issue.identification = finding.identification
2058
2377
  issue.dateFirstDetected = finding.first_seen
2059
-
2060
- # Ensure a due date is always set using configured policy defaults (e.g., FedRAMP)
2061
- if not finding.due_date:
2062
- try:
2063
- base_created = finding.date_created or issue.dateCreated
2064
- finding.due_date = issue_due_date(
2065
- severity=finding.severity,
2066
- created_date=base_created,
2067
- title=self.title,
2068
- )
2069
- except Exception:
2070
- # Final fallback to a Low severity default if anything goes wrong
2071
- base_created = finding.date_created or issue.dateCreated
2072
- finding.due_date = issue_due_date(
2073
- severity=regscale_models.IssueSeverity.Low,
2074
- created_date=base_created,
2075
- title=self.title,
2076
- )
2077
- issue.dueDate = finding.due_date
2078
- issue.description = description
2079
- issue.sourceReport = finding.source_report or self.title
2080
- issue.recommendedActions = finding.recommendation_for_mitigation
2081
2378
  issue.assetIdentifier = asset_identifier
2082
- issue.securityChecks = finding.security_check or finding.external_id
2083
- issue.remediationDescription = remediation_description
2084
- issue.integrationFindingId = self.get_finding_identifier(finding)
2085
- issue.poamComments = finding.poam_comments
2086
- issue.cve = finding.cve
2087
2379
 
2088
- # CRITICAL: Set assessmentId (this is the key fix)
2380
+ # Ensure due date is set
2381
+ self._set_issue_due_date(issue, finding)
2382
+
2383
+ def _set_compliance_properties(self, issue: regscale_models.Issue, finding: IntegrationFinding) -> None:
2384
+ """Set compliance-specific properties."""
2089
2385
  issue.assessmentId = finding.assessment_id
2090
- logger.debug(f"SETTING assessmentId = {finding.assessment_id} with parent = {parent_module} #{parent_id}")
2386
+ logger.debug(f"SETTING assessmentId = {finding.assessment_id}")
2091
2387
 
2092
2388
  control_id = self.get_control_implementation_id_for_cci(finding.cci_ref) if finding.cci_ref else None
2093
2389
  issue.controlId = control_id
2094
2390
 
2095
- # Add the control implementation ids and the cci ref if it exists
2096
2391
  cci_control_ids = [control_id] if control_id is not None else []
2097
2392
  if finding.affected_controls:
2098
2393
  issue.affectedControls = finding.affected_controls
2099
2394
  elif finding.control_labels:
2100
2395
  issue.affectedControls = ", ".join(sorted({cl for cl in finding.control_labels if cl}))
2101
2396
 
2102
- issue.controlImplementationIds = list(set(finding._control_implementation_ids + cci_control_ids)) # noqa
2103
- issue.isPoam = is_poam
2397
+ issue.controlImplementationIds = list(set(finding._control_implementation_ids + cci_control_ids))
2398
+
2399
+ def _set_additional_properties(
2400
+ self, issue: regscale_models.Issue, finding: IntegrationFinding, issue_data: Dict[str, Any]
2401
+ ) -> None:
2402
+ """Set additional issue properties."""
2403
+ issue.description = issue_data["description"]
2404
+ issue.sourceReport = finding.source_report or self.title
2405
+ issue.recommendedActions = finding.recommendation_for_mitigation
2406
+ issue.securityChecks = finding.security_check or finding.external_id
2407
+ issue.remediationDescription = issue_data["remediation_description"]
2408
+ issue.integrationFindingId = self.get_finding_identifier(finding)
2409
+ issue.poamComments = finding.poam_comments
2410
+ issue.cve = finding.cve
2411
+ issue.isPoam = issue_data["is_poam"]
2104
2412
  issue.basisForAdjustment = (
2105
2413
  finding.basis_for_adjustment if finding.basis_for_adjustment else f"{self.title} import"
2106
2414
  )
@@ -2116,9 +2424,10 @@ class WizPolicyComplianceIntegration(ComplianceIntegration):
2116
2424
  issue.dateLastUpdated = get_current_datetime()
2117
2425
  issue.affectedControls = finding.affected_controls
2118
2426
 
2119
- if finding.cve:
2120
- issue = self.lookup_kev_and_update_issue(cve=finding.cve, issue=issue, cisa_kevs=self._kev_data)
2121
-
2427
+ def _save_or_create_issue_record(
2428
+ self, issue: regscale_models.Issue, finding: IntegrationFinding, existing_issue, is_poam: bool
2429
+ ) -> regscale_models.Issue:
2430
+ """Save or create the issue record."""
2122
2431
  if existing_issue:
2123
2432
  logger.debug(f"Saving existing issue {issue.id} with assessmentId={issue.assessmentId}")
2124
2433
  issue.save(bulk=True)
@@ -2131,20 +2440,18 @@ class WizPolicyComplianceIntegration(ComplianceIntegration):
2131
2440
  logger.debug(f"Issue created with ID: {issue.id}")
2132
2441
  self.extra_data_to_properties(finding, issue.id)
2133
2442
  else:
2134
- logger.error(f" Issue creation failed - no ID returned for finding {finding.external_id}")
2443
+ logger.error(f"Issue creation failed - no ID returned for finding {finding.external_id}")
2135
2444
  return None
2445
+ return issue
2136
2446
 
2137
- # Only create milestones if issue has an ID
2138
- if issue and issue.id:
2139
- # Check if existing issue needs initial milestone creation
2140
- if existing_issue and ScannerVariables.useMilestones:
2141
- self._ensure_issue_has_milestone(issue, finding)
2142
-
2143
- self._handle_property_and_milestone_creation(issue, finding, existing_issue)
2144
- else:
2145
- logger.debug("Skipping milestone creation - issue has no ID")
2447
+ def _handle_post_creation_tasks(
2448
+ self, issue: regscale_models.Issue, finding: IntegrationFinding, existing_issue
2449
+ ) -> None:
2450
+ """Handle tasks after issue creation/update."""
2451
+ if existing_issue and ScannerVariables.useMilestones:
2452
+ self._ensure_issue_has_milestone(issue, finding)
2146
2453
 
2147
- return issue
2454
+ self._handle_property_and_milestone_creation(issue, finding, existing_issue)
2148
2455
 
2149
2456
  def _populate_compliance_fields_on_finding(self, finding: IntegrationFinding) -> None:
2150
2457
  """
@@ -2164,7 +2471,6 @@ class WizPolicyComplianceIntegration(ComplianceIntegration):
2164
2471
  if hasattr(finding, "rule_id") and finding.rule_id:
2165
2472
  control_id = self._normalize_control_id_string(finding.rule_id)
2166
2473
  if control_id:
2167
-
2168
2474
  # Get control implementation ID
2169
2475
  impl_id = self._issue_field_setter._get_or_find_implementation_id(control_id)
2170
2476
  if impl_id:
@@ -2628,7 +2934,6 @@ class WizPolicyComplianceIntegration(ComplianceIntegration):
2628
2934
  """
2629
2935
  logger.info("Starting control assessment processing for Wiz compliance integration")
2630
2936
 
2631
- # Ensure existing records cache is loaded
2632
2937
  self._load_existing_records_cache()
2633
2938
 
2634
2939
  implementations = self._get_control_implementations()
@@ -2636,61 +2941,96 @@ class WizPolicyComplianceIntegration(ComplianceIntegration):
2636
2941
  logger.warning("No control implementations found for assessment processing")
2637
2942
  return
2638
2943
 
2639
- # Get all potential control IDs from compliance data
2944
+ validated_controls = self._validate_controls_with_assets()
2945
+ if not validated_controls["controls_with_assets"]:
2946
+ logger.warning("No controls have assets in RegScale boundary - no control assessments will be created")
2947
+ logger.info("SUMMARY: 0 control assessments created (no assets exist in RegScale)")
2948
+ return
2949
+
2950
+ assessments_created = self._create_assessments_for_validated_controls(
2951
+ validated_controls["controls_with_assets"], implementations
2952
+ )
2953
+ self._log_assessment_summary(assessments_created, validated_controls)
2954
+
2955
+ def _validate_controls_with_assets(self) -> Dict[str, Any]:
2956
+ """Validate controls and identify those with existing assets."""
2640
2957
  all_potential_controls = set(self.passing_controls.keys()) | set(self.failing_controls.keys())
2641
2958
  logger.debug(
2642
2959
  f"Found {len(all_potential_controls)} potential controls from compliance data: {sorted(all_potential_controls)}"
2643
2960
  )
2644
2961
 
2645
- # Validate each control has actual assets in our boundary before processing
2646
2962
  validated_controls_with_assets = {}
2647
2963
  validated_passing_controls = {}
2648
2964
  validated_failing_controls = {}
2649
2965
 
2650
2966
  for control_id in all_potential_controls:
2651
- # Get all compliance items for this control
2967
+ validation_result = self._validate_single_control(control_id)
2968
+
2969
+ if validation_result["should_process"]:
2970
+ validated_controls_with_assets[control_id] = validation_result["asset_identifiers"]
2971
+
2972
+ if control_id in self.failing_controls:
2973
+ validated_failing_controls[control_id] = self.failing_controls[control_id]
2974
+ elif control_id in self.passing_controls:
2975
+ validated_passing_controls[control_id] = self.passing_controls[control_id]
2976
+
2977
+ return {
2978
+ "controls_with_assets": validated_controls_with_assets,
2979
+ "passing_controls": validated_passing_controls,
2980
+ "failing_controls": validated_failing_controls,
2981
+ }
2982
+
2983
+ def _validate_single_control(self, control_id: str) -> Dict[str, Any]:
2984
+ """Validate a single control for asset existence."""
2985
+ is_passing_control = control_id in self.passing_controls
2986
+
2987
+ if is_passing_control:
2988
+ control_items = self._get_control_compliance_items(control_id)
2989
+ else:
2652
2990
  control_items = self._get_validated_control_compliance_items(control_id)
2653
2991
 
2654
- if not control_items:
2655
- continue
2992
+ if not control_items and is_passing_control:
2993
+ logger.debug(f"Control {control_id} is passing - will process for compliance documentation")
2994
+ return {"should_process": True, "asset_identifiers": []}
2656
2995
 
2657
- # Check if we have any assets for the compliance items
2658
- asset_identifiers = set()
2659
- assets_found = 0
2660
-
2661
- for item in control_items:
2662
- if hasattr(item, "resource_name") and item.resource_name:
2663
- resource_id = getattr(item, "resource_id", "")
2664
- # Verify the asset actually exists in RegScale
2665
- if self._asset_exists_in_regscale(resource_id):
2666
- asset_identifiers.add(item.resource_name)
2667
- assets_found += 1
2668
- else:
2669
- logger.debug(
2670
- f"Control {control_id}: Asset {resource_id} ({item.resource_name}) not found in RegScale"
2671
- )
2672
- logger.debug(f"Found {assets_found} valid assets for control {control_id}")
2673
- if not asset_identifiers:
2674
- continue
2996
+ if not control_items:
2997
+ return {"should_process": False, "asset_identifiers": []}
2675
2998
 
2676
- # This control has valid assets, include it in processing
2677
- validated_controls_with_assets[control_id] = list(asset_identifiers)
2999
+ asset_identifiers = self._collect_asset_identifiers(control_items, control_id, is_passing_control)
2678
3000
 
2679
- # Preserve the pass/fail status for validated controls
2680
- if control_id in self.failing_controls:
2681
- validated_failing_controls[control_id] = self.failing_controls[control_id]
2682
- elif control_id in self.passing_controls:
2683
- validated_passing_controls[control_id] = self.passing_controls[control_id]
3001
+ # For passing controls, allow through even without assets
3002
+ # For failing controls, require at least one asset
3003
+ should_process = bool(asset_identifiers) or is_passing_control
2684
3004
 
2685
- if not validated_controls_with_assets:
2686
- logger.warning(" No controls have assets in RegScale boundary - no control assessments will be created")
2687
- logger.info("SUMMARY: 0 control assessments created (no assets exist in RegScale)")
2688
- return
3005
+ return {"should_process": should_process, "asset_identifiers": list(asset_identifiers)}
3006
+
3007
+ def _collect_asset_identifiers(self, control_items: List[Any], control_id: str, is_passing_control: bool) -> set:
3008
+ """Collect asset identifiers for control items."""
3009
+ asset_identifiers = set()
3010
+ assets_found = 0
3011
+
3012
+ for item in control_items:
3013
+ if hasattr(item, "resource_name") and item.resource_name:
3014
+ resource_id = getattr(item, "resource_id", "")
3015
+ # Verify the asset actually exists in RegScale (if not a passing control)
3016
+ if is_passing_control or self._asset_exists_in_regscale(resource_id):
3017
+ asset_identifiers.add(item.resource_name)
3018
+ assets_found += 1
3019
+ else:
3020
+ logger.debug(
3021
+ f"Control {control_id}: Asset {resource_id} ({item.resource_name}) not found in RegScale"
3022
+ )
2689
3023
 
3024
+ logger.debug(f"Found {assets_found} valid assets for control {control_id}")
3025
+ return asset_identifiers
3026
+
3027
+ def _create_assessments_for_validated_controls(
3028
+ self, validated_controls_with_assets: Dict[str, List[str]], implementations: List[Any]
3029
+ ) -> int:
3030
+ """Create assessments for validated controls."""
2690
3031
  assessments_created = 0
2691
3032
  processed_impl_today: set[int] = set()
2692
3033
 
2693
- # Only process validated controls that have assets in our boundary
2694
3034
  for control_id in validated_controls_with_assets.keys():
2695
3035
  created = self._process_single_control_assessment(
2696
3036
  control_id=control_id,
@@ -2699,8 +3039,13 @@ class WizPolicyComplianceIntegration(ComplianceIntegration):
2699
3039
  )
2700
3040
  assessments_created += created
2701
3041
 
2702
- # Calculate stats only for validated controls
2703
- validated_control_ids = set(validated_controls_with_assets.keys())
3042
+ return assessments_created
3043
+
3044
+ def _log_assessment_summary(self, assessments_created: int, validated_controls: Dict[str, Any]) -> None:
3045
+ """Log summary of assessment creation."""
3046
+ validated_control_ids = set(validated_controls["controls_with_assets"].keys())
3047
+ validated_failing_controls = validated_controls["failing_controls"]
3048
+
2704
3049
  passing_assessments = len([cid for cid in validated_control_ids if cid not in validated_failing_controls])
2705
3050
  failing_assessments = len([cid for cid in validated_control_ids if cid in validated_failing_controls])
2706
3051
 
@@ -2710,11 +3055,11 @@ class WizPolicyComplianceIntegration(ComplianceIntegration):
2710
3055
  )
2711
3056
  else:
2712
3057
  logger.warning(
2713
- f"No control assessments were actually created (0 assessments) despite finding {len(validated_controls_with_assets)} controls with assets"
3058
+ f"No control assessments were actually created (0 assessments) despite finding {len(validated_controls['controls_with_assets'])} controls with assets"
2714
3059
  )
2715
3060
 
2716
3061
  logger.info(
2717
- f"CONTROL ASSESSMENT SUMMARY: {assessments_created} assessments created for {len(validated_controls_with_assets)} validated controls"
3062
+ f"CONTROL ASSESSMENT SUMMARY: {assessments_created} assessments created for {len(validated_controls['controls_with_assets'])} validated controls"
2718
3063
  )
2719
3064
 
2720
3065
  def _sync_assessment_cache_from_base_class(self) -> None:
@@ -2989,6 +3334,29 @@ class WizPolicyComplianceIntegration(ComplianceIntegration):
2989
3334
  # Fallback (should not normally happen since resource_id is required)
2990
3335
  return resource_name or "Unknown Resource"
2991
3336
 
3337
+ def _get_provider_unique_id_for_asset_identifier(self, compliance_item: "WizComplianceItem") -> str:
3338
+ """
3339
+ Get the provider unique ID for meaningful asset identification in eMASS exports.
3340
+
3341
+ This provides cloud provider-specific identifiers like ARNs, Azure resource IDs, etc.
3342
+ instead of internal Wiz IDs for better readability in POAMs and eMASS exports.
3343
+
3344
+ :param WizComplianceItem compliance_item: Compliance item with resource information
3345
+ :return: Provider unique ID or fallback to resource name/ID
3346
+ :rtype: str
3347
+ """
3348
+ provider_unique_id = getattr(compliance_item, "provider_unique_id", "")
3349
+ resource_name = getattr(compliance_item, "resource_name", "")
3350
+ resource_id = getattr(compliance_item, "resource_id", "")
3351
+
3352
+ # Priority: providerUniqueId -> resource_name -> resource_id
3353
+ if provider_unique_id:
3354
+ return provider_unique_id
3355
+ elif resource_name:
3356
+ return resource_name
3357
+ else:
3358
+ return resource_id
3359
+
2992
3360
  def _create_consolidated_asset_identifier(self, asset_mappings: Dict[str, Dict[str, str]]) -> str:
2993
3361
  """
2994
3362
  Create a consolidated asset identifier with only asset names (one per line).
@@ -3029,6 +3397,56 @@ class WizPolicyComplianceIntegration(ComplianceIntegration):
3029
3397
  )
3030
3398
  return consolidated_identifier
3031
3399
 
3400
+ def _categorize_controls_by_aggregation(self) -> None:
3401
+ """
3402
+ Override the base method to handle multiple control IDs per compliance item.
3403
+ Wiz policies can map to multiple NIST controls (e.g., AC-2(4), AC-6(9)) in securitySubCategories.
3404
+ This method ensures all controls from a policy assessment are properly categorized.
3405
+ """
3406
+ from collections import defaultdict, Counter
3407
+
3408
+ # Group all compliance items by control ID - handle multiple controls per item
3409
+ control_items = defaultdict(list)
3410
+
3411
+ for item in self.all_compliance_items:
3412
+ # Get all control IDs that this compliance item maps to
3413
+ all_control_ids = self._get_all_control_ids_for_compliance_item(item)
3414
+
3415
+ # Add this item to each control it maps to
3416
+ for control_id in all_control_ids:
3417
+ control_key = control_id.lower()
3418
+ control_items[control_key].append(item)
3419
+
3420
+ # Analyze each control's results
3421
+ for control_key, items in control_items.items():
3422
+ results = [item.compliance_result for item in items]
3423
+ result_counts = Counter(results)
3424
+
3425
+ fail_count = sum(result_counts.get(status, 0) for status in self.FAIL_STATUSES)
3426
+ pass_count = sum(result_counts.get(status, 0) for status in self.PASS_STATUSES)
3427
+
3428
+ # Determine control status - strict compliance: ALL assessments must pass
3429
+ if fail_count == 0 and pass_count > 0:
3430
+ # All results are passing - control passes
3431
+ self.passing_controls[control_key] = items[0] # Use first item as representative
3432
+ logger.debug(f"Control {control_key} marked as PASSING: {pass_count}P/{fail_count}F")
3433
+
3434
+ elif fail_count > 0:
3435
+ # Any failures present - control fails (strict compliance)
3436
+ self.failing_controls[control_key] = next(
3437
+ item for item in items if item.compliance_result in self.FAIL_STATUSES
3438
+ )
3439
+ logger.debug(
3440
+ f"Control {control_key} marked as FAILING: {pass_count}P/{fail_count}F (any failure = control fails)"
3441
+ )
3442
+ else:
3443
+ # No pass or fail results - skip this control
3444
+ logger.debug(f"Control {control_key} skipped: no valid pass/fail results")
3445
+
3446
+ logger.info(
3447
+ f"Control categorization complete: {len(self.passing_controls)} passing, {len(self.failing_controls)} failing"
3448
+ )
3449
+
3032
3450
 
3033
3451
  def resolve_framework_id(framework_input: str) -> str:
3034
3452
  """